diff --git a/README.md b/README.md index 736118b..12974a9 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,9 @@ The current implementation starts with the classic two-player card game: - classic 5-expedition rules by default - Python/Cython game engine -- Rust core parity checks - env wrapper - 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 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 ``` -The GUI currently uses the Python backend only. The Rust core remains covered by -parity tests and is not exposed as a GUI backend. +The GUI uses the in-process Python backend. ## Basic Usage @@ -59,11 +57,4 @@ backend = build_backend("python", classic_config(), seed=1) 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. diff --git a/docs/classic-port-notes.md b/docs/classic-port-notes.md index 40c739c..2b52c78 100644 --- a/docs/classic-port-notes.md +++ b/docs/classic-port-notes.md @@ -10,9 +10,8 @@ game, without the earlier training-oriented tiers. - Treat classic as the initial concrete game under `coolrl_lost_cities.games`. - Do not carry over `tier0` through `tier3`; those were useful for experiments, but they should not shape the first public game API. -- Keep backend selection available. The Python/Cython and Rust implementations - should remain swappable behind a small backend boundary. -- Keep the GUI and Rust implementation in scope for the port. +- Use the in-process Python/Cython implementation for the current port. +- Keep the GUI in scope for local play, but do not carry a separate native backend. - Keep RL and training code out of the first extraction. The expected package shape is roughly: @@ -30,10 +29,6 @@ src/coolrl_lost_cities/ fixtures/ assets/ docs/ -rust/ - lost-cities-core/ -proto/ - lost_cities.proto ``` Tests should live outside the package, roughly under: @@ -47,6 +42,7 @@ tests/games/classic/ - Deep CFR - General training infrastructure - Evaluation loops for learned policies +- Separate native backend support - Web client - 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, -backend selection, and playable UI. Training code can adapt those pieces later. +and playable UI. Training code can adapt those pieces later. ## Naming Notes diff --git a/proto/lost_cities.proto b/proto/lost_cities.proto deleted file mode 100644 index b869ab6..0000000 --- a/proto/lost_cities.proto +++ /dev/null @@ -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 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); -} diff --git a/rust/lost-cities-core/Cargo.lock b/rust/lost-cities-core/Cargo.lock deleted file mode 100644 index 48a1e9d..0000000 --- a/rust/lost-cities-core/Cargo.lock +++ /dev/null @@ -1,1367 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "axum" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" -dependencies = [ - "async-trait", - "axum-core", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "sync_wrapper", - "tower 0.5.3", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", - "wasip3", -] - -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "libc", - "pin-project-lite", - "socket2 0.6.3", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.0", - "serde", - "serde_core", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.185" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "lost-cities-core" -version = "0.1.0" -dependencies = [ - "prost", - "prost-types", - "protoc-bin-vendored", - "rand", - "serde", - "serde_json", - "tokio", - "tokio-stream", - "tonic", - "tonic-build", -] - -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "multimap" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "petgraph" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" -dependencies = [ - "fixedbitset", - "indexmap 2.14.0", -] - -[[package]] -name = "pin-project" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "prost" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-build" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" -dependencies = [ - "heck", - "itertools", - "log", - "multimap", - "once_cell", - "petgraph", - "prettyplease", - "prost", - "prost-types", - "regex", - "syn", - "tempfile", -] - -[[package]] -name = "prost-derive" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "prost-types" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" -dependencies = [ - "prost", -] - -[[package]] -name = "protoc-bin-vendored" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" -dependencies = [ - "protoc-bin-vendored-linux-aarch_64", - "protoc-bin-vendored-linux-ppcle_64", - "protoc-bin-vendored-linux-s390_64", - "protoc-bin-vendored-linux-x86_32", - "protoc-bin-vendored-linux-x86_64", - "protoc-bin-vendored-macos-aarch_64", - "protoc-bin-vendored-macos-x86_64", - "protoc-bin-vendored-win32", -] - -[[package]] -name = "protoc-bin-vendored-linux-aarch_64" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" - -[[package]] -name = "protoc-bin-vendored-linux-ppcle_64" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" - -[[package]] -name = "protoc-bin-vendored-linux-s390_64" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" - -[[package]] -name = "protoc-bin-vendored-linux-x86_32" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" - -[[package]] -name = "protoc-bin-vendored-linux-x86_64" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" - -[[package]] -name = "protoc-bin-vendored-macos-aarch_64" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" - -[[package]] -name = "protoc-bin-vendored-macos-x86_64" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" - -[[package]] -name = "protoc-bin-vendored-win32" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio" -version = "1.52.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2 0.6.3", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tonic" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" -dependencies = [ - "async-stream", - "async-trait", - "axum", - "base64", - "bytes", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-timeout", - "hyper-util", - "percent-encoding", - "pin-project", - "prost", - "socket2 0.5.10", - "tokio", - "tokio-stream", - "tower 0.4.13", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tonic-build" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" -dependencies = [ - "prettyplease", - "proc-macro2", - "prost-build", - "prost-types", - "quote", - "syn", -] - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "indexmap 1.9.3", - "pin-project", - "pin-project-lite", - "rand", - "slab", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/rust/lost-cities-core/Cargo.toml b/rust/lost-cities-core/Cargo.toml deleted file mode 100644 index 6a0ce81..0000000 --- a/rust/lost-cities-core/Cargo.toml +++ /dev/null @@ -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" diff --git a/rust/lost-cities-core/build.rs b/rust/lost-cities-core/build.rs deleted file mode 100644 index 09a4673..0000000 --- a/rust/lost-cities-core/build.rs +++ /dev/null @@ -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"); -} diff --git a/rust/lost-cities-core/src/bin/lost_cities_probe.rs b/rust/lost-cities-core/src/bin/lost_cities_probe.rs deleted file mode 100644 index c7e16fd..0000000 --- a/rust/lost-cities-core/src/bin/lost_cities_probe.rs +++ /dev/null @@ -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 = Result>; - -#[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, -} - -#[derive(Debug, Deserialize)] -struct FixtureInput { - config: ConfigJson, - initial_deck: Vec, - steps: Vec, -} - -#[derive(Debug, Deserialize)] -struct FixtureStepInput { - action: Option, -} - -#[derive(Debug, Serialize)] -struct TraceOutput { - config: ConfigJson, - steps: Vec, -} - -#[derive(Debug, Serialize)] -struct StateTraceStep { - action: Option, - phase: &'static str, - current_player: usize, - turn_count: u32, - terminal: bool, - pending_discarded_color: Option, - score_diff_player0: i32, - legal_mask: Vec, - deck: Vec, - hands: Vec>, - expeditions: Vec>>, - discards: Vec>, -} - -#[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, - opponent_hand_size: u32, - deck_size: u32, - discards: Vec>, - my_expeditions: Vec>, - opponent_expeditions: Vec>, - legal_mask: Vec, - 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, - stale_kind: String, - end_session_counts: Vec, - 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(value: &T) -> ProbeResult<()> { - println!("{}", serde_json::to_string_pretty(value)?); - Ok(()) -} - -fn run_trace(path: &str) -> ProbeResult { - 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::>(); - 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 { - 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 { - 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 { - 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, 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 { - 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) -> 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 for Card { - fn from(value: CardJson) -> Self { - Self { - color: value.color, - rank: value.rank, - } - } -} - -impl From 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, 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, - } - } -} diff --git a/rust/lost-cities-core/src/config.rs b/rust/lost-cities-core/src/config.rs deleted file mode 100644 index 0c492a6..0000000 --- a/rust/lost-cities-core/src/config.rs +++ /dev/null @@ -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, -} - -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) -> 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 { - 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 for Config { - type Error = EngineError; - - fn try_from(value: proto::GameConfig) -> Result { - 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, - } - } -} diff --git a/rust/lost-cities-core/src/engine.rs b/rust/lost-cities-core/src/engine.rs deleted file mode 100644 index c9cfb99..0000000 --- a/rust/lost-cities-core/src/engine.rs +++ /dev/null @@ -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 { - HashMap::from([(0, self.game.total_score(0)), (1, self.game.total_score(1))]) - } - - fn build_expeditions(player: usize, game: &GameState) -> Vec { - 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 { - 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, -} - -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 { - 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 { - 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 { - 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, - default_player: usize, - ) -> Result { - observer_player - .map(Self::validate_observer) - .transpose() - .map(|observer| observer.unwrap_or(default_player)) - } - - fn validate_observer(observer: u32) -> Result { - match observer { - 0 => Ok(0), - 1 => Ok(1), - _ => Err(EngineError::invalid_argument( - "observer_player must be 0 or 1", - )), - } - } -} diff --git a/rust/lost-cities-core/src/error.rs b/rust/lost-cities-core/src/error.rs deleted file mode 100644 index 6eed7f9..0000000 --- a/rust/lost-cities-core/src/error.rs +++ /dev/null @@ -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) -> 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) -> Self { - Self::new(EngineErrorKind::AlreadyExists, message) - } - - pub(crate) fn not_found(message: impl Into) -> Self { - Self::new(EngineErrorKind::NotFound, message) - } - - pub(crate) fn failed_precondition(message: impl Into) -> Self { - Self::new(EngineErrorKind::FailedPrecondition, message) - } - - pub(crate) fn invalid_argument(message: impl Into) -> 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 {} diff --git a/rust/lost-cities-core/src/lib.rs b/rust/lost-cities-core/src/lib.rs deleted file mode 100644 index d0b8fb7..0000000 --- a/rust/lost-cities-core/src/lib.rs +++ /dev/null @@ -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}; diff --git a/rust/lost-cities-core/src/service.rs b/rust/lost-cities-core/src/service.rs deleted file mode 100644 index 380af45..0000000 --- a/rust/lost-cities-core/src/service.rs +++ /dev/null @@ -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>, -} - -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, - ) -> Result, 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, - ) -> Result, 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, - ) -> Result, 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, - ) -> Result, 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()), - } -} diff --git a/rust/lost-cities-core/src/state.rs b/rust/lost-cities-core/src/state.rs deleted file mode 100644 index 48b637d..0000000 --- a/rust/lost-cities-core/src/state.rs +++ /dev/null @@ -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, - pub hands: [Vec; 2], - pub expeditions: [Vec>; 2], - pub discards: Vec>, - pub current_player: usize, - pub phase: Phase, - pub pending_discarded_color: Option, - pub turn_count: u32, - pub terminal: bool, -} - -impl GameState { - pub fn new_game(config: Config) -> Result { - 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) -> Result { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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) -> 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 { - 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::(); - let mut score = (numeric_sum + config.expedition_penalty) * (handshakes + 1); - if expedition.len() >= config.bonus_threshold { - score += config.bonus_amount; - } - score -} diff --git a/src/coolrl_lost_cities/games/classic/backends/__init__.py b/src/coolrl_lost_cities/games/classic/backends/__init__.py index 9499102..6a70cee 100644 --- a/src/coolrl_lost_cities/games/classic/backends/__init__.py +++ b/src/coolrl_lost_cities/games/classic/backends/__init__.py @@ -2,10 +2,8 @@ from __future__ import annotations from .factory import build_backend from .python import PythonLostCitiesBackend -from .rust import RustLostCitiesBackend __all__ = [ "PythonLostCitiesBackend", - "RustLostCitiesBackend", "build_backend", ] diff --git a/src/coolrl_lost_cities/games/classic/backends/factory.py b/src/coolrl_lost_cities/games/classic/backends/factory.py index 070104e..9c6c3df 100644 --- a/src/coolrl_lost_cities/games/classic/backends/factory.py +++ b/src/coolrl_lost_cities/games/classic/backends/factory.py @@ -3,7 +3,6 @@ from __future__ import annotations from ..game import LostCitiesConfig from ..interfaces import BackendName, LostCitiesBackend from .python import PythonLostCitiesBackend -from .rust import RustLostCitiesBackend def build_backend( @@ -13,6 +12,4 @@ def build_backend( ) -> LostCitiesBackend: if backend == "python": return PythonLostCitiesBackend(config, seed) - if backend == "rust": - return RustLostCitiesBackend(config, seed) raise ValueError(f"unknown backend: {backend}") diff --git a/src/coolrl_lost_cities/games/classic/backends/rust.py b/src/coolrl_lost_cities/games/classic/backends/rust.py deleted file mode 100644 index 8a63307..0000000 --- a/src/coolrl_lost_cities/games/classic/backends/rust.py +++ /dev/null @@ -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 diff --git a/src/coolrl_lost_cities/games/classic/docs/lost_cities_spec.md b/src/coolrl_lost_cities/games/classic/docs/lost_cities_spec.md index 6ccbfd8..2b7a0f8 100644 --- a/src/coolrl_lost_cities/games/classic/docs/lost_cities_spec.md +++ b/src/coolrl_lost_cities/games/classic/docs/lost_cities_spec.md @@ -1,6 +1,6 @@ # 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은 항상 특정 플레이어 관점 @@ -185,15 +185,15 @@ gRPC 인터페이스로 플레이하는 에이전트가 알아야 할 계약. `Action.id`는 그 observation과 함께 반환된 것에 한해서만 유효하다. 상태가 한 번이라도 진행되면 이전 observation의 id는 재사용 불가. -이를 강제하기 위해 모든 observation에는 `state_version`(세션 내 단조증가)이 실리고, `ApplyAction`은 `expected_state_version`을 함께 받아 mismatch 시 거절한다. +이를 강제하려면 observation에는 `state_version` 같은 단조증가 값을 싣고, 액션 적용 시 기대 버전을 함께 확인하면 된다. ### 9.4 합법 액션은 observation에 임베드되어 옴 -별도 RPC 호출 없이 `observation.legal_actions` 안에서 전체 리스트와 마스크가 모두 제공된다. 외부 에이전트는 이 리스트에서 `id`만 골라 `ApplyAction`으로 되돌려주면 된다. +observation 안에서 전체 리스트와 마스크가 모두 제공되어야 한다. 에이전트는 이 리스트에서 `id`만 골라 다음 액션으로 되돌려주면 된다. ### 9.5 보상 -`ApplyAction`의 `reward`는 observer 관점이며, terminal 전이에서만 nonzero이고 값은 observer의 `score_diff`. 비terminal 전이의 reward는 0. +전이 보상은 observer 관점이며, terminal 전이에서만 nonzero이고 값은 observer의 `score_diff`. 비terminal 전이의 reward는 0. --- diff --git a/src/coolrl_lost_cities/games/classic/interfaces.py b/src/coolrl_lost_cities/games/classic/interfaces.py index d3618d9..3d5eb91 100644 --- a/src/coolrl_lost_cities/games/classic/interfaces.py +++ b/src/coolrl_lost_cities/games/classic/interfaces.py @@ -5,7 +5,7 @@ from typing import Literal, Protocol, TypeAlias, runtime_checkable from .game import Card, GameState, LostCitiesConfig, score_expedition -BackendName = Literal["python", "rust"] +BackendName = Literal["python"] @dataclass diff --git a/tests/games/classic/test_pygame_pvp.py b/tests/games/classic/test_pygame_pvp.py index 7c1d1c5..aef3bb2 100644 --- a/tests/games/classic/test_pygame_pvp.py +++ b/tests/games/classic/test_pygame_pvp.py @@ -30,4 +30,4 @@ def test_gui_argparser_accepts_classic_options() -> None: def test_gui_argparser_rejects_removed_backend_option() -> None: with pytest.raises(SystemExit): - pygame_pvp.build_argparser().parse_args(["--backend", "rust"]) + pygame_pvp.build_argparser().parse_args(["--backend", "native"]) diff --git a/tests/games/classic/test_rust_parity.py b/tests/games/classic/test_rust_parity.py deleted file mode 100644 index 5a75b02..0000000 --- a/tests/games/classic/test_rust_parity.py +++ /dev/null @@ -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"))