read_the_room / creatorview.py
Ilia-Iliev's picture
Upload folder using huggingface_hub
9ff1b35 verified
Raw
History Blame Contribute Delete
15.1 kB
"""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]