case0 / src /case_zero /engine /state_update.py
HusseinEid's picture
Case Zero - initial public release (fully local: Qwen2.5-1.5B via llama.cpp + Supertonic, custom pixel-noir SPA via gradio.Server)
414dc55
raw
history blame
5.01 kB
"""Pure reducers that fold a turn + adjudication into a new GameState."""
from __future__ import annotations
from ..schemas.case import CaseFile
from ..schemas.interrogation import InterrogationTurn
from .director import Adjudication
from .game_state import Exchange, GameState, NotebookEntry, NotebookKind, SuspectState
def _fact_statement(case: CaseFile, fact_id: str) -> str:
for fact in case.facts:
if fact.fact_id == fact_id:
return fact.statement
return fact_id
def _suspect_name(case: CaseFile, sus_id: str) -> str:
try:
return case.suspect(sus_id).name
except KeyError:
return sus_id
def _next_suspect_state(
prev: SuspectState,
*,
turn_index: int,
question: str,
turn: InterrogationTurn,
adj: Adjudication,
presented_clue_id: str | None,
) -> SuspectState:
evidence_shown = prev.evidence_shown
if presented_clue_id is not None:
evidence_shown = evidence_shown | {presented_clue_id}
broken = prev.broken_lie_ids
contradictions = prev.contradictions
if adj.is_contradiction:
if adj.broken_lie_id is not None:
broken = broken | {adj.broken_lie_id}
contradictions = (*contradictions, f"Alibi contradicted on turn {turn_index}.")
return prev.model_copy(
update={
"turns": prev.turns + 1,
"stress": adj.stress_after,
"rapport": adj.rapport_after,
"transcript": (*prev.transcript, Exchange(turn_index=turn_index, question=question, answer=turn.spoken)),
"revealed_fact_ids": prev.revealed_fact_ids | set(adj.revealed_fact_ids),
"evidence_shown": evidence_shown,
"broken_lie_ids": broken,
"contradictions": contradictions,
}
)
def _notebook_entries(
case: CaseFile, sus_id: str, turn_index: int, adj: Adjudication
) -> tuple[NotebookEntry, ...]:
entries: list[NotebookEntry] = []
if adj.is_contradiction:
entries.append(
NotebookEntry(
kind=NotebookKind.CONTRADICTION,
text=f"{_suspect_name(case, sus_id)}'s alibi cracked under evidence.",
turn_index=turn_index,
suspect_id=sus_id,
)
)
for fid in adj.revealed_fact_ids:
entries.append(
NotebookEntry(
kind=NotebookKind.LEAD,
text=_fact_statement(case, fid),
turn_index=turn_index,
suspect_id=sus_id,
)
)
return tuple(entries)
def apply_turn(
state: GameState,
case: CaseFile,
sus_id: str,
question: str,
turn: InterrogationTurn,
adj: Adjudication,
presented_clue_id: str | None = None,
) -> GameState:
"""Return a new GameState with this turn folded in. Never mutates ``state``."""
turn_index = state.turn_count
prev = state.state_for(sus_id)
next_state = _next_suspect_state(
prev,
turn_index=turn_index,
question=question,
turn=turn,
adj=adj,
presented_clue_id=presented_clue_id,
)
new_states = {**state.suspect_states, sus_id: next_state}
notebook = state.notebook + _notebook_entries(case, sus_id, turn_index, adj)
return state.model_copy(
update={
"suspect_states": new_states,
"current_suspect_id": sus_id,
"turn_count": turn_index + 1,
"notebook": notebook,
}
)
def add_player_note(state: GameState, text: str) -> GameState:
"""Append a free-text note the player typed into the notebook (never mutates state)."""
text = text.strip()
if not text:
return state
note = NotebookEntry(kind=NotebookKind.NOTE, text=text[:240], turn_index=state.turn_count)
return state.model_copy(update={"notebook": (*state.notebook, note)})
def discover_clues(state: GameState, clue_ids: tuple[str, ...], case: CaseFile) -> GameState:
"""Add searched/forensic/document clues to the discovered set with notebook notes.
Unknown clue ids are ignored so a bad id can never enter the discovered set."""
seen = set(state.discovered_clue_ids)
collected: list[str] = []
for cid in clue_ids:
if cid in seen or not _clue_exists(case, cid):
continue
seen.add(cid)
collected.append(cid)
newly = tuple(collected)
if not newly:
return state
notes = tuple(
NotebookEntry(
kind=NotebookKind.CLUE,
text=case.clue(cid).name,
turn_index=state.turn_count,
clue_id=cid,
)
for cid in newly
)
return state.model_copy(
update={
"discovered_clue_ids": state.discovered_clue_ids | set(newly),
"notebook": state.notebook + notes,
}
)
def _clue_exists(case: CaseFile, clue_id: str) -> bool:
try:
case.clue(clue_id)
return True
except KeyError:
return False