The-Podium / app.py
MannKnee's picture
Fix iframe-resizer 100vh feedback loop causing scroll to grow each screen.
2e2eb88
Raw
History Blame Contribute Delete
113 kB
"""
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 <head>) ─────────
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"""
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@700&family=Bebas+Neue&family=Inter:ital,wght@0,300;0,400;0,600;1,400&display=swap" rel="stylesheet">
<script>
{js_content}
</script>
"""
APP_HEAD = _build_app_head()
# ── HTML helpers ─────────────────────────────────────────────────────────────
def _sid_tag(sid: Optional[str]) -> str:
return f'<div id="podium-sid" data-sid="{sid or ""}" style="display:none;"></div>'
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'<div class="tally">'
f'<span class="tally-you">YOU</span>'
f'<span class="tally-score you">{p}</span>'
f'<span class="tally-sep">β€”</span>'
f'<span class="tally-score opp">{o}</span>'
f'<span class="tally-opp">OPPONENT</span>'
f'</div>')
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'<span class="chip-word" style="font-size:{fs}rem;">{w.word}</span><div class="chip-div"></div><span class="chip-cat">{cat_label}</span>'
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'<div class="chip-spent-wrap">'
f'<div class="chip {cat} used-pool{echo_cls}" data-word="{w.word}" '
f'title="Used round {w.round_used}">{inner}</div>'
f'<div class="chip-spent-stamp"><span>{stamp}</span></div></div>')
return (f'<div class="chip {cat}" data-word="{w.word}" data-order=""'
f' role="button" tabindex="0" onclick="window.podiumToggleWord(this)">{inner}</div>')
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'<div id="ability-meta" data-round="{gs.round_num}" '
f'data-silence-preview="{silence_preview}" data-echo-words="{echo_words}" '
f'data-skip-bonus="{config.SKIP_BONUS}" '
f'data-requires-choice="{"1" if requires_choice else "0"}" '
f'data-before-turn-order="{"1" if commit_step else "0"}" style="display:none;"></div>'
)
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('"', "&quot;")
safe_lock = (lock_reason or "").replace('"', "&quot;")
tray_cards += f"""
<div class="ab-mini{locked_cls}" data-ability-id="{card.id}" data-emoji="{card.emoji}"
data-name="{card.name}" data-desc="{safe_desc}" data-needs-input="{int(card.requires_input)}"
data-lock-reason="{safe_lock}"
onclick="window.podiumSelectAbility(this)">
<div class="ab-mini-check">βœ“</div>
<div class="ab-mini-emoji">{card.emoji}</div>
<div class="ab-mini-name">{card.name}</div>
</div>"""
skip_card = ""
if requires_choice:
skip_card = f"""
<div class="ab-mini ab-mini-skip" data-ability-id="skip" data-emoji="⊘" data-name="SKIP"
data-desc="Pass on playing an ability this round. +{config.SKIP_BONUS} points if you still have a card you could play." data-needs-input="0"
onclick="window.podiumSelectAbility(this)">
<div class="ab-mini-check">βœ“</div>
<div class="ab-mini-skip-line"></div>
<div class="ab-mini-emoji">⊘</div>
<div class="ab-mini-name">SKIP</div>
</div>"""
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 = """
<button id="ability-continue-btn" class="btn-gold" style="opacity:.4;margin-top:20px;" disabled
onclick="window.podiumCommitAbility(this);">
CONTINUE β†’
</button>"""
return f"""{meta}
<div id="ability-zone" class="{zone_cls}" style="margin-top:20px;">
<div class="step-head" style="margin-bottom:12px;">
<span class="step-label">{step_label}</span>
<div class="step-divider"></div>
</div>
<div class="subtext" style="margin-bottom:12px;">{subtext}</div>
<div class="ability-tray">{tray_cards}{skip_card}</div>
<div id="ability-panel" class="ability-panel hidden"></div>
{continue_btn}
</div>"""
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'<div class="chip pool-preview {cat}" style="cursor:default;pointer-events:none;">{inner}</div>'
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"""
<div class="debater-card card tex{ucls}" data-debater-id="{d.id}" data-tag="{d.tag}">
<div class="card-strip-top"></div>
<div class="debater-card-body">
<div class="debater-emoji" data-emoji="{html.escape(d.emoji)}">{portrait}</div>
<div class="debater-name">{d.name}</div>
<div class="debater-line">"{d.line}"</div>
<div class="debater-tag">{d.tag}</div>
</div>
<div class="card-strip-bot"></div>
</div>"""
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"""
<div class="debater-summary" style="border:1px solid {accent};">
{portrait}
<div>
<div class="debater-summary-name">{d.name}</div>
<div class="debater-summary-line">"{d.line}"</div>
<div class="debater-summary-tag">{d.tag}</div>
</div>
</div>"""
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'<div class="sb-line"><span class="sb-word">{wd.get("word","")}</span>'
f'<span class="sb-dots">{dots}</span>'
f'<span>{wd.get("label","")} ({sign}{bonus})</span></div>'
)
deb = bd.get("debater_bonus", 0)
deb_lines = ""
if bd.get("edge_bonus", 0):
deb_lines += f'<div class="sb-line">Edge ({bd["edge_bonus"]:.0f}β˜…): persuasion weight</div>'
if bd.get("precision_bonus", 0):
deb_lines += f'<div class="sb-line">Precision ({bd["precision_bonus"]:.0f}β˜…): coherence weight</div>'
if bd.get("style_bonus", 0):
deb_lines += f'<div class="sb-line">Style ({bd["style_bonus"]:.0f}β˜…): judge appeal weight</div>'
if bd.get("contrarian_bonus", 0):
deb_lines += f'<div class="sb-line">Contrarian: +{bd["contrarian_bonus"]}</div>'
swing = bd.get("risk_swing", 0)
if swing != 0:
deb_lines += f'<div class="sb-line">Risk ({bd.get("risk_level", 1)}β˜…): {"+" if swing > 0 else ""}{swing} swing</div>'
if bd.get("skip_bonus", 0):
deb_lines += f'<div class="sb-line">Skip bonus: +{bd["skip_bonus"]}</div>'
if bd.get("wildcard_synergy_bonus", 0):
deb_lines += f'<div class="sb-line">Wildcard synergy: +{bd["wildcard_synergy_bonus"]}</div>'
coh = bd.get("coherence", 0)
per = bd.get("persuasion", 0)
ja = bd.get("judge_appeal", 0)
return f"""
<div class="sb-section" style="border-left:3px solid {color};">
<div class="sb-side-head" style="color:{color};">{label}</div>
<div class="sb-head">WORD INTEGRATION &nbsp; {bd.get('word_integration', 0)} / 60</div>
{word_lines}
<div class="sb-head" style="margin-top:12px;">ARGUMENT QUALITY &nbsp; {bd.get('quality_total', 0):.0f} / 40</div>
<div class="sb-line">Coherence <div class="score-track" style="flex:1;max-width:120px;"><div class="score-fill p" style="width:{coh}%;"></div></div> {coh}</div>
<div class="sb-line">Persuasion <div class="score-track" style="flex:1;max-width:120px;"><div class="score-fill p" style="width:{per}%;"></div></div> {per}</div>
<div class="sb-line">Judge Appeal <div class="score-track" style="flex:1;max-width:120px;"><div class="score-fill p" style="width:{ja}%;"></div></div> {ja}</div>
<div class="sb-head" style="margin-top:12px;">DEBATER BONUS &nbsp; {'+' if deb >= 0 else ''}{deb:.0f}</div>
{deb_lines}
<div class="sb-final">FINAL SCORE &nbsp; {bd.get('final_score', 0)}</div>
{"<div class='note-sm'>" + bd.get('archetype_note','') + "</div>" if bd.get('archetype_note') else ""}
</div>"""
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'<mark class="word-highlight">\1</mark>',
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'<div class="arg-card tex {side_cls}">\n'
out += f' <div style="border-top:1px solid {strip_top};border-bottom:1px solid {strip_bot};height:14px;flex-shrink:0;"></div>\n'
if debater_header:
out += f' <div style="padding:12px 22px 0;">{debater_header}</div>\n'
out += f' <div class="arg-label">{label}</div>\n'
out += ' <div class="argument-sentences">\n'
for i, (sentence, word) in enumerate(zip(sents, (words + ["", "", ""])[:3]), 1):
raw_hl = _highlight_word(sentence, word)
hl = raw_hl.replace('<mark class="word-highlight">', f'<span style="background:{hl_bg};color:{hl_color};padding:1px 5px;border-radius:2px;">').replace('</mark>', '</span>')
sep = f' <div style="height:1px;background:{sep_color};margin:0 22px;"></div>\n' if i > 1 else ""
out += sep
out += f''' <div class="argument-sentence">
<span class="sentence-number">{_ROMAN[i]}</span>
<span class="sentence-text">{hl}</span>
</div>\n'''
out += ' </div>\n'
if words:
out += f' <div style="border-top:1px solid {strip_bot};border-bottom:1px solid {strip_top};height:14px;flex-shrink:0;"></div>\n'
out += '</div>'
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'<span class="arg-side-badge {badge_cls}">{side.upper()}</span>'
return (
f'<div class="arg-debater-header">'
f'{portraits.portrait_html(d.emoji, d.id, css_class="arg-debater-compact-emoji portrait-slot")}'
f'<span class="arg-debater-compact-name">{html.escape(d.name.upper())}</span>'
f'{badge}'
f'</div>'
)
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)}
<div class="pw landing-screen">
<div class="landing-wrap fade-in">
<div class="landing-title">THE PODIUM</div>
<div class="landing-divider"></div>
<div class="landing-tagline">
Pick your words. Choose your champion. Convince the judge.
</div>
<button class="btn-gold"
data-orig-label="TAKE THE STAGE β†’"
onclick="window.podiumStartGame(this)">
TAKE THE STAGE
</button>
</div>
</div>"""
# ── 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 = '<div style="width:5px;height:5px;border-radius:50%;background:#c9a84c;box-shadow:0 0 5px rgba(201,168,76,0.7);"></div>' * 3
tag_color = "#8b1a2a"
elif card.rarity_weight <= 6:
dot_html = '<div style="width:5px;height:5px;border-radius:50%;background:#c9a84c;box-shadow:0 0 5px rgba(201,168,76,0.7);"></div>' * 2
tag_color = "#5b7fa6"
else:
dot_html = '<div style="width:5px;height:5px;border-radius:50%;background:#7a6e8a;"></div>'
tag_color = "#4a8c5c"
flip_html += f"""
<div class="flip-c" id="fc-{i}" onclick="window.podiumFlipCard({i})">
<div class="flip-inner">
<div class="flip-face flip-back">
<div class="card-strip-top"></div>
<div style="flex:1;display:flex;align-items:center;justify-content:center;">
<div style="width:72px;height:72px;border:1.5px solid rgba(201,168,76,0.35);border-radius:50%;display:flex;align-items:center;justify-content:center;position:relative;">
<span style="font-family:'Bebas Neue',sans-serif;font-size:1.44rem;font-weight:400;color:rgba(201,168,76,0.65);">P</span>
<div style="position:absolute;inset:-8px;border:1px solid rgba(201,168,76,0.18);border-radius:50%;"></div>
<div style="position:absolute;inset:-17px;border:1px solid rgba(201,168,76,0.07);border-radius:50%;"></div>
</div>
</div>
<div style="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;display:flex;align-items:center;justify-content:center;">
<span class="flip-ability-lbl">ABILITY</span>
</div>
</div>
<div class="flip-face flip-front">
<div style="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;display:flex;align-items:center;justify-content:center;gap:5px;">{dot_html}</div>
<div class="flip-front-body">
<div class="ab-emoji">{card.emoji}</div>
<div class="ab-name">{card.name}</div>
<div class="ab-desc">{card.description}</div>
<div class="flip-uses-wrap">
<div class="flip-uses-tag" style="border:1px solid {tag_color};color:{tag_color};">{_ability_uses_label(card)}</div>
</div>
</div>
<div class="card-strip-bot"></div>
</div>
</div>
</div>"""
else:
flip_html = '<div class="abilities-disabled-msg">Abilities disabled.</div>'
return f"""
{_sid_tag(sid)}
<div class="pw">
<nav class="podium-nav">
<div class="podium-wordmark">THE PODIUM</div>
</nav>
<div class="screen setup-screen">
<div class="tex word-pool-panel" style="background:#12102a;border:1px solid rgba(201,168,76,0.2);border-radius:2px;overflow:hidden;">
<div class="card-strip-top"></div>
<div class="word-pool-body">
<div class="sec-label">YOUR WORD POOL FOR THIS GAME</div>
<div class="subtext" style="margin-bottom:17px;">You'll use these words in the rounds ahead. Pick debaters who suit them.</div>
<div class="pool-chips" style="display:flex;flex-wrap:wrap;">{pool_chips}</div>
</div>
<div class="card-strip-bot"></div>
</div>
<div class="setup-step" id="setup-step-1">
<div class="step-head">
<span class="step-label">STEP I β€” CHOOSE YOUR 3 DEBATERS</span>
<div class="step-divider"></div>
<div id="d-counter" class="step-counter">0 / 3 SELECTED</div>
</div>
<div class="subtext" style="margin-bottom:17px;">Click 3 debaters below. Each one argues for you in a different round.</div>
<div class="debater-grid">{d_cards}</div>
</div>
<div class="setup-step hidden setup-step-spaced" id="setup-step-2">
<div class="step-head">
<span class="step-label">STEP II β€” DRAW YOUR HAND</span>
<div class="step-divider"></div>
</div>
<div class="subtext" style="margin-bottom:24px;">3 ability cards from the deck. Flip to reveal.</div>
<div class="flip-row">{flip_html}</div>
</div>
<div class="setup-step hidden setup-step-spaced" id="setup-begin">
<button class="btn-gold" data-orig-label="BEGIN ROUND I β†’"
onclick="window.podiumBeginGame(this)">BEGIN ROUND I β†’</button>
</div>
</div>
</div>"""
# ── 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'<div class="position-label">YOUR POSITION: <span style="color:{"#4a8c5c" if ps=="for" else "#c16070"};">{"FOR" if ps=="for" else "AGAINST"}</span></div>'
return f"""
<div class="side-choice">
<div class="round-step-label">STEP I β€” PICK YOUR SIDE</div>
{side_label}
<div class="side-buttons">
<button class="side-btn for-btn{for_sel}"{for_attr}{disabled}>
FOR
<span class="side-sub">Defend this position</span>
</button>
<button class="side-btn against-btn{against_sel}"{against_attr}{disabled}>
AGAINST
<span class="side-sub">Oppose this position</span>
</button>
</div>
</div>"""
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"""
<div style="margin-top:38px;" id="debater-pick-section"{abilities_attr}>
<div id="round-opponent-data" data-opp-emoji="{opp_emoji}" data-opp-name="{opp_name}" style="display:none;"></div>
<div id="round-opponent-portrait" style="display:none;">{opp_portrait}</div>
<div id="debater-step-label" class="step-head">
<span class="round-step-label">STEP II β€” CHOOSE YOUR DEBATER</span>
<div class="step-divider"></div>
</div>
<div class="debater-grid setup-roster" id="roster-grid">{roster_html}</div>
<div id="lineup-vs" style="display:none;"></div>
{ability_block}
<button id="determine-btn" class="btn-gold" style="opacity:0.4;margin-top:28px;" disabled
onclick="window.podiumCommitRoundSetup();">
DETERMINE WHO GOES FIRST β†’
</button>
<div id="to-podium-wrap" style="display:none;margin-top:16px;">
<button id="to-podium-btn" class="btn-gold"
onclick="window.podiumToPodium();">
TO THE PODIUM β†’
</button>
</div>
</div>"""
def _render_judge_take_inline(judge_take: str) -> str:
if not judge_take:
return ""
return f'<div class="judge-take-inline">{html.escape(judge_take)}</div>'
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"""
<div class="judge-card tex fade-in">
<div class="judge-section-label">THIS ROUND'S JUDGE</div>
<div class="judge-portrait-wrap">
<div class="judge-glow"></div>
{portrait}
</div>
<div class="judge-name">{j.name.upper()}</div>
{take_html}
<div class="judge-bias">{html.escape(j.bias_blurb)}</div>
</div>"""
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'<div class="content-flag-banner">🚫 {html.escape(msg)}</div>'
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'<div id="round-marker" data-rid="{rid}" data-round-num="{gs.round_num}"{debater_attr}{player_side_attr} style="display:none;"></div>'
call_it_badge = ""
if gs.call_it_active:
call_it_badge = (
'<div class="call-it-badge">⚑ YOUR TOPIC · SCORE 80+ TO STAY ALIVE</div>'
)
if gs.call_it_rewritten:
call_it_badge += (
'<div class="call-it-badge call-it-revised">βš–οΈ REVISED BY THE JUDGE</div>'
)
content_flag_banner = _content_flag_banner_html(gs)
j = gs.current_judge
safe_topic = html.escape(gs.current_topic)
motion = f"""
<div style="margin-bottom:6px;">
<div class="motion-card fade-in">
{call_it_badge}
{content_flag_banner}
<div class="motion-divider"></div>
<div class="motion-statement-label">THE STATEMENT</div>
<div class="motion-text">\u201c{safe_topic}\u201d</div>
<div class="motion-divider"></div>
</div>
</div>"""
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"""
<div id="opp-gen-trigger" data-rid="{rid}" style="display:none;"></div>
<div class="arg-card opponent-formulating fade-in">
<div class="formulating-text">
{opp_label} IS FORMULATING THEIR RESPONSE
</div>
<div class="formulating-sub">Reading your argument and choosing their words<span class="loading-dots" aria-hidden="true"><span>.</span><span>.</span><span>.</span></span></div>
</div>"""
)
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 = """
<div style="margin-top:28px;">
<button class="btn-gold"
data-orig-label="ASK THE JUDGE β†’"
onclick="window.podiumRequestJudge(this)">
ASK THE JUDGE β†’
</button>
</div>"""
return f"""
{_sid_tag(sid)}
{marker}
<div class="pw">
{_tally(sid)}
<div class="screen">
<div class="round-banner" style="text-align:center;margin-bottom:24px;">ROUND {gs.round_num} OF {config.ROUNDS}</div>
{motion}{judge}{sides}
<div style="margin-top:24px;">{args}</div>
{judge_btn}
</div>
</div>"""
# ── 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}
<div class="pw">
{_tally(sid)}
<div class="screen">
<div class="round-banner" style="text-align:center;margin-bottom:24px;">ROUND {gs.round_num} OF {config.ROUNDS}</div>
{motion}{judge}{sides}
<div style="margin-top:24px;">
<div id="opp-first-trigger" data-rid="{rid}" style="display:none;"></div>
<div class="arg-card opponent-formulating fade-in">
<div class="formulating-text">
{opp_label} IS PREPARING THEIR OPENING
</div>
<div class="formulating-sub">They argue first this round &mdash; their argument is on the way<span class="loading-dots" aria-hidden="true"><span>.</span><span>.</span><span>.</span></span></div>
</div>
</div>
</div>
</div>"""
# ── 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'<div id="ability-meta" data-round="{gs.round_num}" '
f'data-echo-words="{echo_words}" style="display:none;"></div>'
)
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"""
<div id="opp-preview" style="margin:16px 0;">
{_arg_html(gs.opponent_argument_preview_sentences, gs.opponent_preview_words or [], "OPPONENT'S ARGUMENT", "#8b1a2a", "opponent")}
</div>"""
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}
<div id="word-select" class="{select_cls}" style="margin-top:29px;">
<div class="step-head" style="margin-bottom:19px;">
<span class="step-label">SELECT YOUR WORDS</span>
<div class="step-divider"></div>
<div id="w-counter" class="step-counter">0 / 3 WORDS SELECTED</div>
</div>
<div id="word-chips"{echo_attr} style="display:flex;flex-wrap:wrap;margin:8px 0 20px;">{chips}</div>
<button id="submit-btn" class="btn-gold" style="opacity:.4;" disabled
onclick="window.podiumSubmitTurn();">
SEND TO THE PODIUM β†’
</button>
</div>"""
return f"""
{_sid_tag(sid)}
{marker}
<div class="pw">
{_tally(sid)}
<div class="screen">
<div class="round-banner" style="text-align:center;margin-bottom:24px;">ROUND {gs.round_num} OF {config.ROUNDS}</div>
{motion}{judge}{sides}{setup_block}{post_side}
{select_block}
</div>
</div>"""
# ── 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 = (
'<div class="forfeit-banner">'
'⚑ CALL IT FORFEIT β€” Score fell below 80. Round goes to opponent.'
'</div>'
)
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 = (
'<div class="w-opp fade-in">βœ— OPPONENT WINS</div>'
'<div class="subtext" style="text-align:center;margin-top:6px;margin-bottom:0;color:#8b7a6a;">'
'Dead heat on points β€” the judge broke the tie.'
'</div>'
)
else:
banner = '<div class="w-opp fade-in">βœ— OPPONENT WINS</div>'
elif appeal_hollow_victory:
banner = (
'<div class="w-you fade-in" style="opacity:0.7;">βš–οΈ YOU WIN THIS ROUND</div>'
'<div class="subtext" style="text-align:center;margin-top:6px;margin-bottom:0;color:#8b7a6a;">'
'Appeal granted β€” but you earn 0 points. A victory without honor.'
'</div>'
)
elif round_winner == "player":
if tiebreaker_used:
banner = (
'<div class="w-you fade-in">βœ“ YOU WIN THIS ROUND</div>'
'<div class="subtext" style="text-align:center;margin-top:6px;margin-bottom:0;color:#8b7a6a;">'
'Dead heat on points β€” the judge broke the tie.'
'</div>'
)
else:
banner = '<div class="w-you fade-in">βœ“ YOU WIN THIS ROUND</div>'
else:
banner = '<div class="w-draw fade-in">═ A DRAW</div>'
score_anim = f"""
<div id="score-anim" data-rid="{rid}" data-ps="{ps}" data-os="{opp_score}" style="display:none;"></div>
<div class="score-vs" style="margin:24px 0;">
<div class="score-side">
<div class="score-lbl">YOU</div>
<div class="big-score" id="ps-num" style="animation:scoreIn 0.7s cubic-bezier(0.34,1.56,0.64,1) both;">0</div>
</div>
<div style="font-family:'Bebas Neue',sans-serif;font-size:1.32rem;color:#3d3550;flex-shrink:0;">vs</div>
<div class="score-side">
<div class="score-lbl">OPPONENT</div>
<div class="big-score opp" id="os-num" style="animation:scoreIn 0.7s cubic-bezier(0.34,1.56,0.64,1) 0.15s both;">0</div>
</div>
</div>"""
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"""
<div class="bd-row" style="margin:12px 0;">
<div class="bd-label">{label}</div>
<div class="bd-bars">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:4px;">
<span class="bd-val p">{pv}</span>
<div class="score-track" style="flex:1;"><div class="score-fill p" style="width:{pv}%;"></div></div>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<span class="bd-val o">{ov}</span>
<div class="score-track" style="flex:1;"><div class="score-fill o" style="width:{ov}%;"></div></div>
</div>
</div>
</div>"""
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 = '<button class="btn-gold" id="verdict-nxt-btn" onclick="window.podiumFinalResults(this);">SEE FINAL RESULTS β†’</button>'
else:
nxt = '<button class="btn-gold" id="verdict-nxt-btn" onclick="window.podiumNextRound(this);">NEXT ROUND β†’</button>'
appeal_btn = ""
if _can_appeal(gs, log):
appeal_btn = """
<button class="btn-gold btn-appeal" data-orig-label="βš–οΈ FILE APPEAL β†’"
onclick="window.podiumUseAppeal(this);">
βš–οΈ FILE APPEAL β†’
</button>
<div class="subtext" style="margin-bottom:19px;text-align:center;">
Request a fresh re-score. Win and you take the round β€” but earn 0 points. Once per game.
</div>"""
judge_take_block = _render_judge_take_inline(gs.judge_take)
verdict_topic_block = f"""
<div class="verdict-topic-block fade-in">
<div class="verdict-topic-text">\u201c{html.escape(log.topic)}\u201d</div>
{judge_take_block}
</div>"""
return f"""
{_sid_tag(sid)}
<div class="pw">
{_tally(sid)}
<div class="screen">
<div class="round-banner" style="text-align:center;margin-bottom:24px;">ROUND {log.round_num} VERDICT</div>
{forfeit_banner}
{content_flag_banner}
{banner}
{score_anim}
{verdict_topic_block}
<div class="verdict-card tex fade-in">
<div class="card-strip-top"></div>
<div class="verdict-card-body">
<div class="verdict-who">{j.emoji} {j.name.upper()} DECLARES:</div>
<div class="verdict-text">"{verdict}"</div>
</div>
<div class="card-strip-bot"></div>
</div>
{args}
<div style="background:#12102a;border:1px solid rgba(201,168,76,0.15);
border-radius:2px;padding:16px 20px;margin:16px 0;">
<div class="coll-hdr" onclick="window.podiumToggleBD(this);">
<div class="coll-title">SCORE BREAKDOWN</div>
<span class="arrow" style="color:#3d3550;">β–Ό</span>
</div>
<div class="coll-body" id="bd-body">
<div style="margin-top:19px;">
<div class="breakdown-legend">
<span style="color:#7a5ba6;">β–  YOU</span><span style="color:#8b1a2a;">β–  OPPONENT</span>
</div>
{bars}
<div style="border-top:1px solid rgba(201,168,76,0.1);margin-top:16px;padding-top:8px;">
{math_breakdown}
</div>
</div>
</div>
</div>
{appeal_btn}
<div style="margin-top:32px;">{nxt}</div>
</div>
</div>"""
# ── 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"""
<div class="near-miss-banner" style="display:block;">
⚑ You were {stats['closest_margin']} points away from taking that round. One sharper word and it was yours.
</div>"""
return f"""
<div class="postgame-card card" style="cursor:default;pointer-events:none;">
<div class="postgame-title">YOUR GAME IN REVIEW</div>
<div class="postgame-stats">
<div class="stat-highlight">
<span class="stat-icon">⭐</span>
<div>
<div class="stat-label">BEST WORD</div>
<div class="stat-value">"{best['word']}" β€” {best['note']}</div>
</div>
</div>
<div class="stat-highlight">
<span class="stat-icon">πŸ†</span>
<div>
<div class="stat-label">MVP DEBATER</div>
<div class="stat-value">{mvp_display}</div>
</div>
</div>
<div class="stat-highlight">
<span class="stat-icon">πŸ’Ž</span>
<div>
<div class="stat-label">POWER WORDS LANDED</div>
<div class="stat-value">{stats['power_words_sharp']} of {stats['power_words_used']} used sharply</div>
</div>
</div>
</div>
{near_miss_html}
</div>"""
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'<div class="lb-row-detail">"{hrt[:48]}{"…" if len(hrt) > 48 else ""}" Β· {hrj}</div>'
sharpest_html = ""
if sw:
sharpest_html = f"""
<div class="lb-row highlight" id="lb-sharpest">
<div class="lb-row-main">
<span class="lb-label">SHARPEST WORD</span>
<span class="lb-value gold">"{sw}"</span>
</div>
{"<div class='lb-row-detail'>" + swn + "</div>" if swn else ""}
</div>"""
return f"""
<div class="leaderboard-panel card" style="cursor:default;">
<div class="leaderboard-header" onclick="(function(el){{
var body = el.closest('.leaderboard-panel').querySelector('.leaderboard-body');
var tog = el.querySelector('.lb-toggle');
if(body.style.display==='none'){{body.style.display='flex';tog.textContent='β–²';}}
else{{body.style.display='none';tog.textContent='β–Ό';}}
}})(this)">
<span class="lb-title">SESSION RECORDS</span>
<span class="lb-toggle">β–²</span>
</div>
<div class="leaderboard-body" id="lb-body">
<div class="lb-row">
<span class="lb-label">GAMES PLAYED</span>
<span class="lb-value">{gp}</span>
</div>
<div class="lb-row">
<span class="lb-label">WIN RATE</span>
<span class="lb-value">{gw} / {gp}</span>
</div>
<div class="lb-row highlight" id="lb-best-round">
<div class="lb-row-main">
<span class="lb-label">HIGHEST ROUND SCORE</span>
<span class="lb-value gold">{hrs}</span>
</div>
{best_round_detail}
</div>
{sharpest_html}
<div class="lb-row">
<span class="lb-label">BEST GAME TOTAL</span>
<span class="lb-value">{bgt} pts</span>
</div>
</div>
</div>"""
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 = '<div class="w-you w-banner-lg">THE PODIUM IS YOURS</div>'
elif gs.winner == "opponent":
banner = '<div class="w-opp w-banner-lg">THE OPPONENT TAKES THE PODIUM</div>'
else:
banner = '<div class="w-draw w-banner-lg">BOTH DEBATERS SHARE THE PODIUM</div>'
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"""
<tr>
<td><span style="color:{mc};">{mk}</span> Round {log.round_num}</td>
<td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{log.topic[:52]}…</td>
<td>{log.judge.emoji} {log.judge.name}</td>
<td>{log.player_debater.emoji} {log.player_debater.name}</td>
<td style="color:#7a5ba6;">{ps_}</td>
<td style="color:#8b1a2a;">{os_}</td>
</tr>"""
leaderboard = _leaderboard_html(session_stats)
return f"""
{_sid_tag(sid)}
<div class="pw">
<nav class="podium-nav">
<div class="podium-wordmark">THE PODIUM</div>
</nav>
<div class="screen fade-in">
{banner}
{postgame}
{leaderboard}
<div style="display:flex;gap:38px;justify-content:center;margin:38px 0;text-align:center;">
<div>
<div class="final-stat-label">ROUNDS WON</div>
<div class="stat-value-lg" style="color:#7a5ba6;">{prw}
<span class="stat-vs">vs</span> {orw}
</div>
</div>
<div style="width:1px;background:rgba(201,168,76,0.1);flex-shrink:0;"></div>
<div>
<div class="final-stat-label">TOTAL POINTS</div>
<div class="stat-value-lg" style="color:#c9a84c;">{tp}
<span class="stat-vs">vs</span> {to}
</div>
</div>
</div>
<div style="margin:24px 0;">
<div class="sec-label">ROUND RECAP</div>
<div style="overflow-x:auto;">
<table class="recap">
<tr><th>Round</th><th>Topic</th><th>Judge</th><th>Your Debater</th><th>You</th><th>Opp</th></tr>
{rows}
</table>
</div>
</div>
<div style="margin-top:32px;">
<button class="btn-gold" onclick="window.podiumRematch(this);">REMATCH β†’</button>
</div>
</div>
</div>"""
# ── Error screen ──────────────────────────────────────────────────────────────
def render_error(sid: Optional[str], err: str) -> str:
return f"""
{_sid_tag(sid)}
<div class="pw" style="padding:48px 24px;">
<div class="error-wrap">
<div class="error-title">SOMETHING WENT WRONG</div>
<pre class="error-body">{err}</pre>
<button class="btn-gold" style="margin-top:19px;"
onclick="window.podiumRematch(this);">START OVER</button>
</div>
</div>"""
# ── 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)