"""GameState dataclass, in-memory session store, and the client-safe snapshot. The snapshot NEVER includes morale, relationship scores, or constraint flags — only npc_moods strings and an ambient band (AGENTS.md non-negotiables). """ from __future__ import annotations import threading import uuid from collections import OrderedDict from dataclasses import dataclass, field from .schemas import NPC_IDS TARGET = 1_000_000 # small opening buffer so early losses register on the meter instead of # silently flooring at zero (the board reads the applied figure, not the # model's claim — see validator). ~15% of target keeps difficulty intact. START_REVENUE = 150_000 START_BUDGET = 50_000 START_POCKET = 3_000 START_MORALE = 65 START_RELATIONSHIP = 50 TOTAL_CRISES = 15 PRESENTATION_EVENTS = (4, 8, 15) MAX_SESSIONS = 200 @dataclass class NpcState: relationship: int = START_RELATIONSHIP gifts_received: int = 0 mood: str = "normal" incident_count: int = 0 personal_situation: str | None = None recent_events: list[str] = field(default_factory=list) last_gift_event: int = -10 # crisis number of last gift, for half-value rule consecutive_praise: int = 0 romance_active: bool = False # the player is dating this NPC @dataclass class GameState: session_id: str = "" phase: str = "title" # title | free_roam | crisis | presentation | review crisis_number: int = 0 revenue: int = START_REVENUE target: int = TARGET company_budget: int = START_BUDGET bonuses_issued: int = 0 total_bonus_spend: int = 0 pocket_money: int = START_POCKET bribes_accepted: int = 0 morale: int = START_MORALE boss_title: str = "Head of Sales and Partnerships" npcs: dict[str, NpcState] = field( default_factory=lambda: {n: NpcState() for n in NPC_IDS}) board_scrutiny: str = "low" scrutiny_high_streak: int = 0 consecutive_harsh: int = 0 consecutive_fine_whatever: int = 0 hr_alert: bool = False newspaper_count: int = 0 event_log: list[str] = field(default_factory=list) paper_trail: list[dict] = field(default_factory=list) current_event: dict | None = None special_drought: int = 0 queued_special: str | None = None pending_bribe: int = 0 presentation: dict | None = None # {round, transcript, board_tone, ...} crises_since_salary: int = 0 fallback_count: int = 0 game_over: bool = False review: dict | None = None # idle-activity budgets, reset every roam gap (events.next_event) chats_this_gap: dict[str, int] = field(default_factory=dict) idle_done_this_gap: bool = False chat_session: dict | None = None # {npc_id, turns} pending_email: dict | None = None def npc(self, npc_id: str) -> NpcState: return self.npcs[npc_id] def log(self, text: str) -> None: self.event_log.append(text) def trail(self, npc: str, text: str, delta: int) -> None: self.paper_trail.append({"npc": npc, "text": text, "delta": delta}) @property def morale_band(self) -> str: m = self.morale if m >= 70: return "high" if m >= 50: return "normal" if m >= 30: return "tired" if m >= 20: return "low" return "critical" def snapshot(self) -> dict: """Client-safe view. No hidden numbers ever leave this function.""" return { "revenue": self.revenue, "target": self.target, "pocket_money": self.pocket_money, "boss_title": self.boss_title, "crisis_number": self.crisis_number, "total_crises": TOTAL_CRISES, "paper_trail": self.paper_trail[-30:], "npc_moods": {n: s.mood for n, s in self.npcs.items()}, # coarse romance state only — never the raw relationship number "npc_romance": { n: ("active" if s.romance_active else "available" if s.relationship >= 65 else "none") for n, s in self.npcs.items() }, "gift_available": self.pocket_money >= 50, "bonus_available": self.company_budget >= 5_000, "hr_alert": self.hr_alert, "email_waiting": self.pending_email is not None, "ambient": "gloomy" if self.morale < 20 else "normal", "newspaper_on_floor": self.newspaper_count > 0, "phase": self.phase, "game_over": self.game_over, } class SessionStore: def __init__(self, cap: int = MAX_SESSIONS): self._cap = cap self._sessions: OrderedDict[str, GameState] = OrderedDict() self._lock = threading.Lock() def create(self) -> GameState: with self._lock: sid = uuid.uuid4().hex state = GameState(session_id=sid) self._sessions[sid] = state while len(self._sessions) > self._cap: self._sessions.popitem(last=False) return state def get(self, session_id: str) -> GameState | None: with self._lock: state = self._sessions.get(session_id) if state is not None: self._sessions.move_to_end(session_id) return state STORE = SessionStore()