"""Render game data to themed, animated HTML.
The suspect's hidden ``internal`` state is never rendered; only ``spoken`` text and a
few derived in-world cues reach the player. The stage and notebook are deterministic
CSS-animated graphics - no model runs to draw them.
"""
from __future__ import annotations
import html
from ..engine.game_state import GameState
from ..projections.player_view import PlayerCaseView
from ..schemas.accusation import Verdict
from ..schemas.case import CaseFile
def _esc(text: str) -> str:
return html.escape(text or "")
def how_to_play_html() -> str:
steps = [
("1", "INTERROGATE", "Pick a suspect and ask anything. They remember, and they lie."),
("2", "INVESTIGATE", "Search the rooms to collect physical evidence."),
("3", "CONFRONT", "Present a clue that contradicts a suspect - watch their alibi crack."),
("4", "ACCUSE", "Name the killer and cite your proof. One of them did it."),
]
cards = "".join(
f"
"
for n, title, body in steps
)
return (
"HOW TO PLAY
"
f"
{cards}
"
"
Press NEW CASE to open a fresh file, "
"then question your suspects.
"
)
def briefing_html(view: PlayerCaseView) -> str:
return (
f"{_esc(view.title)}
{_esc(view.briefing)}"
f"
Victim: {_esc(view.victim.name)} ({_esc(view.victim.role)}) - "
f"{_esc(view.victim.cause_of_death)}.
"
)
def stage_html(scene_uri: str, sprite_uri: str, name: str, room_name: str = "") -> str:
room = f"{_esc(room_name)}
" if room_name else ""
return (
""
f"
"
f"
{_esc(name)}
{room}"
f"
"
"
"
)
def dialogue_html(name: str, line: str, streaming: bool = False) -> str:
caret = "█" if streaming else ""
body = _esc(line) if line else "..."
who = _esc(name.upper()) if name else " "
return f""
def scene_panel_html(scene_uri: str, caption: str) -> str:
return (
f"
"
f"
{_esc(caption)}
"
)
def evidence_html(items: list[tuple[str, str, str]]) -> str:
"""items: (name, reveal, image_uri)."""
if not items:
return "No evidence yet. Search the rooms.
"
# Each card is a : the description is clamped to 3 lines, click to expand the
# full text (native HTML, no JS needed). The "+"/"-" corner marker shows it is toggleable.
slots = [
""
f"
"
f"{_esc(name)}
"
f"
{_esc(reveal[:300])}
"
"
"
for name, reveal, uri in items
]
return f"{''.join(slots)}
"
def notebook_html(state: GameState, case: CaseFile) -> str:
if not state.notebook:
return "The page is blank. Question them and search the rooms.
"
rows = []
for entry in reversed(state.notebook):
cls = entry.kind.value
rows.append(f"{entry.kind.value.upper()} · {_esc(entry.text)}
")
return f"{''.join(rows)}
"
def verdict_html(case: CaseFile, verdict: Verdict) -> str:
head_color = "#7bbf73" if verdict.solved else "#d2603f"
head = "CASE SOLVED" if verdict.solved else "CASE UNSOLVED"
parts = [f"{head}
",
f"
Score: {verdict.score}/100
",
f"
{_esc(verdict.rationale)}
",
"
Director's Cut:"]
parts += [f"- {_esc(step)}
" for step in verdict.deduction_chain]
parts.append("
")
return "".join(parts)