Spaces:
Running on Zero
Running on Zero
| """ | |
| 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'<div class="res-seg {"filled" if i < value else "empty"}" style="{f"background:{accent}" if i < value else ""}"></div>' | |
| for i in range(max_value) | |
| ) if max_value else "" | |
| return ( | |
| '<div class="res-row">' | |
| f'<span class="res-label">{h(label)}</span>' | |
| f'<div class="res-segs">{segs}</div>' | |
| f'<span class="res-frac">{value} / {max_value}</span>' | |
| "</div>" | |
| ) | |
| 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 ( | |
| '<div class="stat-row">' | |
| f'<span class="stat-label">{h(label)}</span>' | |
| '<div class="stat-bg">' | |
| f'<div class="stat-fill" style="width:{pct}%;"></div>' | |
| "</div>" | |
| f'<span class="stat-val">{value}</span>' | |
| "</div>" | |
| ) | |
| def render_sheet_summary(bug: dict, current_level: int) -> str: | |
| stat_items = "".join( | |
| f'<div class="stat-item"><span class="stat-name">{stat.capitalize()}</span><span class="stat-val">{stat_value_after_levels(bug, stat, current_level)}</span></div>' | |
| for stat in STAT_KEYS | |
| ) | |
| progression = "".join( | |
| render_level_row(bug, idx, current_level) | |
| for idx in range(len(bug["levels"])) | |
| ) | |
| return ( | |
| '<div class="sheet-body surface-panel">' | |
| '<div class="sheet-head">' | |
| f'<p class="eyebrow">Level {current_level} Character Sheet</p>' | |
| f'<h2 class="sheet-name">{h(bug["name"])}</h2>' | |
| "</div>" | |
| f'<p class="sheet-desc"><em>{h(bug["description"])}</em></p>' | |
| '<div class="sheet-grid">' | |
| '<div class="sheet-resources">' | |
| '<p class="sec-label">Resources</p>' | |
| '<div class="res-wrap">' | |
| f'{resource_bar("HP", bug["hp"], bug["hp"], "var(--moss)")}' | |
| f'{resource_bar("Moxie", bug["moxie"], bug["moxie"], "var(--amber)")}' | |
| "</div>" | |
| "</div>" | |
| '<div class="sheet-stats">' | |
| '<p class="sec-label">Stats</p>' | |
| f'<div class="stat-list">{stat_items}</div>' | |
| "</div>" | |
| "</div>" | |
| '<p class="sec-label progression-label">Level Progression</p>' | |
| f'<div class="progression-list">{progression}</div>' | |
| "</div>" | |
| ) | |
| def render_ingame_header(bug: dict, sheet: dict) -> str: | |
| stats = sheet["stats"] | |
| stat_items = "".join( | |
| f'<div class="stat-item"><span class="stat-name">{stat.capitalize()}</span><span class="stat-val">{stats[stat]}</span></div>' | |
| for stat in STAT_KEYS | |
| ) | |
| return ( | |
| '<div class="ingame-header">' | |
| '<div class="sheet-head">' | |
| f'<p class="eyebrow">Level {sheet["level"]}</p>' | |
| f'<h2 class="sheet-name">{h(bug["name"])}</h2>' | |
| "</div>" | |
| f'<p class="sheet-desc"><em>{h(bug["description"])}</em></p>' | |
| '<div class="sheet-grid">' | |
| '<div class="sheet-resources">' | |
| '<p class="sec-label">Resources</p>' | |
| '<div class="res-wrap">' | |
| f'{resource_bar("HP", sheet["hp"]["cur"], sheet["hp"]["max"], "var(--moss)")}' | |
| f'{resource_bar("Moxie", sheet["moxie"]["cur"], sheet["moxie"]["max"], "var(--amber)")}' | |
| "</div>" | |
| "</div>" | |
| '<div class="sheet-stats">' | |
| '<p class="sec-label">Stats</p>' | |
| f'<div class="stat-list">{stat_items}</div>' | |
| "</div>" | |
| "</div>" | |
| "</div>" | |
| ) | |
| 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'<strong>{h(row["name"])}</strong>' | |
| f'<span>{h(row["description"])}</span>' | |
| ) | |
| else: | |
| body = f'<strong>{stat_bonus_text(row)}</strong><span>Permanent stat increase.</span>' | |
| lock_text = "" if is_active_level else '<span class="lock-note">Upcoming</span>' | |
| return ( | |
| f'<div class="level-card{locked_class}">' | |
| '<div class="level-meta">' | |
| f'<span>Level {row["level"]}</span>' | |
| f'<span>{kind_label}</span>' | |
| f"{lock_text}" | |
| "</div>" | |
| f'<div class="level-body">{body}</div>' | |
| "</div>" | |
| ) | |
| 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'<div class="active-ability-card{selected_class}">' | |
| '<div class="level-meta">' | |
| f'<span>Level {row["level"]}</span>' | |
| '<span>Ability</span>' | |
| "</div>" | |
| '<div class="level-body">' | |
| f'<strong>{h(row["name"])}</strong>' | |
| f'<span>{h(row["description"])}</span>' | |
| "</div>" | |
| "</div>" | |
| ) | |
| def render_turn_counter(turns_remaining: int) -> str: | |
| turn_word = "turn" if turns_remaining == 1 else "turns" | |
| return f'<p class="turn-counter">{turns_remaining} {turn_word} remaining today</p>' | |
| def render_pending_line(active_ability: str | None) -> str: | |
| if not active_ability: | |
| return "" | |
| return ( | |
| '<div class="turn-prompt">' | |
| f"You will use {h(active_ability)} this turn. Describe what happens!" | |
| "</div>" | |
| ) | |
| 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 = """ | |
| <div id="title-inner"> | |
| <h1>You Are a Bug.</h1> | |
| </div> | |
| """ | |
| INTRO_HTML = """ | |
| <div id="intro-copy"> | |
| <p>Failure is fun. Constraints are enabling. Small is huge.<br> | |
| You are a bug.</p> | |
| </div> | |
| <p class="choose-label">Choose your character:</p> | |
| """ | |
| CSS = """ | |
| /* Belt-and-braces font load: the <head> 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 = | |
| '<svg width="0" height="0" aria-hidden="true"><defs>' + | |
| // Panels: low frequency = long slow waves along the edges; the larger | |
| // scale is safe because this only ever filters the ::before skin layer. | |
| '<filter id="squiggle-panel" x="-5%" y="-5%" width="110%" height="110%">' + | |
| '<feTurbulence type="fractalNoise" baseFrequency="0.008 0.012" numOctaves="2" seed="3" result="noise"/>' + | |
| '<feDisplacementMap in="SourceGraphic" in2="noise" scale="20"/>' + | |
| '</filter>' + | |
| '</defs></svg>'; | |
| 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""" | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="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" rel="stylesheet"> | |
| <script> | |
| {BG_SCRIPT} | |
| </script> | |
| """ | |
| 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('<p class="sec-label">Active Abilities</p>') | |
| 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], | |
| ) | |