| """ |
| 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 = { |
| "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"]) |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class WordEntry: |
| word: str |
| category: str |
| tier: str = "standard" |
| 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_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] |
| 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: |
| |
| player_pool: List[WordEntry] = field(default_factory=list) |
| opponent_pool: List[WordEntry] = field(default_factory=list) |
|
|
| |
| 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) |
|
|
| |
| round_debater: Optional[content.Debater] = None |
| opponent_round_debater: Optional[content.Debater] = None |
|
|
| |
| opponent_argument_preview_sentences: List[str] = field(default_factory=list) |
| opponent_preview_words: List[str] = field(default_factory=list) |
|
|
| |
| 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_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_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_used_words: List[str] = field(default_factory=list) |
|
|
| |
| game_over: bool = False |
| winner: Optional[str] = None |
|
|
| |
| 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 |
|
|
| |
| log_id: Optional[str] = None |
| player_flavor_seed: Optional[str] = None |
| opponent_flavor_seed: Optional[str] = None |
|
|
| |
| difficulty: str = "normal" |
|
|
| |
| call_it_active: bool = False |
| call_it_threshold: int = 80 |
| call_it_rewritten: bool = False |
| call_it_original_topic: Optional[str] = None |
|
|
| |
| judge_take: str = "" |
|
|
| |
| content_flag_active: bool = False |
| content_flag_ability: Optional[str] = None |
|
|
|
|
| |
| |
| |
|
|
| 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) |
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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() |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| state.all_debaters = list(content.DEBATERS) |
|
|
| |
| state.opponent_roster = _build_opponent_roster(state.difficulty) |
|
|
| |
| 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 |
|
|
| |
| topic, topic_meta = _generate_topic(state) |
| state.current_topic = topic |
| state.current_topic_meta = topic_meta |
| state.previous_topics.append(topic) |
|
|
| |
| judge = _pick_judge(state) |
| state.used_judges.append(judge.id) |
| state.current_judge = judge |
|
|
| state.judge_take = _generate_judge_take(state) |
|
|
| |
| state.current_player_side = None |
| state.current_opponent_side = None |
| state.current_first_to_argue = None |
| state.waiting_for_side_choice = True |
|
|
| |
| state.opponent_round_debater = _auto_select_opponent_debater(state) |
|
|
| |
| 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 |
| 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) |
|
|
| |
| 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 = {} |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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_used(state.player_pool, player_words, state.round_num) |
| _mark_used(state.opponent_pool, opp_words, state.round_num) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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, "") |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| 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 |
|
|
| 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") |
|
|
| |
| 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 |
|
|
| |
| total_p = sum(p for p, o in gs.scores) |
| session.best_game_total = max(session.best_game_total, total_p) |
|
|
| |
| 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", "") |
|
|
| |
| 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))) |
|
|
|
|
| |
| |
| |
|
|
| 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() |
|
|
| |
| 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.") |
|
|
| |
| 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}") |
|
|
| |
| 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_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}") |
|
|
| |
| 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}") |
|
|
| |
| 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}") |
|
|
| |
| 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.") |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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...") |
|
|
| |
| 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) |
|
|