diff --git a/deepwolf/cli.py b/deepwolf/cli.py index 54a5655..0646b7b 100644 --- a/deepwolf/cli.py +++ b/deepwolf/cli.py @@ -301,6 +301,36 @@ def progress(done: int, total: int) -> None: return 0 +def cmd_replay(args: argparse.Namespace) -> int: + import time + + from deepwolf.game.transcript import load + + console = _console() + transcript = load(args.path) + + console.rule(f"deepwolf replay — {len(transcript.players)} players, {transcript.days} days") + + delay = args.delay + for event in transcript.events: + _print_event(console, event) + if delay > 0: + time.sleep(delay) + + console.rule("result") + rows = [] + for p in transcript.players: + status = "survived" if p["alive"] else f"died day {p['death_day']}" + rows.append(f" P{p['id']} {p['name']:<8} {p['role']:<9} — {status}") + body = "\n".join(rows) + title = f"{transcript.winner_label} win" + if _RICH: + console.print(Panel(body, title=title, border_style="green")) + else: + console.print(f"{title}\n{body}") + return 0 + + # -------------------------------------------------------------- rendering _STYLE = { EventType.GAME_START: "bold cyan", @@ -492,6 +522,14 @@ def build_parser() -> argparse.ArgumentParser: help="also write the ranking as a Markdown table", ) board.set_defaults(func=cmd_leaderboard) + + replay = sub.add_parser("replay", help="replay a saved transcript") + replay.add_argument("path", help="path to a transcript JSON file") + replay.add_argument( + "--delay", type=float, default=0.0, + help="seconds to pause between events (default: 0, instant)", + ) + replay.set_defaults(func=cmd_replay) return parser diff --git a/deepwolf/game/transcript.py b/deepwolf/game/transcript.py index 9c937fb..f7aa0ae 100644 --- a/deepwolf/game/transcript.py +++ b/deepwolf/game/transcript.py @@ -14,12 +14,48 @@ import json from pathlib import Path -from deepwolf.game.events import Event +from deepwolf.game.events import Event, EventType +from deepwolf.game.roles import Faction from deepwolf.game.state import GameResult SCHEMA = "deepwolf.transcript/v1" +class Transcript: + """A loaded transcript ready for replay.""" + + def __init__(self, data: dict) -> None: + self.schema: str = data.get("schema", "") + self.winner: str = data["winner"] + self.days: int = data["days"] + self.players: list[dict] = data["players"] + self.events: list[Event] = [_event_from_json(e) for e in data["events"]] + + @property + def winner_label(self) -> str: + return "the Village" if self.winner == Faction.VILLAGE.value else "the Werewolves" + + +def _event_from_json(raw: dict) -> Event: + """Reconstruct an :class:`Event` from a transcript dict entry.""" + return Event( + type=EventType(raw["type"]), + day=raw["day"], + phase=raw["phase"], + text=raw["text"], + actor=raw.get("actor"), + target=raw.get("target"), + public=raw.get("public", True), + visible_to=frozenset(raw.get("visible_to", ())), + data=raw.get("data", {}), + ) + + +def load(path: str | Path) -> Transcript: + """Read a transcript JSON file and return a :class:`Transcript`.""" + return Transcript(json.loads(Path(path).read_text(encoding="utf-8"))) + + def event_to_json(event: Event) -> dict: """Serialise one :class:`Event` to a JSON-safe dict.""" return { diff --git a/tests/test_transcript.py b/tests/test_transcript.py index 2eb4383..bc8593f 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -6,8 +6,9 @@ from deepwolf.agents.random_agent import RandomAgent from deepwolf.game.engine import GameEngine +from deepwolf.game.events import EventType from deepwolf.game.state import GameConfig -from deepwolf.game.transcript import SCHEMA, dumps, save, to_json +from deepwolf.game.transcript import SCHEMA, Transcript, dumps, load, save, to_json def _finished_game(): @@ -43,3 +44,41 @@ def test_save_writes_a_readable_file(tmp_path): data = json.loads(path.read_text(encoding="utf-8")) assert data["schema"] == SCHEMA assert data["days"] >= 1 + + +def test_load_reconstructs_events(tmp_path): + result = _finished_game() + path = save(result, tmp_path / "game.json") + transcript = load(path) + assert isinstance(transcript, Transcript) + assert transcript.winner == result.winner.value + assert transcript.days == result.days + assert len(transcript.players) == len(result.players) + assert len(transcript.events) == len(result.events) + for loaded, original in zip(transcript.events, result.events, strict=True): + assert loaded.type is original.type + assert loaded.day == original.day + assert loaded.text == original.text + assert loaded.public == original.public + + +def test_round_trip_simulate_save_load(tmp_path): + """Simulate -> save -> load -> verify events match exactly.""" + result = _finished_game() + path = save(result, tmp_path / "rt.json") + transcript = load(path) + # every event text must survive the round trip + original_texts = [e.text for e in result.events] + loaded_texts = [e.text for e in transcript.events] + assert original_texts == loaded_texts + # event types must be proper EventType enums, not strings + assert all(isinstance(e.type, EventType) for e in transcript.events) + + +def test_replay_cli_runs_without_error(tmp_path): + """End-to-end: simulate, save, replay via CLI entry point.""" + from deepwolf.cli import main + + path = save(_finished_game(), tmp_path / "replay.json") + exit_code = main(["replay", str(path)]) + assert exit_code == 0