Add match replay and JSONL export
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user