""" The Podium — Hybrid Gradio/HTML UI Python handles all LLM calls; HTML/JS handles all visual interaction. State is kept server-side (_SESSIONS dict); only a JSON-safe session_id travels through Gradio, so no serialisation errors. """ import html import json import os import re import subprocess import sys import threading import time import traceback import uuid from pathlib import Path from typing import Any, Dict, List, Optional, Tuple import gradio as gr from dotenv import load_dotenv load_dotenv() import config import content import engine import portraits from engine import GameState, RoundLog # ── Server-side stores ─────────────────────────────────────────────────────── _SESSIONS: Dict[str, GameState] = {} # session_id → GameState _LAST_LOGS: Dict[str, RoundLog] = {} # session_id → most-recent RoundLog _APP_STATES: Dict[str, dict] = {} # session_id → lightweight state dict _PROCESSING: set = set() # keys currently being processed (dedup) _ACTION_LOCK = threading.Lock() def _new_sid() -> str: return uuid.uuid4().hex def _get_gs(sid: Optional[str]) -> Optional[GameState]: return _SESSIONS.get(sid) if sid else None def _get_log(sid: Optional[str]) -> Optional[RoundLog]: return _LAST_LOGS.get(sid) if sid else None def _save(state: dict, gs: Optional[GameState] = None, log: Optional[RoundLog] = None) -> dict: sid = state.get("session_id") or _new_sid() state = {k: v for k, v in state.items()} state["session_id"] = sid _APP_STATES[sid] = state if gs is not None: _SESSIONS[sid] = gs if log is not None: _LAST_LOGS[sid] = log return state # ── CSS (injected via Gradio css= param — always in document ) ───────── APP_CSS = """ :root { --bg-base: #0a0812; --bg-table: #0f0d22; --bg-card: #12102a; --gold: #c9a84c; --gold-glow: rgba(201,168,76,0.2); --gold-dim: rgba(201,168,76,0.25); --purple: #7a5ba6; --purple-g: rgba(122,91,166,0.3); --red: #8b1a2a; --red-mid: #c16070; --green: #4a8c5c; --txt1: #e8e0cc; --txt2: #c4b89a; --txt3: #3d3550; --noun-c: #5b7fa6; --noun-color: #5b7fa6; --adj-c: #a65b3a; --verb-c: #4a8c5c; --wild-c: #7a5ba6; --wild-color: #7a5ba6; --r: 2px; --chip-r: 2px; --sh: inset 0 2px 5px rgba(0,0,0,0.5), 0 4px 14px rgba(0,0,0,0.6); --shf: 0 8px 32px rgba(0,0,0,0.7); --bg-raised: #1a1730; --bg-overlay: rgba(255,255,255,0.05); --text-primary: var(--txt1); --text-secondary: var(--txt2); --text-muted: var(--txt3); --purple-dim: rgba(201,168,76,0.15); --red-dim: rgba(139,26,42,0.2); } /* ── Base size increase — scales everything proportionally ── */ html { font-size: 17px; } /* Component-level overrides where proportional scaling needs tuning */ .sentence-text { font-size: 1.26rem; line-height: 1.6; } .topic-text { font-size: 1.44rem; line-height: 1.55; } .ability-description { font-size: 1.02rem; line-height: 1.5; } .chip { font-size: 1.14rem; padding: 12px 22px; } .card { padding: 24px; } .lb-label { font-size: 0.86rem; } .lb-value { font-size: 1.2rem; } .verdict-text { font-size: 1.2rem; line-height: 1.65; } .postgame-card { font-size: 1.14rem; } /* Spacing improvements */ .round-screen { gap: 29px; } .setup-screen { gap: 34px; } .verdict-screen { gap: 29px; } .argument-sentences { gap: 14px; } .argument-sentence { padding: 17px 22px; } .debater-grid { gap: 14px; } .ability-hand { gap: 17px; } /* Gradio shell reset */ html, body { height: auto !important; min-height: 0 !important; overflow-x: hidden; } body, .gradio-container { background: var(--bg-base) !important; } .gradio-container { max-width: 100% !important; padding: 0 !important; margin: 0 !important; } /* HF iframe-resizer: shrink Gradio HTML shell to match game content */ #podium-ui, #podium-ui.block, #podium-ui > .html-container, #podium-ui .html-container, #podium-ui .prose { height: auto !important; min-height: 0 !important; max-height: none !important; overflow: visible !important; } .block:has(#podium-action-input), .block:has(#podium-action-btn) { height: 0 !important; min-height: 0 !important; margin: 0 !important; padding: 0 !important; overflow: hidden !important; border: none !important; visibility: hidden !important; } footer { display: none !important; } .progress-bar-wrap, .progress-bar, .eta-bar, .meta { display: none !important; } .queue-position { display: none !important; } /* Suppress Gradio queue grey-out — keep game UI and in-button loading dots visible */ .generating { border: none !important; opacity: 1 !important; filter: none !important; } #podium-ui, #podium-ui.block, #podium-ui.wrap, #podium-ui.generating, #podium-ui .html-container, #podium-ui .html-container.pending, #podium-ui .prose, #podium-ui .prose.pending, #podium-ui .pending, #podium-ui [class*="pending"] { opacity: 1 !important; filter: none !important; pointer-events: auto !important; } .block:has(#podium-ui).pending, .block:has(#podium-ui).generating { opacity: 1 !important; filter: none !important; background: transparent !important; } .gradio-container .loader, .gradio-container .load-wrap, .gradio-container .progress-level, .gradio-container .progress-text, .gradio-container .overlay { display: none !important; background: transparent !important; backdrop-filter: none !important; } .gradio-container .block.pending, .gradio-container .wrap.generating { opacity: 1 !important; background: transparent !important; } .block { padding: 0 !important; margin: 0 !important; background: transparent !important; border: none !important; } [class*="svelte"] { background: transparent !important; border: none !important; box-shadow: none !important; } /* Hide the action channel but keep it functional */ #podium-action-input { position: fixed !important; left: -9999px !important; top: 0 !important; width: 1px !important; height: 1px !important; overflow: hidden !important; opacity: 0 !important; } #podium-action-btn { position: fixed !important; left: -9990px !important; top: 0 !important; width: 40px !important; height: 24px !important; opacity: 0 !important; z-index: 9999 !important; } /* ── Texture overlay ── */ .tex { position: relative; overflow: hidden; } .tex::after { content: ''; position: absolute; inset: 0; z-index: 5; pointer-events: none; background-image: url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%20200%20200%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cfilter%20id%3D%22f%22%3E%3CfeTurbulence%20type%3D%22fractalNoise%22%20baseFrequency%3D%220.75%22%20numOctaves%3D%224%22%20stitchTiles%3D%22stitch%22%2F%3E%3C%2Ffilter%3E%3Crect%20width%3D%22200%22%20height%3D%22200%22%20filter%3D%22url%28%23f%29%22%2F%3E%3C%2Fsvg%3E"); background-size: 200px 200px; opacity: 0.04; } /* ── Scrollbar ── */ ::-webkit-scrollbar { width: 5px; background: var(--bg-base); } ::-webkit-scrollbar-thumb { background: #3d3550; border-radius: 2px; } /* ── Game UI ── */ .pw { font-family: 'Inter', sans-serif; color: var(--txt1); background: var(--bg-base); min-height: 0; padding: 0; } .pw * { box-sizing: border-box; } .screen { max-width: 1200px; margin: 0 auto; padding: 38px 24px 96px; } /* ── Card strip decorators ── */ .card-strip-top { border-top: 1px solid rgba(201,168,76,0.5); border-bottom: 1px solid rgba(201,168,76,0.2); height: 17px; flex-shrink: 0; } .card-strip-bot { border-top: 1px solid rgba(201,168,76,0.2); border-bottom: 1px solid rgba(201,168,76,0.5); height: 17px; flex-shrink: 0; } /* ── Cards ── */ .card { background: var(--bg-card); border: 2px solid rgba(201,168,76,0.2); border-radius: var(--r); box-shadow: var(--sh); transition: transform .15s, box-shadow .15s, border-color .15s; cursor: pointer; display: flex; flex-direction: column; position: relative; overflow: hidden; } .card:hover { transform: translateY(-4px); box-shadow: var(--shf); border-color: rgba(201,168,76,0.4); } .card.selected { border-color: var(--gold); box-shadow: 0 0 0 2px rgba(201,168,76,0.18), var(--shf); transform: translateY(-4px); } .card.used { opacity: .35; filter: saturate(0.18); cursor: not-allowed; pointer-events: none; } /* ── Debater cards ── */ .debater-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 14px; margin: 19px 0; } @media (max-width: 960px) { .debater-grid { grid-template-columns: repeat(3, 1fr); } } .debater-grid.round-roster { display: flex; flex-wrap: wrap; gap: 17px; align-items: stretch; } .debater-grid.round-roster .debater-card { min-width: 200px; max-width: 280px; flex: 1 1 220px; } .debater-card { display: flex; flex-direction: column; text-align: center; cursor: pointer; min-width: 0; max-width: none; min-height: 252px; height: 100%; width: 100%; position: relative; } .debater-card.card:hover, .debater-card.card.selected { transform: none; } .debater-card.card:hover { border-color: rgba(201,168,76,0.45); box-shadow: var(--shf); } .debater-card.card.selected { border-color: var(--gold); box-shadow: 0 0 0 2px rgba(201,168,76,0.18), var(--shf); } .debater-card-body { flex: 1; display: flex; flex-direction: column; align-items: stretch; padding: 12px 14px 0; text-align: center; } .debater-emoji { font-size: 2.64rem; line-height: 1; margin-bottom: 6px; align-self: center; } /* Custom portraits — disable via config.USE_CUSTOM_PORTRAITS */ .portrait-slot { display: inline-flex; align-items: center; justify-content: center; position: relative; line-height: 1; } .portrait-slot .portrait-img { width: 100%; height: 100%; object-fit: contain; display: block; } .portrait-slot:not(.portrait-fallback) .portrait-emoji { display: none; } .portrait-slot.portrait-fallback .portrait-emoji { display: block; } .debater-emoji .portrait-slot { width: 2.64rem; height: 2.64rem; font-size: 2.64rem; } .debater-summary .portrait-slot { width: 1.92rem; height: 1.92rem; font-size: 1.92rem; flex-shrink: 0; } .judge-portrait-wrap .portrait-slot { width: 72px; height: 72px; font-size: 3.6rem; } .arg-debater-compact-emoji .portrait-slot { width: 1.4rem; height: 1.4rem; font-size: 1.4rem; } .vr-debater-emoji .portrait-slot { width: 2rem; height: 2rem; font-size: 2rem; } .debater-name { font-family: 'Bebas Neue', sans-serif; font-size: 1.06rem; color: var(--gold); letter-spacing: 0.08em; line-height: 1.15; margin-bottom: 5px; align-self: center; } .debater-line { font-family: 'Inter', sans-serif; font-style: italic; font-size: 0.86rem; color: var(--txt1); line-height: 1.35; flex: 1; margin-bottom: 7px; width: 100%; min-height: 5.4em; } .debater-tag { font-family: 'Bebas Neue', sans-serif; font-size: 0.70rem; letter-spacing: 0.1em; padding: 2px 8px; border-radius: 2px; border: 1px solid var(--txt3); color: var(--txt3); display: inline-block; margin-bottom: 12px; margin-top: auto; align-self: center; } .debater-card[data-tag="HIGH INTENSITY"] .debater-tag, .debater-card[data-tag="HIGH RISK"] .debater-tag { border-color: var(--red-mid); color: var(--red-mid); } .debater-card[data-tag="PRECISION"] .debater-tag, .debater-card[data-tag="UNSHAKEABLE"] .debater-tag, .debater-card[data-tag="RESULTS ONLY"] .debater-tag { border-color: var(--noun-c); color: var(--noun-c); } .debater-card[data-tag="JUDGE FAVOURITE"] .debater-tag, .debater-card[data-tag="MEMORABLE"] .debater-tag { border-color: var(--wild-c); color: var(--wild-c); } .debater-card[data-tag="UNPREDICTABLE"] .debater-tag, .debater-card[data-tag="PLAYS AGAINST"] .debater-tag { border-color: var(--gold); color: var(--gold); } .debater-card[data-tag="BALANCED"] .debater-tag { border-color: var(--green); color: var(--green); } .debater-card.selected .debater-name { color: var(--gold); } .debater-card.used { opacity: 0.35; filter: saturate(0.18); pointer-events: none; } .debater-card.used .debater-tag::before { content: "DEPLOYED · "; } .d-badge { position: absolute; top: 17px; right: 0; background: var(--gold); color: #0a0812; width: 29px; height: 29px; font-family: 'Bebas Neue', sans-serif; font-size: 0.74rem; letter-spacing: 0; display: flex; align-items: center; justify-content: center; border-radius: 0 0 0 4px; z-index: 10; } /* ── Word chips ── */ .chip { display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; width: 134px; height: 79px; background: var(--bg-card); border: 1.5px solid transparent; border-radius: var(--chip-r); cursor: pointer; position: relative; box-shadow: inset 0 2px 5px rgba(0,0,0,0.5); transition: border-color .12s, box-shadow .12s; flex-shrink: 0; overflow: visible; margin: 6px; } .chip.pool-preview { cursor: default; pointer-events: none; } .chip-word { font-family: 'Bebas Neue', sans-serif; color: var(--txt1); font-size: 1.03rem; letter-spacing: 0.05em; line-height: 1; } .chip-div { width: 68%; height: 1px; background: rgba(201,168,76,0.2); } .chip-cat { font-family: 'Bebas Neue', sans-serif; color: #9a8e78; font-size: 0.86rem; letter-spacing: 0.14em; line-height: 1; } .chip.noun { border-color: var(--noun-c); } .chip.adj { border-color: var(--adj-c); } .chip.verb { border-color: var(--verb-c); } .chip.wild, .chip.wildcard { border-color: var(--wild-c); } .chip:hover { border-color: rgba(201,168,76,0.5); filter: brightness(1.15); } .chip.selected { border: 2.5px solid var(--gold); box-shadow: 0 0 0 2px rgba(201,168,76,0.15), inset 0 2px 4px rgba(0,0,0,0.4); } .chip.selected .chip-word { color: var(--gold); } .chip.selected .chip-div { background: rgba(201,168,76,0.35); } .chip.selected .chip-cat { color: #7a6e8a; } .chip.selected::after { content: attr(data-order); position: absolute; top: -7px; right: -7px; background: var(--gold); color: #0a0812; border-radius: 50%; width: 24px; height: 24px; font-family: 'Bebas Neue', sans-serif; font-size: 0.66rem; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 6px rgba(0,0,0,0.5); z-index: 10; } .chip.used-pool { opacity: 0.28; cursor: not-allowed; pointer-events: none; filter: saturate(0.1); } .chip-spent-wrap { position: relative; flex-shrink: 0; display: inline-flex; margin: 6px; width: 134px; height: 79px; } .chip-spent-wrap .chip { position: absolute; inset: 0; margin: 0; } .chip-spent-stamp { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; transform: rotate(-12deg); z-index: 10; pointer-events: none; } .chip-spent-stamp span { font-family: 'Bebas Neue', sans-serif; font-size: 0.70rem; letter-spacing: 0.22em; color: rgba(122,110,138,0.75); border: 1px solid rgba(122,110,138,0.3); padding: 2px 6px; } /* ── Session leaderboard ── */ .leaderboard-panel { margin: 16px 0; padding: 0; overflow: hidden; } .leaderboard-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; cursor: pointer; border-bottom: 1px solid rgba(255,255,255,0.06); } .lb-title { font-family: 'Bebas Neue', sans-serif; font-size: 1.08rem; letter-spacing: 0.12em; color: var(--gold); } .lb-toggle { color: var(--text-muted); font-size: 0.7rem; } .leaderboard-body { padding: 12px 20px 20px; display: flex; flex-direction: column; gap: 10px; } .lb-row { display: flex; justify-content: space-between; align-items: flex-start; padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.04); } .lb-row.highlight { flex-direction: column; gap: 4px; background: var(--bg-raised); border-radius: 8px; padding: 10px 12px; border: none; } .lb-row-main { display: flex; justify-content: space-between; width: 100%; } .lb-row-detail { font-size: 0.75rem; color: var(--text-muted); font-style: italic; } .lb-label { font-size: 0.65rem; font-weight: 700; letter-spacing: 0.1em; color: var(--text-muted); font-family: 'Bebas Neue', sans-serif; } .lb-value { font-size: 0.95rem; color: var(--text-primary); font-weight: 600; } .lb-value.gold { color: var(--gold); } /* ── Post-game card ── */ .postgame-card { margin: 20px 0; padding: 0; border-color: rgba(201,168,76,0.2); } .postgame-title { font-family: 'Bebas Neue', sans-serif; font-size: 0.82rem; letter-spacing: 0.32em; color: #e0c878; margin-bottom: 19px; padding: 19px 24px 0; } .postgame-stats { display: flex; flex-direction: column; gap: 14px; padding: 0 24px 24px; } .stat-highlight { display: flex; align-items: flex-start; gap: 14px; background: var(--bg-raised); border-radius: var(--r); padding: 14px; } .stat-icon { font-size: 1.44rem; } .stat-label { font-family: 'Bebas Neue', sans-serif; font-size: 0.72rem; letter-spacing: 0.1em; color: var(--txt3); } .stat-value { font-family: 'Inter', sans-serif; font-size: 1.10rem; color: var(--txt1); margin-top: 2px; } .near-miss-banner { margin-top: 19px; padding: 14px 19px; background: rgba(122,91,166,0.08); border: 1px solid rgba(122,91,166,0.2); border-radius: var(--r); font-family: 'Inter', sans-serif; font-size: 1.02rem; color: var(--purple); display: none; } /* ── Buttons ── */ .btn-gold { background: var(--gold); color: #0a0812; border: none; font-family: 'Bebas Neue', sans-serif; font-size: 1.44rem; letter-spacing: 0.32em; padding: 22px 0; border-radius: var(--r); cursor: pointer; width: 100%; transition: all .15s; box-shadow: 0 4px 24px rgba(201,168,76,0.2); } .btn-gold:hover { background: #d9b85c; transform: translateY(-2px); box-shadow: 0 6px 32px rgba(201,168,76,0.3); } .btn-gold:disabled { opacity: .35; cursor: not-allowed; transform: none; } .btn-gold.is-loading { letter-spacing: 0.14em; pointer-events: none; } .loading-dots { display: inline-block; margin-left: 3px; letter-spacing: 0; width: 1.5em; text-align: left; vertical-align: baseline; } .loading-dots span { display: inline-block; animation: dot-pulse 1.2s ease-in-out infinite; opacity: 0.25; } .loading-dots span:nth-child(1) { animation-delay: 0s; } .loading-dots span:nth-child(2) { animation-delay: 0.2s; } .loading-dots span:nth-child(3) { animation-delay: 0.4s; } .opponent-formulating .loading-dots span { color: var(--txt2); } @keyframes dot-pulse { 0%, 60%, 100% { opacity: 0.25; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-3px); } } /* ── Labels / text helpers ── */ .sec-label { font-family: 'Bebas Neue', sans-serif; font-size: 1.2rem; letter-spacing: 0.22em; color: #e0c878; text-shadow: 0 0 18px rgba(201,168,76,0.25); border-bottom: 1px solid rgba(201,168,76,0.22); padding-bottom: 12px; margin-bottom: 22px; } .sec-label.sec-label-red { color: #e88898; border-bottom-color: rgba(193,96,112,0.22); } .subtext { font-family: 'Inter', sans-serif; color: #d4c8a8; font-size: 1.22rem; line-height: 1.55; } .round-banner { font-family: 'Bebas Neue', sans-serif; font-size: 1.68rem; color: var(--gold); letter-spacing: 0.12em; text-shadow: 0 0 40px rgba(201,168,76,0.25); } /* ── Shared step / label utilities ── */ .step-label { font-family: 'Bebas Neue', sans-serif; font-size: 1.2rem; letter-spacing: 0.22em; color: #e0c878; white-space: nowrap; text-shadow: 0 0 18px rgba(201,168,76,0.25); } .step-head { display: flex; align-items: center; gap: 19px; margin-bottom: 24px; } .step-divider { flex: 1; height: 1px; background: rgba(201,168,76,0.1); } .step-counter { font-family: 'Bebas Neue', sans-serif; font-size: 0.98rem; letter-spacing: 0.15em; color: #a89a80; white-space: nowrap; } .muted-label { font-family: 'Bebas Neue', sans-serif; font-size: 0.82rem; letter-spacing: 0.22em; color: #3d3550; text-align: center; } .final-stat-label { font-family: 'Bebas Neue', sans-serif; font-size: 0.82rem; letter-spacing: 0.22em; color: #3d3550; margin-bottom: 10px; } .stat-value-lg { font-family: 'Bebas Neue', sans-serif; font-size: 3rem; } .stat-value-lg .stat-vs { font-size: 1.2rem; color: #3d3550; } .position-label { font-family: 'Bebas Neue', sans-serif; font-size: 0.86rem; letter-spacing: 0.18em; color: #3d3550; margin-bottom: 10px; } .coll-title { font-family: 'Bebas Neue', sans-serif; font-size: 0.94rem; letter-spacing: 0.15em; color: #e8e0cc; } .bd-val { font-family: 'Bebas Neue', sans-serif; font-size: 0.84rem; width: 29px; text-align: right; flex-shrink: 0; } .bd-val.p { color: #7a5ba6; } .bd-val.o { color: #8b1a2a; } .debater-summary { display: flex; align-items: center; gap: 12px; padding: 12px 17px; background: #12102a; border-radius: 2px; margin: 7px 0; } .debater-summary-name { font-family: 'Bebas Neue', sans-serif; font-size: 1.06rem; letter-spacing: 0.08em; color: #c9a84c; } .debater-summary-line { font-family: 'Inter', sans-serif; font-style: italic; color: #e8e0cc; font-size: 0.94rem; } .debater-summary-tag { font-family: 'Bebas Neue', sans-serif; font-size: 0.70rem; letter-spacing: 0.1em; color: #3d3550; margin-top: 5px; } .sb-side-head { font-weight: 700; font-size: 0.98rem; margin-bottom: 12px; } .breakdown-legend { display: flex; gap: 19px; font-family: 'Bebas Neue', sans-serif; font-size: 0.78rem; letter-spacing: 0.12em; margin-bottom: 10px; } .score-vs-divider { font-family: 'Bebas Neue', sans-serif; font-size: 1.32rem; color: #3d3550; flex-shrink: 0; } .note-sm { font-size: 0.94rem; color: var(--txt2); margin-top: 7px; } .abilities-disabled-msg { font-family: 'Inter', sans-serif; color: #3d3550; font-size: 1.02rem; padding: 24px; } /* ── Setup structural helpers ── */ .word-pool-panel { margin-bottom: 43px; } .word-pool-body { padding: 19px 26px 22px; } .setup-step-spaced { margin-top: 53px; } /* ── Landing page ── */ .landing-screen { position: fixed; inset: 0; z-index: 1; display: flex; align-items: center; justify-content: center; overflow-y: auto; overflow-x: hidden; min-height: unset !important; background: radial-gradient(ellipse at 50% -10%, #1c103a 0%, #0f0d22 45%, #0a0812 100%); } .pw.landing-screen { min-height: unset !important; } #podium-ui, #podium-ui > .wrap, #podium-ui > .block { padding: 0 !important; margin: 0 !important; min-height: 0 !important; } .landing-wrap { text-align: center; max-width: 700px; padding: 48px 29px; } .landing-title { font-family: 'Bebas Neue', sans-serif; font-size: 3.36rem; font-weight: 400; color: #c9a84c; letter-spacing: 0.1em; text-shadow: 0 0 40px rgba(201,168,76,0.3); margin-bottom: 12px; } .landing-divider { height: 1px; background: linear-gradient(to right, transparent, rgba(201,168,76,0.4), transparent); margin-bottom: 22px; } .landing-tagline { font-family: 'Inter', sans-serif; color: #c4b89a; font-size: 1.26rem; line-height: 1.6; margin-bottom: 38px; } /* ── Error screen ── */ /* ── Stage 3: debater lock ── */ .debater-grid.setup-roster.locked { opacity: 0; pointer-events: none; transition: opacity 0.4s ease; } /* ── Stage 4: VS reveal panel + turn-order banner ── */ .vs-reveal-panel { display: flex; gap: 2px; margin: 20px 0; border-radius: var(--r); overflow: hidden; } #lineup-vs { margin: 4px 0 8px; } .vr-slot { flex: 1; padding: 20px 14px; text-align: center; background: var(--bg-card); border: 1px solid rgba(201,168,76,0.1); transition: background 0.08s, box-shadow 0.08s; } .vr-label { font-family: 'Bebas Neue', sans-serif; font-size: 0.7rem; letter-spacing: 0.18em; color: var(--text-secondary); margin-bottom: 8px; } .vr-debater-emoji { font-size: 2rem; line-height: 1; margin-bottom: 6px; } .vr-debater-name { font-family: 'Bebas Neue', sans-serif; font-size: 0.95rem; letter-spacing: 0.08em; color: var(--txt1); } .vr-center { flex-shrink: 0; display: flex; align-items: center; padding: 0 14px; background: #0a0812; } .vr-vs-text { font-family: 'Bebas Neue', sans-serif; font-size: 1.6rem; color: var(--gold); letter-spacing: 0.1em; } .vr-slot.lit { background: rgba(201,168,76,0.18); } .vr-slot.winner { background: rgba(201,168,76,0.08); box-shadow: inset 0 0 0 1px var(--gold), 0 0 24px rgba(201,168,76,0.2); } .vr-slot.loser { background: #0a0812; opacity: 0.35; } .turn-order-banner { font-family: 'Bebas Neue', sans-serif; font-size: 1.05rem; letter-spacing: 0.18em; text-align: center; padding: 14px; color: var(--gold); opacity: 0; transition: opacity 0.5s ease; } .turn-order-banner.visible { opacity: 1; } /* ── Stage 5: argument block compact debater header ── */ .arg-debater-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid rgba(255,255,255,0.06); } .arg-debater-compact-emoji { font-size: 1.4rem; line-height: 1; } .arg-debater-compact-name { font-family: 'Bebas Neue', sans-serif; font-size: 0.88rem; letter-spacing: 0.1em; color: var(--txt1); } .arg-side-badge { margin-left: auto; font-family: 'Bebas Neue', sans-serif; font-size: 0.62rem; letter-spacing: 0.18em; padding: 2px 8px; border-radius: 2px; } .arg-side-badge.for { background: rgba(74,140,92,0.2); color: #5ca870; } .arg-side-badge.against { background: rgba(139,26,42,0.2); color: #c16070; } .error-wrap { max-width: 960px; margin: 0 auto; background: #12102a; border: 1px solid rgba(139,26,42,0.5); border-radius: 2px; padding: 29px; } .error-title { font-family: 'Bebas Neue', sans-serif; font-size: 0.98rem; letter-spacing: 0.18em; color: #c16070; margin-bottom: 14px; } .error-body { font-family: 'Inter', sans-serif; color: #c4b89a; font-size: 1.02rem; white-space: pre-wrap; overflow-x: auto; } /* ── Top nav branding ── */ .podium-nav { position: sticky; top: 0; z-index: 100; background: rgba(10,8,18,0.97); border-bottom: 1px solid rgba(201,168,76,0.1); display: flex; align-items: center; padding: 0 48px; height: 70px; } .podium-wordmark { font-family: 'Bebas Neue', sans-serif; font-size: 1.5rem; font-weight: 400; color: #c9a84c; letter-spacing: 0.1em; text-shadow: 0 0 24px rgba(201,168,76,0.25); } .flip-ability-lbl { font-family: 'Bebas Neue', sans-serif; font-size: 0.74rem; color: rgba(201,168,76,0.38); letter-spacing: 0.1em; } .flip-uses-tag { font-family: 'Bebas Neue', sans-serif; font-size: 0.70rem; letter-spacing: 0.1em; padding: 2px 8px; border-radius: 2px; display: inline-block; } /* ── Flip cards ── */ .flip-row { display: flex; gap: 24px; justify-content: center; margin: 19px 0; flex-wrap: wrap; align-items: stretch; } .flip-c { perspective: 1000px; width: 232px; height: 336px; position: relative; cursor: pointer; overflow: hidden; border-radius: var(--r); } .flip-inner { position: relative; width: 100%; height: 100%; transition: transform .5s ease-in-out; transform-style: preserve-3d; } .flip-c.flipped .flip-inner { transform: rotateY(180deg); } .flip-face { position: absolute; width: 100%; height: 100%; backface-visibility: hidden; -webkit-backface-visibility: hidden; border-radius: var(--r); display: flex; flex-direction: column; overflow: hidden; } .flip-back { background: var(--bg-card); border: 1px solid rgba(201,168,76,0.25); box-shadow: 0 8px 32px rgba(0,0,0,0.7); } .flip-front { background: var(--bg-card); border: 1px solid rgba(201,168,76,0.25); box-shadow: 0 8px 32px rgba(0,0,0,0.7); transform: rotateY(180deg); } .flip-front-body { flex: 1; display: flex; flex-direction: column; align-items: center; padding: 10px 12px 0; min-height: 0; text-align: center; width: 100%; overflow: hidden; } .ab-emoji { font-size: 1.9rem; line-height: 1; margin-bottom: 5px; flex-shrink: 0; } .ab-name { font-family: 'Bebas Neue', sans-serif; font-size: 1.15rem; color: var(--gold); letter-spacing: 0.1em; margin-bottom: 6px; flex-shrink: 0; } .ab-desc { font-family: 'Inter', sans-serif; font-size: 0.76rem; color: var(--txt1); text-align: center; line-height: 1.38; flex: 1; width: 100%; margin-bottom: 6px; min-height: 0; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.28) transparent; } .ab-desc::-webkit-scrollbar { width: 3px; } .ab-desc::-webkit-scrollbar-thumb { background: rgba(201,168,76,0.28); border-radius: 2px; } .flip-uses-wrap { width: 100%; flex-shrink: 0; padding-bottom: 10px; margin-top: auto; } /* ── In-round ability tray ── */ .ability-tray { display: flex; gap: 12px; flex-wrap: wrap; margin: 14px 0 19px; } .ab-mini { width: 120px; min-height: 106px; padding: 10px 7px; background: var(--bg-card); border: 1px solid rgba(201,168,76,0.2); border-radius: var(--r); cursor: pointer; text-align: center; transition: border-color .15s, box-shadow .15s, opacity .15s; display: flex; flex-direction: column; align-items: center; gap: 4px; position: relative; } .ab-mini:hover:not(.locked) { border-color: rgba(201,168,76,0.45); box-shadow: 0 4px 16px rgba(0,0,0,0.4); } .ab-mini.selected { border: 2px solid var(--gold); box-shadow: 0 0 0 2px rgba(201,168,76,0.15); } .ab-mini.armed { border-color: var(--green); box-shadow: 0 0 12px rgba(74,140,92,0.25); } .ab-mini.locked { opacity: 0.42; cursor: pointer; filter: saturate(0.35); } .ab-mini.locked:hover { border-color: rgba(201,168,76,0.28); } .ability-zone.ability-locked .ab-mini.locked { cursor: not-allowed; pointer-events: none; } .ab-mini-check { display: none; position: absolute; top: 5px; right: 5px; width: 18px; height: 18px; border-radius: 50%; background: var(--green); color: #fff; font-size: 11px; font-weight: 700; line-height: 18px; text-align: center; pointer-events: none; z-index: 2; } .ab-mini.armed .ab-mini-check { display: block; } .ab-mini-skip { overflow: hidden; } .ab-mini-skip-line { position: absolute; inset: 0; pointer-events: none; z-index: 1; } .ab-mini-skip-line::before { content: ''; position: absolute; top: 50%; left: -12%; right: -12%; height: 2px; background: rgba(201,168,76,0.38); transform: rotate(-42deg); } .ab-mini-emoji { font-size: 1.68rem; line-height: 1; position: relative; z-index: 2; } .ab-mini-name { font-family: 'Bebas Neue', sans-serif; font-size: 0.74rem; letter-spacing: 0.08em; color: var(--gold); position: relative; z-index: 2; } .ability-panel { background: var(--bg-card); border: 1px solid rgba(201,168,76,0.22); border-radius: var(--r); padding: 19px 22px; margin-bottom: 19px; } .ability-panel.hidden { display: none; } .ability-panel-head { font-family: 'Bebas Neue', sans-serif; font-size: 1.14rem; letter-spacing: 0.12em; color: var(--gold); margin-bottom: 10px; } .ability-panel-desc { font-family: 'Inter', sans-serif; font-size: 1.06rem; color: var(--txt2); line-height: 1.45; margin-bottom: 14px; } .ability-warn { font-family: 'Inter', sans-serif; font-size: 0.98rem; color: var(--red-mid); margin-bottom: 12px; } .ability-panel-error { font-family: 'Inter', sans-serif; font-size: 1.01rem; color: var(--red-mid); margin-bottom: 12px; padding: 10px 12px; background: rgba(139,26,42,0.12); border-radius: var(--r); border: 1px solid rgba(139,26,42,0.25); display: none; } .ability-panel-error.visible { display: block; } .ability-hint { font-family: 'Inter', sans-serif; font-size: 0.98rem; color: var(--green); margin-bottom: 12px; } .ability-input { width: 100%; padding: 12px 14px; background: var(--bg-table); border: 1px solid rgba(201,168,76,0.25); border-radius: var(--r); color: var(--txt1); font-family: 'Inter', sans-serif; font-size: 1.14rem; margin-bottom: 12px; } .bias-btns { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; } .bias-btn { flex: 1; min-width: 96px; padding: 12px 10px; background: var(--bg-table); border: 1px solid rgba(201,168,76,0.25); border-radius: var(--r); font-family: 'Bebas Neue', sans-serif; font-size: 0.86rem; letter-spacing: 0.1em; color: var(--txt1); cursor: pointer; transition: all .15s; } .bias-btn:hover, .bias-btn.selected { background: rgba(201,168,76,0.15); border-color: var(--gold); color: var(--gold); } .call-it-input { background: var(--bg-raised); border: 1px solid rgba(201,168,76,0.3); border-radius: var(--r); padding: 16px; margin-top: 12px; display: flex; flex-direction: column; gap: 10px; } .call-it-label { font-family: 'Bebas Neue', sans-serif; font-size: 0.75rem; letter-spacing: 0.12em; color: var(--gold); } .call-it-textarea { background: var(--bg-card); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: var(--text-primary); font-family: 'Inter', sans-serif; font-size: 0.95rem; line-height: 1.5; padding: 10px 12px; resize: none; width: 100%; box-sizing: border-box; } .call-it-textarea:focus { outline: none; border-color: var(--gold); box-shadow: 0 0 0 1px rgba(201,168,76,0.3); } .call-it-footer { display: flex; justify-content: space-between; align-items: center; } .call-it-counter { font-size: 0.7rem; color: var(--text-muted); font-family: 'Bebas Neue', sans-serif; letter-spacing: 0.08em; } .call-it-counter.at-limit { color: var(--red); } .call-it-warning { font-size: 0.7rem; color: var(--red); font-style: italic; } .call-it-confirm { background: var(--gold); color: var(--bg-base); border: none; border-radius: 8px; font-family: 'Bebas Neue', sans-serif; font-size: 0.9rem; letter-spacing: 0.1em; padding: 10px 20px; cursor: pointer; transition: all 0.15s ease; width: 100%; } .call-it-confirm:disabled { opacity: 0.35; cursor: not-allowed; } .call-it-confirm:not(:disabled):hover { background: #e8b830; transform: translateY(-1px); } .call-it-badge { font-family: 'Bebas Neue', sans-serif; font-size: 0.65rem; letter-spacing: 0.12em; color: var(--gold); text-align: center; margin-bottom: 8px; opacity: 0.7; } .call-it-badge.call-it-revised { color: var(--txt2); opacity: 0.85; margin-bottom: 4px; } .content-flag-banner { background: rgba(224,80,96,0.08); border: 1px solid rgba(224,80,96,0.35); border-radius: 8px; padding: 10px 14px; margin-bottom: 12px; font-family: 'Bebas Neue', sans-serif; font-size: 0.72rem; letter-spacing: 0.08em; color: var(--red-mid); text-align: center; } .forfeit-banner { background: rgba(224,80,96,0.1); border: 1px solid var(--red); border-radius: 8px; padding: 12px 16px; font-family: 'Bebas Neue', sans-serif; font-size: 0.85rem; letter-spacing: 0.08em; color: var(--red); text-align: center; margin-bottom: 16px; } .silence-pick { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; } .silence-word-btn { padding: 8px 14px; background: var(--bg-table); border: 1px solid rgba(139,26,42,0.35); border-radius: var(--r); font-family: 'Bebas Neue', sans-serif; font-size: 0.78rem; letter-spacing: 0.06em; color: var(--red-mid); cursor: pointer; } .silence-word-btn.selected { background: rgba(139,26,42,0.2); border-color: var(--red-mid); } .btn-sm-gold { padding: 10px 18px; background: rgba(201,168,76,0.15); border: 1px solid rgba(201,168,76,0.4); border-radius: var(--r); font-family: 'Bebas Neue', sans-serif; font-size: 0.72rem; letter-spacing: 0.12em; color: var(--gold); cursor: pointer; margin-right: 8px; } .btn-sm-gold:hover { background: rgba(201,168,76,0.28); } .btn-sm-gold.armed { background: rgba(74,140,92,0.18); border-color: var(--green); color: var(--green); } .chip.echo-eligible { opacity: 0.55; } .chip.used-pool.echo-eligible.echo-active { opacity: 1; cursor: pointer; pointer-events: auto; filter: none; border-color: var(--green) !important; } .chip-wildcard-injected { border-color: var(--wild-c) !important; } .btn-appeal { background: rgba(139,26,42,0.15) !important; border: 1px solid rgba(139,26,42,0.45) !important; color: var(--red-mid) !important; margin-bottom: 12px; } .btn-appeal .loading-dots span { color: #c9a84c; } /* ── Judge card ── */ .judge-card { max-width: 360px; margin: 20px auto 28px; text-align: center; padding: 20px 24px; position: relative; background: var(--bg-card); border: 1px solid rgba(201,168,76,0.28); border-radius: var(--r); box-shadow: 0 8px 32px rgba(0,0,0,0.7); } .judge-section-label { font-family: 'Bebas Neue', sans-serif; font-size: 0.65rem; letter-spacing: 0.18em; color: var(--text-secondary); margin-bottom: 12px; } .judge-portrait-wrap { position: relative; display: flex; align-items: center; justify-content: center; width: 72px; height: 72px; margin: 0 auto 10px; } .judge-emoji { font-size: 3.6rem; display: block; text-align: center; position: relative; z-index: 2; } .judge-portrait-wrap .portrait-slot { display: flex; } .judge-name { font-family: 'Cinzel', serif; font-size: 1rem; color: var(--gold); letter-spacing: 0.05em; margin: 8px 0 6px; } .judge-take-inline { font-size: 0.82rem; color: var(--gold); font-style: italic; line-height: 1.4; margin: 6px 0 10px; opacity: 0.85; } .judge-bias { font-size: 0.76rem; color: var(--text-secondary); font-style: italic; line-height: 1.45; } .judge-glow { position: absolute; inset: -28px; background: radial-gradient(circle at center, rgba(201,168,76,0.1) 0%, transparent 65%); pointer-events: none; border-radius: 50%; } /* ── Arguments ── */ .arg-card { background: var(--bg-card); border: 1px solid rgba(201,168,76,0.25); border-radius: var(--r); margin: 12px 0; overflow: hidden; position: relative; } .opponent-argument { background: #0f0d22; border-color: rgba(139,26,42,0.2); } .arg-label { font-family: 'Bebas Neue', sans-serif; font-size: 0.74rem; letter-spacing: 0.22em; color: #a89a80; padding: 12px 26px 0; margin-bottom: 0; } .opponent-argument .arg-label { color: #7a6e8a; } .words-used { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 16px 14px; } .argument-sentences { display: flex; flex-direction: column; } .argument-sentence { display: flex; align-items: baseline; gap: 16px; padding: 12px 22px; } .argument-sentence + .argument-sentence { border-top: 1px solid rgba(201,168,76,0.07); } .opponent-argument .argument-sentence + .argument-sentence { border-color: rgba(139,26,42,0.08); } .sentence-number { font-family: 'Bebas Neue', sans-serif; font-size: 0.90rem; color: var(--txt3); letter-spacing: 0.1em; min-width: 22px; flex-shrink: 0; } .sentence-text { font-family: 'Inter', sans-serif; font-size: 1.15rem; color: var(--txt1); line-height: 1.55; flex: 1; } mark.word-highlight { background: rgba(201,168,76,0.16); color: var(--gold); padding: 1px 5px; border-radius: 2px; } .opponent-argument mark.word-highlight { background: rgba(139,26,42,0.2); color: var(--red-mid); } .opponent-formulating { background: #0f0d22; border: 1px dashed rgba(139,26,42,0.35); border-radius: var(--r); margin: 16px 0; padding: 28px 22px; text-align: center; } .opponent-formulating.fade-in { animation: fadeIn .4s ease forwards, pulse 2s ease-in-out .4s infinite; } .formulating-text { font-family: 'Bebas Neue', sans-serif; font-size: 0.98rem; letter-spacing: 0.22em; color: #c16070; margin-bottom: 10px; } .formulating-sub { font-family: 'Inter', sans-serif; font-style: italic; font-size: 1.02rem; color: var(--txt3); } /* ── Scores ── */ .score-track { background: rgba(255,255,255,.07); border-radius: 4px; height: 10px; overflow: hidden; } .score-fill { height: 100%; border-radius: 4px; transition: width 1s; } .score-fill.p { background: linear-gradient(90deg, #3d2870, var(--purple)); } .score-fill.o { background: linear-gradient(90deg, #5a1020, var(--red)); } /* ── Verdict ── */ .verdict-card { background: var(--bg-card); border: 1px solid rgba(201,168,76,0.28); border-radius: var(--r); overflow: hidden; margin: 16px 0; } .verdict-card-body { padding: 24px 29px; } .verdict-who { font-family: 'Bebas Neue', sans-serif; font-size: 1.06rem; letter-spacing: 0.12em; color: var(--gold); margin-bottom: 12px; } .verdict-text { font-family: 'Inter', sans-serif; font-style: italic; color: var(--txt1); font-size: 1.14rem; line-height: 1.6; } /* ── Winner banners ── */ .w-you { background: rgba(201,168,76,0.07); border: 1px solid rgba(201,168,76,0.28); border-radius: var(--r); padding: 13px 24px; text-align: center; color: var(--gold); font-family: 'Bebas Neue', sans-serif; font-size: 1.06rem; letter-spacing: 0.22em; } .w-opp { background: rgba(139,26,42,0.08); border: 1px solid rgba(139,26,42,0.3); border-radius: var(--r); padding: 13px 24px; text-align: center; color: var(--red-mid); font-family: 'Bebas Neue', sans-serif; font-size: 1.06rem; letter-spacing: 0.22em; } .w-draw { background: rgba(201,168,76,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: var(--r); padding: 13px 24px; text-align: center; color: rgba(201,168,76,0.7); font-family: 'Bebas Neue', sans-serif; font-size: 1.06rem; letter-spacing: 0.22em; } .w-you.w-banner-lg, .w-opp.w-banner-lg, .w-draw.w-banner-lg { font-size: 2.16rem; margin-bottom: 29px; } /* ── Big score ── */ .big-score { font-family: 'Bebas Neue', sans-serif; font-size: 6.24rem; color: var(--gold); text-align: center; line-height: 0.9; letter-spacing: -0.02em; } .big-score.opp { color: var(--red); } .score-vs { display: flex; gap: 34px; justify-content: center; align-items: center; margin: 29px 0; } .score-side { text-align: center; } .score-lbl { font-family: 'Bebas Neue', sans-serif; font-size: 0.84rem; letter-spacing: 0.22em; color: var(--txt3); margin-bottom: 6px; } /* ── Tally bar ── */ .tally { background: rgba(10,8,18,0.97); border-bottom: 1px solid rgba(201,168,76,0.07); padding: 11px 48px; display: flex; align-items: center; justify-content: center; gap: 24px; position: sticky; top: 0; z-index: 10; font-family: 'Bebas Neue', sans-serif; } .tally-you { font-size: 0.94rem; letter-spacing: 0.2em; color: #c9a84c; } .tally-score { font-size: 1.38rem; } .tally-score.you { color: #c9a84c; } .tally-score.opp { color: #8b1a2a; } .tally-sep { font-size: 0.84rem; color: #3d3550; letter-spacing: 0.1em; } .tally-opp { font-size: 0.94rem; letter-spacing: 0.2em; color: #7a6e8a; } /* ── Breakdown ── */ .bd-row { display: flex; align-items: center; gap: 14px; margin: 10px 0; } .bd-label { width: 168px; font-family: 'Inter', sans-serif; font-size: .94rem; color: var(--txt2); flex-shrink: 0; } .bd-bars { flex: 1; display: flex; flex-direction: column; gap: 4px; } /* ── Collapsible ── */ .coll-hdr { cursor: pointer; user-select: none; display: flex; align-items: center; gap: 8px; justify-content: space-between; } .coll-body { overflow: hidden; max-height: 0; transition: max-height .3s; } .coll-body.open { max-height: 2000px; } /* ── Score math breakdown ── */ .sb-section { margin: 14px 0; padding: 12px 14px; background: rgba(0,0,0,.2); border-radius: var(--r); } .sb-head { font-family: 'Bebas Neue', sans-serif; font-size: .82rem; letter-spacing: .12em; color: var(--txt3); margin-bottom: 10px; } .sb-line { display: flex; align-items: center; gap: 10px; font-family: 'Inter', sans-serif; font-size: 1.02rem; color: var(--txt2); margin: 5px 0; } .sb-word { min-width: 108px; font-weight: 700; color: var(--txt1); } .sb-dots { font-size: .90rem; letter-spacing: 2px; color: var(--gold); } .sb-final { font-family: 'Bebas Neue', sans-serif; font-size: 1.32rem; color: var(--gold); letter-spacing: 0.08em; margin-top: 12px; } /* ── Recap table ── */ .recap { width: 100%; border-collapse: collapse; margin: 19px 0; font-family: 'Inter', sans-serif; font-size: 1.06rem; } .recap th { font-family: 'Bebas Neue', sans-serif; color: #e0c878; text-transform: uppercase; font-size: .78rem; letter-spacing: .18em; padding: 10px 12px; text-align: left; border-bottom: 1px solid rgba(201,168,76,0.22); } .recap td { padding: 10px; border-bottom: 1px solid rgba(255,255,255,.03); color: var(--txt2); } .recap td:first-child { color: var(--txt1); font-weight: 700; } /* ── Motion card ── */ .motion-card { position: relative; text-align: center; margin: 16px 0; padding: 0 16px; } .motion-divider { height: 1px; background: linear-gradient(to right, transparent, rgba(201,168,76,0.45), transparent); margin: 18px 0; } .motion-quote { display: none; } .motion-statement-label { font-family: 'Bebas Neue', sans-serif; font-size: 0.7rem; letter-spacing: 0.18em; color: var(--text-secondary); text-align: center; margin-bottom: 10px; } .motion-text { font-family: 'Inter', sans-serif; font-size: 1.70rem; color: var(--txt1); line-height: 1.4; text-wrap: pretty; } .round-step-label { font-family: 'Bebas Neue', sans-serif; font-size: 0.72rem; letter-spacing: 0.15em; color: var(--gold); text-align: left; margin-bottom: 12px; padding-left: 2px; } .verdict-topic-block { text-align: center; margin: 0 0 20px; } .verdict-topic-text { font-size: 0.9rem; color: var(--text-secondary); font-style: italic; text-align: center; margin-bottom: 6px; } /* ── Side choice ── */ .side-choice { margin: 24px 0 19px; text-align: left; } .side-buttons { display: flex; gap: 19px; margin-top: 14px; } .side-btn { flex: 1; padding: 24px 19px; border-radius: var(--r); border: 2px solid rgba(201,168,76,0.1); background: var(--bg-card); cursor: pointer; transition: all 0.15s ease; display: flex; flex-direction: column; align-items: center; gap: 4px; font-family: 'Bebas Neue', sans-serif; color: var(--txt1); } .for-btn:hover { border-color: var(--green); background: rgba(74,140,92,0.1); } .against-btn:hover { border-color: var(--red-mid); background: rgba(139,26,42,0.1); } .side-btn.selected { transform: translateY(-4px); } .for-btn.selected { border-color: var(--green); box-shadow: 0 0 0 1px var(--green), 0 0 16px rgba(74,140,92,0.3); } .against-btn.selected { border-color: var(--red-mid); box-shadow: 0 0 0 1px var(--red-mid), 0 0 16px rgba(139,26,42,0.3); } .side-btn:disabled { cursor: default; opacity: 1; } .side-btn:disabled:not(.selected) { opacity: 0.35; } .side-btn span { font-size: 1.8rem; font-weight: 700; letter-spacing: 0; } .side-sub { font-family: 'Inter', sans-serif; font-size: 0.90rem; color: #d4c8a8; } /* ── Setup step reveal ── */ .setup-step { transition: opacity .4s; } .setup-step.hidden { display: none; } /* ── Animations ── */ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.45} } @keyframes fadeIn { from{opacity:0;transform:translateY(12px)} to{opacity:1;transform:translateY(0)} } @keyframes scoreIn { 0%{opacity:0;transform:scale(0.7) translateY(10px)} 60%{opacity:1;transform:scale(1.05) translateY(-2px)} 100%{opacity:1;transform:scale(1) translateY(0)} } .fade-in { animation: fadeIn .4s ease forwards; } """ if config.GOLD_EMOJI_PORTRAITS: APP_CSS += """ /* Gold emoji tint — disable via config.GOLD_EMOJI_PORTRAITS */ .portrait-slot.portrait-fallback .portrait-emoji { filter: sepia(1) saturate(4) hue-rotate(5deg) brightness(0.95); } """ # ── Head extras (fonts + game JS inlined — external file= URLs don't load in Gradio 5.20) ─ def _build_app_head() -> str: js_path = Path(__file__).parent / "podium.js" js_content = js_path.read_text(encoding="utf-8") return f""" """ APP_HEAD = _build_app_head() # ── HTML helpers ───────────────────────────────────────────────────────────── def _sid_tag(sid: Optional[str]) -> str: return f'' def _tally(sid: Optional[str]) -> str: gs = _get_gs(sid) if not gs or not gs.scores: return "" p, o = engine.count_round_wins(gs) return (f'
' f'YOU' f'{p}' f'' f'{o}' f'OPPONENT' f'
') def _chip_font_size(word: str, *, preview: bool = False) -> str: if preview: if len(word) > 11: return "0.98" if len(word) > 8: return "1.13" return "1.26" if len(word) > 11: return "0.70" if len(word) > 8: return "0.86" return "1.03" def _chip_inner(w: engine.WordEntry, *, preview: bool = False) -> str: cat_map = {"adjective": "ADJ", "noun": "NOUN", "verb": "VERB", "wildcard": "CONCEPT", "wild": "CONCEPT"} cat_label = cat_map.get(w.category, w.category.upper()) fs = _chip_font_size(w.word, preview=preview) return f'{w.word}
{cat_label}' def _chip_html(w: engine.WordEntry, *, echo_eligible: bool = False) -> str: cat = "adj" if w.category == "adjective" else w.category inner = _chip_inner(w) if w.used: echo_cls = " echo-eligible" if echo_eligible else "" stamp = "🔁 ECHO" if echo_eligible else "SPENT" return (f'
' f'
{inner}
' f'
{stamp}
') return (f'
{inner}
') def _ability_card_usable(gs: GameState, card: content.AbilityCard) -> bool: return engine.is_ability_usable(gs, card) def _ability_uses_label(card: content.AbilityCard) -> str: return "1× USE" if card.uses == 1 else "∞ USE" def _ability_zone_html(gs: GameState, *, commit_step: bool = False, embedded: bool = False) -> str: if not config.ABILITIES_ENABLED or not gs.abilities_hand: return "" playable = engine.usable_pre_round_abilities(gs) requires_choice = bool(playable) silence_preview = ",".join(engine.get_silence_preview(gs, 3)) echo_words = ",".join(w.word for w in gs.player_pool if w.used) meta = ( f'' ) tray_cards = "" for card in gs.abilities_hand: lock_reason = engine.ability_unavailable_reason(gs, card, context="pre-round") locked_cls = " locked" if lock_reason else "" safe_desc = card.description.replace('"', """) safe_lock = (lock_reason or "").replace('"', """) tray_cards += f"""
{card.emoji}
{card.name}
""" skip_card = "" if requires_choice: skip_card = f"""
SKIP
""" step_label = ( "STEP III — PLAY AN ABILITY?" if commit_step else "PLAY AN ABILITY?" ) subtext = ( "Each card is one use. Arm one, or Skip for +5 if you still have a playable card." if commit_step and requires_choice else "Each card is one use. Greyed-out cards unlock later." if commit_step else "Optional — one card per round." ) zone_cls = "setup-step hidden" if embedded else "setup-step" continue_btn = "" if commit_step and not embedded: continue_btn = """ """ return f"""{meta}
{step_label}
{subtext}
{tray_cards}{skip_card}
{continue_btn}
""" def _can_appeal(gs: GameState, log: RoundLog) -> bool: if not config.ABILITIES_ENABLED or gs.appeal_used: return False if not any(c.id == "appeal" for c in gs.abilities_hand): return False ps = log.judge_result.get("player_score", 50) opp_score = log.judge_result.get("opponent_score", 50) rw = log.judge_result.get("round_winner") if rw == "opponent": return True if rw == "player": return False return ps < opp_score def _validate_ability_play(gs: GameState, ability_id: Optional[str], ability_args: dict) -> None: if not ability_id: return if not config.ABILITIES_ENABLED: raise ValueError("Abilities are disabled.") card = content.ABILITY_CARD_MAP.get(ability_id) if card is None: raise ValueError(f"Unknown ability: {ability_id}") if card.id == "appeal": raise ValueError("Appeal can only be used from the verdict screen after a loss.") hand_ids = {c.id for c in gs.abilities_hand} if card.id not in hand_ids: raise ValueError(f"You don't have {card.name} in your hand.") if not _ability_card_usable(gs, card): raise ValueError(f"{card.name} is not available.") if card.id == "wildcard_card" and not (ability_args.get("wildcard_word") or "").strip(): raise ValueError("Enter a word for Wildcard.") if card.id == "plant" and not (ability_args.get("plant_word") or "").strip(): raise ValueError("Enter a word to plant in the opponent's argument.") if card.id == "bias": emphasis = (ability_args.get("bias_emphasis") or "").strip().lower() if emphasis not in ("logic", "emotion", "creativity"): raise ValueError("Pick argument style: logic, emotion, or creativity.") if card.id == "silence" and not (ability_args.get("silence_word") or "").strip(): raise ValueError("Choose a word to silence from the opponent's pool.") if card.id == "call_it" and not (ability_args.get("custom_topic") or "").strip(): raise ValueError("Write a topic for Call It.") def _pool_chip_html(w: engine.WordEntry) -> str: cat = "adj" if w.category == "adjective" else w.category inner = _chip_inner(w, preview=True) return f'
{inner}
' def _debater_card_html(d: content.Debater, *, used: bool = False) -> str: ucls = " used" if used else "" portrait = portraits.portrait_html(d.emoji, d.id) return f"""
{portrait}
{d.name}
"{d.line}"
{d.tag}
""" def _debater_summary_html(d: content.Debater, *, accent: str = "rgba(201,168,76,0.25)") -> str: portrait = portraits.portrait_html(d.emoji, d.id) return f"""
{portrait}
{d.name}
"{d.line}"
{d.tag}
""" def _side_breakdown_html(label: str, bd: dict, color: str) -> str: word_lines = "" for wd in bd.get("word_details", []): score = wd.get("score", 0) dots = "●" * score + "○" * (2 - score) pts = wd.get("points", 0) bonus = f" {wd['bonus_note']}" if wd.get("bonus_note") else "" sign = f"+{pts}" if pts >= 0 else str(pts) word_lines += ( f'
{wd.get("word","")}' f'{dots}' f'{wd.get("label","")} ({sign}{bonus})
' ) deb = bd.get("debater_bonus", 0) deb_lines = "" if bd.get("edge_bonus", 0): deb_lines += f'
Edge ({bd["edge_bonus"]:.0f}★): persuasion weight
' if bd.get("precision_bonus", 0): deb_lines += f'
Precision ({bd["precision_bonus"]:.0f}★): coherence weight
' if bd.get("style_bonus", 0): deb_lines += f'
Style ({bd["style_bonus"]:.0f}★): judge appeal weight
' if bd.get("contrarian_bonus", 0): deb_lines += f'
Contrarian: +{bd["contrarian_bonus"]}
' swing = bd.get("risk_swing", 0) if swing != 0: deb_lines += f'
Risk ({bd.get("risk_level", 1)}★): {"+" if swing > 0 else ""}{swing} swing
' if bd.get("skip_bonus", 0): deb_lines += f'
Skip bonus: +{bd["skip_bonus"]}
' if bd.get("wildcard_synergy_bonus", 0): deb_lines += f'
Wildcard synergy: +{bd["wildcard_synergy_bonus"]}
' coh = bd.get("coherence", 0) per = bd.get("persuasion", 0) ja = bd.get("judge_appeal", 0) return f"""
{label}
WORD INTEGRATION   {bd.get('word_integration', 0)} / 60
{word_lines}
ARGUMENT QUALITY   {bd.get('quality_total', 0):.0f} / 40
Coherence
{coh}
Persuasion
{per}
Judge Appeal
{ja}
DEBATER BONUS   {'+' if deb >= 0 else ''}{deb:.0f}
{deb_lines}
FINAL SCORE   {bd.get('final_score', 0)}
{"
" + bd.get('archetype_note','') + "
" if bd.get('archetype_note') else ""}
""" def _normalize_sentences(sentences: Optional[List[str]], fallback_text: str = "") -> List[str]: if sentences and len(sentences) >= 3: return [s.strip() for s in sentences[:3]] parts = [s.strip() for s in re.split(r'(?<=[.!?])\s+', (fallback_text or "").strip()) if s.strip()] while len(parts) < 3: parts.append("") return parts[:3] def _highlight_word(sentence: str, word: str) -> str: if not word: return sentence result = re.sub( r'\b(' + re.escape(word.strip()) + r')\b', r'\1', sentence, count=1, flags=re.IGNORECASE, ) return result _ROMAN = {1: "I", 2: "II", 3: "III"} def _arg_html( sentences: List[str], words: List[str], label: str, border_hex: str, side: str, *, debater_header: str = "", ) -> str: """Render 3 numbered sentence blocks with per-sentence word highlights.""" side_cls = "player-argument" if side == "player" else "opponent-argument" sents = _normalize_sentences(sentences) if side == "opponent": strip_top = "rgba(139,26,42,0.4)" strip_bot = "rgba(139,26,42,0.15)" hl_bg = "rgba(139,26,42,0.2)" hl_color = "#c16070" sep_color = "rgba(139,26,42,0.08)" else: strip_top = "rgba(201,168,76,0.5)" strip_bot = "rgba(201,168,76,0.2)" hl_bg = "rgba(201,168,76,0.16)" hl_color = "#c9a84c" sep_color = "rgba(201,168,76,0.07)" out = f'
\n' out += f'
\n' if debater_header: out += f'
{debater_header}
\n' out += f'
{label}
\n' out += '
\n' for i, (sentence, word) in enumerate(zip(sents, (words + ["", "", ""])[:3]), 1): raw_hl = _highlight_word(sentence, word) hl = raw_hl.replace('', f'').replace('', '') sep = f'
\n' if i > 1 else "" out += sep out += f'''
{_ROMAN[i]} {hl}
\n''' out += '
\n' if words: out += f'
\n' out += '
' return out def _debater_compact_html(d: "content.Debater", side: Optional[str] = None) -> str: """Compact debater identifier for argument block headers.""" badge = "" if side: badge_cls = "for" if side == "for" else "against" badge = f'{side.upper()}' return ( f'
' f'{portraits.portrait_html(d.emoji, d.id, css_class="arg-debater-compact-emoji portrait-slot")}' f'{html.escape(d.name.upper())}' f'{badge}' f'
' ) def _render_argument_block( sentences: List[str], words: List[str], label: str, border_hex: str, side: str, *, debater: Optional["content.Debater"] = None, side_label: Optional[str] = None, ) -> str: """Argument block with optional compact debater header (Stage 5).""" header = _debater_compact_html(debater, side_label) if debater else "" return _arg_html(sentences, words, label, border_hex, side, debater_header=header) # ── SCREEN 1: LANDING ───────────────────────────────────────────────────────── def render_landing(sid: Optional[str] = None) -> str: return f""" {_sid_tag(sid)}
THE PODIUM
Pick your words. Choose your champion. Convince the judge.
""" # ── SCREEN 2: SETUP ─────────────────────────────────────────────────────────── def render_setup(sid: str, gs: GameState) -> str: # Word pool banner pool_chips = "".join(_pool_chip_html(w) for w in gs.player_pool) # Debater cards d_cards = "".join(_debater_card_html(d) for d in gs.all_debaters) # Flip cards flip_html = "" if config.ABILITIES_ENABLED and gs.abilities_hand: for i, card in enumerate(gs.abilities_hand): if card.rarity_weight <= 3: dot_html = '
' * 3 tag_color = "#8b1a2a" elif card.rarity_weight <= 6: dot_html = '
' * 2 tag_color = "#5b7fa6" else: dot_html = '
' tag_color = "#4a8c5c" flip_html += f"""
P
ABILITY
{dot_html}
{card.emoji}
{card.name}
{card.description}
{_ability_uses_label(card)}
""" else: flip_html = '
Abilities disabled.
' return f""" {_sid_tag(sid)}
YOUR WORD POOL FOR THIS GAME
You'll use these words in the rounds ahead. Pick debaters who suit them.
{pool_chips}
STEP I — CHOOSE YOUR 3 DEBATERS
0 / 3 SELECTED
Click 3 debaters below. Each one argues for you in a different round.
{d_cards}
""" # ── SCREEN 3: ROUND ─────────────────────────────────────────────────────────── def _side_choice_html(gs: GameState) -> str: waiting = gs.waiting_for_side_choice ps = gs.current_player_side if waiting: for_attr = ' onclick="chooseSide(\'for\')"' against_attr = ' onclick="chooseSide(\'against\')"' for_sel = against_sel = disabled = "" else: for_attr = against_attr = "" for_sel = " selected" if ps == "for" else "" against_sel = " selected" if ps == "against" else "" disabled = " disabled" side_label = "" if not waiting and ps: side_label = f'
YOUR POSITION: {"FOR" if ps=="for" else "AGAINST"}
' return f"""
STEP I — PICK YOUR SIDE
{side_label}
""" def _round_setup_html(gs: GameState) -> str: """Debater pick + ability; shown while waiting for side/debater commit.""" roster_html = "".join( _debater_card_html(d, used=d.id in gs.player_debaters_used) for d in gs.player_roster ) has_abilities = bool(config.ABILITIES_ENABLED and engine.usable_pre_round_abilities(gs)) abilities_attr = ' data-abilities="1"' if has_abilities else "" ability_block = ( _ability_zone_html(gs, commit_step=True, embedded=True) if has_abilities else "" ) # Embed opponent data for the client-side VS animation opp = gs.opponent_round_debater opp_emoji = html.escape(opp.emoji) if opp else "" opp_name = html.escape(opp.name) if opp else "" opp_portrait = portraits.portrait_html(opp.emoji, opp.id) if opp else "" return f"""
STEP II — CHOOSE YOUR DEBATER
{roster_html}
{ability_block}
""" def _render_judge_take_inline(judge_take: str) -> str: if not judge_take: return "" return f'
{html.escape(judge_take)}
' def _judge_card_html(j: content.Judge, judge_take: str = "") -> str: take_html = _render_judge_take_inline(judge_take) portrait = portraits.portrait_html(j.emoji, j.id) return f"""
THIS ROUND'S JUDGE
{portrait}
{j.name.upper()}
{take_html}
{html.escape(j.bias_blurb)}
""" def _content_flag_banner_html(gs: GameState, log: Optional[RoundLog] = None) -> str: active = log.content_flag_active if log else gs.content_flag_active ability = log.content_flag_ability if log else gs.content_flag_ability if not active or not ability: return "" msg = engine.content_flag_message(ability) if not msg: return "" return f'
🚫 {html.escape(msg)}
' def render_round(sid: str, gs: GameState, state: dict) -> str: phase = state.get("round_phase", "selecting") rid = f"r{gs.round_num}_{id(gs)%9999}" debater_attr = f' data-debater-id="{gs.round_debater.id}"' if gs.round_debater else "" player_side_attr = f' data-player-side="{gs.current_player_side}"' if gs.current_player_side else "" marker = f'' call_it_badge = "" if gs.call_it_active: call_it_badge = ( '
⚡ YOUR TOPIC · SCORE 80+ TO STAY ALIVE
' ) if gs.call_it_rewritten: call_it_badge += ( '
⚖️ REVISED BY THE JUDGE
' ) content_flag_banner = _content_flag_banner_html(gs) j = gs.current_judge safe_topic = html.escape(gs.current_topic) motion = f"""
{call_it_badge} {content_flag_banner}
THE STATEMENT
\u201c{safe_topic}\u201d
""" judge = _judge_card_html(j, gs.judge_take) sides = _side_choice_html(gs) if gs.waiting_for_side_choice: setup_block = _round_setup_html(gs) else: setup_block = "" if phase in ("player_submitted", "submitted"): p_sents = state.get("player_argument_sentences") or gs.pending_player_argument_sentences o_sents = state.get("opponent_argument_sentences") or gs.pending_opponent_argument_sentences p_words = gs.pending_player_words or [] o_words = gs.pending_opponent_words or [] p_debater = gs.round_debater opp_debater = gs.opponent_round_debater p_side = gs.current_player_side opp_side = gs.current_opponent_side opp_label = opp_debater.name.upper() if opp_debater else "OPPONENT" if gs.current_first_to_argue == "opponent": args = (_render_argument_block(o_sents, o_words, "OPPONENT'S ARGUMENT", "#8b1a2a", "opponent", debater=opp_debater, side_label=opp_side) + _render_argument_block(p_sents, p_words, "YOUR ARGUMENT", "#c9a84c", "player", debater=p_debater, side_label=p_side)) elif phase == "player_submitted": args = ( _render_argument_block(p_sents, p_words, "YOUR ARGUMENT", "#c9a84c", "player", debater=p_debater, side_label=p_side) + f"""
{opp_label} IS FORMULATING THEIR RESPONSE
Reading your argument and choosing their words
""" ) else: args = (_render_argument_block(p_sents, p_words, "YOUR ARGUMENT", "#c9a84c", "player", debater=p_debater, side_label=p_side) + _render_argument_block(o_sents, o_words, "OPPONENT'S ARGUMENT", "#8b1a2a", "opponent", debater=opp_debater, side_label=opp_side)) judge_btn = "" if phase == "submitted": judge_btn = """
""" return f""" {_sid_tag(sid)} {marker}
{_tally(sid)}
ROUND {gs.round_num} OF {config.ROUNDS}
{motion}{judge}{sides}
{args}
{judge_btn}
""" # ── opponent_first_pending phase ── if phase == "opponent_first_pending": opp_debater = gs.opponent_round_debater opp_label = opp_debater.name.upper() if opp_debater else "OPPONENT" return f""" {_sid_tag(sid)} {marker}
{_tally(sid)}
ROUND {gs.round_num} OF {config.ROUNDS}
{motion}{judge}{sides}
{opp_label} IS PREPARING THEIR OPENING
They argue first this round — their argument is on the way
""" # ── selecting phase ── echo_ok = gs.round_num >= 2 echo_committed = gs.pending_ability_played == "echo" chips = "".join( _chip_html(w, echo_eligible=echo_ok and echo_committed and w.used) for w in gs.player_pool ) echo_words = ",".join(w.word for w in gs.player_pool if w.used) ability_meta = ( f'' ) echo_attr = ' data-echo-committed="1"' if echo_committed else "" # Opponent argument preview (shown immediately when available) if gs.current_first_to_argue == "opponent" and gs.opponent_argument_preview_sentences: opp_prev = f"""
{_arg_html(gs.opponent_argument_preview_sentences, gs.opponent_preview_words or [], "OPPONENT'S ARGUMENT", "#8b1a2a", "opponent")}
""" else: opp_prev = "" post_side = "" if gs.waiting_for_side_choice else opp_prev select_block = "" if not gs.waiting_for_side_choice and gs.ability_committed: select_cls = "setup-step" select_block = f""" {ability_meta}
SELECT YOUR WORDS
0 / 3 WORDS SELECTED
{chips}
""" return f""" {_sid_tag(sid)} {marker}
{_tally(sid)}
ROUND {gs.round_num} OF {config.ROUNDS}
{motion}{judge}{sides}{setup_block}{post_side} {select_block}
""" # ── SCREEN 4: VERDICT ───────────────────────────────────────────────────────── def render_verdict(sid: str, gs: GameState, log: RoundLog) -> str: r = log.judge_result ps = r.get("player_score", 50) opp_score = r.get("opponent_score", 50) verdict = r.get("verdict", "") bd = r.get("breakdown", {}) p_bd = bd.get("player", {}) o_bd = bd.get("opponent", {}) j = log.judge is_appeal_render = log.ability_played == "appeal" rid = f"v{log.round_num}_{id(log)%9999}{'_a' if is_appeal_render else ''}" call_it_forfeit = r.get("call_it_forfeit", False) appeal_hollow_victory = r.get("appeal_hollow_victory", False) tiebreaker_used = r.get("tiebreaker_used", False) round_winner = r.get("round_winner") if not round_winner: if ps > opp_score: round_winner = "player" elif opp_score > ps: round_winner = "opponent" else: round_winner = "draw" forfeit_banner = "" if call_it_forfeit: forfeit_banner = ( '
' '⚡ CALL IT FORFEIT — Score fell below 80. Round goes to opponent.' '
' ) content_flag_banner = _content_flag_banner_html(gs, log=log) if call_it_forfeit or round_winner == "opponent": if tiebreaker_used and not call_it_forfeit: banner = ( '
✗ OPPONENT WINS
' '
' 'Dead heat on points — the judge broke the tie.' '
' ) else: banner = '
✗ OPPONENT WINS
' elif appeal_hollow_victory: banner = ( '
⚖️ YOU WIN THIS ROUND
' '
' 'Appeal granted — but you earn 0 points. A victory without honor.' '
' ) elif round_winner == "player": if tiebreaker_used: banner = ( '
✓ YOU WIN THIS ROUND
' '
' 'Dead heat on points — the judge broke the tie.' '
' ) else: banner = '
✓ YOU WIN THIS ROUND
' else: banner = '
═ A DRAW
' score_anim = f"""
YOU
0
vs
OPPONENT
0
""" if gs.current_first_to_argue == "opponent": args = (_render_argument_block(log.opponent_argument_sentences, log.opponent_words, "OPPONENT'S ARGUMENT", "#8b1a2a", "opponent", debater=log.opponent_debater, side_label=log.opponent_side) + _render_argument_block(log.player_argument_sentences, log.player_words, "YOUR ARGUMENT", "#c9a84c", "player", debater=log.player_debater, side_label=log.player_side)) else: args = (_render_argument_block(log.player_argument_sentences, log.player_words, "YOUR ARGUMENT", "#c9a84c", "player", debater=log.player_debater, side_label=log.player_side) + _render_argument_block(log.opponent_argument_sentences, log.opponent_words, "OPPONENT'S ARGUMENT", "#8b1a2a", "opponent", debater=log.opponent_debater, side_label=log.opponent_side)) def bar(label, pv, ov): return f"""
{label}
{pv}
{ov}
""" bars = (bar("Coherence", p_bd.get("coherence", 0), o_bd.get("coherence", 0)) + bar("Persuasion", p_bd.get("persuasion", 0), o_bd.get("persuasion", 0)) + bar("Judge Appeal", p_bd.get("judge_appeal", 0), o_bd.get("judge_appeal", 0))) math_breakdown = ( _side_breakdown_html("YOU", p_bd, "#7a5ba6") + _side_breakdown_html("OPPONENT", o_bd, "#8b1a2a") ) prw, orw = engine.count_round_wins(gs) if gs.game_over: nxt = '' else: nxt = '' appeal_btn = "" if _can_appeal(gs, log): appeal_btn = """
Request a fresh re-score. Win and you take the round — but earn 0 points. Once per game.
""" judge_take_block = _render_judge_take_inline(gs.judge_take) verdict_topic_block = f"""
\u201c{html.escape(log.topic)}\u201d
{judge_take_block}
""" return f""" {_sid_tag(sid)}
{_tally(sid)}
ROUND {log.round_num} VERDICT
{forfeit_banner} {content_flag_banner} {banner} {score_anim} {verdict_topic_block}
{j.emoji} {j.name.upper()} DECLARES:
"{verdict}"
{args}
SCORE BREAKDOWN
■ YOU■ OPPONENT
{bars}
{math_breakdown}
{appeal_btn}
{nxt}
""" # ── SCREEN 5: FINAL ─────────────────────────────────────────────────────────── def _postgame_card_html(gs: GameState) -> str: stats = engine.compute_postgame_stats(gs) best = stats["best_word"] mvp_name = stats["mvp_debater"] mvp_debater = next((d for d in content.DEBATERS if d.name == mvp_name), None) mvp_emoji = mvp_debater.emoji if mvp_debater else "🏆" mvp_display = f"{mvp_emoji} {mvp_name}" if mvp_name != "—" else "—" near_miss_html = "" if stats["near_miss"]: near_miss_html = f"""
⚡ You were {stats['closest_margin']} points away from taking that round. One sharper word and it was yours.
""" return f"""
YOUR GAME IN REVIEW
BEST WORD
"{best['word']}" — {best['note']}
🏆
MVP DEBATER
{mvp_display}
💎
POWER WORDS LANDED
{stats['power_words_sharp']} of {stats['power_words_used']} used sharply
{near_miss_html}
""" def _leaderboard_html(ss: Optional[dict]) -> str: """Render session leaderboard panel. ss is a plain dict version of SessionStats.""" if not ss or ss.get("games_played", 0) == 0: return "" gp = ss.get("games_played", 0) gw = ss.get("games_won", 0) hrs = ss.get("highest_round_score", 0) hrt = ss.get("highest_round_score_topic", "") hrj = ss.get("highest_round_score_judge", "") sw = ss.get("sharpest_word", "") swn = ss.get("sharpest_word_note", "") bgt = ss.get("best_game_total", 0) best_round_detail = "" if hrt: best_round_detail = f'
"{hrt[:48]}{"…" if len(hrt) > 48 else ""}" · {hrj}
' sharpest_html = "" if sw: sharpest_html = f"""
SHARPEST WORD "{sw}"
{"
" + swn + "
" if swn else ""}
""" return f"""
SESSION RECORDS
GAMES PLAYED {gp}
WIN RATE {gw} / {gp}
HIGHEST ROUND SCORE {hrs}
{best_round_detail}
{sharpest_html}
BEST GAME TOTAL {bgt} pts
""" def render_final(sid: str, gs: GameState, session_stats: Optional[dict] = None) -> str: prw, orw = engine.count_round_wins(gs) tp = sum(a for a, b in gs.scores) to = sum(b for a, b in gs.scores) postgame = _postgame_card_html(gs) if gs.winner == "player": banner = '
THE PODIUM IS YOURS
' elif gs.winner == "opponent": banner = '
THE OPPONENT TAKES THE PODIUM
' else: banner = '
BOTH DEBATERS SHARE THE PODIUM
' rows = "" for log in gs.round_logs: ps_ = log.judge_result.get("player_score", "?") os_ = log.judge_result.get("opponent_score", "?") rw = log.judge_result.get("round_winner") if rw == "player": mk, mc = "✓", "#4a8c5c" elif rw == "opponent": mk, mc = "✗", "#c16070" elif ps_ > os_: mk, mc = "✓", "#4a8c5c" elif os_ > ps_: mk, mc = "✗", "#c16070" else: mk, mc = "═", "#c9a84c" rows += f""" {mk} Round {log.round_num} {log.topic[:52]}… {log.judge.emoji} {log.judge.name} {log.player_debater.emoji} {log.player_debater.name} {ps_} {os_} """ leaderboard = _leaderboard_html(session_stats) return f""" {_sid_tag(sid)}
{banner} {postgame} {leaderboard}
ROUNDS WON
{prw} vs {orw}
TOTAL POINTS
{tp} vs {to}
ROUND RECAP
{rows}
RoundTopicJudgeYour DebaterYouOpp
""" # ── Error screen ────────────────────────────────────────────────────────────── def render_error(sid: Optional[str], err: str) -> str: return f""" {_sid_tag(sid)}
SOMETHING WENT WRONG
{err}
""" # ── Main process_action ─────────────────────────────────────────────────────── def process_action(action_json: str) -> str: """Receive action JSON from JS, return new HTML screen. No gr.State needed.""" if not action_json or not action_json.strip(): return render_landing() try: action = json.loads(action_json) except json.JSONDecodeError: return render_landing() sid = action.get("_sid") atype = action.get("type", "") # Lock: prevent the same action from running twice simultaneously. # JS fires change + submit + button click — Gradio may receive all three. lock_key = f"{sid}:{atype}" with _ACTION_LOCK: if lock_key in _PROCESSING: state = _APP_STATES.get(sid, {}) return _render_from_state(sid, state) _PROCESSING.add(lock_key) state = _APP_STATES.get(sid, {}).copy() if sid else {} try: if atype == "init_game": return _do_init_game(action, sid, state) elif atype == "begin_game": return _do_begin_game(action, sid, state) elif atype == "commit_and_reveal": return _do_commit_and_reveal(action, sid, state) elif atype == "commit_round_setup": return _do_commit_round_setup(action, sid, state) elif atype == "commit_ability": return _do_commit_ability(action, sid, state) elif atype == "submit_turn": return _do_submit_turn(action, sid, state) elif atype == "generate_opponent_opening": return _do_generate_opponent_opening(action, sid, state) elif atype == "generate_opponent": return _do_generate_opponent(action, sid, state) elif atype == "use_appeal": return _do_use_appeal(sid, state) elif atype == "request_judge": return _do_request_judge(sid, state) elif atype == "next_round": return _do_next_round(sid, state) elif atype == "final_results": return _do_final_results(sid, state) elif atype == "rematch": return _do_rematch(sid, state) else: return _render_from_state(sid, state) except Exception: tb = traceback.format_exc() return render_error(sid, tb) finally: with _ACTION_LOCK: _PROCESSING.discard(lock_key) def _render_from_state(sid: Optional[str], state: dict) -> str: screen = state.get("screen", "landing") gs = _get_gs(sid) if screen == "setup" and gs: return render_setup(sid, gs) elif screen == "round" and gs: return render_round(sid, gs, state) elif screen == "verdict" and gs: log = _get_log(sid) if log: return render_verdict(sid, gs, log) elif screen == "final" and gs: return render_final(sid, gs, session_stats=state.get("session_stats")) return render_landing(sid) def _do_init_game(action: dict, old_sid: Optional[str], old_state: dict) -> str: # If there's already a setup-phase game for this session, just re-render it # (guards against duplicate queue events firing the same action twice). old_gs = _get_gs(old_sid) if old_gs and old_state.get("screen") == "setup": return render_setup(old_sid, old_gs) sess_words = old_gs.session_used_words if old_gs else [] session_stats = old_state.get("session_stats") gs = engine.new_game(session_used_words=sess_words, difficulty="normal") state = {"screen": "setup", "round_phase": "selecting", "_last": "", "difficulty": gs.difficulty} if session_stats: state["session_stats"] = session_stats state = _save(state, gs=gs) sid = state["session_id"] return render_setup(sid, gs) def _do_begin_game(action: dict, sid: Optional[str], state: dict) -> str: gs = _get_gs(sid) if not gs: return _do_init_game(sid, state) # Queued duplicate events after the first begin_game must not start another round. if state.get("screen") == "round" or gs.round_num > 0: return render_round(state.get("session_id", sid), gs, state) ids = action.get("debater_ids", []) try: gs = engine.choose_roster(gs, ids) gs = engine.start_round(gs) except Exception as e: return render_error(sid, str(e)) state["screen"] = "round" state["round_phase"] = "waiting_for_side_choice" state = _save(state, gs=gs) return render_round(state["session_id"], gs, state) def _do_commit_and_reveal(action: dict, sid: Optional[str], state: dict) -> str: """Choose side + debater, commit ability if armed. The client sends the randomly determined first_to_argue so we honour it rather than re-rolling. Returns opponent_first_pending when opponent goes first, or selecting when the player goes first.""" gs = _get_gs(sid) if not gs: return render_landing(sid) if not gs.waiting_for_side_choice: return _render_from_state(sid, state) side = action.get("side", "") debater_id = action.get("debater_id", "") ability_played = action.get("ability_played") ability_args = action.get("ability_args") or {} first_to_argue = action.get("first_to_argue") # "player" or "opponent" if not debater_id: return render_error(sid, "Choose a debater for this round.") # Honour the client's random pick by pre-seeding the field before choose_side; # the engine guard in _resolve_turn_order skips re-rolling when it is set. if first_to_argue in ("player", "opponent"): gs.current_first_to_argue = first_to_argue try: gs = engine.choose_side(gs, side, debater_id=debater_id) if gs.waiting_for_ability_choice: _validate_ability_play(gs, ability_played, ability_args) gs = engine.commit_round_ability(gs, ability_played, ability_args) except Exception as e: return render_error(sid, str(e)) state["screen"] = "round" if gs.current_first_to_argue == "opponent": state["round_phase"] = "opponent_first_pending" else: state["round_phase"] = "selecting" state = _save(state, gs=gs) return render_round(state["session_id"], gs, state) def _do_generate_opponent_opening(action: dict, sid: Optional[str], state: dict) -> str: """Triggered after reaching the word-select page when opponent argues first. Generates the opponent's opening argument then transitions to selecting.""" gs = _get_gs(sid) if not gs: return render_landing(sid) if state.get("round_phase") == "selecting": return _render_from_state(sid, state) try: gs = engine.generate_opponent_opening(gs) except Exception as e: return render_error(sid, str(e)) state["screen"] = "round" state["round_phase"] = "selecting" state = _save(state, gs=gs) return render_round(state["session_id"], gs, state) def _do_commit_round_setup(action: dict, sid: Optional[str], state: dict) -> str: """Legacy alias kept for any outstanding in-flight requests.""" return _do_commit_and_reveal(action, sid, state) def _do_commit_ability(action: dict, sid: Optional[str], state: dict) -> str: gs = _get_gs(sid) if not gs: return render_landing(sid) if not gs.waiting_for_ability_choice: return _render_from_state(sid, state) ability_played = action.get("ability_played") ability_args = action.get("ability_args") or {} try: _validate_ability_play(gs, ability_played, ability_args) gs = engine.commit_round_ability(gs, ability_played, ability_args) except Exception as e: return render_error(sid, str(e)) state["screen"] = "round" state["round_phase"] = "selecting" state = _save(state, gs=gs) return render_round(state["session_id"], gs, state) def _do_submit_turn(action: dict, sid: Optional[str], state: dict) -> str: gs = _get_gs(sid) if not gs: return render_landing(sid) # Duplicate event: turn already submitted — re-render. if state.get("round_phase") in ("player_submitted", "submitted"): return _render_from_state(sid, state) words = action.get("words", []) debater_id = action.get("debater_id", "") try: gs, p_sents, o_sents = engine.submit_player_turn(gs, words, words, debater_id) except Exception as e: return render_error(sid, str(e)) state["screen"] = "round" state["player_argument_sentences"] = p_sents if gs.current_first_to_argue == "player": state["round_phase"] = "player_submitted" else: state["round_phase"] = "submitted" state["opponent_argument_sentences"] = o_sents state = _save(state, gs=gs) return render_round(state["session_id"], gs, state) def _do_generate_opponent(action: dict, sid: Optional[str], state: dict) -> str: gs = _get_gs(sid) if not gs: return render_landing(sid) if state.get("round_phase") == "submitted": return _render_from_state(sid, state) if state.get("round_phase") != "player_submitted": return _render_from_state(sid, state) try: gs, o_sents = engine.generate_opponent_turn(gs) except Exception as e: return render_error(sid, str(e)) state["screen"] = "round" state["round_phase"] = "submitted" state["opponent_argument_sentences"] = o_sents state = _save(state, gs=gs) return render_round(state["session_id"], gs, state) def _do_use_appeal(sid: Optional[str], state: dict) -> str: gs = _get_gs(sid) if not gs: return render_landing(sid) log = _get_log(sid) if not log: return render_error(sid, "No round to appeal.") if not _can_appeal(gs, log): return render_error(sid, "Appeal is not available.") try: gs, log = engine.use_appeal(gs) except Exception as e: return render_error(sid, str(e)) state["screen"] = "verdict" state = _save(state, gs=gs, log=log) return render_verdict(state["session_id"], gs, log) def _do_request_judge(sid: Optional[str], state: dict) -> str: gs = _get_gs(sid) if not gs: return render_landing(sid) if state.get("round_phase") == "player_submitted": return _render_from_state(sid, state) # Duplicate event: verdict already rendered — re-render. if state.get("screen") == "verdict": log = _get_log(sid) if log: return render_verdict(sid, gs, log) try: gs, log = engine.finalize_round_scoring(gs) except Exception as e: return render_error(sid, str(e)) state["screen"] = "verdict" state = _save(state, gs=gs, log=log) return render_verdict(state["session_id"], gs, log) def _do_next_round(sid: Optional[str], state: dict) -> str: gs = _get_gs(sid) if not gs: return render_landing(sid) if gs.game_over: return _do_final_results(sid, state) # Duplicate queue events (change + submit + click) must not bump round_num twice. if state.get("screen") == "round": return render_round(state.get("session_id", sid), gs, state) if state.get("screen") != "verdict": return _render_from_state(sid, state) try: gs = engine.start_round(gs) except Exception as e: return render_error(sid, str(e)) state["screen"] = "round" state["round_phase"] = "waiting_for_side_choice" state = _save(state, gs=gs) return render_round(state["session_id"], gs, state) def _do_final_results(sid: Optional[str], state: dict) -> str: gs = _get_gs(sid) if not gs: return render_landing(sid) # Update session-level stats old_ss_dict = state.get("session_stats") or {} ss = engine.SessionStats(**{k: v for k, v in old_ss_dict.items() if k in engine.SessionStats.__dataclass_fields__}) ss = engine.update_session_stats(ss, gs) ss_dict = {k: getattr(ss, k) for k in engine.SessionStats.__dataclass_fields__} state["screen"] = "final" state["session_stats"] = ss_dict state = _save(state, gs=gs) return render_final(state["session_id"], gs, session_stats=ss_dict) def _do_rematch(sid: Optional[str], state: dict) -> str: old_gs = _get_gs(sid) sess_words = old_gs.session_used_words if old_gs else [] session_stats = state.get("session_stats") gs = engine.new_game(session_used_words=sess_words, difficulty="normal") new_state = {"screen": "setup", "round_phase": "selecting", "_last": "", "difficulty": gs.difficulty} if session_stats: new_state["session_stats"] = session_stats new_state = _save(new_state, gs=gs) return render_setup(new_state["session_id"], gs) # ── Gradio app ──────────────────────────────────────────────────────────────── # Gradio 6 moved css/head from Blocks() to launch(); HF Space still runs 5.x. _GRADIO_V6 = int(gr.__version__.split(".", 1)[0]) >= 6 _BLOCKS_KW = {} if _GRADIO_V6 else {"css": APP_CSS, "head": APP_HEAD} _ACTION_KW = {"container": False} if _GRADIO_V6 else {} with gr.Blocks(title="The Podium", **_BLOCKS_KW) as demo: ui = gr.HTML(value=render_landing(), elem_id="podium-ui") action_input = gr.Textbox(value="", elem_id="podium-action-input", show_label=False, **_ACTION_KW) action_btn = gr.Button("go", elem_id="podium-action-btn", **_ACTION_KW) _ev = dict(fn=process_action, inputs=[action_input], outputs=[ui], show_progress="hidden", queue=True, api_name="process_action") # Hidden button only — JS sets the textbox then clicks once (see PODIUM_JS). action_btn.click(**_ev) if __name__ == "__main__": import os import socket # Detect HF Spaces: the platform sets SPACE_ID in the environment. on_hf_spaces = bool(os.getenv("SPACE_ID")) launch_kwargs: dict = {} if _GRADIO_V6: launch_kwargs["css"] = APP_CSS launch_kwargs["head"] = APP_HEAD if on_hf_spaces: # HF Spaces manages its own networking — let Gradio use defaults. print("\n THE PODIUM — running on Hugging Face Spaces\n") else: # Local dev: find a free port and optionally kill stale listeners. def _free(port): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: try: s.bind(("0.0.0.0", port)) return True except OSError: return False def _stop_listeners_on_port(port: int) -> None: """Free a port before bind (avoids stale servers on the wrong localhost URL).""" if sys.platform != "win32": return try: result = subprocess.run( ["netstat", "-ano"], capture_output=True, text=True, check=False, ) pids: set[int] = set() needle = f":{port}" for line in result.stdout.splitlines(): line = line.strip() if "LISTENING" not in line: continue if needle not in line: continue pid_str = line.split()[-1] if pid_str.isdigit(): pid = int(pid_str) if pid > 0: pids.add(pid) for pid in pids: print(f" Stopping stale listener on :{port} (PID {pid})...") subprocess.run( ["taskkill", "/PID", str(pid), "/F"], capture_output=True, check=False, ) if pids: time.sleep(0.4) except Exception: pass preferred = int(os.getenv("GRADIO_SERVER_PORT", "7860")) reuse = os.getenv("PODIUM_REUSE_PORT", "1").lower() in ("1", "true", "yes") if reuse: _stop_listeners_on_port(preferred) _stop_listeners_on_port(preferred + 1) port = preferred if not _free(preferred): for p in range(preferred + 1, preferred + 20): if _free(p): port = p print(f"\n WARNING: Port {preferred} is still busy.") print(f" Open http://localhost:{port} (not :{preferred}).\n") break else: raise OSError(f"No free port found in {preferred}–{preferred+19}") open_browser = os.getenv("PODIUM_OPEN_BROWSER", "").lower() in ("1", "true", "yes") print(f"\n THE PODIUM — http://localhost:{port}\n") launch_kwargs["server_name"] = "0.0.0.0" launch_kwargs["server_port"] = port launch_kwargs["inbrowser"] = open_browser demo.queue(default_concurrency_limit=1, status_update_rate="auto") demo.launch(**launch_kwargs)