feat(mc-turn): Introduce bridge contract tests in lib.rs and bridge_contract_tests.rs to validate Rust-native bridge compliance with GDExtension requirements

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-08 20:15:54 -07:00
parent 8b9a8654a7
commit 9d1989dcb5
2 changed files with 467 additions and 0 deletions

View file

@ -0,0 +1,464 @@
//! Bridge contract regression tests.
//!
//! Iter 7h-7k built a GDExtension bridge surface (GdGameState + GdTurnProcessor)
//! on top of `mc-turn`. That surface is currently exercised only by Godot proof
//! scenes that aren't run in CI — so a signature change to `step`,
//! `step_encounters_only`, `set_turn`, `process_fauna_encounters_inner`, or
//! `LairCombatConfig` could silently break the proof scenes.
//!
//! These tests lock the contract into `cargo test` via the underlying `mc-turn`
//! API (which the Gd wrappers thinly delegate to). They are deliberately
//! self-contained — the state builder here is a trimmed clone of
//! `processor::tests::make_bench_state` because the module-private helper is
//! not cross-module visible.
use crate::combat_event::FaunaCombatEvent;
use crate::game_state::{GameState, MapUnit, PlayerState};
use crate::processor::{LairCombatConfig, TurnProcessor};
use mc_city::CityState;
use mc_core::grid::GridState;
use std::collections::HashMap;
// ── Local test fixtures ─────────────────────────────────────────────────────
/// SplitMix64 — local copy of the processor's RNG mixer so we can generate
/// deterministic lair distributions without touching the processor module.
fn mix(x: u64, seed: u64) -> u64 {
let mut z = x.wrapping_add(seed).wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Build a dense bench state with 40 seeded lairs and a militarist player.
/// Mirrors `processor::tests::make_bench_state` shape.
fn dense_bench_state(seed: u64, map_size: i32) -> GameState {
let mut grid = GridState::new(map_size, map_size);
let mut s = seed;
let n_lairs = 40_u32;
for _ in 0..n_lairs {
s = mix(s, 0x1357_9BDF_2468_ACE0);
let col = (s % map_size as u64) as i32;
s = mix(s, 0x2468_ACE0_1357_9BDF);
let row = (s % map_size as u64) as i32;
s = mix(s, 0xDEAD_BEEF_FEED_FACE);
let roll = (s >> 32) as u32 % 40;
let tier = if roll < 6 {
1 + (roll % 3) as i32
} else if roll < 18 {
4 + ((roll - 6) % 3) as i32
} else {
7 + ((roll - 18) % 4) as i32
};
let idx = (row * map_size + col) as usize;
if idx < grid.tiles.len() {
grid.tiles[idx].lair_tier = tier;
grid.tiles[idx].lair_population = 10.0;
grid.tiles[idx].lair_species_id = (s as u32).max(1);
}
}
let mut axes = HashMap::new();
axes.insert("expansion".to_string(), 2u8);
axes.insert("production".to_string(), 5u8);
axes.insert("wealth".to_string(), 2u8);
axes.insert("culture".to_string(), 2u8);
GameState {
turn: 0,
players: vec![PlayerState {
player_index: 0,
gold: 60,
cities: vec![CityState::starter()],
unit_upkeep: vec![],
strategic_axes: axes,
scoring_weights: Default::default(),
expansion_points: 0,
city_buildings: vec![vec![]],
city_ecology: vec![Default::default()],
tech_state: None,
science_yield: 0,
units: vec![
MapUnit {
col: 5,
row: 5,
hp: 60,
max_hp: 60,
attack: 12,
defense: 1,
is_fortified: false,
unit_id: "dwarf_warrior".to_string(),
},
MapUnit {
col: 6,
row: 5,
hp: 60,
max_hp: 60,
attack: 12,
defense: 1,
is_fortified: false,
unit_id: "dwarf_warrior".to_string(),
},
MapUnit {
col: 5,
row: 6,
hp: 60,
max_hp: 60,
attack: 12,
defense: 1,
is_fortified: false,
unit_id: "dwarf_warrior".to_string(),
},
],
city_positions: vec![(5, 5)],
arcane_lore_pop_deducted: false,
}],
grid: Some(grid),
}
}
/// Build an isolated state: one unit at (5, 5), one T5 lair at (10, 10). At
/// default radii (T5 → 2) the lair is OUT of encounter range, so
/// `step_encounters_only` must produce an empty fauna log and leave the
/// unit pinned at (5, 5).
fn isolated_unit_state(map_size: i32) -> GameState {
let mut grid = GridState::new(map_size, map_size);
let lair_idx = (10 * map_size + 10) as usize;
grid.tiles[lair_idx].lair_tier = 5;
grid.tiles[lair_idx].lair_population = 10.0;
grid.tiles[lair_idx].lair_species_id = 1;
let mut axes = HashMap::new();
axes.insert("expansion".to_string(), 2u8);
axes.insert("production".to_string(), 2u8);
axes.insert("wealth".to_string(), 2u8);
axes.insert("culture".to_string(), 2u8);
GameState {
turn: 0,
players: vec![PlayerState {
player_index: 0,
gold: 1234,
cities: vec![CityState::starter(), CityState::starter()],
unit_upkeep: vec![0],
strategic_axes: axes,
scoring_weights: Default::default(),
expansion_points: 0,
city_buildings: vec![vec![], vec![]],
city_ecology: vec![Default::default(), Default::default()],
tech_state: None,
science_yield: 0,
units: vec![MapUnit {
col: 5,
row: 5,
hp: 60,
max_hp: 60,
attack: 12,
defense: 1,
is_fortified: false,
unit_id: "dwarf_warrior".to_string(),
}],
city_positions: vec![(0, 0), (15, 15)],
arcane_lore_pop_deducted: false,
}],
grid: Some(grid),
}
}
// ── Test 1 ──────────────────────────────────────────────────────────────────
/// `GdGameState::set_turn` relies on `state.turn` seeding the RNG stream. Two
/// otherwise-identical states with different turn counters must produce
/// different `fauna_combat_log` output when stepped through encounters.
#[test]
fn set_turn_changes_rng_stream() {
let processor = TurnProcessor::new(100);
let mut state_a = dense_bench_state(42, 16);
let mut state_b = dense_bench_state(42, 16);
// Bypass the normal loop and set turn numbers far apart — this is what
// `GdGameState::set_turn` does when the GDScript driver resyncs.
state_a.turn = 5;
state_b.turn = 100;
let result_a = processor.step_encounters_only(&mut state_a);
let result_b = processor.step_encounters_only(&mut state_b);
assert_ne!(
result_a.fauna_combat_log, result_b.fauna_combat_log,
"different turn numbers must produce different encounter streams \
(got identical logs of length {})",
result_a.fauna_combat_log.len(),
);
}
// ── Test 2 ──────────────────────────────────────────────────────────────────
/// `step_encounters_only` must not run the city production phase, even across
/// many turns where the production accumulator would otherwise overflow the
/// unit-spawn threshold. Specifically targets `production_stored` which the
/// existing iter 7j test does not snapshot.
#[test]
fn encounters_only_does_not_spawn_units() {
let processor = TurnProcessor::new(200);
let mut state = dense_bench_state(7, 16);
let initial_units = state.players[0].units.len();
let initial_production_stored: i32 = state.players[0]
.cities
.iter()
.map(|c| c.production_stored)
.sum();
let mut total_deaths: u32 = 0;
for _ in 0..30 {
let r = processor.step_encounters_only(&mut state);
total_deaths += r.units_lost_to_fauna;
}
let final_units = state.players[0].units.len();
let expected_max = initial_units.saturating_sub(total_deaths as usize);
assert!(
final_units <= expected_max,
"unit count {final_units} exceeds initial-deaths bound {expected_max} \
(initial={initial_units}, deaths={total_deaths}) spawn phase leaked through",
);
// production_stored must be EXACTLY unchanged — `process_city_production`
// is the only writer, and `step_encounters_only` skips it.
let final_production_stored: i32 = state.players[0]
.cities
.iter()
.map(|c| c.production_stored)
.sum();
assert_eq!(
initial_production_stored, final_production_stored,
"production_stored drifted — step_encounters_only ran city production",
);
}
// ── Test 3 ──────────────────────────────────────────────────────────────────
/// `step_encounters_only` must not move units. Cross-check: the same initial
/// state run through `step` (full) DOES move the unit — this proves the
/// "no movement" contract is actually being tested, not vacuously true.
#[test]
fn encounters_only_does_not_move_units() {
let processor = TurnProcessor::new(100);
// encounters-only branch: single unit at (5, 5), lair at (10, 10), T5 radius=2,
// distance=5 → out of range. The lair exists but the encounter fires zero times.
let mut eo_state = isolated_unit_state(32);
let (start_col, start_row) = (eo_state.players[0].units[0].col, eo_state.players[0].units[0].row);
let result = processor.step_encounters_only(&mut eo_state);
let unit = &eo_state.players[0].units[0];
assert_eq!(
(unit.col, unit.row),
(start_col, start_row),
"step_encounters_only must not move units (was at ({start_col},{start_row}), now at ({}, {}))",
unit.col,
unit.row,
);
assert!(
result.fauna_combat_log.is_empty(),
"encounter should be out of range — got {} events",
result.fauna_combat_log.len(),
);
// Cross-check: `step` (full) from the same starting state DOES move the
// unit toward the lair. If both paths were no-ops we'd be testing nothing.
let mut full_state = isolated_unit_state(32);
processor.step(&mut full_state);
let moved = &full_state.players[0].units[0];
assert_ne!(
(moved.col, moved.row),
(start_col, start_row),
"full step() must move the unit toward the lair — if this fails the \
cross-check is broken and test 3 is vacuous",
);
}
// ── Test 4 ──────────────────────────────────────────────────────────────────
/// `step_encounters_only` must not mutate gold, found cities, or accumulate
/// expansion points. Exhaustive check across many turns.
#[test]
fn encounters_only_does_not_touch_economy() {
let processor = TurnProcessor::new(200);
let mut state = dense_bench_state(99, 16);
// Force a known gold and city count.
state.players[0].gold = 1234;
let initial_gold = state.players[0].gold;
let initial_cities = state.players[0].cities.len();
let initial_expansion_points = state.players[0].expansion_points;
for _ in 0..50 {
processor.step_encounters_only(&mut state);
}
assert_eq!(
state.players[0].gold, initial_gold,
"gold drifted from {initial_gold} to {} — economy phase ran",
state.players[0].gold,
);
assert_eq!(
state.players[0].cities.len(),
initial_cities,
"city count changed — founding phase ran",
);
assert_eq!(
state.players[0].expansion_points, initial_expansion_points,
"expansion_points accumulated — production phase ran",
);
}
// ── Test 5 ──────────────────────────────────────────────────────────────────
/// `step_encounters_only` is implemented in terms of
/// `process_fauna_encounters_inner(state, result, false)`. A future refactor
/// that diverges these two paths (e.g. accidentally passing `true`, or
/// skipping the `state.turn += 1` increment) must break this test.
///
/// We assert byte-identical equality of the `fauna_combat_log`,
/// `units_lost_to_fauna`, and `cities_harassed_by_fauna` fields between:
/// A) `step_encounters_only(&mut clone_a)`
/// B) manual `state.turn += 1` + `process_fauna_encounters_inner(&mut clone_b, &mut result_b, false)`
#[test]
fn inner_matches_encounters_only() {
let processor = TurnProcessor::new(100);
let starting = dense_bench_state(7, 16);
// Path A: step_encounters_only on a clone.
let mut state_a = starting.clone();
let result_a = processor.step_encounters_only(&mut state_a);
// Path B: replay the internal contract by hand on a separate clone.
let mut state_b = starting.clone();
let mut result_b = crate::combat_event::TurnResult::default();
state_b.turn += 1; // step_encounters_only increments first…
processor.process_fauna_encounters_inner(&mut state_b, &mut result_b, false);
assert_eq!(
result_a.fauna_combat_log, result_b.fauna_combat_log,
"fauna_combat_log diverged — step_encounters_only is no longer a \
thin wrapper around process_fauna_encounters_inner(_, _, false)",
);
assert_eq!(
result_a.units_lost_to_fauna, result_b.units_lost_to_fauna,
"units_lost_to_fauna diverged between step_encounters_only and inner",
);
assert_eq!(
result_a.cities_harassed_by_fauna, result_b.cities_harassed_by_fauna,
"cities_harassed_by_fauna diverged between step_encounters_only and inner",
);
// And the states themselves should match — same turn advance, same
// player mutations.
assert_eq!(state_a.turn, state_b.turn);
assert_eq!(state_a.players[0].units.len(), state_b.players[0].units.len());
}
// ── Test 6 ──────────────────────────────────────────────────────────────────
/// Extends iter 7f's `lair_combat_config_serde_roundtrip`. Where that test only
/// asserts field equality after JSON roundtrip, this one asserts the roundtrip
/// preserves the BEHAVIOUR derived from those fields: the two configs
/// (original vs deserialised) must produce byte-identical `fauna_combat_log`
/// output when driven against identical starting states.
#[test]
fn lair_combat_config_json_roundtrip_extended() {
// Non-default mutation of every single field — any field the serde impl
// silently drops will show up as the restored value reverting to Default.
let original = LairCombatConfig {
encounter_radius_t1_t3: 2,
encounter_radius_t4_t6: 3,
encounter_radius_t7_t9: 4,
encounter_radius_t10: 5,
base_kill_rate: 0.137,
tier_kill_slope: 0.0421,
tier_kill_exponent: 1.73,
fortify_divisor: 3.25,
encounter_probability_per_turn: 0.17,
gold_per_wealth_per_city: 9,
prod_per_axis_per_city: 13,
expansion_per_axis_per_turn: 5,
city_founding_cost: 77,
unit_spawn_cost: 19,
max_cities_per_player_base: 23,
};
// Exhaustive destructure — compiler error if a new field is added.
let LairCombatConfig {
encounter_radius_t1_t3: _,
encounter_radius_t4_t6: _,
encounter_radius_t7_t9: _,
encounter_radius_t10: _,
base_kill_rate: _,
tier_kill_slope: _,
tier_kill_exponent: _,
fortify_divisor: _,
encounter_probability_per_turn: _,
gold_per_wealth_per_city: _,
prod_per_axis_per_city: _,
expansion_per_axis_per_turn: _,
city_founding_cost: _,
unit_spawn_cost: _,
max_cities_per_player_base: _,
} = original.clone();
let json = serde_json::to_string(&original).expect("cfg serializes");
let restored: LairCombatConfig = serde_json::from_str(&json).expect("cfg deserializes");
// Per-field equality — covers the iter 7f baseline.
assert_eq!(original.encounter_radius_t1_t3, restored.encounter_radius_t1_t3);
assert_eq!(original.encounter_radius_t4_t6, restored.encounter_radius_t4_t6);
assert_eq!(original.encounter_radius_t7_t9, restored.encounter_radius_t7_t9);
assert_eq!(original.encounter_radius_t10, restored.encounter_radius_t10);
assert!((original.base_kill_rate - restored.base_kill_rate).abs() < f32::EPSILON);
assert!((original.tier_kill_slope - restored.tier_kill_slope).abs() < f32::EPSILON);
assert!((original.tier_kill_exponent - restored.tier_kill_exponent).abs() < f32::EPSILON);
assert!((original.fortify_divisor - restored.fortify_divisor).abs() < f32::EPSILON);
assert!(
(original.encounter_probability_per_turn - restored.encounter_probability_per_turn).abs()
< f32::EPSILON,
);
assert_eq!(original.gold_per_wealth_per_city, restored.gold_per_wealth_per_city);
assert_eq!(original.prod_per_axis_per_city, restored.prod_per_axis_per_city);
assert_eq!(original.expansion_per_axis_per_turn, restored.expansion_per_axis_per_turn);
assert_eq!(original.city_founding_cost, restored.city_founding_cost);
assert_eq!(original.unit_spawn_cost, restored.unit_spawn_cost);
assert_eq!(original.max_cities_per_player_base, restored.max_cities_per_player_base);
// Behavioural equivalence — the real regression guard. Run each config
// against its own clone of the same starting state and compare the
// fauna_combat_log byte-for-byte.
let run = |cfg: LairCombatConfig| -> Vec<FaunaCombatEvent> {
let mut processor = TurnProcessor::new(100);
processor.lair_combat_config = cfg;
let mut state = dense_bench_state(42, 16);
let mut log: Vec<FaunaCombatEvent> = Vec::new();
for _ in 0..20 {
let r = processor.step(&mut state);
log.extend(r.fauna_combat_log);
}
log
};
let log_original = run(original);
let log_restored = run(restored);
assert_eq!(
log_original, log_restored,
"JSON roundtrip of LairCombatConfig must preserve bench behaviour \
(log_original.len()={}, log_restored.len()={})",
log_original.len(),
log_restored.len(),
);
// Sanity: the scenario actually produces encounters, otherwise the
// assertion above is vacuously true.
assert!(
!log_original.is_empty(),
"bench scenario produced zero encounters — test 6 is vacuous",
);
}

View file

@ -25,6 +25,9 @@ pub mod spatial_index;
#[cfg(test)]
mod processor_invariants;
#[cfg(test)]
mod bridge_contract_tests;
pub use game_state::{CityEcology, GameState, MapUnit, PlayerState, TechState};
pub use combat_event::{FaunaCombatEvent, TurnResult};
pub use processor::{LairCombatConfig, TurnProcessor};