맥락: - 새 레포의 첫 범위를 RL 없는 Lost Cities classic 게임 구현으로 잡았다. - 기존 tier0-3 실험 축은 제거하고 classic 5-expedition 룰을 기본값으로 둔다. 변경: - games/classic 아래에 Cython 게임 엔진, env, bots, backend 경계, Rust core와 proto schema를 이식했다. - setuptools/Cython 빌드 설정과 package data, README, classic port notes를 추가했다. - 룰, 점수, 마스크, env, canonical state, bot, Rust parity 테스트를 새 경로로 가져왔다. 확인: - uv run pytest tests/games/classic - uv run lost-cities-classic
168 lines
5.4 KiB
Python
168 lines
5.4 KiB
Python
import json
|
|
from pathlib import Path
|
|
import random
|
|
import subprocess
|
|
|
|
import coolrl_lost_cities.games.classic as classic
|
|
from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig, build_deck
|
|
|
|
|
|
LOST_CITIES_DIR = Path(classic.__file__).resolve().parent
|
|
FIXTURE_DIR = LOST_CITIES_DIR / "fixtures"
|
|
RUST_CORE_DIR = LOST_CITIES_DIR / "rust_core"
|
|
|
|
|
|
def _run_probe(*args: str) -> dict:
|
|
result = subprocess.run(
|
|
["cargo", "run", "--quiet", "--bin", "lost_cities_probe", "--", *args],
|
|
cwd=RUST_CORE_DIR,
|
|
check=True,
|
|
text=True,
|
|
capture_output=True,
|
|
)
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def _python_trace(path: Path) -> dict:
|
|
fixture = json.loads(path.read_text())
|
|
config = LostCitiesConfig(**fixture["config"])
|
|
state = GameState.new_game_from_deck(fixture["initial_deck"], config)
|
|
steps = []
|
|
for step in fixture["steps"]:
|
|
action = step["action"]
|
|
if action is not None:
|
|
state.apply_unified_action(action)
|
|
state.validate_invariants()
|
|
snapshot = state.to_snapshot()
|
|
steps.append(
|
|
{
|
|
"action": action,
|
|
"phase": state.phase,
|
|
"current_player": state.current_player,
|
|
"turn_count": state.turn_count,
|
|
"terminal": state.terminal,
|
|
"pending_discarded_color": state.pending_discarded_color,
|
|
"score_diff_player0": state.score_diff(0),
|
|
"legal_mask": state.unified_legal_mask(),
|
|
"deck": snapshot["deck"],
|
|
"hands": snapshot["hands"],
|
|
"expeditions": snapshot["expeditions"],
|
|
"discards": snapshot["discards"],
|
|
}
|
|
)
|
|
return {"config": config.to_snapshot(), "steps": steps}
|
|
|
|
|
|
def test_rust_default_config_matches_python_default() -> None:
|
|
assert _run_probe("defaults") == LostCitiesConfig().to_snapshot()
|
|
|
|
|
|
def test_rust_fixture_trace_matches_python_core() -> None:
|
|
fixture_path = FIXTURE_DIR / "canonical_small.json"
|
|
assert _run_probe("trace", str(fixture_path)) == _python_trace(fixture_path)
|
|
|
|
|
|
def test_rust_randomized_fixture_traces_match_python_core(tmp_path: Path) -> None:
|
|
config = LostCitiesConfig(
|
|
n_colors=3,
|
|
n_ranks=5,
|
|
min_rank=2,
|
|
n_handshakes=1,
|
|
hand_size=5,
|
|
)
|
|
|
|
for seed in range(12):
|
|
deck = build_deck(config)
|
|
rng = random.Random(seed)
|
|
rng.shuffle(deck)
|
|
state = GameState.new_game_from_deck(deck, config)
|
|
steps = [{"action": None}]
|
|
|
|
for _ in range(64):
|
|
if state.terminal:
|
|
break
|
|
legal = [
|
|
index
|
|
for index, is_legal in enumerate(state.unified_legal_mask())
|
|
if is_legal
|
|
]
|
|
action = rng.choice(legal)
|
|
state.apply_unified_action(action)
|
|
steps.append({"action": action})
|
|
|
|
fixture_path = tmp_path / f"parity_{seed}.json"
|
|
fixture_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"config": config.to_snapshot(),
|
|
"initial_deck": [card.to_snapshot() for card in deck],
|
|
"steps": steps,
|
|
}
|
|
)
|
|
)
|
|
assert _run_probe("trace", str(fixture_path)) == _python_trace(fixture_path)
|
|
|
|
|
|
def test_rust_engine_contract_is_checked_from_python() -> None:
|
|
result = _run_probe("engine")
|
|
|
|
assert result["duplicate_kind"] == "AlreadyExists"
|
|
assert result["missing_config_kind"] == "InvalidArgument"
|
|
assert result["empty_session_kind"] == "InvalidArgument"
|
|
assert result["unknown_session_kind"] == "NotFound"
|
|
assert result["invalid_observer_kind"] == "InvalidArgument"
|
|
assert result["invalid_observer_state_unchanged"] is True
|
|
assert result["invalid_observer_action_still_applies"] is True
|
|
assert result["phase_flow"][:2] == [
|
|
{
|
|
"state_version": 0,
|
|
"current_player": 0,
|
|
"observer_player": 0,
|
|
"phase": "card",
|
|
"terminal": False,
|
|
},
|
|
{
|
|
"state_version": 1,
|
|
"current_player": 0,
|
|
"observer_player": 0,
|
|
"phase": "draw",
|
|
"terminal": False,
|
|
},
|
|
]
|
|
assert result["phase_flow"][2]["state_version"] == 2
|
|
assert result["stale_kind"] == "FailedPrecondition"
|
|
assert result["end_session_counts"] == [1, 0]
|
|
assert result["off_turn_legal_empty"] is True
|
|
assert result["full_session_terminal_reward_matches"] is True
|
|
assert result["full_session_final_scores_match"] is True
|
|
assert result["terminal_reject_kind"] == "FailedPrecondition"
|
|
assert result["deterministic_match"] is True
|
|
|
|
|
|
def test_rust_grpc_contract_is_checked_from_python() -> None:
|
|
result = _run_probe("grpc")
|
|
|
|
assert result == {
|
|
"round_trip_phase": "card",
|
|
"opponent_legal_empty": True,
|
|
"stale_code": "FailedPrecondition",
|
|
"invalid_observer_code": "InvalidArgument",
|
|
"invalid_observer_state_unchanged": True,
|
|
"ended_session_code": "NotFound",
|
|
}
|
|
|
|
|
|
def test_rust_core_has_no_native_tests_left() -> None:
|
|
rust_files = [
|
|
path
|
|
for path in (RUST_CORE_DIR / "src").rglob("*.rs")
|
|
if "target" not in path.parts
|
|
]
|
|
for path in rust_files:
|
|
text = path.read_text()
|
|
assert "#[test]" not in text
|
|
assert "#[tokio::test" not in text
|
|
|
|
tests_dir = RUST_CORE_DIR / "tests"
|
|
assert not list(tests_dir.glob("*.rs"))
|