feat(@projects/@magic-civilization): add siphash-based seed derivation system

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-04 16:52:04 -04:00
parent 0e724b3949
commit 467d7ee951
9 changed files with 971 additions and 208 deletions

View file

@ -984,6 +984,7 @@ dependencies = [
"rayon",
"serde",
"serde_json",
"siphasher",
]
[[package]]

View file

@ -7,6 +7,7 @@ edition = "2021"
serde.workspace = true
serde_json.workspace = true
getrandom.workspace = true
siphasher.workspace = true
rayon = "1"
[lints]

View file

@ -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;

View file

@ -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.01.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);
}
}

View file

@ -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.01.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::*;

View file

@ -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

View file

@ -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<String, u8> on PlayerState; axes_to_flat
// wants HashMap. Cheap conversion — there are only ~4 keys.
let axes_hash: std::collections::HashMap<String, u8> =
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<u32, &MapUnit> = 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<u8, (u64, u32)> = 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<u32, &MapUnit>) -> 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,
}
}

View file

@ -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;

View file

@ -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<u32>) -> 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<u64> =
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"
);
}