whose markdown stays live: the blank lines
make the tag its own HTML block, so the body still renders as markdown paragraph(s)
while CSS addresses each kind of entry by class (the registers in gameview.STORY_CSS)."""
return f'\n\n
\n\n{body}\n\n
'
def header(scen, started):
"""The setup card: the intro folded into a
that stands open until the player's
first line and collapses to one row after, with the goal pinned visible below it — the
goal's underline (CSS) is the boundary where the setup ends and play begins."""
state = "" if started else " open"
return (
f'The scene
\n\n'
f"{esc(scen.intro)}\n\n "
+ _block("rtr-goal", f"**Goal:** {esc(scen.goal)}")
)
def speaker_tag(uri, name):
"""The lead-in for a character's spoken line: their avatar followed by their name."""
attr = html.escape(name)
return (
f'
'
f'{attr}:'
)
def render_story(scen, game, current=None):
"""Rebuild the player-facing transcript from the game log. `current` is whatever is being
written live and not yet committed to the log: a speaker's partial dict (name/line), or a
scene beat the director is writing (`{"beat": text}`). Private reasoning is never shown
here — it lives in the debug log."""
avatars = {c.name: avatar_uri(scen, c) for c in scen.characters}
started = any(e.kind == "player" for e in game.log)
md = header(scen, started)
if not started: # opening: cue the player to speak
md += _block("rtr-beat", PROMPT_TO_SPEAK)
turn_no = 0 # 0-based index of each player turn, matching game.snapshots for rewind
for e in game.log[1:]: # log[0] is the intro; the setup card already shows it
if e.kind == "beat":
md += _block("rtr-beat", esc(e.text))
elif e.kind == "player":
md += _block(
"rtr-you",
f"“{esc(e.text)}”{regen_link(turn_no)}{edit_link(turn_no)}",
)
turn_no += 1
elif e.kind == "line":
md += _block(
"rtr-line", f"{speaker_tag(avatars[e.who], e.who)} {esc(e.text)}"
)
if current and "name" in current: # a character speaking live
tag = speaker_tag(avatars[current["name"]], current["name"])
md += _block("rtr-line", f"{tag} {esc(current['line'])} ▌")
elif current: # a scene beat the director is writing live
md += _block("rtr-beat", f"{esc(current['beat'])} ▌")
return md
def render_debug(scen, game):
"""The full plain-text transcript for copy-paste debugging: every beat, the player's
words, and each character's PRIVATE reasoning alongside what they said."""
if scen is None or game is None:
return "(nothing to copy yet)"
lines = [f"SETUP: {scen.intro}", f"GOAL: {scen.goal}", ""]
for e in game.log:
if e.kind == "beat":
lines.append(f"[scene] {e.text}")
elif e.kind == "player":
lines.append(f"YOU: {e.text}")
elif e.kind == "line":
if e.reasoning:
lines.append(f"{e.who} (thinks): {e.reasoning}")
lines.append(f"{e.who}: {e.text}")
if not game.active:
lines += ["", f"RESULT: {final_verdict(game)}"]
lines += ["", "-- current dispositions --"]
for name, cs in game.chars.items():
row = cs.disposition
lines.append(f"{name}:")
lines.append(f" player: {row.get('Player', '')}")
lines.append(f" self: {row.get(name, '')}")
lines += [
f" -> {k}: {v}" for k, v in row.items() if k not in ("Player", name)
]
return "\n".join(lines)
def export_debug(game):
"""The full raw LM input+output for every turn — the trace."""
return "\n".join(game.trace) if game and game.trace else "(no turns yet)"
def progress(scen, game):
used, total = game.turn, scen.max_turns
if not game.active:
return "**Game Over**"
return f"**You {PLAYER_VERB.lower()}…** ({used + 1}/{total})"
def room_strip(scen):
"""The scene art as a full-page backdrop behind the story (`.rtr-bg`, styled in
gameview.BG_CSS) — only when the scenario carries a real image; otherwise nothing and
the plain page stands. The cast appear inline as they speak in the transcript, and how
they finally read you is the end screen."""
uri = scene_uri(scen)
if not uri:
return ""
return f''
def _web_rows(scen, game, c):
"""The lines of one character's final stance web: their read of the player, of each other
character, then their own closing mood (self) — each pulled straight from the matrix."""
row = game.chars[c.name].disposition
out = [f"You: {esc(row.get('Player', ''))}"]
out += [
f"{esc(o.name)}: {esc(row.get(o.name, ''))}"
for o in scen.characters
if o.name != c.name and row.get(o.name)
]
mood = row.get(c.name, "")
if mood:
out.append(f"Self: {esc(mood)}")
return out
def end_screen(scen, game):
"""The verdict card: the result, plus how each mind finally read the player, the others,
and itself — straight from the disposition matrix, no extra model calls. Gated on
`concluded`, not the derived `active`: the clock-cap turn flips `active` off before
`outcome` is known, and the finale must stream without flashing an undecided verdict."""
if not game.concluded:
return gr.update(value="", visible=False)
verdict = final_verdict(game)
text, border, wash = WIN_TINT if verdict == scen.verdict_labels[0] else LOSE_TINT
cards = "".join(
f''
f'
})
'
f"
{esc(c.name)}"
+ "".join(
f'
{line}
'
for line in _web_rows(scen, game, c)
)
+ "
"
for c in scen.characters
)
return gr.update(
value=(
f''
f'
{verdict}
'
f'
'
f'
How the room ended up
'
f"{cards}
"
),
visible=True,
)
def buttons(active):
"""(act, reset) button states. While playing, act is the bright primary; once the tale
is told, act greys out and New game becomes the obvious next move."""
if active:
return (
gr.update(interactive=True, variant="primary"),
gr.update(variant="secondary"),
)
return (
gr.update(interactive=False, variant="secondary"),
gr.update(variant="primary"),
)
BLANK_STORY = (
"*Author a scenario in **✨ Create your own**, then press **Play it ▶** "
"— it opens here. Already have a saved scenario? Load the `.txt` above.*"
)
def turn_outputs(scen, game, *, box, status=None, current=None):
"""The seven per-turn outputs in build_game_ui's turn_outs order, for a live game.
`box` is the input update; `status` and `current` override the defaults for the
live-streaming frame (a fixed think cue and the in-progress speaker)."""
return (
render_story(scen, game, current),
progress(scen, game) if status is None else status,
box,
game,
*buttons(game.active),
end_screen(scen, game),
)
def fresh_turn(scen):
"""The seven per-turn outputs for a brand-new game of `scen`, or a blank shell when
`scen` is None (the custom tab before anything is loaded into it)."""
if scen is None:
return (
BLANK_STORY,
"",
gr.update(value="", visible=False),
None,
*buttons(False),
gr.update(value="", visible=False),
)
return turn_outputs(scen, new_game(scen), box=gr.update(value="", visible=True))