Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions deepwolf/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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


Expand Down
38 changes: 37 additions & 1 deletion deepwolf/game/transcript.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
41 changes: 40 additions & 1 deletion tests/test_transcript.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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