""" You Are a Bug - HuggingFace Space app shell. CSV-backed roster UI, character sheet, ability selection, and chat scaffold. Run: python3 app/app.py """ import concurrent.futures import csv import html import json import os import re import tempfile import threading import gradio as gr import engine import roster APP_DIR = os.path.dirname(os.path.abspath(__file__)) ASSETS_DIR = os.path.join(APP_DIR, "test-assets") ROSTER_CSV = os.path.join(APP_DIR, "bug-classes-and-abilities.csv") STAT_KEYS = ["might", "speed", "smarts", "mystique"] MAX_LEVEL = 7 def slugify(value: str) -> str: slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") return slug or "bug" def parse_level_entry(raw: str) -> dict: text = (raw or "").strip() if ":" in text: name, description = text.split(":", 1) return { "kind": "ability", "name": name.strip(), "description": description.strip(), "text": text, } bonuses = [] for part in text.split(","): match = re.search(r"([+-]?\d+)\s+(Might|Speed|Smarts|Mystique)", part.strip(), re.I) if match: bonuses.append( { "amount": int(match.group(1)), "stat": match.group(2).lower(), } ) return { "kind": "stat", "name": "Stat Growth", "description": text, "bonuses": bonuses, "text": text, } def load_roster(csv_path: str) -> list[dict]: with open(csv_path, newline="", encoding="utf-8") as handle: rows = list(csv.reader(handle)) names = [cell.strip() for cell in rows[0][1:] if cell.strip()] bugs = [ { "id": slugify(name), "name": name, "description": "", "hidden_description": "", "hp": 0, "moxie": 0, "stats": {}, "levels": [], } for name in names ] row_map = {} for row in rows[1:]: if not row: continue key = row[0].strip().rstrip(":") row_map[key.lower()] = row[1:] for idx, bug in enumerate(bugs): bug["description"] = cell_at(row_map, "description", idx) bug["hidden_description"] = cell_at(row_map, "hidden_description", idx) bug["hp"] = int(cell_at(row_map, "hp", idx) or 0) bug["moxie"] = int(cell_at(row_map, "moxie", idx) or 0) bug["stats"] = { "might": int(cell_at(row_map, "might", idx) or 0), "speed": int(cell_at(row_map, "speed", idx) or 0), "smarts": int(cell_at(row_map, "smarts", idx) or 0), "mystique": int(cell_at(row_map, "mystique", idx) or 0), } bug["levels"] = [ {"level": level, **parse_level_entry(cell_at(row_map, f"level {level}", idx))} for level in range(1, MAX_LEVEL + 1) ] return bugs def cell_at(row_map: dict, key: str, idx: int) -> str: row = row_map.get(key.lower(), []) return row[idx].strip() if idx < len(row) else "" BUGS = load_roster(ROSTER_CSV) BUG_BY_ID = {bug["id"]: bug for bug in BUGS} DEFAULT_BUG_ID = BUG_BY_ID.get("stag-beetle", BUGS[0])["id"] def h(value) -> str: return html.escape(str(value), quote=True) def stat_value_after_levels(bug: dict, stat: str, current_level: int) -> int: value = bug["stats"].get(stat, 0) for row in bug["levels"]: if row["level"] > current_level or row["kind"] != "stat": continue for bonus in row.get("bonuses", []): if bonus["stat"] == stat: value += bonus["amount"] return value def stat_bonus_text(row: dict) -> str: bonuses = row.get("bonuses", []) if not bonuses: return h(row.get("text", "")) parts = [] for bonus in bonuses: sign = "+" if bonus["amount"] >= 0 else "" parts.append(f"{sign}{bonus['amount']} {bonus['stat'].capitalize()}") return ", ".join(parts) def resource_bar(label: str, value: int, max_value: int, accent: str) -> str: segs = "".join( f'
' for i in range(max_value) ) if max_value else "" return ( '
' f'{h(label)}' f'
{segs}
' f'{value} / {max_value}' "
" ) def stat_bar(label: str, value: int, max_value: int = 10) -> str: pct = max(0, min(100, int(value / max_value * 100))) if max_value else 0 return ( '
' f'{h(label)}' '
' f'
' "
" f'{value}' "
" ) def render_sheet_summary(bug: dict, current_level: int) -> str: stat_items = "".join( f'
{stat.capitalize()}{stat_value_after_levels(bug, stat, current_level)}
' for stat in STAT_KEYS ) progression = "".join( render_level_row(bug, idx, current_level) for idx in range(len(bug["levels"])) ) return ( '
' '
' f'

Level {current_level} Character Sheet

' f'

{h(bug["name"])}

' "
" f'

{h(bug["description"])}

' '
' '
' '

Resources

' '
' f'{resource_bar("HP", bug["hp"], bug["hp"], "var(--moss)")}' f'{resource_bar("Moxie", bug["moxie"], bug["moxie"], "var(--amber)")}' "
" "
" '
' '

Stats

' f'
{stat_items}
' "
" "
" '

Level Progression

' f'
{progression}
' "
" ) def render_ingame_header(bug: dict, sheet: dict) -> str: stats = sheet["stats"] stat_items = "".join( f'
{stat.capitalize()}{stats[stat]}
' for stat in STAT_KEYS ) return ( '
' '
' f'

Level {sheet["level"]}

' f'

{h(bug["name"])}

' "
" f'

{h(bug["description"])}

' '
' '
' '

Resources

' '
' f'{resource_bar("HP", sheet["hp"]["cur"], sheet["hp"]["max"], "var(--moss)")}' f'{resource_bar("Moxie", sheet["moxie"]["cur"], sheet["moxie"]["max"], "var(--amber)")}' "
" "
" '
' '

Stats

' f'
{stat_items}
' "
" "
" "
" ) def render_level_row(bug: dict, row_index: int, current_level: int) -> str: row = bug["levels"][row_index] is_active_level = row["level"] <= current_level locked_class = "" if is_active_level else " level-locked" kind_label = "Ability" if row["kind"] == "ability" else "Growth" if row["kind"] == "ability": body = ( f'{h(row["name"])}' f'{h(row["description"])}' ) else: body = f'{stat_bonus_text(row)}Permanent stat increase.' lock_text = "" if is_active_level else 'Upcoming' return ( f'
' '
' f'Level {row["level"]}' f'{kind_label}' f"{lock_text}" "
" f'
{body}
' "
" ) def active_ability_rows(bug: dict, current_level: int) -> list[dict]: return [ row for row in bug["levels"] if row["kind"] == "ability" and row["level"] <= current_level ] def render_active_ability_card(row: dict, active_ability: str | None) -> str: selected_class = " active-ability-selected" if row["name"] == active_ability else "" return ( f'
' '
' f'Level {row["level"]}' 'Ability' "
" '
' f'{h(row["name"])}' f'{h(row["description"])}' "
" "
" ) def render_turn_counter(turns_remaining: int) -> str: turn_word = "turn" if turns_remaining == 1 else "turns" return f'

{turns_remaining} {turn_word} remaining today

' def render_pending_line(active_ability: str | None) -> str: if not active_ability: return "" return ( '
' f"You will use {h(active_ability)} this turn. Describe what happens!" "
" ) def render_bug_buttons(selected_bug_id: str) -> list: return [ gr.update(elem_classes=["bug-btn-selected"] if bug["id"] == selected_bug_id else []) for bug in BUGS ] MAX_ACTIVE_ABILITY_SLOTS = max( sum(1 for row in bug["levels"] if row["kind"] == "ability") for bug in BUGS ) def ability_moxie_cost(name: str, sheet: dict | None) -> int: for ability in (sheet or {}).get("abilities", []): if ability["name"] == name: return ability["moxie_cost"] return roster._DEFAULT_MOXIE_COST def render_active_ability_outputs( bug: dict, current_level: int, active_ability: str | None, sheet: dict | None = None ) -> list: rows = active_ability_rows(bug, current_level) outputs = [] for idx in range(MAX_ACTIVE_ABILITY_SLOTS): if idx < len(rows): row = rows[idx] selected = row["name"] == active_ability cost = ability_moxie_cost(row["name"], sheet) affordable = sheet is None or sheet["moxie"]["cur"] >= cost outputs.append(gr.update(value=render_active_ability_card(row, active_ability), visible=True)) outputs.append( gr.update( value="Selected" if selected else f"Activate ({cost} Moxie)", visible=True, interactive=affordable or selected, elem_classes=["activate-btn", "activate-btn-active"] if selected else ["activate-btn"], ) ) else: outputs.append(gr.update(value="", visible=False)) outputs.append(gr.update(value="Activate", visible=False, interactive=False, elem_classes=["activate-btn"])) return outputs def select_bug(bug_id: str, current_level: int): bug = BUG_BY_ID[bug_id] active_ability = None return ( [bug, active_ability, render_sheet_summary(bug, current_level)] + render_active_ability_outputs(bug, current_level, active_ability) + [render_pending_line(active_ability)] + render_bug_buttons(bug_id) ) def make_select_bug_handler(bug_id: str): def handler(current_level: int): return select_bug(bug_id, current_level) return handler def make_activate_handler(slot_index: int): def handler(bug: dict, current_level: int, active_ability: str | None, game: dict | None): sheet = game["sheet"] if game else None rows = active_ability_rows(bug, current_level) if slot_index >= len(rows): new_active = active_ability else: ability_name = rows[slot_index]["name"] cost = ability_moxie_cost(ability_name, sheet) if active_ability == ability_name: new_active = None elif sheet is not None and sheet["moxie"]["cur"] < cost: new_active = active_ability # can't afford it; ignore the click else: new_active = ability_name return ( [new_active] + render_active_ability_outputs(bug, current_level, new_active, sheet) + [render_pending_line(new_active)] ) return handler def roster_id(bug: dict) -> str: """UI slug ('stag-beetle') -> roster id ('stag_beetle').""" return bug["id"].replace("-", "_") def next_world_seed(world_deck: list | None) -> tuple[dict, list]: """Pop the next pre-shuffled world state; reshuffle when the deck runs dry.""" if not world_deck: world_deck = engine.shuffled_world_deck() return world_deck[0], world_deck[1:] def infer_stream(card: str, loading: dict): """Run inference in a thread; yield ('loading', msg) every 2 s from the pre-shuffled loading deck, then ('done', parsed_output).""" executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) try: future = executor.submit(engine.infer, card) while True: msg = loading["deck"][loading["i"] % len(loading["deck"])] loading["i"] += 1 yield "loading", msg try: yield "done", future.result(timeout=2.0) return except concurrent.futures.TimeoutError: continue except Exception as exc: # noqa: BLE001 — degrade, never stall the game yield "done", { "action": "say", "text": f"(The forest mumbles incoherently. {exc})", } return finally: executor.shutdown(wait=False) def loading_message(msg: str) -> dict: return {"role": "assistant", "content": f"*{h(msg)}*"} def new_game_state(bug: dict, level: int, xp: int, world_deck: list | None): """Fresh day: full sheet at `level`, new world seed, empty log.""" sheet = roster.load_at_level(roster_id(bug), level) daily_context, world_deck = next_world_seed(world_deck) game = { "sheet": sheet, "daily_context": daily_context, "log": [], "trace": [], "xp": xp, "turn": 0, "dead": False, "day_over": False, } return game, world_deck def run_opening_turn(game: dict, history: list, loading: dict): """Shared turn-0 flow for game start and each new day. Yields (history, done) tuples; the final yield has the narration appended to both history and the game log. """ card = engine.opening_card(game["sheet"], game["daily_context"]) history = history + [loading_message("The forest stirs...")] yield history, False for kind, payload in infer_stream(card, loading): if kind == "loading": history = history[:-1] + [loading_message(payload)] yield history, False else: # A roll on turn 0 has no `text`; treat its success line as narration. text = ( payload.get("text") or payload.get("on_success") or "Something went wrong in the forest. Nature is like that." ) history = history[:-1] + [{"role": "assistant", "content": text}] game["log"].append({"kind": "dm", "text": text}) game["trace"].append({"card": card, "response": json.dumps(payload)}) yield history, True def start_game(bug: dict, world_deck, loading): if not loading: loading = {"deck": engine.shuffled_loading_strings(), "i": 0} game, world_deck = new_game_state(bug, 1, 0, world_deck) def ui(history, busy: bool): turns_left = engine.TURNS_PER_DAY - game["turn"] return tuple( [ True, history, gr.update( interactive=not busy, placeholder="The forest is thinking..." if busy else "Describe your action...", value="", ), gr.update(interactive=not busy), gr.update(visible=False), # control_surface_col gr.update(visible=False), # sheet_summary gr.update(visible=False), # start_btn gr.update(visible=True), # quit_btn gr.update(value=render_ingame_header(bug, game["sheet"]), visible=True), gr.update(value=render_turn_counter(turns_left), visible=True), gr.update(visible=True), # chat_panel_col gr.update(visible=True), # active_abilities_col game, world_deck, loading, gr.update(visible=True), # export_row ] + render_active_ability_outputs(bug, game["sheet"]["level"], None, game["sheet"]) ) history: list = [] for history, done in run_opening_turn(game, history, loading): yield ui(history, busy=not done) def quit_game(): return tuple( [ False, [{"role": "assistant", "content": "Choose a bug, review your sheet, then start the game."}], gr.update(interactive=False, placeholder="Start the game before taking a turn..."), gr.update(interactive=False), gr.update(visible=True), # control_surface_col gr.update(visible=True), # sheet_summary gr.update(visible=True), # start_btn gr.update(visible=False), # quit_btn gr.update(value="", visible=False), # ingame_header gr.update(value="", visible=False), # turn_counter gr.update(visible=False), # chat_panel_col gr.update(visible=False), # active_abilities_col None, # game gr.update(), # world_deck (keep the shuffled deck) gr.update(), # loading gr.update(visible=False), # export_row ] + [gr.update() for _ in range(MAX_ACTIVE_ABILITY_SLOTS * 2)] ) def roll_tag(stat: str, band: str, result: str) -> str: outcome = "success!" if result == "success" else "failed." damage = engine.BAND_DAMAGE[band] if result == "fail" else 0 hp_note = f" -{damage} HP" if damage else "" return f"*{stat.capitalize()} check, {band.capitalize()} — {outcome}{hp_note}*" def confirm_turn( player_text: str, history: list, bug: dict, current_level: int, active_ability: str | None, game_started: bool, game: dict | None, world_deck, loading, ): """Resolve one turn against the model. Generator: streams loading lines.""" text = (player_text or "").strip() history = list(history or []) def ui(busy: bool, level: int, ability, pending: str | None = None): sheet = game["sheet"] turns_left = max(0, engine.TURNS_PER_DAY - game["turn"]) if game["dead"]: input_upd = gr.update( value="", interactive=False, placeholder="You have died. Quit & Return to begin anew.", ) confirm_upd = gr.update(interactive=False) elif game["day_over"]: input_upd = gr.update( value="", interactive=False, placeholder="Your day is over. Confirm to begin a new one...", ) confirm_upd = gr.update(interactive=True) elif busy: input_upd = gr.update(interactive=False, placeholder="The forest is thinking...") confirm_upd = gr.update(interactive=False) else: input_upd = gr.update(value="", interactive=True, placeholder="Describe your action...") confirm_upd = gr.update(interactive=True) header = render_ingame_header(bug, sheet) counter = render_turn_counter(turns_left) line = render_pending_line(ability) if pending is None else pending return ( [history, input_upd, confirm_upd, ability] + render_active_ability_outputs(bug, level, ability, sheet) + [line, gr.update(value=header), gr.update(value=counter), game, world_deck, loading, level] ) def noop(): sheet = game["sheet"] if game else None return ( [history, gr.update(), gr.update(), active_ability] + render_active_ability_outputs(bug, current_level, active_ability, sheet) + [render_pending_line(active_ability), gr.update(), gr.update(), game, world_deck, loading, current_level] ) if not game_started or game is None: history.append({"role": "assistant", "content": "Press Start Game before taking your first turn."}) yield noop() return if game["dead"]: yield noop() return # --- Day rollover: Confirm doubles as the Continue button. --------------- if game["day_over"]: level = game["sheet"]["level"] new_game, world_deck = new_game_state(bug, level, game["xp"], world_deck) game.update(new_game) history = [] # bugs have short memories: each day opens on a clean chat yield ui(busy=True, level=level, ability=None) for history, done in run_opening_turn(game, history, loading): yield ui(busy=not done, level=level, ability=None) return if not text and not active_ability: yield noop() return sheet = game["sheet"] # --- Ability activation: debit Moxie, pass the trained activation line. -- activations = [] if active_ability: ability = next( (a for a in sheet.get("abilities", []) if a["name"] == active_ability), None ) if ability is None: active_ability = None elif sheet["moxie"]["cur"] < ability["moxie_cost"]: history.append({ "role": "assistant", "content": f"*Not enough Moxie for {h(active_ability)}. The urge passes.*", }) yield ui(busy=False, level=current_level, ability=None) return else: activations = [active_ability] # Card shows pre-turn state (training parity: damage and Moxie apply after). card = engine.turn_card(sheet, game["daily_context"], game["log"], text, activations) if os.environ.get("BUG_DEBUG_CARD"): print("=" * 60, "\n", card, "\n", "=" * 60, flush=True) if activations: ability = next(a for a in sheet["abilities"] if a["name"] == activations[0]) sheet["moxie"]["cur"] -= ability["moxie_cost"] shown_text = text if text else f"({active_ability})" history.append({"role": "user", "content": shown_text}) history.append(loading_message("The forest stirs...")) yield ui(busy=True, level=current_level, ability=active_ability, pending="") output = None for kind, payload in infer_stream(card, loading): if kind == "loading": history[-1] = loading_message(payload) yield ui(busy=True, level=current_level, ability=active_ability, pending="") else: output = payload game["trace"].append({"card": card, "response": json.dumps(output)}) # --- Log the player's side of the turn (exploder order). ----------------- game["log"].append({"kind": "player", "text": text}) for name in activations: game["log"].append({"kind": "player_ability", "ability": name}) # --- Resolve the model's action. ------------------------------------------ new_level = current_level if output["action"] == "roll": stat, band = output["stat"], output["difficulty"] result = engine.resolve_roll(sheet["stats"][stat], band) narration = output["on_success"] if result == "success" else output["on_fail"] history[-1] = { "role": "assistant", "content": f"{roll_tag(stat, band, result)}\n\n{narration}", } game["log"].append( {"kind": "dm_roll", "stat": stat, "band": band, "result": result, "text": narration} ) if result == "fail": game["sheet"] = engine.apply_damage(sheet, band) if game["sheet"]["hp"]["cur"] <= 0: game["dead"] = True history.append({"role": "assistant", "content": engine.DEATH_MESSAGE}) else: game["xp"] += engine.BAND_XP[band] leveled, announcements = engine.maybe_level_up(sheet, game["xp"]) game["sheet"] = leveled new_level = leveled["level"] for line in announcements: history.append({"role": "assistant", "content": f"**{h(line)}**"}) else: history[-1] = {"role": "assistant", "content": output["text"]} game["log"].append({"kind": "dm", "text": output["text"]}) # --- Advance the day clock. ------------------------------------------------ game["turn"] += 1 if not game["dead"] and game["turn"] >= engine.TURNS_PER_DAY: game["day_over"] = True history.append({"role": "assistant", "content": engine.TORPOR_MESSAGE}) yield ui(busy=False, level=new_level, ability=None) def export_trace(game: dict | None): if not game or not game.get("trace"): return gr.update(value=None, visible=False) lines = [ json.dumps({ "messages": [ {"role": "system", "content": engine.SYSTEM_PROMPT}, {"role": "user", "content": entry["card"]}, {"role": "assistant", "content": entry["response"]}, ] }) for entry in game["trace"] ] tmp = tempfile.NamedTemporaryFile( mode="w", suffix=".jsonl", delete=False, encoding="utf-8" ) tmp.write("\n".join(lines)) tmp.close() return gr.update(value=tmp.name, visible=True) TITLE_HTML = """

You Are a Bug.

""" INTRO_HTML = """

Failure is fun. Constraints are enabling. Small is huge.
You are a bug.

Choose your character:

""" CSS = """ /* Belt-and-braces font load: the link may not be injected inside the HF Spaces iframe, but the CSS payload always is. */ @import url('https://fonts.googleapis.com/css2?family=Gluten:wght@400;700&family=Dongle:wght@300;400;700&family=Varela+Round&family=DM+Mono:ital,wght@0,400;0,500;1,400&display=swap'); :root { --bg-void: #0d0b08; --ink: #1c180f; --parchment: #e8dfc8; --parchment-dark: #c9bfa6; --moss: #4a5e3a; --moss-dark: #203018; --moss-deep: rgba(18,35,20,0.84); --rust: #8b3a2a; --amber: #c47c1a; --dust: rgba(232,223,200,0.07); --shadow: rgba(13,11,8,0.6); --font-hero: 'Gluten', cursive; --font-d: 'Dongle', sans-serif; --font-body: 'Varela Round', sans-serif; --font-m: 'DM Mono', 'Courier Prime', monospace; /* Organic blob radii: four unequal corners, unequal h/v per corner, so surfaces read hand-cut rather than geometric. Two variants so adjacent elements don't visibly share a shape. */ --blob-lg: 1.7rem 0.7rem 1.5rem 0.8rem / 0.8rem 1.6rem 0.7rem 1.7rem; --blob-md: 1.1rem 0.5rem 1.2rem 0.45rem / 0.5rem 1.1rem 0.45rem 1.2rem; --blob-btn: 0.95rem 0.45rem 1.05rem 0.4rem / 0.45rem 0.95rem 0.4rem 1.05rem; } html, body { background: var(--bg-void) !important; margin: 0; padding: 0; } gradio-app, gradio-app > div { background: transparent !important; } .gradio-container { background: transparent !important; position: relative; z-index: 1; min-height: 100vh; } .gradio-container > div, .gradio-container .wrap, .gradio-container .contain, .gradio-container section, .gradio-container .block, .gradio-container .panel, .gradio-container form, .gradio-container fieldset { background: transparent !important; border: none !important; box-shadow: none !important; } footer, .gradio-container footer { display: none !important; } #app-shell { max-width: 1080px; margin: 0 auto; padding: 0 1rem 2.5rem; } #title-inner { text-align: center; padding: 3rem 1rem 0.95rem; } #title-inner h1 { font-family: var(--font-hero); font-size: clamp(2.4rem, 5vw, 4.4rem); color: var(--moss-dark); text-shadow: 0 1px 0 rgba(232,223,200,0.35), 0 2px 12px rgba(232,223,200,0.22); margin: 0; letter-spacing: 0; } /* Wavy panel edges: the surface itself stays transparent; its skin lives on a ::before layer so the displacement filter waves the border without warping the text on top. */ .surface-panel, #control-surface, #chat-panel, #active-abilities-panel { position: relative !important; z-index: 0 !important; background: transparent !important; border: none !important; color: var(--parchment); } .surface-panel::before, #control-surface::before, #chat-panel::before, #active-abilities-panel::before, .ingame-header::before { content: ''; position: absolute; inset: 0; z-index: -1; background: var(--moss-deep); border: 1px solid rgba(201,191,166,0.18); border-radius: var(--blob-lg); box-shadow: 0 18px 50px rgba(13,11,8,0.18); filter: url(#squiggle-panel); } #control-surface { padding: 1.15rem 1.25rem !important; margin-bottom: 0.85rem; } #intro-copy p { font-family: var(--font-body); color: var(--parchment); font-size: 1.2rem; line-height: 1.75; margin: 0 auto 0.95rem; max-width: 48ch; text-align: center; } .choose-label { font-family: var(--font-d); font-size: 2rem; color: var(--parchment); text-align: left; text-transform: none; letter-spacing: 0; margin: 0 0 0.5rem; line-height: 1.1; } #bug-btn-row { display: grid !important; grid-template-columns: repeat(3, 1fr) !important; gap: 0.5rem !important; padding: 0 !important; } #bug-btn-row > div { width: 100% !important; flex: none !important; min-width: 0 !important; } /* Button skins live on ::before — kept as a layer so per-state styling stays separable from the label. */ #bug-btn-row button { width: 100% !important; position: relative !important; z-index: 0; background: transparent !important; color: var(--parchment) !important; border: none !important; font-family: var(--font-body) !important; font-size: 0.84rem !important; letter-spacing: 0.04em !important; text-transform: uppercase; padding: 0.55rem 0.5rem !important; } #bug-btn-row button::before { content: ''; position: absolute; inset: 0; z-index: -1; background: rgba(232,223,200,0.13); border: 1px solid rgba(232,223,200,0.28); border-radius: var(--blob-btn); } #bug-btn-row button:hover { color: #fff9ea !important; } #bug-btn-row button:hover::before { background: rgba(232,223,200,0.24); } #bug-btn-row .bug-btn-selected { color: var(--bg-void) !important; } #bug-btn-row .bug-btn-selected::before { background: var(--amber); border-color: var(--amber); box-shadow: 0 0 10px rgba(196,124,26,0.45); } #main-row { align-items: flex-start !important; } #chat-col { gap: 0.85rem !important; } .sheet-body, #chat-panel, #active-abilities-panel { padding: 1.2rem 1.3rem; } .eyebrow, .sec-label { font-family: var(--font-m); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.13em; color: var(--parchment); margin: 0 0 0.55rem; } .sec-label { border-bottom: 1px solid rgba(201,191,166,0.18); padding-bottom: 0.3rem; } .sheet-name { font-family: var(--font-d), 'Dongle', sans-serif !important; color: var(--parchment); font-size: 2.4rem !important; line-height: 1.1; margin: 0 0 0.15rem; letter-spacing: 0; } .sheet-desc { font-family: var(--font-body); color: var(--parchment-dark); font-size: 1.2rem; line-height: 1.65; margin: 0.2rem 0 1rem; } .sheet-head { padding-bottom: 0; margin-bottom: 0; } .sheet-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 1.4rem; margin-bottom: 1rem; } .res-wrap { margin-bottom: 0; } .stat-row, .res-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; } .res-label { font-family: var(--font-body); font-size: 1.05rem; color: var(--parchment); min-width: 3.2rem; } .res-segs { flex: 1; display: flex; gap: 2px; align-items: center; } .res-seg { flex: 1; min-width: 4px; height: 9px; border-radius: 1px; } .res-seg.empty { background: rgba(201,191,166,0.15); } .res-frac { font-family: var(--font-body); color: var(--parchment); text-align: right; font-size: 1.05rem; min-width: 3.5rem; } .stat-list { display: grid; grid-template-columns: max-content auto; column-gap: 0.75rem; } .stat-item { display: contents; } .stat-name, .stat-val { border-bottom: 1px solid rgba(201,191,166,0.12); padding: 0.38rem 0; } .stat-name { font-family: var(--font-body); font-size: 1.05rem; color: var(--parchment-dark); } .stat-val { font-family: var(--font-body); font-size: 1.15rem; color: var(--parchment); font-weight: 600; text-align: right; } .progression-label { margin-top: 1.1rem; } .progression-list, .active-ability-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.55rem; } .level-card { height: 100%; min-height: 5.35rem; background: rgba(232,223,200,0.07); border: 1px solid rgba(201,191,166,0.12); border-left: 3px solid var(--moss); border-radius: var(--blob-md); padding: 0.58rem 0.65rem; } .active-ability-card { flex: 1; min-width: 0; } .active-ability-selected { background: rgba(196,124,26,0.12) !important; border-color: rgba(196,124,26,0.34) !important; border-left-color: var(--amber) !important; } .level-locked { border-left-color: rgba(201,191,166,0.22); } .level-meta { display: flex; flex-wrap: wrap; gap: 0.35rem; font-family: var(--font-m); color: var(--parchment-dark); font-size: 0.76rem; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 0.3rem; } .lock-note { color: var(--parchment-dark); margin-left: auto; } .level-body strong { display: block; color: var(--parchment); font-family: var(--font-d); font-size: 1.75rem; margin-bottom: 0.1rem; line-height: 1.1; } .level-body span { color: var(--parchment-dark); font-family: var(--font-body); font-size: 0.92rem; line-height: 1.45; } .active-ability-row { align-items: center !important; gap: 0 !important; margin-bottom: 0.45rem; background: rgba(232,223,200,0.07) !important; border: 1px solid rgba(201,191,166,0.12) !important; border-left: 3px solid var(--moss) !important; border-radius: var(--blob-md) !important; padding: 0.58rem 0.65rem !important; min-height: 5.35rem; } .active-ability-row:last-child { margin-bottom: 0; } .activate-btn { align-self: center; flex-grow: 0 !important; flex-shrink: 0; position: relative !important; z-index: 0; background: transparent !important; color: var(--moss) !important; border: none !important; font-family: var(--font-body) !important; font-size: 0.9rem !important; letter-spacing: 0.02em; min-width: 8.5rem !important; width: auto !important; padding: 0.4rem 0.55rem !important; margin-left: 0.65rem !important; } .activate-btn::before { content: ''; position: absolute; inset: 0; z-index: -1; background: var(--parchment); border: 1px solid rgba(74,94,58,0.4); border-radius: var(--blob-btn); } .activate-btn:hover:not(:disabled) { color: var(--parchment) !important; } .activate-btn:hover:not(:disabled)::before { background: var(--moss); } .activate-btn:disabled { opacity: 0.35 !important; cursor: not-allowed !important; } .activate-btn-active { color: var(--bg-void) !important; } .activate-btn-active::before { background: var(--amber); border-color: var(--amber); } #confirm-btn { background: transparent !important; color: var(--parchment) !important; border: none !important; font-family: var(--font-body) !important; font-size: 0.95rem !important; letter-spacing: 0.04em !important; } #start-btn-row { justify-content: center !important; margin: 0.85rem 0 !important; padding: 0 !important; gap: 0 !important; flex-grow: 0 !important; } #start-btn-row > * { flex-grow: 0 !important; flex-basis: auto !important; width: auto !important; min-width: 0 !important; } #start-btn, #start-btn > button, button#start-btn { background: transparent !important; color: var(--parchment) !important; border: none !important; font-family: var(--font-body) !important; font-size: 1.15rem !important; letter-spacing: 0.04em !important; padding: 1rem 2.5rem !important; width: auto !important; min-width: 0 !important; flex-grow: 0 !important; } /* Shared moss-button skin layer (wavy edges, crisp labels). */ #confirm-btn, #start-btn, #quit-btn, #export-btn, #start-btn > button, #quit-btn > button, #export-btn > button { position: relative !important; z-index: 0; } #confirm-btn::before, #start-btn::before, #quit-btn::before, #export-btn::before, #start-btn > button::before, #quit-btn > button::before, #export-btn > button::before { content: ''; position: absolute; inset: 0; z-index: -1; background: var(--moss); border-radius: var(--blob-btn); } #confirm-btn:hover::before, #start-btn:hover::before, #quit-btn:hover::before, #export-btn:hover::before, #start-btn > button:hover::before, #quit-btn > button:hover::before, #export-btn > button:hover::before { background: #3a4d2d; } #quit-btn { justify-content: center !important; flex-grow: 0 !important; width: auto !important; margin: 0.85rem auto !important; } #quit-btn.hide { display: none !important; } #quit-btn, #quit-btn > button, button#quit-btn { background: transparent !important; color: var(--parchment) !important; border: none !important; font-family: var(--font-body) !important; font-size: 1.15rem !important; letter-spacing: 0.04em !important; padding: 1rem 1.6rem !important; width: auto !important; min-width: 0 !important; flex-grow: 0 !important; } #export-row { justify-content: center !important; margin: 0.85rem auto !important; gap: 0 !important; flex-grow: 0 !important; } #export-row > * { flex-grow: 0 !important; flex-basis: auto !important; width: auto !important; min-width: 0 !important; } #export-btn, #export-btn > button, button#export-btn { background: transparent !important; color: var(--parchment) !important; border: none !important; font-family: var(--font-body) !important; font-size: 1.15rem !important; letter-spacing: 0.04em !important; padding: 1rem 1.6rem !important; width: auto !important; min-width: 0 !important; flex-grow: 0 !important; } #game-chat { background: rgba(13,11,8,0.55) !important; border: 1px solid rgba(201,191,166,0.12) !important; border-radius: var(--blob-md) !important; } #game-chat .icon-button-wrapper { display: none !important; } #game-chat .message-wrap, #game-chat .message { font-family: var(--font-body) !important; font-size: 1.0rem !important; } #game-chat .bot { background: var(--parchment) !important; border: none !important; border-radius: 1rem 0.55rem 0.95rem 0.3rem / 0.6rem 1rem 0.35rem 0.9rem !important; } #game-chat .bot, #game-chat .bot * { color: var(--ink) !important; line-height: 1.65; } #game-chat .user { background: var(--bg-void) !important; border: 1px solid rgba(201,191,166,0.12) !important; border-radius: 0.55rem 1rem 0.3rem 0.95rem / 1rem 0.6rem 0.9rem 0.35rem !important; } #game-chat .user, #game-chat .user * { color: var(--parchment) !important; } #input-row { align-items: flex-end !important; gap: 0.5rem !important; margin-top: 0.65rem; } #player-input label > span:first-child, #player-input [data-testid="block-info"] { display: none !important; } #player-input textarea, #player-input input { background: rgba(13,11,8,0.82) !important; color: var(--parchment) !important; border: 1px solid rgba(201,191,166,0.18) !important; border-radius: var(--blob-md) !important; font-family: var(--font-body) !important; font-size: 1.05rem !important; line-height: 1.5; } #player-input textarea::placeholder, #player-input input::placeholder { color: rgba(201,191,166,0.55) !important; } #player-input textarea:focus, #player-input input:focus { border-color: var(--moss) !important; outline: none !important; box-shadow: none !important; } .turn-counter { font-family: var(--font-m); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.13em; color: var(--parchment-dark); text-align: right; margin: 0.45rem 0 0; } .turn-prompt { border-left: 4px solid var(--amber); background: rgba(196,124,26,0.1); color: var(--parchment); font-family: var(--font-body); font-size: 1.05rem; line-height: 1.5; padding: 0.62rem 0.85rem; margin: 0.55rem 0 0; border-radius: 0 0.9rem 0.8rem 0 / 0 0.7rem 1rem 0; } #active-abilities-panel .sec-label { margin-bottom: 0.75rem; } .ingame-header { position: relative; z-index: 0; padding: 1.2rem 1.3rem; margin-bottom: 0; } @media (max-width: 840px) { .sheet-grid, .progression-list, .active-ability-list { grid-template-columns: 1fr; } #chat-col { min-width: 100% !important; } #input-row { flex-direction: column !important; align-items: stretch !important; } } """ def build_bg_script(assets_dir: str) -> str: return f""" function injectBgLayers() {{ if (!document.getElementById('squiggle-defs')) {{ // SVG displacement filter for the panel ::before skin layers: waves the // surface edges hand-drawn-style without touching the content on top. const defs = document.createElement('div'); defs.id = 'squiggle-defs'; defs.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden;'; defs.innerHTML = ''; document.body.appendChild(defs); }} if (document.getElementById('bg-layers')) return; const A = '/gradio_api/file={assets_dir}'; const bg = document.createElement('div'); bg.id = 'bg-layers'; bg.style.cssText = 'position:fixed;inset:0;z-index:0;pointer-events:none;overflow:hidden;'; const base = document.createElement('div'); base.style.cssText = 'position:absolute;inset:0;opacity:1;' + 'background-image:url(' + A + '/tile-background.png);' + 'background-repeat:repeat;background-size:auto;'; bg.appendChild(base); [ {{ id:'bg-mid', file:'flowers-and-grasses-3-scaled.png', dur:'120s', opacity:'0.8' }}, {{ id:'bg-near', file:'peonies-2-scaled.png', dur:'70s', opacity:'0.7' }}, {{ id:'bg-fore', file:'procession-bugs-1-scaled.png', dur:'40s', opacity:'0.55' }} ].forEach(function(cfg) {{ const div = document.createElement('div'); div.id = cfg.id; div.style.cssText = 'position:absolute;inset:0;opacity:' + cfg.opacity + ';'; bg.appendChild(div); const url = A + '/' + cfg.file; const img = new Image(); img.onload = function() {{ const scaledW = Math.ceil(window.innerHeight * this.naturalWidth / this.naturalHeight); div.style.backgroundImage = 'url(' + url + ')'; div.style.backgroundRepeat = 'repeat-x'; div.style.backgroundSize = scaledW + 'px 100%'; div.style.backgroundPosition = '0 center'; const kf = 'drift_' + cfg.id; const style = document.createElement('style'); style.textContent = '@keyframes ' + kf + ' {{ from {{ background-position-x: 0px }} to {{ background-position-x: -' + scaledW + 'px }} }}'; document.head.appendChild(style); div.style.animation = kf + ' ' + cfg.dur + ' linear infinite'; }}; img.src = url; }}); document.body.insertBefore(bg, document.body.firstChild); }} if (document.readyState === 'loading') {{ document.addEventListener('DOMContentLoaded', injectBgLayers); }} else {{ injectBgLayers(); }} """ BG_SCRIPT = build_bg_script(ASSETS_DIR) JS_ON_LOAD = "() => { " + BG_SCRIPT + " }" HEAD = f""" """ default_bug = BUG_BY_ID[DEFAULT_BUG_ID] default_level = 1 with gr.Blocks(title="You Are a Bug") as demo: selected_bug = gr.State(default_bug) current_level = gr.State(default_level) active_ability = gr.State(None) game_started = gr.State(False) game = gr.State(None) world_deck = gr.State(None) # world states, shuffled once per session loading = gr.State(None) # loading strings deck + cycle index with gr.Column(elem_id="app-shell"): gr.HTML(TITLE_HTML, elem_id="title-block") with gr.Column(elem_id="control-surface") as control_surface_col: gr.HTML(INTRO_HTML, elem_id="intro-block") with gr.Row(elem_id="bug-btn-row"): bug_btns = [ gr.Button( bug["name"], elem_id=f"btn-{bug['id']}", elem_classes=["bug-btn-selected"] if bug["id"] == DEFAULT_BUG_ID else [], ) for bug in BUGS ] with gr.Row(elem_id="main-row"): with gr.Column(scale=1, min_width=420, elem_id="chat-col"): sheet_summary = gr.HTML(render_sheet_summary(default_bug, default_level)) with gr.Row(elem_id="start-btn-row"): start_btn = gr.Button("Start Game", elem_id="start-btn") quit_btn = gr.Button("Quit & Return", elem_id="quit-btn", visible=False) ingame_header = gr.HTML("", visible=False) with gr.Column(elem_id="chat-panel", visible=False) as chat_panel_col: chatbot = gr.Chatbot( value=[ { "role": "assistant", "content": "Choose a bug, review your sheet, then start the game.", } ], elem_id="game-chat", show_label=False, height=510, buttons=[], group_consecutive_messages=False, ) with gr.Row(elem_id="input-row"): player_input = gr.Textbox( placeholder="Start the game before taking a turn...", show_label=False, elem_id="player-input", lines=2, scale=5, interactive=False, max_length=300, ) confirm_btn = gr.Button( "Confirm Turn", elem_id="confirm-btn", scale=1, interactive=False, ) turn_counter = gr.HTML("", elem_id="turn-counter", visible=False) pending_line = gr.HTML("", elem_id="pending-line") with gr.Column(elem_id="active-abilities-panel", visible=False) as active_abilities_col: gr.HTML('

Active Abilities

') active_components = [] for idx in range(MAX_ACTIVE_ABILITY_SLOTS): rows = active_ability_rows(default_bug, default_level) visible = idx < len(rows) with gr.Row(elem_classes=["active-ability-row"]): row_html = gr.HTML( render_active_ability_card(rows[idx], None) if visible else "", elem_id=f"active-ability-{idx + 1}", scale=5, ) row_button = gr.Button( "Activate", visible=visible, interactive=visible, elem_id=f"ability-activate-{idx + 1}", elem_classes=["activate-btn"], scale=0, min_width=0, ) active_components.extend([row_html, row_button]) with gr.Row(elem_id="export-row", visible=False) as export_row: export_btn = gr.Button("Export Trace", elem_id="export-btn") export_file = gr.File(label=None, visible=False) select_outputs = ( [selected_bug, active_ability, sheet_summary] + active_components + [pending_line] + bug_btns ) for bug in BUGS: bug_btns[BUGS.index(bug)].click( fn=make_select_bug_handler(bug["id"]), inputs=[current_level], outputs=select_outputs, show_progress="hidden", ) activate_outputs = [active_ability] + active_components + [pending_line] for idx, component in enumerate(active_components): if idx % 2 == 1: slot_idx = idx // 2 component.click( fn=make_activate_handler(slot_idx), inputs=[selected_bug, current_level, active_ability, game], outputs=activate_outputs, show_progress="hidden", ) toggle_outputs = [ game_started, chatbot, player_input, confirm_btn, control_surface_col, sheet_summary, start_btn, quit_btn, ingame_header, turn_counter, chat_panel_col, active_abilities_col, game, world_deck, loading, export_row, ] + active_components export_btn.click(fn=export_trace, inputs=[game], outputs=[export_file]) start_btn.click( fn=start_game, inputs=[selected_bug, world_deck, loading], outputs=toggle_outputs, show_progress="hidden", ) quit_btn.click( fn=quit_game, inputs=[], outputs=toggle_outputs, show_progress="hidden", ) turn_inputs = [ player_input, chatbot, selected_bug, current_level, active_ability, game_started, game, world_deck, loading, ] turn_outputs = ( [chatbot, player_input, confirm_btn, active_ability] + active_components + [pending_line, ingame_header, turn_counter, game, world_deck, loading, current_level] ) confirm_btn.click( fn=confirm_turn, inputs=turn_inputs, outputs=turn_outputs, show_progress="hidden", ) player_input.submit( fn=confirm_turn, inputs=turn_inputs, outputs=turn_outputs, show_progress="hidden", ) if __name__ == "__main__": # Warm the model in the background so the first Start Game isn't a cold load. threading.Thread(target=engine.warmup, daemon=True).start() demo.launch( css=CSS, js=JS_ON_LOAD, head=HEAD, allowed_paths=[ASSETS_DIR], )