"""The Adventures of the HF Knight -- Gradio app (Space entrypoint). Left: the parchment scroll (our own HTML, not gr.Chatbot) + input. Right: a HUD plaque -- rank ladder, stage, trial, strikes. Game logic is the same agent layer as sim.py; the per-quest loop lives in two handlers (begin / answer). """ import html import warnings import gradio as gr from agents.judge import Judge from agents.narrator import KICKOFF, Narrator from config import MAX_ATTEMPT from core.game_state import RANK_LADDER from core.validation import Validator from llm import LLM from tools import GameSession warnings.filterwarnings("ignore", message=".*moved from the Blocks constructor.*") warnings.filterwarnings("ignore", message=".*HTTP_422_UNPROCESSABLE_ENTITY.*") # gr.HTML doesn't run embedded """ PARCHMENT_CSS = """ @import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@600;800&family=EB+Garamond:ital@0;1&display=swap'); .gradio-container { background: radial-gradient(circle at 50% 0%, #4a3520 0%, rgba(0,0,0,0) 60%), #2e2013 !important; font-family: 'EB Garamond', Georgia, serif !important; color: #e8d3a0 !important; } #title { text-align: center; margin: 0.4em 0; padding: 8px 12px; background: #f1e4c3; border: 2px solid #6b4f2c; border-radius: 8px; box-shadow: 0 5px 16px rgba(0,0,0,0.5); } #title h1 { font-family: 'EB Garamond', Georgia, serif !important; font-weight: 700; font-size: 2.5rem; color: #120c05 !important; letter-spacing: 1px; margin: 0; } /* shared parchment surface for scroll + HUD + rules */ #tale, #hud, #rules { background: radial-gradient(circle at 25% 15%, #f9f1d6 0%, rgba(249,241,214,0) 70%), radial-gradient(circle at 85% 95%, #e4d09c 0%, rgba(228,208,156,0) 70%), #f2e6c4 !important; border: 3px solid #6b4f2c; border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.5), inset 0 0 50px rgba(120,90,50,0.18); color: #3b2a18 !important; } /* lock text dark on the parchment regardless of Gradio light/dark mode */ #tale *, #hud *, #rules * { color: inherit !important; } /* the tale scroll */ #tale { padding: 26px 34px; height: 600px; overflow-y: auto; line-height: 1.6; font-size: 1.12rem; } #tale .status { text-align: center; font-family: 'Cinzel', serif; font-size: 0.8rem; letter-spacing: 1px; text-transform: uppercase; color: #7a5a30; border-bottom: 1px solid #c9b079; padding-bottom: 8px; margin-bottom: 18px; } #tale .herald { margin: 0 0 1.1em 0; } #tale .knight { margin: 0 0 1.1em 2.2em; font-style: italic; color: #5b3a1a !important; } #tale .who { display: block; font-family: 'Cinzel', serif; font-size: 0.74rem; letter-spacing: 1px; text-transform: uppercase; color: #8a6a3a !important; margin-bottom: 2px; } /* the HUD plaque */ #hud { padding: 6px 16px; } #hud .rank { font-family: 'Cinzel', serif; font-size: 1.1rem; color: #5b3a1a !important; text-align: center; margin-bottom: 1px; } #hud ul.ladder { list-style: none; padding: 0; margin: 2px 0 0 0; font-size: 0.86rem; line-height: 1.2; } #hud ul.ladder li { padding: 0 8px; color: #9a7b4a !important; border-radius: 4px; } #hud ul.ladder li.cur { color: #3b2a18 !important; font-weight: 700; background: #e4cf9a; } #hud ul.ladder li.done { color: #6b8a4a !important; } #hud .sec { font-family: 'Cinzel', serif; font-size: 0.72rem; letter-spacing: 1px; text-transform: uppercase; color: #7a5a30 !important; margin: 3px 0 0 0; border-top: 1px solid #c9b079; padding-top: 2px; } #hud .val { font-size: 0.9rem; } #hud .strikes { font-size: 1.1rem; letter-spacing: 3px; color: #8a3a2a !important; } /* the rules decree card */ /* kill Gradio's default flex gap between HUD and rules in the right column */ #rightcol { gap: 0 !important; } #rules { padding: 12px 16px; margin: 0; } #rules .sec { font-family: 'Cinzel', serif; font-size: 0.72rem; letter-spacing: 1px; text-transform: uppercase; color: #7a5a30 !important; margin: 0 0 8px 0; } #rules ul { list-style: none; padding: 0; margin: 0; font-size: 0.9rem; line-height: 1.3; } #rules li { padding: 2px 0 2px 16px; position: relative; } #rules li::before { content: "\\2756"; position: absolute; left: 0; color: #8a6a3a; font-size: 0.7rem; top: 5px; } /* input row */ #answerbox textarea { background: #f2e6c4 !important; color: #3b2a18 !important; border: 2px solid #6b4f2c !important; font-family: 'EB Garamond', serif !important; font-size: 1.05rem !important; } #sendbtn, #beginbtn { background: #6b4f2c !important; color: #f2e6c4 !important; border: 2px solid #4a3520 !important; font-family: 'Cinzel', serif !important; letter-spacing: 1px; } """ # model loaded once, lazily (keeps `gradio` hot-reload re-import fast) _narrator: Narrator | None = None def narrator() -> Narrator: global _narrator if _narrator is None: _narrator = Narrator(LLM()) return _narrator def render_tale(display) -> str: """The parchment scroll -- just the dialogue (rank/trial/strikes live in the HUD).""" rows = [] for who, text in display: body = html.escape(text).replace("\n", "
") label = "Herald-Mentor" if who == "herald" else "Knight" rows.append(f'

{label}{body}

') return f'
{"".join(rows)}
' def render_hud(session) -> str: s = session.state if session else None cur = s.rank if s else None items = [] for i, r in enumerate(RANK_LADDER): cls = "" if cur and r == cur: cls = "cur" elif s and i < s.stage_idx: cls = "done" items.append(f'
  • {html.escape(r)}
  • ') ladder = f'' if s and s.status == "playing": stage = session.orchestrator.bank.stage(s.stage_idx) nq = len(stage["questions"]) stage_v = f"{s.stage_idx + 1}/{len(session.orchestrator.bank)} — {stage['concept']}" trial_v = f"{s.q_idx + 1}/{nq}" strikes_v = '' + "◆" * s.attempt + "◇" * (MAX_ATTEMPT - s.attempt) + "" elif s and s.status == "won": stage_v, trial_v, strikes_v = "All cleared", "—", "🏆" elif s and s.status == "lost": stage_v, trial_v, strikes_v = "Quest ended", "—", "◆◆◆" else: stage_v, trial_v, strikes_v = "Awaiting the summons", "—", "◇◇◇" return ( f'
    ' f'
    {html.escape(cur or "—")}
    ' f'{ladder}' f'
    Stage
    {html.escape(stage_v)}
    ' f'
    Trial
    {html.escape(trial_v)}
    ' f'
    Strikes
    {strikes_v}
    ' f'
    ' ) WELCOME_TALE = ('
    The Realm of the Hub awaits
    ' '

    Herald-Mentor' 'A spirit stirs in the Realm of the Hub. Press Begin thy quest to be ' 'summoned, Squire, and face thy first trial.

    ') RULES_HTML = ( '
    The Decree of the Trials
    ') def begin(): """Summon the knight, pose the first trial, and swap Begin for the answer input.""" n = narrator() session = GameSession(validator=Validator(judge=Judge(n.llm))) messages = [{"role": "system", "content": n.system_block(session)}, {"role": "user", "content": KICKOFF}] display = [("herald", n.run(session, messages))] return (render_tale(display), session, messages, display, render_hud(session), gr.update(visible=False), gr.update(visible=True), gr.update(visible=True)) def answer(text, session, messages, display): """One player turn: judge the answer, react, pose the next trial if cleared.""" if session is None or session.state.status != "playing" or not text.strip(): tale = render_tale(display) if session else WELCOME_TALE return tale, session, messages, display, "", render_hud(session) n = narrator() before = (session.state.stage_idx, session.state.q_idx) display = display + [("knight", text)] messages.append({"role": "user", "content": text}) display = display + [("herald", n.run(session, messages))] moved = (session.state.stage_idx, session.state.q_idx) != before if session.state.status == "playing" and moved: # cleared -> pose next trial messages = [{"role": "system", "content": n.system_block(session)}, {"role": "user", "content": KICKOFF}] display = display + [("herald", n.run(session, messages))] return render_tale(display), session, messages, display, "", render_hud(session) with gr.Blocks(title="The Adventures of the HF Knight", css=PARCHMENT_CSS, head=HEAD_JS) as demo: gr.Markdown("# 🛡️ The Adventures of the HF Knight", elem_id="title") st_session = gr.State() st_messages = gr.State() st_display = gr.State() with gr.Row(): with gr.Column(scale=3): tale = gr.HTML(WELCOME_TALE) with gr.Row(): begin_btn = gr.Button("⚔️ Begin thy quest", scale=1, elem_id="beginbtn") box = gr.Textbox(placeholder="Speak thy answer, Squire...", show_label=False, scale=5, elem_id="answerbox", visible=False, max_length=200) send = gr.Button("⚔️ Answer", scale=1, elem_id="sendbtn", visible=False) with gr.Column(scale=1, elem_id="rightcol"): hud = gr.HTML(render_hud(None)) gr.HTML(RULES_HTML) begin_btn.click(begin, outputs=[tale, st_session, st_messages, st_display, hud, begin_btn, box, send]) _in = [box, st_session, st_messages, st_display] _out = [tale, st_session, st_messages, st_display, box, hud] box.submit(answer, _in, _out) send.click(answer, _in, _out) if __name__ == "__main__": demo.launch()