| """Bridge Troll — Gradio app. |
| |
| Each session, Gorm is secretly assigned one of several hidden NATURES. The player |
| wins by discovering what moves THIS troll — generic sob stories are discounted. |
| On win (resolve -> 0) or loss (resolve -> LOSE_AT, he hurls you back), a reveal |
| card shows what his nature was. |
| |
| Local loop test (no GPU/download): BRIDGE_TROLL_MOCK=1 python app.py |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
|
|
| import gradio as gr |
|
|
| from troll_engine import (GameState, START_RESOLVE, LOSE_AT, build_messages, |
| parse_judgment, random_nature) |
| from models import get_backend |
|
|
| |
| try: |
| import spaces |
|
|
| gpu = spaces.GPU |
| except Exception: |
|
|
| def gpu(*args, **_kwargs): |
| if args and callable(args[0]): |
| return args[0] |
| return lambda fn: fn |
|
|
|
|
| _backend = get_backend() |
|
|
|
|
| @gpu(duration=30) |
| def _generate(messages: list[dict]) -> str: |
| return _backend.generate(messages) |
|
|
|
|
| INTRO = ("A mossy troll heaves himself upright across the only bridge over the Mirebeck. " |
| '*"None cross Gorm\'s bridge for free, traveller. Give me a reason — a *good* one."*') |
|
|
|
|
| def _meter_html(resolve: int, won: bool, lost: bool) -> str: |
| if won: |
| return ("<div class='resolve-wrap'><div class='resolve-label'>GORM HAS STEPPED ASIDE 🌉</div>" |
| "<div class='resolve-bar'><div class='resolve-fill won' style='width:0%'></div></div></div>") |
| if lost: |
| return ("<div class='resolve-wrap'><div class='resolve-label'>GORM HURLS YOU BACK 💢</div>" |
| "<div class='resolve-bar'><div class='resolve-fill lost' style='width:100%'></div></div></div>") |
| pct = max(0, min(100, round(resolve / START_RESOLVE * 100))) |
| hue = 90 + (1 - pct / 100) * 30 |
| return ("<div class='resolve-wrap'>" |
| f"<div class='resolve-label'>Gorm's Resolve — {resolve}</div>" |
| f"<div class='resolve-bar'><div class='resolve-fill' " |
| f"style='width:{pct}%;background:hsl({hue},55%,42%)'></div></div></div>") |
|
|
|
|
| def _reveal(state: GameState) -> str: |
| if not state.over or not state.nature: |
| return "" |
| n = state.nature |
| if state.won: |
| return (f"### 🌉 You crossed in {state.turns} turns.\n" |
| f"**This Gorm's hidden nature:** *{n['name']}* — moved by {n['soft']}.") |
| return (f"### 💢 Gorm lost patience and hurled you back.\n" |
| f"**His hidden nature was:** *{n['name']}* — moved by {n['soft']}. " |
| f"You leaned too hard on what he can't stand: {n['sore']}.") |
|
|
|
|
| def on_submit(user_text: str, chat: list, state: GameState): |
| user_text = (user_text or "").strip() |
| if not user_text or state.over: |
| return chat, state, _meter_html(state.resolve, state.won, state.lost), "", _reveal(state), gr.update() |
|
|
| raw = _generate(build_messages(state, user_text)) |
| j = parse_judgment(raw) |
| state.history.append({"role": "user", "content": user_text}) |
| state.history.append({"role": "assistant", "content": j.reply}) |
| state.apply(j) |
|
|
| chat = chat + [{"role": "user", "content": user_text}, |
| {"role": "assistant", "content": j.reply}] |
| why = f"*{j.tactic.value}* · {j.reason}" + (f" · persuasiveness {j.persuasiveness}/5" |
| if j.tactic.value == "genuine" else "") |
| box = gr.update(interactive=not state.over, |
| placeholder="The bridge is yours." if state.won else |
| ("Gorm has thrown you out." if state.lost else "Speak to Gorm…")) |
| return chat, state, _meter_html(state.resolve, state.won, state.lost), why, _reveal(state), box |
|
|
|
|
| def on_reset(): |
| state = GameState(nature=random_nature()) |
| chat = [{"role": "assistant", "content": INTRO}] |
| return (chat, state, _meter_html(state.resolve, False, False), "", "", |
| gr.update(interactive=True, value="", placeholder="Speak to Gorm…")) |
|
|
|
|
| CSS = """ |
| .resolve-wrap { margin: 6px 0 14px; } |
| .resolve-label { font-family: Georgia, serif; font-size: 14px; letter-spacing:.04em; margin-bottom:4px; } |
| .resolve-bar { height: 16px; background:#2a2118; border:1px solid #5a4a32; border-radius:9px; overflow:hidden; } |
| .resolve-fill { height:100%; transition: width .5s ease, background .5s ease; } |
| .resolve-fill.won { background:#caa54a; } |
| .resolve-fill.lost { background:#a33; } |
| #why { font-family: Georgia, serif; opacity:.8; min-height:1.4em; } |
| #reveal { font-family: Georgia, serif; } |
| """ |
|
|
| with gr.Blocks(title="Bridge Troll") as demo: |
| gr.Markdown("## 🧌🌉 Bridge Troll\n*Talk your way across — if your argument is actually good. " |
| "Every troll is hiding something different.*") |
| if os.environ.get("BRIDGE_TROLL_MOCK") == "1": |
| gr.Markdown("> ⚠️ **MOCK MODE** — keyword stub, not the real model. " |
| "Natures, discovery, and probing do NOT work here. " |
| "Run on the Space (no `BRIDGE_TROLL_MOCK`) to play the real Gorm.") |
| meter = gr.HTML(_meter_html(START_RESOLVE, False, False)) |
| chatbot = gr.Chatbot(value=[{"role": "assistant", "content": INTRO}], height=420, show_label=False) |
| why = gr.Markdown("", elem_id="why") |
| reveal = gr.Markdown("", elem_id="reveal") |
| with gr.Row(): |
| box = gr.Textbox(placeholder="Speak to Gorm…", show_label=False, scale=8, autofocus=True) |
| send = gr.Button("Say it", variant="primary", scale=1) |
| reset = gr.Button("New traveller", size="sm") |
|
|
| state = gr.State(GameState(nature=random_nature())) |
| outs = [chatbot, state, meter, why, reveal, box] |
|
|
| send.click(on_submit, [box, chatbot, state], outs).then(lambda: "", None, box) |
| box.submit(on_submit, [box, chatbot, state], outs).then(lambda: "", None, box) |
| reset.click(on_reset, None, outs) |
| demo.load(on_reset, None, outs) |
|
|
|
|
| if __name__ == "__main__": |
| demo.launch(css=CSS, theme=gr.themes.Soft()) |
|
|