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:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
Generated
-1367
File diff suppressed because it is too large
Load Diff
@@ -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"
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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};
|
|
||||||
@@ -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()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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"))
|
|
||||||
Reference in New Issue
Block a user