docker000 / game /sim /session.py
seachen's picture
Deploy Quiet Town Corner Shop Docker demo (Gradio + Python sim) (part 5)
3831e97 verified
Raw
History Blame Contribute Delete
9.2 kB
"""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