"""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