diff --git a/src/simulator/Cargo.lock b/src/simulator/Cargo.lock index e5eb5828..53e508a5 100644 --- a/src/simulator/Cargo.lock +++ b/src/simulator/Cargo.lock @@ -984,6 +984,7 @@ dependencies = [ "rayon", "serde", "serde_json", + "siphasher", ] [[package]] diff --git a/src/simulator/crates/mc-core/Cargo.toml b/src/simulator/crates/mc-core/Cargo.toml index 42a5945c..47ec109a 100644 --- a/src/simulator/crates/mc-core/Cargo.toml +++ b/src/simulator/crates/mc-core/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" serde.workspace = true serde_json.workspace = true getrandom.workspace = true +siphasher.workspace = true rayon = "1" [lints] diff --git a/src/simulator/crates/mc-core/src/lib.rs b/src/simulator/crates/mc-core/src/lib.rs index ae0a3dad..170d4822 100644 --- a/src/simulator/crates/mc-core/src/lib.rs +++ b/src/simulator/crates/mc-core/src/lib.rs @@ -18,6 +18,7 @@ pub mod perf; pub mod player; pub mod production_origin; pub mod resources; +pub mod seed; pub mod tech; pub mod units; pub mod wonder; diff --git a/src/simulator/crates/mc-core/src/seed.rs b/src/simulator/crates/mc-core/src/seed.rs new file mode 100644 index 00000000..30311e22 --- /dev/null +++ b/src/simulator/crates/mc-core/src/seed.rs @@ -0,0 +1,271 @@ +//! Deterministic seed derivation for worldgen passes (and AI rollout streams). +//! +//! Implements the Wave-A spec from +//! `public/games/age-of-dwarves/docs/terrain/WORLDGEN_RNG.md`. +//! +//! Every pass (tectonics, hydrology, climate, …) derives a sub-seed from the +//! map seed via SipHash-2-4 with a fixed key. Changing the key or the mixing +//! constant breaks all existing saves — see the canonical doc for the migration +//! procedure. +//! +//! # Why not rand_pcg? +//! `rand_pcg 0.3` requires `rand = "0.8"`. The workspace is pinned to +//! `rand = "0.9"` which is used by `mc-trade` and `mc-turn`. The two are +//! API-incompatible. This module uses an inline PCG-64 implementation +//! instead, described in `WORLDGEN_RNG.md` §2. +//! +//! # Relocation note (p0-20 Phase A v3, 2026-05-04) +//! This module was previously `mc-mapgen::seed`. It moved to `mc-core` so the +//! AI rollout layer (`mc-ai`, which does not depend on `mc-mapgen`) can share +//! the same `SeedDomain` registry and SipHash mixer. The byte-equivalent +//! relocation is verified by `mc-mapgen/tests/cross_build_determinism.rs`: +//! the existing `DERIVE_GOLDEN` table must continue to pass byte-for-byte. +//! The `AiRollout` variant was appended **at the end of the enum** so existing +//! discriminant ordinals stay frozen. + +use siphasher::sip::SipHasher13; +use std::hash::{Hash, Hasher}; + +/// Fixed SipHash-2-4 key — part of the save format; NEVER change. +/// Changing these constants silently breaks all existing saved maps. +const SIPHASH_KEY: (u64, u64) = (0x517C_C1B7_2722_0A95, 0xDB2B_9B8A_4C31_338A); + +/// Worldgen sub-seed domains. Each pass gets its own isolated RNG stream. +/// +/// New worldgen passes MUST add a new variant. Never reuse an existing +/// discriminant — doing so would produce the same sub-seeds as the old pass +/// and break any save that relied on the old domain's output. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SeedDomain { + /// Plate generation and boundary classification. + Tectonics = 0, + /// Hydraulic erosion pre-pass. + Erosion = 1, + /// River routing and lake fill. + Hydrology = 2, + /// Climate pass (BFS continentality; reserved for future stochastic steps). + Climate = 3, + /// Per-tile flora species selection. + FloraSelect = 4, + /// Per-tile fauna species selection. + FaunaSelect = 5, + /// Per-step ambient encounter rolls during unit movement (p2-58). + /// Mixed with (turn, unit_id, step_idx) at the call site via [`derive_step`]. + Encounter = 6, + /// AI MCTS rollout RNG seeding (p0-20 Phase A v3). + /// Mixed with (turn, player_idx) via [`derive_step`] to give every + /// (turn, player) pair its own deterministic stream — same input always + /// yields the same SplitMix64 starting state in `AbstractPlayerState.rng_state`. + /// Appended at the end so existing variant ordinals stay frozen. + AiRollout = 7, +} + +/// Derive a deterministic per-step seed for ambient encounter rolls. +/// +/// Designed for `(turn, unit_id, step_idx)` mixing on top of a base +/// `SeedDomain::Encounter` sub-seed: replaying the same turn for the same +/// unit produces an identical RNG stream. The mixing strategy is the same +/// splitmix64 chain used by the Wave-A `derive`, applied iteratively. +/// +/// `mix_inputs` is a small slice of u64 inputs to fold into the seed; for the +/// `Encounter` domain the convention is `[turn, unit_id, step_idx]`. +#[must_use] +pub fn derive_step(map_seed: u64, domain: SeedDomain, mix_inputs: &[u64]) -> u64 { + let mut h = SipHasher13::new_with_keys(SIPHASH_KEY.0, SIPHASH_KEY.1); + let base = map_seed + .wrapping_mul(0x9E37_79B9_7F4A_7C15) + .wrapping_add(domain as u64); + base.hash(&mut h); + for &v in mix_inputs { + // splitmix64 step before each fold so reordering produces distinct seeds. + v.wrapping_mul(0x9E37_79B9_7F4A_7C15).hash(&mut h); + } + h.finish() +} + +/// Derive a deterministic sub-seed for `domain` from `map_seed`. +/// +/// Uses SipHash-2-4 with [`SIPHASH_KEY`] to mix the map seed and domain +/// discriminant. The splitmix64 pre-mix (`0x9E37_79B9_7F4A_7C15`) improves +/// avalanche before hashing. +/// +/// The output is stable: same (map_seed, domain) always produces the same u64 +/// regardless of platform, Rust version, or crate version, as long as +/// `SIPHASH_KEY` and the multiplier constant are unchanged. +pub fn derive(map_seed: u64, domain: SeedDomain) -> u64 { + let input = map_seed + .wrapping_mul(0x9E37_79B9_7F4A_7C15) + .wrapping_add(domain as u64); + let mut h = SipHasher13::new_with_keys(SIPHASH_KEY.0, SIPHASH_KEY.1); + input.hash(&mut h); + h.finish() +} + +/// Construct a per-tile RNG seeded from `domain_seed` and hex coordinates. +/// +/// Using independent per-tile seeds means tile output is invariant under +/// changes to the order in which tiles are processed. +pub fn tile_rng(domain_seed: u64, col: u32, row: u32) -> Pcg64 { + const COL_HASH: u64 = 0x6C62_272E_07BB_0142; + const ROW_HASH: u64 = 0x94D0_49BB_1331_11EB; + let tile_seed = domain_seed + .wrapping_add((col as u64).wrapping_mul(COL_HASH)) + .wrapping_add((row as u64).wrapping_mul(ROW_HASH)); + Pcg64::seed(tile_seed) +} + +/// Inline PCG-64 PRNG. +/// +/// Avoids `rand_pcg = "0.3"` which requires `rand = "0.8"` (workspace uses +/// 0.9). Algorithm follows O'Neill's PCG paper. Output is stable: same seed +/// always produces the same sequence. +pub struct Pcg64 { + state: u128, + inc: u128, +} + +impl Pcg64 { + /// PCG-64 multiplier from O'Neill 2014, §6.3.1. + const MULTIPLIER: u128 = + (6_364_136_223_846_793_005_u128) | ((1_442_695_040_888_963_407_u128) << 64); + + /// Seed from a single u64. The increment is derived from the seed so that + /// different seeds use different streams. + pub fn seed(s: u64) -> Self { + let inc = ((s as u128) << 1) | 1; + let mut rng = Self { state: 0, inc }; + rng.state = rng.state.wrapping_add(inc); + rng.advance(); + rng + } + + fn advance(&mut self) { + self.state = self.state + .wrapping_mul(Self::MULTIPLIER) + .wrapping_add(self.inc); + } + + /// Draw the next u64. + pub fn next_u64(&mut self) -> u64 { + let old = self.state; + self.advance(); + let count = (old >> 122) as u32; + let xsl = ((old >> 64) as u64) ^ (old as u64); + xsl.rotate_right(count) + } + + /// Draw a float in `[0, 1)`. + pub fn next_f32(&mut self) -> f32 { + (self.next_u64() >> 40) as f32 * (1.0_f32 / (1_u64 << 24) as f32) + } + + /// Draw a u32 in `[lo, hi]` inclusive. + pub fn next_u32_range(&mut self, lo: u32, hi: u32) -> u32 { + let range = (hi - lo + 1) as u64; + lo + (self.next_u64() % range) as u32 + } + + /// Draw a bool with probability `p` (0.0–1.0). + pub fn next_bool_p(&mut self, p: f32) -> bool { + self.next_f32() < p + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derive_stability() { + // Frozen expected values — if these change, bump CURRENT_DERIVE_VERSION in the + // save format and update RNG.md with a migration note. + let cases: &[(u64, SeedDomain, u64)] = &[ + (0, SeedDomain::Tectonics, derive(0, SeedDomain::Tectonics)), + (42, SeedDomain::Tectonics, derive(42, SeedDomain::Tectonics)), + (0xDEAD_BEEF, SeedDomain::Tectonics, derive(0xDEAD_BEEF, SeedDomain::Tectonics)), + (u64::MAX, SeedDomain::FloraSelect, derive(u64::MAX, SeedDomain::FloraSelect)), + (12345678901234, SeedDomain::Climate, derive(12345678901234, SeedDomain::Climate)), + ]; + // First run generates the expected values; subsequent runs assert stability. + // Values are frozen by running `cargo test -- --nocapture` once and recording output. + for &(seed, domain, expected) in cases { + assert_eq!(derive(seed, domain), expected, + "derive({seed}, {domain:?}) changed — this breaks save compatibility"); + } + } + + #[test] + fn tile_rng_stability() { + let domain_seed = derive(42, SeedDomain::Tectonics); + let mut rng = tile_rng(domain_seed, 5, 3); + let v = rng.next_u64(); + // Frozen: same value on every run. + assert_eq!(v, tile_rng(domain_seed, 5, 3).next_u64(), + "tile_rng output changed — this breaks per-tile determinism"); + } + + #[test] + fn domains_produce_distinct_seeds() { + let seed = 42u64; + let t = derive(seed, SeedDomain::Tectonics); + let c = derive(seed, SeedDomain::Climate); + let f = derive(seed, SeedDomain::FloraSelect); + assert_ne!(t, c); + assert_ne!(t, f); + assert_ne!(c, f); + } + + #[test] + fn pcg64_range_stays_in_bounds() { + let mut rng = Pcg64::seed(99); + for _ in 0..1000 { + let v = rng.next_u32_range(3, 7); + assert!((3..=7).contains(&v)); + } + } + + // ── AiRollout domain (p0-20 Phase A v3) ────────────────────────────────── + + #[test] + fn ai_rollout_ordinal_is_seven() { + // The AiRollout variant was appended at the end of the enum so the + // existing worldgen ordinals (Tectonics=0..Encounter=6) stay frozen + // and worldgen golden tests in mc-mapgen continue to pass byte-for-byte. + assert_eq!(SeedDomain::AiRollout as u64, 7); + } + + #[test] + fn ai_rollout_domain_is_distinct_from_worldgen_domains() { + let s = 42u64; + let ai = derive(s, SeedDomain::AiRollout); + let worldgen = [ + SeedDomain::Tectonics, + SeedDomain::Erosion, + SeedDomain::Hydrology, + SeedDomain::Climate, + SeedDomain::FloraSelect, + SeedDomain::FaunaSelect, + SeedDomain::Encounter, + ]; + for d in worldgen { + assert_ne!( + ai, derive(s, d), + "AiRollout domain must be distinct from worldgen domain {d:?}" + ); + } + } + + #[test] + fn ai_rollout_derive_step_is_stable_per_turn_player() { + // Same (turn, player_idx) must always produce the same starting state. + let s = 0xCAFE_F00Du64; + let a = derive_step(s, SeedDomain::AiRollout, &[7, 2]); + let b = derive_step(s, SeedDomain::AiRollout, &[7, 2]); + assert_eq!(a, b, "AiRollout derive_step must be deterministic"); + // Different (turn, player_idx) must produce different streams. + let c = derive_step(s, SeedDomain::AiRollout, &[7, 3]); + let d = derive_step(s, SeedDomain::AiRollout, &[8, 2]); + assert_ne!(a, c); + assert_ne!(a, d); + } +} diff --git a/src/simulator/crates/mc-mapgen/src/seed.rs b/src/simulator/crates/mc-mapgen/src/seed.rs index 7fa864b7..fe5ae7a7 100644 --- a/src/simulator/crates/mc-mapgen/src/seed.rs +++ b/src/simulator/crates/mc-mapgen/src/seed.rs @@ -1,211 +1,13 @@ -//! Deterministic seed derivation for worldgen passes. +//! Compatibility re-export. //! -//! Implements the Wave-A spec from -//! `public/games/age-of-dwarves/docs/terrain/WORLDGEN_RNG.md`. +//! The seed module relocated from `mc-mapgen` to `mc-core` in p0-20 Phase A v3 +//! so the AI rollout layer (`mc-ai`, no `mc-mapgen` dependency) can share the +//! same `SeedDomain` registry and SipHash mixer. The byte-equivalent relocation +//! is verified by `tests/cross_build_determinism.rs` — `DERIVE_GOLDEN` continues +//! to pass byte-for-byte. //! -//! Every pass (tectonics, hydrology, climate, …) derives a sub-seed from the -//! map seed via SipHash-2-4 with a fixed key. Changing the key or the mixing -//! constant breaks all existing saves — see the canonical doc for the migration -//! procedure. -//! -//! # Why not rand_pcg? -//! `rand_pcg 0.3` requires `rand = "0.8"`. The workspace is pinned to -//! `rand = "0.9"` which is used by `mc-trade` and `mc-turn`. The two are -//! API-incompatible. This module uses an inline PCG-64 implementation -//! instead, described in `WORLDGEN_RNG.md` §2. +//! Internal call sites (`hydrology.rs`, `erosion.rs`, `tectonics.rs`) keep +//! using `crate::seed::{derive, tile_rng, SeedDomain, ...}` unchanged via this +//! re-export. External callers may now import from either path. -use siphasher::sip::SipHasher13; -use std::hash::{Hash, Hasher}; - -/// Fixed SipHash-2-4 key — part of the save format; NEVER change. -/// Changing these constants silently breaks all existing saved maps. -const SIPHASH_KEY: (u64, u64) = (0x517C_C1B7_2722_0A95, 0xDB2B_9B8A_4C31_338A); - -/// Worldgen sub-seed domains. Each pass gets its own isolated RNG stream. -/// -/// New worldgen passes MUST add a new variant. Never reuse an existing -/// discriminant — doing so would produce the same sub-seeds as the old pass -/// and break any save that relied on the old domain's output. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum SeedDomain { - /// Plate generation and boundary classification. - Tectonics = 0, - /// Hydraulic erosion pre-pass. - Erosion = 1, - /// River routing and lake fill. - Hydrology = 2, - /// Climate pass (BFS continentality; reserved for future stochastic steps). - Climate = 3, - /// Per-tile flora species selection. - FloraSelect = 4, - /// Per-tile fauna species selection. - FaunaSelect = 5, - /// Per-step ambient encounter rolls during unit movement (p2-58). - /// Mixed with (turn, unit_id, step_idx) at the call site via [`derive_step`]. - Encounter = 6, -} - -/// Derive a deterministic per-step seed for ambient encounter rolls. -/// -/// Designed for `(turn, unit_id, step_idx)` mixing on top of a base -/// `SeedDomain::Encounter` sub-seed: replaying the same turn for the same -/// unit produces an identical RNG stream. The mixing strategy is the same -/// splitmix64 chain used by the Wave-A `derive`, applied iteratively. -/// -/// `mix_inputs` is a small slice of u64 inputs to fold into the seed; for the -/// `Encounter` domain the convention is `[turn, unit_id, step_idx]`. -#[must_use] -pub fn derive_step(map_seed: u64, domain: SeedDomain, mix_inputs: &[u64]) -> u64 { - let mut h = SipHasher13::new_with_keys(SIPHASH_KEY.0, SIPHASH_KEY.1); - let base = map_seed - .wrapping_mul(0x9E37_79B9_7F4A_7C15) - .wrapping_add(domain as u64); - base.hash(&mut h); - for &v in mix_inputs { - // splitmix64 step before each fold so reordering produces distinct seeds. - v.wrapping_mul(0x9E37_79B9_7F4A_7C15).hash(&mut h); - } - h.finish() -} - -/// Derive a deterministic sub-seed for `domain` from `map_seed`. -/// -/// Uses SipHash-2-4 with [`SIPHASH_KEY`] to mix the map seed and domain -/// discriminant. The splitmix64 pre-mix (`0x9E37_79B9_7F4A_7C15`) improves -/// avalanche before hashing. -/// -/// The output is stable: same (map_seed, domain) always produces the same u64 -/// regardless of platform, Rust version, or crate version, as long as -/// `SIPHASH_KEY` and the multiplier constant are unchanged. -pub fn derive(map_seed: u64, domain: SeedDomain) -> u64 { - let input = map_seed - .wrapping_mul(0x9E37_79B9_7F4A_7C15) - .wrapping_add(domain as u64); - let mut h = SipHasher13::new_with_keys(SIPHASH_KEY.0, SIPHASH_KEY.1); - input.hash(&mut h); - h.finish() -} - -/// Construct a per-tile RNG seeded from `domain_seed` and hex coordinates. -/// -/// Using independent per-tile seeds means tile output is invariant under -/// changes to the order in which tiles are processed. -pub fn tile_rng(domain_seed: u64, col: u32, row: u32) -> Pcg64 { - const COL_HASH: u64 = 0x6C62_272E_07BB_0142; - const ROW_HASH: u64 = 0x94D0_49BB_1331_11EB; - let tile_seed = domain_seed - .wrapping_add((col as u64).wrapping_mul(COL_HASH)) - .wrapping_add((row as u64).wrapping_mul(ROW_HASH)); - Pcg64::seed(tile_seed) -} - -/// Inline PCG-64 PRNG. -/// -/// Avoids `rand_pcg = "0.3"` which requires `rand = "0.8"` (workspace uses -/// 0.9). Algorithm follows O'Neill's PCG paper. Output is stable: same seed -/// always produces the same sequence. -pub struct Pcg64 { - state: u128, - inc: u128, -} - -impl Pcg64 { - /// PCG-64 multiplier from O'Neill 2014, §6.3.1. - const MULTIPLIER: u128 = - (6_364_136_223_846_793_005_u128) | ((1_442_695_040_888_963_407_u128) << 64); - - /// Seed from a single u64. The increment is derived from the seed so that - /// different seeds use different streams. - pub fn seed(s: u64) -> Self { - let inc = ((s as u128) << 1) | 1; - let mut rng = Self { state: 0, inc }; - rng.state = rng.state.wrapping_add(inc); - rng.advance(); - rng - } - - fn advance(&mut self) { - self.state = self.state - .wrapping_mul(Self::MULTIPLIER) - .wrapping_add(self.inc); - } - - /// Draw the next u64. - pub fn next_u64(&mut self) -> u64 { - let old = self.state; - self.advance(); - let count = (old >> 122) as u32; - let xsl = ((old >> 64) as u64) ^ (old as u64); - xsl.rotate_right(count) - } - - /// Draw a float in `[0, 1)`. - pub fn next_f32(&mut self) -> f32 { - (self.next_u64() >> 40) as f32 * (1.0_f32 / (1_u64 << 24) as f32) - } - - /// Draw a u32 in `[lo, hi]` inclusive. - pub fn next_u32_range(&mut self, lo: u32, hi: u32) -> u32 { - let range = (hi - lo + 1) as u64; - lo + (self.next_u64() % range) as u32 - } - - /// Draw a bool with probability `p` (0.0–1.0). - pub fn next_bool_p(&mut self, p: f32) -> bool { - self.next_f32() < p - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn derive_stability() { - // Frozen expected values — if these change, bump CURRENT_DERIVE_VERSION in the - // save format and update RNG.md with a migration note. - let cases: &[(u64, SeedDomain, u64)] = &[ - (0, SeedDomain::Tectonics, derive(0, SeedDomain::Tectonics)), - (42, SeedDomain::Tectonics, derive(42, SeedDomain::Tectonics)), - (0xDEAD_BEEF, SeedDomain::Tectonics, derive(0xDEAD_BEEF, SeedDomain::Tectonics)), - (u64::MAX, SeedDomain::FloraSelect, derive(u64::MAX, SeedDomain::FloraSelect)), - (12345678901234, SeedDomain::Climate, derive(12345678901234, SeedDomain::Climate)), - ]; - // First run generates the expected values; subsequent runs assert stability. - // Values are frozen by running `cargo test -- --nocapture` once and recording output. - for &(seed, domain, expected) in cases { - assert_eq!(derive(seed, domain), expected, - "derive({seed}, {domain:?}) changed — this breaks save compatibility"); - } - } - - #[test] - fn tile_rng_stability() { - let domain_seed = derive(42, SeedDomain::Tectonics); - let mut rng = tile_rng(domain_seed, 5, 3); - let v = rng.next_u64(); - // Frozen: same value on every run. - assert_eq!(v, tile_rng(domain_seed, 5, 3).next_u64(), - "tile_rng output changed — this breaks per-tile determinism"); - } - - #[test] - fn domains_produce_distinct_seeds() { - let seed = 42u64; - let t = derive(seed, SeedDomain::Tectonics); - let c = derive(seed, SeedDomain::Climate); - let f = derive(seed, SeedDomain::FloraSelect); - assert_ne!(t, c); - assert_ne!(t, f); - assert_ne!(c, f); - } - - #[test] - fn pcg64_range_stays_in_bounds() { - let mut rng = Pcg64::seed(99); - for _ in 0..1000 { - let v = rng.next_u32_range(3, 7); - assert!((3..=7).contains(&v)); - } - } -} +pub use mc_core::seed::*; diff --git a/src/simulator/crates/mc-turn/Cargo.toml b/src/simulator/crates/mc-turn/Cargo.toml index 687cafef..e9da2c57 100644 --- a/src/simulator/crates/mc-turn/Cargo.toml +++ b/src/simulator/crates/mc-turn/Cargo.toml @@ -25,6 +25,9 @@ bytemuck = { version = "1", features = ["derive"], optional = true } proptest = "1" mc-happiness = { path = "../mc-happiness" } rand.workspace = true +# Used by tests/abstract_projection.rs to read raw bytes of the POD +# returned by `to_abstract_rollout_state` for byte-identical assertions. +bytemuck = { version = "1", features = ["derive"] } [lints] workspace = true diff --git a/src/simulator/crates/mc-turn/src/abstract_projection.rs b/src/simulator/crates/mc-turn/src/abstract_projection.rs new file mode 100644 index 00000000..da0dbb59 --- /dev/null +++ b/src/simulator/crates/mc-turn/src/abstract_projection.rs @@ -0,0 +1,389 @@ +//! GameState → AbstractRolloutState projection (p0-20 Phase A v3). +//! +//! Builds the GPU-uploadable, fixed-size 256-byte +//! [`mc_ai::abstract_state::AbstractRolloutState`] POD from a live +//! [`crate::game_state::GameState`]. The POD is the contract between the CPU +//! reference rollout (`mc-ai::gpu::cpu_reference`) and the WGSL shader +//! (`rollout.wgsl`); the projection feeds both paths through one entry point. +//! +//! # Decisions (locked, see p0-20 Phase A v3 brief) +//! - Lives in `mc-turn` because it needs the full live `GameState` shape. +//! `mc-ai` cannot host this projection — it would create a +//! `mc-ai → mc-turn` cycle (mc-turn already depends on mc-ai). +//! - Does **not** add `serde` to `AbstractRolloutState`. The IPC path uses a +//! parallel `AbstractJobState` mirror in `mc-mcts-service::protocol` which +//! round-trips through this POD via field-by-field copy. +//! - Per-player RNG state is seeded from `mc_core::seed::derive_step` with +//! domain `SeedDomain::AiRollout` and mix `[turn, player_idx]`, giving every +//! `(turn, player)` pair its own deterministic stream. +//! - The brief's `unit_id_to_tier` table is inline-hardcoded for Phase A; +//! promotion to a UnitWeb-driven table is deferred to Phase D when the +//! service runner lands. + +use std::collections::BTreeMap; + +use mc_ai::abstract_state::{AbstractPlayerState, AbstractRolloutState, MAX_PLAYERS}; +use mc_ai::game_state::axes_to_flat; +use mc_core::formation::Formation; +use mc_core::seed::{derive_step, SeedDomain}; + +use crate::game_state::{GameState, MapUnit, PlayerState, TechState}; + +/// Project a live [`GameState`] into a GPU-uploadable +/// [`AbstractRolloutState`]. +/// +/// Player slots are filled in `state.players` order, capped at +/// [`MAX_PLAYERS`] (= 4). Excess players are dropped — Game 1 ships 4-player +/// max so this is the documented overflow behaviour. Missing slots stay +/// zero-initialised (the POD's `Zeroable` default). +/// +/// Determinism: same `GameState` → byte-identical POD. The only RNG-touching +/// field is `rng_state`, derived via `derive_step(SeedDomain::AiRollout, …)`. +#[must_use] +pub fn to_abstract_rollout_state(state: &GameState) -> AbstractRolloutState { + let mut out = AbstractRolloutState::zeroed(); + let total_techs = total_known_techs(state); + + let n = state.players.len().min(MAX_PLAYERS); + for player_idx in 0..n { + let ps = &state.players[player_idx]; + out.players[player_idx] = project_player(state, ps, player_idx, total_techs); + } + + out +} + +/// Project a single [`PlayerState`] into the per-player POD slot. +/// +/// Pure function — every caller-visible field is derived from `state`, `ps`, +/// `player_idx`, and `total_techs`. No RNG, no globals. +fn project_player( + state: &GameState, + ps: &PlayerState, + player_idx: usize, + total_techs: usize, +) -> AbstractPlayerState { + let pop_total: u32 = ps + .cities + .iter() + .map(|c| c.population as u32) + .sum(); + + let unit_counts = count_units_at_tier(&ps.units); + let formation_count = count_active_formations(state, player_idx as u8); + let formation_strength = compute_formation_strength_per_tier(state, player_idx as u8); + let force_rel = compute_force_rel(state, player_idx); + let relations = project_relations(state, player_idx); + + // strategic_axes is BTreeMap on PlayerState; axes_to_flat + // wants HashMap. Cheap conversion — there are only ~4 keys. + let axes_hash: std::collections::HashMap = + ps.strategic_axes.iter().map(|(k, v)| (k.clone(), *v)).collect(); + let axes = axes_to_flat(&axes_hash); + + let rng_state = + derive_step(state.turn as u64, SeedDomain::AiRollout, &[player_idx as u64]); + + AbstractPlayerState { + gold: ps.gold, + // PlayerState carries `science_pool: i64`. Saturate to i32 — pool is + // always non-negative and well below i32::MAX in any plausible bench. + science: ps.science_pool.clamp(i32::MIN as i64, i32::MAX as i64) as i32, + pop_total, + city_count: ps.cities.len().min(u16::MAX as usize) as u16, + tech_index: tech_index(ps.tech_state.as_ref(), total_techs), + unit_counts, + formation_count, + _pad_uc: 0, + // PlayerState has no aggregate `happiness_pool`; per-city happiness + // lives elsewhere. The POD slot stays zero until p1-30 wires it. + happiness_pool: 0, + _pad0: 0, + force_rel, + axes, + relations, + formation_strength, + rng_state, + turn: state.turn, + _pad2: [0; 4], + } +} + +/// Total number of distinct researched-or-known techs across the empire. +/// Used as the denominator for `tech_index`. Falls back to a sentinel of +/// `100` (so tech_index renders as a percent against a notional full tree) +/// when no `TechState` is populated, matching the bench harness's "no +/// research simulated" convention. +fn total_known_techs(state: &GameState) -> usize { + // Conservative: max progress count seen across players. The full tech + // tree is loaded in `player_tech: PlayerTechState` but not all benches + // populate it; using max(researched ∪ in-progress) keeps the projection + // well-defined for both the live game and the bench harness. + let mut max = 0; + for ps in &state.players { + if let Some(t) = ps.tech_state.as_ref() { + let total = t.researched.len() + t.progress.len(); + if total > max { + max = total; + } + } + } + max.max(1) +} + +/// Compute `tech_index` ∈ `[0, 100]` for the player. +/// `ceil(researched_count / total_known_techs * 100)`, clamped. +fn tech_index(tech_state: Option<&TechState>, total: usize) -> u16 { + let researched = tech_state.map(|t| t.researched.len()).unwrap_or(0); + if total == 0 { + return 0; + } + let frac = researched as f64 / total as f64; + let pct = (frac * 100.0).ceil() as i64; + pct.clamp(0, 100) as u16 +} + +/// T1 / T2 unit counts via inline `unit_id_to_tier` mapping. +/// Tiers ≥3 are not represented in `unit_counts` (only 2 slots); they +/// influence `formation_strength` instead. Counts saturate to `u8::MAX`. +fn count_units_at_tier(units: &[MapUnit]) -> [u8; 2] { + let mut t1 = 0u32; + let mut t2 = 0u32; + for u in units { + match unit_id_to_tier(&u.unit_id) { + 1 => t1 += 1, + 2 => t2 += 1, + _ => {} + } + } + [ + t1.min(u8::MAX as u32) as u8, + t2.min(u8::MAX as u32) as u8, + ] +} + +/// Number of formations owned by `player_idx`. Saturates to `u8::MAX`. +fn count_active_formations(state: &GameState, player_idx: u8) -> u8 { + let n = state + .formations + .values() + .filter(|f| f.owner == player_idx) + .count(); + n.min(u8::MAX as usize) as u8 +} + +/// Mean strength per formation tier bucket (T1..T4), scaled to `[0, 255]`. +/// +/// Strength of one formation = sum of member-unit `attack` stats. Tier +/// bucket = leader unit's tier (clamped 1..=4). Empty tier buckets emit 0. +/// +/// Uses `BTreeMap` for the per-tier accumulator so iteration order is +/// deterministic — required by the rail "BTreeMap for any aggregation". +fn compute_formation_strength_per_tier(state: &GameState, player_idx: u8) -> [u8; 4] { + // Build a unit-id → MapUnit lookup once per player. Linear scan into + // BTreeMap so ordering is stable and the projection is a pure function. + let mut unit_lookup: BTreeMap = BTreeMap::new(); + if let Some(ps) = state.players.get(player_idx as usize) { + for u in &ps.units { + unit_lookup.insert(u.id, u); + } + } + + let mut per_tier_totals: BTreeMap = BTreeMap::new(); + for f in state.formations.values().filter(|f| f.owner == player_idx) { + let tier = formation_tier(f, &unit_lookup); + if !(1..=4).contains(&tier) { + continue; + } + let strength: u64 = f + .unit_ids + .iter() + .filter_map(|uid| unit_lookup.get(uid)) + .map(|u| u.attack.max(0) as u64) + .sum(); + let entry = per_tier_totals.entry(tier).or_insert((0, 0)); + entry.0 += strength; + entry.1 += 1; + } + + let mut out = [0u8; 4]; + for (tier, (sum, count)) in per_tier_totals { + if count == 0 { + continue; + } + let mean = sum / count as u64; + // Scale to 0..=255. Strength values rarely exceed ~200 in Game 1 + // unit data; cap at 255. + out[(tier - 1) as usize] = mean.min(255) as u8; + } + out +} + +/// Tier bucket for a formation. Falls back to T1 when the leader is missing +/// from the lookup (defensive: should never happen for live formations). +fn formation_tier(f: &Formation, unit_lookup: &BTreeMap) -> u8 { + unit_lookup + .get(&f.leader_id) + .map(|u| unit_id_to_tier(&u.unit_id)) + .unwrap_or(1) + .clamp(1, 4) +} + +/// Per-opponent relative force ratio scaled to `[0, u16::MAX]`. +/// +/// `force_rel[opp] = (own_units + 1) / (opp_units + 1)`, clamped to `[0, 1]` +/// then scaled to a u16. Self-slot is 0. Slots beyond `state.players.len()` +/// are 0. +fn compute_force_rel(state: &GameState, player_idx: usize) -> [u16; 4] { + let mut out = [0u16; 4]; + let own = state + .players + .get(player_idx) + .map(|p| p.units.len()) + .unwrap_or(0) as u32 + + 1; + for opp in 0..MAX_PLAYERS { + if opp == player_idx { + continue; + } + let opp_units = state + .players + .get(opp) + .map(|p| p.units.len()) + .unwrap_or(0) as u32 + + 1; + // ratio in [0, 1]: own / (own + opp_units) keeps the value bounded + // and monotonically increasing in own / decreasing in opp_units. + let ratio = own as f32 / (own + opp_units) as f32; + let scaled = (ratio.clamp(0.0, 1.0) * u16::MAX as f32) as u32; + out[opp] = scaled.min(u16::MAX as u32) as u16; + } + out +} + +/// Project diplomatic relations from the canonical-pair `BTreeMap` into +/// the POD's per-opponent `[i8; 4]`. +/// +/// Convention: `<0` war, `0` peace/neutral, `>0` friendly. Reads from +/// `state.players[0].relations` per the GameState contract — only player 0 +/// carries the authoritative copy, synced each turn by `process_trade_phase` +/// (see `game_state.rs:456-459`). +fn project_relations(state: &GameState, player_idx: usize) -> [i8; 4] { + let mut out = [0i8; 4]; + let Some(p0) = state.players.first() else { + return out; + }; + let me = player_idx as u8; + for opp in 0..MAX_PLAYERS { + if opp == player_idx { + continue; + } + let opp_u = opp as u8; + let key = if me < opp_u { (me, opp_u) } else { (opp_u, me) }; + if let Some(rs) = p0.relations.get(&key) { + out[opp] = relation_to_i8(rs); + } + } + out +} + +/// Encode a [`mc_trade::relation::RelationState`] into the POD's `i8` slot. +fn relation_to_i8(rs: &mc_trade::relation::RelationState) -> i8 { + use mc_trade::relation::Relation; + match rs.relation { + Relation::War => -1, + Relation::Neutral | Relation::Peace => 0, + Relation::Friendly => 1, + } +} + +/// Inline `unit_id` → tier table for Phase A. +/// +/// Matches `public/resources/units/dwarf_*.json` `tier` fields verbatim. A +/// generic fallback for non-dwarf units returns tier `1` so the projection +/// stays well-defined under encounter-spawned wild creatures. +/// +/// **Phase D promotion path**: replace this match with a UnitWeb lookup +/// (`mc_core::ids::UnitId` → tier). Keeping it inline for Phase A means the +/// projection compiles without touching the data-loader path. +fn unit_id_to_tier(unit_id: &str) -> u8 { + match unit_id { + // T1 — basic dwarves and civilians + "dwarf_warrior" + | "dwarf_axeman" + | "dwarf_spearman" + | "dwarf_crossbowman" + | "dwarf_scout" + | "dwarf_engineer" + | "dwarf_smith" + | "dwarf_woodcutter" + | "dwarf_prospector" + | "dwarf_wanderer" + | "dwarf_tribe" => 1, + + // T2 — promoted line, founders, naval starters + "dwarf_berserker" + | "dwarf_arbalest" + | "dwarf_iron_vanguard" + | "dwarf_deep_guard" + | "dwarf_deep_scout" + | "dwarf_high_engineer" + | "dwarf_high_sapper" + | "dwarf_master_woodcutter" + | "dwarf_sapper" + | "dwarf_founder" + | "dwarf_catapult" + | "dwarf_gyrocopter" + | "dwarf_war_galley" + | "dwarf_river_galley" + | "dwarf_armored_barge" => 2, + + // T3 — siege, advanced melee/ranged, advanced naval/air + "dwarf_ballista" + | "dwarf_bulwark" + | "dwarf_thunderer" + | "dwarf_repeating_arbalest" + | "dwarf_graven_warrior" + | "dwarf_ironwarden" + | "dwarf_grand_engineer" + | "dwarf_grand_sapper" + | "dwarf_grand_scout" + | "dwarf_high_smith" + | "dwarf_steam_golem" + | "dwarf_steam_bomber" + | "dwarf_iron_hawk" + | "dwarf_steam_corvette" + | "dwarf_deep_frigate" + | "dwarf_flak_battery" => 3, + + // T4+ collapse to bucket 4 — formation_strength only has 4 buckets. + "dwarf_bombard" + | "dwarf_carrier" + | "dwarf_destroyer" + | "dwarf_dreadnought" + | "dwarf_grand_smith" + | "dwarf_hammerguard" + | "dwarf_iron_submarine" + | "dwarf_mithril_hawk" + | "dwarf_mithril_vanguard" + | "dwarf_steam_cannon" + | "dwarf_steam_warship" + | "dwarf_thunder_arbalest" + | "dwarf_war_zeppelin" + | "dwarf_adamantine_champion" + | "dwarf_ascendant_engineer" + | "dwarf_ascendant_sapper" + | "dwarf_ascendant_scout" + | "dwarf_mithril_cruiser" + | "dwarf_silent_runner" + | "dwarf_ascendant_smith" + | "dwarf_forge_colossus" + | "dwarf_fortress_ship" + | "dwarf_sky_fortress" => 4, + + // Wild creatures, freepeople, unknown — treated as tier 1 for + // projection purposes. They never appear in player formations so the + // tier-bucket choice is mostly cosmetic. + _ => 1, + } +} diff --git a/src/simulator/crates/mc-turn/src/lib.rs b/src/simulator/crates/mc-turn/src/lib.rs index 0b75386e..97ac4d9c 100644 --- a/src/simulator/crates/mc-turn/src/lib.rs +++ b/src/simulator/crates/mc-turn/src/lib.rs @@ -17,6 +17,7 @@ //! diplomacy, events) lives in the Godot engine / mc-sim GameRunner extensions. //! This crate keeps the headless bench happy and nothing more. +pub mod abstract_projection; pub mod action; pub mod action_handlers; pub mod capture; diff --git a/src/simulator/crates/mc-turn/tests/abstract_projection.rs b/src/simulator/crates/mc-turn/tests/abstract_projection.rs new file mode 100644 index 00000000..a6297136 --- /dev/null +++ b/src/simulator/crates/mc-turn/tests/abstract_projection.rs @@ -0,0 +1,294 @@ +//! Tests for `mc_turn::abstract_projection::to_abstract_rollout_state` +//! (p0-20 Phase A v3). +//! +//! Six cases cover: +//! 1. Empty GameState → all-zero POD. +//! 2. Single-player projection — gold / pop / unit_counts / tech_index. +//! 3. Four-player projection — every slot populated, force_rel mirrors +//! relative unit counts, relations come from player 0's authoritative copy. +//! 4. Five-player overflow — only the first 4 slots are filled. +//! 5. Determinism — same input ⇒ byte-identical POD across two calls. +//! 6. Round-trip stable — projecting twice with no mutation in between +//! produces equal `bytemuck` byte representations. + +use mc_ai::abstract_state::MAX_PLAYERS; +use mc_city::CityState; +use mc_core::formation::{Formation, FormationCommand, FormationShape}; +use mc_trade::relation::{Relation, RelationState}; +use mc_turn::abstract_projection::to_abstract_rollout_state; +use mc_turn::game_state::{GameState, MapUnit, PlayerState, TechState}; + +// ── Test fixture helpers ──────────────────────────────────────────────────── + +/// Build a minimal `MapUnit` at (0, 0) with `attack=10`, `hp=max_hp=10`. +fn unit(id: u32, unit_id: &str, attack: i32) -> MapUnit { + MapUnit { + id, + col: 0, + row: 0, + hp: 10, + max_hp: 10, + attack, + defense: 5, + is_fortified: false, + is_sentrying: false, + unit_id: unit_id.to_string(), + ..Default::default() + } +} + +/// Build a minimal `PlayerState` slot-`idx`. +fn player(idx: u8) -> PlayerState { + PlayerState { + player_index: idx, + ..Default::default() + } +} + +/// Build a minimal city with `population` people. +fn city(population: u32) -> CityState { + CityState { + population, + ..Default::default() + } +} + +/// Build a `Formation` owned by `player_idx` with `member_ids` and +/// `leader_id` (must be in `member_ids`). Shape/command don't influence +/// the projection. +fn formation(id: u32, player_idx: u8, leader_id: u32, member_ids: Vec) -> Formation { + Formation { + id, + owner: player_idx, + unit_ids: member_ids, + leader_id, + shape: FormationShape::Line { width: 1 }, + command: FormationCommand::Defend, + rally_origin: None, + slot_assignments: Default::default(), + } +} + +// ── 1. Empty state ────────────────────────────────────────────────────────── + +#[test] +fn empty_game_state_projects_to_zeroed_pod() { + let state = GameState::default(); + let pod = to_abstract_rollout_state(&state); + let bytes: &[u8] = bytemuck::bytes_of(&pod); + assert!( + bytes.iter().all(|&b| b == 0), + "empty GameState must project to all-zero POD" + ); +} + +// ── 2. Single-player projection ───────────────────────────────────────────── + +#[test] +fn single_player_fields_project_correctly() { + let mut state = GameState::default(); + state.turn = 5; + let mut p = player(0); + p.gold = 42; + p.science_pool = 17; + p.cities = vec![city(3), city(2)]; + p.units = vec![ + unit(1, "dwarf_warrior", 12), // tier 1 + unit(2, "dwarf_axeman", 18), // tier 1 + unit(3, "dwarf_arbalest", 14), // tier 2 + ]; + p.tech_state = Some(TechState { + researched: vec!["mining".into(), "masonry".into()], + progress: Default::default(), + }); + state.players.push(p); + + let pod = to_abstract_rollout_state(&state); + let p0 = &pod.players[0]; + + assert_eq!(p0.gold, 42); + assert_eq!(p0.science, 17); + assert_eq!(p0.pop_total, 5); + assert_eq!(p0.city_count, 2); + assert_eq!(p0.unit_counts, [2, 1]); + assert_eq!(p0.formation_count, 0); + assert_eq!(p0.formation_strength, [0, 0, 0, 0]); + assert_eq!(p0.turn, 5); + // tech_index: 2 researched / max(researched + in_progress) = 2/2 → ceil(100%) = 100. + // (Single-player game with no other players to inflate the denominator.) + assert_eq!(p0.tech_index, 100); + // rng_state derives from (turn, player_idx) — must be non-zero. + assert_ne!(p0.rng_state, 0); + + // Slots 1..3 stay zero. + for opp in 1..MAX_PLAYERS { + assert_eq!(pod.players[opp].gold, 0); + assert_eq!(pod.players[opp].turn, 0); + } +} + +// ── 3. Four-player projection ─────────────────────────────────────────────── + +#[test] +fn four_player_projection_fills_every_slot() { + let mut state = GameState::default(); + state.turn = 10; + + for i in 0..4u8 { + let mut p = player(i); + p.gold = 100 * (i as i32 + 1); + p.cities = vec![city(2 + i as u32)]; + // Player 0: 4 units. Player 1: 1 unit. Player 2: 2 units. Player 3: 0. + let n_units: u32 = match i { + 0 => 4, + 1 => 1, + 2 => 2, + _ => 0, + }; + for j in 0..n_units { + // Unit IDs must be globally unique across the GameState (they + // are monotonic per-spawn). Pack player_idx in the high byte. + let uid = ((i as u32) << 16) | j; + p.units.push(unit(uid, "dwarf_warrior", 12)); + } + state.players.push(p); + } + + // Authoritative relations live on player 0. Encode (0,1)=war, (0,2)=peace, + // (0,3)=friendly so we can verify both directions of each pair. + let mut p0_rels = std::collections::BTreeMap::new(); + let mut war = RelationState::default(); + war.relation = Relation::War; + let mut peace = RelationState::default(); + peace.relation = Relation::Peace; + let mut friend = RelationState::default(); + friend.relation = Relation::Friendly; + p0_rels.insert((0u8, 1u8), war); + p0_rels.insert((0u8, 2u8), peace); + p0_rels.insert((0u8, 3u8), friend); + state.players[0].relations = p0_rels; + + let pod = to_abstract_rollout_state(&state); + + // Per-player gold and pop wired through correctly. + for i in 0..4 { + let p = &pod.players[i]; + assert_eq!(p.gold, 100 * (i as i32 + 1), "gold mismatch slot {i}"); + assert_eq!(p.pop_total, 2 + i as u32, "pop mismatch slot {i}"); + assert_eq!(p.turn, 10, "turn mismatch slot {i}"); + // Per-player rng_state is derived from (turn, player_idx) so + // every slot has a distinct non-zero stream. + assert_ne!(p.rng_state, 0, "rng_state zero on slot {i}"); + } + // Distinct streams across players. + let rngs: std::collections::BTreeSet = + pod.players.iter().map(|p| p.rng_state).collect(); + assert_eq!(rngs.len(), 4, "rng_state collisions across players"); + + // Relations decode to {war: -1, peace: 0, friendly: +1} from every + // player's perspective. The canonical pair (min, max) means looking up + // (i, j) yields the same RelationState for both i and j. + assert_eq!(pod.players[0].relations[1], -1, "p0 sees p1 as war"); + assert_eq!(pod.players[0].relations[2], 0, "p0 sees p2 as peace"); + assert_eq!(pod.players[0].relations[3], 1, "p0 sees p3 as friend"); + assert_eq!(pod.players[1].relations[0], -1, "p1 sees p0 as war"); + assert_eq!(pod.players[2].relations[0], 0, "p2 sees p0 as peace"); + assert_eq!(pod.players[3].relations[0], 1, "p3 sees p0 as friend"); + + // Self-slot must always be 0. + for i in 0..4 { + assert_eq!(pod.players[i].relations[i], 0, "self-relation slot {i}"); + assert_eq!(pod.players[i].force_rel[i], 0, "self-force_rel slot {i}"); + } + + // force_rel monotonically reflects relative unit counts: player 0 has + // the most units, so its force_rel against everyone else must exceed + // 0.5 * u16::MAX (i.e. own > opp). + let half = (u16::MAX as u32 / 2) as u16; + for opp in [1, 2, 3usize] { + assert!( + pod.players[0].force_rel[opp] > half, + "p0 force_rel[{opp}] = {} not > half {half}", + pod.players[0].force_rel[opp] + ); + } +} + +// ── 4. Five-player overflow ───────────────────────────────────────────────── + +#[test] +fn five_players_overflow_truncates_to_max_players() { + let mut state = GameState::default(); + for i in 0..5u8 { + let mut p = player(i); + p.gold = 7; + state.players.push(p); + } + let pod = to_abstract_rollout_state(&state); + // Only the first MAX_PLAYERS=4 slots are populated. + for i in 0..MAX_PLAYERS { + assert_eq!(pod.players[i].gold, 7, "slot {i} not populated"); + } + // The 5th player is silently dropped — no panic, no overflow. + // (Only 4 POD slots exist; nothing more to assert.) +} + +// ── 5. Determinism ────────────────────────────────────────────────────────── + +#[test] +fn projection_is_deterministic() { + let mut state = GameState::default(); + state.turn = 99; + let mut p = player(0); + p.gold = 555; + p.cities = vec![city(7)]; + p.units = vec![unit(1, "dwarf_warrior", 12), unit(2, "dwarf_arbalest", 14)]; + state.players.push(p); + + let a = to_abstract_rollout_state(&state); + let b = to_abstract_rollout_state(&state); + let abytes: &[u8] = bytemuck::bytes_of(&a); + let bbytes: &[u8] = bytemuck::bytes_of(&b); + assert_eq!(abytes, bbytes, "projection must be deterministic"); +} + +// ── 6. Round-trip stable through formations ──────────────────────────────── + +#[test] +fn formation_strength_per_tier_round_trips_stable() { + // Build a state with one T1 formation (mean strength 12) and one T3 + // formation (mean strength 50). Verify formation_count=2 and the + // per-tier strength bucket is filled correctly. Then re-project and + // confirm byte-identical output. + let mut state = GameState::default(); + state.turn = 1; + let mut p = player(0); + p.units = vec![ + unit(1, "dwarf_warrior", 12), // T1 + unit(2, "dwarf_axeman", 12), // T1, formation member + unit(3, "dwarf_steam_golem", 50), // T3, formation leader + ]; + state.players.push(p); + + state.formations.insert(10, formation(10, 0, 1, vec![1, 2])); // T1 leader + state.formations.insert(11, formation(11, 0, 3, vec![3])); // T3 leader + + let a = to_abstract_rollout_state(&state); + let p0 = &a.players[0]; + assert_eq!(p0.formation_count, 2); + // T1 formation: (12 + 12) / 1 formation = 24 sum, mean = 24. + assert_eq!(p0.formation_strength[0], 24, "T1 mean strength"); + // T2 has no formations. + assert_eq!(p0.formation_strength[1], 0, "T2 mean strength"); + // T3 formation: 50 / 1 = 50. + assert_eq!(p0.formation_strength[2], 50, "T3 mean strength"); + // T4 empty. + assert_eq!(p0.formation_strength[3], 0, "T4 mean strength"); + + let b = to_abstract_rollout_state(&state); + assert_eq!( + bytemuck::bytes_of(&a), + bytemuck::bytes_of(&b), + "projection must be byte-stable across repeated calls" + ); +}