The-Podium / engine.py
MannKnee's picture
Fix HF scroll shell height and round gameplay edge cases.
b1e6c7e
Raw
History Blame Contribute Delete
96.3 kB
"""
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)