"""Full shop day session orchestrator — ties DayCycle, Economy, Director, Atmosphere.""" from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Dict, List, Optional from game.io.content_db import ContentDB from game.io.new_game import build_new_game_state from game.sim.atmosphere import AtmosphereModel from game.sim.day_cycle import DayCycle, Pace, Phase from game.sim.director import CustomerDirector from game.sim.economy import Economy from game.sim.relationship import RelationshipStore from game.sim.shop_space import ShopSpace from game.sim.settings import Settings from game.sim.telemetry import Telemetry from game.sim.night_summary import build_night_summary from game.sim.diary import DiaryPool from pathlib import Path @dataclass class DayReport: day_index: int enters: int sales: int revenue: int wallet_end: int regular_visits: Dict[str, int] mood_line: str phase_log: List[str] = field(default_factory=list) class GameSession: """Runnable session used by CLI and playtests.""" def __init__(self, db: Optional[ContentDB] = None, rng_seed: int = 7, pace: Pace = Pace.NORMAL): self.db = db or ContentDB.from_project_root() self.state = build_new_game_state(self.db) pace_raw = self.db.rules.get("pace_multiplier") or { "Relaxed": 1.5, "Normal": 1.0, "Brisk": 0.75 } base_raw = self.db.rules.get("phase_duration_base_sec") or {} base_sec = None if base_raw: base_sec = { Phase(k): (None if v is None else float(v)) for k, v in base_raw.items() } self.day = DayCycle( pace=pace, day_index=int(self.state["meta"]["day_index"]), phase=Phase(self.state["meta"].get("phase", "PREP")), pace_multipliers={Pace(k): float(v) for k, v in pace_raw.items()}, base_sec=base_sec, ) self.economy = Economy.from_state(self.state, self.db) self.atmosphere = AtmosphereModel() self.atmosphere.light = self.state["shop"].get("light", "warm") self.atmosphere.bgm = self.state["shop"].get("bgm", "quiet") self.relationships = RelationshipStore.from_state(self.state, rules=self.db.rules) self.director = CustomerDirector( self.db, self.economy, self.atmosphere, self.relationships, rng_seed=rng_seed ) self.space = ShopSpace() try: self.space.load_layout(self.state["shop"].get("layout") or [], self.db.furniture) except ValueError: # layout cells may clip; leave empty space model pass self.sales_count = 0 self.revenue = 0 self.phase_log: List[str] = [] self._wallet_start_day = self.economy.wallet self._hook_director_pay() self.settings = Settings(pace=pace) self.telemetry = Telemetry(enabled=False) diary_path = Path(__file__).resolve().parents[2] / "content/diary/pool.json" self.diary = DiaryPool.load(diary_path) if diary_path.exists() else DiaryPool([]) def _hook_director_pay(self) -> None: original = self.director._pay def wrapped(agent, day, state): before = self.economy.wallet original(agent, day, state) delta = self.economy.wallet - before if delta > 0: self.sales_count += 1 self.revenue += delta self.director._pay = wrapped # type: ignore def open_shop(self) -> None: if self.day.phase != Phase.PREP: raise RuntimeError("open_shop only in PREP") self._wallet_start_day = self.economy.wallet self.sales_count = 0 self.revenue = 0 self.day.confirm_open() self.director.on_phase(self.day) self.phase_log.append(f"open day={self.day.day_index}") self._recompute_atm() def sleep(self) -> DayReport: if self.day.phase != Phase.NIGHT: # force to night for convenience in tests while self.day.phase != Phase.NIGHT: self.day.skip_phase() self.director.on_phase(self.day) report = self._make_report() self.day.confirm_sleep() self.state["meta"]["day_index"] = self.day.day_index self.state["meta"]["phase"] = self.day.phase.value self.economy.sync_to_state(self.state) self.relationships.sync_to_state(self.state) self.director.on_phase(self.day) self.phase_log.append(f"sleep -> day {self.day.day_index}") return report def tick(self, delta: float = 0.25) -> None: prev = self.day.phase self.day.tick(delta) if self.day.phase != prev: self.phase_log.append(f"phase->{self.day.phase.value}") self.director.on_phase(self.day) self.director.tick(delta, self.day, self.state) self._recompute_atm() def run_until_night(self, step: float = 0.25, max_steps: int = 200000) -> DayReport: if self.day.phase == Phase.PREP: self.open_shop() n = 0 while self.day.phase != Phase.NIGHT and n < max_steps: self.tick(step) n += 1 if self.day.phase != Phase.NIGHT: raise RuntimeError("failed to reach NIGHT") return self._make_report() def restock(self, product_id: str, qty: int) -> None: self.economy.restock(product_id, qty, phase=self.day.phase.value) self.economy.sync_to_state(self.state) def stock_display(self, product_id: str, slot_id: str, qty: int) -> None: self.economy.stock_to_display(product_id, slot_id, qty, phase=self.day.phase.value) self.economy.sync_to_state(self.state) LIGHT_MODES = ("warm", "cool", "off") # R165 contract def set_light(self, mode: str) -> None: """Set shop light to warm / cool / off and recompute atmosphere.""" from game.sim.atmosphere import LIGHT_MOD, light_mode_modifiers if mode not in LIGHT_MOD: raise ValueError(f"light must be one of {sorted(LIGHT_MOD)}; got {mode!r}") # touch light_mode_modifiers so warm/cool/off contract is exercised _ = light_mode_modifiers(mode) self.atmosphere.light = mode self.state["shop"]["light"] = mode self._recompute_atm() def cycle_light(self) -> str: """Cycle warm → cool → off → warm (R165 light modes).""" order = list(self.LIGHT_MODES) cur = getattr(self.atmosphere, "light", "warm") try: idx = order.index(cur) except ValueError: idx = -1 nxt = order[(idx + 1) % len(order)] self.set_light(nxt) return nxt def set_bgm(self, mode: str) -> None: self.atmosphere.bgm = mode self.state["shop"]["bgm"] = mode self._recompute_atm() def _recompute_atm(self) -> None: self.atmosphere.recompute( self.state, self.day.phase, n_customers=len(self.director.agents), max_customers=int(self.db.rules.get("max_simultaneous_customers", 5)), products=self.db.products, furniture=self.db.furniture, ) def _make_report(self) -> DayReport: regs: Dict[str, int] = {} for nid, rel in self.relationships.data.items(): regs[nid] = int(rel.get("visit_count", 0)) return DayReport( day_index=self.day.day_index, enters=int(self.director.stats.get("enters", 0)), sales=self.sales_count, revenue=self.revenue, wallet_end=self.economy.wallet, regular_visits=regs, mood_line=self.atmosphere.mood_line(), phase_log=list(self.phase_log), ) def night_summary(self) -> Dict[str, Any]: rep = self._make_report() self.telemetry.emit("night_summary", {"sales": rep.sales, "enters": rep.enters}) return build_night_summary( atmosphere_snapshot=self.atmosphere.snapshot, mood_line=rep.mood_line, sales=rep.sales, revenue=rep.revenue, enters=rep.enters, diary=self.diary, ) def apply_settings(self) -> None: self.day.set_pace(self.settings.pace) self.telemetry.set_enabled(self.settings.telemetry_enabled) self.director.reduce_motion = self.settings.reduce_motion def snapshot(self) -> Dict[str, Any]: self.economy.sync_to_state(self.state) self.relationships.sync_to_state(self.state) self.state["meta"]["day_index"] = self.day.day_index self.state["meta"]["phase"] = self.day.phase.value return self.state def cycle_light(self) -> str: """Cycle warm → cool → off → warm (R165 light modes).""" order = ["warm", "cool", "off"] cur = getattr(self.atmosphere, "light", "warm") try: idx = order.index(cur) except ValueError: idx = -1 nxt = order[(idx + 1) % len(order)] self.set_light(nxt) return nxt