Han-Solo's picture
tune character behavior
cdd8f7e
Raw
History Blame Contribute Delete
2.99 kB
"""Game and level state, plus the rules for applying an NPC `<state>` block.
The model proposes per-turn deltas and flags in its hidden `<state>` 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 `<state>` 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