""" Pure-Python game engine for The Podium. No Gradio imports. Fully testable headless via: python engine.py """ import hashlib import json import random import re from copy import deepcopy from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple import config import content import game_log import llm import prompts # --------------------------------------------------------------------------- # Difficulty settings # --------------------------------------------------------------------------- DIFFICULTY_SETTINGS = { "easy": { "opponent_roster_pool": ["everyman", "everyman", "stoic"], "word_selection": "standard_only", "quality_modifier": -15, }, "normal": { "opponent_roster_pool": None, "word_selection": "normal", "quality_modifier": 0, }, "hard": { "opponent_roster_pool": ["firebrand", "academic", "contrarian"], "word_selection": "power_priority", "quality_modifier": 10, }, } def get_difficulty_settings(difficulty: Optional[str] = None) -> dict: key = (difficulty or config.DIFFICULTY or "normal").lower() return DIFFICULTY_SETTINGS.get(key, DIFFICULTY_SETTINGS["normal"]) # --------------------------------------------------------------------------- # Data structures # --------------------------------------------------------------------------- @dataclass class WordEntry: word: str category: str # noun | adjective | verb | wildcard tier: str = "standard" # power | standard | spice used: bool = False round_used: Optional[int] = None @dataclass class RoundLog: round_num: int topic: str judge: content.Judge player_side: str opponent_side: str first_to_argue: str # "player" | "opponent" player_debater: content.Debater opponent_debater: content.Debater player_words: List[str] opponent_words: List[str] player_argument_sentences: List[str] opponent_argument_sentences: List[str] player_argument: str opponent_argument: str judge_result: Dict[str, Any] # parsed JSON from judge ability_played: Optional[str] = None ability_args: Optional[Dict] = None player_omitted_words: List[str] = field(default_factory=list) opponent_omitted_words: List[str] = field(default_factory=list) content_flag_active: bool = False content_flag_ability: Optional[str] = None @dataclass class GameState: # Word pools player_pool: List[WordEntry] = field(default_factory=list) opponent_pool: List[WordEntry] = field(default_factory=list) # Debater roster system all_debaters: List[content.Debater] = field(default_factory=list) player_roster: List[content.Debater] = field(default_factory=list) player_debaters_used: List[str] = field(default_factory=list) opponent_roster: List[content.Debater] = field(default_factory=list) opponent_debaters_used: List[str] = field(default_factory=list) # Current round debaters round_debater: Optional[content.Debater] = None opponent_round_debater: Optional[content.Debater] = None # Opponent-goes-first preview (populated in start_round if opp goes first) opponent_argument_preview_sentences: List[str] = field(default_factory=list) opponent_preview_words: List[str] = field(default_factory=list) # Pending round data (populated after submit_player_turn, cleared after finalize_round_scoring) pending_player_words: List[str] = field(default_factory=list) pending_opponent_words: List[str] = field(default_factory=list) pending_player_argument_sentences: List[str] = field(default_factory=list) pending_opponent_argument_sentences: List[str] = field(default_factory=list) pending_ability_played: Optional[str] = None pending_ability_args: Optional[Dict] = None pending_skip_bonus: int = 0 pending_player_omitted_words: List[str] = field(default_factory=list) pending_opponent_omitted_words: List[str] = field(default_factory=list) # Abilities (only populated when ABILITIES_ENABLED) abilities_hand: List[content.AbilityCard] = field(default_factory=list) abilities_used: Dict[str, int] = field(default_factory=dict) silence_used: bool = False appeal_used: bool = False # Round tracking round_num: int = 0 scores: List[Tuple[int, int]] = field(default_factory=list) round_logs: List[RoundLog] = field(default_factory=list) used_judges: List[str] = field(default_factory=list) previous_topics: List[str] = field(default_factory=list) # Session memory (carries across rematches) session_used_words: List[str] = field(default_factory=list) # Game status game_over: bool = False winner: Optional[str] = None # "player" | "opponent" | "tie" # Current round setup (populated by start_round) current_topic: Optional[str] = None current_topic_meta: Optional[Dict[str, str]] = None current_judge: Optional[content.Judge] = None current_player_side: Optional[str] = None current_opponent_side: Optional[str] = None current_first_to_argue: Optional[str] = None waiting_for_side_choice: bool = False waiting_for_ability_choice: bool = False ability_committed: bool = False # Session logging log_id: Optional[str] = None player_flavor_seed: Optional[str] = None opponent_flavor_seed: Optional[str] = None # Difficulty difficulty: str = "normal" # Call It ability call_it_active: bool = False call_it_threshold: int = 80 call_it_rewritten: bool = False call_it_original_topic: Optional[str] = None # Judge's first-impression reaction to the topic (decoration only) judge_take: str = "" # Ability word flagged by moderation and replaced with INAPPROPRIATE content_flag_active: bool = False content_flag_ability: Optional[str] = None # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _pool_from_dicts(word_dicts: List[dict]) -> List[WordEntry]: return [ WordEntry( word=w["word"].lower().strip(), category=w["category"], tier=w.get("tier", "standard"), ) for w in word_dicts ] def _pool_as_dicts(pool: List[WordEntry]) -> List[dict]: return [{"word": w.word, "category": w.category, "tier": w.tier} for w in pool] def get_word_from_pool(word: str, pool: List[WordEntry]) -> dict: word_lower = word.lower().strip() for entry in pool: if entry.word.lower() == word_lower: return {"word": entry.word, "category": entry.category, "tier": entry.tier} return {"word": word_lower, "category": "noun", "tier": "standard"} def calc_word_score(word_results: List[dict], pool: List[WordEntry]) -> Tuple[int, List[dict]]: """Word integration: max 60 pts. Returns (total, per-word detail list).""" total = 0 details = [] flagged_replacement = config.FLAGGED_WORD_REPLACEMENT.upper() for result in word_results: score = int(result.get("score", 0)) word = result.get("word", "") if word.upper() == flagged_replacement: score = 0 base = score * 10 word_data = get_word_from_pool(result.get("word", ""), pool) tier = word_data.get("tier", "standard") label = {0: "shoehorned", 1: "functional", 2: "sharp"}.get(score, "functional") points = base bonus_note = "" note = result.get("note", "") if word.upper() == flagged_replacement: note = "flagged replacement" if tier == "power": if score == 2: points = int(base * 1.5) bonus_note = "power bonus" elif score == 0: points = -5 bonus_note = "power penalty" total += points details.append({ "word": result.get("word", ""), "score": score, "label": label, "points": points, "tier": tier, "note": note, "bonus_note": bonus_note, }) return min(max(total, 0), 60), details def calculate_final_score( word_results: List[dict], judge_scores: dict, debater: content.Debater, pool: List[WordEntry], is_opponent_side: bool, difficulty_modifier: int = 0, ) -> dict: """Combine word integration + argument quality + debater stat bonuses + risk swing.""" word_total, word_details = calc_word_score(word_results, pool) coherence = judge_scores.get("coherence", 50) persuasion = judge_scores.get("persuasion", 50) judge_appeal = judge_scores.get("judge_appeal", 50) quality_raw = (coherence * 0.35 + persuasion * 0.40 + judge_appeal * 0.25) / 100 * 40 stats = debater.stats edge_bonus = (stats.get("edge", 1) - 1) * 2.5 precision_bonus = (stats.get("precision", 1) - 1) * 2.5 style_bonus = (stats.get("style", 1) - 1) * 2.5 contrarian_bonus = 8 if (debater.type == "contrarian" and is_opponent_side) else 0 debater_bonus = ( edge_bonus * 0.4 + precision_bonus * 0.35 + style_bonus * 0.25 + contrarian_bonus ) applied_difficulty = difficulty_modifier if is_opponent_side else 0 quality_total = quality_raw + debater_bonus + applied_difficulty risk = stats.get("risk", 1) if risk >= 4: swing = random.randint(-15, 15) elif risk >= 3: swing = random.randint(-8, 8) else: swing = 0 final = word_total + quality_total + swing final = max(0, min(100, round(final))) return { "final_score": final, "word_integration": word_total, "word_details": word_details, "coherence": coherence, "persuasion": persuasion, "judge_appeal": judge_appeal, "quality_raw": round(quality_raw, 1), "quality_total": round(quality_total, 1), "debater_bonus": round(debater_bonus, 1), "edge_bonus": round(edge_bonus, 1), "precision_bonus": round(precision_bonus, 1), "style_bonus": round(style_bonus, 1), "contrarian_bonus": contrarian_bonus, "difficulty_modifier": applied_difficulty, "risk_swing": swing, "risk_level": risk, "archetype_note": judge_scores.get("archetype_note", ""), } def apply_flavor_swap(pool: List[dict], flavor_seed: str, used_words: List[str]) -> List[dict]: """Swap 1-2 standard words for flavor-seed-colored alternatives via LLM.""" standard_slots = [i for i, w in enumerate(pool) if w.get("tier") == "standard"] if not standard_slots: return pool pool_words = {w["word"] for w in pool} blocked = set(used_words) | pool_words try: system, user = prompts.FLAVOR_SWAP_PROMPT(pool, flavor_seed, list(blocked)) raw = llm.complete(system, user, json_mode=True, max_tokens=200) data = json.loads(raw) replacements = data.get("replacements", []) result = [dict(w) for w in pool] swapped = 0 for rep in replacements: if swapped >= 1: break old_word = str(rep.get("replace", "")).lower().strip() new_word = str(rep.get("with", "")).lower().strip() category = str(rep.get("category", "")).lower().strip() if not old_word or not new_word or not new_word.isalpha(): continue if new_word in blocked: continue pool_without_old = {w for w in pool_words if w != old_word} if content.is_similar_to_any(new_word, pool_without_old): continue for i in standard_slots: if result[i]["word"] == old_word and result[i]["tier"] == "standard": result[i] = { "word": new_word, "category": category or result[i]["category"], "tier": "standard", } blocked.add(new_word) swapped += 1 break return result except Exception: return pool def generate_word_pool(flavor_seed: str, used_words: List[str]) -> List[dict]: """3 power + 2 spice + 7 standard words, then one flavor swap.""" pool = content.generate_word_pool(flavor_seed, used_words) return apply_flavor_swap(pool, flavor_seed, used_words) def _available_words(pool: List[WordEntry]) -> List[WordEntry]: return [w for w in pool if not w.used] def _mark_used(pool: List[WordEntry], words: List[str], round_num: int) -> None: remaining = {w.lower() for w in words} for entry in pool: if entry.word.lower() in remaining and not entry.used: entry.used = True entry.round_used = round_num remaining.discard(entry.word.lower()) def _pick_judge(state: GameState) -> content.Judge: available = [j for j in content.JUDGES if j.id not in state.used_judges] if not available: available = content.JUDGES return random.choice(available) def _generate_pool(flavor_seed: str, session_used_words: List[str]) -> List[WordEntry]: """Hybrid pool: 3 power + 2 spice + 7 standard from master lists, 1 flavor swap via LLM.""" word_dicts = generate_word_pool(flavor_seed, session_used_words) return _pool_from_dicts(word_dicts) def is_suspicious_plant_word(word: str) -> bool: """Flag words that look like obfuscated slurs (heuristic fallback).""" if re.search(r'[a-zA-Z][0-9@$!][a-zA-Z]', word): return True if re.search(r'^[a-zA-Z][\s.\-_][a-zA-Z]', word): return True if len(word) > 18: return True return False def moderate_submitted_word(word: str) -> bool: """Return True if the submitted word should be replaced with INAPPROPRIATE.""" cleaned = word.strip().lower() if not cleaned: return False if cleaned == config.FLAGGED_WORD_REPLACEMENT.lower(): return False system, user = prompts.WORD_MODERATION_PROMPT(cleaned) try: raw = llm.complete(system, user, json_mode=True, max_tokens=80) data = json.loads(raw) return bool(data.get("flagged", False)) except (json.JSONDecodeError, ValueError, RuntimeError): return is_suspicious_plant_word(cleaned) def _ability_word_arg_key(ability_id: str) -> Optional[str]: return {"wildcard_card": "wildcard_word", "plant": "plant_word"}.get(ability_id) def _moderate_ability_word_args( state: GameState, ability_id: Optional[str], args: Dict, ) -> Dict: """LLM-moderate Plant/Wildcard words; replace flagged words before any LLM generation.""" if not ability_id or not args: return dict(args or {}) word_key = _ability_word_arg_key(ability_id) if not word_key: return dict(args) raw_word = (args.get(word_key) or "").strip() if not raw_word: return dict(args) sanitized = dict(args) if moderate_submitted_word(raw_word): sanitized[word_key] = config.FLAGGED_WORD_REPLACEMENT sanitized["word_flagged"] = True state.content_flag_active = True state.content_flag_ability = ability_id return sanitized def _ability_word_for_game(word: str) -> str: """Normalize an ability word for pool/argument use; preserve INAPPROPRIATE casing.""" cleaned = word.strip() if cleaned.upper() == config.FLAGGED_WORD_REPLACEMENT: return config.FLAGGED_WORD_REPLACEMENT return cleaned.lower() def _wildcard_synergy_bonus( round_debater: Optional[content.Debater], ability_played: Optional[str], ability_args: Dict, player_words: List[str], ) -> int: """+WILDCARD_SYNERGY_BONUS when The Wildcard plays Wildcard and uses the injected word.""" if not round_debater or round_debater.id != "wildcard": return 0 if ability_played != "wildcard_card": return 0 wc_raw = (ability_args.get("wildcard_word") or "").strip() if not wc_raw: return 0 wc_word = _ability_word_for_game(wc_raw) used = {_ability_word_for_game(w) for w in player_words} if wc_word not in used: return 0 return config.WILDCARD_SYNERGY_BONUS def _add_wildcard_to_pool(state: GameState, wc_word: str) -> None: pool_word = _ability_word_for_game(wc_word) existing = {w.word for w in state.player_pool} if pool_word not in existing: state.player_pool.append( WordEntry(word=pool_word, category="wildcard", tier="standard") ) def sanitize_ability_args_for_log(args: Optional[Dict]) -> Optional[Dict]: """Redact flagged ability words from session logs.""" if not args: return args out = dict(args) if out.get("word_flagged"): for key in ("wildcard_word", "plant_word"): if key in out: out[key] = config.FLAGGED_WORD_REPLACEMENT return out def content_flag_message(ability: Optional[str]) -> str: if ability == "wildcard_card": return "That word was flagged and replaced with INAPPROPRIATE." if ability == "plant": return ( "You tried to plant a flagged word — your opponent will argue " "with INAPPROPRIATE instead." ) return "" def content_flag_player_message(state: GameState) -> str: if not state.content_flag_active: return "" return content_flag_message(state.content_flag_ability) def _content_flag_judge_notes(state: GameState) -> str: if not state.content_flag_active or not state.content_flag_ability: return "" return prompts.CONTENT_FLAG_JUDGE_NOTES(state.content_flag_ability) def _truncate_topic_words(topic: str, max_words: int = 20) -> str: words = topic.strip().split() if len(words) > max_words: return " ".join(words[:max_words]) return topic.strip() def _generate_judge_take(state: GameState) -> str: """Short in-character reaction to the current topic.""" judge = state.current_judge topic = state.current_topic if not judge or not topic: return "" try: persona_short = judge.hidden_persona.split(".")[0] + "." system, user = prompts.JUDGE_TAKE_PROMPT( judge_name=judge.name, judge_persona_short=persona_short, topic=topic, ) return llm.complete( system=system, user=user, json_mode=False, max_tokens=60, ).strip().strip('"') except Exception: return "" def _process_call_it_topic(state: GameState, custom_topic: str) -> None: """Apply Call It: filter for policy violations, pass verbatim or judge-rewrite.""" raw = _truncate_topic_words(custom_topic) state.call_it_original_topic = raw state.call_it_rewritten = False state.call_it_active = True judge = state.current_judge if not judge: state.current_topic = raw state.judge_take = "" return persona_short = judge.hidden_persona.split(".")[0] + "." system, user = prompts.CALL_IT_TOPIC_PROMPT( judge_name=judge.name, judge_persona_short=persona_short, raw_topic=raw, ) try: response = llm.complete(system, user, json_mode=True, max_tokens=200) data = json.loads(response) passed = bool(data.get("passed", True)) judge_take = str(data.get("judge_take", "")).strip().strip('"') if passed: state.current_topic = raw state.call_it_rewritten = False else: rewritten = str(data.get("topic", "")).strip().strip('"') state.current_topic = _truncate_topic_words(rewritten) if rewritten else raw state.call_it_rewritten = rewritten != "" if not rewritten: state.call_it_rewritten = False state.judge_take = judge_take[:120] except Exception: state.current_topic = raw state.call_it_rewritten = False state.judge_take = _generate_judge_take(state) def enforce_topic_length(topic: str, llm_fn) -> str: words = topic.split() if len(words) > 13: retry_prompt = ( f"Rewrite this topic in 12 words or fewer. Keep the core idea, cut everything else. " f"Output only the rewritten topic, nothing else:\n\n{topic}" ) topic = llm_fn( system="You rewrite debate topics to be shorter.", user=retry_prompt, ) topic = topic.strip().strip('"') return topic def _count_topic_words(topic: str) -> int: return len(topic.split()) def _generate_topic(state: GameState) -> Tuple[str, Dict[str, str]]: seed_word = random.choice(content.SEED_WORDS) frame = random.choice(content.DEBATE_FRAMES) system, user = prompts.TOPIC_PROMPT(seed_word, frame, state.previous_topics) topic = llm.complete(system, user, json_mode=False, max_tokens=120).strip().rstrip(".") if _count_topic_words(topic) > 12: r_system, r_user = prompts.TOPIC_REWRITE_PROMPT(topic) topic = llm.complete(r_system, r_user, json_mode=False, max_tokens=80).strip().rstrip(".") topic = enforce_topic_length(topic, lambda system, user: llm.complete(system, user, json_mode=False, max_tokens=80)) return topic, {"seed_word": seed_word, "frame": frame} def _opponent_select_words( state: GameState, topic: str, side: str, judge: content.Judge, forced_words: List[str] = None ) -> Tuple[List[str], str]: available = _available_words(state.opponent_pool) diff_settings = get_difficulty_settings(state.difficulty) selection_mode = diff_settings["word_selection"] if selection_mode == "power_priority": power_words = [w.word for w in available if w.tier == "power"] picked = list(power_words[: config.WORDS_PER_ROUND]) if len(picked) < config.WORDS_PER_ROUND: filler_pool = [w.word for w in available if w.word not in picked] system, user = prompts.OPPONENT_SELECTION_PROMPT( filler_pool, topic, side, judge.bias_blurb ) raw = llm.complete(system, user, json_mode=True, max_tokens=200) data = json.loads(raw) for w in data.get("words", []): if w in filler_pool and w not in picked: picked.append(w) if len(picked) >= config.WORDS_PER_ROUND: break remaining = [w for w in filler_pool if w not in picked] picked.extend(remaining[: config.WORDS_PER_ROUND - len(picked)]) words = picked[: config.WORDS_PER_ROUND] rationale = "power priority selection" else: if selection_mode == "standard_only": pool_words = [w.word for w in available if w.tier != "power"] else: pool_words = [w.word for w in available] system, user = prompts.OPPONENT_SELECTION_PROMPT(pool_words, topic, side, judge.bias_blurb) raw = llm.complete(system, user, json_mode=True, max_tokens=200) data = json.loads(raw) words = data.get("words", []) rationale = data.get("order_rationale", "") valid = [w for w in words if w in pool_words] if selection_mode == "standard_only": valid = [w for w in valid if get_word_from_pool(w, state.opponent_pool).get("tier") != "power"] if len(valid) < config.WORDS_PER_ROUND: remaining = [w for w in pool_words if w not in valid] valid.extend(remaining[: config.WORDS_PER_ROUND - len(valid)]) words = valid[: config.WORDS_PER_ROUND] if forced_words: for fw in forced_words: if fw not in words: words = words[: config.WORDS_PER_ROUND - 1] + [fw] return words, rationale def _word_variants(word: str) -> List[str]: """Exact pool form only — no plurals, tense changes, or conjugations.""" w = word.strip().lower() return [w] if w else [] def _word_in_sentence(sentence: str, word: str) -> bool: if not word or not sentence: return False for variant in _word_variants(word): if re.search( r"\b(" + re.escape(variant) + r")\b", sentence, flags=re.IGNORECASE, ): return True return False def _missing_word_slots(sentences: List[str], words: List[str]) -> List[int]: """Indices where the assigned word is absent from the corresponding sentence.""" missing: List[int] = [] for i, (sentence, word) in enumerate(zip(sentences, words)): if not _word_in_sentence(sentence, word): missing.append(i) return missing def _parse_single_sentence(raw: str) -> str: """Parse a one-sentence LLM retry response.""" text = raw.strip() try: data = json.loads(text) if isinstance(data, list) and data: return str(data[0]).strip() except json.JSONDecodeError: pass match = re.search(r"\[[\s\S]*\]", text) if match: try: data = json.loads(match.group()) if isinstance(data, list) and data: return str(data[0]).strip() except json.JSONDecodeError: pass lines = [ln.strip() for ln in text.splitlines() if ln.strip()] if lines: return lines[0] return text.strip() def _format_word_omission_notes( player_debater_name: str, opponent_debater_name: str, player_omitted: List[str], opponent_omitted: List[str], ) -> str: lines: List[str] = [] if player_omitted: words = ", ".join(f'"{w}"' for w in player_omitted) lines.append( f"- {player_debater_name} failed to use required word(s): {words}" ) if opponent_omitted: words = ", ".join(f'"{w}"' for w in opponent_omitted) lines.append( f"- {opponent_debater_name} failed to use required word(s): {words}" ) if not lines: return "" return ( "\n".join(lines) + "\nYou may reference these violations in your verdict commentary if fitting." ) def _parse_argument_sentences(raw: str, words: List[str]) -> List[str]: """Parse champion LLM output into exactly 3 sentences.""" text = raw.strip() def _from_list(items: List[Any]) -> List[str]: return [str(s).strip() for s in items[:3]] wrong_schema = False try: data = json.loads(text) if isinstance(data, dict) and ("player" in data or "opponent" in data): wrong_schema = True elif isinstance(data, list) and len(data) >= 3: return _from_list(data) except json.JSONDecodeError: pass if wrong_schema: raise ValueError( "Champion LLM returned word-score JSON instead of a sentence array." ) match = re.search(r"\[[\s\S]*\]", text) if match: try: data = json.loads(match.group()) if isinstance(data, list) and len(data) >= 3: return _from_list(data) except json.JSONDecodeError: pass lines = [ln.strip() for ln in text.splitlines() if ln.strip()] if len(lines) >= 3: return lines[:3] sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()] while len(sentences) < 3: sentences.append("") return sentences[:3] def _join_argument_sentences(sentences: List[str]) -> str: return " ".join(s.strip() for s in sentences if s.strip()) def _regenerate_sentence( sentence_idx: int, word: str, topic: str, side: str, debater: content.Debater, judge: content.Judge, sentences: List[str], extra_instructions: str = "", ) -> str: """Surgically rewrite one sentence that missed its required word.""" archetype = content.ARCHETYPE_MAP[debater.archetype_type] other = [sentences[i] for i in range(len(sentences)) if i != sentence_idx] system, user = prompts.CHAMPION_SENTENCE_RETRY_PROMPT( debater_name=debater.name, archetype_style=archetype.champion_style, hard_constraint=archetype.hard_constraint, topic=topic, side=side, sentence_number=sentence_idx + 1, required_word=word, failed_sentence=sentences[sentence_idx], other_sentences=other, judge_bias_short=judge.bias_blurb, extra_instructions=extra_instructions, ) raw = llm.complete(system, user, json_mode=True, max_tokens=120).strip() return _parse_single_sentence(raw) def _generate_argument( words: List[str], topic: str, side: str, debater: content.Debater, judge: content.Judge, opponent_side: Optional[str] = None, opponent_argument: Optional[str] = None, opponent_words: Optional[List[str]] = None, extra_instructions: str = "", ) -> Tuple[List[str], List[str]]: """Generate a 3-sentence argument; retry missing words surgically once.""" archetype = content.ARCHETYPE_MAP[debater.archetype_type] system, user = prompts.CHAMPION_PROMPT( debater_name=debater.name, archetype_style=archetype.champion_style, hard_constraint=archetype.hard_constraint, words=words, topic=topic, side=side, judge_bias_short=judge.bias_blurb, opponent_side=opponent_side, opponent_argument=opponent_argument, opponent_words=opponent_words, extra_instructions=extra_instructions, ) raw = llm.complete(system, user, json_mode=True, max_tokens=400).strip() sentences = _parse_argument_sentences(raw, words) missing = _missing_word_slots(sentences, words) if missing: for idx in missing: sentences[idx] = _regenerate_sentence( idx, words[idx], topic, side, debater, judge, sentences, extra_instructions=extra_instructions, ) still_missing = [ words[i] for i in _missing_word_slots(sentences, words) ] return sentences, still_missing def _run_word_score( player_sentences: List[str], player_words: List[str], opponent_sentences: List[str], opponent_words: List[str], ) -> Dict[str, Any]: system, user = prompts.WORD_SCORE_PROMPT( player_sentences, player_words, opponent_sentences, opponent_words ) raw = llm.complete(system, user, json_mode=True, max_tokens=400) return json.loads(raw) def _run_judge( state: GameState, topic: str, judge: content.Judge, player_arg: str, opponent_arg: str, ability_played: Optional[str], ability_args: Optional[Dict], bias_emphasis: str = "", ) -> Dict[str, Any]: player_archetype = content.ARCHETYPE_MAP[state.round_debater.archetype_type] opp_archetype = content.ARCHETYPE_MAP[state.opponent_round_debater.archetype_type] ability_mods_lines = [] if ability_played == "inspiration": if state.current_first_to_argue == "opponent": ability_mods_lines.append( "INSPIRATION PENALTY: The player used the Inspiration card, but argued SECOND. " "Score the player's argument as if it seems derivative of the opponent's. Apply a notable penalty." ) else: ability_mods_lines.append( "INSPIRATION BONUS: The player used the Inspiration card and argued FIRST. " "Be generously disposed toward the player's argument this round." ) if ability_played == "appeal": ability_mods_lines.append( "APPEAL: This is a re-score. Score freshly as if you haven't seen this debate before." ) ability_mods_str = ("\n".join(ability_mods_lines) + "\n") if ability_mods_lines else "" player_debater = state.round_debater opponent_debater = state.opponent_round_debater player_type = player_debater.archetype_type opponent_type = opponent_debater.archetype_type player_note_key = judge.debater_notes.get(player_type, "neutral") opponent_note_key = judge.debater_notes.get(opponent_type, "neutral") player_debater_note = content.DEBATER_NOTE_TRANSLATIONS.get( player_note_key, content.DEBATER_NOTE_TRANSLATIONS["neutral"] ) opponent_debater_note = content.DEBATER_NOTE_TRANSLATIONS.get( opponent_note_key, content.DEBATER_NOTE_TRANSLATIONS["neutral"] ) system, user = prompts.JUDGE_PROMPT( topic=topic, judge_name=judge.name, judge_persona=judge.hidden_persona, judge_bias_blurb=judge.bias_blurb, player_side=state.current_player_side, opponent_side=state.current_opponent_side, player_argument=player_arg, opponent_argument=opponent_arg, player_debater_style=player_archetype.champion_style, opponent_debater_style=opp_archetype.champion_style, player_debater_name=player_debater.name, player_debater_type=player_type, player_debater_note=player_debater_note, opponent_debater_name=opponent_debater.name, opponent_debater_type=opponent_type, opponent_debater_note=opponent_debater_note, ability_modifiers=ability_mods_str, bias_emphasis=bias_emphasis, content_flag_notes=_content_flag_judge_notes(state), ) raw = llm.complete(system, user, json_mode=True, max_tokens=800) return json.loads(raw) _DEBATER_NOTE_RANK = { "strong_penalty": 0, "penalty": 1, "slight_penalty": 2, "neutral": 3, "slight_bonus": 4, "bonus": 5, "strong_bonus": 6, } def _debater_note_rank(note_key: str) -> int: return _DEBATER_NOTE_RANK.get(note_key, 3) def _resolve_round_winner( state: GameState, judge_result: dict, call_it_forfeit: bool = False, ) -> str: """Pick round winner; on tied scores, break deadlock using judge personality.""" if call_it_forfeit: judge_result["round_winner"] = "opponent" return "opponent" p_score = judge_result.get("player_score", 50) o_score = judge_result.get("opponent_score", 50) if p_score > o_score: judge_result["round_winner"] = "player" return "player" if o_score > p_score: judge_result["round_winner"] = "opponent" return "opponent" p_bd = judge_result["breakdown"]["player"] o_bd = judge_result["breakdown"]["opponent"] p_appeal = p_bd.get("judge_appeal", 50) o_appeal = o_bd.get("judge_appeal", 50) if p_appeal > o_appeal: judge_result["tiebreaker_used"] = True judge_result["tiebreaker_basis"] = "judge_appeal" judge_result["round_winner"] = "player" return "player" if o_appeal > p_appeal: judge_result["tiebreaker_used"] = True judge_result["tiebreaker_basis"] = "judge_appeal" judge_result["round_winner"] = "opponent" return "opponent" judge = state.current_judge if judge and state.round_debater and state.opponent_round_debater: p_rank = _debater_note_rank( judge.debater_notes.get(state.round_debater.type, "neutral") ) o_rank = _debater_note_rank( judge.debater_notes.get(state.opponent_round_debater.type, "neutral") ) if p_rank > o_rank: judge_result["tiebreaker_used"] = True judge_result["tiebreaker_basis"] = "debater_affinity" judge_result["round_winner"] = "player" return "player" if o_rank > p_rank: judge_result["tiebreaker_used"] = True judge_result["tiebreaker_basis"] = "debater_affinity" judge_result["round_winner"] = "opponent" return "opponent" seed = f"{state.log_id or 'podium'}:{state.round_num}:tiebreak" digest = hashlib.md5(seed.encode()).hexdigest() winner = "player" if int(digest, 16) % 2 == 0 else "opponent" judge_result["tiebreaker_used"] = True judge_result["tiebreaker_basis"] = "coin_flip" judge_result["round_winner"] = winner return winner def count_round_wins(state: GameState) -> Tuple[int, int]: """Return (player_round_wins, opponent_round_wins) from round logs.""" player_wins = opponent_wins = 0 for log in state.round_logs: rw = log.judge_result.get("round_winner") if rw == "player": player_wins += 1 elif rw == "opponent": opponent_wins += 1 else: ps = log.judge_result.get("player_score", 0) os = log.judge_result.get("opponent_score", 0) if ps > os: player_wins += 1 elif os > ps: opponent_wins += 1 return player_wins, opponent_wins def _validate_echo_words(state: GameState, word_order: List[str]) -> None: if not config.ABILITIES_ENABLED: return if state.pending_ability_played != "echo": return used_words = {w.word for w in state.player_pool if w.used} echo_count = sum(1 for w in word_order if w in used_words) if echo_count != 1: raise ValueError("Echo requires exactly one word from a previous round.") def _score_breakdown_for_verdict( player_breakdown: dict, opponent_breakdown: dict, player_debater_name: str, opponent_debater_name: str, ) -> str: def _side_summary(debater: str, bd: dict) -> str: word_notes = ", ".join( f"{w['word']} ({w.get('label', 'used')})" for w in bd.get("word_details", []) ) or "none" extras = [] if bd.get("skip_bonus"): extras.append(f"skip bonus +{bd['skip_bonus']}") if bd.get("risk_swing"): extras.append(f"risk swing {bd['risk_swing']:+d}") extra_str = f" [{', '.join(extras)}]" if extras else "" return ( f"{debater}: {bd['final_score']} total = " f"{bd['word_integration']} word integration + {bd['quality_total']} argument quality " f"(coherence {bd['coherence']}, persuasion {bd['persuasion']}, judge appeal {bd['judge_appeal']})" f"{extra_str}; words: {word_notes}" ) return "\n".join([ _side_summary(player_debater_name, player_breakdown), _side_summary(opponent_debater_name, opponent_breakdown), ]) def _run_verdict( state: GameState, topic: str, judge: content.Judge, player_arg: str, opponent_arg: str, judge_result: dict, call_it_forfeit: bool = False, is_appeal: bool = False, player_omitted_words: Optional[List[str]] = None, opponent_omitted_words: Optional[List[str]] = None, ) -> str: p_bd = judge_result["breakdown"]["player"] o_bd = judge_result["breakdown"]["opponent"] p_score = judge_result["player_score"] o_score = judge_result["opponent_score"] winner = judge_result.get("round_winner") if not winner: winner = _resolve_round_winner(state, judge_result, call_it_forfeit) tiebreaker_used = bool(judge_result.get("tiebreaker_used")) winner_debater = ( state.round_debater.name if winner == "player" else state.opponent_round_debater.name if winner == "opponent" else "neither" ) breakdown = _score_breakdown_for_verdict( p_bd, o_bd, state.round_debater.name, state.opponent_round_debater.name, ) omission_notes = _format_word_omission_notes( state.round_debater.name, state.opponent_round_debater.name, player_omitted_words or [], opponent_omitted_words or [], ) system, user = prompts.VERDICT_PROMPT( topic=topic, judge_name=judge.name, judge_persona=judge.hidden_persona, judge_bias_blurb=judge.bias_blurb, player_side=state.current_player_side, opponent_side=state.current_opponent_side, player_argument=player_arg, opponent_argument=opponent_arg, player_debater_name=state.round_debater.name, opponent_debater_name=state.opponent_round_debater.name, player_score=p_score, opponent_score=o_score, official_winner=winner, winner_debater_name=winner_debater, score_breakdown=breakdown, call_it_forfeit=call_it_forfeit, call_it_threshold=state.call_it_threshold, is_appeal=is_appeal, tiebreaker_used=tiebreaker_used, word_omission_notes=omission_notes, content_flag_notes=_content_flag_judge_notes(state), ) raw = llm.complete(system, user, json_mode=True, max_tokens=300) try: data = json.loads(raw) verdict = data.get("verdict", "").strip() if verdict: return verdict except json.JSONDecodeError: pass return raw.strip().strip('"') def _attach_verdict( state: GameState, judge_result: dict, player_arg: str, opponent_arg: str, call_it_forfeit: bool = False, is_appeal: bool = False, player_omitted_words: Optional[List[str]] = None, opponent_omitted_words: Optional[List[str]] = None, ) -> None: if "round_winner" not in judge_result: _resolve_round_winner(state, judge_result, call_it_forfeit) judge_result["verdict"] = _run_verdict( state, state.current_topic, state.current_judge, player_arg, opponent_arg, judge_result, call_it_forfeit=call_it_forfeit, is_appeal=is_appeal, player_omitted_words=player_omitted_words, opponent_omitted_words=opponent_omitted_words, ) def _build_judge_result( state: GameState, player_sentences: List[str], opponent_sentences: List[str], player_words: List[str], opponent_words: List[str], player_arg: str, opponent_arg: str, ability_played: Optional[str], ability_args: Optional[Dict], bias_emphasis: str = "", ) -> Dict[str, Any]: """Two-pass scoring: word integration, then argument quality + mechanical bonuses.""" word_scores = _run_word_score( player_sentences, player_words, opponent_sentences, opponent_words ) judge_raw = _run_judge( state, state.current_topic, state.current_judge, player_arg, opponent_arg, ability_played, ability_args, bias_emphasis=bias_emphasis, ) player_quality = judge_raw.get("player", {}) opponent_quality = judge_raw.get("opponent", {}) diff_mod = get_difficulty_settings(state.difficulty)["quality_modifier"] player_breakdown = calculate_final_score( word_scores.get("player", []), player_quality, state.round_debater, state.player_pool, is_opponent_side=False, ) opponent_breakdown = calculate_final_score( word_scores.get("opponent", []), opponent_quality, state.opponent_round_debater, state.opponent_pool, is_opponent_side=True, difficulty_modifier=diff_mod, ) return { "player_score": player_breakdown["final_score"], "opponent_score": opponent_breakdown["final_score"], "breakdown": { "player": player_breakdown, "opponent": opponent_breakdown, }, } def _auto_select_opponent_debater(state: GameState) -> content.Debater: """Auto-select opponent's debater for the current round from their unused roster.""" unused = [d for d in state.opponent_roster if d.id not in state.opponent_debaters_used] if unused: return random.choice(unused) # Fallback: reset and pick from full roster return random.choice(state.opponent_roster) def _deal_ability_hand(size: int) -> List[content.AbilityCard]: cards = content.ABILITY_CARDS weights = [c.rarity_weight for c in cards] chosen = random.choices(cards, weights=weights, k=size * 3) seen = set() hand = [] for c in chosen: if c.id not in seen: seen.add(c.id) hand.append(c) if len(hand) == size: break while len(hand) < size: remaining = [c for c in cards if c.id not in seen] if not remaining: break extra = random.choice(remaining) hand.append(extra) seen.add(extra.id) return hand # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def _opponent_extra_instructions(state: GameState, forced_words: Optional[List[str]] = None) -> str: extra = "" if state.difficulty == "hard": extra += prompts.OPPONENT_HARD_MODE_ADDITION return extra def _build_opponent_roster(difficulty: str) -> List[content.Debater]: diff_settings = get_difficulty_settings(difficulty) roster_pool = diff_settings.get("opponent_roster_pool") if roster_pool: ids = (roster_pool * 3)[:3] return [content.DEBATER_MAP[did] for did in ids] return random.sample(content.DEBATERS, 3) def new_game( session_used_words: Optional[List[str]] = None, difficulty: Optional[str] = None, ) -> GameState: """ Create a fresh GameState: generate pools, prepare debater roster, deal ability hand. Does NOT select player roster yet — caller shows all debaters for player to pick 3. """ if session_used_words is None: session_used_words = [] state = GameState(session_used_words=list(session_used_words)) state.difficulty = (difficulty or config.DIFFICULTY or "normal").lower() # Generate player pool player_flavor = random.choice(content.FLAVOR_SEEDS) state.player_flavor_seed = player_flavor state.player_pool = _generate_pool(player_flavor, state.session_used_words) state.session_used_words.extend(w.word for w in state.player_pool) # Generate opponent pool (different seed, avoid player words) opp_flavor_candidates = [s for s in content.FLAVOR_SEEDS if s != player_flavor] opp_flavor = random.choice(opp_flavor_candidates) state.opponent_flavor_seed = opp_flavor state.opponent_pool = _generate_pool(opp_flavor, state.session_used_words) state.session_used_words.extend(w.word for w in state.opponent_pool) # All debaters available for player to draft from state.all_debaters = list(content.DEBATERS) # Opponent roster depends on difficulty state.opponent_roster = _build_opponent_roster(state.difficulty) # Deal ability hand if config.ABILITIES_ENABLED: state.abilities_hand = _deal_ability_hand(config.ABILITY_HAND_SIZE) if config.GAME_LOGGING_ENABLED: logger = game_log.new_game_logger() state.log_id = logger.game_id logger.log_game_setup(state, player_flavor, opp_flavor) return state def choose_roster(state: GameState, debater_ids: List[str]) -> GameState: """ Player selects exactly 3 debaters from the roster for their team. Returns updated state with player_roster populated. """ if len(debater_ids) != 3: raise ValueError(f"Must choose exactly 3 debaters, got {len(debater_ids)}") debaters = [] for did in debater_ids: d = content.DEBATER_MAP.get(did) if d is None: raise ValueError(f"Unknown debater id: {did}") debaters.append(d) state.player_roster = debaters state.player_debaters_used = [] return state def start_round(state: GameState) -> GameState: """ Advance to the next round: generate topic, pick judge, wait for player side choice. After choose_side(), assigns order and opponent preview if needed. Returns updated state. """ if state.game_over: raise ValueError("Game is already over.") if not state.player_roster: raise ValueError("Player roster not set. Choose 3 debaters first.") state.round_num += 1 # Generate topic topic, topic_meta = _generate_topic(state) state.current_topic = topic state.current_topic_meta = topic_meta state.previous_topics.append(topic) # Pick judge judge = _pick_judge(state) state.used_judges.append(judge.id) state.current_judge = judge state.judge_take = _generate_judge_take(state) # Sides assigned when player calls choose_side() state.current_player_side = None state.current_opponent_side = None state.current_first_to_argue = None state.waiting_for_side_choice = True # Auto-select opponent's debater for this round state.opponent_round_debater = _auto_select_opponent_debater(state) # Clear previous round preview / pending data state.opponent_argument_preview_sentences = [] state.opponent_preview_words = [] state.pending_player_words = [] state.pending_opponent_words = [] state.pending_player_argument_sentences = [] state.pending_opponent_argument_sentences = [] state.pending_player_omitted_words = [] state.pending_opponent_omitted_words = [] state.round_debater = None state.pending_ability_played = None state.pending_ability_args = None state.pending_skip_bonus = 0 state.waiting_for_ability_choice = False state.ability_committed = False state.call_it_active = False state.call_it_rewritten = False state.call_it_original_topic = None state.content_flag_active = False state.content_flag_ability = None return state def is_ability_usable(state: GameState, card: content.AbilityCard) -> bool: """Whether a card in the player's hand can still be played.""" if card.id == "appeal": return False if card.id == "silence" and state.silence_used: return False used = state.abilities_used.get(card.id, 0) if card.uses == 1 and used >= 1: return False if card.id == "echo" and state.round_num < 2: return False return True def ability_unavailable_reason( state: GameState, card: content.AbilityCard, *, context: str = "pre-round", ) -> Optional[str]: """Human-readable reason a card can't be played in the given context, or None if usable.""" if context == "pre-round": if card.timing == "post-round": return "Use after losing a round — the button appears on the verdict screen." if card.id == "silence" and state.silence_used: return "Already used this game." used = state.abilities_used.get(card.id, 0) if card.uses == 1 and used >= 1: return "Already used this game." if card.id == "echo" and state.round_num < 2: return "Can't use until Round 2 — you need a word you've already spent." return None return None def is_ability_usable_pre_round(state: GameState, card: content.AbilityCard) -> bool: return card.timing == "pre-round" and is_ability_usable(state, card) def usable_pre_round_abilities(state: GameState) -> List[content.AbilityCard]: if not config.ABILITIES_ENABLED: return [] return [c for c in state.abilities_hand if is_ability_usable_pre_round(state, c)] def _has_pre_round_abilities(state: GameState) -> bool: return bool(usable_pre_round_abilities(state)) def _forced_plant_words(state: GameState) -> List[str]: if state.pending_ability_played != "plant": return [] args = state.pending_ability_args or {} plant_word = args.get("plant_word", "").strip() return [_ability_word_for_game(plant_word)] if plant_word else [] def _resolve_turn_order(state: GameState) -> GameState: """Roll who argues first; generate opponent preview when they go first.""" if state.current_first_to_argue is not None: return state state.current_first_to_argue = random.choice(["player", "opponent"]) topic = state.current_topic judge = state.current_judge forced = _forced_plant_words(state) if state.current_first_to_argue == "opponent": opp_words, _ = _opponent_select_words( state, topic, state.current_opponent_side, judge, forced ) opp_sentences, opp_omitted = _generate_argument( opp_words, topic, state.current_opponent_side, state.opponent_round_debater, judge, extra_instructions=_opponent_extra_instructions(state, forced_words=forced), ) state.opponent_argument_preview_sentences = opp_sentences state.opponent_preview_words = opp_words state.pending_opponent_omitted_words = opp_omitted topic_meta = state.current_topic_meta or {} game_log.log_if_enabled(state, "log_round_setup", state, topic_meta) return state def generate_opponent_opening(state: GameState) -> GameState: """Generate the opponent's opening argument for opponent-first rounds. Called separately from commit so the UI can show a formulating state first.""" if state.current_first_to_argue != "opponent": return state if state.opponent_argument_preview_sentences: return state # already generated topic = state.current_topic judge = state.current_judge forced = _forced_plant_words(state) opp_words, _ = _opponent_select_words( state, topic, state.current_opponent_side, judge, forced ) opp_sentences, opp_omitted = _generate_argument( opp_words, topic, state.current_opponent_side, state.opponent_round_debater, judge, extra_instructions=_opponent_extra_instructions(state, forced_words=forced), ) state.opponent_argument_preview_sentences = opp_sentences state.opponent_preview_words = opp_words state.pending_opponent_omitted_words = opp_omitted topic_meta = state.current_topic_meta or {} game_log.log_if_enabled(state, "log_round_setup", state, topic_meta) return state def choose_side(state: GameState, side: str, debater_id: Optional[str] = None) -> GameState: """ Player picks FOR or AGAINST; opponent gets the opposite side. Optionally locks in the player's round debater. Turn order is resolved after ability commit (or immediately if abilities are off). """ if not state.waiting_for_side_choice: raise ValueError("Side has already been chosen for this round.") if side not in ("for", "against"): raise ValueError(f"Side must be 'for' or 'against', got: {side!r}") if debater_id: debater = content.DEBATER_MAP.get(debater_id) if debater is None: raise ValueError(f"Unknown debater id: {debater_id}") roster_ids = {d.id for d in state.player_roster} if debater_id not in roster_ids: raise ValueError(f"Debater {debater.name} is not on your roster.") if debater_id in state.player_debaters_used: raise ValueError(f"Debater {debater.name} has already been used this game.") state.round_debater = debater state.current_player_side = side state.current_opponent_side = "against" if side == "for" else "for" state.waiting_for_side_choice = False if _has_pre_round_abilities(state): state.waiting_for_ability_choice = True else: state.ability_committed = True state = _resolve_turn_order(state) return state def commit_round_ability( state: GameState, ability_played: Optional[str] = None, ability_args: Optional[Dict] = None, ) -> GameState: """ Lock in an optional pre-round ability, then resolve turn order. Must be called (with ability_played=None to skip) before word selection when abilities are enabled. """ if not state.waiting_for_ability_choice: raise ValueError("Ability choice has already been committed for this round.") if ability_args is None: ability_args = {} if ability_played: ability_args = _moderate_ability_word_args(state, ability_played, ability_args) else: state.content_flag_active = False state.content_flag_ability = None state.pending_ability_played = ability_played state.pending_ability_args = dict(ability_args) if ability_args else {} if ability_played: state.pending_skip_bonus = 0 _apply_ability_at_commit(state, ability_played, state.pending_ability_args) else: had_usable = bool(usable_pre_round_abilities(state)) state.pending_skip_bonus = config.SKIP_BONUS if had_usable else 0 state.waiting_for_ability_choice = False state.ability_committed = True return _resolve_turn_order(state) def submit_player_turn( state: GameState, selected_words: List[str], word_order: List[str], debater_id: str, ability_played: Optional[str] = None, ability_args: Optional[Dict] = None, ) -> Tuple[GameState, List[str], List[str]]: """ Player submits their round: sets debater, applies abilities, generates player argument. If opponent argued first (preview exists), uses stored preview. If opponent argues second, generates their argument now. Returns: (updated_state, player_argument_sentences, opponent_argument_sentences) """ if ability_args is None: ability_args = {} if state.waiting_for_side_choice: raise ValueError("Choose FOR or AGAINST before submitting your turn.") if state.waiting_for_ability_choice: raise ValueError("Choose an ability or skip before submitting your turn.") if _has_pre_round_abilities(state) and not state.ability_committed: raise ValueError("Ability choice has not been committed for this round.") if state.round_debater: if debater_id != state.round_debater.id: raise ValueError("Debater does not match your round selection.") debater = state.round_debater else: debater = content.DEBATER_MAP.get(debater_id) if debater is None: raise ValueError(f"Unknown debater id: {debater_id}") if debater_id in state.player_debaters_used: raise ValueError(f"Debater {debater.name} has already been used this game.") state.round_debater = debater if debater_id not in state.player_debaters_used: state.player_debaters_used.append(debater_id) # Use server-committed ability when present if state.ability_committed: ability_played = state.pending_ability_played ability_args = dict(state.pending_ability_args or {}) elif ability_args is None: ability_args = {} # Apply pre-round abilities (submit-time effects only if already committed at ability step) player_words = list(word_order) forced_opponent_words = [] bias_emphasis = ability_args.get("bias_emphasis", "") if config.ABILITIES_ENABLED and ability_played: state, player_words, forced_opponent_words = _apply_ability_pre( state, ability_played, ability_args, player_words, skip_commit_effects=state.ability_committed, ) _validate_echo_words(state, player_words) # Handle opponent argument context for responsive second-arguer prompts if state.current_first_to_argue == "opponent": opp_sentences = list(state.opponent_argument_preview_sentences) opp_arg_text = _join_argument_sentences(opp_sentences) player_sentences, player_omitted = _generate_argument( player_words, state.current_topic, state.current_player_side, debater, state.current_judge, opponent_side=state.current_opponent_side, opponent_argument=opp_arg_text, opponent_words=state.opponent_preview_words, ) else: player_sentences, player_omitted = _generate_argument( player_words, state.current_topic, state.current_player_side, debater, state.current_judge, ) if state.current_first_to_argue == "player": opp_sentences = [] opp_omitted: List[str] = [] else: opp_sentences = list(state.opponent_argument_preview_sentences) opp_omitted = list(state.pending_opponent_omitted_words) # Store pending data for scoring step state.pending_player_words = player_words state.pending_player_argument_sentences = player_sentences state.pending_opponent_words = list(state.opponent_preview_words) state.pending_opponent_argument_sentences = opp_sentences state.pending_player_omitted_words = player_omitted state.pending_opponent_omitted_words = opp_omitted state.pending_ability_played = ability_played state.pending_ability_args = ability_args return state, player_sentences, opp_sentences def generate_opponent_turn(state: GameState) -> Tuple[GameState, List[str]]: """ Generate the opponent's responsive argument after the player argued first. Called as a separate step so the player can read their argument first. """ if state.current_first_to_argue != "player": raise ValueError("Opponent response is only deferred when the player argues first.") if not state.pending_player_argument_sentences: raise ValueError("Player turn not submitted yet.") if state.pending_opponent_argument_sentences: return state, list(state.pending_opponent_argument_sentences) player_sentences = state.pending_player_argument_sentences player_words = state.pending_player_words forced = _forced_plant_words(state) opp_words, _ = _opponent_select_words( state, state.current_topic, state.current_opponent_side, state.current_judge, forced, ) player_arg_text = _join_argument_sentences(player_sentences) opp_sentences, opp_omitted = _generate_argument( opp_words, state.current_topic, state.current_opponent_side, state.opponent_round_debater, state.current_judge, opponent_side=state.current_player_side, opponent_argument=player_arg_text, opponent_words=player_words, extra_instructions=_opponent_extra_instructions(state, forced_words=forced), ) state.opponent_preview_words = opp_words state.pending_opponent_words = list(opp_words) state.pending_opponent_argument_sentences = opp_sentences state.pending_opponent_omitted_words = opp_omitted return state, opp_sentences def finalize_round_scoring(state: GameState) -> Tuple[GameState, RoundLog]: """ Run judge scoring on the pending arguments. Marks words as used, updates scores, appends RoundLog. Returns: (updated_state, round_log) """ judge = state.current_judge topic = state.current_topic player_sentences = state.pending_player_argument_sentences opp_sentences = state.pending_opponent_argument_sentences player_arg = _join_argument_sentences(player_sentences) opp_arg = _join_argument_sentences(opp_sentences) player_words = state.pending_player_words opp_words = state.pending_opponent_words ability_played = state.pending_ability_played ability_args = state.pending_ability_args or {} bias_emphasis = ability_args.get("bias_emphasis", "") judge_result = _build_judge_result( state, player_sentences, opp_sentences, player_words, opp_words, player_arg, opp_arg, ability_played, ability_args, bias_emphasis=bias_emphasis, ) skip_bonus = state.pending_skip_bonus if not ability_played else 0 if skip_bonus: p_bd = judge_result["breakdown"]["player"] p_score = min(100, judge_result.get("player_score", 50) + skip_bonus) p_bd["skip_bonus"] = skip_bonus p_bd["final_score"] = p_score judge_result["player_score"] = p_score synergy_bonus = _wildcard_synergy_bonus( state.round_debater, ability_played, ability_args, player_words ) if synergy_bonus: p_bd = judge_result["breakdown"]["player"] p_score = min(100, judge_result.get("player_score", 50) + synergy_bonus) p_bd["wildcard_synergy_bonus"] = synergy_bonus p_bd["final_score"] = p_score judge_result["player_score"] = p_score # Mark words as used _mark_used(state.player_pool, player_words, state.round_num) _mark_used(state.opponent_pool, opp_words, state.round_num) # Mark opponent debater as used if state.opponent_round_debater: if state.opponent_round_debater.id not in state.opponent_debaters_used: state.opponent_debaters_used.append(state.opponent_round_debater.id) # Record scores p_score = judge_result.get("player_score", 50) o_score = judge_result.get("opponent_score", 50) call_it_forfeit = bool( state.call_it_active and p_score < state.call_it_threshold ) if call_it_forfeit: judge_result["call_it_forfeit"] = True _attach_verdict( state, judge_result, player_arg, opp_arg, call_it_forfeit=call_it_forfeit, player_omitted_words=state.pending_player_omitted_words, opponent_omitted_words=state.pending_opponent_omitted_words, ) state.scores.append((p_score, o_score)) if judge_result.get("call_it_forfeit"): tally_p, tally_o = state.scores[-1] if tally_p >= tally_o: state.scores[-1] = (tally_p, tally_p + 1) # Build log log = RoundLog( round_num=state.round_num, topic=topic, judge=judge, player_side=state.current_player_side, opponent_side=state.current_opponent_side, first_to_argue=state.current_first_to_argue, player_debater=state.round_debater, opponent_debater=state.opponent_round_debater, player_words=player_words, opponent_words=opp_words, player_argument_sentences=player_sentences, opponent_argument_sentences=opp_sentences, player_argument=player_arg, opponent_argument=opp_arg, judge_result=judge_result, ability_played=ability_played, ability_args=sanitize_ability_args_for_log(ability_args), player_omitted_words=list(state.pending_player_omitted_words), opponent_omitted_words=list(state.pending_opponent_omitted_words), content_flag_active=state.content_flag_active, content_flag_ability=state.content_flag_ability, ) state.round_logs.append(log) game_log.log_if_enabled(state, "log_round_result", log, "") # Clear pending data state.pending_player_argument_sentences = [] state.pending_opponent_argument_sentences = [] state.pending_player_words = [] state.pending_opponent_words = [] state.pending_player_omitted_words = [] state.pending_opponent_omitted_words = [] state.pending_ability_played = None state.pending_ability_args = None state.pending_skip_bonus = 0 state.content_flag_active = False state.content_flag_ability = None # Check game over if state.round_num >= config.ROUNDS: state = finish_game(state) return state, log def _remove_ability_from_hand(state: GameState, ability_id: str) -> None: state.abilities_hand = [c for c in state.abilities_hand if c.id != ability_id] def _apply_ability_at_commit( state: GameState, ability_id: str, args: Dict, ) -> None: """Apply pool-changing ability effects when the player commits their ability choice.""" card = content.ABILITY_CARD_MAP.get(ability_id) if card is None: return state.abilities_used[ability_id] = state.abilities_used.get(ability_id, 0) + 1 if ability_id == "wildcard_card": wc_word = args.get("wildcard_word", "").strip() if wc_word: _add_wildcard_to_pool(state, wc_word) elif ability_id == "silence": silence_word = args.get("silence_word", "").strip().lower() if silence_word and not state.silence_used: state.silence_used = True state.opponent_pool = [ w for w in state.opponent_pool if not (w.word == silence_word and not w.used) ] elif ability_id == "call_it" and args.get("custom_topic"): _process_call_it_topic(state, args["custom_topic"]) _remove_ability_from_hand(state, ability_id) def _apply_ability_pre( state: GameState, ability_id: str, args: Dict, player_words: List[str], skip_commit_effects: bool = False, ) -> Tuple[GameState, List[str], List[str]]: """ Apply a pre-round ability card effect. Returns (updated_state, player_words, forced_opponent_words) """ forced_opponent_words = [] card = content.ABILITY_CARD_MAP.get(ability_id) if card is None: return state, player_words, forced_opponent_words if skip_commit_effects: if ability_id == "plant": plant_word = args.get("plant_word", "").strip() if plant_word: forced_opponent_words = [_ability_word_for_game(plant_word)] return state, player_words, forced_opponent_words state.abilities_used[ability_id] = state.abilities_used.get(ability_id, 0) + 1 if ability_id == "echo": pass # echo word already included in player_words by UI elif ability_id == "wildcard_card": wc_word = args.get("wildcard_word", "").strip() if wc_word: _add_wildcard_to_pool(state, wc_word) elif ability_id == "plant": plant_word = args.get("plant_word", "").strip() if plant_word: forced_opponent_words = [_ability_word_for_game(plant_word)] elif ability_id == "silence": silence_word = args.get("silence_word", "").strip().lower() if silence_word and not state.silence_used: state.silence_used = True state.opponent_pool = [ w for w in state.opponent_pool if not (w.word == silence_word and not w.used) ] elif ability_id in ("bias", "inspiration"): pass # handled downstream return state, player_words, forced_opponent_words def use_appeal(state: GameState) -> Tuple[GameState, RoundLog]: """Re-score the most recent round with a fresh judge call. Once per game.""" if state.appeal_used: raise ValueError("Appeal already used this game.") if not state.round_logs: raise ValueError("No round to appeal.") state.appeal_used = True last_log = state.round_logs[-1] state.abilities_used["appeal"] = state.abilities_used.get("appeal", 0) + 1 _remove_ability_from_hand(state, "appeal") # Temporarily restore round state for scoring state.round_debater = last_log.player_debater state.opponent_round_debater = last_log.opponent_debater state.current_player_side = last_log.player_side state.current_opponent_side = last_log.opponent_side state.current_first_to_argue = last_log.first_to_argue state.content_flag_active = last_log.content_flag_active state.content_flag_ability = last_log.content_flag_ability judge_result = _build_judge_result( state, last_log.player_argument_sentences, last_log.opponent_argument_sentences, last_log.player_words, last_log.opponent_words, last_log.player_argument, last_log.opponent_argument, ability_played="appeal", ability_args={}, ) p_score = judge_result.get("player_score", 50) o_score = judge_result.get("opponent_score", 50) _resolve_round_winner(state, judge_result) appeal_won = judge_result["round_winner"] == "player" if appeal_won: judge_result["appeal_hollow_victory"] = True state.scores[-1] = (0, o_score) judge_result["player_score"] = 0 else: state.scores[-1] = (p_score, o_score) _attach_verdict( state, judge_result, last_log.player_argument, last_log.opponent_argument, call_it_forfeit=bool(last_log.judge_result.get("call_it_forfeit")), is_appeal=True, player_omitted_words=last_log.player_omitted_words, opponent_omitted_words=last_log.opponent_omitted_words, ) last_log.judge_result = judge_result last_log.ability_played = "appeal" game_log.log_if_enabled(state, "log_round_result", last_log, "", appeal=True) if state.round_num >= config.ROUNDS: state = finish_game(state) return state, last_log def compute_postgame_stats(state: GameState) -> dict: """Summarize player performance across the completed game.""" all_word_details: List[dict] = [] debater_wins: Dict[str, int] = {} debater_losses: Dict[str, int] = {} best_round = 1 best_round_score = -1 total_word_score = 0 power_words_used = 0 power_words_sharp = 0 closest_margin = None near_miss = False default_notes = { 2: "Carried the entire argument", 1: "Functional — did the job", 0: "Shoehorned — didn't land", } for log in state.round_logs: p_score = log.judge_result.get("player_score", 0) o_score = log.judge_result.get("opponent_score", 0) bd = log.judge_result.get("breakdown", {}).get("player", {}) total_word_score += bd.get("word_integration", 0) if p_score > best_round_score: best_round_score = p_score best_round = log.round_num margin = abs(p_score - o_score) if p_score < o_score: if closest_margin is None or margin < closest_margin: closest_margin = margin if margin <= 10: near_miss = True debater_name = log.player_debater.name if p_score > o_score: debater_wins[debater_name] = debater_wins.get(debater_name, 0) + 1 elif o_score > p_score: debater_losses[debater_name] = debater_losses.get(debater_name, 0) + 1 for wd in bd.get("word_details", []): entry = dict(wd) entry["round"] = log.round_num all_word_details.append(entry) if entry.get("tier") == "power": power_words_used += 1 if entry.get("score") == 2: power_words_sharp += 1 if all_word_details: best_entry = max(all_word_details, key=lambda w: (w.get("score", 0), w.get("points", 0))) worst_entry = min(all_word_details, key=lambda w: (w.get("score", 0), w.get("points", 0))) best_word = { "word": best_entry.get("word", ""), "score": best_entry.get("score", 0), "note": best_entry.get("note") or default_notes.get(best_entry.get("score", 0), ""), } worst_word = { "word": worst_entry.get("word", ""), "score": worst_entry.get("score", 0), "note": worst_entry.get("note") or default_notes.get(worst_entry.get("score", 0), ""), } else: best_word = {"word": "—", "score": 0, "note": "No words scored"} worst_word = {"word": "—", "score": 0, "note": "No words scored"} debater_record = {} all_debaters = set(debater_wins) | set(debater_losses) for name in all_debaters: w = debater_wins.get(name, 0) l = debater_losses.get(name, 0) if w > l: debater_record[name] = "W" elif l > w: debater_record[name] = "L" else: debater_record[name] = "T" mvp_debater = "—" if debater_wins: mvp_debater = max(debater_wins, key=debater_wins.get) return { "best_word": best_word, "worst_word": worst_word, "best_round": best_round, "power_words_used": power_words_used, "power_words_sharp": power_words_sharp, "closest_margin": closest_margin if closest_margin is not None else 0, "debater_record": debater_record, "mvp_debater": mvp_debater, "total_word_score": total_word_score, "near_miss": near_miss, } @dataclass class SessionStats: games_played: int = 0 games_won: int = 0 highest_round_score: int = 0 highest_round_score_word: str = "" highest_round_score_topic: str = "" highest_round_score_judge: str = "" best_game_total: int = 0 longest_win_streak: int = 0 current_streak: int = 0 sharpest_word: str = "" sharpest_word_note: str = "" def update_session_stats(session: SessionStats, gs: GameState) -> SessionStats: """Update session-level records after a completed game.""" if not gs.game_over or not gs.round_logs: return session session.games_played += 1 if gs.winner == "player": session.games_won += 1 session.current_streak += 1 session.longest_win_streak = max(session.longest_win_streak, session.current_streak) else: session.current_streak = 0 # Best game total total_p = sum(p for p, o in gs.scores) session.best_game_total = max(session.best_game_total, total_p) # Highest round score + context for log in gs.round_logs: p_score = log.judge_result.get("player_score", 0) if p_score > session.highest_round_score: session.highest_round_score = p_score session.highest_round_score_topic = log.topic session.highest_round_score_judge = f"{log.judge.emoji} {log.judge.name}" bd = log.judge_result.get("breakdown", {}).get("player", {}) word_details = bd.get("word_details", []) if word_details: best_wd = max(word_details, key=lambda w: (w.get("score", 0), w.get("points", 0))) session.highest_round_score_word = best_wd.get("word", "") # Sharpest word (highest score × points across session) for log in gs.round_logs: bd = log.judge_result.get("breakdown", {}).get("player", {}) for wd in bd.get("word_details", []): if wd.get("score", 0) == 2: note = wd.get("note", "") if not session.sharpest_word or (wd.get("points", 0) > 0 and note): session.sharpest_word = wd.get("word", "") session.sharpest_word_note = note return session def finish_game(state: GameState) -> GameState: """Compute final winner and mark game over.""" player_rounds, opponent_rounds = count_round_wins(state) if player_rounds > opponent_rounds: state.winner = "player" elif opponent_rounds > player_rounds: state.winner = "opponent" else: total_p = sum(p for p, o in state.scores) total_o = sum(o for p, o in state.scores) if total_p > total_o: state.winner = "player" elif total_o > total_p: state.winner = "opponent" else: state.winner = "tie" state.game_over = True game_log.log_if_enabled(state, "log_game_end", state) return state def get_silence_preview(state: GameState, count: int = 3) -> List[str]: """Return `count` random unused words from opponent pool for Silence ability preview.""" available = [w.word for w in state.opponent_pool if not w.used] return random.sample(available, min(count, len(available))) # --------------------------------------------------------------------------- # Headless terminal mode # --------------------------------------------------------------------------- def _print_pool(pool: List[WordEntry]) -> None: by_cat: Dict[str, List[str]] = {} for w in pool: marker = "★" if w.tier == "power" else ("◆" if w.tier == "spice" else "") by_cat.setdefault(w.category, []).append(f"{w.word}{marker}") for cat, words in by_cat.items(): print(f" {cat:12s}: {', '.join(words)}") power_count = sum(1 for w in pool if w.tier == "power") spice_count = sum(1 for w in pool if w.tier == "spice") print(f" (power words: {power_count}/3, spice words: {spice_count}/2)") def _print_score_breakdown(label: str, bd: dict) -> None: print(f"\n --- {label} ---") print(f" WORD INTEGRATION {bd.get('word_integration', 0)} / 60") for wd in bd.get("word_details", []): tier_mark = "★" if wd.get("tier") == "power" else " " dots = "●" * wd.get("score", 0) + "○" * (2 - wd.get("score", 0)) 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) print(f" {tier_mark} {wd.get('word', ''):12s} {dots} {wd.get('label', '')} ({sign}{bonus})") print(f" ARGUMENT QUALITY {bd.get('quality_total', 0):.0f} / 40") print(f" Coherence {bd.get('coherence', 0)}") print(f" Persuasion {bd.get('persuasion', 0)}") print(f" Judge Appeal {bd.get('judge_appeal', 0)}") deb_bonus = bd.get("debater_bonus", 0) print(f" DEBATER BONUS {'+' if deb_bonus >= 0 else ''}{deb_bonus:.0f}") if bd.get("edge_bonus", 0): print(f" Edge: +{bd['edge_bonus']:.0f} persuasion weight") if bd.get("precision_bonus", 0): print(f" Precision: +{bd['precision_bonus']:.0f} coherence weight") if bd.get("style_bonus", 0): print(f" Style: +{bd['style_bonus']:.0f} judge appeal weight") if bd.get("contrarian_bonus", 0): print(f" Contrarian: +{bd['contrarian_bonus']}") swing = bd.get("risk_swing", 0) if swing != 0: print(f" Risk ({bd.get('risk_level', 1)}★): {'+' if swing > 0 else ''}{swing} swing") print(f" FINAL SCORE {bd.get('final_score', 0)}") note = bd.get("archetype_note", "") if note: print(f" → {note}") def _terminal_game() -> None: print("\n" + "=" * 60) print(" THE PODIUM — Headless Terminal Mode") print("=" * 60) print("\nInitialising game (generating word pools)...\n") state = new_game() # Show all 10 debaters, player picks 3 print("All available debaters:") for i, d in enumerate(state.all_debaters, 1): arch = content.ARCHETYPE_MAP[d.archetype_type] print(f" {i:2d}. {d.emoji} {d.name:12s} — {d.line[:60]}") print(f"\nOpponent's roster: " + ", ".join( f"{d.emoji} {d.name}" for d in state.opponent_roster )) while True: raw = input("\nChoose your 3 debaters (enter 3 numbers separated by spaces): ").strip() try: indices = [int(x) - 1 for x in raw.split()] if len(indices) != 3: print("Enter exactly 3 numbers.") continue if any(i < 0 or i >= len(state.all_debaters) for i in indices): print("Invalid index.") continue selected_ids = [state.all_debaters[i].id for i in indices] state = choose_roster(state, selected_ids) print(f"\nYour roster: " + ", ".join( f"{d.emoji} {d.name}" for d in state.player_roster )) break except ValueError: print("Enter numbers only.") # Show player pool print("\nYour word pool:") _print_pool(state.player_pool) if config.ABILITIES_ENABLED and state.abilities_hand: print("\nYour ability cards:") for i, card in enumerate(state.abilities_hand, 1): print(f" {i}. {card.emoji} {card.name} — {card.description}") # Round loop for _ in range(config.ROUNDS): print("\n" + "-" * 60) state = start_round(state) print(f"\n ROUND {state.round_num} of {config.ROUNDS}") print(f" Topic: {state.current_topic}") print(f" Judge: {state.current_judge.emoji} {state.current_judge.name}") print(f" Judge bias: {state.current_judge.bias_blurb}") while state.waiting_for_side_choice: side_in = input(" Choose your side (for/against): ").strip().lower() if side_in in ("for", "against"): state = choose_side(state, side_in) else: print(" Enter 'for' or 'against'.") # Ability selection before turn order is revealed ability_played = None ability_args: Dict = {} if state.waiting_for_ability_choice: print(f"\nPlay an ability before turn order is decided? (0 = skip)") for i, card in enumerate(state.abilities_hand, 1): if card.timing != "pre-round": continue used = state.abilities_used.get(card.id, 0) marker = "[USED] " if card.uses == 1 and used >= 1 else "" print(f" {i}. {card.emoji} {card.name} {marker}— {card.description}") ab_choice = input("Choice: ").strip() if ab_choice.isdigit() and 1 <= int(ab_choice) <= len(state.abilities_hand): card = state.abilities_hand[int(ab_choice) - 1] used = state.abilities_used.get(card.id, 0) if not (card.uses == 1 and used >= 1): ability_played = card.id if card.requires_input: val = input(f" {card.input_prompt} ").strip() if ability_played == "wildcard_card": ability_args["wildcard_word"] = val elif ability_played == "plant": ability_args["plant_word"] = val elif ability_played == "bias": ability_args["bias_emphasis"] = val if ability_played == "silence": preview = get_silence_preview(state) print(f" Opponent words visible: {', '.join(preview)}") silence_choice = input(" Which word to permanently remove? ").strip().lower() ability_args["silence_word"] = silence_choice state = commit_round_ability(state, ability_played, ability_args) print(f" Your side: {state.current_player_side.upper()}") print(f" First to argue: {state.current_first_to_argue.upper()}") print(f" Opponent debater: {state.opponent_round_debater.emoji} {state.opponent_round_debater.name}") # Show opponent preview if they went first if state.current_first_to_argue == "opponent" and state.opponent_argument_preview_sentences: print(f"\n OPPONENT ARGUES FIRST:") print(f" Words: {', '.join(state.opponent_preview_words)}") for i, sentence in enumerate(state.opponent_argument_preview_sentences, 1): word = state.opponent_preview_words[i - 1] if i <= len(state.opponent_preview_words) else "" arg_display = sentence if word: arg_display = re.sub( r"\b(" + re.escape(word) + r")\b", f"[{word.upper()}]", arg_display, count=1, flags=re.IGNORECASE, ) print(f" {i}. {arg_display}") # Show available words available = _available_words(state.player_pool) print("\nAvailable words:") for i, w in enumerate(available, 1): print(f" {i:2d}. [{w.category:10s}] {w.word}") # Word selection words_to_play = config.WORDS_PER_ROUND print(f"\nSelect {words_to_play} words (enter numbers separated by spaces):") while True: raw_sel = input("Selection: ").strip().split() try: indices = [int(x) - 1 for x in raw_sel] if len(indices) != words_to_play: print(f"Please select exactly {words_to_play} words.") continue if any(i < 0 or i >= len(available) for i in indices): print("Invalid index, try again.") continue selected_words = [available[i].word for i in indices] break except ValueError: print("Enter numbers only.") # Order selection print(f"\nOrder for emphasis (enter numbers 1-{words_to_play}, or Enter to keep):") print(f"Current: {', '.join(f'{i+1}:{w}' for i, w in enumerate(selected_words))}") raw_order = input("Order: ").strip() if raw_order: try: order_idx = [int(x) - 1 for x in raw_order.split()] if sorted(order_idx) == list(range(words_to_play)): word_order = [selected_words[i] for i in order_idx] else: word_order = selected_words except ValueError: word_order = selected_words else: word_order = selected_words # Debater selection unused_roster = [d for d in state.player_roster if d.id not in state.player_debaters_used] print(f"\nChoose your debater for this round:") for i, d in enumerate(unused_roster, 1): print(f" {i}. {d.emoji} {d.name} — {d.line[:55]}") while True: dc = input("Choice: ").strip() if dc.isdigit() and 1 <= int(dc) <= len(unused_roster): chosen_debater_id = unused_roster[int(dc) - 1].id break print("Invalid choice.") print(f"\nFinal word order: {', '.join(word_order)}") print("\nGenerating arguments and judge verdict... (this may take a moment)\n") state, player_sentences, opp_sentences = submit_player_turn( state, selected_words, word_order, chosen_debater_id ) if state.current_first_to_argue == "player": state, opp_sentences = generate_opponent_turn(state) state, log = finalize_round_scoring(state) # Display results print("\n" + "=" * 60) print(f" ROUND {log.round_num} RESULTS") print("=" * 60) print(f"\nTopic: {log.topic}") print(f"Judge: {log.judge.emoji} {log.judge.name}\n") print(f"YOUR ARGUMENT ({log.player_side.upper()}) — {log.player_debater.emoji} {log.player_debater.name}:") for i, sentence in enumerate(log.player_argument_sentences, 1): word = log.player_words[i - 1] if i <= len(log.player_words) else "" arg_display = sentence if word: arg_display = re.sub( r"\b(" + re.escape(word) + r")\b", f"[{word.upper()}]", arg_display, count=1, flags=re.IGNORECASE, ) print(f" {i}. {arg_display}") print(f"\nOPPONENT'S ARGUMENT ({log.opponent_side.upper()}) — {log.opponent_debater.emoji} {log.opponent_debater.name}:") for i, sentence in enumerate(log.opponent_argument_sentences, 1): word = log.opponent_words[i - 1] if i <= len(log.opponent_words) else "" opp_display = sentence if word: opp_display = re.sub( r"\b(" + re.escape(word) + r")\b", f"[{word.upper()}]", opp_display, count=1, flags=re.IGNORECASE, ) print(f" {i}. {opp_display}") result = log.judge_result print(f"\n--- SCORES ---") print(f" Your score: {result.get('player_score', '?')}") print(f" Opponent score: {result.get('opponent_score', '?')}") breakdown = result.get("breakdown", {}) if breakdown: _print_score_breakdown("YOUR SCORE", breakdown.get("player", {})) _print_score_breakdown("OPPONENT SCORE", breakdown.get("opponent", {})) print(f"\n VERDICT: \"{result.get('verdict', '...')}\"") round_winner = ( "YOU" if log.judge_result.get("round_winner") == "player" else "OPPONENT" if log.judge_result.get("round_winner") == "opponent" else ( "YOU" if result.get("player_score", 0) > result.get("opponent_score", 0) else "OPPONENT" ) ) print(f"\n Round winner: {round_winner}") rounds_won = sum(1 for p, o in state.scores if p > o) rounds_lost = sum(1 for p, o in state.scores if o > p) print(f" Running tally: You {rounds_won} — Opponent {rounds_lost}") if state.game_over: break input("\nPress Enter for next round...") # Final results print("\n" + "=" * 60) print(" GAME OVER") print("=" * 60) rounds_won = sum(1 for p, o in state.scores if p > o) rounds_lost = sum(1 for p, o in state.scores if o > p) total_p = sum(p for p, o in state.scores) total_o = sum(o for p, o in state.scores) print(f"\n Final score: You {rounds_won} rounds | Opponent {rounds_lost} rounds") print(f" Total points: You {total_p} | Opponent {total_o}") if state.winner == "player": print("\n YOU WIN! Step down from The Podium victorious.") elif state.winner == "opponent": print("\n YOU LOSE. The opponent takes The Podium.") else: print("\n IT'S A TIE. Both debaters share The Podium.") print("\n Round recap:") print(f" {'Rnd':>3} {'Topic':<35} {'Judge':<20} {'You':>5} {'Opp':>5}") print(" " + "-" * 75) for log in state.round_logs: p_s = log.judge_result.get("player_score", "?") o_s = log.judge_result.get("opponent_score", "?") print(f" {log.round_num:>3} {log.topic[:35]:<35} {log.judge.name[:20]:<20} {p_s:>5} {o_s:>5}") def _test_wildcard_synergy_bonus() -> None: wildcard = content.DEBATER_MAP["wildcard"] everyman = content.DEBATER_MAP["everyman"] args = {"wildcard_word": "chaos"} assert _wildcard_synergy_bonus(wildcard, "wildcard_card", args, ["chaos", "a", "b"]) == 5 assert _wildcard_synergy_bonus(wildcard, "wildcard_card", args, ["other", "a", "b"]) == 0 assert _wildcard_synergy_bonus(everyman, "wildcard_card", args, ["chaos", "a", "b"]) == 0 assert _wildcard_synergy_bonus(wildcard, "plant", args, ["chaos", "a", "b"]) == 0 assert _wildcard_synergy_bonus(wildcard, "wildcard_card", {}, ["chaos", "a", "b"]) == 0 print(" OK Wildcard synergy bonus") def _headless_smoke_test() -> None: """Quick validation: spice tier in pools + difficulty modifier in scoring.""" print("Running headless smoke test...\n") pool = content.generate_word_pool("noir", []) spice_count = sum(1 for w in pool if w.get("tier") == "spice") power_count = sum(1 for w in pool if w.get("tier") == "power") standard_count = sum(1 for w in pool if w.get("tier") == "standard") assert spice_count == 2, f"Expected 2 spice words, got {spice_count}" assert power_count == 3, f"Expected 3 power words, got {power_count}" assert standard_count == 7, f"Expected 7 standard words, got {standard_count}" print(f" OK Word pool tiers: {power_count} power, {spice_count} spice, {standard_count} standard") print(f" Spice words: {', '.join(w['word'] for w in pool if w['tier'] == 'spice')}") debater = content.DEBATER_MAP["everyman"] dummy_pool = [WordEntry(word="test", category="noun", tier="standard")] word_results = [{"word": "test", "score": 1, "note": "functional"}] judge_scores = {"coherence": 50, "persuasion": 50, "judge_appeal": 50} normal_bd = calculate_final_score( word_results, judge_scores, debater, dummy_pool, is_opponent_side=True, difficulty_modifier=0, ) hard_bd = calculate_final_score( word_results, judge_scores, debater, dummy_pool, is_opponent_side=True, difficulty_modifier=10, ) assert hard_bd["difficulty_modifier"] == 10 assert hard_bd["final_score"] > normal_bd["final_score"] print(f" OK Difficulty modifier: normal={normal_bd['final_score']}, hard={hard_bd['final_score']} (+10)") easy_roster = _build_opponent_roster("easy") hard_roster = _build_opponent_roster("hard") assert all(d.id in ("everyman", "stoic") for d in easy_roster) assert [d.id for d in hard_roster] == ["firebrand", "academic", "contrarian"] print(" OK Difficulty rosters: easy=weak, hard=strong") _test_wildcard_synergy_bonus() print("\nAll smoke tests passed.") if __name__ == "__main__": import sys from dotenv import load_dotenv load_dotenv() if len(sys.argv) > 1 and sys.argv[1] == "--test": _headless_smoke_test() sys.exit(0) try: _terminal_game() except KeyboardInterrupt: print("\n\nGame interrupted. Goodbye!") sys.exit(0)