| """ |
| 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 |
|
|
| |
| _SESSIONS: Dict[str, GameState] = {} |
| _LAST_LOGS: Dict[str, RoundLog] = {} |
| _APP_STATES: Dict[str, dict] = {} |
| _PROCESSING: set = set() |
| _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 |
|
|
|
|
| |
| 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); |
| } |
| """ |
|
|
| |
| 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() |
|
|
| |
|
|
| 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('"', """) |
| safe_lock = (lock_reason or "").replace('"', """) |
| 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 {bd.get('word_integration', 0)} / 60</div> |
| {word_lines} |
| <div class="sb-head" style="margin-top:12px;">ARGUMENT QUALITY {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 {'+' if deb >= 0 else ''}{deb:.0f}</div> |
| {deb_lines} |
| <div class="sb-final">FINAL SCORE {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) |
|
|
|
|
| |
|
|
| 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>""" |
|
|
|
|
| |
|
|
| def render_setup(sid: str, gs: GameState) -> str: |
| |
| pool_chips = "".join(_pool_chip_html(w) for w in gs.player_pool) |
|
|
| |
| d_cards = "".join(_debater_card_html(d) for d in gs.all_debaters) |
|
|
| |
| 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>""" |
|
|
|
|
| |
|
|
| 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 "" |
| ) |
| |
| 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>""" |
|
|
| |
| 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 — 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>""" |
|
|
| |
| 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 "" |
|
|
| |
| 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>""" |
|
|
|
|
| |
|
|
| 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>""" |
|
|
|
|
| |
|
|
| 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>""" |
|
|
|
|
| |
|
|
| 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>""" |
|
|
|
|
| |
|
|
| 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_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: |
| |
| |
| 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) |
| |
| 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") |
| if not debater_id: |
| return render_error(sid, "Choose a debater for this round.") |
| |
| |
| 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) |
| |
| 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) |
| |
| 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) |
| |
| 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) |
|
|
| |
| 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_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") |
|
|
| |
| action_btn.click(**_ev) |
|
|
|
|
| if __name__ == "__main__": |
| import os |
| import socket |
|
|
| |
| 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: |
| |
| print("\n THE PODIUM β running on Hugging Face Spaces\n") |
| else: |
| |
| 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) |
|
|