| """ |
| 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") |
|
|
|
|
| |
| |
| |
|
|
| _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) |
|
|