"""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
# ZeroGPU decorator β no-op locally. Supports @gpu and @gpu(duration=...).
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 ("
GORM HAS STEPPED ASIDE π
"
"
")
if lost:
return ("GORM HURLS YOU BACK π’
"
"
")
pct = max(0, min(100, round(resolve / START_RESOLVE * 100)))
hue = 90 + (1 - pct / 100) * 30
return (""
f"
Gorm's Resolve β {resolve}
"
f"
")
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) # fresh hidden nature for every visitor
if __name__ == "__main__":
demo.launch(css=CSS, theme=gr.themes.Soft())