you-are-a-bug / app.py
jnalv's picture
Upload folder using huggingface_hub
1082b22 verified
Raw
History Blame Contribute Delete
52.3 kB
"""
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],
)