Add match replay and JSONL export

This commit is contained in:
2026-07-12 15:14:24 +09:00
parent 7e481ca064
commit f854d97764
6 changed files with 616 additions and 141 deletions
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
import json
from collections import Counter
import pytest
from coolrl_lost_cities.games.classic.game import GameState, classic_config
from coolrl_lost_cities.games.classic.match_record import (
FORMAT_ID,
MatchRecord,
decode_public_hands,
encode_public_hands,
make_step,
)
def _record() -> MatchRecord:
state = GameState.new_game(classic_config(), seed=17)
public = [Counter(), Counter()]
steps = [
make_step(
index=0,
state=state,
public_hands=public,
actor=None,
phase_before=None,
action_id=None,
)
]
before_player, before_phase = state.current_player, state.phase
action = state.unified_legal_actions()[0]
state.apply_unified_action(action)
steps.append(
make_step(
index=1,
state=state,
public_hands=public,
actor=before_player,
phase_before=before_phase,
action_id=action,
)
)
return MatchRecord(
metadata={"type": "metadata", "format": FORMAT_ID, "complete": False},
steps=steps,
)
def test_match_record_jsonl_round_trip(tmp_path) -> None:
record = _record()
path = record.write_jsonl(tmp_path / "match.jsonl")
restored = MatchRecord.read_jsonl(path)
assert restored.metadata == record.metadata
assert restored.steps == record.steps
assert len(path.read_text().splitlines()) == 3
def test_public_hand_encoding_is_stable_and_counted() -> None:
counts = [Counter({(2, 7): 1, (0, 0): 2}), Counter()]
encoded = encode_public_hands(counts)
assert encoded[0] == [
{"color": 0, "value": 0, "count": 2},
{"color": 2, "value": 7, "count": 1},
]
assert decode_public_hands(encoded) == counts
def test_match_record_rejects_unknown_format(tmp_path) -> None:
path = tmp_path / "future.jsonl"
rows = [
{"type": "metadata", "format": "coolrl.lost-cities.match.v2"},
_record().steps[0],
]
path.write_text("\n".join(json.dumps(row) for row in rows) + "\n")
with pytest.raises(ValueError, match="unsupported match format"):
MatchRecord.read_jsonl(path)
def test_match_record_rejects_noncontiguous_steps() -> None:
record = _record()
record.steps[1]["index"] = 3
with pytest.raises(ValueError, match="contiguous"):
record.validate()
def test_match_record_rejects_state_not_produced_by_action() -> None:
record = _record()
record.steps[1]["action_id"] = record.steps[1]["action_id"] + 1
with pytest.raises(ValueError, match="state does not follow|invalid action"):
record.validate()
+82
View File
@@ -16,6 +16,7 @@ def test_argparser_defaults_to_final_candidate() -> None:
assert args.width == 1600
assert args.height == 1000
assert args.offline is False
assert args.export_dir == "exports"
def test_argparser_accepts_overrides() -> None:
@@ -56,6 +57,8 @@ def test_layout_scales_between_window_sizes() -> None:
assert len(slots) == 8
assert slots[0][0] >= 0
assert slots[-1][0] + layout.hand_w <= layout.w
lane_center = (layout.col_x[0] + layout.col_x[-1] + layout.board_w) / 2
assert abs(lane_center - layout.w / 2) <= 1
assert large.hand_w >= small.hand_w
@@ -128,3 +131,82 @@ def test_offline_mode_explicitly_uses_heuristic() -> None:
assert "offline" in opponent.label
finally:
opponent.shutdown()
def test_finished_match_can_be_replayed_and_exported(tmp_path) -> None:
app = pygame_table.TableApp(
seed=29,
offline=True,
headless=True,
export_dir=tmp_path,
)
try:
while not app.state.terminal:
app.apply_unified(app.state.unified_legal_actions()[0])
final_index = len(app.match_steps) - 1
assert app.review_index == final_index
assert all(sprite.face_up for sprite in app.hand_zones[app.ai_seat])
app.review_step(-1)
assert app.review_index == final_index - 1
app.enter_review(0)
assert app.review_index == 0
assert app.state.turn_count == 0
assert all(sprite.face_up for sprite in app.hand_zones[app.ai_seat])
path = app.export_match()
restored = pygame_table.MatchRecord.read_jsonl(path)
assert restored.metadata["complete"] is True
assert len(restored.steps) == len(app.match_steps)
assert restored.steps[-1]["state"]["terminal"] is True
finally:
app.opponent.shutdown()
pygame_table.pygame.quit()
def test_undo_then_new_action_replaces_export_timeline() -> None:
app = pygame_table.TableApp(seed=37, offline=True, headless=True)
try:
first = app.state.unified_legal_actions()[0]
app.apply_unified(first)
app.apply_unified(app.state.unified_legal_actions()[0])
assert app.timeline_cursor == 2
app.undo()
assert app.timeline_cursor < len(app.match_steps) - 1
replacement = app.state.unified_legal_actions()[-1]
app.apply_unified(replacement)
record = app.current_match_record()
record.validate()
assert len(record.steps) == app.timeline_cursor + 1
assert record.steps[-1]["action_id"] == replacement
finally:
app.opponent.shutdown()
pygame_table.pygame.quit()
def test_menu_exports_current_match(tmp_path) -> None:
app = pygame_table.TableApp(
seed=43,
offline=True,
headless=True,
export_dir=tmp_path,
)
try:
app.draw()
app.on_click(app.menu_button_rect.center)
assert app.menu_open is True
app.draw()
export_rect = next(rect for action, rect in app.menu_items if action == "export")
app.on_click(export_rect.center)
exported = list(tmp_path.glob("lost-cities-match-*.jsonl"))
assert len(exported) == 1
restored = pygame_table.MatchRecord.read_jsonl(exported[0])
assert restored.metadata["complete"] is False
assert len(restored.steps) == 1
finally:
app.opponent.shutdown()
pygame_table.pygame.quit()