"""The 'Create your own' view: a scenario form that starts preloaded with a blank template — premise fields, one card per character, the disposition matrix as an editable grid. Fill it by hand, or let the AI author the whole thing from a one-line idea. Save it as a shareable .txt, and hand the parsed Scenario off to the Play tab. Nobody edits raw JSON here; Save still serializes the spec. """ import gradio as gr import pandas as pd import community import creator from dispositions import party_keys MAX_CAST = 6 # the editor pre-builds this many character cards and shows the live ones # the flat theme (app.py) strips the card behind every component; these two are # container-style widgets whose card WAS their boundary, so they get a quiet outline back CREATOR_CSS = ( ".rtr-drop{border:1px dashed var(--border-color-primary);" "border-radius:var(--block-radius)}" ".rtr-fold{border:1px solid var(--border-color-primary);" "border-radius:var(--block-radius)}" # match the flat field labels: same size/colour as block titles, snugged to its zone ".rtr-caption p{color:var(--block-title-text-color);" "font-size:var(--block-title-text-size);font-weight:500;margin-bottom:0}" ) CREATOR_TAB_ID = "creator" # so other tabs (community's Share-yours) can jump here WHO = "Who" # the grid's display-only first column; cells are read by position, not header def load_button(): """The '⬆️ Load scenario (.txt)' control — the editor and the Play tab present the same loader.""" return gr.UploadButton( "⬆️ Load scenario (.txt)", file_types=[".txt"], type="filepath", size="sm", scale=0, ) def grid_value(characters): """The disposition matrix as a grid: one row per character (first cell their name), then one cell per party — Player plus every name; a character's own column is their mood.""" keys = party_keys([c.name for c in characters]) rows = [[c.name, *(c.disposition.get(k, "") for k in keys)] for c in characters] return pd.DataFrame(rows, columns=[WHO, *keys]) def cell(grid, i, j): """One grid cell as text, tolerant of rows/columns missing from the live dataframe.""" if i < grid.shape[0] and j < grid.shape[1]: v = grid.iat[i, j] return str(v) if pd.notna(v) else "" return "" def rebuild_grid(grid, names): """The grid resized and re-headed to `names`, every surviving cell kept by position (new parties get empty cells, dropped ones lose theirs).""" cols = [WHO, "Player", *names] rows = [ [name, *(cell(grid, i, j) for j in range(1, len(cols)))] for i, name in enumerate(names) ] return pd.DataFrame(rows, columns=cols) def grid_widths(ncols): """Column widths that always fit: a narrow name gutter, then an equal share per disposition column — otherwise wide cells push the last columns off the edge and they can't be edited.""" share = f"{92 / max(ncols - 1, 1):.0f}%" return ["8%", *[share] * (ncols - 1)] def grid_update(df): return gr.update(value=df, column_widths=grid_widths(len(df.columns))) def named(values, k): """The first k name boxes, blank ones falling back to a placeholder.""" return [values[i] or f"Character {i + 1}" for i in range(k)] def assemble(title, intro, goal, max_turns, win, lose, grid, cast, *fields): """Read the whole form back into a ScenarioSpec. `fields` is the MAX_CAST name boxes then the MAX_CAST persona boxes; the first len(cast) of each are live. Grid cells are read by position, so its headers stay display-only.""" names = named(fields, len(cast)) keys = party_keys(names) characters = [ creator.CharSpec( name=name, persona=fields[MAX_CAST + i] or "", disposition={k: cell(grid, i, j + 1) for j, k in enumerate(keys)}, ) for i, name in enumerate(names) ] return creator.ScenarioSpec( title=title or "", intro=intro or "", goal=goal or "", max_turns=int(max_turns), verdict_labels=[ win or creator.DEFAULT_LABELS[0], lose or creator.DEFAULT_LABELS[1], ], characters=characters, ) # authoring and loading fill the same outputs: a status line (errors only — success is # silent, the form filling in IS the feedback), every premise field, the grid, the cast, # and the MAX_CAST card slots (visibility, name, persona). def filled(spec): """The full creator output row for a freshly loaded spec.""" chars = spec.characters win, lose = creator.verdict_pair(spec) shown = [gr.update(visible=i < len(chars)) for i in range(MAX_CAST)] names = [chars[i].name if i < len(chars) else "" for i in range(MAX_CAST)] personas = [chars[i].persona if i < len(chars) else "" for i in range(MAX_CAST)] return ( "", spec.title, spec.intro, spec.goal, spec.max_turns, win, lose, grid_update(grid_value(chars)), [c.name for c in chars], *shown, *names, *personas, ) def abort(msg): """Leave the editor untouched and just show a status line.""" return (msg, *(gr.update(),) * (8 + 3 * MAX_CAST)) def author_handler(idea, n): idea = (idea or "").strip() if not idea: return abort("✍️ Describe your idea first.") try: spec = creator.author(idea, int(n)) except ( Exception ) as e: # malformed model output / parse failure — show it, don't crash return abort(f"❌ Authoring failed: {e}") return filled(spec) def load_handler(path): if not path: return abort("⬆️ Pick a scenario .txt.") try: spec = creator.load_spec(path) except ( Exception ) as e: # not a spec / invalid spec — surface it, keep the editor as-is return abort(f"❌ {creator.LOAD_ERROR}: {e}") if len(spec.characters) > MAX_CAST: return abort(f"❌ This editor holds up to {MAX_CAST} characters.") return filled(spec) def rename_handler(cast_now, grid, *names): """Live rename: the cast list and the grid headers follow the name boxes; every disposition cell survives by position.""" new = named(names, len(cast_now)) return new, grid_update(rebuild_grid(grid, new)) # add/remove both write the same outputs: cast, grid, and every card slot. def cards_noop(): return (gr.update(),) * (2 + 3 * MAX_CAST) def add_handler(cast_now, grid): if len(cast_now) >= MAX_CAST: return cards_noop() k = len(cast_now) name = f"Character {k + 1}" new = [*cast_now, name] shown = [ gr.update(visible=True) if i == k else gr.update() for i in range(MAX_CAST) ] names = [name if i == k else gr.update() for i in range(MAX_CAST)] personas = ["" if i == k else gr.update() for i in range(MAX_CAST)] return ( new, grid_update(rebuild_grid(grid, new)), *shown, *names, *personas, ) def remove_handler(cast_now, grid): if len(cast_now) <= 1: # a scenario needs at least one character return cards_noop() new = list(cast_now[:-1]) shown = [ gr.update(visible=False) if i == len(new) else gr.update() for i in range(MAX_CAST) ] return ( new, grid_update(rebuild_grid(grid, new)), *shown, *cards_noop()[: 2 * MAX_CAST], ) def parse_or_raise(picture, *args): """Build the Scenario the player is about to play from the form, or raise a toast they can read. The .success() chain only fires on a clean parse, so the Play tab is never half-filled. The picture rides along as the backdrop; it is never serialized.""" try: scen = creator.spec_to_scenario(assemble(*args)) except Exception as e: # invalid / incomplete spec — surface the reason as a toast raise gr.Error(f"Couldn't load this spec: {e}") scen.scene = picture or "" return scen def share_handler(picture, *args): """Publish the form (+ optional picture) to the community dataset.""" spec = assemble(*args) try: creator.spec_to_scenario(spec) # publish only what actually plays community.publish(spec, picture) except Exception as e: # invalid spec / missing config / failed upload raise gr.Error(f"Couldn't share: {e}") gr.Info("Shared 🌍 — find it in the Community tab") def save_handler(*args): """Serialize the form to the spec JSON and hand back a downloadable .txt.""" return creator.save_spec(assemble(*args)) def build_creator(): """A tab with the scenario form, preloaded blank, that the AI can fill from a one-line idea; pressing Play hands it to the dedicated Play tab. Returns the refs the top-level needs to wire that handoff: (play_btn, form_inputs).""" blank = creator.blank_spec(3) with gr.Tab("✨ Create your own", id=CREATOR_TAB_ID): with gr.Row(): idea = gr.Textbox( label="Your idea", placeholder="Friends who can't decide what to order for dinner. Nobody's objection is about food — it's grudges, pride, and who gets to decide.", lines=2, scale=4, ) n = gr.Number( value=len(blank.characters), minimum=1, maximum=MAX_CAST, precision=0, label="Characters", scale=0, min_width=110, ) author_btn = gr.Button( "Author with AI ✨", variant="primary", scale=0, min_width=180 ) status = gr.Markdown() with gr.Row(): title = gr.Textbox(label="Title", scale=3) max_turns = gr.Number( value=blank.max_turns, minimum=creator.MIN_TURNS, maximum=12, precision=0, label="Max turns", scale=0, min_width=110, ) intro = gr.Textbox( label="Intro, addressed to you", placeholder="Set the scene, spoken to the player: 'You walk into…'", lines=2, max_lines=6, ) with gr.Row(): goal = gr.Textbox( label="Win condition", placeholder="What you must pull off — social leverage, not trivia", lines=1, max_lines=3, scale=3, ) win = gr.Textbox( label="Win verdict", value=creator.DEFAULT_LABELS[0], scale=1 ) lose = gr.Textbox( label="Lose verdict", value=creator.DEFAULT_LABELS[1], scale=1 ) with gr.Row(): gr.Markdown("#### Characters") add_btn = gr.Button("➕ Add", size="sm", scale=0) remove_btn = gr.Button("➖ Remove", size="sm", scale=0) groups, names, personas = [], [], [] for i in range(MAX_CAST): live = i < len(blank.characters) # table-like: the column labels show on the first card only with gr.Row(visible=live) as g: nm = gr.Textbox( value=blank.characters[i].name if live else "", label="Name", show_label=i == 0, scale=1, ) pe = gr.Textbox( label="Description", show_label=i == 0, lines=1, max_lines=4, scale=4, ) groups.append(g) names.append(nm) personas.append(pe) start_grid = grid_value(blank.characters) with gr.Accordion( "Dispositions — who starts feeling what about whom", open=False, elem_classes="rtr-fold", ): grid = gr.Dataframe( value=start_grid, interactive=True, wrap=True, static_columns=[0], column_widths=grid_widths(len(start_grid.columns)), ) # a plain caption, not the component label: the theme's overlay label is the one # pill left on the page and it sits straight on the drop zone's border gr.Markdown( "Scene picture — optional; the in-game backdrop, and your community card", elem_classes="rtr-caption", ) picture = gr.Image( show_label=False, type="filepath", sources=["upload"], height=160, elem_classes="rtr-drop", ) cast = gr.State([c.name for c in blank.characters]) with gr.Row(): play_btn = gr.Button("Play it ▶", variant="primary", scale=3) download_btn = gr.DownloadButton( "⬇️ Save scenario (.txt)", size="sm", scale=0 ) load_btn = load_button() share_btn = gr.Button("🌍 Share to community", size="sm", scale=0) form_inputs = [title, intro, goal, max_turns, win, lose, grid, cast] form_inputs += [*names, *personas] outputs = [status, *form_inputs[:7], cast] outputs += [*groups, *names, *personas] # everything the player could press or type locks while the AI authors, so a # half-written room can't be edited or re-authored mid-flight. lockable = [ author_btn, idea, n, load_btn, play_btn, download_btn, share_btn, picture, add_btn, remove_btn, *form_inputs[:7], *names, *personas, ] def locked(interactive): return lambda: [gr.update(interactive=interactive)] * len(lockable) # gradio fires a multiline textbox's submit on Shift+Enter, so the idea box # gets a keyboard path to the same locked authoring chain as the button for trigger in (author_btn.click, idea.submit): trigger(locked(False), None, lockable).then( author_handler, [idea, n], outputs ).then(locked(True), None, lockable) load_btn.upload(load_handler, [load_btn], outputs) # .input (not .change) so programmatic fills don't echo back through the handler for nm in names: nm.input(rename_handler, [cast, grid, *names], [cast, grid]) card_outputs = [cast, grid, *groups, *names, *personas] add_btn.click(add_handler, [cast, grid], card_outputs) remove_btn.click(remove_handler, [cast, grid], card_outputs) download_btn.click(save_handler, form_inputs, download_btn) share_btn.click(share_handler, [picture, *form_inputs], None) # the picture leads so parse_or_raise can pull it off before assemble reads the rest return play_btn, [picture, *form_inputs]