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:
parent
8b9a8654a7
commit
9d1989dcb5
2 changed files with 467 additions and 0 deletions
464
src/simulator/crates/mc-turn/src/bridge_contract_tests.rs
Normal file
464
src/simulator/crates/mc-turn/src/bridge_contract_tests.rs
Normal 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",
|
||||
);
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue