diff --git a/src/simulator/crates/mc-turn/src/bridge_contract_tests.rs b/src/simulator/crates/mc-turn/src/bridge_contract_tests.rs new file mode 100644 index 00000000..d81ec1b5 --- /dev/null +++ b/src/simulator/crates/mc-turn/src/bridge_contract_tests.rs @@ -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 { + let mut processor = TurnProcessor::new(100); + processor.lair_combat_config = cfg; + let mut state = dense_bench_state(42, 16); + let mut log: Vec = 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", + ); +} diff --git a/src/simulator/crates/mc-turn/src/lib.rs b/src/simulator/crates/mc-turn/src/lib.rs index dc236265..0431fc3d 100644 --- a/src/simulator/crates/mc-turn/src/lib.rs +++ b/src/simulator/crates/mc-turn/src/lib.rs @@ -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};