| """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: |
| |
| 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 |
|
|
| 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: |
| |
| 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") |
|
|
| 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}") |
| |
| _ = 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 |
|
|