| """The playable game view: the room, the transcript, the input, and the per-turn handlers. |
| |
| One factory (`build_game_ui`) builds every game from the Scenario data, so adding a scenario |
| in scenarios/ adds a tab for free. Every string and update the layout shows comes from the |
| pure renderers in render.py; this module only builds the components and wires the handlers. |
| """ |
|
|
| import gradio as gr |
|
|
| from engine import new_game, play_turn_stream, rewind_to |
| from render import ( |
| BLANK_STORY, |
| EDIT_CLS, |
| PLAYER_VERB, |
| REGEN_CLS, |
| export_debug, |
| fresh_turn, |
| progress, |
| render_debug, |
| render_story, |
| room_strip, |
| turn_outputs, |
| ) |
|
|
| REGEN_CAP = ( |
| 16 |
| ) |
|
|
| |
| |
| |
| HIDE_CSS = ".rtr-regen-hide{display:none !important}" |
|
|
| |
| |
| |
| |
| |
| STORY_CSS = ( |
| ".rtr-story .prose{font-size:1.2em}" |
| ".rtr-story .prose p{margin:.45em 0}" |
| ".rtr-setup summary{cursor:pointer;font-weight:600}" |
| ".rtr-goal{border-bottom:1px solid rgba(0,0,0,.15);" |
| "padding-bottom:.5em;margin-bottom:.7em}" |
| ".rtr-you{text-align:right}" |
| ".rtr-you p{display:inline-block;text-align:left;max-width:75%;" |
| "background:rgba(99,102,241,.12);border:1px solid rgba(99,102,241,.35);" |
| "border-radius:12px 12px 2px 12px;padding:.3em .7em;margin:.4em 0}" |
| |
| ".rtr-beat{font-style:italic;font-size:.95em;opacity:.85;padding-left:1.2em}" |
| |
| |
| |
| |
| |
| ".form:has(.rtr-say),.rtr-say,.rtr-say :not(textarea){" |
| "background:transparent !important;border:none !important;box-shadow:none !important}" |
| |
| |
| ".rtr-say textarea{background:#f7f8ff !important;" |
| "border:1px solid rgba(99,102,241,.35) !important;" |
| "border-radius:12px 12px 2px 12px !important;color:#111 !important;" |
| "font-size:1.1em;padding:.5em .8em}" |
| |
| |
| ".rtr-game .generating{border:none !important}" |
| ) |
|
|
| |
| |
| |
| |
| BG_CSS = ( |
| ".rtr-bg{position:fixed;inset:0;z-index:-1;" |
| "background-size:cover;background-position:center}" |
| |
| |
| "body,gradio-app,.gradio-container{background:transparent !important}" |
| ".rtr-game:has(.rtr-bg){background:rgba(255,255,255,.78);" |
| "padding:8px 20px 20px;border-radius:14px}" |
| |
| |
| |
| |
| ".rtr-game:has(.rtr-bg) .prose," |
| ".rtr-game:has(.rtr-bg) .prose :not(a){color:#111}" |
| ) |
|
|
|
|
| def build_game_ui(scen=None): |
| """The playable game for one scenario: the room, the transcript, the input, and the |
| handlers. With a `scen` it builds a fixed game (the static tabs). With None it builds a |
| blank shell whose scenario is supplied at runtime through the returned state β the custom |
| 'Play your scenario' tab. The handlers read the scenario from that state, so the same |
| builder serves both. Returns (scen_state, load_outputs) for the caller to wire a loader.""" |
| scen_state = gr.State(scen) |
| |
| game = gr.State((lambda: new_game(scen)) if scen else None) |
| g0 = new_game(scen) if scen else None |
|
|
| |
| |
| with gr.Column(elem_classes="rtr-game"): |
| banner = gr.HTML(room_strip(scen) if scen else "") |
| |
| story = gr.Markdown( |
| render_story(scen, g0) if scen else BLANK_STORY, |
| sanitize_html=False, |
| elem_classes="rtr-story", |
| ) |
| recap = gr.HTML(visible=False) |
| status = gr.Markdown(progress(scen, g0) if scen else "") |
| box = gr.Textbox( |
| label=PLAYER_VERB, |
| show_label=False, |
| lines=2, |
| autofocus=True, |
| elem_classes="rtr-say", |
| ) |
| with gr.Row(): |
| act = gr.Button( |
| f"{PLAYER_VERB} βΆ", variant="primary", interactive=bool(scen) |
| ) |
| reset = gr.Button("New game") |
| |
| |
| |
| with gr.Row(): |
| gr.HTML("") |
| copy_convo = gr.Button("π", scale=0, size="sm") |
| copy_debug = gr.Button("π", scale=0, size="sm") |
| clip = gr.Textbox(visible=False) |
|
|
| |
| |
| |
| regen_btns = [ |
| gr.Button(elem_classes=[f"{REGEN_CLS}-{i}", "rtr-regen-hide"]) |
| for i in range(REGEN_CAP) |
| ] |
| |
| edit_btns = [ |
| gr.Button(elem_classes=[f"{EDIT_CLS}-{i}", "rtr-regen-hide"]) |
| for i in range(REGEN_CAP) |
| ] |
|
|
| turn_outs = [story, status, box, game, act, reset, recap] |
| load_outs = [banner, *turn_outs, scen_state] |
|
|
| async def submit(directive, game, scen): |
| directive = (directive or "").strip() |
| if scen is None or game is None: |
| yield fresh_turn(None) |
| return |
| if not directive or not game.active: |
| yield turn_outputs( |
| scen, game, box=gr.update(value=directive, visible=game.active) |
| ) |
| return |
| async for out in play_forward(scen, game, directive): |
| yield out |
|
|
| async def play_forward(scen, game, directive): |
| """Stream one turn and the trailing frame. Shared by a fresh Say and a β» replay.""" |
| async for current, _ in play_turn_stream(game, directive): |
| yield turn_outputs(scen, game, box="", status="π€ β¦", current=current) |
| box = gr.update(value="", visible=False) if not game.active else "" |
| yield turn_outputs(scen, game, box=box) |
|
|
| def rewind_bail(game, scen, turn_no): |
| """The early-out frame when a rewind can't run β a blank shell when nothing is |
| loaded, a no-op frame when the turn no longer exists β or None when it's valid. |
| The one guard both rewind paths (β» replay and β edit) share.""" |
| if scen is None or game is None: |
| return fresh_turn(None) |
| if not (0 <= turn_no < len(game.snapshots)): |
| return turn_outputs(scen, game, box=gr.update()) |
| return None |
|
|
| def make_regenerate(turn_no): |
| """A handler bound to one turn index: rewind to player turn `turn_no` (dropping it and |
| everything after) and replay it with the same words. The play LM has caching off, so the |
| room reacts anew rather than echoing the original β same streaming path as a fresh Say.""" |
|
|
| async def regenerate(game, scen): |
| bail = rewind_bail(game, scen, turn_no) |
| if bail is not None: |
| yield bail |
| return |
| directive = rewind_to(game, turn_no) |
| async for out in play_forward(scen, game, directive): |
| yield out |
|
|
| return regenerate |
|
|
| def make_edit(turn_no): |
| """A handler bound to one turn index: rewind to player turn `turn_no` (dropping it and |
| everything after) and drop the original words back into the box, UNPLAYED, so the player |
| can reword before pressing Say. Rewind-only β no streaming β so it's a plain sync return.""" |
|
|
| def edit(game, scen): |
| bail = rewind_bail(game, scen, turn_no) |
| if bail is not None: |
| return bail |
| directive = rewind_to(game, turn_no) |
| return turn_outputs( |
| scen, game, box=gr.update(value=directive, visible=True) |
| ) |
|
|
| return edit |
|
|
| act.click(submit, [box, game, scen_state], turn_outs) |
| box.submit(submit, [box, game, scen_state], turn_outs) |
| reset.click(fresh_turn, [scen_state], turn_outs) |
| for i, btn in enumerate(regen_btns): |
| btn.click(make_regenerate(i), [game, scen_state], turn_outs) |
| for i, btn in enumerate(edit_btns): |
| btn.click(make_edit(i), [game, scen_state], turn_outs) |
| to_clipboard = "(t) => { navigator.clipboard.writeText(t); }" |
| copy_convo.click(render_debug, [scen_state, game], clip).then( |
| None, clip, None, js=to_clipboard |
| ) |
| copy_debug.click(export_debug, [game], clip).then(None, clip, None, js=to_clipboard) |
|
|
| return scen_state, load_outs |
|
|
|
|
| def load_into_play(scen): |
| """Fill the custom Play tab with a fresh game of `scen` β banner, transcript, the lot β |
| in the order of build_game_ui's load_outs ([banner, *turn, scen_state]).""" |
| return (room_strip(scen), *fresh_turn(scen), scen) |
|
|
|
|
| def build_game(scen): |
| """Wrap one scenario's game in its own tab.""" |
| with gr.Tab(scen.title): |
| build_game_ui(scen) |
|
|