Download render.py from build-small-hackathon/read_the_room: direct link, hf CLI and curl.
- Browser
- Download file 12.4 kB
-
https://huggingface.co/spaces/build-small-hackathon/read_the_room/resolve/main/render.py
- Command line
-
hf download hf://spaces/build-small-hackathon/read_the_room/render.py
-
curl -L -o render.py https://huggingface.co/spaces/build-small-hackathon/read_the_room/resolve/main/render.py
12.4 kB
| """Pure renderers for the game view: everything that turns (scenario, game) into the | |
| strings and component updates the player sees. Nothing here creates a Gradio component — | |
| gameview.py builds the layout and handlers on top of these. | |
| """ | |
| import html | |
| import gradio as gr | |
| from art import avatar_uri, scene_uri | |
| from engine import final_verdict, new_game | |
| # The end screen carries the verdict as colour, not iconography: the whole recap card | |
| # tints gold on a win and crimson on a loss, so how it ended is obvious even scrolled | |
| # past the verdict word — and no skull presumes a loss means death. | |
| WIN_TINT = ("#7a5800", "rgba(212,160,30,.55)", "rgba(212,160,30,.12)") | |
| LOSE_TINT = ("#8b1a1a", "rgba(139,26,26,.55)", "rgba(139,26,26,.10)") | |
| # the player-facing verb: the input label, the act button, and the turn status line | |
| PLAYER_VERB = "Say" | |
| def esc(text): | |
| """Neutralise raw HTML in model/player text. The transcript renders with the HTML | |
| sanitizer OFF (so our SVG-avatar <img> tags survive), so dynamic text is escaped here | |
| instead — markdown (*, _, ---) still works, injected tags don't.""" | |
| return html.escape(text or "", quote=False) | |
| # A scenario-agnostic cue to act, shown once at the opening (intros set the scene but don't | |
| # all end on a hook). The player is "you" here; the model channel never sees this line. | |
| # Rendered as a narration beat, so the italics come from the CSS register, not markdown. | |
| PROMPT_TO_SPEAK = "The room turns to you. What do you say?" | |
| # Inline rewind links on each of the player's past lines. Each turn index has its own hidden | |
| # button (`.rtr-regen-N` / `.rtr-edit-N`, wired in gameview.py), so the link just confirms and | |
| # clicks that button — no value passes through the frontend. These prefixes are the contract | |
| # between the links here and the hidden buttons gameview builds. | |
| REGEN_CLS = "rtr-regen" | |
| EDIT_CLS = "rtr-edit" | |
| def _click_hidden(cls, confirm): | |
| """JS for an inline link: confirm, then click THIS game's hidden `.cls` button (scoped to the | |
| nearest `.rtr-game`, so the right game answers even with several game tabs in the page).""" | |
| return ( | |
| f"if(confirm('{confirm}'))" | |
| f"{{var t=this.closest('.rtr-game').querySelector('.{cls}');" | |
| # elem_classes lands on the <button> itself for a Button, but on a wrapper for others — | |
| # so click the element if it's the button, else its inner button. | |
| "(t.tagName==='BUTTON'?t:t.querySelector('button')).click();}return false;" | |
| ) | |
| def _inline_link(cls_prefix, turn_no, icon, title, confirm): | |
| """A small inline icon that clicks the hidden `.cls_prefix-turn_no` button for player turn | |
| `turn_no` (0-based, indexing game.snapshots), after a confirm prompt.""" | |
| return ( | |
| f' <a href="#" title="{title}" ' | |
| 'style="text-decoration:none;cursor:pointer;opacity:.5" ' | |
| f'onclick="{_click_hidden(f"{cls_prefix}-{turn_no}", confirm)}">{icon}</a>' | |
| ) | |
| def regen_link(turn_no): | |
| """↻ — rewind to this turn and replay it with the SAME words, fresh reactions.""" | |
| return _inline_link( | |
| REGEN_CLS, | |
| turn_no, | |
| "↻", | |
| "Rewind & regenerate from here", | |
| "Rewind here and replay from this line? Everything after it is discarded.", | |
| ) | |
| def edit_link(turn_no): | |
| """✎ — rewind to this turn and drop the original words back in the box to reword first.""" | |
| return _inline_link( | |
| EDIT_CLS, | |
| turn_no, | |
| "✎", | |
| "Rewind & edit this line", | |
| "Rewind here to reword this line? Everything after it is discarded.", | |
| ) | |
| def _block(cls, body): | |
| """One transcript entry as a classed <div> 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<div class="{cls}">\n\n{body}\n\n</div>' | |
| def header(scen, started): | |
| """The setup card: the intro folded into a <details> 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'<details class="rtr-setup"{state}><summary>The scene</summary>\n\n' | |
| f"{esc(scen.intro)}\n\n</details>" | |
| + _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'<img src="{uri}" alt="{attr}" title="{attr}" width="30" height="30" ' | |
| 'style="display:inline-block;border-radius:50%;vertical-align:middle;' | |
| 'margin:0 8px 0 0">' | |
| f'<strong style="vertical-align:middle;margin-right:8px">{attr}:</strong>' | |
| ) | |
| 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'<div class="rtr-bg" style="background-image:url({uri})"></div>' | |
| 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"<b>You:</b> {esc(row.get('Player', ''))}"] | |
| out += [ | |
| f"<b>{esc(o.name)}:</b> {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"<b>Self:</b> {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'<div style="display:flex;gap:12px;align-items:flex-start;margin:12px 0">' | |
| f'<img src="{avatar_uri(scen, c)}" ' | |
| f'style="width:52px;height:52px;border-radius:50%;flex:none"/>' | |
| f"<div><b>{esc(c.name)}</b>" | |
| + "".join( | |
| f'<div style="opacity:.85;font-size:.92em">{line}</div>' | |
| for line in _web_rows(scen, game, c) | |
| ) | |
| + "</div></div>" | |
| for c in scen.characters | |
| ) | |
| return gr.update( | |
| value=( | |
| f'<div style="border:2px solid {border};background:{wash};' | |
| f'border-radius:12px;padding:14px 18px">' | |
| f'<div style="text-align:center;font-size:28px;font-weight:700;' | |
| f'letter-spacing:4px;color:{text}">{verdict}</div>' | |
| f'<hr style="margin:12px 0;border:none;border-top:1px solid {border}">' | |
| f'<div style="font-weight:600;margin-bottom:2px">How the room ended up</div>' | |
| f"{cards}</div>" | |
| ), | |
| 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)) | |