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
+1
View File
@@ -37,6 +37,7 @@ the workflow document for deciding where new documentation belongs.
## Engine / Performance ## Engine / Performance
- [Classic game port architecture](classic-port-notes.md) — Describes the standalone Cython classic engine as the stable rules layer for all consumers. (2026-05-08) - [Classic game port architecture](classic-port-notes.md) — Describes the standalone Cython classic engine as the stable rules layer for all consumers. (2026-05-08)
- [Lost Cities match record v1](lost-cities-match-record-v1.md) — Defines the versioned JSONL format for complete hidden-state replay and post-game analysis. (2026-07-12)
- [Fast engine optimization architecture](fast-engine-next-optimizations.md) — Prioritizes C-level APIs, contiguous allocation, and zero-copy extraction for high-throughput RL. (2026-05-08) - [Fast engine optimization architecture](fast-engine-next-optimizations.md) — Prioritizes C-level APIs, contiguous allocation, and zero-copy extraction for high-throughput RL. (2026-05-08)
- [Optimization sequencing](optimization_sequencing.md) — Orders runtime, traversal, model-scale, and inference optimizations to avoid invalidating experiments. (2026-05-07) - [Optimization sequencing](optimization_sequencing.md) — Orders runtime, traversal, model-scale, and inference optimizations to avoid invalidating experiments. (2026-05-07)
@@ -0,0 +1,42 @@
# Lost Cities Match Record v1
Last verified: 2026-07-12
`coolrl.lost-cities.match.v1` is the canonical portable record for one complete
or in-progress classic Lost Cities match. Files use UTF-8 JSON Lines (`.jsonl`)
so metadata can be inspected without loading the full replay and steps can be
streamed in order.
## Row 1: metadata
The first row has `type: "metadata"` and `format:
"coolrl.lost-cities.match.v1"`. It records creation time, classic rules,
initial seed, human seat, opponent identity, and completion status. Producers
may add fields; v1 readers must ignore unknown metadata fields.
## Rows 2+: steps
Every remaining row has `type: "step"` and a contiguous zero-based `index`.
Step 0 is the initial position and has null `actor`, `phase_before`, and
`action_id`. Later steps contain:
- `actor`: player that took the action (`0` or `1`).
- `phase_before`: `card` or `draw`.
- `action_id`: the classic engine unified action ID.
- `state`: complete `GameState.to_snapshot()` output after the action.
- `public_hands`: cards known to be in each player's hand because they were
drawn from a discard pile. Entries use `{color, value, count}`; wagers have
value 0 and numbered cards use their printed value.
Full state snapshots intentionally include hidden hands and deck order. A match
record is therefore suitable for post-game analysis and deterministic replay,
but must not be exposed to a player during a live match.
## Compatibility
Readers must reject an unknown `format` value rather than guessing. New
optional fields may be added within v1. Any incompatible change to action IDs,
state encoding, or required row semantics requires `v2`.
The reference reader, writer, and validator are in
`src/coolrl_lost_cities/games/classic/match_record.py`.
@@ -0,0 +1,146 @@
"""Versioned, replayable Lost Cities match records."""
from __future__ import annotations
import json
from collections import Counter
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .game import GameState
FORMAT_NAME = "coolrl.lost-cities.match"
FORMAT_VERSION = 1
FORMAT_ID = f"{FORMAT_NAME}.v{FORMAT_VERSION}"
def encode_public_hands(
counts: Sequence[Mapping[tuple[int, int], int]],
) -> list[list[dict[str, int]]]:
return [
[
{"color": color, "value": value, "count": count}
for (color, value), count in sorted(player.items())
if count > 0
]
for player in counts
]
def decode_public_hands(
payload: Sequence[Sequence[Mapping[str, Any]]],
) -> list[Counter[tuple[int, int]]]:
if len(payload) != 2:
raise ValueError("public_hands must contain exactly two players")
result: list[Counter[tuple[int, int]]] = []
for cards in payload:
counter: Counter[tuple[int, int]] = Counter()
for card in cards:
color = int(card["color"])
value = int(card["value"])
count = int(card["count"])
if color < 0 or value < 0 or count <= 0:
raise ValueError("invalid public hand entry")
counter[(color, value)] += count
result.append(counter)
return result
def make_step(
*,
index: int,
state: GameState,
public_hands: Sequence[Mapping[tuple[int, int], int]],
actor: int | None,
phase_before: str | None,
action_id: int | None,
) -> dict[str, Any]:
return {
"type": "step",
"index": index,
"actor": actor,
"phase_before": phase_before,
"action_id": action_id,
"state": state.to_snapshot(),
"public_hands": encode_public_hands(public_hands),
}
@dataclass
class MatchRecord:
metadata: dict[str, Any]
steps: list[dict[str, Any]]
def validate(self) -> None:
if self.metadata.get("type") != "metadata":
raise ValueError("first record must be metadata")
if self.metadata.get("format") != FORMAT_ID:
raise ValueError(f"unsupported match format: {self.metadata.get('format')!r}")
if not self.steps:
raise ValueError("match record must contain at least the initial step")
for expected, step in enumerate(self.steps):
if step.get("type") != "step" or step.get("index") != expected:
raise ValueError("steps must be contiguous and zero-indexed")
state = step.get("state")
if not isinstance(state, dict):
raise ValueError(f"step {expected} has no state snapshot")
GameState.from_snapshot(state)
decode_public_hands(step.get("public_hands", []))
if expected == 0 and any(
step.get(key) is not None for key in ("actor", "phase_before", "action_id")
):
raise ValueError("initial step cannot contain an action")
if expected > 0:
previous = GameState.from_snapshot(self.steps[expected - 1]["state"])
if step.get("actor") != previous.current_player:
raise ValueError(f"step {expected} actor does not match previous state")
if step.get("phase_before") != previous.phase:
raise ValueError(f"step {expected} phase does not match previous state")
try:
previous.apply_unified_action(int(step["action_id"]))
except (KeyError, TypeError, ValueError) as exc:
raise ValueError(f"step {expected} has an invalid action") from exc
if previous.to_snapshot() != state:
raise ValueError(f"step {expected} state does not follow its action")
def write_jsonl(self, path: str | Path) -> Path:
self.validate()
destination = Path(path)
destination.parent.mkdir(parents=True, exist_ok=True)
with destination.open("w", encoding="utf-8") as handle:
for row in (self.metadata, *self.steps):
handle.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n")
return destination
@classmethod
def read_jsonl(cls, path: str | Path) -> MatchRecord:
with Path(path).open(encoding="utf-8") as handle:
rows = [json.loads(line) for line in handle if line.strip()]
if not rows:
raise ValueError("empty match record")
record = cls(metadata=rows[0], steps=rows[1:])
record.validate()
return record
def record_from_rows(rows: Iterable[dict[str, Any]]) -> MatchRecord:
materialized = list(rows)
if not materialized:
raise ValueError("empty match record")
record = MatchRecord(materialized[0], materialized[1:])
record.validate()
return record
__all__ = [
"FORMAT_ID",
"FORMAT_NAME",
"FORMAT_VERSION",
"MatchRecord",
"decode_public_hands",
"encode_public_hands",
"make_step",
"record_from_rows",
]
@@ -27,6 +27,7 @@ import pygame
from .bots import build_bot from .bots import build_bot
from .game import Card, GameState, classic_config, score_expedition from .game import Card, GameState, classic_config, score_expedition
from .match_record import FORMAT_ID, MatchRecord, decode_public_hands, make_step
LOGGER = logging.getLogger("coolrl_lost_cities.games.classic.pygame_table") LOGGER = logging.getLogger("coolrl_lost_cities.games.classic.pygame_table")
@@ -549,7 +550,9 @@ class Layout:
col_gap = int(self.board_w * 0.52) col_gap = int(self.board_w * 0.52)
self.col_w = self.board_w + col_gap self.col_w = self.board_w + col_gap
total = self.col_w * n_colors total = self.col_w * n_colors
cx = (w - deck_zone) // 2 # The five expedition lanes are the table's visual anchor. Center them
# on the window; the deck occupies independent space on the far right.
cx = w // 2
self.col_x = [cx - total // 2 + self.col_w * i + col_gap // 2 for i in range(n_colors)] self.col_x = [cx - total // 2 + self.col_w * i + col_gap // 2 for i in range(n_colors)]
self.discard_cy = (self.board_top + self.board_bottom) // 2 self.discard_cy = (self.board_top + self.board_bottom) // 2
self.deck_rect = pygame.Rect(0, 0, self.board_w, self.board_h) self.deck_rect = pygame.Rect(0, 0, self.board_w, self.board_h)
@@ -711,6 +714,7 @@ class TableApp:
height: int = 1000, height: int = 1000,
offline: bool = False, offline: bool = False,
headless: bool = False, headless: bool = False,
export_dir: str | Path = "exports",
) -> None: ) -> None:
if headless: if headless:
import os import os
@@ -726,6 +730,7 @@ class TableApp:
self.rng = random.Random(seed) self.rng = random.Random(seed)
self.seed = seed self.seed = seed
self.export_dir = Path(export_dir)
self.config = classic_config() self.config = classic_config()
self.min_rank = self.config.min_rank self.min_rank = self.config.min_rank
self.opponent = Opponent( self.opponent = Opponent(
@@ -751,9 +756,15 @@ class TableApp:
self.ai_min_reveal_at = 0 self.ai_min_reveal_at = 0
self.prompt_text = "" self.prompt_text = ""
self.prompt_since = 0 self.prompt_since = 0
self.end_overlay_at: int | None = None self.menu_open = False
self.end_backdrop: pygame.Surface | None = None self.menu_button_rect = pygame.Rect(0, 0, 1, 1)
self.play_again_rect: pygame.Rect | None = None self.menu_items: list[tuple[str, pygame.Rect]] = []
self.review_index: int | None = None
self.match_metadata: dict[str, Any] = {}
self.match_steps: list[dict[str, Any]] = []
self.timeline_cursor = 0
self.toast_text = ""
self.toast_until = 0
self.shake_until = 0 self.shake_until = 0
self.shake_rect: pygame.Rect | None = None self.shake_rect: pygame.Rect | None = None
self.running = False self.running = False
@@ -786,6 +797,9 @@ class TableApp:
self.history = [] self.history = []
self.future = [] self.future = []
self.public_hand_counts = [Counter(), Counter()] self.public_hand_counts = [Counter(), Counter()]
self.review_index = None
self.menu_open = False
self.toast_text = ""
n = self.config.n_colors n = self.config.n_colors
self.sprites = [] self.sprites = []
@@ -794,10 +808,33 @@ class TableApp:
self.discard_zones = [[] for _ in range(n)] self.discard_zones = [[] for _ in range(n)]
self.selected = None self.selected = None
self.hovered = None self.hovered = None
self.end_overlay_at = None
self.end_backdrop = None
self.ai_next_at = 0 self.ai_next_at = 0
self.match_metadata = {
"type": "metadata",
"format": FORMAT_ID,
"created_at": datetime.now().astimezone().isoformat(),
"complete": False,
"seed": game_seed,
"human_seat": self.human_seat,
"opponent": {
"kind": "heuristic" if self.opponent.offline else "jax_ppo",
"checkpoint": (str(self.opponent.checkpoint) if self.opponent.checkpoint else None),
},
"config": self.config.to_snapshot(),
}
self.match_steps = [
make_step(
index=0,
state=self.state,
public_hands=self.public_hand_counts,
actor=None,
phase_before=None,
action_id=None,
)
]
self.timeline_cursor = 0
deck_pos = self.layout.deck_rect.topleft deck_pos = self.layout.deck_rect.topleft
deal_order: list[tuple[int, Sprite]] = [] deal_order: list[tuple[int, Sprite]] = []
for slot in range(self.config.hand_size): for slot in range(self.config.hand_size):
@@ -841,10 +878,13 @@ class TableApp:
def apply_unified(self, action_id: int) -> None: def apply_unified(self, action_id: int) -> None:
"""Apply an engine action and move the matching sprites.""" """Apply an engine action and move the matching sprites."""
if self.timeline_cursor < len(self.match_steps) - 1:
self.match_steps = self.match_steps[: self.timeline_cursor + 1]
self.history.append((self.state.clone(), self._copy_public_counts())) self.history.append((self.state.clone(), self._copy_public_counts()))
self.future.clear() self.future.clear()
state = self.state state = self.state
seat = state.current_player seat = state.current_player
phase_before = state.phase
is_human = seat == self.human_seat is_human = seat == self.human_seat
if state.phase == "card": if state.phase == "card":
slot, place = divmod(action_id, 2) slot, place = divmod(action_id, 2)
@@ -888,8 +928,26 @@ class TableApp:
if is_human: if is_human:
self._sort_human_hand() self._sort_human_hand()
self.selected = None self.selected = None
if state.terminal and self.end_overlay_at is None: self.match_steps.append(
self.end_overlay_at = pygame.time.get_ticks() + 1300 make_step(
index=len(self.match_steps),
state=state,
public_hands=self.public_hand_counts,
actor=seat,
phase_before=phase_before,
action_id=action_id,
)
)
self.timeline_cursor += 1
if state.terminal and not self.match_metadata.get("complete"):
self.match_metadata.update(
{
"complete": True,
"completed_at": datetime.now().astimezone().isoformat(),
"scores": [state.total_score(0), state.total_score(1)],
}
)
self.enter_review(len(self.match_steps) - 1)
# -- undo / redo ------------------------------------------------------------- # -- undo / redo -------------------------------------------------------------
@@ -905,28 +963,30 @@ class TableApp:
def undo(self) -> None: def undo(self) -> None:
"""Step back to the most recent point the human had agency, skipping """Step back to the most recent point the human had agency, skipping
over the rival's automatic moves in between.""" over the rival's automatic moves in between."""
if not self.history: if self.review_index is not None or not self.history:
return return
while self.history: while self.history:
self.future.append((self.state.clone(), self._copy_public_counts())) self.future.append((self.state.clone(), self._copy_public_counts()))
self.state, counts = self.history.pop() self.state, counts = self.history.pop()
self.public_hand_counts = [Counter(values) for values in counts] self.public_hand_counts = [Counter(values) for values in counts]
self.timeline_cursor = max(0, self.timeline_cursor - 1)
if self._at_human_decision(): if self._at_human_decision():
break break
self._sync_view_to_state() self._sync_view_to_state()
def redo(self) -> None: def redo(self) -> None:
if not self.future: if self.review_index is not None or not self.future:
return return
while self.future: while self.future:
self.history.append((self.state.clone(), self._copy_public_counts())) self.history.append((self.state.clone(), self._copy_public_counts()))
self.state, counts = self.future.pop() self.state, counts = self.future.pop()
self.public_hand_counts = [Counter(values) for values in counts] self.public_hand_counts = [Counter(values) for values in counts]
self.timeline_cursor = min(len(self.match_steps) - 1, self.timeline_cursor + 1)
if self._at_human_decision(): if self._at_human_decision():
break break
self._sync_view_to_state() self._sync_view_to_state()
def _sync_view_to_state(self) -> None: def _sync_view_to_state(self, *, reveal_all: bool = False) -> None:
"""Rebuild every sprite/zone from self.state — used after an undo/redo """Rebuild every sprite/zone from self.state — used after an undo/redo
jump, since animating backward through discarded history isn't sound.""" jump, since animating backward through discarded history isn't sound."""
now = pygame.time.get_ticks() now = pygame.time.get_ticks()
@@ -940,7 +1000,7 @@ class TableApp:
deck_pos = self.layout.deck_rect.topleft deck_pos = self.layout.deck_rect.topleft
for seat in range(2): for seat in range(2):
face_up = seat == self.human_seat face_up = seat == self.human_seat or reveal_all
public_remaining = Counter(self.public_hand_counts[seat]) public_remaining = Counter(self.public_hand_counts[seat])
for card in self.state.hands[seat]: for card in self.state.hands[seat]:
face = card_key(card, self.min_rank) face = card_key(card, self.min_rank)
@@ -982,8 +1042,40 @@ class TableApp:
self.ai_next_at = 0 self.ai_next_at = 0
self.ai_min_reveal_at = 0 self.ai_min_reveal_at = 0
self.shake_until = 0 self.shake_until = 0
self.end_backdrop = None
self.end_overlay_at = now if self.state.terminal else None # -- replay / export -------------------------------------------------------
def enter_review(self, index: int | None = None) -> None:
if not self.match_steps or not self.match_metadata.get("complete"):
return
target = len(self.match_steps) - 1 if index is None else index
target = max(0, min(target, len(self.match_steps) - 1))
step_row = self.match_steps[target]
self.state = GameState.from_snapshot(step_row["state"])
self.public_hand_counts = decode_public_hands(step_row["public_hands"])
self.review_index = target
self._sync_view_to_state(reveal_all=True)
def review_step(self, delta: int) -> None:
if self.review_index is None:
self.enter_review()
return
self.enter_review(self.review_index + delta)
def current_match_record(self) -> MatchRecord:
return MatchRecord(
dict(self.match_metadata),
list(self.match_steps[: self.timeline_cursor + 1]),
)
def export_match(self) -> Path:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
path = self.export_dir / f"lost-cities-match-{stamp}.jsonl"
written = self.current_match_record().write_jsonl(path)
self.toast_text = f"EXPORTED {written}"
self.toast_until = pygame.time.get_ticks() + 4000
LOGGER.info("대국 기록 내보내기: %s", written)
return written
# -- helpers --------------------------------------------------------------- # -- helpers ---------------------------------------------------------------
@@ -992,7 +1084,11 @@ class TableApp:
return 1 - self.human_seat return 1 - self.human_seat
def human_turn(self) -> bool: def human_turn(self) -> bool:
return not self.state.terminal and self.state.current_player == self.human_seat return (
self.review_index is None
and not self.state.terminal
and self.state.current_player == self.human_seat
)
def _legal(self, action_id: int) -> bool: def _legal(self, action_id: int) -> bool:
mask = self.state.unified_legal_mask() mask = self.state.unified_legal_mask()
@@ -1036,7 +1132,6 @@ class TableApp:
self.screen = pygame.display.set_mode((w, h), pygame.RESIZABLE) self.screen = pygame.display.set_mode((w, h), pygame.RESIZABLE)
self.layout = Layout(w, h, self.config.n_colors) self.layout = Layout(w, h, self.config.n_colors)
self.background = self._make_background(w, h) self.background = self._make_background(w, h)
self.end_backdrop = None
elif event.type == pygame.KEYDOWN: elif event.type == pygame.KEYDOWN:
mod = getattr(event, "mod", 0) mod = getattr(event, "mod", 0)
ctrl = bool(mod & pygame.KMOD_CTRL) ctrl = bool(mod & pygame.KMOD_CTRL)
@@ -1044,9 +1139,22 @@ class TableApp:
if event.key == pygame.K_n: if event.key == pygame.K_n:
self.new_game() self.new_game()
elif event.key == pygame.K_ESCAPE: elif event.key == pygame.K_ESCAPE:
self.selected = None if self.menu_open:
elif event.key in (pygame.K_RETURN, pygame.K_SPACE) and self.state.terminal: self.menu_open = False
self.new_game() else:
self.selected = None
elif event.key == pygame.K_m:
self.menu_open = not self.menu_open
elif event.key in (pygame.K_LEFT, pygame.K_PAGEUP):
self.review_step(-1)
elif event.key in (pygame.K_RIGHT, pygame.K_PAGEDOWN):
self.review_step(1)
elif event.key == pygame.K_HOME:
self.enter_review(0)
elif event.key == pygame.K_END:
self.enter_review(len(self.match_steps) - 1)
elif event.key == pygame.K_e and self.review_index is not None:
self.export_match()
elif event.key == pygame.K_F12: elif event.key == pygame.K_F12:
self.save_screenshot() self.save_screenshot()
elif ctrl and event.key == pygame.K_z and shift: elif ctrl and event.key == pygame.K_z and shift:
@@ -1062,13 +1170,9 @@ class TableApp:
def on_click(self, pos: tuple[int, int]) -> None: def on_click(self, pos: tuple[int, int]) -> None:
now = pygame.time.get_ticks() now = pygame.time.get_ticks()
if self.state.terminal: if self._handle_chrome_click(pos):
if ( return
self.end_overlay_at is not None if self.review_index is not None or self.state.terminal:
and now >= self.end_overlay_at
and (self.play_again_rect and self.play_again_rect.collidepoint(pos))
):
self.new_game()
return return
if now < self.input_locked_until or not self.human_turn(): if now < self.input_locked_until or not self.human_turn():
return return
@@ -1077,6 +1181,33 @@ class TableApp:
else: else:
self._click_draw_phase(pos) self._click_draw_phase(pos)
def _handle_chrome_click(self, pos: tuple[int, int]) -> bool:
if self.menu_button_rect.collidepoint(pos):
self.menu_open = not self.menu_open
return True
for action, rect in self.menu_items:
if not rect.collidepoint(pos):
continue
if action == "review":
self.enter_review()
self.menu_open = False
elif action == "export":
self.export_match()
self.menu_open = False
elif action == "new":
self.new_game()
elif action == "close":
self.running = False
elif action == "previous":
self.review_step(-1)
elif action == "next":
self.review_step(1)
return True
if self.menu_open:
self.menu_open = False
return True
return False
def _click_card_phase(self, pos: tuple[int, int]) -> None: def _click_card_phase(self, pos: tuple[int, int]) -> None:
hand = self.hand_zones[self.human_seat] hand = self.hand_zones[self.human_seat]
for sprite in reversed(hand): for sprite in reversed(hand):
@@ -1127,7 +1258,11 @@ class TableApp:
for sprite in self.sprites: for sprite in self.sprites:
sprite.update(dt, now) sprite.update(dt, now)
self._update_hover() self._update_hover()
if not self.state.terminal and self.state.current_player == self.ai_seat: if (
self.review_index is None
and not self.state.terminal
and self.state.current_player == self.ai_seat
):
self._update_ai(now) self._update_ai(now)
self._update_prompt(now) self._update_prompt(now)
if pygame.display.get_active(): if pygame.display.get_active():
@@ -1194,8 +1329,12 @@ class TableApp:
def _cursor_interactive(self) -> bool: def _cursor_interactive(self) -> bool:
pos = pygame.mouse.get_pos() pos = pygame.mouse.get_pos()
if self.menu_button_rect.collidepoint(pos) or any(
rect.collidepoint(pos) for _, rect in self.menu_items
):
return True
if self.state.terminal: if self.state.terminal:
return bool(self.play_again_rect and self.play_again_rect.collidepoint(pos)) return False
if not self.human_turn(): if not self.human_turn():
return False return False
if self.hovered is not None: if self.hovered is not None:
@@ -1216,7 +1355,7 @@ class TableApp:
return False return False
def _update_prompt(self, now: int) -> None: def _update_prompt(self, now: int) -> None:
if self.state.terminal: if self.review_index is not None or self.state.terminal:
text = "" text = ""
elif not self.human_turn(): elif not self.human_turn():
if self.opponent.load_error is not None: if self.opponent.load_error is not None:
@@ -1289,11 +1428,9 @@ class TableApp:
for sprite in self.sprites: for sprite in self.sprites:
if sprite.flying: if sprite.flying:
sprite.draw(screen, self.art, sprite.width or self.layout.board_w) sprite.draw(screen, self.art, sprite.width or self.layout.board_w)
if self.state.terminal and self.end_overlay_at is not None: self.menu_items = []
if now >= self.end_overlay_at: self._draw_review_controls()
self._draw_end_overlay(now) self._draw_menu(now)
else:
self.play_again_rect = None
def _pulse(self, now: int, speed: float = 2.4) -> float: def _pulse(self, now: int, speed: float = 2.4) -> float:
return 0.5 + 0.5 * math.sin(now / 1000.0 * speed * math.tau / 2) return 0.5 + 0.5 * math.sin(now / 1000.0 * speed * math.tau / 2)
@@ -1490,7 +1627,13 @@ class TableApp:
self._draw_plaque( self._draw_plaque(
pygame.Rect(lay.margin, 14, 236, 64), pygame.Rect(lay.margin, 14, 236, 64),
"THE RIVAL", "THE RIVAL",
self.opponent.label if self.opponent.ready else "loading model…", (
"FINAL PPO"
if self.opponent.ready and self.opponent.load_error is None
else self.opponent.label
if self.opponent.ready
else "LOADING"
),
self.total_score(self.ai_seat), self.total_score(self.ai_seat),
active=ai_active, active=ai_active,
thinking=ai_active and self.opponent.load_error is None, thinking=ai_active and self.opponent.load_error is None,
@@ -1499,33 +1642,75 @@ class TableApp:
self._draw_plaque( self._draw_plaque(
pygame.Rect(lay.margin, lay.h - 78, 236, 64), pygame.Rect(lay.margin, lay.h - 78, 236, 64),
"YOU", "YOU",
"expedition leader", "",
self.total_score(self.human_seat), self.total_score(self.human_seat),
active=human_active, active=human_active,
thinking=False, thinking=False,
now=now, now=now,
) )
self._draw_hints(lay) # The table itself carries the identity; keep chrome intentionally sparse.
title_font = self.fonts.serif(20)
title = render_tracked(title_font, "LOST CITIES", GOLD_DIM, 6)
self.screen.blit(title, title.get_rect(topright=(lay.w - lay.margin, 20)))
def _draw_hints(self, lay: Layout) -> None: def _draw_review_controls(self) -> None:
disabled = (72, 72, 74) if self.review_index is None:
parts = [ return
("N NEW GAME", True), lay = self.layout
("CTRL+Y REDO", self.can_redo()), y = lay.prompt_y - 8
("CTRL+Z UNDO", self.can_undo()), label = f"STEP {self.review_index} / {len(self.match_steps) - 1}"
] label_surface = render_tracked(self.fonts.sans(12, bold=True), label, INK, 2)
x = lay.w - lay.margin label_rect = label_surface.get_rect(center=(lay.w // 2, y))
y = lay.h - 16 self.screen.blit(label_surface, label_rect)
for text, enabled in parts:
surf = render_tracked( prev_rect = pygame.Rect(label_rect.left - 66, y - 18, 44, 36)
self.fonts.sans(11, bold=True), text, MUTED if enabled else disabled, 2 next_rect = pygame.Rect(label_rect.right + 22, y - 18, 44, 36)
self.menu_items.extend([("previous", prev_rect), ("next", next_rect)])
for text, rect, enabled in (
("", prev_rect, self.review_index > 0),
("", next_rect, self.review_index < len(self.match_steps) - 1),
):
color = GOLD if enabled else (72, 72, 74)
pygame.draw.rect(self.screen, color, rect, width=1, border_radius=9)
glyph = self.fonts.serif(25).render(text, True, color)
self.screen.blit(glyph, glyph.get_rect(center=rect.center))
def _draw_menu(self, now: int) -> None:
lay = self.layout
self.menu_button_rect = pygame.Rect(lay.w - lay.margin - 92, 16, 92, 38)
pygame.draw.rect(self.screen, (9, 17, 14), self.menu_button_rect, border_radius=10)
pygame.draw.rect(self.screen, GOLD_DIM, self.menu_button_rect, width=1, border_radius=10)
menu_text = render_tracked(self.fonts.sans(11, bold=True), "MENU", INK, 2)
self.screen.blit(menu_text, menu_text.get_rect(center=self.menu_button_rect.center))
if self.toast_text and now < self.toast_until:
toast = self.fonts.sans(11).render(self.toast_text, True, GOLD)
self.screen.blit(
toast,
toast.get_rect(
topright=(self.menu_button_rect.right, self.menu_button_rect.bottom + 8)
),
) )
rect = surf.get_rect(bottomright=(x, y)) if not self.menu_open:
self.screen.blit(surf, rect) return
x = rect.left - 22
labels: list[tuple[str, str]] = []
if self.match_metadata.get("complete"):
labels.append(("review", "REVIEW MATCH"))
labels.extend(
[
("export", "EXPORT JSONL"),
("new", "NEW GAME"),
("close", "QUIT"),
]
)
panel = pygame.Rect(self.menu_button_rect.right - 238, 62, 238, 18 + 48 * len(labels))
pygame.draw.rect(self.screen, (8, 16, 13), panel, border_radius=12)
pygame.draw.rect(self.screen, GOLD_DIM, panel, width=1, border_radius=12)
for index, (action, text) in enumerate(labels):
rect = pygame.Rect(panel.x + 10, panel.y + 9 + index * 48, panel.w - 20, 38)
self.menu_items.append((action, rect))
if rect.collidepoint(pygame.mouse.get_pos()):
pygame.draw.rect(self.screen, (*GOLD, 35), rect, border_radius=8)
label = render_tracked(self.fonts.sans(11, bold=True), text, INK, 2)
self.screen.blit(label, label.get_rect(midleft=(rect.x + 12, rect.centery)))
def _draw_plaque( def _draw_plaque(
self, self,
@@ -1552,7 +1737,8 @@ class TableApp:
dots = "·" * (1 + (now // 350) % 3) dots = "·" * (1 + (now // 350) % 3)
sub_text = f"{sub} {dots}" sub_text = f"{sub} {dots}"
sub_surf = self.fonts.sans(11).render(sub_text, True, MUTED) sub_surf = self.fonts.sans(11).render(sub_text, True, MUTED)
self.screen.blit(sub_surf, (rect.x + 16, rect.y + 34)) if sub_text:
self.screen.blit(sub_surf, (rect.x + 16, rect.y + 34))
score_surf = self.fonts.serif(26).render(str(score), True, GOLD if active else INK) score_surf = self.fonts.serif(26).render(str(score), True, GOLD if active else INK)
self.screen.blit(score_surf, score_surf.get_rect(midright=(rect.right - 16, rect.centery))) self.screen.blit(score_surf, score_surf.get_rect(midright=(rect.right - 16, rect.centery)))
if active: if active:
@@ -1571,91 +1757,6 @@ class TableApp:
surf.set_alpha(alpha) surf.set_alpha(alpha)
self.screen.blit(surf, surf.get_rect(center=(self.layout.w // 2, self.layout.prompt_y))) self.screen.blit(surf, surf.get_rect(center=(self.layout.w // 2, self.layout.prompt_y)))
# -- end overlay -------------------------------------------------------------
def _draw_end_overlay(self, now: int) -> None:
lay = self.layout
if self.end_backdrop is None or self.end_backdrop.get_size() != self.screen.get_size():
snapshot = self.screen.copy()
blurred = _blur(snapshot, 6)
scrim = pygame.Surface(blurred.get_size(), pygame.SRCALPHA)
scrim.fill((4, 9, 7, 175))
blurred.blit(scrim, (0, 0))
self.end_backdrop = blurred
reveal = ease_out_cubic((now - self.end_overlay_at) / 420)
self.screen.blit(self.end_backdrop, (0, 0))
me = self.total_score(self.human_seat)
rival = self.total_score(self.ai_seat)
if me > rival:
verdict, vcolor = "YOU PREVAIL", GOLD
elif me < rival:
verdict, vcolor = "THE RIVAL PREVAILS", (206, 130, 116)
else:
verdict, vcolor = "DEAD HEAT", INK
panel_w, panel_h = 620, 420
panel = pygame.Rect(0, 0, panel_w, panel_h)
panel.center = (lay.w // 2, int(lay.h * (0.52 - 0.03 * (1 - reveal))))
surface = pygame.Surface(panel.size, pygame.SRCALPHA)
pygame.draw.rect(surface, (10, 19, 16, 235), surface.get_rect(), border_radius=18)
pygame.draw.rect(surface, (*GOLD_DIM, 220), surface.get_rect(), width=1, border_radius=18)
header = render_tracked(self.fonts.sans(13, bold=True), "EXPEDITION COMPLETE", MUTED, 4)
surface.blit(header, header.get_rect(midtop=(panel_w // 2, 34)))
title = render_tracked(self.fonts.serif(34), verdict, vcolor, 2)
surface.blit(title, title.get_rect(midtop=(panel_w // 2, 62)))
score_font = self.fonts.serif(26)
dash = score_font.render("", True, FAINT)
surface.blit(dash, dash.get_rect(center=(panel_w // 2, 132)))
mine_score = score_font.render(str(me), True, INK)
rival_score = score_font.render(str(rival), True, INK)
surface.blit(mine_score, mine_score.get_rect(center=(panel_w // 2 - 90, 132)))
surface.blit(rival_score, rival_score.get_rect(center=(panel_w // 2 + 90, 132)))
for text, dx in (("YOU", -90), ("RIVAL", 90)):
label = render_tracked(self.fonts.sans(10, bold=True), text, FAINT, 2)
surface.blit(label, label.get_rect(center=(panel_w // 2 + dx, 158)))
# per-suit breakdown
table_y = 196
col_step = 104
x0 = panel_w // 2 - col_step * 2
for color in range(self.config.n_colors):
cx = x0 + color * col_step
glyph = self.art.glyph(color, 30)
surface.blit(glyph, glyph.get_rect(center=(cx, table_y)))
mine = self.expedition_score(self.human_seat, color)
theirs = self.expedition_score(self.ai_seat, color)
mine_s = self.fonts.sans(15, bold=True).render(
f"{mine:+d}" if self.state.expeditions[self.human_seat][color] else "",
True,
INK if mine >= 0 else (222, 148, 134),
)
their_s = self.fonts.sans(13).render(
f"{theirs:+d}" if self.state.expeditions[self.ai_seat][color] else "", True, MUTED
)
surface.blit(mine_s, mine_s.get_rect(center=(cx, table_y + 36)))
surface.blit(their_s, their_s.get_rect(center=(cx, table_y + 60)))
row_label = self.fonts.sans(11).render("you", True, FAINT)
surface.blit(row_label, row_label.get_rect(midright=(x0 - 64, table_y + 36)))
row_label2 = self.fonts.sans(11).render("rival", True, FAINT)
surface.blit(row_label2, row_label2.get_rect(midright=(x0 - 64, table_y + 60)))
button = pygame.Rect(0, 0, 220, 46)
button.midtop = (panel_w // 2, 306)
mouse = pygame.mouse.get_pos()
hovered = button.move(panel.topleft).collidepoint(mouse)
pygame.draw.rect(surface, (*GOLD, 40 if hovered else 0), button, border_radius=12)
pygame.draw.rect(surface, GOLD, button, width=1, border_radius=12)
btn_text = render_tracked(self.fonts.sans(14, bold=True), "PLAY AGAIN", GOLD, 3)
surface.blit(btn_text, btn_text.get_rect(center=button.center))
hint = self.fonts.sans(11).render("enter / space / n", True, FAINT)
surface.blit(hint, hint.get_rect(midtop=(panel_w // 2, 362)))
surface.set_alpha(int(255 * reveal))
self.screen.blit(surface, panel)
self.play_again_rect = button.move(panel.topleft)
# -- misc --------------------------------------------------------------------- # -- misc ---------------------------------------------------------------------
def save_screenshot(self) -> Path: def save_screenshot(self) -> Path:
@@ -1687,6 +1788,11 @@ def build_argparser() -> argparse.ArgumentParser:
parser.add_argument("--seed", type=int, default=None) parser.add_argument("--seed", type=int, default=None)
parser.add_argument("--width", type=int, default=1600) parser.add_argument("--width", type=int, default=1600)
parser.add_argument("--height", type=int, default=1000) parser.add_argument("--height", type=int, default=1000)
parser.add_argument(
"--export-dir",
default="exports",
help="Directory for versioned match JSONL exports",
)
parser.add_argument( parser.add_argument(
"--offline", "--offline",
action="store_true", action="store_true",
@@ -1709,6 +1815,7 @@ def main(argv: list[str] | None = None) -> None:
width=args.width, width=args.width,
height=args.height, height=args.height,
offline=args.offline, offline=args.offline,
export_dir=args.export_dir,
) )
app.run() app.run()
+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.width == 1600
assert args.height == 1000 assert args.height == 1000
assert args.offline is False assert args.offline is False
assert args.export_dir == "exports"
def test_argparser_accepts_overrides() -> None: def test_argparser_accepts_overrides() -> None:
@@ -56,6 +57,8 @@ def test_layout_scales_between_window_sizes() -> None:
assert len(slots) == 8 assert len(slots) == 8
assert slots[0][0] >= 0 assert slots[0][0] >= 0
assert slots[-1][0] + layout.hand_w <= layout.w 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 assert large.hand_w >= small.hand_w
@@ -128,3 +131,82 @@ def test_offline_mode_explicitly_uses_heuristic() -> None:
assert "offline" in opponent.label assert "offline" in opponent.label
finally: finally:
opponent.shutdown() 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()