Rust 지원 제거

맥락: 클래식 포트를 Python/Cython 구현과 pygame GUI 중심으로 정리한다.

변경: Rust 크레이트, proto 계약, Rust backend shim, 패리티 테스트를 삭제하고 문서와 backend factory를 Python 전용으로 갱신했다.

확인: uv run pre-commit run --all-files; uv run pytest tests/games/classic; uv run lost-cities-classic-gui --help
This commit is contained in:
2026-05-06 20:11:43 +09:00
parent a6de79d444
commit b578b38628
20 changed files with 16 additions and 3776 deletions
+2 -11
View File
@@ -6,10 +6,9 @@ The current implementation starts with the classic two-player card game:
- classic 5-expedition rules by default - classic 5-expedition rules by default
- Python/Cython game engine - Python/Cython game engine
- Rust core parity checks
- env wrapper - env wrapper
- random, passive-discard, and safe-heuristic bots - random, passive-discard, and safe-heuristic bots
- core rule, scoring, mask, env, canonical-state, bot, and Rust parity tests - core rule, scoring, mask, env, canonical-state, bot, and GUI smoke tests
Training code, Deep CFR, learned-policy evaluation, GUI, and web client are Training code, Deep CFR, learned-policy evaluation, GUI, and web client are
intentionally outside the first port. intentionally outside the first port.
@@ -33,8 +32,7 @@ Run the classic pygame GUI:
uv run lost-cities-classic-gui --mode pvc --bot safe-heuristic uv run lost-cities-classic-gui --mode pvc --bot safe-heuristic
``` ```
The GUI currently uses the Python backend only. The Rust core remains covered by The GUI uses the in-process Python backend.
parity tests and is not exposed as a GUI backend.
## Basic Usage ## Basic Usage
@@ -59,11 +57,4 @@ backend = build_backend("python", classic_config(), seed=1)
snapshot = backend.snapshot() snapshot = backend.snapshot()
``` ```
Rust sources live outside the Python package:
```text
rust/lost-cities-core/
proto/lost_cities.proto
```
See [classic port notes](docs/classic-port-notes.md) for the current direction. See [classic port notes](docs/classic-port-notes.md) for the current direction.
+4 -8
View File
@@ -10,9 +10,8 @@ game, without the earlier training-oriented tiers.
- Treat classic as the initial concrete game under `coolrl_lost_cities.games`. - Treat classic as the initial concrete game under `coolrl_lost_cities.games`.
- Do not carry over `tier0` through `tier3`; those were useful for experiments, - Do not carry over `tier0` through `tier3`; those were useful for experiments,
but they should not shape the first public game API. but they should not shape the first public game API.
- Keep backend selection available. The Python/Cython and Rust implementations - Use the in-process Python/Cython implementation for the current port.
should remain swappable behind a small backend boundary. - Keep the GUI in scope for local play, but do not carry a separate native backend.
- Keep the GUI and Rust implementation in scope for the port.
- Keep RL and training code out of the first extraction. - Keep RL and training code out of the first extraction.
The expected package shape is roughly: The expected package shape is roughly:
@@ -30,10 +29,6 @@ src/coolrl_lost_cities/
fixtures/ fixtures/
assets/ assets/
docs/ docs/
rust/
lost-cities-core/
proto/
lost_cities.proto
``` ```
Tests should live outside the package, roughly under: Tests should live outside the package, roughly under:
@@ -47,6 +42,7 @@ tests/games/classic/
- Deep CFR - Deep CFR
- General training infrastructure - General training infrastructure
- Evaluation loops for learned policies - Evaluation loops for learned policies
- Separate native backend support
- Web client - Web client
- Legacy experiment configs, checkpoints, logs, exports, and analysis artifacts - Legacy experiment configs, checkpoints, logs, exports, and analysis artifacts
@@ -75,7 +71,7 @@ src/coolrl_lost_cities/
``` ```
The game package should expose rules, state transitions, legal actions, scoring, The game package should expose rules, state transitions, legal actions, scoring,
backend selection, and playable UI. Training code can adapt those pieces later. and playable UI. Training code can adapt those pieces later.
## Naming Notes ## Naming Notes
-225
View File
@@ -1,225 +0,0 @@
syntax = "proto3";
package lost_cities.v1;
import "google/protobuf/empty.proto";
// See lost_cities_spec.md for the full game rules.
// This proto is the wire contract for external agents to play Lost Cities
// against a trusted orchestrator. RL training uses in-process PyO3 bindings
// instead and does not go through this service.
// =============================================================
// Core value types
// =============================================================
// A single card. Rank 0 is a handshake (investment) card.
// Rank 1..n_ranks are numeric cards whose printed face value is
// min_rank + rank - 1.
message Card {
uint32 color = 1;
uint32 rank = 2;
uint32 numeric_value = 3; // 0 for handshake, else min_rank + rank - 1
string label = 4; // debug-friendly e.g. "[2]H", "[0]5"
// non-authoritative, do not parse
}
enum Phase {
PHASE_UNSPECIFIED = 0;
PHASE_CARD = 1; // the current player must play or discard a card
PHASE_DRAW = 2; // the current player must draw from deck or a discard pile
}
enum ActionKind {
ACTION_KIND_UNSPECIFIED = 0;
ACTION_KIND_PLAY_CARD = 1;
ACTION_KIND_DISCARD_CARD = 2;
ACTION_KIND_DRAW_DECK = 3;
ACTION_KIND_DRAW_DISCARD = 4;
}
// A resolved legal action.
//
// `id` is an opaque integer handle valid only within the observation it
// was returned with. Clients pass it back via ApplyAction together with
// the observation's state_version.
//
// `kind` plus typed payload fields let semantic agents (LLM, rule-based)
// reason without decoding ids. `description` is a human-readable label
// such as "Play yellow 5" and must not be parsed by the agent.
message Action {
uint32 id = 1;
ActionKind kind = 2;
// Set for PLAY_CARD and DISCARD_CARD.
uint32 hand_slot = 3;
Card card = 4;
// Set for DRAW_DISCARD.
uint32 discard_color = 5;
string description = 6;
}
// =============================================================
// Configuration
// =============================================================
message GameConfig {
uint32 n_colors = 1;
uint32 n_ranks = 2;
uint32 min_rank = 3;
uint32 n_handshakes = 4;
uint32 hand_size = 5;
int32 expedition_penalty = 6; // typically negative, e.g. -20
uint32 bonus_threshold = 7;
int32 bonus_amount = 8;
optional uint64 seed = 9; // absent = nondeterministic shuffle
}
// =============================================================
// Piles and expeditions
// =============================================================
message Expedition {
uint32 color = 1;
repeated Card cards = 2; // bottom to top, push-only during game
int32 current_score = 3; // derived from the game rules
}
message DiscardPile {
uint32 color = 1;
repeated Card cards = 2; // bottom to top; last element is drawable top
uint32 size = 3; // = cards.size(); convenience
}
// =============================================================
// Legal actions
// =============================================================
// The set of legal actions at a specific state.
//
// Invariants:
// - actions are sorted by id ascending
// - for every action a in actions: mask[a.id] == true
// - mask.size() == action_space_size
// - all ids are < action_space_size
// - state_version matches the observation this set was produced for
message LegalActionSet {
uint64 state_version = 1;
repeated Action actions = 2;
repeated bool mask = 3;
uint32 action_space_size = 4;
Phase phase = 5;
}
// =============================================================
// Observation
// =============================================================
// Player-perspective snapshot. `observer_player` identifies whose view
// this is. The observer sees their own hand; the opponent's hand is
// represented only by its size.
message GameObservation {
string session_id = 1;
GameConfig config = 2;
// Monotonically increasing per session. Required for ApplyAction.
uint64 state_version = 3;
uint32 observer_player = 4;
uint32 current_player = 5;
Phase phase = 6;
repeated Card hand = 7; // observer's hand, sorted
uint32 opponent_hand_size = 8;
repeated Expedition my_expeditions = 9;
repeated Expedition opponent_expeditions = 10;
repeated DiscardPile discards = 11;
uint32 deck_size = 12;
// Color just discarded during the current turn's card phase.
// The current player's draw phase may not re-draw that color
// from the discard pile. Cleared at turn boundary.
optional uint32 pending_discarded_color = 13;
// Legal actions for the player to act. Empty when terminal.
// Embedded to avoid a round-trip on the common observe->select->apply loop.
LegalActionSet legal_actions = 14;
uint32 turn_count = 15;
bool terminal = 16;
// Scores are from the observer's perspective.
int32 my_score = 17;
int32 opponent_score = 18;
int32 score_diff = 19; // my_score - opponent_score
}
// =============================================================
// Requests and responses
// =============================================================
message NewGameRequest {
string session_id = 1;
GameConfig config = 2;
}
message SessionRef {
string session_id = 1;
// Defaults to current_player when absent.
optional uint32 observer_player = 2;
}
message ApplyActionRequest {
string session_id = 1;
uint32 action_id = 2;
// Must match the observation the action was selected from.
// Mismatch yields FAILED_PRECONDITION; no state change occurs.
// This makes ApplyAction safely retryable.
uint64 expected_state_version = 3;
// Perspective for the returned observation. Defaults to current_player
// after the action is applied.
optional uint32 observer_player = 4;
}
message StepResult {
GameObservation observation = 1;
// From the observation's observer_player perspective.
// Sparse: nonzero only on the terminal transition, equal to score_diff.
double reward = 2;
bool terminal = 3;
// Populated on terminal transitions. Keyed by player index.
map<uint32, int32> final_scores = 4;
}
// =============================================================
// Service
// =============================================================
service LostCities {
// Start a new game. session_id must be unique; duplicates return
// ALREADY_EXISTS. The returned observation is from player 0's perspective.
rpc NewGame(NewGameRequest) returns (GameObservation);
// Re-read the current state without advancing. Useful after reconnects
// or for a second client joining an existing session.
rpc GetObservation(SessionRef) returns (GameObservation);
// Apply one action. Fails with:
// NOT_FOUND session_id unknown
// FAILED_PRECONDITION expected_state_version mismatch,
// action_id not legal in current state,
// or game already terminal
// INVALID_ARGUMENT malformed request
rpc ApplyAction(ApplyActionRequest) returns (StepResult);
// Release server-side session state. Idempotent.
rpc EndSession(SessionRef) returns (google.protobuf.Empty);
}
-1367
View File
File diff suppressed because it is too large Load Diff
-22
View File
@@ -1,22 +0,0 @@
[package]
name = "lost-cities-core"
version = "0.1.0"
edition = "2021"
[lib]
name = "lost_cities_core"
path = "src/lib.rs"
[dependencies]
prost = "0.13"
prost-types = "0.13"
rand = { version = "0.8", features = ["std", "std_rng"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["net", "rt-multi-thread"] }
tokio-stream = { version = "0.1", features = ["net"] }
tonic = { version = "0.12", features = ["transport"] }
[build-dependencies]
protoc-bin-vendored = "3"
tonic-build = "0.12"
-15
View File
@@ -1,15 +0,0 @@
use std::path::PathBuf;
fn main() {
let protoc = protoc_bin_vendored::protoc_bin_path().expect("vendored protoc");
std::env::set_var("PROTOC", protoc);
let proto_dir = PathBuf::from("../../proto");
let proto_file = proto_dir.join("lost_cities.proto");
println!("cargo:rerun-if-changed={}", proto_file.display());
tonic_build::configure()
.compile_protos(&[proto_file], &[proto_dir])
.expect("compile lost_cities proto");
}
@@ -1,779 +0,0 @@
use std::env;
use std::error::Error;
use std::fs;
use std::io;
use lost_cities_core::proto::{
self, lost_cities_client::LostCitiesClient, lost_cities_server::LostCitiesServer,
};
use lost_cities_core::{
Card, Config, EngineErrorKind, GameState, LostCitiesEngine, LostCitiesGrpcService, Phase,
};
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use tokio_stream::wrappers::TcpListenerStream;
use tonic::transport::{Channel, Endpoint, Server};
type ProbeResult<T> = Result<T, Box<dyn Error>>;
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
struct CardJson {
color: u32,
rank: u32,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
struct ConfigJson {
n_colors: usize,
n_ranks: u32,
min_rank: u32,
n_handshakes: u32,
hand_size: usize,
expedition_penalty: i32,
bonus_threshold: usize,
bonus_amount: i32,
seed: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct FixtureInput {
config: ConfigJson,
initial_deck: Vec<CardJson>,
steps: Vec<FixtureStepInput>,
}
#[derive(Debug, Deserialize)]
struct FixtureStepInput {
action: Option<u32>,
}
#[derive(Debug, Serialize)]
struct TraceOutput {
config: ConfigJson,
steps: Vec<StateTraceStep>,
}
#[derive(Debug, Serialize)]
struct StateTraceStep {
action: Option<u32>,
phase: &'static str,
current_player: usize,
turn_count: u32,
terminal: bool,
pending_discarded_color: Option<u32>,
score_diff_player0: i32,
legal_mask: Vec<bool>,
deck: Vec<CardJson>,
hands: Vec<Vec<CardJson>>,
expeditions: Vec<Vec<Vec<CardJson>>>,
discards: Vec<Vec<CardJson>>,
}
#[derive(Debug, Serialize)]
struct ObservationMini {
state_version: u64,
current_player: u32,
observer_player: u32,
phase: String,
terminal: bool,
}
#[derive(Debug, Serialize, PartialEq)]
struct ObservationSummary {
state_version: u64,
current_player: u32,
observer_player: u32,
phase: String,
hand: Vec<CardJson>,
opponent_hand_size: u32,
deck_size: u32,
discards: Vec<Vec<CardJson>>,
my_expeditions: Vec<Vec<CardJson>>,
opponent_expeditions: Vec<Vec<CardJson>>,
legal_mask: Vec<bool>,
terminal: bool,
my_score: i32,
opponent_score: i32,
score_diff: i32,
}
#[derive(Debug, Serialize)]
struct EngineProbeOutput {
duplicate_kind: String,
missing_config_kind: String,
empty_session_kind: String,
unknown_session_kind: String,
invalid_observer_kind: String,
invalid_observer_state_unchanged: bool,
invalid_observer_action_still_applies: bool,
phase_flow: Vec<ObservationMini>,
stale_kind: String,
end_session_counts: Vec<usize>,
off_turn_legal_empty: bool,
full_session_terminal_reward_matches: bool,
full_session_final_scores_match: bool,
terminal_reject_kind: String,
deterministic_match: bool,
}
#[derive(Debug, Serialize)]
struct GrpcProbeOutput {
round_trip_phase: String,
opponent_legal_empty: bool,
stale_code: String,
invalid_observer_code: String,
invalid_observer_state_unchanged: bool,
ended_session_code: String,
}
fn main() -> ProbeResult<()> {
let mut args = env::args().skip(1);
let command = args
.next()
.ok_or_else(|| io::Error::other("expected command"))?;
match command.as_str() {
"defaults" => print_json(&ConfigJson::from_config(&Config::default()))?,
"trace" => {
let path = args
.next()
.ok_or_else(|| io::Error::other("expected fixture path"))?;
print_json(&run_trace(&path)?)?;
}
"engine" => print_json(&run_engine_probe()?)?,
"grpc" => {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
print_json(&runtime.block_on(run_grpc_probe())?)?;
}
_ => return Err(io::Error::other(format!("unknown command: {command}")).into()),
}
Ok(())
}
fn print_json<T: Serialize>(value: &T) -> ProbeResult<()> {
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
fn run_trace(path: &str) -> ProbeResult<TraceOutput> {
let fixture: FixtureInput = serde_json::from_str(&fs::read_to_string(path)?)?;
let config = fixture.config.to_config();
let deck = fixture
.initial_deck
.iter()
.copied()
.map(Card::from)
.collect::<Vec<_>>();
let mut state = GameState::new_game_from_deck(config, deck)?;
let mut steps = Vec::with_capacity(fixture.steps.len());
for step in fixture.steps {
if let Some(action) = step.action {
state.apply_unified_action(action)?;
}
state.validate_invariants().map_err(io::Error::other)?;
steps.push(StateTraceStep::from_state(step.action, &state));
}
Ok(TraceOutput {
config: ConfigJson::from_config(&state.config),
steps,
})
}
fn run_engine_probe() -> ProbeResult<EngineProbeOutput> {
let duplicate_kind = {
let mut engine = LostCitiesEngine::new();
engine.new_game(new_game_request("dup", 7, true))?;
kind_name(
engine
.new_game(new_game_request("dup", 7, true))
.expect_err("duplicate session must fail")
.kind(),
)
};
let missing_config_kind = {
let mut engine = LostCitiesEngine::new();
kind_name(
engine
.new_game(new_game_request("missing-config", 7, false))
.expect_err("missing config must fail")
.kind(),
)
};
let empty_session_kind = {
let mut engine = LostCitiesEngine::new();
kind_name(
engine
.new_game(new_game_request(" ", 7, true))
.expect_err("empty session id must fail")
.kind(),
)
};
let unknown_session_kind = {
let engine = LostCitiesEngine::new();
kind_name(
engine
.get_observation(proto::SessionRef {
session_id: "unknown".to_string(),
observer_player: None,
})
.expect_err("unknown session must fail")
.kind(),
)
};
let (
invalid_observer_kind,
invalid_observer_state_unchanged,
invalid_observer_action_still_applies,
) = {
let mut engine = LostCitiesEngine::new();
let observation = engine.new_game(new_game_request("bad-observer", 7, true))?;
let action_id = first_action(&observation)?;
let err = engine
.apply_action(proto::ApplyActionRequest {
session_id: "bad-observer".to_string(),
action_id,
expected_state_version: observation.state_version,
observer_player: Some(2),
})
.expect_err("invalid observer must fail");
let after_error = engine.get_observation(proto::SessionRef {
session_id: "bad-observer".to_string(),
observer_player: Some(0),
})?;
let state_unchanged = after_error.state_version == observation.state_version
&& after_error.phase == observation.phase
&& after_error.current_player == observation.current_player;
let still_applies = engine
.apply_action(proto::ApplyActionRequest {
session_id: "bad-observer".to_string(),
action_id,
expected_state_version: observation.state_version,
observer_player: Some(0),
})
.is_ok();
(kind_name(err.kind()), state_unchanged, still_applies)
};
let phase_flow = {
let mut engine = LostCitiesEngine::new();
let observation = engine.new_game(new_game_request("phase-flow", 11, true))?;
let first = engine
.apply_action(proto::ApplyActionRequest {
session_id: "phase-flow".to_string(),
action_id: first_action(&observation)?,
expected_state_version: observation.state_version,
observer_player: None,
})?
.observation
.ok_or_else(|| io::Error::other("missing first observation"))?;
let second = engine
.apply_action(proto::ApplyActionRequest {
session_id: "phase-flow".to_string(),
action_id: first_action(&first)?,
expected_state_version: first.state_version,
observer_player: None,
})?
.observation
.ok_or_else(|| io::Error::other("missing second observation"))?;
vec![
ObservationMini::from_observation(&observation),
ObservationMini::from_observation(&first),
ObservationMini::from_observation(&second),
]
};
let stale_kind = {
let mut engine = LostCitiesEngine::new();
let observation = engine.new_game(new_game_request("stale", 3, true))?;
let action_id = first_action(&observation)?;
engine.apply_action(proto::ApplyActionRequest {
session_id: "stale".to_string(),
action_id,
expected_state_version: observation.state_version,
observer_player: None,
})?;
kind_name(
engine
.apply_action(proto::ApplyActionRequest {
session_id: "stale".to_string(),
action_id,
expected_state_version: observation.state_version,
observer_player: None,
})
.expect_err("stale version must fail")
.kind(),
)
};
let end_session_counts = {
let mut engine = LostCitiesEngine::new();
engine.new_game(new_game_request("cleanup", 5, true))?;
let before = engine.session_count();
engine.end_session(session_ref("cleanup", None))?;
engine.end_session(session_ref("cleanup", None))?;
vec![before, engine.session_count()]
};
let off_turn_legal_empty = {
let mut engine = LostCitiesEngine::new();
let config = small_config(9);
engine.new_game(proto::NewGameRequest {
session_id: "hidden".to_string(),
config: Some(config),
})?;
let hidden = engine.get_observation(session_ref("hidden", Some(1)))?;
hidden
.legal_actions
.as_ref()
.map(|legal| legal.actions.is_empty() && legal.mask.iter().all(|value| !value))
.unwrap_or(false)
};
let (
full_session_terminal_reward_matches,
full_session_final_scores_match,
terminal_reject_kind,
) = run_full_session_probe()?;
let deterministic_match = run_deterministic_probe()?;
Ok(EngineProbeOutput {
duplicate_kind,
missing_config_kind,
empty_session_kind,
unknown_session_kind,
invalid_observer_kind,
invalid_observer_state_unchanged,
invalid_observer_action_still_applies,
phase_flow,
stale_kind,
end_session_counts,
off_turn_legal_empty,
full_session_terminal_reward_matches,
full_session_final_scores_match,
terminal_reject_kind,
deterministic_match,
})
}
async fn run_grpc_probe() -> ProbeResult<GrpcProbeOutput> {
let (mut client, server) = spawn_client().await?;
let observation = client
.new_game(new_game_request("grpc-round-trip", 13, true))
.await?
.into_inner();
let opponent_view = client
.get_observation(session_ref("grpc-round-trip", Some(1)))
.await?
.into_inner();
let round_trip_phase = phase_name_proto(observation.phase).to_string();
let opponent_legal_empty = opponent_view
.legal_actions
.map(|legal| legal.actions.is_empty())
.unwrap_or(false);
server.abort();
let (mut client, server) = spawn_client().await?;
let observation = client
.new_game(new_game_request("grpc-stale", 21, true))
.await?
.into_inner();
let action_id = first_action(&observation)?;
client
.apply_action(proto::ApplyActionRequest {
session_id: "grpc-stale".to_string(),
action_id,
expected_state_version: observation.state_version,
observer_player: None,
})
.await?;
let stale_code = format!(
"{:?}",
client
.apply_action(proto::ApplyActionRequest {
session_id: "grpc-stale".to_string(),
action_id,
expected_state_version: observation.state_version,
observer_player: None,
})
.await
.expect_err("stale state_version must fail")
.code()
);
server.abort();
let (mut client, server) = spawn_client().await?;
let observation = client
.new_game(new_game_request("grpc-bad-observer", 31, true))
.await?
.into_inner();
let action_id = first_action(&observation)?;
let invalid_observer_code = format!(
"{:?}",
client
.apply_action(proto::ApplyActionRequest {
session_id: "grpc-bad-observer".to_string(),
action_id,
expected_state_version: observation.state_version,
observer_player: Some(2),
})
.await
.expect_err("invalid observer must fail")
.code()
);
let after_error = client
.get_observation(session_ref("grpc-bad-observer", Some(0)))
.await?
.into_inner();
let invalid_observer_state_unchanged = after_error.state_version == observation.state_version
&& after_error.phase == observation.phase
&& after_error.current_player == observation.current_player;
server.abort();
let (mut client, server) = spawn_client().await?;
client
.new_game(new_game_request("grpc-end-session", 41, true))
.await?;
client
.end_session(session_ref("grpc-end-session", None))
.await?;
client
.end_session(session_ref("grpc-end-session", None))
.await?;
let ended_session_code = format!(
"{:?}",
client
.get_observation(session_ref("grpc-end-session", None))
.await
.expect_err("ended session should not be readable")
.code()
);
server.abort();
Ok(GrpcProbeOutput {
round_trip_phase,
opponent_legal_empty,
stale_code,
invalid_observer_code,
invalid_observer_state_unchanged,
ended_session_code,
})
}
fn run_full_session_probe() -> ProbeResult<(bool, bool, String)> {
let mut engine = LostCitiesEngine::new();
let mut observation = engine.new_game(new_game_request("loop", 17, true))?;
loop {
let action_id = first_action(&observation)?;
let step = engine.apply_action(proto::ApplyActionRequest {
session_id: "loop".to_string(),
action_id,
expected_state_version: observation.state_version,
observer_player: None,
})?;
let next_observation = step
.observation
.ok_or_else(|| io::Error::other("missing loop observation"))?;
if step.terminal {
let observer = next_observation.observer_player;
let other = 1 - observer;
let reward_matches = step.reward as i32 == next_observation.score_diff;
let scores_match = step.final_scores.len() == 2
&& step.final_scores.get(&observer).copied() == Some(next_observation.my_score)
&& step.final_scores.get(&other).copied() == Some(next_observation.opponent_score);
let terminal_reject_kind = kind_name(
engine
.apply_action(proto::ApplyActionRequest {
session_id: "loop".to_string(),
action_id: 0,
expected_state_version: next_observation.state_version,
observer_player: None,
})
.expect_err("terminal game must reject further actions")
.kind(),
);
return Ok((reward_matches, scores_match, terminal_reject_kind));
}
observation = next_observation;
}
}
fn run_deterministic_probe() -> ProbeResult<bool> {
let config = small_config(29);
let mut left = LostCitiesEngine::new();
let mut right = LostCitiesEngine::new();
let mut left_observation = left.new_game(proto::NewGameRequest {
session_id: "det-left".to_string(),
config: Some(config.clone()),
})?;
let mut right_observation = right.new_game(proto::NewGameRequest {
session_id: "det-right".to_string(),
config: Some(config),
})?;
loop {
if observation_summary(&left_observation) != observation_summary(&right_observation) {
return Ok(false);
}
if left_observation.terminal {
return Ok(right_observation.terminal);
}
let action_id = first_action(&left_observation)?;
let left_step = left.apply_action(proto::ApplyActionRequest {
session_id: "det-left".to_string(),
action_id,
expected_state_version: left_observation.state_version,
observer_player: None,
})?;
let right_step = right.apply_action(proto::ApplyActionRequest {
session_id: "det-right".to_string(),
action_id,
expected_state_version: right_observation.state_version,
observer_player: None,
})?;
if left_step.terminal != right_step.terminal
|| left_step.final_scores != right_step.final_scores
|| left_step.reward != right_step.reward
{
return Ok(false);
}
left_observation = left_step
.observation
.ok_or_else(|| io::Error::other("missing left observation"))?;
right_observation = right_step
.observation
.ok_or_else(|| io::Error::other("missing right observation"))?;
}
}
async fn spawn_client() -> ProbeResult<(LostCitiesClient<Channel>, tokio::task::JoinHandle<()>)> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
let incoming = TcpListenerStream::new(listener);
let server = tokio::spawn(async move {
Server::builder()
.add_service(LostCitiesServer::new(LostCitiesGrpcService::default()))
.serve_with_incoming(incoming)
.await
.expect("gRPC server should run");
});
let endpoint = Endpoint::from_shared(format!("http://{}", addr))?;
let client = LostCitiesClient::new(endpoint.connect().await?);
Ok((client, server))
}
fn observation_summary(observation: &proto::GameObservation) -> ObservationSummary {
ObservationSummary {
state_version: observation.state_version,
current_player: observation.current_player,
observer_player: observation.observer_player,
phase: phase_name_proto(observation.phase).to_string(),
hand: observation.hand.iter().map(CardJson::from_proto).collect(),
opponent_hand_size: observation.opponent_hand_size,
deck_size: observation.deck_size,
discards: observation
.discards
.iter()
.map(|discard| discard.cards.iter().map(CardJson::from_proto).collect())
.collect(),
my_expeditions: observation
.my_expeditions
.iter()
.map(|expedition| expedition.cards.iter().map(CardJson::from_proto).collect())
.collect(),
opponent_expeditions: observation
.opponent_expeditions
.iter()
.map(|expedition| expedition.cards.iter().map(CardJson::from_proto).collect())
.collect(),
legal_mask: observation
.legal_actions
.as_ref()
.map(|legal| legal.mask.clone())
.unwrap_or_default(),
terminal: observation.terminal,
my_score: observation.my_score,
opponent_score: observation.opponent_score,
score_diff: observation.score_diff,
}
}
fn first_action(observation: &proto::GameObservation) -> ProbeResult<u32> {
observation
.legal_actions
.as_ref()
.and_then(|actions| actions.actions.first())
.map(|action| action.id)
.ok_or_else(|| io::Error::other("observation has no legal action").into())
}
fn small_config(seed: u64) -> proto::GameConfig {
proto::GameConfig {
n_colors: 2,
n_ranks: 2,
min_rank: 1,
n_handshakes: 0,
hand_size: 1,
expedition_penalty: 0,
bonus_threshold: 99,
bonus_amount: 0,
seed: Some(seed),
}
}
fn new_game_request(session_id: &str, seed: u64, include_config: bool) -> proto::NewGameRequest {
proto::NewGameRequest {
session_id: session_id.to_string(),
config: include_config.then(|| small_config(seed)),
}
}
fn session_ref(session_id: &str, observer_player: Option<u32>) -> proto::SessionRef {
proto::SessionRef {
session_id: session_id.to_string(),
observer_player,
}
}
fn kind_name(kind: EngineErrorKind) -> String {
match kind {
EngineErrorKind::AlreadyExists => "AlreadyExists",
EngineErrorKind::NotFound => "NotFound",
EngineErrorKind::FailedPrecondition => "FailedPrecondition",
EngineErrorKind::InvalidArgument => "InvalidArgument",
}
.to_string()
}
fn phase_name_state(phase: Phase) -> &'static str {
match phase {
Phase::Card => "card",
Phase::Draw => "draw",
}
}
fn phase_name_proto(phase: i32) -> &'static str {
match proto::Phase::try_from(phase) {
Ok(proto::Phase::Card) => "card",
Ok(proto::Phase::Draw) => "draw",
_ => "unspecified",
}
}
impl ConfigJson {
fn to_config(&self) -> Config {
Config {
n_colors: self.n_colors,
n_ranks: self.n_ranks,
min_rank: self.min_rank,
n_handshakes: self.n_handshakes,
hand_size: self.hand_size,
expedition_penalty: self.expedition_penalty,
bonus_threshold: self.bonus_threshold,
bonus_amount: self.bonus_amount,
seed: self.seed,
}
}
fn from_config(config: &Config) -> Self {
Self {
n_colors: config.n_colors,
n_ranks: config.n_ranks,
min_rank: config.min_rank,
n_handshakes: config.n_handshakes,
hand_size: config.hand_size,
expedition_penalty: config.expedition_penalty,
bonus_threshold: config.bonus_threshold,
bonus_amount: config.bonus_amount,
seed: config.seed,
}
}
}
impl From<CardJson> for Card {
fn from(value: CardJson) -> Self {
Self {
color: value.color,
rank: value.rank,
}
}
}
impl From<Card> for CardJson {
fn from(value: Card) -> Self {
Self {
color: value.color,
rank: value.rank,
}
}
}
impl CardJson {
fn from_proto(value: &proto::Card) -> Self {
Self {
color: value.color,
rank: value.rank,
}
}
}
impl StateTraceStep {
fn from_state(action: Option<u32>, state: &GameState) -> Self {
Self {
action,
phase: phase_name_state(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.legal_unified_mask(),
deck: state.deck.iter().copied().map(CardJson::from).collect(),
hands: state
.hands
.iter()
.map(|hand| hand.iter().copied().map(CardJson::from).collect())
.collect(),
expeditions: state
.expeditions
.iter()
.map(|expeditions| {
expeditions
.iter()
.map(|expedition| expedition.iter().copied().map(CardJson::from).collect())
.collect()
})
.collect(),
discards: state
.discards
.iter()
.map(|discard| discard.iter().copied().map(CardJson::from).collect())
.collect(),
}
}
}
impl ObservationMini {
fn from_observation(observation: &proto::GameObservation) -> Self {
Self {
state_version: observation.state_version,
current_player: observation.current_player,
observer_player: observation.observer_player,
phase: phase_name_proto(observation.phase).to_string(),
terminal: observation.terminal,
}
}
}
-131
View File
@@ -1,131 +0,0 @@
use crate::error::EngineError;
use crate::proto;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Config {
pub n_colors: usize,
pub n_ranks: u32,
pub min_rank: u32,
pub n_handshakes: u32,
pub hand_size: usize,
pub expedition_penalty: i32,
pub bonus_threshold: usize,
pub bonus_amount: i32,
pub seed: Option<u64>,
}
impl Default for Config {
fn default() -> Self {
Self {
n_colors: 5,
n_ranks: 9,
min_rank: 2,
n_handshakes: 3,
hand_size: 8,
expedition_penalty: -20,
bonus_threshold: 8,
bonus_amount: 20,
seed: None,
}
}
}
impl Config {
pub fn validate(&self) -> Result<(), EngineError> {
if self.n_colors == 0 {
return Err(EngineError::invalid_argument("n_colors must be positive"));
}
if self.n_ranks == 0 {
return Err(EngineError::invalid_argument("n_ranks must be positive"));
}
if self.min_rank == 0 {
return Err(EngineError::invalid_argument("min_rank must be positive"));
}
if self.hand_size == 0 {
return Err(EngineError::invalid_argument("hand_size must be positive"));
}
if self.bonus_threshold == 0 {
return Err(EngineError::invalid_argument(
"bonus_threshold must be positive",
));
}
if self.deck_size() < 2 * self.hand_size {
return Err(EngineError::invalid_argument(
"deck must contain at least both initial hands",
));
}
Ok(())
}
pub fn with_seed(mut self, seed: Option<u64>) -> Self {
self.seed = seed;
self
}
pub fn deck_size(&self) -> usize {
self.n_colors * (self.n_ranks as usize + self.n_handshakes as usize)
}
pub fn card_action_size(&self) -> usize {
self.hand_size * 2
}
pub fn draw_action_size(&self) -> usize {
1 + self.n_colors
}
pub fn action_space_size(&self) -> usize {
self.card_action_size() + self.draw_action_size()
}
}
impl TryFrom<&proto::GameConfig> for Config {
type Error = EngineError;
fn try_from(value: &proto::GameConfig) -> Result<Self, Self::Error> {
let n_colors = usize::try_from(value.n_colors)
.map_err(|_| EngineError::invalid_argument("n_colors is out of range"))?;
let hand_size = usize::try_from(value.hand_size)
.map_err(|_| EngineError::invalid_argument("hand_size is out of range"))?;
let bonus_threshold = usize::try_from(value.bonus_threshold)
.map_err(|_| EngineError::invalid_argument("bonus_threshold is out of range"))?;
let config = Self {
n_colors,
n_ranks: value.n_ranks,
min_rank: value.min_rank,
n_handshakes: value.n_handshakes,
hand_size,
expedition_penalty: value.expedition_penalty,
bonus_threshold,
bonus_amount: value.bonus_amount,
seed: value.seed,
};
config.validate()?;
Ok(config)
}
}
impl TryFrom<proto::GameConfig> for Config {
type Error = EngineError;
fn try_from(value: proto::GameConfig) -> Result<Self, Self::Error> {
Self::try_from(&value)
}
}
impl From<&Config> for proto::GameConfig {
fn from(value: &Config) -> Self {
Self {
n_colors: value.n_colors as u32,
n_ranks: value.n_ranks,
min_rank: value.min_rank,
n_handshakes: value.n_handshakes,
hand_size: value.hand_size as u32,
expedition_penalty: value.expedition_penalty,
bonus_threshold: value.bonus_threshold as u32,
bonus_amount: value.bonus_amount,
seed: value.seed,
}
}
}
-231
View File
@@ -1,231 +0,0 @@
use std::collections::HashMap;
use crate::config::Config;
use crate::error::EngineError;
use crate::proto;
use crate::state::{GameState, Phase};
#[derive(Clone, Debug)]
struct SessionState {
game: GameState,
state_version: u64,
}
impl SessionState {
fn observation(&self, session_id: &str, observer: usize) -> proto::GameObservation {
let game = &self.game;
let can_act = !game.terminal && observer == game.current_player;
let my_score = game.total_score(observer);
let opponent_score = game.total_score(1 - observer);
proto::GameObservation {
session_id: session_id.to_string(),
config: Some((&game.config).into()),
state_version: self.state_version,
observer_player: observer as u32,
current_player: game.current_player as u32,
phase: game.phase.to_proto(),
hand: game.hands[observer]
.iter()
.copied()
.map(|card| card.to_proto(&game.config))
.collect(),
opponent_hand_size: game.hands[1 - observer].len() as u32,
my_expeditions: Self::build_expeditions(observer, game),
opponent_expeditions: Self::build_expeditions(1 - observer, game),
discards: Self::build_discards(game),
deck_size: game.deck.len() as u32,
pending_discarded_color: if game.phase == Phase::Draw {
game.pending_discarded_color
} else {
None
},
legal_actions: Some(game.build_legal_action_set(self.state_version, can_act)),
turn_count: game.turn_count,
terminal: game.terminal,
my_score,
opponent_score,
score_diff: my_score - opponent_score,
}
}
fn final_scores(&self) -> HashMap<u32, i32> {
HashMap::from([(0, self.game.total_score(0)), (1, self.game.total_score(1))])
}
fn build_expeditions(player: usize, game: &GameState) -> Vec<proto::Expedition> {
game.expeditions[player]
.iter()
.enumerate()
.map(|(color, cards)| proto::Expedition {
color: color as u32,
cards: cards
.iter()
.copied()
.map(|card| card.to_proto(&game.config))
.collect(),
current_score: crate::score_expedition(cards, &game.config),
})
.collect()
}
fn build_discards(game: &GameState) -> Vec<proto::DiscardPile> {
game.discards
.iter()
.enumerate()
.map(|(color, cards)| proto::DiscardPile {
color: color as u32,
cards: cards
.iter()
.copied()
.map(|card| card.to_proto(&game.config))
.collect(),
size: cards.len() as u32,
})
.collect()
}
}
#[derive(Default)]
pub struct LostCitiesEngine {
sessions: HashMap<String, SessionState>,
}
impl LostCitiesEngine {
pub fn new() -> Self {
Self::default()
}
pub fn session_count(&self) -> usize {
self.sessions.len()
}
pub fn new_game(
&mut self,
request: proto::NewGameRequest,
) -> Result<proto::GameObservation, EngineError> {
let session_id = request.session_id;
Self::validate_session_id(&session_id)?;
if self.sessions.contains_key(&session_id) {
return Err(EngineError::already_exists(format!(
"session {} already exists",
session_id
)));
}
let config_proto = request
.config
.ok_or_else(|| EngineError::invalid_argument("config is required"))?;
let config = Config::try_from(config_proto)?;
let session = SessionState {
game: GameState::new_game(config)?,
state_version: 0,
};
let observation = session.observation(&session_id, 0);
self.sessions.insert(session_id, session);
Ok(observation)
}
pub fn get_observation(
&self,
request: proto::SessionRef,
) -> Result<proto::GameObservation, EngineError> {
let session_id = request.session_id;
Self::validate_session_id(&session_id)?;
let session = self
.sessions
.get(&session_id)
.ok_or_else(|| EngineError::not_found(format!("unknown session {}", session_id)))?;
let observer = Self::requested_or_default_observer(
request.observer_player,
session.game.current_player,
)?;
Ok(session.observation(&session_id, observer))
}
pub fn apply_action(
&mut self,
request: proto::ApplyActionRequest,
) -> Result<proto::StepResult, EngineError> {
let session_id = request.session_id;
Self::validate_session_id(&session_id)?;
let requested_observer = request
.observer_player
.map(Self::validate_observer)
.transpose()?;
let session = self
.sessions
.get_mut(&session_id)
.ok_or_else(|| EngineError::not_found(format!("unknown session {}", session_id)))?;
if session.game.terminal {
return Err(EngineError::failed_precondition("game is already terminal"));
}
if request.expected_state_version != session.state_version {
return Err(EngineError::failed_precondition(format!(
"expected state_version {}, got {}",
session.state_version, request.expected_state_version
)));
}
session.game.apply_unified_action(request.action_id)?;
session.state_version += 1;
let observer = requested_observer.unwrap_or(session.game.current_player);
let observation = session.observation(&session_id, observer);
let terminal = session.game.terminal;
let reward = if terminal {
f64::from(observation.score_diff)
} else {
0.0
};
let final_scores = if terminal {
session.final_scores()
} else {
HashMap::new()
};
Ok(proto::StepResult {
observation: Some(observation),
reward,
terminal,
final_scores,
})
}
pub fn end_session(&mut self, request: proto::SessionRef) -> Result<(), EngineError> {
Self::validate_session_id(&request.session_id)?;
self.sessions.remove(&request.session_id);
Ok(())
}
fn validate_session_id(session_id: &str) -> Result<(), EngineError> {
if session_id.trim().is_empty() {
return Err(EngineError::invalid_argument(
"session_id must not be empty",
));
}
Ok(())
}
fn requested_or_default_observer(
observer_player: Option<u32>,
default_player: usize,
) -> Result<usize, EngineError> {
observer_player
.map(Self::validate_observer)
.transpose()
.map(|observer| observer.unwrap_or(default_player))
}
fn validate_observer(observer: u32) -> Result<usize, EngineError> {
match observer {
0 => Ok(0),
1 => Ok(1),
_ => Err(EngineError::invalid_argument(
"observer_player must be 0 or 1",
)),
}
}
}
-57
View File
@@ -1,57 +0,0 @@
use std::error::Error;
use std::fmt::{self, Display, Formatter};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EngineErrorKind {
AlreadyExists,
NotFound,
FailedPrecondition,
InvalidArgument,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EngineError {
kind: EngineErrorKind,
message: String,
}
impl EngineError {
pub fn new(kind: EngineErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
pub fn kind(&self) -> EngineErrorKind {
self.kind
}
pub fn message(&self) -> &str {
&self.message
}
pub(crate) fn already_exists(message: impl Into<String>) -> Self {
Self::new(EngineErrorKind::AlreadyExists, message)
}
pub(crate) fn not_found(message: impl Into<String>) -> Self {
Self::new(EngineErrorKind::NotFound, message)
}
pub(crate) fn failed_precondition(message: impl Into<String>) -> Self {
Self::new(EngineErrorKind::FailedPrecondition, message)
}
pub(crate) fn invalid_argument(message: impl Into<String>) -> Self {
Self::new(EngineErrorKind::InvalidArgument, message)
}
}
impl Display for EngineError {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{:?}: {}", self.kind, self.message)
}
}
impl Error for EngineError {}
-15
View File
@@ -1,15 +0,0 @@
pub mod proto {
tonic::include_proto!("lost_cities.v1");
}
mod config;
mod engine;
mod error;
mod service;
mod state;
pub use config::Config;
pub use engine::LostCitiesEngine;
pub use error::{EngineError, EngineErrorKind};
pub use service::LostCitiesGrpcService;
pub use state::{score_expedition, Card, GameState, Phase};
-82
View File
@@ -1,82 +0,0 @@
use std::sync::{Arc, Mutex};
use tonic::{Request, Response, Status};
use crate::error::{EngineError, EngineErrorKind};
use crate::proto;
use crate::LostCitiesEngine;
#[derive(Clone, Default)]
pub struct LostCitiesGrpcService {
engine: Arc<Mutex<LostCitiesEngine>>,
}
impl LostCitiesGrpcService {
pub fn new(engine: LostCitiesEngine) -> Self {
Self {
engine: Arc::new(Mutex::new(engine)),
}
}
}
#[tonic::async_trait]
impl proto::lost_cities_server::LostCities for LostCitiesGrpcService {
async fn new_game(
&self,
request: Request<proto::NewGameRequest>,
) -> Result<Response<proto::GameObservation>, Status> {
let mut engine = self.engine.lock().map_err(|_| poisoned_engine_status())?;
let observation = engine
.new_game(request.into_inner())
.map_err(map_engine_error)?;
Ok(Response::new(observation))
}
async fn get_observation(
&self,
request: Request<proto::SessionRef>,
) -> Result<Response<proto::GameObservation>, Status> {
let engine = self.engine.lock().map_err(|_| poisoned_engine_status())?;
let observation = engine
.get_observation(request.into_inner())
.map_err(map_engine_error)?;
Ok(Response::new(observation))
}
async fn apply_action(
&self,
request: Request<proto::ApplyActionRequest>,
) -> Result<Response<proto::StepResult>, Status> {
let mut engine = self.engine.lock().map_err(|_| poisoned_engine_status())?;
let result = engine
.apply_action(request.into_inner())
.map_err(map_engine_error)?;
Ok(Response::new(result))
}
async fn end_session(
&self,
request: Request<proto::SessionRef>,
) -> Result<Response<()>, Status> {
let mut engine = self.engine.lock().map_err(|_| poisoned_engine_status())?;
engine
.end_session(request.into_inner())
.map_err(map_engine_error)?;
Ok(Response::new(()))
}
}
fn poisoned_engine_status() -> Status {
Status::internal("lost cities engine mutex poisoned")
}
fn map_engine_error(error: EngineError) -> Status {
match error.kind() {
EngineErrorKind::AlreadyExists => Status::already_exists(error.message().to_string()),
EngineErrorKind::NotFound => Status::not_found(error.message().to_string()),
EngineErrorKind::FailedPrecondition => {
Status::failed_precondition(error.message().to_string())
}
EngineErrorKind::InvalidArgument => Status::invalid_argument(error.message().to_string()),
}
}
-550
View File
@@ -1,550 +0,0 @@
use crate::config::Config;
use crate::error::EngineError;
use crate::proto;
use rand::rngs::StdRng;
use rand::seq::SliceRandom;
use rand::SeedableRng;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Card {
pub color: u32,
pub rank: u32,
}
impl Card {
pub fn is_handshake(self) -> bool {
self.rank == 0
}
pub fn numeric_value(self, min_rank: u32) -> u32 {
if self.is_handshake() {
0
} else {
min_rank + self.rank - 1
}
}
pub fn label(self, min_rank: u32) -> String {
if self.is_handshake() {
format!("[{}]H", self.color)
} else {
format!("[{}]{}", self.color, self.numeric_value(min_rank))
}
}
pub fn to_proto(self, config: &Config) -> proto::Card {
proto::Card {
color: self.color,
rank: self.rank,
numeric_value: self.numeric_value(config.min_rank),
label: self.label(config.min_rank),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Phase {
Card,
Draw,
}
impl Phase {
pub fn to_proto(self) -> i32 {
match self {
Self::Card => proto::Phase::Card as i32,
Self::Draw => proto::Phase::Draw as i32,
}
}
}
#[derive(Clone, Debug)]
pub struct GameState {
pub config: Config,
pub deck: Vec<Card>,
pub hands: [Vec<Card>; 2],
pub expeditions: [Vec<Vec<Card>>; 2],
pub discards: Vec<Vec<Card>>,
pub current_player: usize,
pub phase: Phase,
pub pending_discarded_color: Option<u32>,
pub turn_count: u32,
pub terminal: bool,
}
impl GameState {
pub fn new_game(config: Config) -> Result<Self, EngineError> {
config.validate()?;
let mut deck = build_deck(&config);
match config.seed {
Some(seed) => {
let mut rng = StdRng::seed_from_u64(seed);
deck.shuffle(&mut rng);
}
None => {
let mut rng = rand::thread_rng();
deck.shuffle(&mut rng);
}
}
Self::new_game_from_deck(config, deck)
}
pub fn new_game_from_deck(config: Config, deck: Vec<Card>) -> Result<Self, EngineError> {
config.validate()?;
let mut expected = build_deck(&config);
let mut actual = deck.clone();
expected.sort();
actual.sort();
if actual != expected {
return Err(EngineError::invalid_argument(
"deck must contain exactly the cards defined by config",
));
}
let mut state = Self::empty(config)?;
state.deck = deck;
for _ in 0..state.config.hand_size {
for player in 0..2 {
let card = state
.deck
.pop()
.expect("validated deck must contain both initial hands");
state.hands[player].push(card);
}
}
state.sort_hands();
state
.validate_invariants()
.map_err(EngineError::invalid_argument)?;
Ok(state)
}
pub fn empty(config: Config) -> Result<Self, EngineError> {
config.validate()?;
let n_colors = config.n_colors;
Ok(Self {
config,
deck: Vec::new(),
hands: [Vec::new(), Vec::new()],
expeditions: std::array::from_fn(|_| vec![Vec::new(); n_colors]),
discards: vec![Vec::new(); n_colors],
current_player: 0,
phase: Phase::Card,
pending_discarded_color: None,
turn_count: 0,
terminal: false,
})
}
pub fn sort_hands(&mut self) {
self.sort_hand(0);
self.sort_hand(1);
}
pub fn sort_hand(&mut self, player: usize) {
self.hands[player].sort_by_key(|card| (card.color, card.rank));
}
pub fn last_numeric_rank(&self, player: usize, color: usize) -> u32 {
self.expeditions[player][color]
.iter()
.filter(|card| !card.is_handshake())
.map(|card| card.rank)
.max()
.unwrap_or(0)
}
pub fn can_play_card(&self, player: usize, card: Card) -> bool {
let color = match usize::try_from(card.color) {
Ok(value) if value < self.config.n_colors => value,
_ => return false,
};
if card.rank > self.config.n_ranks {
return false;
}
let last_numeric = self.last_numeric_rank(player, color);
if card.is_handshake() {
last_numeric == 0
} else {
card.rank > last_numeric
}
}
pub fn legal_card_mask_phase(&self) -> Vec<bool> {
let mut mask = vec![false; self.config.card_action_size()];
if self.terminal {
return mask;
}
for (slot, card) in self.hands[self.current_player].iter().copied().enumerate() {
let play_id = Self::play_action_id(slot);
mask[play_id] = self.can_play_card(self.current_player, card);
mask[Self::discard_action_id(slot)] = true;
}
mask
}
pub fn legal_draw_mask_phase(&self) -> Vec<bool> {
let mut mask = vec![false; self.config.draw_action_size()];
if self.terminal {
return mask;
}
mask[0] = !self.deck.is_empty();
for color in 0..self.config.n_colors {
let pending = self.pending_discarded_color == Some(color as u32);
mask[1 + color] = !self.discards[color].is_empty() && !pending;
}
mask
}
pub fn legal_unified_mask(&self) -> Vec<bool> {
if self.terminal {
return vec![false; self.config.action_space_size()];
}
match self.phase {
Phase::Card => {
let mut mask = self.legal_card_mask_phase();
mask.resize(self.config.action_space_size(), false);
mask
}
Phase::Draw => {
let mut mask = vec![false; self.config.card_action_size()];
mask.extend(self.legal_draw_mask_phase());
mask
}
}
}
pub fn apply_unified_action(&mut self, action_id: u32) -> Result<(), EngineError> {
if self.terminal {
return Err(EngineError::failed_precondition("game is already terminal"));
}
let action_index = usize::try_from(action_id)
.map_err(|_| EngineError::failed_precondition("action_id is out of range"))?;
let mask = self.legal_unified_mask();
if action_index >= mask.len() || !mask[action_index] {
return Err(EngineError::failed_precondition(format!(
"illegal action {} in phase {:?} for player {}",
action_id, self.phase, self.current_player
)));
}
match self.phase {
Phase::Card => self.apply_card_action(action_index),
Phase::Draw => self.apply_draw_action(action_index),
}
Ok(())
}
pub fn total_score(&self, player: usize) -> i32 {
self.expeditions[player]
.iter()
.map(|expedition| score_expedition(expedition, &self.config))
.sum()
}
pub fn score_diff(&self, player: usize) -> i32 {
self.total_score(player) - self.total_score(1 - player)
}
pub fn validate_invariants(&self) -> Result<(), String> {
self.config.validate().map_err(|err| err.to_string())?;
if self.current_player > 1 {
return Err("current_player must be 0 or 1".to_string());
}
if self.discards.len() != self.config.n_colors {
return Err("discard pile count must match n_colors".to_string());
}
let mut all_cards = Vec::new();
all_cards.extend(self.deck.iter().copied());
for (player_index, hand) in self.hands.iter().enumerate() {
if hand.len() > self.config.hand_size {
return Err(format!("hand {} exceeds hand_size", player_index));
}
if !hand.windows(2).all(|pair| pair[0] <= pair[1]) {
return Err(format!("hand {} is not sorted", player_index));
}
all_cards.extend(hand.iter().copied());
}
for (player_index, expeditions) in self.expeditions.iter().enumerate() {
if expeditions.len() != self.config.n_colors {
return Err("expedition color count must match n_colors".to_string());
}
for (color, expedition) in expeditions.iter().enumerate() {
let mut seen_numeric = false;
let mut last_numeric = 0;
for card in expedition {
if card.color as usize != color {
return Err(format!(
"player {} expedition {} contains wrong color",
player_index, color
));
}
if card.is_handshake() {
if seen_numeric {
return Err(format!(
"player {} expedition {} has handshake after numeric",
player_index, color
));
}
continue;
}
seen_numeric = true;
if card.rank <= last_numeric {
return Err(format!(
"player {} expedition {} is not strictly increasing",
player_index, color
));
}
last_numeric = card.rank;
}
all_cards.extend(expedition.iter().copied());
}
}
for discard in &self.discards {
all_cards.extend(discard.iter().copied());
}
let mut expected = build_deck(&self.config);
expected.sort();
all_cards.sort();
if all_cards != expected {
return Err("card conservation failed".to_string());
}
if self.phase == Phase::Card && self.pending_discarded_color.is_some() {
return Err("pending_discarded_color must be None during card phase".to_string());
}
if let Some(color) = self.pending_discarded_color {
let color = color as usize;
if color >= self.config.n_colors {
return Err("pending_discarded_color is out of range".to_string());
}
if self.discards[color].is_empty() {
return Err("pending discard color must have a discard pile card".to_string());
}
}
let any_legal = self.legal_unified_mask().into_iter().any(|value| value);
if self.terminal && any_legal {
return Err("terminal state must have no legal actions".to_string());
}
if !self.terminal && !any_legal {
return Err("non-terminal state must have at least one legal action".to_string());
}
Ok(())
}
pub(crate) fn build_legal_action_set(
&self,
state_version: u64,
include_actions: bool,
) -> proto::LegalActionSet {
if self.terminal || !include_actions {
return self.empty_legal_action_set(state_version);
}
let mask = self.legal_unified_mask();
let actions = match self.phase {
Phase::Card => self.build_card_actions(&mask),
Phase::Draw => self.build_draw_actions(&mask),
};
proto::LegalActionSet {
state_version,
actions,
mask,
action_space_size: self.config.action_space_size() as u32,
phase: self.phase.to_proto(),
}
}
fn apply_card_action(&mut self, action_index: usize) {
let slot = action_index / 2;
let play = action_index.is_multiple_of(2);
let card = self.hands[self.current_player].remove(slot);
let color = card.color as usize;
if play {
self.expeditions[self.current_player][color].push(card);
self.pending_discarded_color = None;
} else {
self.discards[color].push(card);
self.pending_discarded_color = Some(card.color);
}
self.phase = Phase::Draw;
if self.deck.is_empty() && !self.has_draw_source() {
self.terminal = true;
}
}
fn apply_draw_action(&mut self, action_index: usize) {
let draw_index = action_index - self.draw_action_offset();
let card = if draw_index == 0 {
self.deck.pop().expect("legal deck draw")
} else {
let color = draw_index - 1;
self.discards[color].pop().expect("legal discard draw")
};
self.hands[self.current_player].push(card);
self.sort_hand(self.current_player);
self.pending_discarded_color = None;
self.turn_count += 1;
if self.deck.is_empty() {
self.terminal = true;
return;
}
self.current_player = 1 - self.current_player;
self.phase = Phase::Card;
}
fn build_card_actions(&self, mask: &[bool]) -> Vec<proto::Action> {
let mut actions = Vec::new();
for (slot, card) in self.hands[self.current_player].iter().copied().enumerate() {
let play_id = Self::play_action_id(slot);
if mask[play_id] {
actions.push(self.card_action(
play_id,
proto::ActionKind::PlayCard,
slot,
card,
"Play",
));
}
let discard_id = Self::discard_action_id(slot);
if mask[discard_id] {
actions.push(self.card_action(
discard_id,
proto::ActionKind::DiscardCard,
slot,
card,
"Discard",
));
}
}
actions
}
fn build_draw_actions(&self, mask: &[bool]) -> Vec<proto::Action> {
let mut actions = Vec::new();
let deck_draw_id = self.draw_action_offset();
if mask[deck_draw_id] {
actions.push(self.draw_action(deck_draw_id, None));
}
for color in 0..self.config.n_colors {
let action_id = self.discard_draw_action_id(color);
if mask[action_id] {
actions.push(self.draw_action(action_id, Some(color)));
}
}
actions
}
fn empty_legal_action_set(&self, state_version: u64) -> proto::LegalActionSet {
proto::LegalActionSet {
state_version,
actions: Vec::new(),
mask: vec![false; self.config.action_space_size()],
action_space_size: self.config.action_space_size() as u32,
phase: self.phase.to_proto(),
}
}
fn has_draw_source(&self) -> bool {
self.legal_draw_mask_phase().into_iter().any(|value| value)
}
fn draw_action_offset(&self) -> usize {
self.config.card_action_size()
}
fn play_action_id(slot: usize) -> usize {
slot * 2
}
fn discard_action_id(slot: usize) -> usize {
Self::play_action_id(slot) + 1
}
fn discard_draw_action_id(&self, color: usize) -> usize {
self.draw_action_offset() + 1 + color
}
fn card_action(
&self,
id: usize,
kind: proto::ActionKind,
slot: usize,
card: Card,
verb: &str,
) -> proto::Action {
proto::Action {
id: id as u32,
kind: kind as i32,
hand_slot: slot as u32,
card: Some(card.to_proto(&self.config)),
discard_color: 0,
description: format!("{verb} {}", card.label(self.config.min_rank)),
}
}
fn draw_action(&self, id: usize, discard_color: Option<usize>) -> proto::Action {
let (kind, discard_color, description) = match discard_color {
Some(color) => (
proto::ActionKind::DrawDiscard,
color as u32,
format!("Draw discard {color}"),
),
None => (proto::ActionKind::DrawDeck, 0, "Draw deck".to_string()),
};
proto::Action {
id: id as u32,
kind: kind as i32,
hand_slot: 0,
card: None,
discard_color,
description,
}
}
}
pub fn build_deck(config: &Config) -> Vec<Card> {
let mut deck = Vec::with_capacity(config.deck_size());
for color in 0..config.n_colors as u32 {
for _ in 0..config.n_handshakes {
deck.push(Card { color, rank: 0 });
}
for rank in 1..=config.n_ranks {
deck.push(Card { color, rank });
}
}
deck
}
pub fn score_expedition(expedition: &[Card], config: &Config) -> i32 {
if expedition.is_empty() {
return 0;
}
let handshakes = expedition.iter().filter(|card| card.is_handshake()).count() as i32;
let numeric_sum = expedition
.iter()
.map(|card| card.numeric_value(config.min_rank) as i32)
.sum::<i32>();
let mut score = (numeric_sum + config.expedition_penalty) * (handshakes + 1);
if expedition.len() >= config.bonus_threshold {
score += config.bonus_amount;
}
score
}
@@ -2,10 +2,8 @@ from __future__ import annotations
from .factory import build_backend from .factory import build_backend
from .python import PythonLostCitiesBackend from .python import PythonLostCitiesBackend
from .rust import RustLostCitiesBackend
__all__ = [ __all__ = [
"PythonLostCitiesBackend", "PythonLostCitiesBackend",
"RustLostCitiesBackend",
"build_backend", "build_backend",
] ]
@@ -3,7 +3,6 @@ from __future__ import annotations
from ..game import LostCitiesConfig from ..game import LostCitiesConfig
from ..interfaces import BackendName, LostCitiesBackend from ..interfaces import BackendName, LostCitiesBackend
from .python import PythonLostCitiesBackend from .python import PythonLostCitiesBackend
from .rust import RustLostCitiesBackend
def build_backend( def build_backend(
@@ -13,6 +12,4 @@ def build_backend(
) -> LostCitiesBackend: ) -> LostCitiesBackend:
if backend == "python": if backend == "python":
return PythonLostCitiesBackend(config, seed) return PythonLostCitiesBackend(config, seed)
if backend == "rust":
return RustLostCitiesBackend(config, seed)
raise ValueError(f"unknown backend: {backend}") raise ValueError(f"unknown backend: {backend}")
@@ -1,107 +0,0 @@
from __future__ import annotations
import json
import logging
import random
import subprocess
import tempfile
from pathlib import Path
from ..game import Card, LostCitiesConfig, build_deck
from ..interfaces import BackendName, Snapshot
from .common import snapshot_from_trace, snapshot_summary
LOGGER = logging.getLogger("coolrl_lost_cities.games.classic.backends.rust")
RUST_CORE_DIR = Path(__file__).resolve().parents[5] / "rust" / "lost-cities-core"
class RustLostCitiesBackend:
name: BackendName = "rust"
def __init__(self, config: LostCitiesConfig, seed: int | None):
self.config = config
self.seed = seed
self.initial_deck = _shuffled_deck(config, seed)
self.actions: list[int] = []
self._snapshot = self._run_trace()
LOGGER.debug("러스트 백엔드 초기화: %s", snapshot_summary(self.snapshot()))
def snapshot(self) -> Snapshot:
return self._snapshot
def apply(self, action_id: int) -> None:
before = self.snapshot()
self.actions.append(action_id)
try:
self._snapshot = self._run_trace()
except Exception:
self.actions.pop()
raise
LOGGER.debug(
"러스트 액션 적용: 액션=%s 이전={%s} 이후={%s} 되돌리기깊이=%s",
action_id,
snapshot_summary(before),
snapshot_summary(self.snapshot()),
len(self.actions),
)
def can_undo(self) -> bool:
return bool(self.actions)
def undo(self) -> bool:
if not self.actions:
LOGGER.debug("러스트 되돌리기 무시: 액션 기록이 비어 있음")
return False
before = self.snapshot()
removed = self.actions.pop()
self._snapshot = self._run_trace()
LOGGER.debug(
"러스트 되돌리기: 제거한액션=%s 이전={%s} 이후={%s} 되돌리기깊이=%s",
removed,
snapshot_summary(before),
snapshot_summary(self.snapshot()),
len(self.actions),
)
return True
def _run_trace(self) -> Snapshot:
fixture = {
"config": self.config.to_snapshot(),
"initial_deck": [card.to_snapshot() for card in self.initial_deck],
"steps": [{"action": None}] + [{"action": action} for action in self.actions],
}
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
json.dump(fixture, handle)
fixture_path = Path(handle.name)
try:
result = subprocess.run(
[
"cargo",
"run",
"--quiet",
"--bin",
"lost_cities_probe",
"--",
"trace",
str(fixture_path),
],
cwd=RUST_CORE_DIR,
check=True,
text=True,
capture_output=True,
)
except subprocess.CalledProcessError as exc:
message = exc.stderr.strip() or exc.stdout.strip() or str(exc)
raise RuntimeError(f"rust backend failed: {message}") from exc
finally:
fixture_path.unlink(missing_ok=True)
trace = json.loads(result.stdout)
return snapshot_from_trace(trace["config"], trace["steps"][-1])
def _shuffled_deck(config: LostCitiesConfig, seed: int | None) -> list[Card]:
deck = build_deck(config)
rng = random.Random(config.seed if seed is None else seed)
rng.shuffle(deck)
return deck
@@ -1,6 +1,6 @@
# Lost Cities — 게임 핵심 로직 명세서 # Lost Cities — 게임 핵심 로직 명세서
본 문서는 외부 에이전트가 Lost Cities를 플레이하기 위해 알아야 할 모든 규칙과 상태 전이를 기술한다. 구현(Rust core)과 인터페이스(gRPC proto)는 이 명세를 준수해야 한다. 본 문서는 외부 에이전트가 Lost Cities를 플레이하기 위해 알아야 할 모든 규칙과 상태 전이를 기술한다. Python/Cython 구현은 이 명세를 준수해야 한다.
--- ---
@@ -163,13 +163,13 @@ CARD phase에서는 DISCARD가 항상 가능하므로 합법 액션이 최소 1
--- ---
## 9. 외부 에이전트를 위한 계약 ## 9. 에이전트를 위한 계약
gRPC 인터페이스로 플레이하는 에이전트가 알아야 할 계약. 게임 상태를 관찰하고 action id를 선택하는 에이전트가 알아야 할 계약.
### 9.1 상태는 세션 단위로 서버가 유지 ### 9.1 상태는 게임 인스턴스 단위로 유지
클라이언트는 `session_id`로 게임을 식별한다. 한 세션 = 한 게임. `GameState` 또는 backend 인스턴스가 한 게임을 나타낸다.
### 9.2 observation은 항상 특정 플레이어 관점 ### 9.2 observation은 항상 특정 플레이어 관점
@@ -185,15 +185,15 @@ gRPC 인터페이스로 플레이하는 에이전트가 알아야 할 계약.
`Action.id`는 그 observation과 함께 반환된 것에 한해서만 유효하다. 상태가 한 번이라도 진행되면 이전 observation의 id는 재사용 불가. `Action.id`는 그 observation과 함께 반환된 것에 한해서만 유효하다. 상태가 한 번이라도 진행되면 이전 observation의 id는 재사용 불가.
이를 강제하기 위해 모든 observation에는 `state_version`(세션 내 단조증가)이 실리고, `ApplyAction``expected_state_version`을 함께 받아 mismatch 시 거절한다. 이를 강제하려면 observation에는 `state_version` 같은 단조증가 값을 싣고, 액션 적용 시 기대 버전을 함께 확인하면 된다.
### 9.4 합법 액션은 observation에 임베드되어 옴 ### 9.4 합법 액션은 observation에 임베드되어 옴
별도 RPC 호출 없이 `observation.legal_actions` 안에서 전체 리스트와 마스크가 모두 제공된다. 외부 에이전트는 이 리스트에서 `id`만 골라 `ApplyAction`으로 되돌려주면 된다. observation 안에서 전체 리스트와 마스크가 모두 제공되어야 한다. 에이전트는 이 리스트에서 `id`만 골라 다음 액션으로 되돌려주면 된다.
### 9.5 보상 ### 9.5 보상
`ApplyAction``reward` observer 관점이며, terminal 전이에서만 nonzero이고 값은 observer의 `score_diff`. 비terminal 전이의 reward는 0. 전이 보상은 observer 관점이며, terminal 전이에서만 nonzero이고 값은 observer의 `score_diff`. 비terminal 전이의 reward는 0.
--- ---
@@ -5,7 +5,7 @@ from typing import Literal, Protocol, TypeAlias, runtime_checkable
from .game import Card, GameState, LostCitiesConfig, score_expedition from .game import Card, GameState, LostCitiesConfig, score_expedition
BackendName = Literal["python", "rust"] BackendName = Literal["python"]
@dataclass @dataclass
+1 -1
View File
@@ -30,4 +30,4 @@ def test_gui_argparser_accepts_classic_options() -> None:
def test_gui_argparser_rejects_removed_backend_option() -> None: def test_gui_argparser_rejects_removed_backend_option() -> None:
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
pygame_pvp.build_argparser().parse_args(["--backend", "rust"]) pygame_pvp.build_argparser().parse_args(["--backend", "native"])
-161
View File
@@ -1,161 +0,0 @@
import json
import random
import subprocess
from pathlib import Path
from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig, build_deck
import coolrl_lost_cities.games.classic as classic
from coolrl_lost_cities.games.classic.backends.rust import RUST_CORE_DIR
LOST_CITIES_DIR = Path(classic.__file__).resolve().parent
FIXTURE_DIR = LOST_CITIES_DIR / "fixtures"
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"))