""" Per-game session logging for troubleshooting and prompt refinement. Writes one JSONL file per game under logs/ plus a human-readable summary .txt. """ from __future__ import annotations import json import os import uuid from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Dict, List, Optional import config import content if TYPE_CHECKING: from engine import GameState, RoundLog, WordEntry def _ts() -> str: return datetime.now(timezone.utc).isoformat() def _pool_snapshot(pool: List[WordEntry]) -> List[Dict[str, Any]]: return [ { "word": w.word, "category": w.category, "tier": w.tier, "used": w.used, "round_used": w.round_used, } for w in pool ] def _archetype_snapshot(arch: Optional[content.Archetype]) -> Optional[Dict[str, str]]: if arch is None: return None return {"id": arch.id} def _debater_snapshot(debater: Optional[content.Debater]) -> Optional[Dict[str, str]]: if debater is None: return None return { "id": debater.id, "name": debater.name, "emoji": debater.emoji, "type": debater.type, "stats": debater.stats, } def _ability_snapshot(card: content.AbilityCard) -> Dict[str, Any]: return { "id": card.id, "name": card.name, "emoji": card.emoji, "description": card.description, "timing": card.timing, } def _judge_snapshot(judge: content.Judge) -> Dict[str, str]: return { "id": judge.id, "name": judge.name, "emoji": judge.emoji, "bias_blurb": judge.bias_blurb, } def _llm_info() -> Dict[str, str]: model = config.ANTHROPIC_MODEL if config.LLM_PROVIDER == "anthropic" else config.HF_MODEL return {"provider": config.LLM_PROVIDER, "model": model} def _sanitize_ability_args(args: Optional[Dict]) -> Optional[Dict]: if not args or not args.get("word_flagged"): return args out = dict(args) for key in ("wildcard_word", "plant_word"): if key in out: out[key] = config.FLAGGED_WORD_REPLACEMENT return out class GameLogger: """Append-only JSONL logger for a single game session.""" def __init__(self, game_id: str, log_dir: Optional[str] = None): self.game_id = game_id self.log_dir = log_dir or config.LOG_DIR os.makedirs(self.log_dir, exist_ok=True) stamp = datetime.now().strftime("%Y%m%d_%H%M%S") base = f"game_{stamp}_{game_id[:8]}" self.jsonl_path = os.path.join(self.log_dir, f"{base}.jsonl") self.txt_path = os.path.join(self.log_dir, f"{base}.txt") self._events: List[Dict[str, Any]] = [] def _write(self, event: Dict[str, Any]) -> None: event = {"ts": _ts(), "game_id": self.game_id, **event} self._events.append(event) with open(self.jsonl_path, "a", encoding="utf-8") as f: f.write(json.dumps(event, ensure_ascii=False) + "\n") def log_game_setup( self, state: GameState, player_flavor: str, opponent_flavor: str, ) -> None: self._write({ "event": "game_setup", "llm": _llm_info(), "player_pool": { "flavor_seed": player_flavor, "words": _pool_snapshot(state.player_pool), }, "opponent_pool": { "flavor_seed": opponent_flavor, "words": _pool_snapshot(state.opponent_pool), }, "opponent_roster": [_debater_snapshot(d) for d in state.opponent_roster], "abilities_hand": [_ability_snapshot(c) for c in state.abilities_hand], "abilities_enabled": config.ABILITIES_ENABLED, }) def log_round_setup( self, state: GameState, topic_meta: Dict[str, str], ) -> None: self._write({ "event": "round_setup", "round": state.round_num, "topic": state.current_topic, "topic_seed_word": topic_meta.get("seed_word"), "topic_frame": topic_meta.get("frame"), "judge": _judge_snapshot(state.current_judge), "player_side": state.current_player_side, "opponent_side": state.current_opponent_side, "first_to_argue": state.current_first_to_argue, }) def log_round_result( self, log: RoundLog, opponent_rationale: str = "", appeal: bool = False, ) -> None: self._write({ "event": "round_appeal" if appeal else "round_result", "round": log.round_num, "topic": log.topic, "judge": _judge_snapshot(log.judge), "player_side": log.player_side, "opponent_side": log.opponent_side, "first_to_argue": log.first_to_argue, "player_debater": _debater_snapshot(getattr(log, "player_debater", None)), "opponent_debater": _debater_snapshot(getattr(log, "opponent_debater", None)), "player_words": log.player_words, "opponent_words": log.opponent_words, "opponent_selection_rationale": opponent_rationale, "player_argument": log.player_argument, "opponent_argument": log.opponent_argument, "ability_played": log.ability_played, "ability_args": _sanitize_ability_args(log.ability_args), "content_flag_active": log.content_flag_active, "content_flag_ability": log.content_flag_ability, "judge_result": log.judge_result, "scores": { "player": log.judge_result.get("player_score"), "opponent": log.judge_result.get("opponent_score"), }, }) def log_game_end(self, state: GameState) -> None: total_p = sum(p for p, o in state.scores) total_o = sum(o for p, o in state.scores) 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) self._write({ "event": "game_end", "winner": state.winner, "rounds_won": rounds_won, "rounds_lost": rounds_lost, "total_points": {"player": total_p, "opponent": total_o}, "per_round_scores": state.scores, }) self._write_summary_txt(state) def _write_summary_txt(self, state: GameState) -> None: lines = [ f"THE PODIUM — Game Log", f"Game ID: {self.game_id}", f"Log file: {self.jsonl_path}", f"LLM: {_llm_info()['provider']} / {_llm_info()['model']}", "", "=" * 60, "SETUP", "=" * 60, ] for ev in self._events: if ev["event"] == "game_setup": lines.append(f"Player flavor seed: {ev['player_pool']['flavor_seed']}") lines.append(f"Player words: {', '.join(w['word'] for w in ev['player_pool']['words'])}") lines.append(f"Opponent flavor seed: {ev['opponent_pool']['flavor_seed']}") lines.append(f"Opponent words: {', '.join(w['word'] for w in ev['opponent_pool']['words'])}") opp_roster = ev.get("opponent_roster", []) lines.append("Opponent roster: " + ", ".join( f"{d['emoji']} {d['name']}" for d in opp_roster )) if ev["abilities_hand"]: lines.append("Ability hand: " + ", ".join( f"{c['emoji']} {c['name']}" for c in ev["abilities_hand"] )) break for ev in self._events: if ev["event"] not in ("round_result", "round_appeal"): continue lines += [ "", "=" * 60, f"ROUND {ev['round']}" + (" (APPEAL RE-SCORE)" if ev["event"] == "round_appeal" else ""), "=" * 60, f"Topic: {ev['topic']}", f"Judge: {ev['judge']['emoji']} {ev['judge']['name']}", f"Sides: player={ev['player_side']}, opponent={ev['opponent_side']}", f"First to argue: {ev['first_to_argue']}", f"Player words: {', '.join(ev['player_words'])}", f"Opponent words: {', '.join(ev['opponent_words'])}", ] if ev.get("ability_played"): lines.append(f"Ability played: {ev['ability_played']} {ev.get('ability_args') or ''}") lines += [ "", "--- Player argument ---", ev["player_argument"], "", "--- Opponent argument ---", ev["opponent_argument"], "", f"Scores: player={ev['scores']['player']} opponent={ev['scores']['opponent']}", f"Verdict: {ev['judge_result'].get('verdict', '')}", ] breakdown = ev["judge_result"].get("breakdown", {}) if breakdown: lines.append(f"Breakdown: {json.dumps(breakdown)}") for ev in self._events: if ev["event"] == "game_end": lines += [ "", "=" * 60, "FINAL", "=" * 60, f"Winner: {ev['winner']}", f"Rounds: {ev['rounds_won']} - {ev['rounds_lost']}", f"Total points: player={ev['total_points']['player']} opponent={ev['total_points']['opponent']}", ] with open(self.txt_path, "w", encoding="utf-8") as f: f.write("\n".join(lines) + "\n") # --------------------------------------------------------------------------- # Module-level helpers used by engine.py # --------------------------------------------------------------------------- _loggers: Dict[str, GameLogger] = {} def new_game_logger() -> GameLogger: game_id = uuid.uuid4().hex logger = GameLogger(game_id) _loggers[game_id] = logger return logger def get_logger(game_id: Optional[str]) -> Optional[GameLogger]: if not game_id or not config.GAME_LOGGING_ENABLED: return None return _loggers.get(game_id) def log_if_enabled(state: GameState, fn_name: str, *args, **kwargs) -> None: """Call a GameLogger method if logging is enabled for this state.""" logger = get_logger(getattr(state, "log_id", None)) if logger is None: return getattr(logger, fn_name)(*args, **kwargs)