docker000 / game /sim /day_cycle.py
seachen's picture
Deploy Quiet Town Corner Shop Docker demo (Gradio + Python sim) (part 5)
3831e97 verified
Raw
History Blame Contribute Delete
5.05 kB
"""Day phases and Pace (rules R1/R2) — wall-clock stretch model."""
from __future__ import annotations
from enum import Enum
from typing import Optional
class Pace(str, Enum):
RELAXED = "Relaxed"
NORMAL = "Normal"
BRISK = "Brisk"
class Phase(str, Enum):
PREP = "PREP"
MORNING = "MORNING"
AFTERNOON = "AFTERNOON"
DUSK = "DUSK"
CLOSING = "CLOSING"
NIGHT = "NIGHT"
PHASE_ORDER = [
Phase.PREP,
Phase.MORNING,
Phase.AFTERNOON,
Phase.DUSK,
Phase.CLOSING,
Phase.NIGHT,
]
# defaults mirror content/tuning/mvp_rules.json
DEFAULT_PACE = {
Pace.RELAXED: 1.5,
Pace.NORMAL: 1.0,
Pace.BRISK: 0.75,
}
DEFAULT_BASE_SEC = {
Phase.PREP: None,
Phase.MORNING: 90.0,
Phase.AFTERNOON: 120.0,
Phase.DUSK: 90.0,
Phase.CLOSING: 45.0,
Phase.NIGHT: None,
}
class DayCycle:
"""Discrete day cycle. phase_elapsed accumulates wall delta; limit *= pace."""
def __init__(
self,
pace: Pace = Pace.NORMAL,
pace_multipliers: Optional[dict] = None,
base_sec: Optional[dict] = None,
day_index: int = 0,
phase: Phase = Phase.PREP,
):
self.pace = pace
self.pace_multipliers = {
Pace(k) if not isinstance(k, Pace) else k: float(v)
for k, v in (pace_multipliers or DEFAULT_PACE).items()
}
self.base_sec = {}
src = base_sec or DEFAULT_BASE_SEC
for ph, val in src.items():
key = Phase(ph) if not isinstance(ph, Phase) else ph
self.base_sec[key] = None if val is None else float(val)
self.day_index = day_index
self.phase = phase if isinstance(phase, Phase) else Phase(phase)
self.phase_elapsed = 0.0
self.paused = False
self._listeners = []
@property
def pace_multiplier(self) -> float:
return float(self.pace_multipliers[self.pace])
def phase_limit(self) -> Optional[float]:
base = self.base_sec.get(self.phase)
if base is None:
return None
return base * self.pace_multiplier
def set_paused(self, value: bool) -> None:
self.paused = bool(value)
def set_pace(self, pace: Pace) -> None:
self.pace = Pace(pace)
def tick(self, delta: float) -> bool:
"""Advance wall time by delta seconds. Returns True if phase advanced."""
if self.paused or delta <= 0:
return False
limit = self.phase_limit()
if limit is None:
return False # PREP / NIGHT: button only
# CRITICAL: do NOT multiply delta by pace
self.phase_elapsed += float(delta)
if self.phase_elapsed >= limit:
self.advance_phase()
return True
return False
def confirm_open(self) -> None:
if self.phase != Phase.PREP:
raise ValueError("confirm_open only in PREP")
self._go(Phase.MORNING)
def confirm_sleep(self) -> None:
if self.phase != Phase.NIGHT:
raise ValueError("confirm_sleep only in NIGHT")
self.day_index += 1
self._go(Phase.PREP)
def skip_phase(self) -> None:
limit = self.phase_limit()
if limit is None:
if self.phase == Phase.PREP:
self.confirm_open()
elif self.phase == Phase.NIGHT:
self.confirm_sleep()
return
self.phase_elapsed = limit
self.advance_phase()
def advance_phase(self) -> None:
idx = PHASE_ORDER.index(self.phase)
if idx >= len(PHASE_ORDER) - 1:
# stay NIGHT until sleep
self.phase = Phase.NIGHT
self.phase_elapsed = 0.0
return
self._go(PHASE_ORDER[idx + 1])
def _go(self, phase: Phase) -> None:
self.phase = phase
self.phase_elapsed = 0.0
for cb in self._listeners:
cb(self.phase)
def on_phase_enter(self, cb) -> None:
self._listeners.append(cb)
def to_dict(self) -> dict:
return {
"day_index": self.day_index,
"phase": self.phase.value,
"phase_elapsed": self.phase_elapsed,
"pace": self.pace.value,
"paused": self.paused,
}
@classmethod
def from_dict(cls, data: dict, **kwargs) -> "DayCycle":
d = cls(
pace=Pace(data.get("pace", "Normal")),
day_index=int(data.get("day_index", 0)),
phase=Phase(data.get("phase", "PREP")),
**kwargs,
)
d.phase_elapsed = float(data.get("phase_elapsed", 0.0))
d.paused = bool(data.get("paused", False))
return d
def auto_phase_total(pace: Pace = Pace.NORMAL, base_sec=None, pace_multipliers=None) -> float:
d = DayCycle(pace=pace, base_sec=base_sec, pace_multipliers=pace_multipliers)
total = 0.0
for ph in (Phase.MORNING, Phase.AFTERNOON, Phase.DUSK, Phase.CLOSING):
b = d.base_sec[ph]
total += b * d.pace_multipliers[pace]
return total
# reseal-product R46