"""Game and level state, plus the rules for applying an NPC `` block. The model proposes per-turn deltas and flags in its hidden `` channel; this module is the single authority that turns those proposals into clamped, trustworthy game state. Keeping the rules here (not in the model, not in the UI) means win/lose progression is deterministic and unit-testable. """ from __future__ import annotations from dataclasses import dataclass, field TRUST_MIN = -100 TRUST_MAX = 100 # A run is lost only when trust falls below zero. Being caught lying still tanks # trust hard (and is tracked in caught_lie_count), but no longer ends the run by # itself -- characters keep the conversation going until trust actually goes negative. LOSE_TRUST_FLOOR = 0 # A character can't "open up" and be won until trust is genuinely high. This guards # against weaker models that flip `opened_up` too eagerly -- format is grammar-locked, # but semantics aren't, so the engine enforces the threshold. OPEN_UP_MIN_TRUST = 55 STATUS_IN_PROGRESS = "in_progress" STATUS_WON = "won" STATUS_LOST = "lost" @dataclass class LevelState: """Per-character conversation state.""" character_id: str trust: int = 0 caught_lie_count: int = 0 opened_up: bool = False status: str = STATUS_IN_PROGRESS last_reaction: str = "neutral" # transcript entries: {"role": "user"|"assistant", "content": str} transcript: list[dict] = field(default_factory=list) def add_user(self, text: str) -> None: self.transcript.append({"role": "user", "content": text}) def add_assistant(self, raw: str) -> None: """Store the raw two-channel generation so it can be replayed as context.""" self.transcript.append({"role": "assistant", "content": raw}) def _clamp(value: int, low: int, high: int) -> int: return max(low, min(high, value)) def apply_state( level: LevelState, state: dict, *, threshold: int = OPEN_UP_MIN_TRUST, ) -> LevelState: """Fold one parsed NPC `` block into the level state. `trust` from the model is treated as authoritative but clamped. `trust_delta` and `on_topic` stay in the output contract for model calibration, but the engine does not use them for progression. """ if state.get("caught_lie"): level.caught_lie_count += 1 raw_trust = state.get("trust", level.trust) level.trust = _clamp(int(raw_trust), TRUST_MIN, TRUST_MAX) reaction = state.get("reaction", "neutral") level.last_reaction = reaction if reaction in ("happy", "neutral", "sad") else "neutral" # A win requires BOTH the model's opened_up signal AND genuinely high trust, so # a weak/eager model can't flip the flag on turn one and skip the persuasion. if state.get("opened_up") and level.trust >= threshold: level.opened_up = True level.status = STATUS_WON elif level.trust < LOSE_TRUST_FLOOR: level.status = STATUS_LOST return level