| """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, |
| ] |
|
|
| |
| 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 |
| |
| 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: |
| |
| 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 |
| |
|
|