File size: 9,196 Bytes
3831e97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
"""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