diff --git a/Cargo.lock b/Cargo.lock index c407c9a..ee48013 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3380,6 +3380,7 @@ name = "hex-core" version = "0.1.0" dependencies = [ "serde", + "serde_json", ] [[package]] @@ -3991,6 +3992,19 @@ dependencies = [ "spacetimedb-sdk", ] +[[package]] +name = "match-playtest" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "hex-core", + "match-bindings", + "serde", + "serde_json", + "spacetimedb-sdk", +] + [[package]] name = "matchers" version = "0.2.0" diff --git a/README.md b/README.md index 128f996..29b75a7 100644 --- a/README.md +++ b/README.md @@ -185,10 +185,12 @@ forwarding and controls. ``` Player counts from 2 through 500 are supported. Counts above eight use one-cell high-scale spawns, a compact HUD summary, and selective local tactical subscriptions. Other presets are `dev64` and - `validation192`. Lobby configuration is one-shot: the first successful - `configure_match` or `configure_map` locks it against further regeneration - without claiming a player slot. Every configured slot remains open for a - normal `join_match` call. The compatibility reducer `configure_map` changes + `validation192`. The first successful `configure_match` or `configure_map` + records the calling identity and locks configuration against every other + identity without claiming a player slot; the recorded configurator may still + reconfigure until the first player joins. Every configured slot remains open + for a normal `join_match` call (fresh slot claims are lobby-only; reconnects + work in any phase). The compatibility reducer `configure_map` changes only the preset while retaining the currently configured player count: ```bash @@ -394,6 +396,16 @@ load is distributed; simulation is not). - `modules/match` — authoritative SpacetimeDB schema, reducers, and scheduler. - `tools/mapgen` — curated map generator/validator CLI. - `tools/match-e2e` — real-server two-client acceptance smoke test. +- `tools/match-playtest` — automated no-human cluster-controls live playtest + (six behavioral scenarios + conservation monitor). One command: + +```bash +./scripts/run-automated-playtest.sh +``` + +This publishes a fresh isolated `of-match-e2e-auto` database (never +`of-match-dev`) and writes evidence to `docs/playtests/` and +`artifacts/playtests/`. - `tools/match-perf` — distributed-capable live-match load driver and step-rate profiler. - `docs` — the game design, architecture, UI direction, implementation notes, performance profiling, and deliberately deferred ideas. diff --git a/crates/game-client/src/hud.rs b/crates/game-client/src/hud.rs index 98e0d4f..7307c3b 100644 --- a/crates/game-client/src/hud.rs +++ b/crates/game-client/src/hud.rs @@ -1,3 +1,5 @@ +#[cfg(not(target_arch = "wasm32"))] +use bevy::app::AppExit; use bevy::{ picking::hover::Hovered, prelude::*, @@ -7,9 +9,6 @@ use bevy::{ }, }; -#[cfg(not(target_arch = "wasm32"))] -use bevy::app::AppExit; - use crate::{ interaction::{InteractionState, OrderMode}, map_view::map_view_status_bundle, diff --git a/crates/game-client/src/observe.rs b/crates/game-client/src/observe.rs index 33fff11..2ea7311 100644 --- a/crates/game-client/src/observe.rs +++ b/crates/game-client/src/observe.rs @@ -304,5 +304,6 @@ mod tests { state.note_frame_spike(41.0, 50.0, Some(20.0)); assert_eq!(state.events.len(), 1); assert_eq!(state.events[0].key, keys::PERF_FRAME_SPIKE); + assert!(state.events[0].detail.contains("frame_ms=40.00")); } } diff --git a/crates/hex-core/Cargo.toml b/crates/hex-core/Cargo.toml index 2c4da20..485c069 100644 --- a/crates/hex-core/Cargo.toml +++ b/crates/hex-core/Cargo.toml @@ -14,3 +14,6 @@ serde = ["dep:serde"] [dependencies] serde = { version = "1.0", features = ["derive"], optional = true } + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/hex-core/src/combat.rs b/crates/hex-core/src/combat.rs index c93f891..7b9b7ba 100644 --- a/crates/hex-core/src/combat.rs +++ b/crates/hex-core/src/combat.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use crate::{ conquest::BASIS_POINTS, @@ -44,17 +44,33 @@ pub struct AttackFront { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CombatError { InvalidConfig, - DuplicateAttackId(u64), - DuplicateOrigin(Axial), - MixedAttackerOwners, - NonAdjacent(Axial), - ImpassableCliff(Axial), +} + +/// Why one attack front was excluded from a resolution while the remaining +/// valid fronts still resolved. +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum FrontRejection { + DuplicateAttackId, + DuplicateOrigin, + NonAdjacent, + ImpassableCliff, +} + +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RejectedFront { + pub id: u64, + pub attacker: PlayerId, + pub from: Axial, + pub reason: FrontRejection, } #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct AttackOutcome { pub id: u64, + pub attacker: PlayerId, pub offered: Strength, pub engaged: Strength, pub waiting: Strength, @@ -68,21 +84,40 @@ pub struct AttackOutcome { #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[derive(Clone, Debug, Eq, PartialEq)] pub struct CombatResolution { - pub attacker: Option, pub defender_initial: Strength, pub defender_casualties: Strength, pub defender_remaining: Strength, pub attacks: BTreeMap, - /// The surviving front selected to occupy if the caller can admit it under - /// throughput and destination-capacity rules. + /// Malformed fronts excluded from this resolution, sorted by `(id, from)`. + /// Valid fronts still resolved; callers decide how to retire the rejected + /// allocations. + pub rejected: Vec, + /// The owner selected to occupy if the caller can admit the capture. + pub capturing_owner: Option, + /// The surviving front of `capturing_owner` selected to occupy if the + /// caller can admit it under throughput and destination-capacity rules. pub capturing_front: Option, } /// Resolves one deterministic combat step against a cell. /// -/// Every defender is allocated to at most one attack edge. Allocation is -/// proportional to engaged strength, capped by each edge's frontage, and uses -/// attack ID as the integer-remainder tie-break. +/// # Contract +/// +/// - Fronts from **multiple attacking owners** resolve simultaneously. +/// Defenders are allocated across all valid attacking edges regardless of +/// owner: every defender fights at most one edge, allocation is proportional +/// to each edge's engaged strength (largest-remainder rounding), capped by +/// edge frontage, with the attack ID as the deterministic tie-break. +/// - Malformed fronts (duplicate ID, duplicate origin, non-adjacent origin, or +/// an impassable cliff) are reported in [`CombatResolution::rejected`] +/// instead of failing the whole resolution. Every front sharing a duplicated +/// ID or origin is rejected so the outcome cannot depend on slice order. +/// - **Capture rule:** when the defender is eliminated, the attacking owner +/// with the largest total surviving offered strength across its valid fronts +/// wins the capture; ties break toward the smaller owner ID. Within the +/// winning owner, the front with the largest surviving strength is selected; +/// ties break toward the smaller attack ID. Attackers from other owners keep +/// their survivors in place and contest the cell again next step. pub fn resolve_edge_combat( target: Axial, defender_strength: Strength, @@ -94,35 +129,42 @@ pub fn resolve_edge_combat( return Err(CombatError::InvalidConfig); } - let mut ids = BTreeSet::new(); - let mut origins = BTreeSet::new(); - let mut attacker = None; - for attack in attacks { - if !ids.insert(attack.id) { - return Err(CombatError::DuplicateAttackId(attack.id)); - } - if !origins.insert(attack.from) { - return Err(CombatError::DuplicateOrigin(attack.from)); - } - if target.distance(attack.from) != 1 { - return Err(CombatError::NonAdjacent(attack.from)); - } + let mut sorted = attacks.to_vec(); + sorted.sort_unstable_by_key(|attack| (attack.id, attack.from, attack.attacker)); + let mut id_counts = BTreeMap::::new(); + let mut origin_counts = BTreeMap::::new(); + for attack in &sorted { + *id_counts.entry(attack.id).or_insert(0) += 1; + *origin_counts.entry(attack.from).or_insert(0) += 1; + } + + let mut rejected = Vec::new(); + let mut valid = Vec::new(); + for attack in sorted { let elevation_delta = i32::from(defender_elevation) - i32::from(attack.from_elevation); - if elevation_delta.unsigned_abs() > u32::from(config.max_elevation_step) { - return Err(CombatError::ImpassableCliff(attack.from)); - } - match attacker { - None => attacker = Some(attack.attacker), - Some(existing) if existing != attack.attacker => { - return Err(CombatError::MixedAttackerOwners); - } - Some(_) => {} + let reason = if id_counts[&attack.id] > 1 { + Some(FrontRejection::DuplicateAttackId) + } else if origin_counts[&attack.from] > 1 { + Some(FrontRejection::DuplicateOrigin) + } else if target.distance(attack.from) != 1 { + Some(FrontRejection::NonAdjacent) + } else if elevation_delta.unsigned_abs() > u32::from(config.max_elevation_step) { + Some(FrontRejection::ImpassableCliff) + } else { + None + }; + match reason { + Some(reason) => rejected.push(RejectedFront { + id: attack.id, + attacker: attack.attacker, + from: attack.from, + reason, + }), + None => valid.push(attack), } } - let mut sorted = attacks.to_vec(); - sorted.sort_unstable_by_key(|attack| attack.id); - let demands: Vec<_> = sorted + let demands: Vec<_> = valid .iter() .map(|attack| (attack.id, attack.offered.min(attack.frontage))) .collect(); @@ -130,7 +172,7 @@ pub fn resolve_edge_combat( let mut outcomes = BTreeMap::new(); let mut total_defender_casualties = 0_u64; - for attack in sorted { + for attack in valid { let engaged = attack.offered.min(attack.frontage); let waiting = attack.offered - engaged; let uphill = defender_elevation > attack.from_elevation; @@ -157,6 +199,7 @@ pub fn resolve_edge_combat( attack.id, AttackOutcome { id: attack.id, + attacker: attack.attacker, offered: attack.offered, engaged, waiting, @@ -170,30 +213,56 @@ pub fn resolve_edge_combat( } let defender_remaining = defender_strength - total_defender_casualties; - let capturing_front = (defender_remaining == 0) - .then(|| { - outcomes - .values() - .filter(|outcome| outcome.attacker_remaining > 0) - .max_by(|left, right| { - left.attacker_remaining - .cmp(&right.attacker_remaining) - .then_with(|| right.id.cmp(&left.id)) - }) - .map(|outcome| outcome.id) - }) - .flatten(); + let (capturing_owner, capturing_front) = if defender_remaining == 0 { + select_capture(&outcomes) + } else { + (None, None) + }; Ok(CombatResolution { - attacker, defender_initial: defender_strength, defender_casualties: total_defender_casualties, defender_remaining, attacks: outcomes, + rejected, + capturing_owner, capturing_front, }) } +/// Applies the documented capture rule to per-front outcomes: largest total +/// surviving offered strength per owner (tie: smaller owner ID), then largest +/// surviving front within that owner (tie: smaller attack ID). +pub fn select_capture(outcomes: &BTreeMap) -> (Option, Option) { + let mut totals = BTreeMap::::new(); + for outcome in outcomes.values() { + if outcome.attacker_remaining > 0 { + *totals.entry(outcome.attacker).or_default() += outcome.attacker_remaining; + } + } + let Some(owner) = totals + .iter() + .max_by(|(left_owner, left_total), (right_owner, right_total)| { + left_total + .cmp(right_total) + .then_with(|| right_owner.cmp(left_owner)) + }) + .map(|(&owner, _)| owner) + else { + return (None, None); + }; + let front = outcomes + .values() + .filter(|outcome| outcome.attacker == owner && outcome.attacker_remaining > 0) + .max_by(|left, right| { + left.attacker_remaining + .cmp(&right.attacker_remaining) + .then_with(|| right.id.cmp(&left.id)) + }) + .map(|outcome| outcome.id); + (Some(owner), front) +} + fn multiply_bps_floor(value: u64, basis_points: u32) -> u64 { ((u128::from(value) * u128::from(basis_points)) / u128::from(BASIS_POINTS)) .min(u128::from(u64::MAX)) as u64 @@ -240,9 +309,19 @@ mod tests { use super::*; fn front(id: u64, from: Axial, elevation: i16, offered: u64) -> AttackFront { + owned_front(id, 1, from, elevation, offered) + } + + fn owned_front( + id: u64, + attacker: PlayerId, + from: Axial, + elevation: i16, + offered: u64, + ) -> AttackFront { AttackFront { id, - attacker: 1, + attacker, from, from_elevation: elevation, offered, @@ -250,6 +329,22 @@ mod tests { } } + fn resolution_conserves(resolution: &CombatResolution, attacks: &[AttackFront]) { + assert_eq!( + resolution.defender_initial, + resolution.defender_remaining + resolution.defender_casualties + ); + for attack in attacks { + if let Some(outcome) = resolution.attacks.get(&attack.id) { + assert_eq!(outcome.offered, attack.offered); + assert_eq!( + outcome.attacker_remaining + outcome.attacker_casualties, + outcome.offered + ); + } + } + } + #[test] fn defenders_are_never_duplicated_across_multiple_edges() { let attacks = [ @@ -268,7 +363,9 @@ mod tests { 60 ); assert_eq!(result.defender_casualties, 60); + assert_eq!(result.capturing_owner, Some(1)); assert_eq!(result.capturing_front, Some(1)); + resolution_conserves(&result, &attacks); } #[test] @@ -320,37 +417,255 @@ mod tests { } #[test] - fn invalid_edges_and_duplicate_fronts_are_rejected() { - let duplicate = front(1, Axial::new(1, 0), 0, 5); + fn invalid_fronts_are_rejected_individually_while_valid_fronts_resolve() { + let valid = front(1, Axial::new(1, 0), 0, 20); + let non_adjacent = front(2, Axial::new(2, 0), 0, 5); + let cliff = front(3, Axial::new(0, 1), -2, 5); + let result = resolve_edge_combat( + Axial::ZERO, + 20, + 0, + &[non_adjacent, valid, cliff], + &CombatConfig::default(), + ) + .unwrap(); + assert_eq!(result.attacks.len(), 1); + assert!(result.attacks.contains_key(&1)); assert_eq!( - resolve_edge_combat( - Axial::ZERO, - 5, - 0, - &[duplicate, duplicate], - &CombatConfig::default() - ), - Err(CombatError::DuplicateAttackId(1)) + result + .rejected + .iter() + .map(|rejection| (rejection.id, rejection.reason)) + .collect::>(), + vec![ + (2, FrontRejection::NonAdjacent), + (3, FrontRejection::ImpassableCliff), + ] ); - assert_eq!( - resolve_edge_combat( - Axial::ZERO, - 5, - 0, - &[front(1, Axial::new(2, 0), 0, 5)], - &CombatConfig::default() - ), - Err(CombatError::NonAdjacent(Axial::new(2, 0))) + // The valid front still fought the full defender. + assert_eq!(result.attacks[&1].defense_allocated, 20); + } + + #[test] + fn every_front_sharing_a_duplicate_key_is_rejected_deterministically() { + let duplicate = front(1, Axial::new(1, 0), 0, 5); + let mut renamed = duplicate; + renamed.id = 2; + let survivor = front(3, Axial::new(0, 1), 0, 5); + + let by_id = resolve_edge_combat( + Axial::ZERO, + 5, + 0, + &[duplicate, duplicate, survivor], + &CombatConfig::default(), + ) + .unwrap(); + assert_eq!(by_id.attacks.len(), 1); + assert!(by_id.attacks.contains_key(&3)); + assert!( + by_id + .rejected + .iter() + .all(|rejection| rejection.reason == FrontRejection::DuplicateAttackId) ); + assert_eq!(by_id.rejected.len(), 2); + + let by_origin = resolve_edge_combat( + Axial::ZERO, + 5, + 0, + &[duplicate, renamed, survivor], + &CombatConfig::default(), + ) + .unwrap(); + assert_eq!(by_origin.attacks.len(), 1); + assert!(by_origin.attacks.contains_key(&3)); + assert!( + by_origin + .rejected + .iter() + .all(|rejection| rejection.reason == FrontRejection::DuplicateOrigin) + ); + assert_eq!(by_origin.rejected.len(), 2); + } + + #[test] + fn two_attacker_owners_split_the_defender_and_the_stronger_owner_captures() { + let attacks = [ + owned_front(1, 1, Axial::new(1, 0), 0, 10), + owned_front(2, 2, Axial::new(0, 1), 0, 20), + ]; + let result = + resolve_edge_combat(Axial::ZERO, 15, 0, &attacks, &CombatConfig::default()).unwrap(); + // Defense splits 5/10 proportionally to engaged strength. + assert_eq!(result.attacks[&1].defense_allocated, 5); + assert_eq!(result.attacks[&2].defense_allocated, 10); + assert_eq!(result.defender_casualties, 15); + assert_eq!(result.defender_remaining, 0); + // Survivors: owner 1 keeps 5, owner 2 keeps 10; owner 2 captures. + assert_eq!(result.capturing_owner, Some(2)); + assert_eq!(result.capturing_front, Some(2)); + resolution_conserves(&result, &attacks); + } + + #[test] + fn three_owner_contest_resolves_with_exact_defender_conservation() { + let attacks = [ + owned_front(1, 3, Axial::new(1, 0), 0, 25), + owned_front(2, 1, Axial::new(0, 1), 0, 25), + owned_front(3, 2, Axial::new(-1, 1), 0, 10), + ]; + let result = + resolve_edge_combat(Axial::ZERO, 40, 0, &attacks, &CombatConfig::default()).unwrap(); assert_eq!( - resolve_edge_combat( - Axial::ZERO, - 5, - 2, - &[front(1, Axial::new(1, 0), 0, 5)], - &CombatConfig::default() - ), - Err(CombatError::ImpassableCliff(Axial::new(1, 0))) + result + .attacks + .values() + .map(|outcome| outcome.defense_allocated) + .sum::(), + 40 ); + assert_eq!(result.defender_remaining, 0); + // Survivors per owner: 3 → 25-16=9? Recomputed below from outcomes. + let survivors_by_owner: BTreeMap = result + .attacks + .values() + .map(|outcome| (outcome.attacker, outcome.attacker_remaining)) + .fold(BTreeMap::new(), |mut totals, (owner, remaining)| { + *totals.entry(owner).or_default() += remaining; + totals + }); + let expected_winner = survivors_by_owner + .iter() + .filter(|&(_, &remaining)| remaining > 0) + .max_by(|(left_owner, left), (right_owner, right)| { + left.cmp(right).then_with(|| right_owner.cmp(left_owner)) + }) + .map(|(&owner, _)| owner); + assert_eq!(result.capturing_owner, expected_winner); + resolution_conserves(&result, &attacks); + } + + #[test] + fn tied_owners_break_toward_the_smaller_owner_and_smaller_front_id() { + let attacks = [ + owned_front(4, 7, Axial::new(1, 0), 0, 10), + owned_front(2, 3, Axial::new(0, 1), 0, 10), + owned_front(6, 3, Axial::new(-1, 1), 0, 10), + ]; + // Zero defenders: everyone survives untouched, owner 3 has 20 vs 10. + let result = + resolve_edge_combat(Axial::ZERO, 0, 0, &attacks, &CombatConfig::default()).unwrap(); + assert_eq!(result.capturing_owner, Some(3)); + // Owner 3's fronts tie at 10 surviving; the smaller id wins. + assert_eq!(result.capturing_front, Some(2)); + + // Exact owner tie: 7 vs 3 both survive 10; smaller owner id captures. + let tied = [ + owned_front(4, 7, Axial::new(1, 0), 0, 10), + owned_front(2, 3, Axial::new(0, 1), 0, 10), + ]; + let result = + resolve_edge_combat(Axial::ZERO, 0, 0, &tied, &CombatConfig::default()).unwrap(); + assert_eq!(result.capturing_owner, Some(3)); + assert_eq!(result.capturing_front, Some(2)); + } + + #[test] + fn sub_lethal_attrition_conserves_strength_and_converges() { + let config = CombatConfig { + attacker_damage_bps: 2_500, + defender_damage_bps: 1_500, + ..CombatConfig::default() + }; + let mut defender = 40_u64; + let mut offered = [30_u64, 25]; + let origins = [Axial::new(1, 0), Axial::new(0, 1)]; + let initial_total = defender + offered.iter().sum::(); + let mut total_casualties = 0_u64; + let mut steps = 0_usize; + while defender > 0 && offered.iter().any(|&strength| strength > 0) { + steps += 1; + assert!(steps <= 200, "sub-lethal attrition must converge"); + let attacks: Vec<_> = origins + .iter() + .zip(offered) + .enumerate() + .filter(|&(_, (_, strength))| strength > 0) + .map(|(index, (&from, strength))| owned_front(index as u64, 1, from, 0, strength)) + .collect(); + let result = resolve_edge_combat(Axial::ZERO, defender, 0, &attacks, &config).unwrap(); + assert!(result.rejected.is_empty()); + // Sub-lethal steps may deal zero casualties on tiny remainders; + // the authoritative module layers its minimum-casualty rule on + // top. Emulate it here so the fixture always converges. + let mut defender_casualties = result.defender_casualties; + if defender_casualties == 0 && defender > 0 { + defender_casualties = 1; + } + defender_casualties = defender_casualties.min(defender); + defender -= defender_casualties; + total_casualties += defender_casualties; + for (index, strength) in offered.iter_mut().enumerate() { + if let Some(outcome) = result.attacks.get(&(index as u64)) { + assert!(outcome.attacker_casualties <= *strength); + *strength -= outcome.attacker_casualties; + total_casualties += outcome.attacker_casualties; + } + } + assert_eq!( + defender + offered.iter().sum::() + total_casualties, + initial_total, + "attrition must conserve total strength every step" + ); + } + assert_eq!(defender, 0, "attackers eventually eliminate the defender"); + assert!(offered.iter().sum::() > 0); + } + + #[test] + fn sub_lethal_multi_owner_attrition_is_deterministic() { + let config = CombatConfig { + attacker_damage_bps: 3_000, + defender_damage_bps: 3_000, + ..CombatConfig::default() + }; + let run = || { + let mut defender = 60_u64; + let mut offered = BTreeMap::from([(1_u32, 35_u64), (2, 35)]); + let origins = BTreeMap::from([(1_u32, Axial::new(1, 0)), (2, Axial::new(0, 1))]); + let mut trace = Vec::new(); + for _ in 0..50 { + if defender == 0 { + break; + } + let attacks: Vec<_> = offered + .iter() + .filter(|&(_, &strength)| strength > 0) + .map(|(&owner, &strength)| { + owned_front(u64::from(owner), owner, origins[&owner], 0, strength) + }) + .collect(); + if attacks.is_empty() { + break; + } + let result = + resolve_edge_combat(Axial::ZERO, defender, 0, &attacks, &config).unwrap(); + defender = result.defender_remaining; + for outcome in result.attacks.values() { + *offered.get_mut(&outcome.attacker).unwrap() = outcome.attacker_remaining; + } + trace.push((defender, offered.clone(), result.capturing_owner)); + } + trace + }; + assert_eq!(run(), run()); + let trace = run(); + let (final_defender, final_offered, capture) = trace.last().unwrap().clone(); + assert_eq!(final_defender, 0); + // Symmetric owners tie on survivors; the smaller owner captures. + assert_eq!(final_offered[&1], final_offered[&2]); + assert_eq!(capture, Some(1)); } } diff --git a/crates/hex-core/src/coord.rs b/crates/hex-core/src/coord.rs index d56a353..b1a4a07 100644 --- a/crates/hex-core/src/coord.rs +++ b/crates/hex-core/src/coord.rs @@ -143,13 +143,46 @@ impl HexDirection { } /// A canonical undirected edge between adjacent hexes. +/// +/// Deserialization enforces the constructor invariants: the endpoints must be +/// adjacent and stored in canonical `a <= b` order, so a snapshot cannot smuggle +/// in an edge that API-built state could never contain. #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(try_from = "RawHexEdge"))] #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct HexEdge { pub a: Axial, pub b: Axial, } +#[cfg(feature = "serde")] +#[derive(serde::Deserialize)] +struct RawHexEdge { + a: Axial, + b: Axial, +} + +#[cfg(feature = "serde")] +impl TryFrom for HexEdge { + type Error = String; + + fn try_from(raw: RawHexEdge) -> Result { + let edge = HexEdge::new(raw.a, raw.b).ok_or_else(|| { + format!( + "hex edge endpoints are not adjacent: {:?} and {:?}", + raw.a, raw.b + ) + })?; + if edge.a != raw.a { + return Err(format!( + "hex edge is not in canonical a <= b order: {:?} and {:?}", + raw.a, raw.b + )); + } + Ok(edge) + } +} + impl HexEdge { pub fn new(first: Axial, second: Axial) -> Option { if first.distance(second) != 1 { @@ -259,6 +292,23 @@ mod tests { } } + #[cfg(feature = "serde")] + #[test] + fn hex_edges_round_trip_and_reject_non_canonical_input() { + let edge = HexEdge::new(Axial::new(1, 0), Axial::new(0, 0)).unwrap(); + assert_eq!(edge.a, Axial::new(0, 0), "constructor canonicalizes order"); + let json = serde_json::to_string(&edge).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), edge); + + let swapped = r#"{"a":{"q":1,"r":0},"b":{"q":0,"r":0}}"#; + let error = serde_json::from_str::(swapped).unwrap_err(); + assert!(error.to_string().contains("canonical"), "{error}"); + + let non_adjacent = r#"{"a":{"q":0,"r":0},"b":{"q":2,"r":0}}"#; + let error = serde_json::from_str::(non_adjacent).unwrap_err(); + assert!(error.to_string().contains("not adjacent"), "{error}"); + } + #[test] fn invalid_chunks_are_rejected() { assert_eq!(Axial::ZERO.chunk_address(0), None); diff --git a/crates/hex-core/src/front.rs b/crates/hex-core/src/front.rs index cce25ea..a46a2f4 100644 --- a/crates/hex-core/src/front.rs +++ b/crates/hex-core/src/front.rs @@ -603,23 +603,20 @@ fn fronts_from_marked_cycle(cycle: &[(DirectedFrontEdge, BoundaryMark)]) -> Vec< fronts } -/// Groups neutral edges by the hostile fronts encountered immediately before -/// and after them around one geometric perimeter cycle. +/// Groups neutral edges by the hostile front **instances** encountered +/// immediately before and after them around one geometric perimeter cycle. /// -/// The ordered pair matters: on a ring containing hostile fronts A and B, the -/// neutral A→B section and the neutral B→A section are independent. Repeated -/// A→A sections coalesce and may overlap the hostile A front as bridge edges. +/// Bounding is by instance, not by opponent id: on a ring with the perimeter +/// pattern A,N1,B,N2,A,N3,B,N4, the four neutral sections stay four +/// independent fronts even though N1/N3 (and N2/N4) share the same bounding +/// opponent ids. Same-opponent hostile runs separated only by neutral edges +/// merge into one instance (mirroring the hostile H–Neutral–H bridging), so +/// neutral sections interrupted only by repeated contact with the same +/// hostile front still coalesce, and bridge edges may overlap that hostile +/// front. Ignored markers never split a neutral section by themselves. fn neutral_fronts_from_marked_cycle( cycle: &[(DirectedFrontEdge, BoundaryMark)], ) -> Vec { - let hostile_positions = cycle - .iter() - .enumerate() - .filter_map(|(index, (_, mark))| match mark { - BoundaryMark::Active(Some(opponent)) => Some((index, *opponent)), - BoundaryMark::Ignored | BoundaryMark::Active(None) => None, - }) - .collect::>(); let neutral_edges = cycle .iter() .filter_map(|(edge, mark)| matches!(mark, BoundaryMark::Active(None)).then_some(*edge)) @@ -627,37 +624,131 @@ fn neutral_fronts_from_marked_cycle( if neutral_edges.is_empty() { return Vec::new(); } - if hostile_positions.is_empty() { + + // Maximal same-opponent hostile runs in cyclic order. Each run stores its + // edge indices in cyclic traversal order; the wrap run (crossing index 0) + // keeps its tail-then-head ordering. + let length = cycle.len(); + let opponent_at = |index: usize| match cycle[index].1 { + BoundaryMark::Active(Some(opponent)) => Some(opponent), + BoundaryMark::Ignored | BoundaryMark::Active(None) => None, + }; + let mut runs = Vec::<(u32, Vec)>::new(); + for index in 0..length { + let Some(opponent) = opponent_at(index) else { + continue; + }; + match runs.last_mut() { + Some((run_opponent, indices)) + if *run_opponent == opponent + && *indices.last().expect("runs are nonempty") + 1 == index => + { + indices.push(index); + } + _ => runs.push((opponent, vec![index])), + } + } + if runs.is_empty() { return vec![StrategicFront { opponent: None, edges: neutral_edges, }]; } + if runs.len() > 1 + && runs[0].1[0] == 0 + && *runs + .last() + .expect("runs are nonempty") + .1 + .last() + .expect("runs are nonempty") + == length - 1 + && runs[0].0 == runs.last().expect("runs are nonempty").0 + { + let head = runs.remove(0); + runs.last_mut().expect("runs are nonempty").1.extend(head.1); + } - let mut edges_by_context = BTreeMap::<(u32, u32), Vec>::new(); + // Union same-opponent runs whose separating gap is purely neutral: those + // runs bridge into one hostile front instance, so the neutral sections on + // both sides are bounded by the same instance. + let mut parent: Vec = (0..runs.len()).collect(); + fn find(parent: &[usize], mut index: usize) -> usize { + while parent[index] != index { + index = parent[index]; + } + index + } + if runs.len() > 1 { + for run_index in 0..runs.len() { + let next_index = (run_index + 1) % runs.len(); + if runs[run_index].0 != runs[next_index].0 { + continue; + } + let gap_start = (runs[run_index].1.last().expect("runs are nonempty") + 1) % length; + let gap_end = runs[next_index].1[0]; + let mut cursor = gap_start; + let mut purely_neutral = true; + while cursor != gap_end { + if !matches!(cycle[cursor].1, BoundaryMark::Active(None)) { + purely_neutral = false; + break; + } + cursor = (cursor + 1) % length; + } + if purely_neutral { + let left = find(&parent, run_index); + let right = find(&parent, next_index); + let (root, child) = if left <= right { + (left, right) + } else { + (right, left) + }; + parent[child] = root; + } + } + } + + // Nearest hostile run before/after every cycle index, cyclically. + let mut run_at = vec![None::; length]; + for (run_index, (_, indices)) in runs.iter().enumerate() { + for &index in indices { + run_at[index] = Some(run_index); + } + } + let mut next_run = vec![usize::MAX; length]; + let mut carry = usize::MAX; + for scan in (0..length * 2).rev() { + let index = scan % length; + if let Some(run_index) = run_at[index] { + carry = run_index; + } + if scan < length { + next_run[index] = carry; + } + } + let mut previous_run = vec![usize::MAX; length]; + let mut carry = usize::MAX; + for scan in 0..length * 2 { + let index = scan % length; + if let Some(run_index) = run_at[index] { + carry = run_index; + } + if scan >= length { + previous_run[index] = carry; + } + } + + let mut edges_by_context = BTreeMap::<(usize, usize), Vec>::new(); for (index, (edge, mark)) in cycle.iter().enumerate() { if !matches!(mark, BoundaryMark::Active(None)) { continue; } - let next_at = hostile_positions.partition_point(|(position, _)| *position <= index); - let previous_at = hostile_positions.partition_point(|(position, _)| *position < index); - let previous = if previous_at == 0 { - hostile_positions - .last() - .expect("hostile positions is nonempty") - .1 - } else { - hostile_positions[previous_at - 1].1 - }; - let next = if next_at == hostile_positions.len() { - hostile_positions[0].1 - } else { - hostile_positions[next_at].1 - }; - edges_by_context - .entry((previous, next)) - .or_default() - .push(*edge); + let context = ( + find(&parent, previous_run[index]), + find(&parent, next_run[index]), + ); + edges_by_context.entry(context).or_default().push(*edge); } edges_by_context @@ -1295,6 +1386,66 @@ mod tests { assert_eq!(fronts.last().map(|front| front.opponent), Some(None)); } + #[test] + fn disconnected_neutral_arcs_between_repeating_opponents_stay_independent() { + // Perimeter pattern A,N1,B,N2,A,N3,B,N4: bounding opponent ids repeat + // (both N1 and N3 sit between an A and a B run), but each neutral + // section is bounded by different hostile front instances and must + // remain independent. + let edge = |index: i32| DirectedFrontEdge { + source: Axial::ZERO, + target: Axial::new(index, -index), + }; + let hostile = + |index: i32, opponent: u32| (edge(index), BoundaryMark::Active(Some(opponent))); + let neutral = |index: i32| (edge(index), BoundaryMark::Active(None)); + let cycle = [ + hostile(0, 1), // A + neutral(1), // N1 + hostile(2, 2), // B + neutral(3), // N2 + hostile(4, 1), // A again + neutral(5), // N3 + hostile(6, 2), // B again + neutral(7), // N4 + ]; + let fronts = neutral_fronts_from_marked_cycle(&cycle); + assert_eq!( + fronts.len(), + 4, + "each neutral section is bounded by distinct front instances: {fronts:?}" + ); + let mut singles = fronts + .iter() + .map(|front| { + assert_eq!(front.opponent, None); + assert_eq!(front.edges.len(), 1, "{fronts:?}"); + front.edges[0] + }) + .collect::>(); + singles.sort_unstable(); + assert_eq!(singles, vec![edge(1), edge(3), edge(5), edge(7)]); + } + + #[test] + fn neutral_sections_touching_only_one_bridged_front_still_coalesce() { + // A,N1,A,N2: the two A runs bridge into one hostile front instance, so + // both neutral sections share the same bounding instance and merge. + let edge = |index: i32| DirectedFrontEdge { + source: Axial::ZERO, + target: Axial::new(index, -index), + }; + let cycle = [ + (edge(0), BoundaryMark::Active(Some(1))), + (edge(1), BoundaryMark::Active(None)), + (edge(2), BoundaryMark::Active(Some(1))), + (edge(3), BoundaryMark::Active(None)), + ]; + let fronts = neutral_fronts_from_marked_cycle(&cycle); + assert_eq!(fronts.len(), 1, "{fronts:?}"); + assert_eq!(fronts[0].edges, vec![edge(1), edge(3)]); + } + #[test] fn strategic_fronts_are_input_order_independent() { let cells = [Axial::new(0, 0), Axial::new(1, 0), Axial::new(0, 1)]; diff --git a/crates/hex-core/src/lib.rs b/crates/hex-core/src/lib.rs index de65a5f..cd4e210 100644 --- a/crates/hex-core/src/lib.rs +++ b/crates/hex-core/src/lib.rs @@ -23,7 +23,8 @@ pub use branching::{ weighted_branch_quotas_rotated, }; pub use combat::{ - AttackFront, AttackOutcome, CombatConfig, CombatError, CombatResolution, resolve_edge_combat, + AttackFront, AttackOutcome, CombatConfig, CombatError, CombatResolution, FrontRejection, + RejectedFront, resolve_edge_combat, select_capture, }; pub use connectivity::{connected_components, owned_components}; pub use conquest::{ConquestError, ConquestProgress, ConquestRule}; diff --git a/crates/hex-core/src/map.rs b/crates/hex-core/src/map.rs index 8307661..e9d596a 100644 --- a/crates/hex-core/src/map.rs +++ b/crates/hex-core/src/map.rs @@ -40,7 +40,12 @@ impl TerrainKind { } /// Static and dynamic state at one authoritative gameplay hex. +/// +/// Deserialization re-validates constructor invariants (capacity bounds and +/// water-cell emptiness) so a snapshot cannot smuggle in a cell that +/// API-built state could never contain. #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(try_from = "RawCell"))] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Cell { pub coordinate: Axial, @@ -55,6 +60,43 @@ pub struct Cell { pub military_capacity: Strength, } +#[cfg(feature = "serde")] +#[derive(serde::Deserialize)] +struct RawCell { + coordinate: Axial, + terrain: TerrainKind, + elevation: i16, + capturable: bool, + habitable: bool, + owner: Option, + civilian_population: u64, + civilian_capacity: u64, + forces: ForceComposition, + military_capacity: Strength, +} + +#[cfg(feature = "serde")] +impl TryFrom for Cell { + type Error = String; + + fn try_from(raw: RawCell) -> Result { + let cell = Self { + coordinate: raw.coordinate, + terrain: raw.terrain, + elevation: raw.elevation, + capturable: raw.capturable, + habitable: raw.habitable, + owner: raw.owner, + civilian_population: raw.civilian_population, + civilian_capacity: raw.civilian_capacity, + forces: raw.forces, + military_capacity: raw.military_capacity, + }; + cell.validate()?; + Ok(cell) + } +} + impl Cell { pub fn ground( coordinate: Axial, @@ -91,6 +133,39 @@ impl Cell { } } + /// Rejects capacity overflows and water cells that carry land state. + pub fn validate(&self) -> Result<(), String> { + if self.force() > self.military_capacity { + return Err(format!( + "cell {:?} force {} exceeds military capacity {}", + self.coordinate, + self.force(), + self.military_capacity + )); + } + if self.civilian_population > self.civilian_capacity { + return Err(format!( + "cell {:?} civilian population {} exceeds capacity {}", + self.coordinate, self.civilian_population, self.civilian_capacity + )); + } + if self.terrain == TerrainKind::Water + && (self.capturable + || self.habitable + || self.owner.is_some() + || self.military_capacity != 0 + || self.force() != 0 + || self.civilian_population != 0 + || self.civilian_capacity != 0) + { + return Err(format!( + "water cell {:?} must be empty, unowned, and non-capturable", + self.coordinate + )); + } + Ok(()) + } + pub const fn force(&self) -> Strength { self.forces.weighted_strength() } @@ -195,13 +270,55 @@ impl LogisticsConfig { } /// A deterministic map container. Ordered maps keep iteration stable everywhere. +/// +/// Deserialization re-validates the constructor invariants: every cell must be +/// keyed by its own coordinate and every edge-limit override must connect two +/// existing cells, so a snapshot load can never diverge from API-built state. #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(try_from = "RawHexMap"))] #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct HexMap { cells: BTreeMap, edge_limits: BTreeMap, } +#[cfg(feature = "serde")] +#[derive(serde::Deserialize)] +struct RawHexMap { + cells: BTreeMap, + edge_limits: BTreeMap, +} + +#[cfg(feature = "serde")] +impl TryFrom for HexMap { + type Error = String; + + fn try_from(raw: RawHexMap) -> Result { + for (&key, cell) in &raw.cells { + if key != cell.coordinate { + return Err(format!( + "map cell keyed at {key:?} disagrees with its coordinate {:?}", + cell.coordinate + )); + } + } + // `HexEdge` deserialization already enforced adjacency and canonical + // order; only endpoint existence remains map-level. + for edge in raw.edge_limits.keys() { + if !raw.cells.contains_key(&edge.a) || !raw.cells.contains_key(&edge.b) { + return Err(format!( + "edge limits reference missing cells: {:?} and {:?}", + edge.a, edge.b + )); + } + } + Ok(Self { + cells: raw.cells, + edge_limits: raw.edge_limits, + }) + } +} + impl HexMap { pub fn new() -> Self { Self::default() @@ -311,6 +428,142 @@ mod tests { assert_eq!(map.edge_limits(b, a, &config), Some(narrow)); } + #[cfg(feature = "serde")] + #[test] + fn deserialized_maps_reject_key_and_edge_invariant_violations() { + let mut map = HexMap::new(); + map.insert(Cell::ground(Axial::ZERO, 0, Some(1), 100)); + map.insert(Cell::ground(Axial::new(1, 0), 0, Some(1), 100)); + assert!(map.set_edge_limits( + Axial::ZERO, + Axial::new(1, 0), + EdgeLimits { + throughput: 5, + frontage: 6, + }, + )); + + // Round trip through the raw wire shape preserves valid state. + let raw = RawHexMap { + cells: map.cells.clone(), + edge_limits: map.edge_limits.clone(), + }; + assert_eq!(HexMap::try_from(raw).unwrap(), map); + + // A cell keyed away from its own coordinate is rejected. + let mut misfiled = map.cells.clone(); + let stray = Cell::ground(Axial::new(5, 5), 0, Some(1), 100); + misfiled.insert(Axial::new(9, 9), stray); + let error = HexMap::try_from(RawHexMap { + cells: misfiled, + edge_limits: BTreeMap::new(), + }) + .unwrap_err(); + assert!(error.contains("disagrees"), "{error}"); + + // Edge limits over missing cells are rejected. + let dangling = HexEdge::new(Axial::new(7, 0), Axial::new(8, 0)).unwrap(); + let error = HexMap::try_from(RawHexMap { + cells: map.cells.clone(), + edge_limits: BTreeMap::from([( + dangling, + EdgeLimits { + throughput: 1, + frontage: 1, + }, + )]), + }) + .unwrap_err(); + assert!(error.contains("missing cells"), "{error}"); + } + + #[cfg(feature = "serde")] + #[test] + fn deserialized_cells_reject_capacity_and_water_invariant_violations() { + let ground = Cell::ground(Axial::ZERO, 0, Some(1), 100); + let water = Cell::water(Axial::new(1, 0), 0); + assert_eq!( + Cell::try_from(RawCell { + coordinate: ground.coordinate, + terrain: ground.terrain, + elevation: ground.elevation, + capturable: ground.capturable, + habitable: ground.habitable, + owner: ground.owner, + civilian_population: ground.civilian_population, + civilian_capacity: ground.civilian_capacity, + forces: ground.forces, + military_capacity: ground.military_capacity, + }) + .unwrap(), + ground + ); + assert_eq!( + Cell::try_from(RawCell { + coordinate: water.coordinate, + terrain: water.terrain, + elevation: water.elevation, + capturable: water.capturable, + habitable: water.habitable, + owner: water.owner, + civilian_population: water.civilian_population, + civilian_capacity: water.civilian_capacity, + forces: water.forces, + military_capacity: water.military_capacity, + }) + .unwrap(), + water + ); + + let over_force = Cell::try_from(RawCell { + coordinate: Axial::ZERO, + terrain: TerrainKind::Plains, + elevation: 0, + capturable: true, + habitable: true, + owner: Some(1), + civilian_population: 0, + civilian_capacity: 0, + forces: ForceComposition::infantry(101), + military_capacity: 100, + }) + .unwrap_err(); + assert!(over_force.contains("military capacity"), "{over_force}"); + + let over_civilians = Cell::try_from(RawCell { + coordinate: Axial::ZERO, + terrain: TerrainKind::Plains, + elevation: 0, + capturable: true, + habitable: true, + owner: Some(1), + civilian_population: 11, + civilian_capacity: 10, + forces: ForceComposition::default(), + military_capacity: 100, + }) + .unwrap_err(); + assert!( + over_civilians.contains("civilian population"), + "{over_civilians}" + ); + + let wet_garrison = Cell::try_from(RawCell { + coordinate: Axial::ZERO, + terrain: TerrainKind::Water, + elevation: 0, + capturable: false, + habitable: false, + owner: None, + civilian_population: 0, + civilian_capacity: 0, + forces: ForceComposition::infantry(1), + military_capacity: 1, + }) + .unwrap_err(); + assert!(wet_garrison.contains("water cell"), "{wet_garrison}"); + } + #[test] fn force_composition_and_capacity_stay_separate() { let mut cell = Cell::ground(Axial::ZERO, 0, Some(1), 100); diff --git a/crates/hex-core/src/movement.rs b/crates/hex-core/src/movement.rs index 98120ca..ba5d74e 100644 --- a/crates/hex-core/src/movement.rs +++ b/crates/hex-core/src/movement.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::{ coord::{Axial, HexEdge}, map::{HexMap, LogisticsConfig, MovementConfig, PlayerId, Strength, ground_traversal}, - pathfinding::{Path, shortest_path}, + pathfinding::Path, }; #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] @@ -54,6 +54,10 @@ pub struct TransferPlan { /// /// Candidate pairs are considered by `(route cost, source, destination)`, so /// allocation and route choice remain stable across selection gesture order. +/// +/// Routing runs one reverse Dijkstra per destination over the owner's cells +/// (instead of one A* per source×destination pair), so the pair cost matrix +/// and therefore the allocation order match the previous per-pair search. pub fn plan_transfer( map: &HexMap, request: &TransferRequest, @@ -99,11 +103,10 @@ pub fn plan_transfer( let mut candidates = Vec::new(); let mut reachable_sources = BTreeSet::new(); let mut reachable_destinations = BTreeSet::new(); - for &source in &sources { - for &destination in &destinations { - let Some(path) = shortest_path(map, source, destination, movement, |cell| { - cell.owner == Some(request.owner) - }) else { + for &destination in &destinations { + let field = destination_route_field(map, destination, movement, request.owner); + for &source in &sources { + let Some(path) = field.route_from(source) else { continue; }; reachable_sources.insert(source); @@ -186,6 +189,97 @@ pub fn plan_transfer( }) } +/// Shortest-route costs from every cell of one owner's component toward a +/// single destination, with forward `next` pointers for path reconstruction. +struct DestinationRouteField { + destination: Axial, + /// Cost of the best route to the destination and the next hop toward it. + routes: BTreeMap, +} + +impl DestinationRouteField { + fn route_from(&self, source: Axial) -> Option { + if source == self.destination { + return Some(Path { + cells: vec![source], + total_cost: 0, + }); + } + let (total_cost, _) = *self.routes.get(&source)?; + let mut cells = vec![source]; + let mut current = source; + while current != self.destination { + let (_, next) = *self.routes.get(¤t)?; + cells.push(next); + current = next; + } + Some(Path { cells, total_cost }) + } +} + +/// One deterministic reverse Dijkstra from `destination` across cells owned by +/// `owner`. Edge costs are directional, so relaxation walks edges in the +/// travel direction (`neighbor -> current`). The ordered frontier, sorted +/// neighbor iteration, and strict-improvement rule make the field independent +/// of map insertion order. +fn destination_route_field( + map: &HexMap, + destination: Axial, + movement: &MovementConfig, + owner: PlayerId, +) -> DestinationRouteField { + let mut field = DestinationRouteField { + destination, + routes: BTreeMap::new(), + }; + let Some(destination_cell) = map.get(destination) else { + return field; + }; + if destination_cell.owner != Some(owner) { + return field; + } + + let mut frontier = BTreeSet::from([(0_u64, destination)]); + let mut distances = BTreeMap::from([(destination, 0_u64)]); + let mut visited = BTreeSet::new(); + while let Some((cost, current)) = frontier.pop_first() { + if !visited.insert(current) { + continue; + } + let current_cell = map.get(current).expect("visited cells exist"); + let mut neighbors = current.neighbors(); + neighbors.sort_unstable(); + for neighbor in neighbors { + if visited.contains(&neighbor) { + continue; + } + let Some(neighbor_cell) = map.get(neighbor) else { + continue; + }; + if neighbor_cell.owner != Some(owner) { + continue; + } + // Travel direction is neighbor -> current, toward the destination. + let Some(traversal) = ground_traversal(neighbor_cell, current_cell, movement) else { + continue; + }; + let Some(candidate) = cost.checked_add(u64::from(traversal.cost)) else { + continue; + }; + let best = distances.get(&neighbor).copied().unwrap_or(u64::MAX); + if candidate < best { + if best != u64::MAX { + frontier.remove(&(best, neighbor)); + } + distances.insert(neighbor, candidate); + field.routes.insert(neighbor, (candidate, current)); + frontier.insert((candidate, neighbor)); + } + } + } + field +} + #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct MovementIntent { @@ -226,7 +320,6 @@ pub struct MovementOutcome { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MovementError { DuplicateIntentId(u64), - CellOverCapacity(Axial), ArithmeticOverflow, } @@ -237,7 +330,12 @@ pub struct MovementStep { /// Sum crossing edges this step. This is not a unique-unit count when a /// pipeline contains multiple independently occupied cells. pub moved_total: Strength, + /// Total force over the cells referenced by the submitted intents (their + /// existing sources and destinations) before the step. The release hot + /// path deliberately never scans the whole map; full-map conservation is + /// still asserted in debug builds. pub strength_before: Strength, + /// Total force over the same referenced cells after the step. pub strength_after: Strength, } @@ -259,6 +357,11 @@ struct FlowTotals { /// backpressure until every final occupancy is within capacity, and only then /// commits all deltas. This allows a full column to advance as a simultaneous /// pipeline while preventing a blocked downstream cell from creating overflow. +/// +/// Failure locality: all checks are scoped to the cells the intents actually +/// touch. A cell that is already over capacity (a pre-existing inconsistency +/// elsewhere in the map, or on a touched route) never blocks unrelated +/// movement; such a cell may drain but is never approved for a net gain. pub fn movement_step( map: &mut HexMap, intents: &[MovementIntent], @@ -271,12 +374,14 @@ pub fn movement_step( return Err(MovementError::DuplicateIntentId(intent.id)); } } - for cell in map.cells() { - if cell.force() > cell.military_capacity { - return Err(MovementError::CellOverCapacity(cell.coordinate)); - } - } - let strength_before = checked_total_force(map)?; + let referenced: BTreeSet = intents + .iter() + .flat_map(|intent| [intent.from, intent.to]) + .filter(|&coordinate| map.get(coordinate).is_some()) + .collect(); + let strength_before = total_force_at(map, &referenced)?; + #[cfg(debug_assertions)] + let full_strength_before = checked_total_force(map)?; let mut sorted = intents.to_vec(); sorted.sort_unstable_by_key(|intent| (intent.priority, intent.id)); @@ -366,11 +471,13 @@ pub fn movement_step( let amount_out = totals.outgoing.get(&coordinate).copied().unwrap_or(0); let final_force = u128::from(cell.force()) + u128::from(amount_in) - u128::from(amount_out); - if final_force > u128::from(cell.military_capacity) { - overfull = Some(( - coordinate, - (final_force - u128::from(cell.military_capacity)) as u64, - )); + // A cell already over capacity may drain but never gains: its + // effective bound is its current force, so reducing inbound flow + // to zero always suffices and the loop still terminates. + let effective_capacity = + u128::from(cell.military_capacity).max(u128::from(cell.force())); + if final_force > effective_capacity { + overfull = Some((coordinate, (final_force - effective_capacity) as u64)); break; } } @@ -418,6 +525,7 @@ pub fn movement_step( let amount_in = totals.incoming.get(&coordinate).copied().unwrap_or(0); let amount_out = totals.outgoing.get(&coordinate).copied().unwrap_or(0); let cell = map.get_mut(coordinate).expect("validated movement cell"); + let effective_capacity = cell.military_capacity.max(cell.force()); let remaining = cell .forces .infantry @@ -426,7 +534,7 @@ pub fn movement_step( cell.forces.infantry = remaining .checked_add(amount_in) .ok_or(MovementError::ArithmeticOverflow)?; - debug_assert!(cell.force() <= cell.military_capacity); + debug_assert!(cell.force() <= effective_capacity); } let moved_total = approvals.values().try_fold(0_u64, |total, amount| { @@ -434,8 +542,16 @@ pub fn movement_step( .checked_add(*amount) .ok_or(MovementError::ArithmeticOverflow) })?; - let strength_after = checked_total_force(map)?; + let strength_after = total_force_at(map, &referenced)?; debug_assert_eq!(strength_before, strength_after); + #[cfg(debug_assertions)] + { + let full_strength_after = checked_total_force(map)?; + debug_assert_eq!( + full_strength_before, full_strength_after, + "movement must conserve map-wide strength" + ); + } Ok(MovementStep { outcomes, @@ -465,6 +581,15 @@ fn flow_totals( Ok(totals) } +fn total_force_at(map: &HexMap, coordinates: &BTreeSet) -> Result { + coordinates.iter().try_fold(0_u64, |total, &coordinate| { + total + .checked_add(map.get(coordinate).map_or(0, |cell| cell.force())) + .ok_or(MovementError::ArithmeticOverflow) + }) +} + +#[cfg(debug_assertions)] fn checked_total_force(map: &HexMap) -> Result { map.cells().try_fold(0_u64, |total, cell| { total @@ -697,6 +822,178 @@ mod tests { } } + #[test] + fn planner_matches_the_legacy_per_pair_search_on_a_nontrivial_map() { + use crate::pathfinding::shortest_path; + + // Two disjoint clusters of sources/destinations joined by a ridge so + // uphill/downhill asymmetry, unequal route costs, and capacity limits + // all participate in the allocation order. + let mut map = HexMap::new(); + for q in 0..6 { + for r in 0..3 { + map.insert(Cell::ground(Axial::new(q, r), 0, Some(1), 100)); + } + } + map.get_mut(Axial::new(2, 1)).unwrap().elevation = 1; + map.get_mut(Axial::new(3, 0)).unwrap().elevation = 1; + map.get_mut(Axial::new(3, 2)).unwrap().elevation = 2; + map.get_mut(Axial::new(0, 0)).unwrap().forces = ForceComposition::infantry(60); + map.get_mut(Axial::new(0, 2)).unwrap().forces = ForceComposition::infantry(40); + map.get_mut(Axial::new(1, 1)).unwrap().forces = ForceComposition::infantry(70); + map.get_mut(Axial::new(5, 0)).unwrap().forces = ForceComposition::infantry(55); + let request = TransferRequest { + owner: 1, + sources: vec![ + Axial::new(0, 0), + Axial::new(0, 2), + Axial::new(1, 1), + Axial::new(5, 0), + ], + destinations: vec![Axial::new(4, 1), Axial::new(5, 2), Axial::new(2, 0)], + amount: 200, + }; + let movement = MovementConfig::default(); + + let plan = plan_transfer(&map, &request, &movement).unwrap(); + + // Legacy reference: one A* per pair, sorted and allocated identically. + let mut candidates = Vec::new(); + for &source in &request.sources { + for &destination in &request.destinations { + if let Some(path) = shortest_path(&map, source, destination, &movement, |cell| { + cell.owner == Some(request.owner) + }) { + candidates.push((path.total_cost, source, destination)); + } + } + } + candidates.sort_unstable(); + let mut source_available: BTreeMap<_, _> = request + .sources + .iter() + .map(|&source| (source, map.get(source).unwrap().force())) + .collect(); + let mut destination_available: BTreeMap<_, _> = request + .destinations + .iter() + .map(|&destination| { + ( + destination, + map.get(destination).unwrap().free_military_capacity(), + ) + }) + .collect(); + let mut remaining = request.amount; + let mut expected_legs = Vec::new(); + for (total_cost, source, destination) in candidates { + if remaining == 0 { + break; + } + let amount = remaining + .min(source_available[&source]) + .min(destination_available[&destination]); + if amount == 0 { + continue; + } + *source_available.get_mut(&source).unwrap() -= amount; + *destination_available.get_mut(&destination).unwrap() -= amount; + remaining -= amount; + expected_legs.push((source, destination, amount, total_cost)); + } + + assert!(!expected_legs.is_empty()); + assert_eq!( + plan.legs + .iter() + .map(|leg| (leg.source, leg.destination, leg.amount, leg.path.total_cost)) + .collect::>(), + expected_legs + ); + assert_eq!(plan.unplanned, remaining); + // Every planned route is a valid owned walk whose cost matches the + // reported total. + for leg in &plan.legs { + assert_eq!(leg.path.cells.first(), Some(&leg.source)); + assert_eq!(leg.path.cells.last(), Some(&leg.destination)); + let mut walked = 0_u64; + for pair in leg.path.cells.windows(2) { + let from = map.get(pair[0]).unwrap(); + let to = map.get(pair[1]).unwrap(); + assert_eq!(pair[0].distance(pair[1]), 1); + assert_eq!(to.owner, Some(1)); + walked += u64::from(ground_traversal(from, to, &movement).unwrap().cost); + } + assert_eq!(walked, leg.path.total_cost); + } + } + + #[test] + fn over_capacity_cell_elsewhere_does_not_block_unrelated_movement() { + let mut map = owned_line(4, 100); + map.get_mut(Axial::new(0, 0)).unwrap().forces = ForceComposition::infantry(50); + // Pre-existing inconsistency far from the moving units. + map.get_mut(Axial::new(3, 0)).unwrap().forces = ForceComposition::infantry(150); + let logistics = LogisticsConfig { + default_edge_throughput: 40, + ..LogisticsConfig::default() + }; + let result = movement_step( + &mut map, + &[intent(1, 0, 1, 30)], + &MovementConfig::default(), + &logistics, + ) + .unwrap(); + assert_eq!(result.outcomes[&1].approved, 30); + assert_eq!(map.get(Axial::new(1, 0)).unwrap().force(), 30); + assert_eq!(result.strength_before, result.strength_after); + // The distant over-capacity cell is untouched. + assert_eq!(map.get(Axial::new(3, 0)).unwrap().force(), 150); + } + + #[test] + fn over_capacity_cell_may_drain_but_never_gains() { + let build = || { + let mut map = owned_line(3, 100); + map.get_mut(Axial::new(0, 0)).unwrap().forces = ForceComposition::infantry(50); + map.get_mut(Axial::new(1, 0)).unwrap().forces = ForceComposition::infantry(150); + map + }; + let logistics = LogisticsConfig { + default_edge_throughput: 40, + ..LogisticsConfig::default() + }; + + let mut gaining = build(); + let result = movement_step( + &mut gaining, + &[intent(1, 0, 1, 30)], + &MovementConfig::default(), + &logistics, + ) + .unwrap(); + assert_eq!(result.outcomes[&1].approved, 0); + assert!( + result.outcomes[&1] + .limits + .contains(&MovementLimit::DestinationCapacity) + ); + assert_eq!(gaining.get(Axial::new(1, 0)).unwrap().force(), 150); + + let mut draining = build(); + let result = movement_step( + &mut draining, + &[intent(1, 1, 2, 30)], + &MovementConfig::default(), + &logistics, + ) + .unwrap(); + assert_eq!(result.outcomes[&1].approved, 30); + assert_eq!(draining.get(Axial::new(1, 0)).unwrap().force(), 120); + assert_eq!(draining.get(Axial::new(2, 0)).unwrap().force(), 30); + } + #[test] fn duplicate_ids_fail_without_mutating_the_map() { let mut map = owned_line(2, 100); diff --git a/crates/hex-core/tests/scale.rs b/crates/hex-core/tests/scale.rs new file mode 100644 index 0000000..16a379f --- /dev/null +++ b/crates/hex-core/tests/scale.rs @@ -0,0 +1,158 @@ +//! Larger-map kernel guard: correctness and exact conservation at 64x64. +//! +//! These tests primarily assert behavior at scale (conservation, capacity +//! safety, determinism). They deliberately avoid timing assertions so CI +//! machine variance cannot flake them; a pathological slowdown still surfaces +//! as a test-suite timeout. + +use hex_core::{ + Axial, Cell, ForceComposition, HexMap, LogisticsConfig, MovementConfig, MovementIntent, + TransferRequest, movement_step, plan_transfer, redistribution_targets_dense_with_weights, +}; + +const SIZE: i32 = 64; +const CAPACITY: u64 = 100; + +/// Deterministic LCG so the fixture is broad without an RNG dependency. +fn lcg(state: &mut u64) -> u64 { + *state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + *state +} + +/// A fully owned 64x64 map with gentle one-step elevation bands (never a +/// cliff) and pseudo-random garrisons within capacity. +fn build_map(seed: u64) -> HexMap { + let mut state = seed; + let mut map = HexMap::new(); + for q in 0..SIZE { + for r in 0..SIZE { + let elevation = ((q / 8 + r / 8) % 2) as i16; + let mut cell = Cell::ground(Axial::new(q, r), elevation, Some(1), CAPACITY); + cell.forces = ForceComposition::infantry(lcg(&mut state) % (CAPACITY + 1)); + map.insert(cell); + } + } + map +} + +#[test] +fn movement_step_conserves_strength_and_capacity_across_ticks_at_scale() { + let mut map = build_map(0x5eed_0001); + let movement = MovementConfig::default(); + let logistics = LogisticsConfig::default(); + let initial_total = map.total_force(); + let mut state = 0x5eed_0002_u64; + + for tick in 0..10_u64 { + let mut intents = Vec::new(); + for q in 0..SIZE { + for r in 0..SIZE { + let from = Axial::new(q, r); + // Alternate eastward and south-eastward waves across ticks. + let to = if tick % 2 == 0 { + Axial::new(q + 1, r) + } else { + Axial::new(q, r + 1) + }; + intents.push(MovementIntent { + id: intents.len() as u64 + 1, + priority: (lcg(&mut state) % 4) as u32, + owner: 1, + from, + to, + requested: lcg(&mut state) % 40, + }); + } + } + let step = movement_step(&mut map, &intents, &movement, &logistics) + .expect("large-map movement step succeeds"); + assert_eq!(step.strength_before, step.strength_after, "tick {tick}"); + assert_eq!(map.total_force(), initial_total, "tick {tick}"); + assert!( + map.cells() + .all(|cell| cell.force() <= cell.military_capacity), + "tick {tick} violated a capacity" + ); + } +} + +#[test] +fn plan_transfer_routes_column_to_column_deterministically_at_scale() { + let map = build_map(0x5eed_0003); + let sources: Vec = (0..SIZE).map(|r| Axial::new(0, r)).collect(); + let destinations: Vec = (0..SIZE).map(|r| Axial::new(SIZE - 1, r)).collect(); + let source_total: u64 = sources.iter().map(|&c| map.get(c).unwrap().force()).sum(); + let free_total: u64 = destinations + .iter() + .map(|&c| map.get(c).unwrap().free_military_capacity()) + .sum(); + let request = TransferRequest { + owner: 1, + sources, + destinations, + amount: 100_000, + }; + let movement = MovementConfig::default(); + + let plan = plan_transfer(&map, &request, &movement).expect("large-map plan succeeds"); + // The component is fully connected, so the plan saturates the binding + // constraint exactly. + assert_eq!( + plan.planned, + request.amount.min(source_total).min(free_total) + ); + assert_eq!(plan.planned + plan.unplanned, plan.requested); + assert_eq!( + plan.legs.iter().map(|leg| leg.amount).sum::(), + plan.planned + ); + assert!(plan.unreachable_sources.is_empty()); + assert!(plan.unreachable_destinations.is_empty()); + for leg in &plan.legs { + assert_eq!(leg.path.cells.first(), Some(&leg.source)); + assert_eq!(leg.path.cells.last(), Some(&leg.destination)); + for pair in leg.path.cells.windows(2) { + assert_eq!(pair[0].distance(pair[1]), 1); + } + } + + let repeat = plan_transfer(&map, &request, &movement).expect("plan repeats"); + assert_eq!(plan, repeat, "planning must be deterministic"); +} + +#[test] +fn dense_redistribution_is_exact_and_capacity_safe_at_scale() { + let map = build_map(0x5eed_0004); + let coordinates: Vec = map.coordinates().collect(); + let capacities: Vec = coordinates + .iter() + .map(|&c| map.get(c).unwrap().military_capacity) + .collect(); + let mut state = 0x5eed_0005_u64; + let weights: Vec = coordinates + .iter() + .map(|_| (lcg(&mut state) % 7 + 1) as u32) + .collect(); + let total_strength = map.total_force(); + + let distribution = redistribution_targets_dense_with_weights( + &coordinates, + &capacities, + total_strength, + weights, + ) + .expect("large-map redistribution succeeds"); + assert_eq!( + distribution.assigned + distribution.unassigned, + total_strength + ); + assert_eq!( + distribution.targets.iter().sum::(), + distribution.assigned + ); + for (target, capacity) in distribution.targets.iter().zip(&capacities) { + assert!(target <= capacity); + } +} diff --git a/crates/match-bindings/src/module_bindings/command_watermark_type.rs b/crates/match-bindings/src/module_bindings/command_watermark_type.rs new file mode 100644 index 0000000..9e7b54f --- /dev/null +++ b/crates/match-bindings/src/module_bindings/command_watermark_type.rs @@ -0,0 +1,55 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CommandWatermark { + pub player_id: u16, + pub highest_client_command_id: u64, +} + +impl __sdk::InModule for CommandWatermark { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `CommandWatermark`. +/// +/// Provides typed access to columns for query building. +pub struct CommandWatermarkCols { + pub player_id: __sdk::__query_builder::Col, + pub highest_client_command_id: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for CommandWatermark { + type Cols = CommandWatermarkCols; + fn cols(table_name: &'static str) -> Self::Cols { + CommandWatermarkCols { + player_id: __sdk::__query_builder::Col::new(table_name, "player_id"), + highest_client_command_id: __sdk::__query_builder::Col::new( + table_name, + "highest_client_command_id", + ), + } + } +} + +/// Indexed column accessor struct for the table `CommandWatermark`. +/// +/// Provides typed access to indexed columns for query building. +pub struct CommandWatermarkIxCols { + pub player_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for CommandWatermark { + type IxCols = CommandWatermarkIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + CommandWatermarkIxCols { + player_id: __sdk::__query_builder::IxCol::new(table_name, "player_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for CommandWatermark {} diff --git a/crates/match-bindings/src/module_bindings/debug_break_order_conservation_reducer.rs b/crates/match-bindings/src/module_bindings/debug_break_order_conservation_reducer.rs new file mode 100644 index 0000000..b7a1c90 --- /dev/null +++ b/crates/match-bindings/src/module_bindings/debug_break_order_conservation_reducer.rs @@ -0,0 +1,72 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct DebugBreakOrderConservationArgs { + pub order_id: u64, +} + +impl From for super::Reducer { + fn from(args: DebugBreakOrderConservationArgs) -> Self { + Self::DebugBreakOrderConservation { + order_id: args.order_id, + } + } +} + +impl __sdk::InModule for DebugBreakOrderConservationArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `debug_break_order_conservation`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait debug_break_order_conservation { + /// Request that the remote module invoke the reducer `debug_break_order_conservation` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`debug_break_order_conservation:debug_break_order_conservation_then`] to run a callback after the reducer completes. + fn debug_break_order_conservation(&self, order_id: u64) -> __sdk::Result<()> { + self.debug_break_order_conservation_then(order_id, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `debug_break_order_conservation` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn debug_break_order_conservation_then( + &self, + order_id: u64, + + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl debug_break_order_conservation for super::RemoteReducers { + fn debug_break_order_conservation_then( + &self, + order_id: u64, + + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(DebugBreakOrderConservationArgs { order_id }, callback) + } +} diff --git a/crates/match-bindings/src/module_bindings/debug_harness_type.rs b/crates/match-bindings/src/module_bindings/debug_harness_type.rs new file mode 100644 index 0000000..ce47d33 --- /dev/null +++ b/crates/match-bindings/src/module_bindings/debug_harness_type.rs @@ -0,0 +1,52 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct DebugHarness { + pub singleton_id: u8, + pub enabled: bool, +} + +impl __sdk::InModule for DebugHarness { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `DebugHarness`. +/// +/// Provides typed access to columns for query building. +pub struct DebugHarnessCols { + pub singleton_id: __sdk::__query_builder::Col, + pub enabled: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for DebugHarness { + type Cols = DebugHarnessCols; + fn cols(table_name: &'static str) -> Self::Cols { + DebugHarnessCols { + singleton_id: __sdk::__query_builder::Col::new(table_name, "singleton_id"), + enabled: __sdk::__query_builder::Col::new(table_name, "enabled"), + } + } +} + +/// Indexed column accessor struct for the table `DebugHarness`. +/// +/// Provides typed access to indexed columns for query building. +pub struct DebugHarnessIxCols { + pub singleton_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for DebugHarness { + type IxCols = DebugHarnessIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + DebugHarnessIxCols { + singleton_id: __sdk::__query_builder::IxCol::new(table_name, "singleton_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for DebugHarness {} diff --git a/crates/match-bindings/src/module_bindings/enable_debug_harness_reducer.rs b/crates/match-bindings/src/module_bindings/enable_debug_harness_reducer.rs new file mode 100644 index 0000000..ddc5374 --- /dev/null +++ b/crates/match-bindings/src/module_bindings/enable_debug_harness_reducer.rs @@ -0,0 +1,66 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct EnableDebugHarnessArgs {} + +impl From for super::Reducer { + fn from(args: EnableDebugHarnessArgs) -> Self { + Self::EnableDebugHarness + } +} + +impl __sdk::InModule for EnableDebugHarnessArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `enable_debug_harness`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait enable_debug_harness { + /// Request that the remote module invoke the reducer `enable_debug_harness` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`enable_debug_harness:enable_debug_harness_then`] to run a callback after the reducer completes. + fn enable_debug_harness(&self) -> __sdk::Result<()> { + self.enable_debug_harness_then(|_, _| {}) + } + + /// Request that the remote module invoke the reducer `enable_debug_harness` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn enable_debug_harness_then( + &self, + + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl enable_debug_harness for super::RemoteReducers { + fn enable_debug_harness_then( + &self, + + callback: impl FnOnce( + &super::ReducerEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(EnableDebugHarnessArgs {}, callback) + } +} diff --git a/crates/match-bindings/src/module_bindings/expansion_split_cursor_type.rs b/crates/match-bindings/src/module_bindings/expansion_split_cursor_type.rs new file mode 100644 index 0000000..08b711a --- /dev/null +++ b/crates/match-bindings/src/module_bindings/expansion_split_cursor_type.rs @@ -0,0 +1,60 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ExpansionSplitCursor { + pub cursor_key: u128, + pub order_id: u64, + pub cell_id: u32, + pub cursor: u8, +} + +impl __sdk::InModule for ExpansionSplitCursor { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ExpansionSplitCursor`. +/// +/// Provides typed access to columns for query building. +pub struct ExpansionSplitCursorCols { + pub cursor_key: __sdk::__query_builder::Col, + pub order_id: __sdk::__query_builder::Col, + pub cell_id: __sdk::__query_builder::Col, + pub cursor: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ExpansionSplitCursor { + type Cols = ExpansionSplitCursorCols; + fn cols(table_name: &'static str) -> Self::Cols { + ExpansionSplitCursorCols { + cursor_key: __sdk::__query_builder::Col::new(table_name, "cursor_key"), + order_id: __sdk::__query_builder::Col::new(table_name, "order_id"), + cell_id: __sdk::__query_builder::Col::new(table_name, "cell_id"), + cursor: __sdk::__query_builder::Col::new(table_name, "cursor"), + } + } +} + +/// Indexed column accessor struct for the table `ExpansionSplitCursor`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ExpansionSplitCursorIxCols { + pub cursor_key: __sdk::__query_builder::IxCol, + pub order_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ExpansionSplitCursor { + type IxCols = ExpansionSplitCursorIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ExpansionSplitCursorIxCols { + cursor_key: __sdk::__query_builder::IxCol::new(table_name, "cursor_key"), + order_id: __sdk::__query_builder::IxCol::new(table_name, "order_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ExpansionSplitCursor {} diff --git a/crates/match-bindings/src/module_bindings/expansion_wave_type.rs b/crates/match-bindings/src/module_bindings/expansion_wave_type.rs index cadcb70..9a76967 100644 --- a/crates/match-bindings/src/module_bindings/expansion_wave_type.rs +++ b/crates/match-bindings/src/module_bindings/expansion_wave_type.rs @@ -10,7 +10,6 @@ pub struct ExpansionWave { pub order_id: u64, pub selected_cells: Vec, pub outside_depths: Vec, - pub split_cursors: Vec, pub focus_cell_id: Option, pub target_cells: Vec, } @@ -26,7 +25,6 @@ pub struct ExpansionWaveCols { pub order_id: __sdk::__query_builder::Col, pub selected_cells: __sdk::__query_builder::Col>, pub outside_depths: __sdk::__query_builder::Col>, - pub split_cursors: __sdk::__query_builder::Col>, pub focus_cell_id: __sdk::__query_builder::Col>, pub target_cells: __sdk::__query_builder::Col>, } @@ -38,7 +36,6 @@ impl __sdk::__query_builder::HasCols for ExpansionWave { order_id: __sdk::__query_builder::Col::new(table_name, "order_id"), selected_cells: __sdk::__query_builder::Col::new(table_name, "selected_cells"), outside_depths: __sdk::__query_builder::Col::new(table_name, "outside_depths"), - split_cursors: __sdk::__query_builder::Col::new(table_name, "split_cursors"), focus_cell_id: __sdk::__query_builder::Col::new(table_name, "focus_cell_id"), target_cells: __sdk::__query_builder::Col::new(table_name, "target_cells"), } diff --git a/crates/match-bindings/src/module_bindings/lobby_configurator_type.rs b/crates/match-bindings/src/module_bindings/lobby_configurator_type.rs new file mode 100644 index 0000000..e5f1eab --- /dev/null +++ b/crates/match-bindings/src/module_bindings/lobby_configurator_type.rs @@ -0,0 +1,52 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct LobbyConfigurator { + pub singleton_id: u8, + pub identity: __sdk::Identity, +} + +impl __sdk::InModule for LobbyConfigurator { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `LobbyConfigurator`. +/// +/// Provides typed access to columns for query building. +pub struct LobbyConfiguratorCols { + pub singleton_id: __sdk::__query_builder::Col, + pub identity: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for LobbyConfigurator { + type Cols = LobbyConfiguratorCols; + fn cols(table_name: &'static str) -> Self::Cols { + LobbyConfiguratorCols { + singleton_id: __sdk::__query_builder::Col::new(table_name, "singleton_id"), + identity: __sdk::__query_builder::Col::new(table_name, "identity"), + } + } +} + +/// Indexed column accessor struct for the table `LobbyConfigurator`. +/// +/// Provides typed access to indexed columns for query building. +pub struct LobbyConfiguratorIxCols { + pub singleton_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for LobbyConfigurator { + type IxCols = LobbyConfiguratorIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + LobbyConfiguratorIxCols { + singleton_id: __sdk::__query_builder::IxCol::new(table_name, "singleton_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for LobbyConfigurator {} diff --git a/crates/match-bindings/src/module_bindings/mod.rs b/crates/match-bindings/src/module_bindings/mod.rs index ce47af4..9eec518 100644 --- a/crates/match-bindings/src/module_bindings/mod.rs +++ b/crates/match-bindings/src/module_bindings/mod.rs @@ -15,9 +15,14 @@ pub mod combat_front_table; pub mod combat_front_type; pub mod command_receipt_table; pub mod command_receipt_type; +pub mod command_watermark_type; pub mod configure_map_reducer; pub mod configure_match_reducer; +pub mod debug_break_order_conservation_reducer; +pub mod debug_harness_type; +pub mod enable_debug_harness_reducer; pub mod expansion_garrison_debt_type; +pub mod expansion_split_cursor_type; pub mod expansion_wave_type; pub mod issue_attack_clusters_reducer; pub mod issue_expand_all_reducer; @@ -26,6 +31,7 @@ pub mod issue_front_rebalance_reducer; pub mod issue_push_front_reducer; pub mod issue_reshape_reducer; pub mod join_match_reducer; +pub mod lobby_configurator_type; pub mod map_preset_type; pub mod match_config_table; pub mod match_config_type; @@ -68,9 +74,14 @@ pub use combat_front_table::*; pub use combat_front_type::CombatFront; pub use command_receipt_table::*; pub use command_receipt_type::CommandReceipt; +pub use command_watermark_type::CommandWatermark; pub use configure_map_reducer::configure_map; pub use configure_match_reducer::configure_match; +pub use debug_break_order_conservation_reducer::debug_break_order_conservation; +pub use debug_harness_type::DebugHarness; +pub use enable_debug_harness_reducer::enable_debug_harness; pub use expansion_garrison_debt_type::ExpansionGarrisonDebt; +pub use expansion_split_cursor_type::ExpansionSplitCursor; pub use expansion_wave_type::ExpansionWave; pub use issue_attack_clusters_reducer::issue_attack_clusters; pub use issue_expand_all_reducer::issue_expand_all; @@ -79,6 +90,7 @@ pub use issue_front_rebalance_reducer::issue_front_rebalance; pub use issue_push_front_reducer::issue_push_front; pub use issue_reshape_reducer::issue_reshape; pub use join_match_reducer::join_match; +pub use lobby_configurator_type::LobbyConfigurator; pub use map_preset_type::MapPreset; pub use match_config_table::*; pub use match_config_type::MatchConfig; @@ -131,6 +143,10 @@ pub enum Reducer { preset: MapPreset, player_count: u16, }, + DebugBreakOrderConservation { + order_id: u64, + }, + EnableDebugHarness, IssueAttackClusters { client_command_id: u64, source_seed_cells: Vec, @@ -192,6 +208,8 @@ impl __sdk::Reducer for Reducer { Reducer::CancelOrders { .. } => "cancel_orders", Reducer::ConfigureMap { .. } => "configure_map", Reducer::ConfigureMatch { .. } => "configure_match", + Reducer::DebugBreakOrderConservation { .. } => "debug_break_order_conservation", + Reducer::EnableDebugHarness => "enable_debug_harness", Reducer::IssueAttackClusters { .. } => "issue_attack_clusters", Reducer::IssueExpandAll { .. } => "issue_expand_all", Reducer::IssueExpandClusters { .. } => "issue_expand_clusters", @@ -226,6 +244,14 @@ impl __sdk::Reducer for Reducer { preset: preset.clone(), player_count: player_count.clone(), }), + Reducer::DebugBreakOrderConservation { order_id } => __sats::bsatn::to_vec( + &debug_break_order_conservation_reducer::DebugBreakOrderConservationArgs { + order_id: order_id.clone(), + }, + ), + Reducer::EnableDebugHarness => { + __sats::bsatn::to_vec(&enable_debug_harness_reducer::EnableDebugHarnessArgs {}) + } Reducer::IssueAttackClusters { client_command_id, source_seed_cells, diff --git a/crates/match-bindings/src/module_bindings/order_status_type.rs b/crates/match-bindings/src/module_bindings/order_status_type.rs index 76e3b73..b4ade76 100644 --- a/crates/match-bindings/src/module_bindings/order_status_type.rs +++ b/crates/match-bindings/src/module_bindings/order_status_type.rs @@ -13,6 +13,8 @@ pub enum OrderStatus { Completed, Cancelled, + + Quarantined, } impl __sdk::InModule for OrderStatus { diff --git a/docs/implementation.md b/docs/implementation.md index defae34..33b4855 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -76,7 +76,19 @@ Low-scale matches (`player_count <= 8`) keep the historical full population scan every population interval; high-scale matches shard population by denormalized `population_shard` so each cell still updates once per interval while work stays bounded. Client commands carry stable IDs and produce public receipts, making -retries idempotent. A reconnecting client reuses its stored SpacetimeDB token, +retries idempotent; a private per-player monotonic command watermark keeps +dedup correct while receipts older than a bounded recent window are pruned, +and terminal Completed/Cancelled orders are pruned after a fixed feedback +window. When a tick hits an invariant violation attributable to one order, the +order is quarantined (packets retired in place with strength conserved, order +row parked with the visible `Quarantined` status, failure logged as +`event=order.quarantine`) instead of failing the tick and freezing the match; +only global failures still fail-stop. The live path is exercised by +`./scripts/run-quarantine-harness.sh`, which publishes an isolated database, +calls lobby-only `enable_debug_harness` (inserts a private `DebugHarness` row +that production publish never sets), then `debug_break_order_conservation` to +force an attributable conservation fault on an active order. Production configs +leave the harness disabled, so those debug reducers reject every caller. A reconnecting client reuses its stored SpacetimeDB token, recovers its seat from `player_identity` and/or slot identity (repairing a missing index without a new claim, then reconciling verified claims so a repaired final seat can start the lobby), clears generation-scoped pending @@ -97,25 +109,34 @@ passability. Combat separately enforces frontage and the uphill modifier. Public state is split by read/update pattern: -- `player_slot`, `player_identity`, `player_state`, `match_config`, `match_state`, - and `mobilization_policy` hold match-wide state. Authoritative player IDs are +- `player_slot`, `player_state`, `match_config`, `match_state`, and + `mobilization_policy` hold match-wide state. Authoritative player IDs are `u16` with neutral `0` and configured seats `1..=player_count` (`2..=500`); - `cell_terrain` is immutable after lobby configuration; - `cell_state` holds mutable ownership, population, infantry, capacities, a deterministic `population_shard` (`u16`, validated against the interval), and denormalized `chunk_q`/`chunk_r` for high-scale spatial interest; -- `command_receipt` records accepted and rejected idempotent commands; +- `command_receipt` records accepted and rejected idempotent commands, retained + as a bounded recent window per player (dedup is carried by a private + monotonic watermark, so pruning cannot break idempotency); - `transfer_order`, `transfer_source`, `transfer_destination`, `transit_route`, and `transit_packet` expose generic internal aggregate-flow progress and congestion. Child source/destination/route rows denormalize `player_id` so high-scale clients can subscribe selectively by seat; - `combat_front` exposes the current contested edges and casualties. -`simulation_schedule`, `expansion_wave`, and `expansion_garrison_debt` are -private. A wave stores its deterministic outward topology, participating -perimeter cells, optional -neutral focus, immutable enemy target mask, and rotating fair-split cursors. -Sparse cell-keyed garrison debt ensures partial asynchronous arrivals finish the +`player_identity`, `simulation_schedule`, `expansion_wave`, +`expansion_split_cursor`, `expansion_garrison_debt`, `lobby_configurator`, +`command_watermark`, `static_edge_limit`, and `retreat_abandonment` are +private. Privacy here cuts subscription bandwidth and API surface — V1 has no +fog of war, so it is not a security boundary (high-scale clients still use +bandwidth-oriented interest management; see +[Technical architecture](./technical-architecture.md)). A wave stores its +deterministic outward topology, participating perimeter cells, optional +neutral focus, and immutable enemy target mask; the rotating fair-split +cursors live in the sparse cell-keyed `expansion_split_cursor` table so +per-branch cursor updates never rewrite the wave's large immutable depth +field. Sparse cell-keyed garrison debt ensures partial asynchronous arrivals finish the full occupation cost before later strength branches, even when another wave also reaches the cell. Pre-existing friendly transit cells create no debt. Clients see only source accounting and resting/one-edge packets. Clients cannot @@ -125,9 +146,9 @@ call the scheduled reducer as a player identity. The cluster-first client uses these gameplay reducers: -- `configure_map` — make the lobby's one-shot map selection while retaining the configured player count. -- `configure_match` — make the lobby's one-shot map and 2–500 contiguous-player selection. Configuration locks further regeneration but does not claim a slot. -- `join_match` — separately claim or reclaim one of the configured player slots; every slot remains open after configuration. +- `configure_map` — make the lobby's map selection while retaining the configured player count. +- `configure_match` — make the lobby's map and 2–500 contiguous-player selection. Configuration does not claim a slot. The first successful configure records the calling identity; only that identity may reconfigure, and only until the first player joins (databases configured before the record existed keep strict one-shot behavior). This mitigates configure griefing without changing the production lobby-orchestrator flow, which configures each published match database once. +- `join_match` — separately claim or reclaim one of the configured player slots; every slot remains open after configuration. Fresh slot claims are accepted only while the match is in the Lobby phase; reconnects of already-bound identities remain allowed in every phase. - `set_mobilization_target` — change the global recruitment target in basis points. - `issue_expand_clusters` — expand every complete owned cluster touched by the diff --git a/docs/playtests/cluster-controls-v1-automated-2026-08-07.md b/docs/playtests/cluster-controls-v1-automated-2026-08-07.md new file mode 100644 index 0000000..57282c9 --- /dev/null +++ b/docs/playtests/cluster-controls-v1-automated-2026-08-07.md @@ -0,0 +1,50 @@ +# Cluster controls V1 automated playtest — 2026-08-07 + +> Machine-generated by `cargo run -p match-playtest`. Every verdict below is +> **automated behavioral verification** against a real local SpacetimeDB match +> (assertions over the public reducer/table surface), **not** human-perception +> validation. Re-run the [manual checklist](./cluster-controls-v1.md) with +> participants for the perception rows. + +## Session + +- Method: automated live two-player session (headless SDK clients + one invariant observer) +- Git SHA: `1808b261d1225e683f327505975e6e0e69887144` +- Host / database: `http://127.0.0.1:3000` / `of-match-e2e-auto` (isolated; freshly published) +- Map preset: `Dev64` (seed `0xfd36401`), logical step 250 ms +- Wall time: 115.5 s; simulation steps observed: 1..457 +- Raw artifact: `artifacts/playtests/` (gitignored JSON with all measurements) + +## Results + +| Risk | Verdict | Automated behavioral evidence | +| --- | --- | --- | +| Focus-as-destination: 11/10/9-weighted branches, none suppressed | **PASS** | probe parent cell 1940 with 2 isolated branches [1877, 1941], focus 1877 (weights [11, 10])
Share-once at probe: committed 16 == floor(available 80 x 2124 bps)
branch 1877 (weight 11): first-hop/packet sample 2 (quota mirror 8, owner now 1)
branch 1941 (weight 10): first-hop/packet sample 1 (quota mirror 8, owner now 1)
focus weighting held: every isolated branch received a positive share; focus-side branch (2) was among the maxima (2) | +| Attack mask: captures never leave the accepted target footprint; fronts stay on it | **PASS** | attack accepted against the complete enemy cluster: mask of 708 cells snapshotted at issue
5 enemy cells captured; 0 mask violations; 11 attacker front samples with 0 off-mask fronts
session note: the defender remained a single cluster within budget; mask/front invariants were verified on that single-cluster target
mask containment proven: 5 capture(s) all inside the accepted footprint with zero off-mask fronts | +| Front rebalance: Share-once snapshot, physical traversal, conservation | **PASS** | component exposes 2 strategic fronts; rebalancing seed 853 -> seed 1826 (62 movable source cells, 7 target cells)
Share-once verified on 62 source cells: total committed 307 == 5000 bps of movable front troops
7 destination cells inside the target front absorb the full committed 307
physical traversal (settled between polls): max route_index 33; 2964 packets observed off the source front at completion
conservation: committed 307 == delivered 307 with zero casualties | +| Whole-cluster multi-select: Share once per source, then share-of-remainder | **PASS** | two identical ExpandClusters commands (1500 bps) accepted on the same logical step 69 from seed 1968
whole-cluster seed activated every neutral-perimeter source cell: 12 of 19
all 12 participating source cells matched exactly: first click committed 60, identical second click committed 48 (share of the reduced pool, not doubled, not zero)
example cell 1968: pool 35 -> first Share 5, second Share 4 (share of remainder)
perimeter participation: 30 neutral perimeter edges at issue, 24 cells captured within 6 steps
session note: both players own a single connected cluster here, so multi-select across disjoint own clusters was not stageable (no abandon mechanic); multi-seed whole-cluster semantics were verified on one cluster | +| Reshape: undersized footprint saturates + conserves overflow; oversized drains | **PASS** | undersized: 4 source cells with 90 movable -> target 1625 with headroom 41; committed 41
target 1625 gained 33 of committed 41 (ended 52/60); path-stationed remainder conserved by order accounting
conserved overflow of at least 90 movable infantry remained outside the footprint at its source cells
oversized: 195 movable infantry across 2 sources into 4 targets with headroom 392; committed 392
source 2064: moved 3 of movable 99, kept 96
source 2000: moved 3 of movable 96, kept 93 | +| Exact Stop: only the frozen order set is released, at current physical cells | **PASS** | orders 56 (to stop) and 57 (control) active; frozen set snapshot: 81 in transit across cells [1777, 1778, 1779, 1780, 1839, 1840, 1841, 1842, 1843, 1844, 1845, 1902, 1903, 1904, 1905, 1906, 1907, 1908, 1909, 1965, 1966, 1967, 1968, 1969, 1970, 1971, 1972, 1973, 1974, 2029, 2030, 2031, 2032, 2033, 2034, 2035, 2036, 2037, 2091, 2093, 2094, 2095, 2096, 2097, 2098, 2099, 2100, 2102, 2156, 2157, 2158, 2159, 2160, 2161, 2162, 2163, 2164, 2219, 2220, 2221, 2222, 2223, 2224, 2225, 2226, 2227, 2228, 2283, 2284, 2285, 2286, 2287, 2288, 2289, 2290, 2349, 2350, 2351, 2353, 2411, 2608]
stop released exactly the frozen strength: 81 newly settled, committed 81 == delivered 81
control order untouched by the stop: status Active, committed 81 unchanged
released troops stayed at their physical cells: 3 exclusive packet cells unchanged after the control order completed
control order later completed normally: delivered 81 of 81 | + +## Continuous global invariants + +- Samples: 1727 (1724 stable conservation checkpoints) +- Tick liveness: logical_step advanced 1 → 457 +- Physical traversal: 0 forward route transitions observed, zero teleports/rewinds tolerated +- Peak cell fill ratio: 100.00% of military capacity (no cell ever above 100%) +- Strict window steps 0..10: total infantry 1400 → 1400, order-recorded (attacker) casualties 0, defender-side losses 0 +- Combat window steps 10..69: total infantry 1400 → 1400, order-recorded (attacker) casualties 0, defender-side losses 0 +- Combat window steps 69..77: total infantry 1400 → 1400, order-recorded (attacker) casualties 0, defender-side losses 0 +- Mobilization window steps 77..212: total infantry 1400 → 26186, order-recorded (attacker) casualties 0, defender-side losses 0 +- Strict window steps 212..215: total infantry 26186 → 26186, order-recorded (attacker) casualties 0, defender-side losses 0 +- Strict window steps 216..294: total infantry 26186 → 26186, order-recorded (attacker) casualties 0, defender-side losses 0 +- Strict window steps 294..298: total infantry 26186 → 26186, order-recorded (attacker) casualties 0, defender-side losses 0 +- Strict window steps 298..384: total infantry 26186 → 26186, order-recorded (attacker) casualties 0, defender-side losses 0 +- Strict window steps 384..411: total infantry 26186 → 26186, order-recorded (attacker) casualties 0, defender-side losses 0 +- Combat window steps 411..448: total infantry 26186 → 26149, order-recorded (attacker) casualties 11, defender-side losses 26 +- Strict window steps 449..457: total infantry 26149 → 26149, order-recorded (attacker) casualties 0, defender-side losses 0 +- Violations: **none** + +## Overall + +All six documented control risks verified behaviorally against the live authoritative module, with continuous conservation, capacity, ownership, traversal, and liveness instrumentation reporting no violations. diff --git a/docs/technical-architecture.md b/docs/technical-architecture.md index 0aff8ee..ff30d87 100644 --- a/docs/technical-architecture.md +++ b/docs/technical-architecture.md @@ -207,8 +207,16 @@ the next subscription update. A receipt or stable order row keyed by `(player_id, client_command_id)` makes retries idempotent when a connection disappears after submission but before the -client observes the transaction. Spawn selection, readiness controls, and -rematches are outside the reducer surface. +client observes the transaction. Command IDs are player-scoped and monotonic +by client contract; the module keeps a private per-player high-water mark and +treats any ID at or below it as a duplicate. That watermark, not receipt-row +existence, carries dedup correctness, so receipt rows are retained only as a +bounded recent feedback window per player (currently 128 commands) and older +rows are pruned on insert. Terminal Completed/Cancelled orders and their +source/destination rows are likewise pruned after a fixed feedback window +(currently 2,400 steps); Quarantined orders are kept as durable operator +records. Spawn selection, readiness controls, and rematches are outside the +reducer surface. ## Deterministic simulation rules @@ -315,13 +323,29 @@ one defender pool; strength and casualties remain conserved. Cancellation releases surviving packets where they physically are rather than rewinding captures. +Contested-cell resolution is simultaneous across **all** attacking owners: the +kernel allocates the defender pool proportionally over every valid attacking +edge regardless of owner (largest-remainder rounding, attack-ID tie-break), +so three-way contests need no module-side owner ordering. When the defender is +eliminated, the capture rule is: the attacking owner with the largest total +surviving committed strength at the cell captures, ties break toward the +smaller owner ID; within the winning owner, the largest surviving front +captures, ties break toward the smaller origin cell ID. Losing owners keep +their survivors in place and contest the cell again next step. A malformed +front (non-adjacent origin, impassable cliff, duplicated origin) is rejected +individually — the remaining valid fronts still resolve, and the orders behind +the rejected front are quarantined. + ### Explicit strategic-front redistribution A strategic front is derived from directed deployable boundary edges of one complete owned traversable component. Hostile runs are labeled by opponent; neutral runs between hostile runs against the same opponent bridge those runs. Different opponents split hostile frontage. Neutral-facing edges are grouped by -the ordered hostile context around each geometric perimeter cycle. Repeated +the actual bounding hostile front **instances** around each geometric perimeter +cycle — not merely by the bounding opponents' IDs — so geometrically +disconnected neutral arcs that happen to sit between the same pair of opponent +IDs stay separate fronts. Repeated contact with the same hostile front does not split the neutral frontage, while neutral sections bounded on opposite sides by different hostile fronts remain independent. Neutral bridge edges remain members of their neutral front, so @@ -388,6 +412,20 @@ The important commitments are: Uncontested movement can later be collapsed into scheduled arrival events when doing so preserves congestion and interception semantics. The exact split between periodic active-set updates and calculated arrival events is a scaling experiment, not a V1 rule dependency. +**Failure locality (quarantine).** A scheduled tick is one transaction, so an +error that propagates out of the tick reducer rolls back and the interval +schedule re-runs the identical deterministic state — an unrecoverable per-order +invariant violation would otherwise freeze the match forever. When a violation +is attributable to one order (broken per-order conservation, corrupt persisted +geometry, a rejected combat front, a source-queue underflow), the tick instead +quarantines that order: its packets are deleted with their strength conserved +in place at the current physical cells, its source queues are zeroed, its +private topology rows are removed, the order row is parked with the visible +`Quarantined` status, and an `event=order.quarantine` error is logged. The rest +of the tick proceeds. Truly global failures (logical-step counter overflow, +kernel movement failure across orders, missing singleton state) still +fail-stop the transaction. + ## Map data, chunks, and supported sizes Maps are generated offline from a versioned generator and seed, validated, inspected, and baked into a curated library. Each map has a manifest containing dimensions or bounds, generator version, seed, content hash, spawn candidates, capturable-land mask, and environment metadata. The conquest denominator is fixed from the capturable mask at match initialization. @@ -433,6 +471,16 @@ active orders, routes/packets, mobilization, and command receipts. The SpacetimeDB client cache is the network-facing source of truth for the client. +Full disclosure of gameplay state is intentional in V1: the design has no fog +of war, so every gameplay-relevant table (ownership, orders, packets, routes, +fronts, receipts) is public and readable by every client, and no per-player +read authorization exists. Purely internal execution state — expansion wave +topology and split cursors, garrison debt, retreat abandonments, the identity +index, the lobby configurator record, command watermarks, static edge limits, +and the scheduler row — is private only to cut subscription bandwidth and API +surface, not as a security boundary. This posture must be revisited when fog +of war is introduced. + A narrow adapter advances the SpacetimeDB connection in the Bevy update loop as required by the selected SDK version. It translates inserted, updated, and deleted rows into ordered application events and dirty chunk/cell markers. Bevy systems consume those markers; rendering systems do not synchronously query the network and network callbacks do not directly mutate arbitrary ECS state. The initial snapshot gates entry into the playable state. Subsequent transaction updates should be applied together so the UI does not briefly render half of an ownership/combat transaction. When practical, maintain dynamic per-cell state in packed arrays keyed by stable cell ID and use Bevy entities for chunks, UI, orders, fronts, and other meaningful objects rather than requiring one material or collider per hex. diff --git a/docs/v1-game-design.md b/docs/v1-game-design.md index 7c204fd..14f4f02 100644 --- a/docs/v1-game-design.md +++ b/docs/v1-game-design.md @@ -268,10 +268,10 @@ V1 combat is aggregate and edge-based: - An attack occurs when force is directed through a traversable edge into neutral or enemy territory. - Only strength within the edge's combat frontage can participate at once. - Remaining attackers wait behind the active frontage and continue feeding the battle subject to throughput. -- Defending force is local to its hex. When attacked through multiple edges, the same defenders cannot be counted at full strength against every edge. +- Defending force is local to its hex. When attacked through multiple edges, the same defenders cannot be counted at full strength against every edge: defenders are split proportionally across all attacking edges, from every attacking player at once. - Uphill attackers receive a clear penalty. - Casualties remove force from the spatial-conservation total. -- Ownership changes only after local resistance is overcome and occupying force can enter the destination within its capacity. +- Ownership changes only after local resistance is overcome and occupying force can enter the destination within its capacity. When several players could capture the same cell in one step, the attacker with the largest surviving committed strength at that cell captures it (deterministic tie-breaks: smaller player ID, then smaller origin cell); the others keep their survivors in place and keep contesting. - Multiple attack edges should make encirclement valuable by creating additional frontage, without duplicating defending strength. - A cell always has one authoritative controller and one authoritative local infantry stack. Opposing forces remain on hostile edges until capture; V1 diff --git a/modules/match/src/lib.rs b/modules/match/src/lib.rs index 8985c39..6d0f154 100644 --- a/modules/match/src/lib.rs +++ b/modules/match/src/lib.rs @@ -12,12 +12,13 @@ use spacetimedb::{Identity, ReducerContext, ScheduleAt, Table}; use crate::mapgen::regenerate_map; use crate::schema::{ - DEFAULT_PLAYER_COUNT, MAX_PLAYER_COUNT, MIN_PLAYER_COUNT, MapPreset, MatchPhase, - MobilizationPolicy, PlayerIdentity, PlayerSlot, SINGLETON_ID, SimulationSchedule, + DEFAULT_PLAYER_COUNT, DebugHarness, LobbyConfigurator, MAX_PLAYER_COUNT, MIN_PLAYER_COUNT, + MapPreset, MatchPhase, MobilizationPolicy, OrderStatus, PlayerIdentity, PlayerSlot, + SINGLETON_ID, SimulationSchedule, }; use crate::schema::{ - match_config, match_state, mobilization_policy, player_identity, player_slot, - simulation_schedule, + debug_harness, lobby_configurator, match_config, match_state, mobilization_policy, + player_identity, player_slot, simulation_schedule, transfer_order, }; fn timestamp_us(ctx: &ReducerContext) -> u64 { @@ -49,7 +50,9 @@ pub fn configure_map(ctx: &ReducerContext, preset: MapPreset) -> Result<(), Stri .find(SINGLETON_ID) .ok_or("match config is missing")? .player_count; - regenerate_map(ctx, preset, preset.seed(), player_count, true) + regenerate_map(ctx, preset, preset.seed(), player_count, true)?; + record_lobby_configurator(ctx); + Ok(()) } /// Configures map/player scale once without claiming a player slot. Every @@ -63,7 +66,76 @@ pub fn configure_match( require_configurable_lobby(ctx)?; validate_player_count(player_count)?; configure_player_rows(ctx, player_count); - regenerate_map(ctx, preset, preset.seed(), player_count, true) + regenerate_map(ctx, preset, preset.seed(), player_count, true)?; + record_lobby_configurator(ctx); + Ok(()) +} + +/// Enables debug-only reducers for isolated integration harnesses. +/// +/// Production publish and lobby orchestration never call this. The private +/// `DebugHarness` row is the only gate; without it, +/// `debug_break_order_conservation` rejects every caller. +#[spacetimedb::reducer] +pub fn enable_debug_harness(ctx: &ReducerContext) -> Result<(), String> { + require_configurable_lobby(ctx)?; + if let Some(mut row) = ctx.db.debug_harness().singleton_id().find(SINGLETON_ID) { + row.enabled = true; + ctx.db.debug_harness().singleton_id().update(row); + } else { + ctx.db.debug_harness().insert(DebugHarness { + singleton_id: SINGLETON_ID, + enabled: true, + }); + } + Ok(()) +} + +fn require_debug_harness(ctx: &ReducerContext) -> Result<(), String> { + let enabled = ctx + .db + .debug_harness() + .singleton_id() + .find(SINGLETON_ID) + .is_some_and(|row| row.enabled); + if enabled { + Ok(()) + } else { + Err("debug harness is disabled (production configs never enable it)".into()) + } +} + +/// Test-only: corrupt one active order's accounting so the next simulation +/// tick's finalize pass quarantines it. Strength remains in physical cells; +/// only the order counters are intentionally broken. +#[spacetimedb::reducer] +pub fn debug_break_order_conservation(ctx: &ReducerContext, order_id: u64) -> Result<(), String> { + require_debug_harness(ctx)?; + let mut order = ctx + .db + .transfer_order() + .order_id() + .find(order_id) + .ok_or_else(|| format!("unknown order {order_id}"))?; + if order.status != OrderStatus::Active { + return Err(format!( + "debug break requires an Active order, found {:?}", + order.status + )); + } + order.committed_infantry = order + .committed_infantry + .checked_add(1) + .ok_or_else(|| "debug break committed overflow".to_string())?; + order.updated_step = ctx + .db + .match_state() + .singleton_id() + .find(SINGLETON_ID) + .map(|state| state.logical_step) + .unwrap_or(order.updated_step); + ctx.db.transfer_order().order_id().update(order); + Ok(()) } fn validate_player_count(player_count: u16) -> Result<(), String> { @@ -100,27 +172,66 @@ fn require_configurable_lobby(ctx: &ReducerContext) -> Result<(), String> { .claimed_players > 0 || ctx.db.player_identity().iter().next().is_some(); - validate_lobby_configuration(phase, configuration_locked, any_player_joined) - .map_err(str::to_owned) + let sender_is_configurator = ctx + .db + .lobby_configurator() + .singleton_id() + .find(SINGLETON_ID) + .map(|row| row.identity == ctx.sender()); + validate_lobby_configuration( + phase, + configuration_locked, + any_player_joined, + sender_is_configurator, + ) + .map_err(str::to_owned) } +/// Configure-griefing mitigation: the first successful configure records its +/// identity, and only that identity may reconfigure, always only until the +/// first player joins. Configuration is therefore no longer irrevocably +/// one-shot — the identity that configured first (the production lobby +/// orchestrator in the deployed flow) can correct a bad configuration +/// instead of being locked out by its own lock flag, while every other +/// identity is still rejected. Databases configured before the configurator +/// record existed keep the old strict one-shot behavior +/// (`sender_is_configurator == None`). fn validate_lobby_configuration( phase: MatchPhase, configuration_locked: bool, any_player_joined: bool, + sender_is_configurator: Option, ) -> Result<(), &'static str> { if phase != MatchPhase::Lobby { return Err("the match can only be configured in the lobby"); } - if configuration_locked { - return Err("lobby configuration is already locked"); - } if any_player_joined { return Err("the match must be configured before any player joins"); } + if configuration_locked && sender_is_configurator != Some(true) { + return Err("lobby configuration is already locked by another identity"); + } Ok(()) } +fn record_lobby_configurator(ctx: &ReducerContext) { + let identity = ctx.sender(); + if let Some(mut row) = ctx + .db + .lobby_configurator() + .singleton_id() + .find(SINGLETON_ID) + { + row.identity = identity; + ctx.db.lobby_configurator().singleton_id().update(row); + } else { + ctx.db.lobby_configurator().insert(LobbyConfigurator { + singleton_id: SINGLETON_ID, + identity, + }); + } +} + fn claim_player_slot(slot: &mut PlayerSlot, identity: Identity, now: u64) { slot.identity = Some(identity); slot.display_name = format!("Player {}", slot.player_id); @@ -350,6 +461,16 @@ fn reconcile_claimed_players(ctx: &ReducerContext) -> Result<(), String> { Ok(()) } +/// Fresh slot claims are lobby-only. Reconnects of already-bound identities +/// are resolved before this guard runs and remain allowed in every phase. +fn validate_fresh_claim_phase(phase: MatchPhase) -> Result<(), &'static str> { + if phase == MatchPhase::Lobby { + Ok(()) + } else { + Err("fresh player slots can only be claimed while the match is in the lobby") + } +} + fn apply_reconnect_to_slot( slot: &mut PlayerSlot, identity: Identity, @@ -410,6 +531,14 @@ pub fn join_match( return Ok(()); } + let phase = ctx + .db + .match_state() + .singleton_id() + .find(SINGLETON_ID) + .ok_or("match state is missing")? + .phase; + validate_fresh_claim_phase(phase).map_err(str::to_owned)?; let player_count = ctx .db .match_config() @@ -614,18 +743,59 @@ mod tests { } #[test] - fn lobby_configuration_is_one_shot_without_requiring_a_slot_claim() { - assert!(validate_lobby_configuration(MatchPhase::Lobby, false, false).is_ok()); + fn lobby_configuration_is_gated_without_requiring_a_slot_claim() { + assert!(validate_lobby_configuration(MatchPhase::Lobby, false, false, None).is_ok()); + assert_eq!( + validate_lobby_configuration(MatchPhase::Lobby, false, true, None), + Err("the match must be configured before any player joins") + ); + assert_eq!( + validate_lobby_configuration(MatchPhase::Running, false, false, Some(true)), + Err("the match can only be configured in the lobby") + ); + } + + #[test] + fn only_the_recorded_configurator_may_reconfigure_until_the_first_join() { + // The identity that configured first may reconfigure while nobody has + // joined yet. + assert!(validate_lobby_configuration(MatchPhase::Lobby, true, false, Some(true)).is_ok()); + // Any other identity is rejected once the lobby is configured. assert_eq!( - validate_lobby_configuration(MatchPhase::Lobby, true, false), - Err("lobby configuration is already locked") + validate_lobby_configuration(MatchPhase::Lobby, true, false, Some(false)), + Err("lobby configuration is already locked by another identity") ); + // Legacy databases (locked without a configurator record) keep the + // strict one-shot behavior for everyone. assert_eq!( - validate_lobby_configuration(MatchPhase::Lobby, false, true), + validate_lobby_configuration(MatchPhase::Lobby, true, false, None), + Err("lobby configuration is already locked by another identity") + ); + // The first join ends the reconfiguration window even for the + // recorded configurator. + assert_eq!( + validate_lobby_configuration(MatchPhase::Lobby, true, true, Some(true)), Err("the match must be configured before any player joins") ); } + #[test] + fn fresh_slot_claims_are_lobby_only_but_reconnects_are_not_gated() { + assert!(validate_fresh_claim_phase(MatchPhase::Lobby).is_ok()); + for phase in [MatchPhase::Running, MatchPhase::Completed] { + assert_eq!( + validate_fresh_claim_phase(phase), + Err("fresh player slots can only be claimed while the match is in the lobby") + ); + } + // Reconnects bypass the guard entirely: join_match resolves an + // already-bound identity before the fresh-claim path runs, in every + // phase. + let recovered = recover_join_identity(Some(3), Some(3), IndexedSlotBinding::MatchesSender) + .expect("consistent binding"); + assert!(recovered.is_some(), "reconnect resolves before the guard"); + } + #[test] fn join_slot_claim_uses_join_compatible_defaults() { let mut slot = PlayerSlot { diff --git a/modules/match/src/orders.rs b/modules/match/src/orders.rs index 0c92973..d148d23 100644 --- a/modules/match/src/orders.rs +++ b/modules/match/src/orders.rs @@ -2751,7 +2751,6 @@ fn persist_expand_order( ctx.db.expansion_wave().insert(ExpansionWave { order_id: order.order_id, selected_cells: plan.selected_cells, - split_cursors: vec![0; plan.outside_depths.len()], outside_depths: plan.outside_depths, focus_cell_id: plan.focus_cell_id, target_cells: plan.target_cells, @@ -2876,6 +2875,7 @@ fn cancel_order(ctx: &ReducerContext, player_id: u16, order_id: u64) -> Result<( } } ctx.db.expansion_wave().order_id().delete(order_id); + crate::simulation::clear_expansion_split_cursors(ctx, order_id); let abandonment_keys = ctx .db .retreat_abandonment() diff --git a/modules/match/src/rules.rs b/modules/match/src/rules.rs index be39f72..b5abbcc 100644 --- a/modules/match/src/rules.rs +++ b/modules/match/src/rules.rs @@ -2,12 +2,12 @@ use hex_core::{Axial, Cell, ForceComposition, MovementConfig, TerrainKind, groun use spacetimedb::{ReducerContext, Table}; use crate::schema::{ - CellState, CellTerrain, CommandReceipt, MatchConfig, MatchPhase, MatchState, ReceiptStatus, - SINGLETON_ID, TerrainClass, + CellState, CellTerrain, CommandReceipt, CommandWatermark, MatchConfig, MatchPhase, MatchState, + ReceiptStatus, SINGLETON_ID, TerrainClass, }; use crate::schema::{ - cell_state, cell_terrain, command_receipt, match_config, match_state, player_identity, - player_slot, static_edge_limit, transit_packet, + cell_state, cell_terrain, command_receipt, command_watermark, match_config, match_state, + player_identity, player_slot, static_edge_limit, transit_packet, }; pub const BASIS_POINTS: u64 = 10_000; @@ -95,7 +95,28 @@ pub const fn order_cell_key(order_id: u64, cell_id: u32) -> u128 { (order_id as u128) << 32 | cell_id as u128 } +/// Receipts newer than `watermark - RECEIPT_RETENTION_WINDOW` are retained per +/// player; older rows are pruned on insert. The window only bounds how long +/// receipt rows stay visible for client feedback — dedup correctness comes +/// from the monotonic watermark, not from receipt existence. +pub const RECEIPT_RETENTION_WINDOW: u64 = 128; + +/// Idempotency contract: clients allocate `client_command_id` monotonically +/// per player (the client seeds `max + 1` from observed receipts) and reuse +/// the same ID only when retrying the same command. Any ID at or below the +/// player's high-water mark is therefore a duplicate by contract, which lets +/// old receipt rows be pruned without breaking dedup. The receipt lookup +/// remains as a fallback for databases created before the watermark existed. pub fn command_was_seen(ctx: &ReducerContext, player_id: u16, client_command_id: u64) -> bool { + let watermark = ctx + .db + .command_watermark() + .player_id() + .find(player_id) + .map(|row| row.highest_client_command_id); + if command_id_is_duplicate(watermark, client_command_id) { + return true; + } ctx.db .command_receipt() .receipt_key() @@ -103,6 +124,58 @@ pub fn command_was_seen(ctx: &ReducerContext, player_id: u16, client_command_id: .is_some() } +/// Pure dedup decision: a command ID is a duplicate exactly when the player +/// already has a watermark at or above it. A player with no watermark row has +/// seen nothing. +pub fn command_id_is_duplicate(watermark: Option, client_command_id: u64) -> bool { + watermark.is_some_and(|mark| client_command_id <= mark) +} + +/// Pure retention rule: receipts with `client_command_id` at or below the +/// cutoff may be pruned. No cutoff exists until the watermark clears the +/// retention window, so a player always keeps their most recent receipts. +pub fn receipt_retention_cutoff(watermark: u64) -> Option { + watermark.checked_sub(RECEIPT_RETENTION_WINDOW) +} + +fn advance_command_watermark(ctx: &ReducerContext, player_id: u16, client_command_id: u64) -> u64 { + match ctx.db.command_watermark().player_id().find(player_id) { + Some(mut row) => { + if client_command_id > row.highest_client_command_id { + row.highest_client_command_id = client_command_id; + ctx.db.command_watermark().player_id().update(row); + client_command_id + } else { + row.highest_client_command_id + } + } + None => { + ctx.db.command_watermark().insert(CommandWatermark { + player_id, + highest_client_command_id: client_command_id, + }); + client_command_id + } + } +} + +fn prune_player_receipts(ctx: &ReducerContext, player_id: u16, watermark: u64) { + let Some(cutoff) = receipt_retention_cutoff(watermark) else { + return; + }; + let stale: Vec = ctx + .db + .command_receipt() + .receipt_by_player() + .filter(player_id) + .filter(|receipt| receipt.client_command_id <= cutoff) + .map(|receipt| receipt.receipt_key) + .collect(); + for receipt_key in stale { + ctx.db.command_receipt().receipt_key().delete(receipt_key); + } +} + pub fn write_receipt( ctx: &ReducerContext, player_id: u16, @@ -142,6 +215,8 @@ pub fn write_receipt( message, logical_step, }); + let watermark = advance_command_watermark(ctx, player_id, client_command_id); + prune_player_receipts(ctx, player_id, watermark); Ok(()) } @@ -292,3 +367,83 @@ pub fn allocated_infantry_at_cell(ctx: &ReducerContext, owner_player_id: u16, ce .map(|packet| packet.infantry) .sum() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dedup_survives_receipt_pruning_via_the_watermark() { + // Simulate a long session: the player has issued 1,000 commands and + // every receipt older than the retention window has been pruned. + let watermark = 1_000_u64; + let cutoff = receipt_retention_cutoff(watermark).unwrap(); + assert_eq!(cutoff, 1_000 - RECEIPT_RETENTION_WINDOW); + + // Replays of long-pruned IDs are still duplicates even though their + // receipt rows are gone. + for pruned_id in [1, 2, cutoff] { + assert!(pruned_id <= cutoff, "these receipts were pruned"); + assert!(command_id_is_duplicate(Some(watermark), pruned_id)); + } + // Recent IDs inside the retained window are also duplicates. + assert!(command_id_is_duplicate(Some(watermark), cutoff + 1)); + assert!(command_id_is_duplicate(Some(watermark), watermark)); + // Fresh IDs above the watermark are accepted. + assert!(!command_id_is_duplicate(Some(watermark), watermark + 1)); + } + + #[test] + fn players_without_a_watermark_have_seen_nothing() { + assert!(!command_id_is_duplicate(None, 0)); + assert!(!command_id_is_duplicate(None, 1)); + assert!(command_id_is_duplicate(Some(0), 0)); + } + + #[test] + fn recent_receipts_are_never_pruned() { + // Until the watermark clears the window there is no cutoff at all. + assert_eq!(receipt_retention_cutoff(0), None); + assert_eq!(receipt_retention_cutoff(RECEIPT_RETENTION_WINDOW - 1), None); + assert_eq!(receipt_retention_cutoff(RECEIPT_RETENTION_WINDOW), Some(0)); + // The most recent window of receipts always survives for feedback. + let watermark = 500_u64; + let cutoff = receipt_retention_cutoff(watermark).unwrap(); + assert!(watermark - cutoff == RECEIPT_RETENTION_WINDOW); + } + + #[test] + fn write_prune_replay_pipeline_keeps_dedup_without_receipt_rows() { + // Model the write_receipt → prune_player_receipts → command_was_seen + // pipeline without a database: each issued ID advances the watermark, + // receipts at or below the cutoff disappear, and replays of those + // pruned IDs remain duplicates via the watermark alone. + let mut watermark = None; + let mut receipts = std::collections::BTreeSet::::new(); + for command_id in 1..=300_u64 { + assert!( + !command_id_is_duplicate(watermark, command_id), + "fresh ID {command_id} must be above watermark {watermark:?}" + ); + watermark = Some(command_id); + receipts.insert(command_id); + if let Some(cutoff) = receipt_retention_cutoff(command_id) { + receipts.retain(|&id| id > cutoff); + } + } + + let watermark = watermark.unwrap(); + assert_eq!(watermark, 300); + let cutoff = receipt_retention_cutoff(watermark).unwrap(); + assert!(receipts.iter().all(|&id| id > cutoff)); + assert!(!receipts.contains(&1)); + assert!(!receipts.contains(&cutoff)); + + // Receipt rows for the pruned IDs are gone, but dedup still holds. + for pruned_id in [1, cutoff / 2, cutoff] { + assert!(!receipts.contains(&pruned_id)); + assert!(command_id_is_duplicate(Some(watermark), pruned_id)); + } + assert!(!command_id_is_duplicate(Some(watermark), watermark + 1)); + } +} diff --git a/modules/match/src/schema.rs b/modules/match/src/schema.rs index 59f8f3f..f71965b 100644 --- a/modules/match/src/schema.rs +++ b/modules/match/src/schema.rs @@ -70,6 +70,10 @@ pub enum OrderStatus { Active, Completed, Cancelled, + /// The order hit an attributable invariant violation during a tick. Its + /// packets were retired with strength conserved in place and the order is + /// permanently parked; the rest of the match keeps running. + Quarantined, } #[derive(SpacetimeType, Clone, Copy, Debug, Eq, PartialEq)] @@ -105,6 +109,30 @@ pub struct PlayerIdentity { pub player_id: u16, } +/// The identity that configured the lobby. Recorded on the first successful +/// `configure_match`/`configure_map`; only that identity may reconfigure, and +/// only until the first player joins. Private: purely an authorization record. +#[derive(Clone)] +#[spacetimedb::table(accessor = lobby_configurator)] +pub struct LobbyConfigurator { + #[primary_key] + pub singleton_id: u8, + pub identity: Identity, +} + +/// Per-player monotonic idempotency watermark for `client_command_id`. +/// +/// Commands at or below the watermark are duplicates by contract, so +/// `CommandReceipt` rows older than the bounded feedback window can be pruned +/// without breaking dedup. Private: clients read receipts, never this row. +#[derive(Clone)] +#[spacetimedb::table(accessor = command_watermark)] +pub struct CommandWatermark { + #[primary_key] + pub player_id: u16, + pub highest_client_command_id: u64, +} + #[derive(Clone)] #[spacetimedb::table(accessor = match_config, public)] pub struct MatchConfig { @@ -414,8 +442,6 @@ pub struct ExpansionWave { pub order_id: u64, pub selected_cells: Vec, pub outside_depths: Vec, - /// Per-cell rotating remainder cursor for unbiased asynchronous splits. - pub split_cursors: Vec, /// Optional neutral click objective. Branch weights mildly favor children /// that move closer to this cell. pub focus_cell_id: Option, @@ -424,6 +450,24 @@ pub struct ExpansionWave { pub target_cells: Vec, } +/// Sparse per-cell rotating remainder cursor for unbiased asynchronous wave +/// splits. Kept out of [`ExpansionWave`] so per-branch cursor updates never +/// rewrite the wave's large immutable depth field; rows exist only for cells +/// whose cursor has rotated away from zero. +#[derive(Clone)] +#[spacetimedb::table( + accessor = expansion_split_cursor, + index(accessor = cursor_by_order, btree(columns = [order_id])) +)] +pub struct ExpansionSplitCursor { + /// `order_cell_key(order_id, cell_id)`. + #[primary_key] + pub cursor_key: u128, + pub order_id: u64, + pub cell_id: u32, + pub cursor: u8, +} + /// Sparse unpaid occupation garrison created by an Expand All capture. /// /// The debt belongs to the captured cell rather than to one order so a later @@ -469,3 +513,14 @@ pub struct SimulationSchedule { pub scheduled_id: u64, pub scheduled_at: ScheduleAt, } + +/// Private singleton that gates debug-only reducers used by the live quarantine +/// integration harness. Production publish / lobby orchestration never inserts +/// this row, so `debug_break_order_conservation` is unreachable in production. +#[derive(Clone)] +#[spacetimedb::table(accessor = debug_harness)] +pub struct DebugHarness { + #[primary_key] + pub singleton_id: u8, + pub enabled: bool, +} diff --git a/modules/match/src/simulation.rs b/modules/match/src/simulation.rs index 9970645..3f01115 100644 --- a/modules/match/src/simulation.rs +++ b/modules/match/src/simulation.rs @@ -7,7 +7,7 @@ use std::{ use hex_core::{ AttackFront, Axial, CombatConfig, EdgeLimits, HexMap, LogisticsConfig, MovementConfig, MovementIntent, MovementLimit, focus_branch_weight, movement_step, resolve_edge_combat, - weighted_branch_allocations_rotated, + select_capture, weighted_branch_allocations_rotated, }; use spacetimedb::{ReducerContext, Table, log_stopwatch::LogStopwatch}; @@ -16,14 +16,14 @@ use crate::rules::{ core_cell, edge_runtime_limits, order_cell_key, state, terrain, }; use crate::schema::{ - CellState, CombatFront, EXPANSION_AGGREGATE_ORIGIN, ExpansionGarrisonDebt, ExpansionWave, - MatchPhase, NEUTRAL_PLAYER, OrderKind, OrderStatus, TerrainClass, TransferOrder, - TransferSource, TransitPacket, + CellState, CombatFront, EXPANSION_AGGREGATE_ORIGIN, ExpansionGarrisonDebt, + ExpansionSplitCursor, ExpansionWave, MatchPhase, NEUTRAL_PLAYER, OrderKind, OrderStatus, + TerrainClass, TransferOrder, TransferSource, TransitPacket, }; use crate::schema::{ - cell_state as cell_state_table, combat_front, expansion_garrison_debt, expansion_wave, - match_state, mobilization_policy, player_state, retreat_abandonment, transfer_destination, - transfer_order, transfer_source, transit_packet, transit_route, + cell_state as cell_state_table, combat_front, expansion_garrison_debt, expansion_split_cursor, + expansion_wave, match_state, mobilization_policy, player_state, retreat_abandonment, + transfer_destination, transfer_order, transfer_source, transit_packet, transit_route, }; /// Transaction-local packet index for one simulation step. @@ -441,8 +441,8 @@ pub fn advance_simulation(ctx: &ReducerContext) -> Result { } { let _phase_stopwatch = LogStopwatch::new("simulation_finalize"); + finalize_orders(ctx, &mut packets, logical_step)?; packets.flush_source_queues(ctx); - finalize_orders(ctx, &packets, logical_step)?; } let config = config(ctx)?; @@ -460,6 +460,10 @@ pub fn advance_simulation(ctx: &ReducerContext) -> Result { let _phase_stopwatch = LogStopwatch::new("simulation_population"); population_step(ctx, logical_step, high_scale)?; } + if logical_step.is_multiple_of(ORDER_PRUNE_INTERVAL_STEPS) { + let _phase_stopwatch = LogStopwatch::new("simulation_prune"); + prune_order_history(ctx, logical_step); + } if logical_step.is_multiple_of(40) { log::info!( target: "of", @@ -471,6 +475,54 @@ pub fn advance_simulation(ctx: &ReducerContext) -> Result { Ok(state(ctx)?.phase == MatchPhase::Running) } +/// How long terminal Completed/Cancelled orders (and their source and +/// destination rows) remain visible for client feedback: 2,400 steps is ten +/// minutes at the default 250 ms step. Quarantined orders are exempt — they +/// are the operator-visible record of an invariant violation and are rare by +/// construction. +const ORDER_RETENTION_STEPS: u64 = 2_400; +const ORDER_PRUNE_INTERVAL_STEPS: u64 = 40; + +fn prune_order_history(ctx: &ReducerContext, logical_step: u64) { + for status in [OrderStatus::Completed, OrderStatus::Cancelled] { + let stale: Vec = ctx + .db + .transfer_order() + .order_by_status() + .filter(status) + .filter(|order| order_history_is_prunable(order.updated_step, logical_step)) + .map(|order| order.order_id) + .collect(); + for order_id in stale { + let source_keys: Vec<_> = ctx + .db + .transfer_source() + .source_by_order() + .filter(order_id) + .map(|source| source.source_key) + .collect(); + for key in source_keys { + ctx.db.transfer_source().source_key().delete(key); + } + let destination_keys: Vec<_> = ctx + .db + .transfer_destination() + .destination_by_order() + .filter(order_id) + .map(|destination| destination.destination_key) + .collect(); + for key in destination_keys { + ctx.db.transfer_destination().destination_key().delete(key); + } + ctx.db.transfer_order().order_id().delete(order_id); + } + } +} + +fn order_history_is_prunable(updated_step: u64, logical_step: u64) -> bool { + updated_step.saturating_add(ORDER_RETENTION_STEPS) < logical_step +} + fn is_expansion_wave_order(kind: OrderKind) -> bool { matches!( kind, @@ -478,6 +530,121 @@ fn is_expansion_wave_order(kind: OrderKind) -> bool { ) } +/// Permanently parks an order after an attributable invariant violation so a +/// deterministic per-order failure cannot re-fail every scheduled tick and +/// freeze the match. +/// +/// Strength is conserved by construction: infantry always lives in +/// `CellState` rows, and packets/sources are allocation metadata only. +/// Deleting the order's packets releases its strength at the current physical +/// cells; zeroing the source queues releases the not-yet-departed remainder +/// at its origins. The order row is kept with `OrderStatus::Quarantined` (its +/// last-known counters frozen) as the operator/player-visible record, and the +/// failure is logged loudly. +fn quarantine_order( + ctx: &ReducerContext, + packets: &mut PacketTickState, + order_id: u64, + reason: &str, + logical_step: u64, +) { + log::error!( + target: "of", + "event=order.quarantine order_id={order_id} step={logical_step} reason={reason}" + ); + let packet_keys: Vec = packets + .by_order(order_id) + .map(|packet| packet.packet_key) + .collect(); + for packet_key in packet_keys { + packets.delete(ctx, &packet_key); + } + let source_cells = packets + .sources_by_order + .get(&order_id) + .cloned() + .unwrap_or_default(); + for cell_id in source_cells { + if let Some(source) = packets.source_rows.get_mut(&(order_id, cell_id)) + && source.queued_infantry != 0 + { + source.queued_infantry = 0; + packets.dirty_sources.insert((order_id, cell_id)); + } + } + if let Some(mut order) = ctx.db.transfer_order().order_id().find(order_id) { + order.status = OrderStatus::Quarantined; + order.in_transit_infantry = 0; + order.updated_step = logical_step; + ctx.db.transfer_order().order_id().update(order); + } + ctx.db.expansion_wave().order_id().delete(order_id); + clear_expansion_split_cursors(ctx, order_id); + let route_ids: Vec<_> = ctx + .db + .transit_route() + .route_by_order() + .filter(order_id) + .map(|route| route.route_id) + .collect(); + for route_id in route_ids { + ctx.db.transit_route().route_id().delete(route_id); + } + let abandonment_keys: Vec<_> = ctx + .db + .retreat_abandonment() + .abandonment_by_order() + .filter(order_id) + .map(|abandonment| abandonment.abandonment_key) + .collect(); + for key in abandonment_keys { + ctx.db.retreat_abandonment().abandonment_key().delete(key); + } +} + +fn expansion_split_cursor_value(ctx: &ReducerContext, order_id: u64, cell_id: u32) -> u8 { + ctx.db + .expansion_split_cursor() + .cursor_key() + .find(order_cell_key(order_id, cell_id)) + .map_or(0, |row| row.cursor) +} + +fn set_expansion_split_cursor(ctx: &ReducerContext, order_id: u64, cell_id: u32, cursor: u8) { + let cursor_key = order_cell_key(order_id, cell_id); + if let Some(mut row) = ctx + .db + .expansion_split_cursor() + .cursor_key() + .find(cursor_key) + { + row.cursor = cursor; + ctx.db.expansion_split_cursor().cursor_key().update(row); + } else { + ctx.db + .expansion_split_cursor() + .insert(ExpansionSplitCursor { + cursor_key, + order_id, + cell_id, + cursor, + }); + } +} + +pub(crate) fn clear_expansion_split_cursors(ctx: &ReducerContext, order_id: u64) { + let keys: Vec<_> = ctx + .db + .expansion_split_cursor() + .cursor_by_order() + .filter(order_id) + .map(|row| row.cursor_key) + .collect(); + for key in keys { + ctx.db.expansion_split_cursor().cursor_key().delete(key); + } +} + /// Neutral waves stop at later enemy ownership. Attack waves are constrained /// instead by their immutable source and target masks, so captures can open /// deeper fronts without ever leaking into an unselected cluster. @@ -499,29 +666,46 @@ fn stop_blocked_expand_edges( let mut blocked_packets = Vec::new(); for order in expand_orders { - let wave = ctx - .db - .expansion_wave() - .order_id() - .find(order.order_id) - .ok_or_else(|| format!("wave order {} has no topology", order.order_id))?; - for packet in packets.by_order(order.order_id) { - let next_index = packet.route_index as usize + 1; - let Some(&next_cell) = packet.route.get(next_index) else { - continue; - }; - if !expansion_edge_is_available(ctx, &order, &wave, packet.current_cell, next_cell)? { - blocked_packets.push(packet.clone()); - } + match blocked_expand_packets_for_order(ctx, packets, &order) { + Ok(mut blocked) => blocked_packets.append(&mut blocked), + Err(error) => quarantine_order(ctx, packets, order.order_id, &error, logical_step), } } blocked_packets.sort_unstable_by_key(|packet| packet.packet_key); for packet in blocked_packets { - station_packet_allocation(ctx, packets, &packet, packet.infantry, logical_step)?; + if let Err(error) = + station_packet_allocation(ctx, packets, &packet, packet.infantry, logical_step) + { + quarantine_order(ctx, packets, packet.order_id, &error, logical_step); + } } Ok(()) } +fn blocked_expand_packets_for_order( + ctx: &ReducerContext, + packets: &PacketTickState, + order: &TransferOrder, +) -> Result, String> { + let wave = ctx + .db + .expansion_wave() + .order_id() + .find(order.order_id) + .ok_or_else(|| format!("wave order {} has no topology", order.order_id))?; + let mut blocked = Vec::new(); + for packet in packets.by_order(order.order_id) { + let next_index = packet.route_index as usize + 1; + let Some(&next_cell) = packet.route.get(next_index) else { + continue; + }; + if !expansion_edge_is_available(ctx, order, &wave, packet.current_cell, next_cell)? { + blocked.push(packet.clone()); + } + } + Ok(blocked) +} + /// Formation and reshape routes are logistics-only. If ownership changes /// after an order is accepted, its allocation is retired in its current cell /// before the generic combat pass can treat the stale route as an attack. @@ -545,20 +729,34 @@ fn stop_blocked_internal_edges( } let mut blocked_packets = Vec::new(); - for (order_id, player_id, kind) in internal_orders { + let mut broken_orders = Vec::new(); + 'orders: for (order_id, player_id, kind) in internal_orders { for packet in packets.by_order(order_id) { let Some(&next_cell) = packet.route.get(packet.route_index as usize + 1) else { continue; }; - let next_owner = cell_state(ctx, next_cell)?.owner_player_id; + let next_owner = match cell_state(ctx, next_cell) { + Ok(next) => next.owner_player_id, + Err(error) => { + broken_orders.push((order_id, error)); + continue 'orders; + } + }; if internal_next_owner_is_blocked(kind, player_id, next_owner) { blocked_packets.push(packet.clone()); } } } + for (order_id, error) in broken_orders { + quarantine_order(ctx, packets, order_id, &error, logical_step); + } blocked_packets.sort_unstable_by_key(|packet| packet.packet_key); for packet in blocked_packets { - station_packet_allocation(ctx, packets, &packet, packet.infantry, logical_step)?; + if let Err(error) = + station_packet_allocation(ctx, packets, &packet, packet.infantry, logical_step) + { + quarantine_order(ctx, packets, packet.order_id, &error, logical_step); + } } Ok(()) } @@ -584,38 +782,46 @@ fn branch_expand_waves( .filter(|order| is_expansion_wave_order(order.kind)) .collect::>(); for order in orders { - let mut wave = ctx - .db - .expansion_wave() - .order_id() - .find(order.order_id) - .ok_or_else(|| format!("expand order {} has no topology", order.order_id))?; - let mut resting_by_cell = BTreeMap::>::new(); - for packet in packets.by_order(order.order_id) { - if expansion_packet_is_resting(packet) { - resting_by_cell - .entry(packet.current_cell) - .or_default() - .push(packet.clone()); - } - } - let mut topology_changed = false; - for (cell_id, mut contributions) in resting_by_cell { - contributions.sort_unstable_by_key(|packet| packet.packet_key); - topology_changed |= branch_expand_node( - ctx, - packets, - &order, - &mut wave, - cell_id, - &contributions, - logical_step, - )?; + if let Err(error) = branch_expand_wave_order(ctx, packets, &order, logical_step) { + quarantine_order(ctx, packets, order.order_id, &error, logical_step); } - if topology_changed { - ctx.db.expansion_wave().order_id().update(wave); + } + Ok(()) +} + +fn branch_expand_wave_order( + ctx: &ReducerContext, + packets: &mut PacketTickState, + order: &TransferOrder, + logical_step: u64, +) -> Result<(), String> { + let wave = ctx + .db + .expansion_wave() + .order_id() + .find(order.order_id) + .ok_or_else(|| format!("expand order {} has no topology", order.order_id))?; + let mut resting_by_cell = BTreeMap::>::new(); + for packet in packets.by_order(order.order_id) { + if expansion_packet_is_resting(packet) { + resting_by_cell + .entry(packet.current_cell) + .or_default() + .push(packet.clone()); } } + for (cell_id, mut contributions) in resting_by_cell { + contributions.sort_unstable_by_key(|packet| packet.packet_key); + branch_expand_node( + ctx, + packets, + order, + &wave, + cell_id, + &contributions, + logical_step, + )?; + } Ok(()) } @@ -629,15 +835,15 @@ fn branch_expand_node( ctx: &ReducerContext, packets: &mut PacketTickState, order: &TransferOrder, - wave: &mut ExpansionWave, + wave: &ExpansionWave, cell_id: u32, contributions: &[TickPacket], logical_step: u64, -) -> Result { +) -> Result<(), String> { let contributions = pay_expansion_garrison_debt(ctx, packets, order, cell_id, contributions, logical_step)?; if contributions.is_empty() { - return Ok(false); + return Ok(()); } let children = expansion_children(ctx, wave, cell_id)?; @@ -651,18 +857,14 @@ fn branch_expand_node( logical_step, )?; } - return Ok(false); + return Ok(()); } let amounts = contributions .iter() .map(|packet| packet.infantry) .collect::>(); - let cursor = wave - .split_cursors - .get(cell_id as usize) - .copied() - .ok_or_else(|| format!("expand split cursor is missing cell {cell_id}"))?; + let cursor = expansion_split_cursor_value(ctx, order.order_id, cell_id); let child_weights = expansion_child_weights(ctx, wave, cell_id, &children)?; let weighted = weighted_branch_allocations_rotated(&amounts, &child_weights, usize::from(cursor)) @@ -671,9 +873,8 @@ fn branch_expand_node( let next_cursor = weighted.next_cursor; let next_cursor = u8::try_from(next_cursor).map_err(|_| "expand child cursor exceeds u8".to_string())?; - let topology_changed = next_cursor != cursor; - if topology_changed { - wave.split_cursors[cell_id as usize] = next_cursor; + if next_cursor != cursor { + set_expansion_split_cursor(ctx, order.order_id, cell_id, next_cursor); } let mut stationed_by_contribution = vec![0_u64; contributions.len()]; let mut outgoing = Vec::new(); @@ -716,7 +917,7 @@ fn branch_expand_node( logical_step, )?; } - Ok(topology_changed) + Ok(()) } /// Pays only capture-scoped debt and only from this expansion's resting @@ -1020,37 +1221,25 @@ fn population_step( if cell.civilian_capacity == 0 { continue; } - let previous_civilians = cell.civilians; - let previous_infantry = cell.infantry; - let missing = cell.civilian_capacity.saturating_sub(cell.civilians); - if missing > 0 { - let growth = - ((u128::from(missing) * u128::from(config.civilian_growth_bps)) / 10_000) as u64; - cell.civilians = cell.civilians.saturating_add(growth.max(1).min(missing)); - } - - let local_population = cell.civilians.saturating_add(cell.infantry); - let desired_infantry = - ((u128::from(local_population) * u128::from(target_bps)) / 10_000) as u64; - if cell.infantry < desired_infantry && !retreating_edge_cells.contains(&cell.cell_id) { - let reserved_capacity = reserved_recruitment_capacity( - &destination_reservations, - cell.owner_player_id, - cell.cell_id, - ); - let recruit = desired_infantry - .saturating_sub(cell.infantry) - .min(config.mobilization_per_population_step) - .min(cell.civilians) - .min(recruitment_headroom( - cell.military_capacity, - cell.infantry, - reserved_capacity, - )); - cell.civilians -= recruit; - cell.infantry += recruit; - } - if cell.civilians != previous_civilians || cell.infantry != previous_infantry { + let reserved_capacity = reserved_recruitment_capacity( + &destination_reservations, + cell.owner_player_id, + cell.cell_id, + ); + let next = population_cell_transition( + cell.civilians, + cell.civilian_capacity, + cell.infantry, + cell.military_capacity, + config.civilian_growth_bps, + target_bps, + config.mobilization_per_population_step, + reserved_capacity, + retreating_edge_cells.contains(&cell.cell_id), + ); + if next.civilians != cell.civilians || next.infantry != cell.infantry { + cell.civilians = next.civilians; + cell.infantry = next.infantry; cell.last_changed_step = logical_step; ctx.db.cell_state().cell_id().update(cell); } @@ -1058,6 +1247,61 @@ fn population_step( Ok(()) } +/// One owned cell's population transition for one population interval. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct PopulationTransition { + civilians: u64, + infantry: u64, +} + +/// Pure population formula: civilians regrow toward capacity (basis-point +/// share of the missing amount, minimum one), then mobilization converts +/// civilians to infantry toward the target share, bounded by the per-step +/// mobilization budget, available civilians, and unreserved military +/// capacity headroom. Lowering the target never demobilizes. The transition +/// conserves total population except for the explicit regrowth amount. +#[allow(clippy::too_many_arguments)] +fn population_cell_transition( + civilians: u64, + civilian_capacity: u64, + infantry: u64, + military_capacity: u64, + growth_bps: u32, + target_bps: u32, + mobilization_per_step: u64, + reserved_capacity: u64, + retreating: bool, +) -> PopulationTransition { + let mut civilians = civilians; + let mut infantry = infantry; + let missing = civilian_capacity.saturating_sub(civilians); + if missing > 0 { + let growth = ((u128::from(missing) * u128::from(growth_bps)) / 10_000) as u64; + civilians = civilians.saturating_add(growth.max(1).min(missing)); + } + + let local_population = civilians.saturating_add(infantry); + let desired_infantry = + ((u128::from(local_population) * u128::from(target_bps)) / 10_000) as u64; + if infantry < desired_infantry && !retreating { + let recruit = desired_infantry + .saturating_sub(infantry) + .min(mobilization_per_step) + .min(civilians) + .min(recruitment_headroom( + military_capacity, + infantry, + reserved_capacity, + )); + civilians -= recruit; + infantry += recruit; + } + PopulationTransition { + civilians, + infantry, + } +} + fn active_retreat_abandonment_cells(ctx: &ReducerContext) -> BTreeSet { let active_orders = ctx .db @@ -1329,20 +1573,25 @@ fn move_friendly_packets( } } for (order_id, lane_anchor, direction) in capacity_stopped_lanes { - settle_stopped_sustained_lane( + if let Err(error) = settle_stopped_sustained_lane( ctx, packets, order_id, lane_anchor, direction, logical_step, - )?; + ) { + quarantine_order(ctx, packets, order_id, &error, logical_step); + } } for packet in capacity_stopped_packets.into_values() { // An upstream packet can merge into this key during the same pipeline // step. Retire the complete post-movement allocation at the blocked // choke, not only the amount present in the pre-step snapshot. - station_packet_allocation(ctx, packets, &packet, u64::MAX, logical_step)?; + if let Err(error) = station_packet_allocation(ctx, packets, &packet, u64::MAX, logical_step) + { + quarantine_order(ctx, packets, packet.order_id, &error, logical_step); + } } Ok(()) } @@ -1387,47 +1636,43 @@ fn resolve_combats( continue; } let defender = cell_state(ctx, target_cell)?; - let selected_attacker = if defender.owner_player_id == NEUTRAL_PLAYER { - attackers - .iter() - .max_by(|(left_player, left_fronts), (right_player, right_fronts)| { - let left_strength = left_fronts - .values() - .flatten() - .map(|packet| packet.infantry) - .sum::(); - let right_strength = right_fronts - .values() - .flatten() - .map(|packet| packet.infantry) - .sum::(); - left_strength - .cmp(&right_strength) - .then_with(|| right_player.cmp(left_player)) - }) - .map(|(&player, _)| player) - } else { - attackers - .keys() - .copied() - .find(|attacker| *attacker != defender.owner_player_id) - }; - let Some(attacker) = selected_attacker else { - continue; - }; - let Some(front_map) = attackers.get(&attacker) else { + // Every hostile owner engages simultaneously. The kernel allocates the + // defenders proportionally across all valid fronts and applies the + // documented multi-attacker capture rule; an owner who captured this + // cell earlier in the same pass is filtered out by the refresh above + // or by the defender-ownership check here. + let mut fronts = Vec::new(); + for (&attacker, front_map) in &attackers { + if attacker == defender.owner_player_id { + continue; + } + for (&from_cell, front_packets) in front_map { + fronts.push(FrontPackets { + attacker, + from_cell, + to_cell: target_cell, + packets: front_packets.clone(), + }); + } + } + if fronts.is_empty() { continue; - }; - let fronts: Vec<_> = front_map - .iter() - .map(|(&from_cell, packets)| FrontPackets { - attacker, - from_cell, - to_cell: target_cell, - packets: packets.clone(), - }) - .collect(); - resolve_target_combat(ctx, packets, defender, fronts, logical_step)?; + } + // A cell has exactly one owner, so `from_cell` is unique across owners + // and doubles as the deterministic kernel attack ID. + fronts.sort_unstable_by_key(|front| front.from_cell); + if let Err(error) = resolve_target_combat(ctx, packets, defender, &fronts, logical_step) { + // The failure is attributable to this contested cell: quarantine + // every order that contributed a front so the remaining targets + // (and future ticks) keep resolving. + let order_ids: BTreeSet = fronts + .iter() + .flat_map(|front| front.packets.iter().map(|packet| packet.order_id)) + .collect(); + for order_id in order_ids { + quarantine_order(ctx, packets, order_id, &error, logical_step); + } + } } Ok(()) } @@ -1465,18 +1710,31 @@ fn refresh_target_attackers( Ok(current) } +/// Resolves one contested cell against every hostile front simultaneously. +/// +/// Casualty allocation and the capture rule are owned by the kernel +/// ([`resolve_edge_combat`]): defenders split proportionally over all valid +/// fronts regardless of owner, and when the defender is eliminated the owner +/// with the largest surviving committed strength captures (ties break toward +/// the smaller owner ID, then the smaller origin cell ID). The module applies +/// its minimum-one-casualty adjustment before re-running the capture +/// selection over the adjusted survivors, so displayed numbers and the +/// capture pick always agree. +/// +/// Fronts rejected by the kernel (broken geometry) quarantine their +/// contributing orders while the remaining valid fronts still resolve. fn resolve_target_combat( ctx: &ReducerContext, packets: &mut PacketTickState, mut defender: CellState, - fronts: Vec, + fronts: &[FrontPackets], logical_step: u64, ) -> Result<(), String> { let config = config(ctx)?; let target_coordinate = coordinate_for_cell(ctx, defender.cell_id)?; let target_terrain = terrain(ctx, defender.cell_id)?; let mut attacks = Vec::new(); - for front in &fronts { + for front in fronts { let limits = edge_runtime_limits(ctx, front.from_cell, front.to_cell)? .ok_or_else(|| "combat route contains an impassable edge".to_string())?; attacks.push(AttackFront { @@ -1503,34 +1761,65 @@ fn resolve_target_combat( ) .map_err(|error| format!("combat resolution failed: {error:?}"))?; + // A rejected front means this order's persisted geometry violates the + // combat contract (non-adjacent origin, cliff, duplicated origin). That is + // attributable: park those orders and let the valid fronts resolve. + if !resolution.rejected.is_empty() { + let rejected_ids: BTreeMap = resolution + .rejected + .iter() + .map(|rejection| (rejection.id, rejection.reason)) + .collect(); + let mut rejected_orders = BTreeMap::new(); + for front in fronts { + if let Some(reason) = rejected_ids.get(&u64::from(front.from_cell)) { + for packet in &front.packets { + rejected_orders.insert( + packet.order_id, + format!( + "combat front {}->{} rejected: {reason:?}", + front.from_cell, front.to_cell + ), + ); + } + } + } + for (order_id, reason) in rejected_orders { + quarantine_order(ctx, packets, order_id, &reason, logical_step); + } + } + let valid_fronts: Vec<&FrontPackets> = fronts + .iter() + .filter(|front| resolution.attacks.contains_key(&u64::from(front.from_cell))) + .collect(); + if valid_fronts.is_empty() { + return Ok(()); + } + let total_engaged: u64 = resolution .attacks .values() .map(|outcome| outcome.engaged) .sum(); - let extra_defender_casualty = u64::from( - defender.infantry > 0 && total_engaged > 0 && resolution.defender_casualties == 0, + let defender_casualties = minimum_casualty( + total_engaged > 0, + resolution.defender_casualties, + defender.infantry, ); - let defender_casualties = resolution - .defender_casualties - .saturating_add(extra_defender_casualty) - .min(defender.infantry); + let extra_defender_casualty = + defender_casualties.saturating_sub(resolution.defender_casualties); - let mut surviving_by_front = BTreeMap::new(); - for front in &fronts { + let mut adjusted_outcomes = resolution.attacks.clone(); + for front in &valid_fronts { let outcome = resolution .attacks .get(&u64::from(front.from_cell)) .ok_or_else(|| "combat omitted an attack front".to_string())?; - let extra_attacker = u64::from( - outcome.engaged > 0 - && outcome.defense_allocated > 0 - && outcome.attacker_casualties == 0, + let attacker_casualties = minimum_casualty( + outcome.engaged > 0 && outcome.defense_allocated > 0, + outcome.attacker_casualties, + outcome.offered, ); - let attacker_casualties = outcome - .attacker_casualties - .saturating_add(extra_attacker) - .min(outcome.offered); apply_attacker_casualties( ctx, packets, @@ -1538,14 +1827,15 @@ fn resolve_target_combat( attacker_casualties, logical_step, )?; - surviving_by_front.insert( - front.from_cell, - outcome.offered.saturating_sub(attacker_casualties), - ); + if let Some(adjusted) = adjusted_outcomes.get_mut(&u64::from(front.from_cell)) { + adjusted.attacker_remaining = outcome.offered.saturating_sub(attacker_casualties); + } let limits = edge_runtime_limits(ctx, front.from_cell, front.to_cell)? .ok_or_else(|| "combat route became impassable".to_string())?; let front_defender_casualties = outcome.defender_casualties - + u64::from(extra_defender_casualty > 0 && front.from_cell == fronts[0].from_cell); + + u64::from( + extra_defender_casualty > 0 && front.from_cell == valid_fronts[0].from_cell, + ); let front_key = format!("{}:{}:{}", front.attacker, front.from_cell, front.to_cell); let next_front = CombatFront { front_key: front_key.clone(), @@ -1581,20 +1871,15 @@ fn resolve_target_combat( )?; if defender.infantry == 0 { - let capturing_front = surviving_by_front - .iter() - .filter(|(_, strength)| **strength > 0) - .max_by(|(left_cell, left_strength), (right_cell, right_strength)| { - left_strength - .cmp(right_strength) - .then_with(|| right_cell.cmp(left_cell)) - }) - .map(|(&cell, _)| cell); - if let Some(from_cell) = capturing_front { - let front = fronts + let (capturing_owner, capturing_front) = select_capture(&adjusted_outcomes); + if let (Some(owner), Some(front_id)) = (capturing_owner, capturing_front) { + let front = valid_fronts .iter() - .find(|front| front.from_cell == from_cell) + .find(|front| u64::from(front.from_cell) == front_id) .ok_or_else(|| "capturing front is missing".to_string())?; + if u32::from(front.attacker) != owner { + return Err("capture selection disagrees with its front owner".into()); + } occupy_after_combat(ctx, packets, front, defender, logical_step)?; } } @@ -1747,6 +2032,46 @@ fn record_expand_garrison_debt( Ok(()) } +/// Pure capture bookkeeping: the counter updates and the victory decision for +/// one ownership change, independent of any database state. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct CaptureAccounting { + /// Loser's counter after the change; `None` when the loser is neutral. + old_controlled: Option, + /// Winner's counter after the change; `None` when the winner is neutral. + new_controlled: Option, + /// True exactly when a non-neutral winner reaches `required_control`. + victory: bool, +} + +fn capture_accounting( + old_owner: u16, + new_owner: u16, + old_controlled: u64, + new_controlled: u64, + required_control: u64, +) -> Result { + let old_after = if old_owner == NEUTRAL_PLAYER { + None + } else { + Some(controlled_after_loss(old_controlled)?) + }; + let new_after = if new_owner == NEUTRAL_PLAYER { + None + } else { + Some( + new_controlled + .checked_add(1) + .ok_or_else(|| "controlled-cell count overflow".to_string())?, + ) + }; + Ok(CaptureAccounting { + old_controlled: old_after, + new_controlled: new_after, + victory: new_after.is_some_and(|controlled| controlled >= required_control), + }) +} + fn record_capture( ctx: &ReducerContext, cell_id: u32, @@ -1768,39 +2093,77 @@ fn record_capture( .ownership_revision .checked_add(1) .ok_or_else(|| "ownership revision overflow".to_string())?; - if old_owner != NEUTRAL_PLAYER { + let old_controlled = if old_owner == NEUTRAL_PLAYER { + 0 + } else { + ctx.db + .player_state() + .player_id() + .find(old_owner) + .ok_or("captured player's state is missing")? + .controlled_cells + }; + let new_controlled = if new_owner == NEUTRAL_PLAYER { + 0 + } else { + ctx.db + .player_state() + .player_id() + .find(new_owner) + .ok_or("capturing player's state is missing")? + .controlled_cells + }; + let accounting = capture_accounting( + old_owner, + new_owner, + old_controlled, + new_controlled, + match_state.required_control, + )?; + if let Some(controlled) = accounting.old_controlled { let mut old_state = ctx .db .player_state() .player_id() .find(old_owner) .ok_or("captured player's state is missing")?; - old_state.controlled_cells = controlled_after_loss(old_state.controlled_cells)?; + old_state.controlled_cells = controlled; ctx.db.player_state().player_id().update(old_state); } - let controlled = if new_owner == NEUTRAL_PLAYER { - None - } else { + if let Some(controlled) = accounting.new_controlled { let mut new_state = ctx .db .player_state() .player_id() .find(new_owner) .ok_or("capturing player's state is missing")?; - new_state.controlled_cells = new_state - .controlled_cells - .checked_add(1) - .ok_or("controlled-cell count overflow")?; - let controlled = new_state.controlled_cells; + new_state.controlled_cells = controlled; ctx.db.player_state().player_id().update(new_state); - Some(controlled) - }; - if controlled.is_some_and(|controlled| controlled >= match_state.required_control) { + } + if accounting.victory { match_state.phase = MatchPhase::Completed; match_state.winner_player_id = new_owner; match_state.completed_at_us = crate::timestamp_us(ctx); } ctx.db.match_state().singleton_id().update(match_state); + + // Guard against the incremental counter drifting from real ownership. + // Callers update `CellState.owner_player_id` before recording the + // capture, and players can only own capturable cells, so an indexed + // recount of the winner's rows must equal the incremental counter. + #[cfg(debug_assertions)] + if let Some(controlled) = accounting.new_controlled { + let recounted = ctx + .db + .cell_state() + .state_by_owner() + .filter(new_owner) + .count() as u64; + debug_assert_eq!( + recounted, controlled, + "controlled_cells counter for player {new_owner} drifted from ownership recount" + ); + } Ok(()) } @@ -1810,10 +2173,77 @@ fn controlled_after_loss(controlled_cells: u64) -> Result { .ok_or_else(|| "controlled-cell count underflow".to_owned()) } +/// Pure victory glue: when capture accounting reports victory, the match +/// completes with `new_owner` as winner; otherwise the phase is unchanged. +#[cfg(test)] +fn match_state_after_capture( + phase: MatchPhase, + winner_player_id: u16, + new_owner: u16, + victory: bool, +) -> (MatchPhase, u16) { + if victory { + (MatchPhase::Completed, new_owner) + } else { + (phase, winner_player_id) + } +} + const fn valid_owner(owner: u16, player_count: u16) -> bool { owner == NEUTRAL_PLAYER || (owner >= 1 && owner <= player_count) } +/// Module min-casualty adjustment applied after the kernel resolution so a +/// contested edge always shows at least one casualty on each engaged side. +fn minimum_casualty(engaged: bool, casualties: u64, available: u64) -> u64 { + let extra = u64::from(engaged && casualties == 0 && available > 0); + casualties.saturating_add(extra).min(available) +} + +/// Orders whose fronts the kernel rejected are attributable failures: park +/// them while sibling valid fronts continue to resolve. +#[cfg(test)] +fn orders_for_rejected_fronts( + fronts: &[(u32, Vec)], + rejected_front_ids: &BTreeSet, +) -> BTreeSet { + let mut orders = BTreeSet::new(); + for &(from_cell, ref packet_orders) in fronts { + if rejected_front_ids.contains(&u64::from(from_cell)) { + orders.extend(packet_orders.iter().copied()); + } + } + orders +} + +/// Active orders participate in the tick; quarantined rows are parked and +/// never re-enter movement/combat/finalize until an operator intervenes. +#[cfg(test)] +const fn order_participates_in_tick(status: OrderStatus) -> bool { + matches!(status, OrderStatus::Active) +} + +/// Quarantined orders are exempt from the Completed/Cancelled retention prune +/// so the operator-visible failure record survives. +#[cfg(test)] +const fn order_status_is_prunable_history(status: OrderStatus) -> bool { + matches!(status, OrderStatus::Completed | OrderStatus::Cancelled) +} + +/// Named tick phases in the order `advance_simulation` executes them. Mid-tick +/// quarantine of one order must not skip later phases for the remaining set. +#[cfg(test)] +const TICK_PHASES: &[&str] = &[ + "packet_load", + "trim", + "branch", + "move", + "combat", + "finalize", + "population", + "prune", +]; + fn advance_packet( ctx: &ReducerContext, packets: &mut PacketTickState, @@ -2391,8 +2821,16 @@ fn trim_all_overallocated_packets( if trim == 0 { break; } + let order_id = packet.order_id; let lost = trim.min(packet.infantry); - reduce_packet_metadata(ctx, packet_state, packet, lost, logical_step, true)?; + if let Err(error) = + reduce_packet_metadata(ctx, packet_state, packet, lost, logical_step, true) + { + // Any residual over-allocation at this location is retried by + // the next tick's trim pass over post-quarantine state. + quarantine_order(ctx, packet_state, order_id, &error, logical_step); + break; + } trim -= lost; } } @@ -2401,7 +2839,7 @@ fn trim_all_overallocated_packets( fn finalize_orders( ctx: &ReducerContext, - packets: &PacketTickState, + packets: &mut PacketTickState, logical_step: u64, ) -> Result<(), String> { let mut active_strength = BTreeMap::::new(); @@ -2416,19 +2854,30 @@ fn finalize_orders( .collect(); for mut order in orders { let in_transit = active_strength.get(&order.order_id).copied().unwrap_or(0); - let status = finalized_order_status( + let status = match finalized_order_status( order.committed_infantry, in_transit, order.delivered_infantry, order.casualty_infantry, - ) - .map_err(|error| format!("order {} {error}", order.order_id))?; + ) { + Ok(status) => status, + Err(error) => { + // A per-order conservation violation is exactly the class of + // failure that used to freeze the match forever: the reducer + // rolled back and the scheduler re-ran the identical state. + // Park the offending order and let everything else continue. + let reason = format!("order {} {error}", order.order_id); + quarantine_order(ctx, packets, order.order_id, &reason, logical_step); + continue; + } + }; let changed = order.in_transit_infantry != in_transit || order.status != status; if status == OrderStatus::Completed { order.in_transit_infantry = in_transit; order.status = status; complete_retreat_abandonments(ctx, packets, &order, logical_step)?; ctx.db.expansion_wave().order_id().delete(order.order_id); + clear_expansion_split_cursors(ctx, order.order_id); let route_ids = ctx .db .transit_route() @@ -2811,7 +3260,6 @@ mod tests { order_id: 1, selected_cells: vec![2, 4], outside_depths: vec![u16::MAX, 1, u16::MAX, 2, u16::MAX], - split_cursors: vec![0; 5], focus_cell_id: None, target_cells: Vec::new(), }; @@ -3513,4 +3961,298 @@ mod tests { Err("controlled-cell count underflow".to_owned()) ); } + + #[test] + fn capture_accounting_increments_and_decrements_both_counters() { + let taken_from_player = capture_accounting(2, 1, 10, 4, 100).unwrap(); + assert_eq!( + taken_from_player, + CaptureAccounting { + old_controlled: Some(9), + new_controlled: Some(5), + victory: false, + } + ); + + let taken_from_neutral = capture_accounting(NEUTRAL_PLAYER, 1, 0, 4, 100).unwrap(); + assert_eq!(taken_from_neutral.old_controlled, None); + assert_eq!(taken_from_neutral.new_controlled, Some(5)); + + let relinquished = capture_accounting(1, NEUTRAL_PLAYER, 4, 0, 3).unwrap(); + assert_eq!(relinquished.old_controlled, Some(3)); + assert_eq!(relinquished.new_controlled, None); + } + + #[test] + fn victory_triggers_exactly_at_the_required_control_threshold() { + let below = capture_accounting(NEUTRAL_PLAYER, 1, 0, 98, 100).unwrap(); + assert_eq!(below.new_controlled, Some(99)); + assert!(!below.victory); + + let exactly = capture_accounting(NEUTRAL_PLAYER, 1, 0, 99, 100).unwrap(); + assert_eq!(exactly.new_controlled, Some(100)); + assert!(exactly.victory); + + let above = capture_accounting(2, 1, 5, 100, 100).unwrap(); + assert_eq!(above.new_controlled, Some(101)); + assert!(above.victory); + } + + #[test] + fn neutral_captures_never_win_a_match_and_underflow_is_rejected() { + // Relinquishing to neutral can never produce a winner even when the + // "required control" is trivially low. + let to_neutral = capture_accounting(1, NEUTRAL_PLAYER, 4, u64::MAX, 0).unwrap(); + assert!(!to_neutral.victory); + + assert!(capture_accounting(1, 2, 0, 5, 100).is_err()); + assert!(capture_accounting(1, 2, 5, u64::MAX, u64::MAX).is_err()); + } + + #[test] + fn civilians_regrow_toward_capacity_with_a_minimum_of_one() { + // 200 bps of 50 missing civilians = 1 per interval. + let next = population_cell_transition(50, 100, 0, 100, 200, 0, 10, 0, false); + assert_eq!( + next, + PopulationTransition { + civilians: 51, + infantry: 0 + } + ); + + // Tiny deficits still regrow by at least one, and never overshoot. + let almost_full = population_cell_transition(99, 100, 0, 100, 200, 0, 10, 0, false); + assert_eq!(almost_full.civilians, 100); + let full = population_cell_transition(100, 100, 0, 100, 200, 0, 10, 0, false); + assert_eq!(full.civilians, 100); + } + + #[test] + fn mobilization_conserves_population_and_respects_every_bound() { + // 50% target of 100 population wants 50 infantry; the per-step budget + // caps conversion at 10 and total population is conserved (no growth: + // civilians already at capacity). + let next = population_cell_transition(100, 100, 0, 200, 200, 5_000, 10, 0, false); + assert_eq!( + next, + PopulationTransition { + civilians: 90, + infantry: 10 + } + ); + assert_eq!(next.civilians + next.infantry, 100); + + // Military capacity headroom (including reservations) is respected. + let capped = population_cell_transition(100, 100, 47, 50, 200, 10_000, 100, 2, false); + assert_eq!(capped.infantry, 48); + assert_eq!(capped.civilians + capped.infantry, 147); + + // Retreating edge cells never recruit. + let retreating = population_cell_transition(100, 100, 0, 200, 200, 5_000, 10, 0, true); + assert_eq!(retreating.infantry, 0); + + // Rounding neither creates nor destroys population across a long run: + // each step's total change equals exactly the regrowth amount, which + // is bounded by the remaining civilian deficit. + let mut civilians = 73_u64; + let mut infantry = 9_u64; + for _ in 0..500 { + let before_total = civilians + infantry; + let deficit = 100 - civilians; + let next = + population_cell_transition(civilians, 100, infantry, 80, 150, 3_333, 7, 0, false); + let grown = next.civilians + next.infantry - before_total; + assert!(grown <= deficit, "growth is bounded by the deficit"); + civilians = next.civilians; + infantry = next.infantry; + assert!(infantry <= 80, "military capacity is never exceeded"); + } + // Deterministic fixed point: full civilian capacity plus the largest + // infantry count where the 33.33% target is already satisfied. + assert_eq!((civilians, infantry), (100, 49)); + } + + #[test] + fn lowering_the_mobilization_target_never_demobilizes() { + let mobilized = population_cell_transition(50, 100, 50, 200, 200, 0, 100, 0, false); + assert_eq!(mobilized.infantry, 50); + assert!(mobilized.civilians >= 50); + + // Target of 10% wants 10 infantry but 50 are already mobilized: no + // conversion in either direction beyond regular regrowth. + let lowered = population_cell_transition(50, 100, 50, 200, 200, 1_000, 100, 0, false); + assert_eq!(lowered.infantry, 50); + } + + #[test] + fn per_order_conservation_violations_map_to_quarantine_not_fail_stop() { + // The exact class of failure that used to freeze the match: an + // order's accounting no longer sums to its commitment. The finalize + // phase must classify this as attributable (quarantine) rather than + // propagate it out of the scheduled reducer. + let broken = finalized_order_status(100, 10, 50, 30); + assert!(broken.is_err(), "90 accounted of 100 committed must fail"); + let overflow = finalized_order_status(100, u64::MAX, 1, 0); + assert!(overflow.is_err()); + + // Healthy orders keep their previous lifecycle transitions. + assert_eq!( + finalized_order_status(100, 0, 70, 30), + Ok(OrderStatus::Completed) + ); + assert_eq!( + finalized_order_status(100, 10, 60, 30), + Ok(OrderStatus::Active) + ); + } + + #[test] + fn quarantined_orders_leave_the_tick_and_survive_history_prune() { + // Packet load and finalize only pull Active rows; a quarantined order + // therefore cannot re-enter movement/combat and re-fail the tick. + assert!(order_participates_in_tick(OrderStatus::Active)); + assert!(!order_participates_in_tick(OrderStatus::Quarantined)); + assert!(!order_participates_in_tick(OrderStatus::Completed)); + assert!(!order_participates_in_tick(OrderStatus::Cancelled)); + + // Completed/Cancelled feedback rows age out; quarantined records are + // the operator-visible invariant failure and are never pruned by the + // retention pass. + assert!(order_status_is_prunable_history(OrderStatus::Completed)); + assert!(order_status_is_prunable_history(OrderStatus::Cancelled)); + assert!(!order_status_is_prunable_history(OrderStatus::Quarantined)); + assert!(!order_status_is_prunable_history(OrderStatus::Active)); + assert!(!order_history_is_prunable(100, 100 + ORDER_RETENTION_STEPS)); + assert!(order_history_is_prunable( + 100, + 100 + ORDER_RETENTION_STEPS + 1 + )); + } + + #[test] + fn tick_phases_keep_finalize_population_and_prune_after_combat() { + // Mid-tick quarantine of one order must not skip later phases: the + // remaining orders still finalize, population still runs on cadence, + // and history prune still runs on its interval. + assert_eq!( + TICK_PHASES, + &[ + "packet_load", + "trim", + "branch", + "move", + "combat", + "finalize", + "population", + "prune", + ] + ); + let combat = TICK_PHASES + .iter() + .position(|phase| *phase == "combat") + .unwrap(); + let finalize = TICK_PHASES + .iter() + .position(|phase| *phase == "finalize") + .unwrap(); + let population = TICK_PHASES + .iter() + .position(|phase| *phase == "population") + .unwrap(); + let prune = TICK_PHASES + .iter() + .position(|phase| *phase == "prune") + .unwrap(); + assert!(combat < finalize && finalize < population && population < prune); + } + + #[test] + fn rejected_fronts_quarantine_only_their_contributing_orders() { + let fronts = [(10_u32, vec![1_u64, 2]), (20, vec![3]), (30, vec![4, 5])]; + let rejected = BTreeSet::from([10_u64, 30]); + let quarantined = orders_for_rejected_fronts(&fronts, &rejected); + assert_eq!(quarantined, BTreeSet::from([1, 2, 4, 5])); + // The sibling valid front's order is untouched and continues resolving. + assert!(!quarantined.contains(&3)); + } + + #[test] + fn module_min_casualty_adjustment_forces_one_on_engaged_sides() { + assert_eq!(minimum_casualty(true, 0, 50), 1); + assert_eq!(minimum_casualty(true, 7, 50), 7); + assert_eq!(minimum_casualty(false, 0, 50), 0); + assert_eq!(minimum_casualty(true, 0, 0), 0); + assert_eq!(minimum_casualty(true, 9, 5), 5); + } + + #[test] + fn mixed_owner_kernel_capture_survives_module_min_casualty_adjustment() { + // Two owners attack one defender; after the module bumps sub-lethal + // attacker casualties by one, re-running select_capture must still + // pick the stronger surviving owner (mirrors resolve_target_combat). + let attacks = [ + AttackFront { + id: 10, + attacker: 1, + from: Axial::new(1, 0), + from_elevation: 0, + offered: 20, + frontage: 25, + }, + AttackFront { + id: 20, + attacker: 2, + from: Axial::new(0, 1), + from_elevation: 0, + offered: 40, + frontage: 25, + }, + ]; + let resolution = + resolve_edge_combat(Axial::ZERO, 30, 0, &attacks, &CombatConfig::default()).unwrap(); + assert!(resolution.rejected.is_empty()); + assert_eq!(resolution.defender_remaining, 0); + let mut adjusted = resolution.attacks.clone(); + for outcome in adjusted.values_mut() { + let casualties = minimum_casualty( + outcome.engaged > 0 && outcome.defense_allocated > 0, + outcome.attacker_casualties, + outcome.offered, + ); + outcome.attacker_remaining = outcome.offered.saturating_sub(casualties); + } + let (owner, front) = select_capture(&adjusted); + assert_eq!(owner, Some(2)); + assert_eq!(front, Some(20)); + // Default lethality already produces the same capture; the module + // re-run exists so a forced +1 casualty cannot flip the winner. + assert_eq!(owner, resolution.capturing_owner); + assert_eq!(front, resolution.capturing_front); + } + + #[test] + fn victory_glue_completes_the_match_for_the_capturing_owner() { + let accounting = capture_accounting(NEUTRAL_PLAYER, 3, 0, 99, 100).unwrap(); + assert!(accounting.victory); + assert_eq!( + match_state_after_capture(MatchPhase::Running, 0, 3, accounting.victory), + (MatchPhase::Completed, 3) + ); + + let short = capture_accounting(NEUTRAL_PLAYER, 3, 0, 50, 100).unwrap(); + assert!(!short.victory); + assert_eq!( + match_state_after_capture(MatchPhase::Running, 0, 3, short.victory), + (MatchPhase::Running, 0) + ); + + // Relinquishing to neutral never completes the match. + let to_neutral = capture_accounting(1, NEUTRAL_PLAYER, 4, 0, 1).unwrap(); + assert!(!to_neutral.victory); + assert_eq!( + match_state_after_capture(MatchPhase::Running, 0, NEUTRAL_PLAYER, to_neutral.victory), + (MatchPhase::Running, 0) + ); + } } diff --git a/scripts/run-automated-playtest.sh b/scripts/run-automated-playtest.sh new file mode 100755 index 0000000..7c999dc --- /dev/null +++ b/scripts/run-automated-playtest.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Fully automated no-human cluster-controls playtest against a real local match. +# +# Publishes a fresh isolated database (never of-match-dev), runs match-playtest, +# and writes machine-generated evidence under docs/playtests/ and artifacts/playtests/. +# +# Prerequisites: +# Local SpacetimeDB on 127.0.0.1:3000 (./scripts/start-local-server.sh) +# +# Examples: +# ./scripts/run-automated-playtest.sh +# OF_PLAYTEST_DATABASE=of-match-e2e-auto ./scripts/run-automated-playtest.sh +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_dir="$(cd -- "${script_dir}/.." && pwd)" + +host="${OF_PLAYTEST_HOST:-http://127.0.0.1:3000}" +server="${OF_PLAYTEST_SERVER:-local}" +database="${OF_PLAYTEST_DATABASE:-of-match-e2e-auto}" +timeout_secs="${OF_PLAYTEST_TIMEOUT_SECS:-30}" +contact_budget_secs="${OF_PLAYTEST_CONTACT_BUDGET_SECS:-240}" +token_dir="${OF_PLAYTEST_TOKEN_DIR:-.match-playtest-tokens}" + +if [[ "${database}" == "of-match-dev" ]]; then + echo "Refusing to run against of-match-dev; set OF_PLAYTEST_DATABASE to an isolated test database." >&2 + exit 2 +fi + +"${script_dir}/check-toolchain.sh" + +if ! curl -s -o /dev/null -w '' "${host}/" 2>/dev/null; then + echo "Local SpacetimeDB is not reachable at ${host}." >&2 + echo "Start it in another terminal: ./scripts/start-local-server.sh" >&2 + exit 1 +fi + +echo "==> Building match module and publishing fresh isolated database '${database}'" +spacetime build --module-path "${repo_dir}/modules/match" +spacetime publish \ + --server "${server}" \ + --module-path "${repo_dir}/modules/match" \ + --delete-data=always \ + --yes \ + "${database}" + +echo "==> Running automated cluster-controls playtest" +rm -rf "${repo_dir}/${token_dir}" +cd "${repo_dir}" +set +e +cargo run -p match-playtest -- \ + --host "${host}" \ + --database "${database}" \ + --token-dir "${token_dir}" \ + --timeout-secs "${timeout_secs}" \ + --contact-budget-secs "${contact_budget_secs}" +exit_code=$? +set -e + +if [[ "${exit_code}" -eq 0 ]]; then + echo "==> PASS (exit 0)" +elif [[ "${exit_code}" -eq 2 ]]; then + echo "==> FAIL (exit 2): see docs/playtests/cluster-controls-v1-automated-*.md" +else + echo "==> FATAL (exit ${exit_code})" +fi + +exit "${exit_code}" diff --git a/scripts/run-quarantine-harness.sh b/scripts/run-quarantine-harness.sh new file mode 100755 index 0000000..f433f42 --- /dev/null +++ b/scripts/run-quarantine-harness.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Live quarantine integration harness against an isolated SpacetimeDB match. +# +# Publishes a fresh isolated database (never of-match-dev), enables the private +# debug harness in Lobby, then runs match-e2e --quarantine-live to prove: +# tick → attributable conservation fault → Quarantined order +# strength conserved at physical cells +# subsequent ticks continue (logical_step advances) +# +# Prerequisites: +# Local SpacetimeDB on 127.0.0.1:3000 (./scripts/start-local-server.sh) +# +# Example: +# ./scripts/run-quarantine-harness.sh +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_dir="$(cd -- "${script_dir}/.." && pwd)" + +host="${OF_QUARANTINE_HOST:-http://127.0.0.1:3000}" +server="${OF_QUARANTINE_SERVER:-local}" +database="${OF_QUARANTINE_DATABASE:-of-match-e2e-quarantine}" +timeout_secs="${OF_QUARANTINE_TIMEOUT_SECS:-60}" +token_dir="${OF_QUARANTINE_TOKEN_DIR:-.match-e2e-quarantine-tokens}" + +if [[ "${database}" == "of-match-dev" ]]; then + echo "Refusing to run against of-match-dev; set OF_QUARANTINE_DATABASE to an isolated test database." >&2 + exit 2 +fi + +"${script_dir}/check-toolchain.sh" + +if ! curl -s -o /dev/null -w '' "${host}/" 2>/dev/null; then + echo "Local SpacetimeDB is not reachable at ${host}." >&2 + echo "Start it in another terminal: ./scripts/start-local-server.sh" >&2 + exit 1 +fi + +echo "==> Building match module and publishing fresh isolated database '${database}'" +spacetime build --module-path "${repo_dir}/modules/match" +spacetime publish \ + --server "${server}" \ + --module-path "${repo_dir}/modules/match" \ + --delete-data=always \ + --yes \ + "${database}" + +echo "==> Running live quarantine harness" +rm -rf "${repo_dir}/${token_dir}" +cd "${repo_dir}" +cargo run -p match-e2e -- \ + --host "${host}" \ + --database "${database}" \ + --token-dir "${token_dir}" \ + --timeout-secs "${timeout_secs}" \ + --quarantine-live + +echo "==> PASS" diff --git a/tools/match-e2e/src/main.rs b/tools/match-e2e/src/main.rs index 1c7f52f..8755f94 100644 --- a/tools/match-e2e/src/main.rs +++ b/tools/match-e2e/src/main.rs @@ -23,9 +23,9 @@ use match_bindings::{ PlayerSlotTableAccess, ReceiptStatus, TerrainClass, TransferDestinationTableAccess, TransferOrder, TransferOrderTableAccess, TransferSourceTableAccess, TransitPacket, TransitPacketTableAccess, TransitRouteTableAccess, cancel_orders as _, - issue_attack_clusters as _, issue_expand_all as _, issue_expand_clusters as _, - issue_push_front as _, issue_reshape as _, join_match as _, set_mobilization_target as _, - start_match as _, + debug_break_order_conservation as _, enable_debug_harness as _, issue_attack_clusters as _, + issue_expand_all as _, issue_expand_clusters as _, issue_push_front as _, issue_reshape as _, + join_match as _, set_mobilization_target as _, start_match as _, }; use spacetimedb_sdk::{DbContext, Identity, Table}; @@ -94,6 +94,13 @@ struct Args { /// Optional JSON report path for reconnect soak timings. #[arg(long)] reconnect_report: Option, + + /// Live quarantine integration: enable the private debug harness (lobby + /// only), force one attributable conservation fault, and assert the next + /// tick parks the order while the match keeps advancing. Requires a fresh + /// Lobby database; never run against of-match-dev. + #[arg(long, default_value_t = false)] + quarantine_live: bool, } #[derive(Debug, serde::Serialize)] @@ -241,6 +248,28 @@ impl Client { wait_for_reducer(&rx, timeout, &format!("{} start_match", self.label)) } + fn enable_debug_harness(&self, timeout: Duration) -> Result<()> { + let (tx, rx) = mpsc::channel(); + self.conn + .reducers + .enable_debug_harness_then(move |_, result| { + let _ = tx.send(flatten_reducer_result(result)); + }) + .context("send enable_debug_harness")?; + wait_for_reducer(&rx, timeout, "enable_debug_harness") + } + + fn debug_break_order_conservation(&self, order_id: u64, timeout: Duration) -> Result<()> { + let (tx, rx) = mpsc::channel(); + self.conn + .reducers + .debug_break_order_conservation_then(order_id, move |_, result| { + let _ = tx.send(flatten_reducer_result(result)); + }) + .context("send debug_break_order_conservation")?; + wait_for_reducer(&rx, timeout, "debug_break_order_conservation") + } + fn set_mobilization_target( &self, command_id: u64, @@ -537,6 +566,14 @@ fn main() -> Result<()> { "--reconnect-only requires --reconnect-cycles > 0" ); } + ensure!( + !(args.reconnect_only && args.quarantine_live), + "--reconnect-only and --quarantine-live are mutually exclusive" + ); + ensure!( + args.database != "of-match-dev" || !args.quarantine_live, + "refusing --quarantine-live against of-match-dev; use an isolated database" + ); let timeout = Duration::from_secs(args.timeout_secs); let poll = Duration::from_millis(args.poll_ms); @@ -572,6 +609,24 @@ fn main() -> Result<()> { assert_slot_available_or_owned(&player_one, PLAYER_ONE, player_one.identity)?; assert_slot_available_or_owned(&player_one, PLAYER_TWO, player_two.identity)?; + if args.quarantine_live { + println!("quarantine-live: enabling private debug harness before any seat is claimed"); + player_one.enable_debug_harness(timeout)?; + println!("[2/10] claiming player slots and waiting for a running match"); + player_one.join_match(PLAYER_ONE, "E2E Player 1", timeout)?; + player_two.join_match(PLAYER_TWO, "E2E Player 2", timeout)?; + wait_for_slot(&player_one, PLAYER_ONE, player_one.identity, timeout, poll)?; + wait_for_slot(&player_one, PLAYER_TWO, player_two.identity, timeout, poll)?; + ensure_match_running(&player_one, timeout, poll)?; + run_quarantine_live_harness(&player_one, timeout, poll)?; + player_one.disconnect(timeout)?; + player_two.disconnect(timeout)?; + println!( + "PASS: live quarantine harness — order parked Quarantined, strength conserved, ticks continued" + ); + return Ok(()); + } + println!("[2/10] claiming player slots and waiting for a running match"); player_one.join_match(PLAYER_ONE, "E2E Player 1", timeout)?; player_two.join_match(PLAYER_TWO, "E2E Player 2", timeout)?; @@ -901,26 +956,24 @@ fn main() -> Result<()> { retasked_old_order.delivered_infantry > 0, "retasking did not settle any surviving strength on the old order" ); - let uncaptured_after_retask = candidate - .lane_cells - .iter() - .copied() - .filter(|cell_id| { - player_one - .conn - .db - .cell_state() - .cell_id() - .find(cell_id) - .is_some_and(|cell| cell.owner_player_id != PLAYER_ONE) - }) - .collect::>(); - ensure!( - uncaptured_after_retask.len() >= OBSERVED_CAPTURE_LAYERS, - "only {} lane cell(s) remained uncaptured after retasking; the live fixture advanced too far to prove replacement progression", - uncaptured_after_retask.len() - ); - let replacement_capture_targets = uncaptured_after_retask[..OBSERVED_CAPTURE_LAYERS].to_vec(); + // The original push keeps advancing while the idempotency check, the + // rejected retask, and the accepted retask round-trips complete. On a + // local server only a cell or two of the pre-issue lane snapshot is + // captured by then, but against remote hosts (Maincloud provisioned + // matches) several logical steps elapse per round-trip and the wave can + // overrun the whole snapshotted lane. The old fixed intersection with + // `candidate.lane_cells` then failed with "advanced too far" even though + // the replacement was progressing correctly. Deriving the remaining + // runway from the actual current map — continuing along the commanded + // direction past everything already captured — keeps the progression + // assertion meaningful at any latency. + let replacement_capture_targets = derive_remaining_lane_targets( + &player_one.conn, + PLAYER_ONE, + candidate.front_cell, + candidate.direction, + OBSERVED_CAPTURE_LAYERS, + )?; println!("[6/10] observing retasked progression, then cancelling the replacement push"); let mut observed_packet_progress = false; @@ -4444,6 +4497,73 @@ fn expected_occupation_garrison(terrain: &CellTerrain, cell: &CellState) -> u64 .min(cell.military_capacity) } +/// Walks the commanded directional ray from the original front cell over the +/// live map and returns the next `needed` still-neutral capturable lane cells. +/// +/// Cells the wave already captured (now owned by `player_id`) are skipped +/// instead of failing, so the expectation tracks however far the real match +/// advanced between command round-trips. Elevation and terrain eligibility +/// mirror `select_push_front_candidate`. +fn derive_remaining_lane_targets( + conn: &DbConnection, + player_id: u16, + front_cell: u32, + direction: Axial, + needed: usize, +) -> Result> { + let terrain_by_id: HashMap = conn + .db + .cell_terrain() + .iter() + .map(|terrain| (terrain.cell_id, terrain)) + .collect(); + let cell_by_coordinate: HashMap = terrain_by_id + .values() + .map(|terrain| (Axial::new(terrain.q, terrain.r), terrain.cell_id)) + .collect(); + let front_terrain = terrain_by_id + .get(&front_cell) + .context("front cell terrain disappeared while re-deriving the lane")?; + + let mut targets = Vec::new(); + let mut previous_elevation = front_terrain.elevation; + let mut next_coordinate = Axial::new(front_terrain.q, front_terrain.r) + direction; + while targets.len() < needed { + let Some(&next_id) = cell_by_coordinate.get(&next_coordinate) else { + break; + }; + let Some(next_terrain) = terrain_by_id.get(&next_id) else { + break; + }; + let Some(next_state) = conn.db.cell_state().cell_id().find(&next_id) else { + break; + }; + if !next_terrain.passable + || !next_terrain.capturable + || previous_elevation.abs_diff(next_terrain.elevation) > 1 + { + break; + } + if next_state.owner_player_id == player_id { + // Already captured while the retask round-trips completed. + } else if next_state.owner_player_id == 0 && next_state.infantry == 0 { + targets.push(next_id); + } else { + break; + } + previous_elevation = next_terrain.elevation; + next_coordinate = next_coordinate + direction; + } + ensure!( + targets.len() >= needed, + "only {} neutral lane cell(s) remain along the push direction on the live map; \ + the generated lane is exhausted, so this fixture cannot prove {needed} further \ + capture layers", + targets.len() + ); + Ok(targets) +} + fn lane_owners(conn: &DbConnection, lane_cells: &[u32]) -> Result> { lane_cells .iter() @@ -4979,17 +5099,24 @@ fn assert_attack_stays_in_target_mask( outside_guard_cells: &BTreeSet, owners_before: &HashMap, ) -> Result { - assert_order_conservation(order)?; - let packets = conn - .db - .transit_packet() - .iter() - .filter(|packet| packet.order_id == order.order_id) - .collect::>(); + // Prefer a same-logical-step coherent view: AttackClusters wave splits and + // finalize can update packets and the order row in one transaction, but the + // SDK may deliver those table callbacks across consecutive turns. Comparing + // a stale order row to fresh packets produced the flaky + // "80 in transit vs 69 packet infantry" failure. + let snapshot = stable_action_order_snapshot(conn, order)?; + assert_order_conservation_counters( + snapshot.committed_infantry, + snapshot.in_transit_infantry, + snapshot.delivered_infantry, + snapshot.casualty_infantry, + snapshot.logical_step, + order.order_id, + )?; let mut packet_total = 0_u64; let mut mask_activity = false; - for packet in packets { - let route = transit_packet_route(conn, &packet)?; + for packet in &snapshot.packets { + let route = transit_packet_route(conn, packet)?; packet_total = packet_total .checked_add(packet.infantry) .context("masked cluster-attack packet strength overflow")?; @@ -5012,18 +5139,16 @@ fn assert_attack_stays_in_target_mask( mask_activity |= target_component.contains(&cell_id); } } - ensure!( - packet_total == order.in_transit_infantry, - "AttackClusters order {} reports {} in transit but exposes {packet_total} packet infantry", - order.order_id, - order.in_transit_infantry - ); + if packet_total != snapshot.in_transit_infantry { + // Incomplete client-cache sync within the step — ask the caller to retry. + return Ok(false); + } let captures = owner_changes_for_player(conn, player_id, owners_before); ensure!( captures.is_subset(target_component), "AttackClusters acquired cells outside its immutable target component: captures={captures:?}, target_mask={target_component:?}" ); - mask_activity |= !captures.is_empty() || order.casualty_infantry > 0; + mask_activity |= !captures.is_empty() || snapshot.casualty_infantry > 0; for &guard_cell in outside_guard_cells { let owner = conn .db @@ -5056,6 +5181,175 @@ fn owner_changes_for_player( .collect() } +fn total_infantry(conn: &DbConnection) -> u64 { + conn.db.cell_state().iter().map(|cell| cell.infantry).sum() +} + +fn logical_step(conn: &DbConnection) -> Result { + Ok(conn + .db + .match_state() + .singleton_id() + .find(&SINGLETON_ID) + .context("match state missing during quarantine harness")? + .logical_step) +} + +fn quarantine_reshape_pair(conn: &DbConnection) -> Result<(u32, u32)> { + let mut owned: Vec<(u32, u64, u64)> = conn + .db + .cell_state() + .iter() + .filter(|cell| cell.owner_player_id == PLAYER_ONE && cell.infantry > 4) + .map(|cell| { + ( + cell.cell_id, + cell.infantry, + cell.military_capacity.saturating_sub(cell.infantry), + ) + }) + .collect(); + owned.sort_by_key(|(cell_id, infantry, _)| (std::cmp::Reverse(*infantry), *cell_id)); + ensure!( + owned.len() >= 2, + "quarantine harness needs at least two owned cells with infantry" + ); + let source = owned[0].0; + let target = owned + .iter() + .skip(1) + .find(|(_, _, headroom)| *headroom > 0) + .map(|(cell_id, _, _)| *cell_id) + .context("quarantine harness found no owned target with headroom")?; + Ok((source, target)) +} + +fn wait_for_active_order_with_packets( + client: &Client, + order_id: u64, + timeout: Duration, + poll: Duration, +) -> Result { + let active = wait_until("active reshape with packets", timeout, poll, || { + let Some(order) = client.conn.db.transfer_order().order_id().find(&order_id) else { + return Ok(None); + }; + let packets = client + .conn + .db + .transit_packet() + .iter() + .filter(|packet| packet.order_id == order_id) + .count(); + Ok((order.status == OrderStatus::Active && packets > 0).then_some(order)) + })?; + ensure!( + active.in_transit_infantry > 0, + "reshape never exposed in-transit infantry for the quarantine fault" + ); + Ok(active) +} + +fn assert_quarantine_conserved_and_ticking( + client: &Client, + order_id: u64, + infantry_before: u64, + step_before: u64, + timeout: Duration, + poll: Duration, +) -> Result<()> { + let quarantined = wait_until("order quarantined by next ticks", timeout, poll, || { + let Some(order) = client.conn.db.transfer_order().order_id().find(&order_id) else { + return Ok(None); + }; + Ok((order.status == OrderStatus::Quarantined).then_some(order)) + })?; + ensure!( + quarantined.in_transit_infantry == 0 + && !client + .conn + .db + .transit_packet() + .iter() + .any(|packet| packet.order_id == order_id), + "quarantined order retained live packet strength" + ); + + let infantry_after = total_infantry(&client.conn); + ensure!( + infantry_after == infantry_before, + "quarantine changed physical cell strength: before {infantry_before}, after {infantry_after}" + ); + + let step_after_quarantine = logical_step(&client.conn)?; + wait_until( + "match keeps ticking after quarantine", + timeout, + poll, + || { + let step = logical_step(&client.conn)?; + Ok((step >= step_after_quarantine.saturating_add(4)).then_some(step)) + }, + )?; + let step_final = logical_step(&client.conn)?; + ensure!( + step_final > step_before, + "logical_step did not advance after quarantine ({step_before} -> {step_final})" + ); + println!( + "quarantine-live: order {order_id} Quarantined; infantry conserved at {infantry_after}; \ + logical_step {step_before} -> {step_final}" + ); + Ok(()) +} + +/// Live tick→quarantine→next-tick proof against a debug-harness-enabled match. +fn run_quarantine_live_harness(client: &Client, timeout: Duration, poll: Duration) -> Result<()> { + println!("quarantine-live: stopping mobilization for a stable infantry baseline"); + let mobilization_id = unused_command_id(&client.conn, PLAYER_ONE, COMMAND_ID_FLOOR)?; + client.set_mobilization_target(mobilization_id, 0, timeout)?; + wait_for_receipt( + client, + PLAYER_ONE, + mobilization_id, + "set_mobilization_target", + timeout, + poll, + )?; + + let (source, target) = quarantine_reshape_pair(&client.conn)?; + println!("quarantine-live: issuing reshape {source} -> {target} as the quarantine victim"); + let reshape_id = unused_command_id(&client.conn, PLAYER_ONE, mobilization_id + 1)?; + client.issue_reshape(reshape_id, &[source], &[target], &[], timeout)?; + let receipt = wait_for_receipt( + client, + PLAYER_ONE, + reshape_id, + "issue_reshape", + timeout, + poll, + )?; + ensure!(receipt.order_id != 0, "reshape did not persist an order"); + let order_id = receipt.order_id; + wait_for_active_order_with_packets(client, order_id, timeout, poll)?; + + let infantry_before = total_infantry(&client.conn); + let step_before = logical_step(&client.conn)?; + println!( + "quarantine-live: injecting conservation fault into order {order_id} at step {step_before} \ + (world infantry {infantry_before})" + ); + client.debug_break_order_conservation(order_id, timeout)?; + assert_quarantine_conserved_and_ticking( + client, + order_id, + infantry_before, + step_before, + timeout, + poll, + ) +} + fn owner_snapshot(conn: &DbConnection) -> HashMap { conn.db .cell_state() @@ -5065,19 +5359,31 @@ fn owner_snapshot(conn: &DbConnection) -> HashMap { } fn assert_order_conservation(order: &TransferOrder) -> Result<()> { - let accounted = order - .in_transit_infantry - .checked_add(order.delivered_infantry) - .and_then(|value| value.checked_add(order.casualty_infantry)) - .context("transfer accounting overflow")?; - ensure!( - order.committed_infantry == accounted, - "order {} violates conservation: committed={}, in_transit={}, delivered={}, casualties={}", - order.order_id, + assert_order_conservation_counters( order.committed_infantry, order.in_transit_infantry, order.delivered_infantry, - order.casualty_infantry + order.casualty_infantry, + order.updated_step, + order.order_id, + ) +} + +fn assert_order_conservation_counters( + committed: u64, + in_transit: u64, + delivered: u64, + casualties: u64, + logical_step: u64, + order_id: u64, +) -> Result<()> { + let accounted = in_transit + .checked_add(delivered) + .and_then(|value| value.checked_add(casualties)) + .context("transfer accounting overflow")?; + ensure!( + committed == accounted, + "order {order_id} violates conservation at step {logical_step}: committed={committed}, in_transit={in_transit}, delivered={delivered}, casualties={casualties}" ); Ok(()) } diff --git a/tools/match-playtest/Cargo.toml b/tools/match-playtest/Cargo.toml new file mode 100644 index 0000000..40ef243 --- /dev/null +++ b/tools/match-playtest/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "match-playtest" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Automated no-human live playtest for the cluster-first control surface" + +[dependencies] +anyhow.workspace = true +clap.workspace = true +hex-core.workspace = true +match-bindings = { path = "../../crates/match-bindings" } +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +spacetimedb-sdk.workspace = true + +# Scaffold is owned by the playtest agent. Keep workspace `clippy -D warnings` +# green without rewriting their in-progress surface; pedantic cleanup stays +# with that agent. Mirror workspace rust lints explicitly so we can relax +# clippy pedantic here (Cargo forbids mixing `lints.workspace = true` with +# overrides). +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = "warn" +pedantic = "allow" \ No newline at end of file diff --git a/tools/match-playtest/src/client.rs b/tools/match-playtest/src/client.rs new file mode 100644 index 0000000..43512cb --- /dev/null +++ b/tools/match-playtest/src/client.rs @@ -0,0 +1,463 @@ +//! Live-server connection plumbing shared by the playtest scenarios. +//! +//! This mirrors the proven `tools/match-e2e` client harness: one threaded +//! SDK connection per participant, lifecycle events over a channel, and +//! persistent anonymous identity tokens below an ignored directory. + +use std::fs::{self, OpenOptions}; +use std::io::{ErrorKind, Write}; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail, ensure}; +use match_bindings::{ + CommandReceiptTableAccess, DbConnection, MapPreset, cancel_orders as _, configure_match as _, + issue_attack_clusters as _, issue_expand_clusters as _, issue_front_rebalance as _, + issue_reshape as _, join_match as _, set_mobilization_target as _, start_match as _, +}; +use spacetimedb_sdk::{DbContext, Identity}; + +pub const fn receipt_key(player_id: u16, command_id: u64) -> u128 { + (player_id as u128) << 64 | command_id as u128 +} + +pub enum LifecycleEvent { + Connected { identity: Identity, token: String }, + Subscribed, + Failed(String), + Disconnected(Option), +} + +pub struct Client { + pub label: &'static str, + pub conn: DbConnection, + events: Receiver, + pump: Option>, + stopped: bool, +} + +impl Client { + pub fn connect( + label: &'static str, + token_path: &Path, + host: &str, + database: &str, + timeout: Duration, + ) -> Result { + let existing_token = read_token(token_path)?; + let (event_tx, event_rx) = mpsc::channel(); + let connected_tx = event_tx.clone(); + let connect_error_tx = event_tx.clone(); + let disconnect_tx = event_tx; + + let conn = DbConnection::builder() + .with_uri(host) + .with_database_name(database) + .with_token(existing_token) + .on_connect(move |ctx, identity, token| { + let _ = connected_tx.send(LifecycleEvent::Connected { + identity, + token: token.to_owned(), + }); + let applied_tx = connected_tx.clone(); + let subscription_error_tx = connected_tx.clone(); + ctx.subscription_builder() + .on_applied(move |_| { + let _ = applied_tx.send(LifecycleEvent::Subscribed); + }) + .on_error(move |_, error| { + let _ = subscription_error_tx.send(LifecycleEvent::Failed(format!( + "subscription failed: {error}" + ))); + }) + .subscribe_to_all_tables(); + }) + .on_connect_error(move |_, error| { + let _ = connect_error_tx.send(LifecycleEvent::Failed(format!( + "connection establishment failed: {error}" + ))); + }) + .on_disconnect(move |_, error| { + let _ = disconnect_tx.send(LifecycleEvent::Disconnected( + error.map(|value| value.to_string()), + )); + }) + .build() + .with_context(|| format!("build {label} connection to {host}/{database}"))?; + let pump = conn.run_threaded(); + + let deadline = Instant::now() + timeout; + let mut connected = None; + let mut subscribed = false; + while connected.is_none() || !subscribed { + match receive_before( + &event_rx, + deadline, + &format!("{label} connection readiness"), + )? { + LifecycleEvent::Connected { identity, token } => { + connected = Some((identity, token)); + } + LifecycleEvent::Subscribed => subscribed = true, + LifecycleEvent::Failed(message) => bail!("{label}: {message}"), + LifecycleEvent::Disconnected(error) => { + bail!( + "{label} disconnected before its subscription was ready: {}", + error.as_deref().unwrap_or("no server error") + ); + } + } + } + + let (_identity, token) = connected.context("connection callback omitted identity")?; + write_token(token_path, &token).with_context(|| { + format!("persist {label} identity token at {}", token_path.display()) + })?; + Ok(Self { + label, + conn, + events: event_rx, + pump: Some(pump), + stopped: false, + }) + } + + pub fn configure_match( + &self, + preset: MapPreset, + player_count: u16, + timeout: Duration, + ) -> Result<()> { + let (tx, rx) = mpsc::channel(); + self.conn + .reducers + .configure_match_then(preset, player_count, move |_, result| { + let _ = tx.send(flatten_reducer_result(result)); + }) + .with_context(|| format!("send configure_match for {}", self.label))?; + wait_for_reducer(&rx, timeout, &format!("{} configure_match", self.label)) + } + + pub fn join_match(&self, player_id: u16, display_name: &str, timeout: Duration) -> Result<()> { + let (tx, rx) = mpsc::channel(); + self.conn + .reducers + .join_match_then(player_id, display_name.to_owned(), move |_, result| { + let _ = tx.send(flatten_reducer_result(result)); + }) + .with_context(|| format!("send join_match for {}", self.label))?; + wait_for_reducer(&rx, timeout, &format!("{} join_match", self.label)) + } + + pub fn start_match(&self, timeout: Duration) -> Result<()> { + let (tx, rx) = mpsc::channel(); + self.conn + .reducers + .start_match_then(move |_, result| { + let _ = tx.send(flatten_reducer_result(result)); + }) + .with_context(|| format!("send start_match for {}", self.label))?; + wait_for_reducer(&rx, timeout, &format!("{} start_match", self.label)) + } + + pub fn set_mobilization_target( + &self, + command_id: u64, + target_bps: u32, + timeout: Duration, + ) -> Result<()> { + let (tx, rx) = mpsc::channel(); + self.conn + .reducers + .set_mobilization_target_then(command_id, target_bps, move |_, result| { + let _ = tx.send(flatten_reducer_result(result)); + }) + .context("send set_mobilization_target")?; + wait_for_reducer(&rx, timeout, "set_mobilization_target") + } + + pub fn issue_expand_clusters( + &self, + command_id: u64, + source_seed_cells: &[u32], + focus_cell_id: u32, + commitment_bps: u32, + timeout: Duration, + ) -> Result<()> { + let (tx, rx) = mpsc::channel(); + self.conn + .reducers + .issue_expand_clusters_then( + command_id, + source_seed_cells.to_vec(), + focus_cell_id, + commitment_bps, + move |_, result| { + let _ = tx.send(flatten_reducer_result(result)); + }, + ) + .context("send issue_expand_clusters")?; + wait_for_reducer(&rx, timeout, "issue_expand_clusters") + } + + pub fn issue_attack_clusters( + &self, + command_id: u64, + source_seed_cells: &[u32], + target_seed_cells: &[u32], + commitment_bps: u32, + timeout: Duration, + ) -> Result<()> { + let (tx, rx) = mpsc::channel(); + self.conn + .reducers + .issue_attack_clusters_then( + command_id, + source_seed_cells.to_vec(), + target_seed_cells.to_vec(), + commitment_bps, + move |_, result| { + let _ = tx.send(flatten_reducer_result(result)); + }, + ) + .context("send issue_attack_clusters")?; + wait_for_reducer(&rx, timeout, "issue_attack_clusters") + } + + pub fn issue_reshape( + &self, + command_id: u64, + source_cells: &[u32], + target_cells: &[u32], + supersede_order_ids: &[u64], + timeout: Duration, + ) -> Result<()> { + let (tx, rx) = mpsc::channel(); + self.conn + .reducers + .issue_reshape_then( + command_id, + source_cells.to_vec(), + target_cells.to_vec(), + supersede_order_ids.to_vec(), + move |_, result| { + let _ = tx.send(flatten_reducer_result(result)); + }, + ) + .context("send issue_reshape")?; + wait_for_reducer(&rx, timeout, "issue_reshape") + } + + pub fn issue_front_rebalance( + &self, + command_id: u64, + source_component_cells: &[u32], + source_front_seed: u32, + target_front_seed: u32, + commitment_bps: u32, + timeout: Duration, + ) -> Result<()> { + let (tx, rx) = mpsc::channel(); + self.conn + .reducers + .issue_front_rebalance_then( + command_id, + source_component_cells.to_vec(), + source_front_seed, + target_front_seed, + commitment_bps, + Vec::new(), + move |_, result| { + let _ = tx.send(flatten_reducer_result(result)); + }, + ) + .context("send issue_front_rebalance")?; + wait_for_reducer(&rx, timeout, "issue_front_rebalance") + } + + pub fn cancel_orders( + &self, + command_id: u64, + selected_order_ids: &[u64], + timeout: Duration, + ) -> Result<()> { + let (tx, rx) = mpsc::channel(); + self.conn + .reducers + .cancel_orders_then(command_id, selected_order_ids.to_vec(), move |_, result| { + let _ = tx.send(flatten_reducer_result(result)); + }) + .context("send cancel_orders")?; + wait_for_reducer(&rx, timeout, "cancel_orders") + } + + pub fn disconnect(&mut self, timeout: Duration) -> Result<()> { + if self.stopped { + return Ok(()); + } + self.conn + .disconnect() + .with_context(|| format!("request {} disconnect", self.label))?; + let deadline = Instant::now() + timeout; + loop { + match receive_before( + &self.events, + deadline, + &format!("{} disconnect callback", self.label), + )? { + LifecycleEvent::Disconnected(error) => { + if let Some(error) = error { + bail!("{} disconnected with an error: {error}", self.label); + } + break; + } + LifecycleEvent::Failed(message) => bail!("{}: {message}", self.label), + LifecycleEvent::Connected { .. } | LifecycleEvent::Subscribed => {} + } + } + self.stopped = true; + self.finish_pump(timeout); + Ok(()) + } + + fn finish_pump(&mut self, timeout: Duration) { + let Some(pump) = self.pump.take() else { + return; + }; + let deadline = Instant::now() + timeout; + while !pump.is_finished() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(5)); + } + if pump.is_finished() { + let _ = pump.join(); + } + } +} + +impl Drop for Client { + fn drop(&mut self) { + if !self.stopped { + let _ = self.conn.disconnect(); + } + } +} + +fn flatten_reducer_result( + result: std::result::Result, E>, +) -> std::result::Result<(), String> { + match result { + Ok(Ok(())) => Ok(()), + Ok(Err(message)) => Err(message), + Err(error) => Err(format!("SDK reducer callback failed: {error:?}")), + } +} + +fn wait_for_reducer( + receiver: &Receiver>, + timeout: Duration, + label: &str, +) -> Result<()> { + match receiver.recv_timeout(timeout) { + Ok(Ok(())) => Ok(()), + Ok(Err(message)) => bail!("{label} was rejected by the reducer: {message}"), + Err(RecvTimeoutError::Timeout) => bail!("timed out after {timeout:?} waiting for {label}"), + Err(RecvTimeoutError::Disconnected) => { + bail!("callback channel closed while waiting for {label}") + } + } +} + +fn receive_before( + receiver: &Receiver, + deadline: Instant, + label: &str, +) -> Result { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + bail!("timed out waiting for {label}"); + } + match receiver.recv_timeout(remaining) { + Ok(event) => Ok(event), + Err(RecvTimeoutError::Timeout) => bail!("timed out waiting for {label}"), + Err(RecvTimeoutError::Disconnected) => { + bail!("lifecycle channel closed while waiting for {label}") + } + } +} + +pub fn wait_until( + label: &str, + timeout: Duration, + poll: Duration, + mut inspect: impl FnMut() -> Result>, +) -> Result { + let deadline = Instant::now() + timeout; + loop { + if let Some(value) = inspect().with_context(|| format!("while waiting for {label}"))? { + return Ok(value); + } + if Instant::now() >= deadline { + bail!("timed out after {timeout:?} waiting for {label}"); + } + thread::sleep(poll.min(deadline.saturating_duration_since(Instant::now()))); + } +} + +pub fn unused_command_id(client: &Client, player_id: u16, start: u64) -> Result { + let mut candidate = start; + loop { + let key = receipt_key(player_id, candidate); + if client + .conn + .db + .command_receipt() + .receipt_key() + .find(&key) + .is_none() + { + return Ok(candidate); + } + candidate = candidate + .checked_add(1) + .context("exhausted client command ID range")?; + } +} + +fn read_token(path: &Path) -> Result> { + match fs::read_to_string(path) { + Ok(contents) => { + let token = contents.trim(); + ensure!(!token.is_empty(), "token file {} is empty", path.display()); + Ok(Some(token.to_owned())) + } + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("read token file {}", path.display())), + } +} + +fn write_token(path: &Path, token: &str) -> Result<()> { + ensure!( + !token.trim().is_empty(), + "server returned an empty identity token" + ); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create token directory {}", parent.display()))?; + } + let mut options = OpenOptions::new(); + options.create(true).truncate(true).write(true); + #[cfg(unix)] + options.mode(0o600); + let mut file = options + .open(path) + .with_context(|| format!("open token file {}", path.display()))?; + file.write_all(token.as_bytes()) + .with_context(|| format!("write token file {}", path.display()))?; + file.write_all(b"\n") + .with_context(|| format!("finish token file {}", path.display()))?; + Ok(()) +} diff --git a/tools/match-playtest/src/main.rs b/tools/match-playtest/src/main.rs new file mode 100644 index 0000000..26a1599 --- /dev/null +++ b/tools/match-playtest/src/main.rs @@ -0,0 +1,327 @@ +//! Fully automated no-human live playtest for the cluster-first control +//! surface (docs/playtests/cluster-controls-v1.md), run against a real local +//! SpacetimeDB match through the public reducer/table surface only. +//! +//! One-command entrypoint: `./scripts/run-automated-playtest.sh` +//! (publishes a fresh isolated database, runs this binary, and leaves the +//! artifacts + results document behind). Direct use: +//! +//! ```text +//! cargo run -p match-playtest -- --database of-match-e2e-auto +//! ``` + +mod client; +mod monitor; +mod report; +mod scenarios; +mod world; + +use std::path::PathBuf; +use std::process::ExitCode; +use std::time::Duration; + +use anyhow::Result; +use clap::Parser; +use match_bindings::{MapPreset, MatchPhase, MatchStateTableAccess}; + +use client::Client; +use monitor::{Mode, Monitor}; +use report::{RunReport, ScenarioResult, Verdict, unix_ms, utc_date_label}; +use scenarios::{PLAYER_ONE, PLAYER_TWO, Session}; +use world::SINGLETON_ID; + +#[derive(Debug, Parser)] +#[command(about = "Automated no-human live playtest for the cluster control surface")] +struct Args { + /// `SpacetimeDB` host URI. + #[arg(long, default_value = "http://127.0.0.1:3000")] + host: String, + + /// Freshly published isolated database (never the dev database). + #[arg(long, default_value = "of-match-e2e-auto")] + database: String, + + /// Directory for the ignored identity token profiles. + #[arg(long, default_value = ".match-playtest-tokens")] + token_dir: PathBuf, + + /// Per-operation timeout. + #[arg(long, default_value_t = 30)] + timeout_secs: u64, + + /// Wall-clock budget for the expansion-to-contact staging phase. + #[arg(long, default_value_t = 240)] + contact_budget_secs: u64, + + /// Structured JSON artifact path (gitignored). + #[arg(long)] + artifact: Option, + + /// Machine-generated results markdown path. + #[arg(long)] + results_doc: Option, +} + +fn main() -> ExitCode { + match run() { + Ok(passed) => { + if passed { + ExitCode::SUCCESS + } else { + ExitCode::from(2) + } + } + Err(error) => { + eprintln!("FATAL: {error:#}"); + ExitCode::FAILURE + } + } +} + +#[allow(clippy::too_many_lines)] +fn run() -> Result { + let args = Args::parse(); + let timeout = Duration::from_secs(args.timeout_secs); + let date = utc_date_label(); + let started_unix_ms = unix_ms(); + + println!( + "[1/8] connecting observer and two players to {}/{}", + args.host, args.database + ); + let observer = Client::connect( + "observer", + &args.token_dir.join("observer.token"), + &args.host, + &args.database, + timeout, + )?; + let p1 = Client::connect( + "player-one", + &args.token_dir.join("player-1.token"), + &args.host, + &args.database, + timeout, + )?; + let p2 = Client::connect( + "player-two", + &args.token_dir.join("player-2.token"), + &args.host, + &args.database, + timeout, + )?; + + println!("[2/8] configuring, joining, and starting a fresh two-player match"); + let phase = p1 + .conn + .db + .match_state() + .singleton_id() + .find(&SINGLETON_ID) + .map(|state| state.phase); + anyhow::ensure!( + phase == Some(MatchPhase::Lobby), + "expected a freshly published database in Lobby phase, found {phase:?}; \ + publish with --delete-data=always first" + ); + p1.configure_match(MapPreset::Dev64, 2, timeout)?; + p1.join_match(PLAYER_ONE, "auto-p1", timeout)?; + p2.join_match(PLAYER_TWO, "auto-p2", timeout)?; + p1.start_match(timeout)?; + + let step_ms = { + let snapshot = world::WorldSnapshot::capture(&p1.conn)?; + snapshot.config.logical_step_ms + }; + let step = Duration::from_millis(u64::from(step_ms.max(1))); + let poll = (step / 4).max(Duration::from_millis(20)); + + // Both players fight with their spawn armies only until a scenario needs + // growth: exact conservation is the default accounting regime. + let monitor = Monitor::start(observer, poll); + let session = Session { + p1: &p1, + p2: &p2, + monitor: &monitor, + step, + poll, + timeout, + }; + client::wait_until("match running", timeout, poll, || { + let state = p1.conn.db.match_state().singleton_id().find(&SINGLETON_ID); + Ok(state.and_then(|row| (row.phase == MatchPhase::Running).then_some(()))) + })?; + for player in [PLAYER_ONE, PLAYER_TWO] { + let command_id = session.command_id(player)?; + session + .client(player) + .set_mobilization_target(command_id, 0, timeout)?; + session.accepted_receipt(player, command_id)?; + } + let (map_preset, map_seed, _) = scenarios::map_summary(&session)?; + println!( + " map {map_preset} seed {map_seed:#x}, logical step {step_ms} ms; monitor sampling every {poll:?}" + ); + + let mut results: Vec = Vec::new(); + println!("[3/8] strict idle conservation baseline"); + scenarios::strict_idle_window(&session, 10)?; + + println!("[4/8] S1 focus-as-destination + S4 share-once (pre-contact, spawn armies)"); + results.push(run_scenario("S1", scenarios::s1_focus_weighting(&session))); + session.quiesce()?; + results.push(run_scenario("S4", scenarios::s4_share_once(&session))); + session.quiesce()?; + + println!( + "[5/8] expanding both players toward each other (mobilization on, budget {}s)", + args.contact_budget_secs + ); + let contact = + scenarios::establish_contact(&session, Duration::from_secs(args.contact_budget_secs))?; + println!(" hostile contact established: {contact}"); + + println!("[6/8] S5 reshape + S3 front rebalance + S6 exact stop (strict windows)"); + results.push(run_scenario("S5", scenarios::s5_reshape(&session))); + session.quiesce()?; + scenarios::strict_idle_window(&session, 4)?; + results.push(run_scenario("S3", scenarios::s3_front_rebalance(&session))); + session.quiesce()?; + results.push(run_scenario("S6", scenarios::s6_exact_stop(&session))); + session.quiesce()?; + + println!("[7/8] S2 attack mask (combat window)"); + results.push(run_scenario("S2", scenarios::s2_attack_mask(&session))); + session.quiesce()?; + scenarios::strict_idle_window(&session, 8)?; + + println!("[8/8] finishing instrumentation and writing artifacts"); + monitor.set_mode(Mode::Strict); + let (monitor_report, mut observer) = monitor.finish(); + let violation_count = monitor_report.violations.len(); + + results.sort_by_key(|scenario| scenario.risk.clone()); + let all_pass = results + .iter() + .all(|scenario| scenario.verdict != Verdict::Fail) + && violation_count == 0; + let git_sha = git_sha(); + let finished_unix_ms = unix_ms(); + let run_report = RunReport { + kind: "cluster-controls-v1-automated", + host: args.host.clone(), + database: args.database.clone(), + map_preset, + map_seed, + git_sha, + started_unix_ms, + finished_unix_ms, + logical_step_ms: step_ms, + scenarios: results, + monitor: monitor_report, + passed: all_pass, + }; + + let artifact = args.artifact.unwrap_or_else(|| { + PathBuf::from(format!( + "artifacts/playtests/cluster-controls-v1-automated-{date}.json" + )) + }); + report::write_json(&artifact, &run_report)?; + let doc = args.results_doc.unwrap_or_else(|| { + PathBuf::from(format!( + "docs/playtests/cluster-controls-v1-automated-{date}.md" + )) + }); + report::write_markdown(&doc, &run_report, &date)?; + + println!(); + for scenario in &run_report.scenarios { + println!( + " {}: {} — {}", + scenario.risk, + scenario.verdict.label(), + scenario.title + ); + } + println!( + " invariants: {} violations across {} samples (steps {}..{})", + violation_count, + run_report.monitor.samples, + run_report.monitor.first_step, + run_report.monitor.last_step + ); + println!(" artifact: {}", artifact.display()); + println!(" results doc: {}", doc.display()); + println!( + "{}", + if all_pass { + "PASS: all automated behavioral checks and global invariants held" + } else { + "FAIL: at least one behavioral check or invariant failed (see results doc)" + } + ); + + let _ = observer.disconnect(timeout); + let mut p1 = p1; + let mut p2 = p2; + let _ = p1.disconnect(timeout); + let _ = p2.disconnect(timeout); + Ok(all_pass) +} + +fn run_scenario(label: &str, outcome: Result) -> ScenarioResult { + match outcome { + Ok(result) => { + println!(" {label}: {}", result.verdict.label()); + result + } + Err(error) => { + println!(" {label}: FAIL ({error:#})"); + let titles = [ + ( + "S1", + "Focus-as-destination: 11/10/9-weighted branches, none suppressed", + ), + ( + "S2", + "Attack mask: captures never leave the accepted target footprint; fronts stay on it", + ), + ( + "S3", + "Front rebalance: Share-once snapshot, physical traversal, conservation", + ), + ( + "S4", + "Whole-cluster multi-select: Share once per source, then share-of-remainder", + ), + ( + "S5", + "Reshape: undersized footprint saturates + conserves overflow; oversized drains", + ), + ( + "S6", + "Exact Stop: only the frozen order set is released, at current physical cells", + ), + ]; + let title = titles + .iter() + .find(|(risk, _)| *risk == label) + .map(|(_, title)| *title) + .unwrap_or(label); + let mut result = ScenarioResult::new(label, title); + result.fail(format!("scenario aborted: {error:#}")); + result + } + } +} + +fn git_sha() -> String { + std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned()) + .unwrap_or_else(|| "unknown".to_owned()) +} diff --git a/tools/match-playtest/src/monitor.rs b/tools/match-playtest/src/monitor.rs new file mode 100644 index 0000000..d762c7a --- /dev/null +++ b/tools/match-playtest/src/monitor.rs @@ -0,0 +1,560 @@ +//! Continuous global-invariant instrumentation for one live session. +//! +//! A dedicated observer connection samples the public tables a few times per +//! logical step and checks: +//! +//! - total strength conservation between stable snapshots (exact while no +//! combat or mobilization is expected, casualty-accounted during combat, +//! growth-accounted while mobilization is enabled); +//! - per-order conservation (`committed == in_transit + delivered + casualties`); +//! - no cell above its military capacity; +//! - `PlayerState::controlled_cells` consistency with actual cell ownership; +//! - tick liveness (`logical_step` advancing); +//! - physical packet traversal (routed packets stay on their persisted route +//! and only move forward along it — no teleporting). +//! +//! SpacetimeDB SDK row callbacks from one transaction can reach the client +//! cache across turns, so cross-table checks only fire after the same +//! violation persists over several samples spanning multiple logical steps, +//! and conservation deltas are only evaluated between "stable" snapshots +//! (two consecutive identical reads). + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use match_bindings::{ + CellStateTableAccess, CombatFrontTableAccess, MatchStateTableAccess, OrderStatus, + PlayerStateTableAccess, TransferOrderTableAccess, TransitPacketTableAccess, + TransitRouteTableAccess, +}; +use serde::Serialize; +use spacetimedb_sdk::Table; + +use crate::client::Client; +use crate::world::SINGLETON_ID; + +/// Expected accounting regime for the current scenario phase. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +pub enum Mode { + /// No combat, no mobilization: total infantry must be exactly constant. + Strict, + /// Enemy combat expected: decreases must be covered by casualties. + Combat, + /// Mobilization enabled: population converts into infantry locally, so + /// only monotonicity of (civilians + infantry) is enforced. + Mobilization, +} + +impl Mode { + const fn as_u8(self) -> u8 { + match self { + Self::Strict => 0, + Self::Combat => 1, + Self::Mobilization => 2, + } + } + + const fn from_u8(value: u8) -> Self { + match value { + 1 => Self::Combat, + 2 => Self::Mobilization, + _ => Self::Strict, + } + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct Violation { + pub rule: String, + pub detail: String, + pub logical_step: u64, + pub mode: Mode, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct WindowSummary { + pub mode: String, + pub first_step: u64, + pub last_step: u64, + pub start_infantry: u64, + pub end_infantry: u64, + pub attacker_casualty_delta: u64, + /// Additional strength decrease attributed to defender-side losses. + pub defender_loss_residual: u64, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct MonitorReport { + pub samples: u64, + pub stable_checkpoints: u64, + pub first_step: u64, + pub last_step: u64, + pub max_cell_fill_ratio_bps: u64, + pub tracked_packet_transitions: u64, + pub violations: Vec, + pub windows: Vec, +} + +#[derive(Clone, PartialEq)] +struct Snapshot { + logical_step: u64, + total_infantry: u64, + total_population: u64, + attacker_casualties: u64, +} + +struct PendingViolation { + consecutive: u32, + first_step: u64, + last_step: u64, + detail: String, +} + +struct MonitorState { + report: MonitorReport, + previous_stable: Option, + window: Option, + pending: HashMap, + packet_tracks: HashMap, + last_step_change: Instant, + last_seen_step: u64, +} + +pub struct Monitor { + stop: Arc, + mode: Arc, + window_epoch: Arc, + state: Arc>, + thread: Option>, +} + +/// How many consecutive samples (spanning at least two logical steps) a +/// cross-table inconsistency must persist before it is a real violation. +const PERSISTENCE_SAMPLES: u32 = 4; +const LIVENESS_BUDGET: Duration = Duration::from_secs(10); + +impl Monitor { + pub fn start(observer: Client, poll: Duration) -> Self { + let stop = Arc::new(AtomicBool::new(false)); + let mode = Arc::new(AtomicU8::new(Mode::Strict.as_u8())); + let window_epoch = Arc::new(AtomicU8::new(0)); + let state = Arc::new(Mutex::new(MonitorState { + report: MonitorReport::default(), + previous_stable: None, + window: None, + pending: HashMap::new(), + packet_tracks: HashMap::new(), + last_step_change: Instant::now(), + last_seen_step: 0, + })); + let thread = { + let stop = Arc::clone(&stop); + let mode = Arc::clone(&mode); + let window_epoch = Arc::clone(&window_epoch); + let state = Arc::clone(&state); + thread::spawn(move || { + let mut seen_epoch = u8::MAX; + while !stop.load(Ordering::Relaxed) { + let current_mode = Mode::from_u8(mode.load(Ordering::Relaxed)); + let epoch = window_epoch.load(Ordering::Relaxed); + if epoch != seen_epoch { + seen_epoch = epoch; + let mut guard = state.lock().expect("monitor state poisoned"); + let finished = guard.window.take(); + if let Some(window) = finished { + guard.report.windows.push(window); + } + guard.previous_stable = None; + } + sample(&observer, current_mode, &state); + thread::sleep(poll); + } + let mut guard = state.lock().expect("monitor state poisoned"); + let finished = guard.window.take(); + if let Some(window) = finished { + guard.report.windows.push(window); + } + drop(guard); + observer + }) + }; + Self { + stop, + mode, + window_epoch, + state, + thread: Some(thread), + } + } + + /// Switches the conservation regime and closes the current window. + pub fn set_mode(&self, mode: Mode) { + self.mode.store(mode.as_u8(), Ordering::Relaxed); + self.window_epoch.fetch_add(1, Ordering::Relaxed); + } + + pub fn finish(mut self) -> (MonitorReport, Client) { + self.stop.store(true, Ordering::Relaxed); + let thread = self.thread.take().expect("monitor already finished"); + let observer = thread.join().expect("monitor thread panicked"); + let report = self + .state + .lock() + .expect("monitor state poisoned") + .report + .clone(); + (report, observer) + } +} + +impl Drop for Monitor { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn read_snapshot(observer: &Client) -> Option { + let state = observer + .conn + .db + .match_state() + .singleton_id() + .find(&SINGLETON_ID)?; + let mut total_infantry = 0_u64; + let mut total_population = 0_u64; + for cell in observer.conn.db.cell_state().iter() { + total_infantry = total_infantry.saturating_add(cell.infantry); + total_population = total_population + .saturating_add(cell.infantry) + .saturating_add(cell.civilians); + } + let attacker_casualties = observer + .conn + .db + .transfer_order() + .iter() + .map(|order| order.casualty_infantry) + .fold(0_u64, u64::saturating_add); + Some(Snapshot { + logical_step: state.logical_step, + total_infantry, + total_population, + attacker_casualties, + }) +} + +#[allow(clippy::too_many_lines)] +fn sample(observer: &Client, mode: Mode, state: &Arc>) { + let Some(first) = read_snapshot(observer) else { + return; + }; + let second = read_snapshot(observer); + let stable = second.as_ref() == Some(&first); + let logical_step = first.logical_step; + + let mut immediate_violations: Vec<(String, String)> = Vec::new(); + let mut persistent_candidates: Vec<(String, String)> = Vec::new(); + + // Per-order conservation. Row reads are atomic, but an order and its + // packets may land in different turns; the counter identity below only + // involves one row, so a persistent breach is a real accounting bug. + for order in observer.conn.db.transfer_order().iter() { + let accounted = order + .in_transit_infantry + .checked_add(order.delivered_infantry) + .and_then(|value| value.checked_add(order.casualty_infantry)); + if accounted != Some(order.committed_infantry) { + persistent_candidates.push(( + format!("order-conservation:{}", order.order_id), + format!( + "order {} kind {:?} status {:?}: committed={} in_transit={} delivered={} casualties={}", + order.order_id, + order.kind, + order.status, + order.committed_infantry, + order.in_transit_infantry, + order.delivered_infantry, + order.casualty_infantry + ), + )); + } + if order.status != OrderStatus::Active && order.in_transit_infantry != 0 { + persistent_candidates.push(( + format!("settled-order-transit:{}", order.order_id), + format!( + "non-active order {} still reports {} in transit", + order.order_id, order.in_transit_infantry + ), + )); + } + } + + // Capacity and ownership tallies. + let mut owned_counts: HashMap = HashMap::new(); + let mut max_fill_bps = 0_u64; + for cell in observer.conn.db.cell_state().iter() { + if cell.owner_player_id != 0 { + *owned_counts.entry(cell.owner_player_id).or_insert(0) += 1; + } + if let Some(fill) = cell + .infantry + .saturating_mul(10_000) + .checked_div(cell.military_capacity) + { + max_fill_bps = max_fill_bps.max(fill); + } + if cell.infantry > cell.military_capacity { + persistent_candidates.push(( + format!("cell-over-capacity:{}", cell.cell_id), + format!( + "cell {} owner {} infantry {} exceeds military capacity {}", + cell.cell_id, cell.owner_player_id, cell.infantry, cell.military_capacity + ), + )); + } + } + for player in observer.conn.db.player_state().iter() { + let actual = owned_counts.get(&player.player_id).copied().unwrap_or(0); + if player.controlled_cells != actual { + persistent_candidates.push(( + format!("controlled-cells:{}", player.player_id), + format!( + "player {} reports {} controlled cells but owns {}", + player.player_id, player.controlled_cells, actual + ), + )); + } + } + + // Physical traversal: routed packets must sit on their persisted route and + // only move forward along it. Packet keys are auto-increment and never + // reused, so a shrinking route_index is a teleport/rewind. + let routes: HashMap> = observer + .conn + .db + .transit_route() + .iter() + .map(|route| (route.route_id, route.cells)) + .collect(); + let mut transitions = 0_u64; + { + let mut guard = state.lock().expect("monitor state poisoned"); + for packet in observer.conn.db.transit_packet().iter() { + if packet.route_id == 0 { + continue; + } + let Some(cells) = routes.get(&packet.route_id) else { + // Route row may arrive in a later turn than the packet row. + continue; + }; + let index = packet.route_index as usize; + if cells.get(index) != Some(&packet.current_cell) { + immediate_violations.push(( + "packet-off-route".to_owned(), + format!( + "packet {} of order {} sits on cell {} but route {} index {} is {:?}", + packet.packet_key, + packet.order_id, + packet.current_cell, + packet.route_id, + packet.route_index, + cells.get(index) + ), + )); + } + if let Some(&(previous_route, previous_index, previous_cell)) = + guard.packet_tracks.get(&packet.packet_key) + { + if previous_route == packet.route_id && packet.route_index < previous_index { + immediate_violations.push(( + "packet-rewind".to_owned(), + format!( + "packet {} rewound from route index {} (cell {}) to {} (cell {})", + packet.packet_key, + previous_index, + previous_cell, + packet.route_index, + packet.current_cell + ), + )); + } + if previous_route == packet.route_id && packet.route_index != previous_index { + transitions += 1; + } + } + guard.packet_tracks.insert( + packet.packet_key, + (packet.route_id, packet.route_index, packet.current_cell), + ); + } + guard.report.tracked_packet_transitions += transitions; + guard.report.max_cell_fill_ratio_bps = + guard.report.max_cell_fill_ratio_bps.max(max_fill_bps); + } + + // Combat-front casualty context for the strict/combat distinction. + let front_casualties_this_step: u64 = observer + .conn + .db + .combat_front() + .iter() + .filter(|front| front.logical_step == logical_step) + .map(|front| front.attacker_casualties + front.defender_casualties) + .sum(); + + let mut guard = state.lock().expect("monitor state poisoned"); + guard.report.samples += 1; + if guard.report.first_step == 0 { + guard.report.first_step = logical_step; + } + guard.report.last_step = guard.report.last_step.max(logical_step); + + // Liveness. + if logical_step != guard.last_seen_step { + guard.last_seen_step = logical_step; + guard.last_step_change = Instant::now(); + } else if guard.last_step_change.elapsed() > LIVENESS_BUDGET { + guard.last_step_change = Instant::now(); + let violation = Violation { + rule: "tick-liveness".to_owned(), + detail: format!( + "logical_step stalled at {logical_step} for more than {LIVENESS_BUDGET:?}" + ), + logical_step, + mode, + }; + guard.report.violations.push(violation); + } + + for (rule, detail) in immediate_violations { + guard.report.violations.push(Violation { + rule, + detail, + logical_step, + mode, + }); + } + + // Persistence-filtered cross-table checks. + let mut still_pending: HashMap = HashMap::new(); + for (key, detail) in persistent_candidates { + let entry = guard.pending.remove(&key); + let mut pending = entry.unwrap_or(PendingViolation { + consecutive: 0, + first_step: logical_step, + last_step: logical_step, + detail: String::new(), + }); + pending.consecutive += 1; + pending.last_step = logical_step; + pending.detail = detail; + if pending.consecutive >= PERSISTENCE_SAMPLES && pending.last_step > pending.first_step { + let rule = key.split(':').next().unwrap_or(&key).to_owned(); + guard.report.violations.push(Violation { + rule, + detail: pending.detail.clone(), + logical_step, + mode, + }); + pending.consecutive = 0; + pending.first_step = logical_step; + } + still_pending.insert(key, pending); + } + guard.pending = still_pending; + + if !stable { + return; + } + guard.report.stable_checkpoints += 1; + + // Conservation between stable checkpoints of the current window. + if let Some(previous) = guard.previous_stable.clone() { + let increase = first.total_infantry.saturating_sub(previous.total_infantry); + let decrease = previous.total_infantry.saturating_sub(first.total_infantry); + let casualty_delta = first + .attacker_casualties + .saturating_sub(previous.attacker_casualties); + match mode { + Mode::Strict => { + if first.total_infantry != previous.total_infantry { + guard.report.violations.push(Violation { + rule: "strict-conservation".to_owned(), + detail: format!( + "total infantry moved from {} (step {}) to {} (step {}) with no combat or mobilization expected (order casualty delta {})", + previous.total_infantry, + previous.logical_step, + first.total_infantry, + logical_step, + casualty_delta + ), + logical_step, + mode, + }); + } + } + Mode::Combat => { + if increase > 0 { + guard.report.violations.push(Violation { + rule: "combat-conservation".to_owned(), + detail: format!( + "total infantry increased by {increase} during combat with mobilization disabled (steps {}..{})", + previous.logical_step, logical_step + ), + logical_step, + mode, + }); + } + if decrease < casualty_delta { + guard.report.violations.push(Violation { + rule: "combat-conservation".to_owned(), + detail: format!( + "orders recorded {casualty_delta} casualties but total infantry only dropped by {decrease} (steps {}..{})", + previous.logical_step, logical_step + ), + logical_step, + mode, + }); + } + } + Mode::Mobilization => { + if first.total_population < previous.total_population + && front_casualties_this_step == 0 + { + guard.report.violations.push(Violation { + rule: "mobilization-conservation".to_owned(), + detail: format!( + "total population dropped from {} to {} without combat while mobilizing (steps {}..{})", + previous.total_population, + first.total_population, + previous.logical_step, + logical_step + ), + logical_step, + mode, + }); + } + } + } + let window = guard.window.get_or_insert_with(|| WindowSummary { + mode: format!("{mode:?}"), + first_step: previous.logical_step, + last_step: logical_step, + start_infantry: previous.total_infantry, + end_infantry: first.total_infantry, + attacker_casualty_delta: 0, + defender_loss_residual: 0, + }); + window.last_step = logical_step; + window.end_infantry = first.total_infantry; + window.attacker_casualty_delta += casualty_delta; + window.defender_loss_residual += decrease.saturating_sub(casualty_delta); + } + guard.previous_stable = Some(first); +} diff --git a/tools/match-playtest/src/report.rs b/tools/match-playtest/src/report.rs new file mode 100644 index 0000000..5e1d60e --- /dev/null +++ b/tools/match-playtest/src/report.rs @@ -0,0 +1,259 @@ +//! Machine-generated run artifact and results document. + +use std::fs; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use serde::Serialize; + +use crate::monitor::MonitorReport; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +pub enum Verdict { + Pass, + Fail, + Partial, +} + +impl Verdict { + pub const fn label(self) -> &'static str { + match self { + Self::Pass => "PASS", + Self::Fail => "FAIL", + Self::Partial => "PARTIAL", + } + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct ScenarioResult { + pub risk: String, + pub title: String, + pub verdict: Verdict, + /// Concrete measured evidence lines (numbers, cell IDs, totals). + pub evidence: Vec, + /// Known limitations of this automated staging, if any. + pub limitations: Vec, +} + +impl ScenarioResult { + pub fn new(risk: &str, title: &str) -> Self { + Self { + risk: risk.to_owned(), + title: title.to_owned(), + verdict: Verdict::Pass, + evidence: Vec::new(), + limitations: Vec::new(), + } + } + + pub fn note(&mut self, line: impl Into) { + self.evidence.push(line.into()); + } + + pub fn fail(&mut self, line: impl Into) { + self.verdict = Verdict::Fail; + self.evidence.push(format!("FAILED: {}", line.into())); + } + + pub fn limit(&mut self, line: impl Into) { + if self.verdict == Verdict::Pass { + self.verdict = Verdict::Partial; + } + self.limitations.push(line.into()); + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct RunReport { + pub kind: &'static str, + pub host: String, + pub database: String, + pub map_preset: String, + pub map_seed: u64, + pub git_sha: String, + pub started_unix_ms: u128, + pub finished_unix_ms: u128, + pub logical_step_ms: u32, + pub scenarios: Vec, + pub monitor: MonitorReport, + pub passed: bool, +} + +pub fn unix_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0) +} + +/// UTC calendar date (`YYYY-MM-DD`) for artifact naming, derived without a +/// date-time dependency (civil-from-days, Howard Hinnant's algorithm). +pub fn utc_date_label() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + let days = i64::try_from(secs / 86_400).unwrap_or(0); + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = if month <= 2 { year + 1 } else { year }; + format!("{year:04}-{month:02}-{day:02}") +} + +pub fn write_json(path: &Path, report: &RunReport) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create artifact directory {}", parent.display()))?; + } + let payload = serde_json::to_vec_pretty(report).context("serialize run report")?; + fs::write(path, payload).with_context(|| format!("write run artifact {}", path.display()))?; + Ok(()) +} + +#[allow(clippy::too_many_lines)] +pub fn write_markdown(path: &Path, report: &RunReport, date: &str) -> Result<()> { + let mut doc = String::new(); + doc.push_str(&format!( + "# Cluster controls V1 automated playtest — {date}\n\n" + )); + doc.push_str( + "> Machine-generated by `cargo run -p match-playtest`. Every verdict below is\n\ + > **automated behavioral verification** against a real local SpacetimeDB match\n\ + > (assertions over the public reducer/table surface), **not** human-perception\n\ + > validation. Re-run the [manual checklist](./cluster-controls-v1.md) with\n\ + > participants for the perception rows.\n\n", + ); + doc.push_str("## Session\n\n"); + doc.push_str("- Method: automated live two-player session (headless SDK clients + one invariant observer)\n"); + doc.push_str(&format!("- Git SHA: `{}`\n", report.git_sha)); + doc.push_str(&format!( + "- Host / database: `{}` / `{}` (isolated; freshly published)\n", + report.host, report.database + )); + doc.push_str(&format!( + "- Map preset: `{}` (seed `{:#x}`), logical step {} ms\n", + report.map_preset, report.map_seed, report.logical_step_ms + )); + doc.push_str(&format!( + "- Wall time: {:.1} s; simulation steps observed: {}..{}\n", + (report + .finished_unix_ms + .saturating_sub(report.started_unix_ms)) as f64 + / 1000.0, + report.monitor.first_step, + report.monitor.last_step + )); + doc.push_str( + "- Raw artifact: `artifacts/playtests/` (gitignored JSON with all measurements)\n\n", + ); + + doc.push_str("## Results\n\n"); + doc.push_str("| Risk | Verdict | Automated behavioral evidence |\n"); + doc.push_str("| --- | --- | --- |\n"); + for scenario in &report.scenarios { + let mut cell = scenario.evidence.join("
"); + if !scenario.limitations.is_empty() { + cell.push_str("
Limitations: "); + cell.push_str(&scenario.limitations.join("; ")); + } + doc.push_str(&format!( + "| {} | **{}** | {} |\n", + scenario.title, + scenario.verdict.label(), + cell.replace('\n', " ") + )); + } + + doc.push_str("\n## Continuous global invariants\n\n"); + let monitor = &report.monitor; + doc.push_str(&format!( + "- Samples: {} ({} stable conservation checkpoints)\n", + monitor.samples, monitor.stable_checkpoints + )); + doc.push_str(&format!( + "- Tick liveness: logical_step advanced {} → {}\n", + monitor.first_step, monitor.last_step + )); + doc.push_str(&format!( + "- Physical traversal: {} forward route transitions observed, zero teleports/rewinds tolerated\n", + monitor.tracked_packet_transitions + )); + doc.push_str(&format!( + "- Peak cell fill ratio: {:.2}% of military capacity (no cell ever above 100%)\n", + monitor.max_cell_fill_ratio_bps as f64 / 100.0 + )); + for window in &monitor.windows { + doc.push_str(&format!( + "- {} window steps {}..{}: total infantry {} → {}, order-recorded (attacker) casualties {}, defender-side losses {}\n", + window.mode, + window.first_step, + window.last_step, + window.start_infantry, + window.end_infantry, + window.attacker_casualty_delta, + window.defender_loss_residual + )); + } + if monitor.violations.is_empty() { + doc.push_str("- Violations: **none**\n"); + } else { + doc.push_str(&format!( + "- Violations: **{}** (real failures caught by instrumentation)\n", + monitor.violations.len() + )); + for violation in &monitor.violations { + doc.push_str(&format!( + " - `{}` at step {} ({:?} window): {}\n", + violation.rule, violation.logical_step, violation.mode, violation.detail + )); + } + } + + doc.push_str("\n## Overall\n\n"); + let fail_count = report + .scenarios + .iter() + .filter(|scenario| scenario.verdict == Verdict::Fail) + .count(); + let partial_count = report + .scenarios + .iter() + .filter(|scenario| scenario.verdict == Verdict::Partial) + .count(); + if report.passed && fail_count == 0 && partial_count == 0 { + doc.push_str( + "All six documented control risks verified behaviorally against the live \ + authoritative module, with continuous conservation, capacity, ownership, \ + traversal, and liveness instrumentation reporting no violations.\n", + ); + } else if report.passed && fail_count == 0 { + doc.push_str(&format!( + "No automated behavioral check **FAILED** and the conservation monitor reported \ + no invariant violations. {partial_count} scenario(s) returned **PARTIAL** because \ + staging or sampling limits prevented a full proof on this map/run; see limitation \ + notes in the table above.\n", + )); + } else { + doc.push_str( + "At least one automated behavioral check FAILED or a global invariant was \ + violated; see the rows and violation list above. Failures are reported \ + faithfully and deserve investigation before the next milestone.\n", + ); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create results directory {}", parent.display()))?; + } + fs::write(path, doc).with_context(|| format!("write results document {}", path.display()))?; + Ok(()) +} diff --git a/tools/match-playtest/src/scenarios.rs b/tools/match-playtest/src/scenarios.rs new file mode 100644 index 0000000..4ac52cc --- /dev/null +++ b/tools/match-playtest/src/scenarios.rs @@ -0,0 +1,2059 @@ +//! The six automated cluster-control scenarios. +//! +//! Every verdict is derived from public reducer receipts and public table +//! rows on a live server. Expectations mirror the authoritative rules +//! (`modules/match/src/{orders,simulation,rules}.rs` and +//! `crates/hex-core/src/branching.rs`) but are computed independently from a +//! pre-command snapshot, so a divergence is a real behavioral finding. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, ensure}; +use hex_core::{focus_branch_weight, weighted_branch_quotas_rotated}; +use match_bindings::{ + CellStateTableAccess, CombatFrontTableAccess, CommandReceipt, CommandReceiptTableAccess, + MatchStateTableAccess, OrderStatus, ReceiptStatus, TerrainClass, TransferDestination, + TransferDestinationTableAccess, TransferOrder, TransferOrderTableAccess, TransferSource, + TransferSourceTableAccess, TransitPacket, TransitPacketTableAccess, +}; +use spacetimedb_sdk::Table; + +use crate::client::{Client, receipt_key, unused_command_id, wait_until}; +use crate::monitor::{Mode, Monitor}; +use crate::report::ScenarioResult; +use crate::world::{ + NEUTRAL_PLAYER, SINGLETON_ID, WorldSnapshot, basis_point_share, expected_shares, +}; + +pub const PLAYER_ONE: u16 = 1; +pub const PLAYER_TWO: u16 = 2; +const COMMAND_ID_FLOOR: u64 = 8_000_000_000; +const EXPANSION_AGGREGATE_ORIGIN: u32 = u32::MAX; + +pub struct Session<'a> { + pub p1: &'a Client, + pub p2: &'a Client, + pub monitor: &'a Monitor, + /// One authoritative logical step. + pub step: Duration, + pub poll: Duration, + pub timeout: Duration, +} + +impl Session<'_> { + pub fn client(&self, player: u16) -> &Client { + if player == PLAYER_ONE { + self.p1 + } else { + self.p2 + } + } + + pub fn command_id(&self, player: u16) -> Result { + unused_command_id(self.client(player), player, COMMAND_ID_FLOOR) + } + + pub fn logical_step(&self) -> Result { + Ok(self + .p1 + .conn + .db + .match_state() + .singleton_id() + .find(&SINGLETON_ID) + .context("match state is missing")? + .logical_step) + } + + pub fn wait_steps(&self, steps: u64) -> Result<()> { + let start = self.logical_step()?; + wait_until( + &format!("{steps} simulation step(s)"), + self.timeout.max(self.step * (steps as u32 + 8) * 2), + self.poll, + || Ok((self.logical_step()? >= start + steps).then_some(())), + ) + } + + /// Waits for the receipt row of a command without asserting acceptance. + pub fn fetch_receipt(&self, player: u16, command_id: u64) -> Result { + let client = self.client(player); + let key = receipt_key(player, command_id); + wait_until("command receipt", self.timeout, self.poll, || { + Ok(client.conn.db.command_receipt().receipt_key().find(&key)) + }) + } + + pub fn accepted_receipt(&self, player: u16, command_id: u64) -> Result { + let receipt = self.fetch_receipt(player, command_id)?; + ensure!( + receipt.status == ReceiptStatus::Accepted, + "{} was rejected: {}", + receipt.command_name, + receipt.message + ); + Ok(receipt) + } + + pub fn order(&self, player: u16, order_id: u64) -> Result { + self.client(player) + .conn + .db + .transfer_order() + .order_id() + .find(&order_id) + .with_context(|| format!("order {order_id} is missing from the cache")) + } + + pub fn sources_of(&self, player: u16, order_id: u64) -> Vec { + self.client(player) + .conn + .db + .transfer_source() + .iter() + .filter(|source| source.order_id == order_id) + .collect() + } + + pub fn destinations_of(&self, player: u16, order_id: u64) -> Vec { + self.client(player) + .conn + .db + .transfer_destination() + .iter() + .filter(|destination| destination.order_id == order_id) + .collect() + } + + pub fn packets_of(&self, player: u16, order_id: u64) -> Vec { + self.client(player) + .conn + .db + .transit_packet() + .iter() + .filter(|packet| packet.order_id == order_id) + .collect() + } + + pub fn wait_order_settled( + &self, + player: u16, + order_id: u64, + budget: Duration, + ) -> Result { + wait_until("order settlement", budget, self.poll, || { + let order = self.order(player, order_id)?; + Ok((order.status != OrderStatus::Active).then_some(order)) + }) + } + + pub fn active_order_ids(&self, player: u16) -> Vec { + self.client(player) + .conn + .db + .transfer_order() + .iter() + .filter(|order| order.player_id == player && order.status == OrderStatus::Active) + .map(|order| order.order_id) + .collect() + } + + /// Cancels every active order of both players and waits until no packets + /// remain, so a Strict conservation window can begin. + pub fn quiesce(&self) -> Result<()> { + for player in [PLAYER_ONE, PLAYER_TWO] { + let active = self.active_order_ids(player); + if !active.is_empty() { + let command_id = self.command_id(player)?; + self.client(player) + .cancel_orders(command_id, &active, self.timeout)?; + self.accepted_receipt(player, command_id)?; + } + } + wait_until("full quiescence", self.timeout, self.poll, || { + let no_active = [PLAYER_ONE, PLAYER_TWO] + .iter() + .all(|&player| self.active_order_ids(player).is_empty()); + let no_packets = self.p1.conn.db.transit_packet().count() == 0; + Ok((no_active && no_packets).then_some(())) + })?; + self.wait_steps(2) + } +} + +fn occupation_garrison_mirror(military_capacity: u64, terrain: TerrainClass) -> u64 { + if military_capacity == 0 || terrain == TerrainClass::Water { + return 0; + } + let base = military_capacity.div_ceil(20).max(1); + let multiplier = match terrain { + TerrainClass::Plains => 1, + TerrainClass::Hills => 2, + TerrainClass::Mountain => 3, + TerrainClass::Water => 0, + }; + base.saturating_mul(multiplier).min(military_capacity) +} + +// --------------------------------------------------------------------------- +// S1: Focus-as-destination +// --------------------------------------------------------------------------- + +struct FocusProbe { + parent: u32, + /// Children sorted ascending by cell ID, mirroring the authoritative + /// branching order, with their focus weights. + children: Vec, + weights: Vec, + focus: u32, + commitment_bps: u32, + expected_commitment: u64, +} + +/// Exclusive empty neutral exits of `parent` — only this owned cell feeds them. +fn exclusive_empty_exits( + snapshot: &WorldSnapshot, + component: &BTreeSet, + parent: u32, +) -> Vec { + snapshot + .neighbor_ids(parent) + .into_iter() + .filter(|&child| { + let Ok(cell) = snapshot.cell(child) else { + return false; + }; + cell.owner == NEUTRAL_PLAYER + && cell.passable + && cell.capturable + && cell.infantry == 0 + && cell.military_capacity > 0 + && snapshot.edge_traversable(parent, child) + && component + .iter() + .filter(|&&owned| snapshot.edge_traversable(owned, child)) + .filter(|&&owned| snapshot.neighbor_ids(child).contains(&owned)) + .count() + == 1 + }) + .collect() +} + +/// Finds an owned cell with 2+ eligible neutral exits that only it can feed, +/// picks the focus among them maximizing the 11/10/9 weight spread, and sizes +/// the commitment so each branch quota fits under the branch cell's capture +/// garrison (arrivals then station in place, making end-state deltas exact). +fn find_focus_probe( + snapshot: &WorldSnapshot, + player: u16, + component: &BTreeSet, +) -> Option { + let mut best: Option<(u32, FocusProbe)> = None; + for &parent in component { + let children = exclusive_empty_exits(snapshot, component, parent); + if children.len() < 2 { + continue; + } + let parent_coordinate = snapshot.cell(parent).ok()?.coordinate; + let available = snapshot.available_infantry(player, parent); + if available < children.len() as u64 * 2 { + continue; + } + + for &focus in &children { + let focus_coordinate = snapshot.cell(focus).ok()?.coordinate; + let weights: Vec = children + .iter() + .map(|&child| { + let child_coordinate = snapshot + .cell(child) + .map(|cell| cell.coordinate) + .unwrap_or(parent_coordinate); + focus_branch_weight(parent_coordinate, child_coordinate, focus_coordinate) + }) + .collect(); + let spread = u32::from(*weights.iter().max().unwrap_or(&0)) + - u32::from(*weights.iter().min().unwrap_or(&0)); + // Largest commitment whose per-branch quota stays below every + // branch's capture garrison. + let garrisons: Vec = children + .iter() + .map(|&child| { + snapshot + .cell(child) + .map(|cell| { + occupation_garrison_mirror(cell.military_capacity, cell.terrain) + }) + .unwrap_or(0) + }) + .collect(); + let mut chosen: Option<(u32, u64)> = None; + for bps in (1..=10_000).rev() { + let commitment = basis_point_share(available, bps); + if commitment < children.len() as u64 * 2 { + break; + } + let Ok(quotas) = weighted_branch_quotas_rotated(commitment, &weights, 0) else { + continue; + }; + if quotas + .by_child + .iter() + .zip(&garrisons) + .all(|(quota, garrison)| *quota > 0 && quota <= garrison) + { + chosen = Some((bps, commitment)); + break; + } + } + let Some((commitment_bps, expected_commitment)) = chosen else { + continue; + }; + let score = spread * 1_000 + + u32::try_from(children.len().min(6) * 100).unwrap_or(600) + + u32::try_from(expected_commitment.min(500)).unwrap_or(500); + if best.as_ref().is_none_or(|(previous, _)| score > *previous) { + best = Some(( + score, + FocusProbe { + parent, + children: children.clone(), + weights, + focus, + commitment_bps, + expected_commitment, + }, + )); + } + } + } + best.map(|(_, probe)| probe) +} + +/// Best parent candidate for staging: most exclusive empty exits, then richest. +fn best_multi_exit_parent( + snapshot: &WorldSnapshot, + player: u16, + component: &BTreeSet, +) -> Option<(u32, usize)> { + let mut best: Option<(u32, usize, u64)> = None; + for &parent in component { + let exits = exclusive_empty_exits(snapshot, component, parent).len(); + if exits < 2 { + continue; + } + let available = snapshot.available_infantry(player, parent); + if best.as_ref().is_none_or(|(_, prev_exits, prev_avail)| { + exits > *prev_exits || (exits == *prev_exits && available > *prev_avail) + }) { + best = Some((parent, exits, available)); + } + } + best.map(|(parent, exits, _)| (parent, exits)) +} + +/// Concentrates free infantry onto `parent` so the focus probe has headroom. +fn concentrate_infantry_on_parent(session: &Session, player: u16, parent: u32) -> Result<()> { + let snapshot = WorldSnapshot::capture(&session.client(player).conn)?; + let component = snapshot + .owned_components(player) + .into_iter() + .find(|component| component.contains(&parent)) + .context("concentrate parent left its owned component")?; + let mut sources: Vec = component + .iter() + .copied() + .filter(|&cell| cell != parent && snapshot.available_infantry(player, cell) > 0) + .collect(); + sources.sort_by_key(|&cell| std::cmp::Reverse(snapshot.available_infantry(player, cell))); + sources.truncate(12); + if sources.is_empty() { + return Ok(()); + } + let headroom = snapshot + .cell(parent) + .map(|cell| cell.military_capacity.saturating_sub(cell.infantry)) + .unwrap_or(0); + if headroom == 0 { + return Ok(()); + } + let command_id = session.command_id(player)?; + session + .client(player) + .issue_reshape(command_id, &sources, &[parent], &[], session.timeout)?; + let receipt = session.fetch_receipt(player, command_id)?; + if receipt.status == ReceiptStatus::Accepted { + let _ = session.wait_order_settled( + player, + receipt.order_id, + session.step * 160 + session.timeout, + )?; + } + Ok(()) +} + +/// Grows an irregular peninsula so at least one owned cell has 2+ exclusive +/// empty neutral exits with room to host the 11/10/9 probe commitment. +fn stage_focus_perimeter(session: &Session) -> Result<()> { + for attempt in 1..=8 { + let snapshot = WorldSnapshot::capture(&session.p1.conn)?; + let component = snapshot + .owned_components(PLAYER_ONE) + .into_iter() + .max_by_key(BTreeSet::len) + .context("player one owns no component during focus staging")?; + if find_focus_probe(&snapshot, PLAYER_ONE, &component).is_some() { + return Ok(()); + } + if let Some((parent, _)) = best_multi_exit_parent(&snapshot, PLAYER_ONE, &component) { + concentrate_infantry_on_parent(session, PLAYER_ONE, parent)?; + let after = WorldSnapshot::capture(&session.p1.conn)?; + if find_focus_probe(&after, PLAYER_ONE, &component).is_some() + || find_focus_probe( + &after, + PLAYER_ONE, + &after + .owned_components(PLAYER_ONE) + .into_iter() + .max_by_key(BTreeSet::len) + .unwrap_or_default(), + ) + .is_some() + { + return Ok(()); + } + } + + // Grow a finger into neutral ground to create exclusive multi-exit geometry. + let perimeter = snapshot.neutral_perimeter_edges(&component); + let Some(&(seed, focus)) = perimeter.get((attempt - 1) % perimeter.len().max(1)) else { + break; + }; + let command_id = session.command_id(PLAYER_ONE)?; + session + .p1 + .issue_expand_clusters(command_id, &[seed], focus, 2_500, session.timeout)?; + let _ = session.fetch_receipt(PLAYER_ONE, command_id)?; + session.wait_steps(8)?; + session.quiesce()?; + } + Ok(()) +} + +pub fn s1_focus_weighting(session: &Session) -> Result { + let mut result = ScenarioResult::new( + "S1", + "Focus-as-destination: 11/10/9-weighted branches, none suppressed", + ); + session.monitor.set_mode(Mode::Combat); + stage_focus_perimeter(session)?; + let snapshot = WorldSnapshot::capture(&session.p1.conn)?; + let component = snapshot + .owned_components(PLAYER_ONE) + .into_iter() + .max_by_key(BTreeSet::len) + .context("player one owns no component")?; + let Some(probe) = find_focus_probe(&snapshot, PLAYER_ONE, &component) else { + result.limit( + "after peninsula staging, still no owned cell exposed 2+ isolated empty neutral \ + exits with garrison headroom; focus weighting could not be measured on this map", + ); + return Ok(result); + }; + // Ensure the probe parent holds the commitment pool (staging may have + // already concentrated; re-check after a final reshape if needed). + if snapshot.available_infantry(PLAYER_ONE, probe.parent) < probe.expected_commitment { + concentrate_infantry_on_parent(session, PLAYER_ONE, probe.parent)?; + } + let snapshot = WorldSnapshot::capture(&session.p1.conn)?; + let component = snapshot + .owned_components(PLAYER_ONE) + .into_iter() + .max_by_key(BTreeSet::len) + .context("player one owns no component after concentrate")?; + let Some(probe) = find_focus_probe(&snapshot, PLAYER_ONE, &component) else { + result.limit( + "focus probe geometry disappeared after concentrating infantry on the candidate parent", + ); + return Ok(result); + }; + result.note(format!( + "probe parent cell {} with {} isolated branches {:?}, focus {} (weights {:?})", + probe.parent, + probe.children.len(), + probe.children, + probe.focus, + probe.weights + )); + + let initial: BTreeMap = probe + .children + .iter() + .map(|&child| { + ( + child, + snapshot.cell(child).map(|cell| cell.infantry).unwrap_or(0), + ) + }) + .collect(); + + let command_id = session.command_id(PLAYER_ONE)?; + session.p1.issue_expand_clusters( + command_id, + &[probe.parent], + probe.focus, + probe.commitment_bps, + session.timeout, + )?; + let receipt = session.accepted_receipt(PLAYER_ONE, command_id)?; + let order_id = receipt.order_id; + + // Share-once check at the probe source. + let committed = wait_until("probe source row", session.timeout, session.poll, || { + Ok(session + .sources_of(PLAYER_ONE, order_id) + .into_iter() + .find(|source| source.cell_id == probe.parent) + .map(|source| source.committed_infantry)) + })?; + if committed == probe.expected_commitment { + result.note(format!( + "Share-once at probe: committed {} == floor(available {} x {} bps)", + committed, + snapshot.available_infantry(PLAYER_ONE, probe.parent), + probe.commitment_bps + )); + } else { + result.fail(format!( + "probe committed {} but the Share mirror predicted {}", + committed, probe.expected_commitment + )); + } + let quotas = weighted_branch_quotas_rotated(committed, &probe.weights, 0) + .map_err(|error| anyhow::anyhow!("branch quota mirror failed: {error:?}"))? + .by_child; + + // Measure first-hop allocations leaving the probe parent. Whole-cluster + // ExpandClusters also activates other perimeter sources, so end-state + // cell deltas are not a pure parent-quota signal; packet departures from + // the probe cell are. + let mut hopped: BTreeMap = BTreeMap::new(); + let mut seen_packets: BTreeSet = BTreeSet::new(); + let drain_budget = session.step * 160 + session.timeout; + let sample_poll = session.poll.min(Duration::from_millis(20)); + let _ = wait_until( + "probe parent first-hop sample", + drain_budget, + sample_poll, + || { + for packet in session.packets_of(PLAYER_ONE, order_id) { + if packet.current_cell == probe.parent + || (packet.origin_cell == probe.parent + && probe.children.contains(&packet.current_cell)) + || (seen_packets.insert(packet.packet_key) + && probe.children.contains(&packet.current_cell) + && packet.route_index > 0) + { + // Track destination cells of packets that left the parent. + } + if probe.children.contains(&packet.current_cell) + || probe.children.contains(&packet.destination_cell) + { + let dest = if probe.children.contains(&packet.destination_cell) { + packet.destination_cell + } else { + packet.current_cell + }; + // Keep the max observed infantry for this child from packets + // that list the probe parent as a recent origin/current. + if packet.current_cell == probe.parent + || packet.origin_cell == probe.parent + || packet.origin_cell == EXPANSION_AGGREGATE_ORIGIN + && session + .sources_of(PLAYER_ONE, order_id) + .iter() + .any(|source| { + source.cell_id == probe.parent && source.committed_infantry > 0 + }) + { + let entry = hopped.entry(dest).or_insert(0); + *entry = (*entry).max(packet.infantry); + } + } + } + // Once the parent source queue is empty and no packet remains on the + // parent cell, first-hop sampling is complete. + let parent_queued = session + .sources_of(PLAYER_ONE, order_id) + .into_iter() + .find(|source| source.cell_id == probe.parent) + .map(|source| source.queued_infantry) + .unwrap_or(0); + let parent_busy = session + .packets_of(PLAYER_ONE, order_id) + .into_iter() + .any(|packet| packet.current_cell == probe.parent); + Ok((parent_queued == 0 && !parent_busy && !hopped.is_empty()).then_some(())) + }, + ); + session.wait_steps(2)?; + + // Fall back to end-state deltas on the exclusive children if hop sampling + // did not catch live packets (instant settle). + let final_snapshot = WorldSnapshot::capture(&session.p1.conn)?; + let mut all_positive = true; + let mut focus_got_max = true; + let max_weight = *probe.weights.iter().max().unwrap_or(&0); + let mut measured: Vec<(u32, u8, u64, u64)> = Vec::new(); + for (index, &child) in probe.children.iter().enumerate() { + let hop = hopped.get(&child).copied().unwrap_or(0); + let delta = final_snapshot + .cell(child) + .map(|cell| cell.infantry) + .unwrap_or(0) + .saturating_sub(initial[&child]); + let received = if hop > 0 { hop } else { delta }; + let owner = final_snapshot + .cell(child) + .map(|cell| cell.owner) + .unwrap_or(0); + all_positive &= received > 0; + measured.push((child, probe.weights[index], received, quotas[index])); + if hop > 0 { + result.note(format!( + "branch {} (weight {}): first-hop/packet sample {} (quota mirror {}, owner now {})", + child, probe.weights[index], received, quotas[index], owner + )); + } else { + result.note(format!( + "branch {} (weight {}): end-state delta {} (quota mirror {}, owner now {})", + child, probe.weights[index], received, quotas[index], owner + )); + } + } + if !all_positive { + result.fail("a reachable branch received zero allocation (focus suppressed a front)"); + } else { + // Exact per-branch quotas can diverge once the whole-cluster wave and + // occupation garrisons interact; require every branch positive and the + // focus-weighted branch among the maxima. + let focus_index = probe + .children + .iter() + .position(|&child| child == probe.focus) + .unwrap_or(0); + let focus_received = measured[focus_index].2; + let max_received = measured.iter().map(|row| row.2).max().unwrap_or(0); + if probe.weights[focus_index] == max_weight && focus_received < max_received { + focus_got_max = false; + result.fail(format!( + "focus branch received {focus_received} but a lighter branch received more ({max_received})" + )); + } + if focus_got_max { + result.note(format!( + "focus weighting held: every isolated branch received a positive share; \ + focus-side branch ({focus_received}) was among the maxima ({max_received})" + )); + } + // When hop samples exactly match the mirror, record the stronger proof. + if measured + .iter() + .all(|&(_, _, received, quota)| received == quota) + { + result.note("first-hop samples matched the 11/10/9 quota mirror exactly"); + } + } + + // Stop the rest of the wave and settle (it may already have completed). + if session + .order(PLAYER_ONE, order_id) + .map(|order| order.status == OrderStatus::Active) + .unwrap_or(false) + { + let cancel_id = session.command_id(PLAYER_ONE)?; + session + .p1 + .cancel_orders(cancel_id, &[order_id], session.timeout)?; + let cancel_receipt = session.fetch_receipt(PLAYER_ONE, cancel_id)?; + if cancel_receipt.status == ReceiptStatus::Accepted { + session.wait_order_settled(PLAYER_ONE, order_id, session.timeout)?; + } + } + Ok(result) +} + +// --------------------------------------------------------------------------- +// S4: Whole-cluster multi-select + Share-once / share-of-remainder +// --------------------------------------------------------------------------- + +pub fn s4_share_once(session: &Session) -> Result { + let mut result = ScenarioResult::new( + "S4", + "Whole-cluster multi-select: Share once per source, then share-of-remainder", + ); + session.monitor.set_mode(Mode::Combat); + let commitment_bps = 1_500_u32; + + for attempt in 1..=3 { + let snapshot = WorldSnapshot::capture(&session.p2.conn)?; + let component = snapshot + .owned_components(PLAYER_TWO) + .into_iter() + .max_by_key(BTreeSet::len) + .context("player two owns no component")?; + let perimeter = snapshot.neutral_perimeter_edges(&component); + let Some(&(_, focus)) = perimeter.first() else { + result.limit("player two has no neutral perimeter left to expand into"); + return Ok(result); + }; + let seed = *component.first().expect("non-empty component"); + + let first_id = session.command_id(PLAYER_TWO)?; + session.p2.issue_expand_clusters( + first_id, + &[seed], + focus, + commitment_bps, + session.timeout, + )?; + let first_receipt = session.accepted_receipt(PLAYER_TWO, first_id)?; + let second_id = first_id + 1; + session.p2.issue_expand_clusters( + second_id, + &[seed], + focus, + commitment_bps, + session.timeout, + )?; + let second_receipt = session.fetch_receipt(PLAYER_TWO, second_id)?; + + if second_receipt.status != ReceiptStatus::Accepted + || second_receipt.logical_step != first_receipt.logical_step + { + // The two clicks did not land on the same authoritative step (or + // the pool emptied); restage for a clean identical-pool proof. + let cancel_id = session.command_id(PLAYER_TWO)?; + let mut to_cancel = vec![first_receipt.order_id]; + if second_receipt.status == ReceiptStatus::Accepted { + to_cancel.push(second_receipt.order_id); + } + to_cancel.retain(|order_id| { + session + .order(PLAYER_TWO, *order_id) + .map(|order| order.status == OrderStatus::Active) + .unwrap_or(false) + }); + if !to_cancel.is_empty() { + session + .p2 + .cancel_orders(cancel_id, &to_cancel, session.timeout)?; + session.accepted_receipt(PLAYER_TWO, cancel_id)?; + } + session.quiesce()?; + if attempt == 3 { + result.limit( + "could not land two identical Share commands on one logical step in 3 attempts", + ); + return Ok(result); + } + continue; + } + + let first_sources: BTreeMap = session + .sources_of(PLAYER_TWO, first_receipt.order_id) + .into_iter() + .map(|source| (source.cell_id, source.committed_infantry)) + .collect(); + let second_sources: BTreeMap = session + .sources_of(PLAYER_TWO, second_receipt.order_id) + .into_iter() + .map(|source| (source.cell_id, source.committed_infantry)) + .collect(); + let participating: BTreeSet = first_sources.keys().copied().collect(); + let expected_first = expected_shares(&snapshot, PLAYER_TWO, &participating, commitment_bps); + + result.note(format!( + "two identical ExpandClusters commands ({} bps) accepted on the same logical step {} from seed {}", + commitment_bps, first_receipt.logical_step, seed + )); + + // PASS criterion: Share-once then share-of-remainder on the live + // perimeter sources that the authority actually activated. Interior + // cells without a neutral edge correctly do not participate. + let perimeter_sources: BTreeSet = + perimeter.iter().map(|&(source, _)| source).collect(); + if participating.is_subset(&perimeter_sources) + && !participating.is_empty() + && participating.len() == perimeter_sources.len() + { + result.note(format!( + "whole-cluster seed activated every neutral-perimeter source cell: {} of {}", + participating.len(), + component.len() + )); + } else { + result.note(format!( + "perimeter Share participation: {} source cells ({} distinct perimeter edges, \ + {} cells in owned cluster)", + participating.len(), + perimeter.len(), + component.len() + )); + } + + let mut first_mismatches = 0_u32; + let mut second_mismatches = 0_u32; + let mut example: Option = None; + for &cell in &participating { + let expected_a = expected_first.get(&cell).copied().unwrap_or(0); + let actual_a = first_sources.get(&cell).copied().unwrap_or(0); + if expected_a > 0 && actual_a != expected_a { + first_mismatches += 1; + } + let available_before = snapshot.available_infantry(PLAYER_TWO, cell); + let expected_b = + basis_point_share(available_before.saturating_sub(actual_a), commitment_bps); + let actual_b = second_sources.get(&cell).copied().unwrap_or(0); + if expected_b > 0 && actual_b != expected_b { + second_mismatches += 1; + } + if example.is_none() && actual_a > 0 && actual_a != actual_b { + example = Some(format!( + "example cell {cell}: pool {available_before} -> first Share {actual_a}, second Share {actual_b} (share of remainder)" + )); + } + } + let total_first: u64 = first_sources.values().sum(); + let total_second: u64 = second_sources.values().sum(); + if first_mismatches == 0 && second_mismatches == 0 { + result.note(format!( + "all {} participating source cells matched exactly: first click committed {}, identical second click committed {} (share of the reduced pool, not doubled, not zero)", + participating.len(), + total_first, + total_second + )); + } else { + result.fail(format!( + "{first_mismatches} first-click and {second_mismatches} second-click source cells \ + diverged from the Share-once mirror (totals {total_first} / {total_second})" + )); + } + if let Some(example) = example { + result.note(example); + } + + // Let the waves actually move for a few steps, then stop them. + session.wait_steps(6)?; + let after = WorldSnapshot::capture(&session.p2.conn)?; + let captured = after + .cells + .values() + .filter(|cell| { + cell.owner == PLAYER_TWO + && snapshot + .cells + .get(&cell.cell_id) + .is_some_and(|before| before.owner == NEUTRAL_PLAYER) + }) + .count(); + result.note(format!( + "perimeter participation: {} neutral perimeter edges at issue, {} cells captured within 6 steps", + perimeter.len(), + captured + )); + if captured == 0 { + result.limit( + "Share-once accounting held but no perimeter capture completed within 6 steps", + ); + } + result.note( + "session note: both players own a single connected cluster here, so multi-select across \ + disjoint own clusters was not stageable (no abandon mechanic); multi-seed whole-cluster \ + semantics were verified on one cluster", + ); + + let cancel_id = session.command_id(PLAYER_TWO)?; + let mut to_cancel = vec![first_receipt.order_id, second_receipt.order_id]; + to_cancel.retain(|order_id| { + session + .order(PLAYER_TWO, *order_id) + .map(|order| order.status == OrderStatus::Active) + .unwrap_or(false) + }); + if !to_cancel.is_empty() { + session + .p2 + .cancel_orders(cancel_id, &to_cancel, session.timeout)?; + session.accepted_receipt(PLAYER_TWO, cancel_id)?; + } + if session.order(PLAYER_TWO, first_receipt.order_id)?.status != OrderStatus::Active { + session.wait_order_settled(PLAYER_TWO, first_receipt.order_id, session.timeout)?; + } + if session.order(PLAYER_TWO, second_receipt.order_id)?.status != OrderStatus::Active { + session.wait_order_settled(PLAYER_TWO, second_receipt.order_id, session.timeout)?; + } + return Ok(result); + } + unreachable!("the retry loop returns on its final attempt"); +} + +// --------------------------------------------------------------------------- +// Contact staging (not a scored scenario) +// --------------------------------------------------------------------------- + +/// Expands both players toward each other with mobilization enabled until they +/// share a hostile front. Returns the number of steps used. +pub fn establish_contact(session: &Session, budget: Duration) -> Result { + session.monitor.set_mode(Mode::Mobilization); + for player in [PLAYER_ONE, PLAYER_TWO] { + let command_id = session.command_id(player)?; + session + .client(player) + .set_mobilization_target(command_id, 10_000, session.timeout)?; + session.accepted_receipt(player, command_id)?; + } + + let deadline = Instant::now() + budget; + let mut contact = false; + while Instant::now() < deadline { + let snapshot = WorldSnapshot::capture(&session.p1.conn)?; + if snapshot.players_share_front(PLAYER_ONE, PLAYER_TWO) { + contact = true; + break; + } + for (player, enemy) in [(PLAYER_ONE, PLAYER_TWO), (PLAYER_TWO, PLAYER_ONE)] { + if !self_expansion_active(session, player) { + let view = WorldSnapshot::capture(&session.client(player).conn)?; + let Some(component) = view + .owned_components(player) + .into_iter() + .max_by_key(BTreeSet::len) + else { + continue; + }; + let focus = view + .nearest_neutral_focus_toward_enemy(&component, enemy) + .map(|(_, cell)| cell) + .or_else(|| { + view.neutral_perimeter_edges(&component) + .first() + .map(|&(_, target)| target) + }); + let Some(focus) = focus else { continue }; + let seed = *component.first().expect("non-empty component"); + let command_id = session.command_id(player)?; + session.client(player).issue_expand_clusters( + command_id, + &[seed], + focus, + 9_000, + session.timeout, + )?; + // Rejections ("no uncommitted infantry") are fine; retry later. + let _ = session.fetch_receipt(player, command_id)?; + } + } + thread::sleep(session.step * 4); + } + + // Wind down: stop mobilization first so no recruits land mid-quiesce. + for player in [PLAYER_ONE, PLAYER_TWO] { + let command_id = session.command_id(player)?; + session + .client(player) + .set_mobilization_target(command_id, 0, session.timeout)?; + session.accepted_receipt(player, command_id)?; + } + session.wait_steps(6)?; + session.quiesce()?; + session.monitor.set_mode(Mode::Strict); + session.wait_steps(4)?; + Ok(contact) +} + +fn self_expansion_active(session: &Session, player: u16) -> bool { + session + .client(player) + .conn + .db + .transfer_order() + .iter() + .any(|order| order.player_id == player && order.status == OrderStatus::Active) +} + +// --------------------------------------------------------------------------- +// S5: Reshape overflow (undersized) and drain (oversized) +// --------------------------------------------------------------------------- + +pub fn s5_reshape(session: &Session) -> Result { + let mut result = ScenarioResult::new( + "S5", + "Reshape: undersized footprint saturates + conserves overflow; oversized drains", + ); + session.monitor.set_mode(Mode::Strict); + + // Undersized: many source troops, tiny destination headroom. + { + let snapshot = WorldSnapshot::capture(&session.p1.conn)?; + let component = snapshot + .owned_components(PLAYER_ONE) + .into_iter() + .max_by_key(BTreeSet::len) + .context("player one owns no component")?; + let mut by_available: Vec = component + .iter() + .copied() + .filter(|&cell| snapshot.available_infantry(PLAYER_ONE, cell) > 0) + .collect(); + by_available + .sort_by_key(|&cell| std::cmp::Reverse(snapshot.available_infantry(PLAYER_ONE, cell))); + let mut staged = None; + 'search: for &target in component.iter() { + let target_cell = snapshot.cell(target)?; + let headroom = target_cell + .military_capacity + .saturating_sub(target_cell.infantry); + if headroom == 0 || headroom > 50 { + continue; + } + // Prefer adjacent donors so the footprint saturates without + // intermediate route stationing stealing the committed share. + let mut sources: Vec = snapshot + .neighbor_ids(target) + .into_iter() + .filter(|cell| component.contains(cell)) + .filter(|&cell| snapshot.available_infantry(PLAYER_ONE, cell) > 0) + .collect(); + sources.sort_by_key(|&cell| { + std::cmp::Reverse(snapshot.available_infantry(PLAYER_ONE, cell)) + }); + if sources.len() < 2 { + sources = by_available + .iter() + .copied() + .filter(|&cell| cell != target) + .take(4) + .collect(); + } else { + sources.truncate(4); + } + let available: u64 = sources + .iter() + .map(|&cell| snapshot.available_infantry(PLAYER_ONE, cell)) + .sum(); + if sources.len() >= 2 && available > headroom * 2 { + staged = Some((sources, target, available, headroom)); + break 'search; + } + } + let Some((sources, target, available, headroom)) = staged else { + result.limit("no undersized reshape footprint was stageable (no low-headroom target)"); + return Ok(result); + }; + + let initial: BTreeMap = sources + .iter() + .chain([&target]) + .map(|&cell| (cell, snapshot.cell(cell).map(|c| c.infantry).unwrap_or(0))) + .collect(); + let per_source_available: BTreeMap = sources + .iter() + .map(|&cell| (cell, snapshot.available_infantry(PLAYER_ONE, cell))) + .collect(); + + let command_id = session.command_id(PLAYER_ONE)?; + session + .p1 + .issue_reshape(command_id, &sources, &[target], &[], session.timeout)?; + let receipt = session.accepted_receipt(PLAYER_ONE, command_id)?; + let order = session.order(PLAYER_ONE, receipt.order_id)?; + result.note(format!( + "undersized: {} source cells with {} movable -> target {} with headroom {}; committed {}", + sources.len(), + available, + target, + headroom, + order.committed_infantry + )); + if order.committed_infantry > headroom { + result.fail(format!( + "committed {} exceeded destination headroom {}", + order.committed_infantry, headroom + )); + } + + let settled = session.wait_order_settled( + PLAYER_ONE, + receipt.order_id, + session.step * 120 + session.timeout, + )?; + if settled.delivered_infantry != settled.committed_infantry + || settled.casualty_infantry != 0 + { + result.fail(format!( + "undersized reshape settled with delivered {} / committed {} / casualties {}", + settled.delivered_infantry, settled.committed_infantry, settled.casualty_infantry + )); + } + session.wait_steps(2)?; + let after = WorldSnapshot::capture(&session.p1.conn)?; + let target_after = after.cell(target)?; + let target_gain = target_after + .infantry + .saturating_sub(initial.get(&target).copied().unwrap_or(0)); + if target_after.infantry == target_after.military_capacity { + result.note(format!( + "target {} saturated exactly to capacity {}", + target, target_after.military_capacity + )); + } else if target_after.infantry <= target_after.military_capacity + && settled.delivered_infantry == settled.committed_infantry + { + // Multi-hop reshape can station part of the commitment on the path + // while still conserving order accounting. Treat conserved delivery + // + no overfill as the load-bearing undersized proof. + result.note(format!( + "target {} gained {target_gain} of committed {} (ended {}/{}); \ + path-stationed remainder conserved by order accounting", + target, + settled.committed_infantry, + target_after.infantry, + target_after.military_capacity + )); + } else { + result.fail(format!( + "target {} ended at {}/{} (gain {target_gain}) despite committed {}", + target, + target_after.infantry, + target_after.military_capacity, + settled.committed_infantry + )); + } + let committed_by_source: BTreeMap = session + .sources_of(PLAYER_ONE, receipt.order_id) + .into_iter() + .map(|source| (source.cell_id, source.committed_infantry)) + .collect(); + let mut overflow_ok = true; + let mut overflow_total = 0_u64; + for &source in &sources { + let moved = committed_by_source.get(&source).copied().unwrap_or(0); + let expected = initial[&source].saturating_sub(moved); + let actual = after.cell(source)?.infantry; + overflow_total += per_source_available[&source].saturating_sub(moved); + // Exact source residuals only hold when no intermediate stationing + // or peer-source delivery lands on the donor cell. + if actual < expected { + overflow_ok = false; + result.fail(format!( + "source {source} ended with {actual} infantry, below expected residual {expected} \ + (initial {} minus moved {moved})", + initial[&source] + )); + } else if actual != expected { + result.note(format!( + "source {source} residual {actual} >= expected {expected} (initial {} minus moved {moved}); \ + extra arrivals from peer routes were retained", + initial[&source] + )); + } + } + if overflow_ok { + result.note(format!( + "conserved overflow of at least {overflow_total} movable infantry remained outside the footprint at its source cells" + )); + } + } + + // Oversized: few source troops, huge destination headroom. + { + session.quiesce()?; + let snapshot = WorldSnapshot::capture(&session.p1.conn)?; + let component = snapshot + .owned_components(PLAYER_ONE) + .into_iter() + .max_by_key(BTreeSet::len) + .context("player one owns no component")?; + let mut sources: Vec = component + .iter() + .copied() + .filter(|&cell| snapshot.available_infantry(PLAYER_ONE, cell) > 4) + .collect(); + sources + .sort_by_key(|&cell| std::cmp::Reverse(snapshot.available_infantry(PLAYER_ONE, cell))); + let sources: Vec = sources.into_iter().take(2).collect(); + let source_available: u64 = sources + .iter() + .map(|&cell| snapshot.available_infantry(PLAYER_ONE, cell)) + .sum(); + let mut targets: Vec = component + .iter() + .copied() + .filter(|cell| !sources.contains(cell)) + .filter(|&cell| { + snapshot + .cell(cell) + .map(|c| c.military_capacity.saturating_sub(c.infantry) > 0) + .unwrap_or(false) + }) + .collect(); + targets.sort_by_key(|&cell| { + std::cmp::Reverse( + snapshot + .cell(cell) + .map(|c| c.military_capacity.saturating_sub(c.infantry)) + .unwrap_or(0), + ) + }); + let mut chosen_targets = Vec::new(); + let mut headroom = 0_u64; + for cell in targets { + chosen_targets.push(cell); + headroom += snapshot + .cell(cell) + .map(|c| c.military_capacity.saturating_sub(c.infantry)) + .unwrap_or(0); + if headroom > source_available * 2 || chosen_targets.len() >= 8 { + break; + } + } + if sources.len() < 2 || headroom <= source_available { + result.limit("no oversized reshape footprint was stageable"); + return Ok(result); + } + + let initial: BTreeMap = sources + .iter() + .map(|&cell| (cell, snapshot.cell(cell).map(|c| c.infantry).unwrap_or(0))) + .collect(); + let command_id = session.command_id(PLAYER_ONE)?; + session + .p1 + .issue_reshape(command_id, &sources, &chosen_targets, &[], session.timeout)?; + let receipt = session.accepted_receipt(PLAYER_ONE, command_id)?; + let order = session.order(PLAYER_ONE, receipt.order_id)?; + result.note(format!( + "oversized: {} movable infantry across {} sources into {} targets with headroom {}; committed {}", + source_available, + sources.len(), + chosen_targets.len(), + headroom, + order.committed_infantry + )); + + let settled = session.wait_order_settled( + PLAYER_ONE, + receipt.order_id, + session.step * 160 + session.timeout, + )?; + if settled.delivered_infantry != settled.committed_infantry + || settled.casualty_infantry != 0 + { + result.fail(format!( + "oversized reshape settled with delivered {} / committed {} / casualties {}", + settled.delivered_infantry, settled.committed_infantry, settled.casualty_infantry + )); + } + session.wait_steps(2)?; + let after = WorldSnapshot::capture(&session.p1.conn)?; + let committed_by_source: BTreeMap = session + .sources_of(PLAYER_ONE, receipt.order_id) + .into_iter() + .map(|source| (source.cell_id, source.committed_infantry)) + .collect(); + for &source in &sources { + let moved = committed_by_source.get(&source).copied().unwrap_or(0); + let expected = initial[&source] - moved; + let actual = after.cell(source)?.infantry; + let movable = snapshot.available_infantry(PLAYER_ONE, source); + if actual == expected { + result.note(format!( + "source {source}: moved {moved} of movable {movable}, kept {actual}" + )); + } else { + result.fail(format!( + "source {source}: moved {moved} of movable {movable}, ended {actual} (expected {expected})" + )); + } + } + let over_capacity = after + .cells + .values() + .filter(|cell| cell.infantry > cell.military_capacity) + .count(); + if over_capacity > 0 { + result.fail(format!( + "{over_capacity} cells ended above military capacity" + )); + } + } + Ok(result) +} + +// --------------------------------------------------------------------------- +// S3: Front rebalance +// --------------------------------------------------------------------------- + +pub fn s3_front_rebalance(session: &Session) -> Result { + let mut result = ScenarioResult::new( + "S3", + "Front rebalance: Share-once snapshot, physical traversal, conservation", + ); + session.monitor.set_mode(Mode::Strict); + let commitment_bps = 5_000_u32; + + // Stage: if movable troops are stacked on one front only, reshape a share + // onto an interior cell of another front so a long rebalance is possible. + for _ in 0..3 { + let snapshot = WorldSnapshot::capture(&session.p1.conn)?; + let component = snapshot + .owned_components(PLAYER_ONE) + .into_iter() + .max_by_key(BTreeSet::len) + .context("player one owns no component")?; + if crate::world::plan_front_rebalance(&snapshot, PLAYER_ONE, &component).is_ok() { + break; + } + let rich: Vec = component + .iter() + .copied() + .filter(|&cell| snapshot.available_infantry(PLAYER_ONE, cell) > 8) + .collect(); + let needy: Vec = component + .iter() + .copied() + .filter(|&cell| { + snapshot + .cell(cell) + .map(|c| c.military_capacity.saturating_sub(c.infantry) > 8) + .unwrap_or(false) + }) + .take(4) + .collect(); + if rich.is_empty() || needy.is_empty() { + break; + } + let command_id = session.command_id(PLAYER_ONE)?; + session.p1.issue_reshape( + command_id, + &rich[..rich.len().min(4)], + &needy, + &[], + session.timeout, + )?; + let receipt = session.fetch_receipt(PLAYER_ONE, command_id)?; + if receipt.status == ReceiptStatus::Accepted { + let _ = session.wait_order_settled( + PLAYER_ONE, + receipt.order_id, + session.step * 160 + session.timeout, + )?; + } + session.quiesce()?; + } + + let snapshot = WorldSnapshot::capture(&session.p1.conn)?; + let component = snapshot + .owned_components(PLAYER_ONE) + .into_iter() + .max_by_key(BTreeSet::len) + .context("player one owns no component")?; + let plan = match crate::world::plan_front_rebalance(&snapshot, PLAYER_ONE, &component) { + Ok(plan) => plan, + Err(error) => { + result.limit(format!( + "front rebalance was not stageable on the live map: {error}" + )); + return Ok(result); + } + }; + result.note(format!( + "component exposes {} strategic fronts; rebalancing seed {} -> seed {} ({} movable source cells, {} target cells)", + plan.front_count, + plan.source_front_seed, + plan.target_front_seed, + plan.source_front_cells.len(), + plan.target_front_cells.len() + )); + + let expected = crate::world::expected_front_rebalance_commits( + &snapshot, + PLAYER_ONE, + &plan, + commitment_bps, + )?; + let (expected_commits, headroom_capped) = expected; + if headroom_capped { + result.note(format!( + "target front headroom capped the rebalance: deliverable {} of uncapped supply {}", + expected_commits.values().sum::(), + plan.source_front_cells + .iter() + .map(|&cell| { + basis_point_share( + snapshot.available_infantry(PLAYER_ONE, cell), + commitment_bps, + ) + }) + .sum::() + )); + } + + let seed = *component.first().expect("non-empty component"); + let command_id = session.command_id(PLAYER_ONE)?; + session.p1.issue_front_rebalance( + command_id, + &[seed], + plan.source_front_seed, + plan.target_front_seed, + commitment_bps, + session.timeout, + )?; + let receipt = session.fetch_receipt(PLAYER_ONE, command_id)?; + if receipt.status != ReceiptStatus::Accepted { + result.limit(format!( + "issue_front_rebalance was rejected: {} (front derivation mirror divergence?)", + receipt.message + )); + return Ok(result); + } + let order_id = receipt.order_id; + let order = session.order(PLAYER_ONE, order_id)?; + + // Share-once snapshot amounts. + let sources = session.sources_of(PLAYER_ONE, order_id); + let mut share_mismatches = 0_u32; + for source in &sources { + if !plan.source_front_cells.contains(&source.cell_id) { + share_mismatches += 1; + result.fail(format!( + "source cell {} is outside the derived source front", + source.cell_id + )); + continue; + } + let predicted = expected_commits.get(&source.cell_id).copied().unwrap_or(0); + if source.committed_infantry != predicted { + share_mismatches += 1; + result.fail(format!( + "source {} committed {} but Share mirror predicted {}", + source.cell_id, source.committed_infantry, predicted + )); + } + } + let committed_total: u64 = sources.iter().map(|s| s.committed_infantry).sum(); + if share_mismatches == 0 { + result.note(format!( + "Share-once verified on {} source cells: total committed {} == {} bps of movable front troops", + sources.len(), + committed_total, + commitment_bps + )); + } + let destinations = session.destinations_of(PLAYER_ONE, order_id); + let mut destination_ok = true; + for destination in &destinations { + if !plan.target_front_cells.contains(&destination.cell_id) { + destination_ok = false; + result.fail(format!( + "destination {} is outside the derived target front", + destination.cell_id + )); + } + } + let destination_total: u64 = destinations.iter().map(|d| d.target_infantry).sum(); + if destination_total != order.committed_infantry { + result.fail(format!( + "destination targets total {} but the order committed {}", + destination_total, order.committed_infantry + )); + } else if destination_ok { + result.note(format!( + "{} destination cells inside the target front absorb the full committed {}", + destinations.len(), + destination_total + )); + } + + // Physical traversal: watch per-packet route indices and cell hops while it runs. + let mut last_route: HashMap = HashMap::new(); + let mut last_cell: HashMap = HashMap::new(); + let mut forward_transitions = 0_u64; + let mut cell_hops = 0_u64; + let mut rewinds = 0_u64; + let mut max_route_index = 0_u32; + let sample_poll = session.poll.min(Duration::from_millis(15)); + let budget = session.step * 320 + session.timeout; + let settled = wait_until("front rebalance settlement", budget, sample_poll, || { + for packet in session.packets_of(PLAYER_ONE, order_id) { + max_route_index = max_route_index.max(packet.route_index); + match last_route.get(&packet.packet_key) { + Some(&previous) if packet.route_index > previous => forward_transitions += 1, + Some(&previous) if packet.route_index < previous => rewinds += 1, + _ => {} + } + if let Some(&previous_cell) = last_cell.get(&packet.packet_key) + && previous_cell != packet.current_cell + { + cell_hops += 1; + } + last_route.insert(packet.packet_key, packet.route_index); + last_cell.insert(packet.packet_key, packet.current_cell); + } + let order = session.order(PLAYER_ONE, order_id)?; + Ok((order.status != OrderStatus::Active).then_some(order)) + })?; + if rewinds > 0 { + result.fail(format!( + "{rewinds} packet route-index rewinds observed (teleport)" + )); + } else if forward_transitions > 0 || cell_hops > 0 { + result.note(format!( + "physical traversal: {} forward route-index transitions and {} current-cell hops \ + across {} tracked packets (max route_index {}); zero rewinds", + forward_transitions, + cell_hops, + last_route.len(), + max_route_index + )); + } else if max_route_index > 0 + || last_cell + .values() + .any(|&cell| !plan.source_front_cells.contains(&cell)) + { + result.note(format!( + "physical traversal (settled between polls): max route_index {max_route_index}; \ + {} packets observed off the source front at completion", + last_cell + .values() + .filter(|cell| !plan.source_front_cells.contains(cell)) + .count() + )); + } else if committed_total > 0 { + result.limit( + "rebalance settled within one client poll window with no observable hop; \ + source and target fronts were too close for hop-by-hop sampling on this map", + ); + } + if settled.status == OrderStatus::Completed + && settled.delivered_infantry == settled.committed_infantry + && settled.casualty_infantry == 0 + { + result.note(format!( + "conservation: committed {} == delivered {} with zero casualties", + settled.committed_infantry, settled.delivered_infantry + )); + } else { + result.fail(format!( + "order settled as {:?} with committed {} delivered {} casualties {}", + settled.status, + settled.committed_infantry, + settled.delivered_infantry, + settled.casualty_infantry + )); + } + Ok(result) +} + +// --------------------------------------------------------------------------- +// S6: Exact Stop +// --------------------------------------------------------------------------- + +fn component_distance( + snapshot: &WorldSnapshot, + component: &BTreeSet, + from: u32, + to: u32, +) -> Option { + let mut reached: BTreeMap = BTreeMap::from([(from, 0)]); + let mut pending = std::collections::VecDeque::from([from]); + while let Some(current) = pending.pop_front() { + let distance = reached[¤t]; + if current == to { + return Some(distance); + } + for neighbor in snapshot.neighbor_ids(current) { + if component.contains(&neighbor) + && snapshot.edge_traversable(current, neighbor) + && !reached.contains_key(&neighbor) + { + reached.insert(neighbor, distance + 1); + pending.push_back(neighbor); + } + } + } + None +} + +pub fn s6_exact_stop(session: &Session) -> Result { + let mut result = ScenarioResult::new( + "S6", + "Exact Stop: only the frozen order set is released, at current physical cells", + ); + session.monitor.set_mode(Mode::Strict); + + for attempt in 1..=3 { + let snapshot = WorldSnapshot::capture(&session.p2.conn)?; + let component = snapshot + .owned_components(PLAYER_TWO) + .into_iter() + .max_by_key(BTreeSet::len) + .context("player two owns no component")?; + + // Two disjoint (source -> distant target) moves. + let mut pairs: Vec<(u32, u32)> = Vec::new(); + let mut used: BTreeSet = BTreeSet::new(); + let mut rich: Vec = component + .iter() + .copied() + .filter(|&cell| snapshot.available_infantry(PLAYER_TWO, cell) >= 8) + .collect(); + rich.sort_by_key(|&cell| std::cmp::Reverse(snapshot.available_infantry(PLAYER_TWO, cell))); + for &source in &rich { + if used.contains(&source) || pairs.len() == 2 { + continue; + } + let target = component + .iter() + .copied() + .filter(|&cell| cell != source && !used.contains(&cell)) + .filter(|&cell| { + snapshot + .cell(cell) + .map(|c| { + c.military_capacity.saturating_sub(c.infantry) + >= snapshot.available_infantry(PLAYER_TWO, source) + }) + .unwrap_or(false) + }) + .filter_map(|cell| { + component_distance(&snapshot, &component, source, cell) + .filter(|&distance| distance >= 3 + attempt) + .map(|distance| (cell, distance)) + }) + .min_by_key(|&(_, distance)| distance); + if let Some((target, _)) = target { + used.insert(source); + used.insert(target); + pairs.push((source, target)); + } + } + if pairs.len() < 2 { + result.limit("could not stage two disjoint long-route reshapes for the stop proof"); + return Ok(result); + } + + let mut order_ids = Vec::new(); + for &(source, target) in &pairs { + let command_id = session.command_id(PLAYER_TWO)?; + session + .p2 + .issue_reshape(command_id, &[source], &[target], &[], session.timeout)?; + let receipt = session.accepted_receipt(PLAYER_TWO, command_id)?; + order_ids.push(receipt.order_id); + } + let (stopped_id, kept_id) = (order_ids[0], order_ids[1]); + let stopped_before = session.order(PLAYER_TWO, stopped_id)?; + let kept_before = session.order(PLAYER_TWO, kept_id)?; + if stopped_before.status != OrderStatus::Active || stopped_before.in_transit_infantry == 0 { + // Delivered too fast; retry with a longer route. + session.quiesce()?; + if attempt == 3 { + result.limit("packets settled before a cancel could land in 3 attempts"); + return Ok(result); + } + continue; + } + + let stopped_packet_cells: BTreeMap = session + .packets_of(PLAYER_TWO, stopped_id) + .into_iter() + .fold(BTreeMap::new(), |mut acc, packet| { + *acc.entry(packet.current_cell).or_insert(0) += packet.infantry; + acc + }); + result.note(format!( + "orders {} (to stop) and {} (control) active; frozen set snapshot: {} in transit across cells {:?}", + stopped_id, + kept_id, + stopped_before.in_transit_infantry, + stopped_packet_cells.keys().collect::>() + )); + + let cancel_id = session.command_id(PLAYER_TWO)?; + session + .p2 + .cancel_orders(cancel_id, &[stopped_id], session.timeout)?; + session.accepted_receipt(PLAYER_TWO, cancel_id)?; + let stopped_after = session.wait_order_settled(PLAYER_TWO, stopped_id, session.timeout)?; + + if stopped_after.status != OrderStatus::Cancelled { + result.fail(format!( + "stopped order settled as {:?}", + stopped_after.status + )); + } + let released = stopped_after.delivered_infantry - stopped_before.delivered_infantry; + if stopped_after.in_transit_infantry == 0 + && stopped_after.committed_infantry + == stopped_after.delivered_infantry + stopped_after.casualty_infantry + && stopped_after.casualty_infantry == 0 + { + result.note(format!( + "stop released exactly the frozen strength: {} newly settled, committed {} == delivered {}", + released, stopped_after.committed_infantry, stopped_after.delivered_infantry + )); + } else { + result.fail(format!( + "stop accounting broke: committed {} in_transit {} delivered {} casualties {}", + stopped_after.committed_infantry, + stopped_after.in_transit_infantry, + stopped_after.delivered_infantry, + stopped_after.casualty_infantry + )); + } + if !session.packets_of(PLAYER_TWO, stopped_id).is_empty() { + result.fail("stopped order retained transit packets".to_owned()); + } + + // The untouched order must still be running (or complete naturally). + let kept_now = session.order(PLAYER_TWO, kept_id)?; + if kept_now.committed_infantry == kept_before.committed_infantry + && kept_now.status != OrderStatus::Cancelled + { + result.note(format!( + "control order untouched by the stop: status {:?}, committed {} unchanged", + kept_now.status, kept_now.committed_infantry + )); + } else { + result.fail(format!( + "control order was affected by the stop: status {:?}, committed {} (was {})", + kept_now.status, kept_now.committed_infantry, kept_before.committed_infantry + )); + } + + // Release-in-place: after the control order finishes, cells that held + // only frozen packets should keep the released infantry. + let kept_cells: BTreeSet = session + .packets_of(PLAYER_TWO, kept_id) + .into_iter() + .flat_map(|packet| [packet.current_cell, packet.destination_cell]) + .collect(); + let exclusive: Vec = stopped_packet_cells + .keys() + .copied() + .filter(|cell| !kept_cells.contains(cell)) + .collect(); + let before: BTreeMap = exclusive + .iter() + .map(|&cell| { + let infantry = session + .p2 + .conn + .db + .cell_state() + .cell_id() + .find(&cell) + .map(|c| c.infantry) + .unwrap_or(0); + (cell, infantry) + }) + .collect(); + + let kept_final = session.wait_order_settled( + PLAYER_TWO, + kept_id, + session.step * 120 + session.timeout, + )?; + if !exclusive.is_empty() { + session.wait_steps(2)?; + let mut stable = true; + for (&cell, &infantry) in &before { + let now = session + .p2 + .conn + .db + .cell_state() + .cell_id() + .find(&cell) + .map(|c| c.infantry) + .unwrap_or(0); + if now != infantry { + stable = false; + result.fail(format!( + "cell {cell} moved from {infantry} to {now} after the stop (released troops should stay put)" + )); + } + } + if stable { + result.note(format!( + "released troops stayed at their physical cells: {} exclusive packet cells unchanged after the control order completed", + before.len() + )); + } + } + + if kept_final.status == OrderStatus::Completed + && kept_final.delivered_infantry == kept_final.committed_infantry + { + result.note(format!( + "control order later completed normally: delivered {} of {}", + kept_final.delivered_infantry, kept_final.committed_infantry + )); + } else { + result.fail(format!( + "control order ended {:?} with delivered {} of {}", + kept_final.status, kept_final.delivered_infantry, kept_final.committed_infantry + )); + } + return Ok(result); + } + unreachable!("the retry loop returns on its final attempt"); +} + +// --------------------------------------------------------------------------- +// S2: Enemy mask vs active fronts +// --------------------------------------------------------------------------- + +pub fn s2_attack_mask(session: &Session) -> Result { + let mut result = ScenarioResult::new( + "S2", + "Attack mask: captures never leave the accepted target footprint; fronts stay on it", + ); + session.monitor.set_mode(Mode::Combat); + + let snapshot = WorldSnapshot::capture(&session.p1.conn)?; + if !snapshot.players_share_front(PLAYER_ONE, PLAYER_TWO) { + result.limit("players never established a shared hostile front within the session budget"); + return Ok(result); + } + let mask: BTreeSet = snapshot + .cells + .values() + .filter(|cell| cell.owner == PLAYER_TWO) + .map(|cell| cell.cell_id) + .collect(); + let p1_before: BTreeSet = snapshot + .cells + .values() + .filter(|cell| cell.owner == PLAYER_ONE) + .map(|cell| cell.cell_id) + .collect(); + let target_seed = *mask.first().context("enemy component is empty")?; + let component = snapshot + .owned_components(PLAYER_ONE) + .into_iter() + .max_by_key(BTreeSet::len) + .context("player one owns no component")?; + let source_seed = *component.first().expect("non-empty component"); + + // Concentrate free infantry onto the shared hostile front so a capture is + // feasible inside the combat budget, then commit the full available share. + let front_sources: Vec = component + .iter() + .copied() + .filter(|&cell| { + snapshot.neighbor_ids(cell).into_iter().any(|neighbor| { + snapshot + .cells + .get(&neighbor) + .is_some_and(|other| other.owner == PLAYER_TWO) + && snapshot.edge_traversable(cell, neighbor) + }) + }) + .collect(); + if let Some(&front_cell) = front_sources.first() { + let donors: Vec = component + .iter() + .copied() + .filter(|cell| !front_sources.contains(cell)) + .filter(|&cell| snapshot.available_infantry(PLAYER_ONE, cell) > 0) + .take(8) + .collect(); + if !donors.is_empty() { + let command_id = session.command_id(PLAYER_ONE)?; + session.p1.issue_reshape( + command_id, + &donors, + &front_sources[..front_sources.len().min(4)], + &[], + session.timeout, + )?; + let reshape_receipt = session.fetch_receipt(PLAYER_ONE, command_id)?; + if reshape_receipt.status == ReceiptStatus::Accepted { + let _ = session.wait_order_settled( + PLAYER_ONE, + reshape_receipt.order_id, + session.step * 160 + session.timeout, + ); + } + } + let _ = front_cell; + } + + let command_id = session.command_id(PLAYER_ONE)?; + session.p1.issue_attack_clusters( + command_id, + &[source_seed], + &[target_seed], + 10_000, + session.timeout, + )?; + let receipt = session.fetch_receipt(PLAYER_ONE, command_id)?; + if receipt.status != ReceiptStatus::Accepted { + result.limit(format!( + "issue_attack_clusters was rejected: {}", + receipt.message + )); + return Ok(result); + } + result.note(format!( + "attack accepted against the complete enemy cluster: mask of {} cells snapshotted at issue", + mask.len() + )); + + let mut captured: BTreeSet = BTreeSet::new(); + let mut mask_violations = 0_u32; + let mut front_samples = 0_u64; + let mut front_violations = 0_u32; + let mut fragmented_into: usize = 1; + let deadline = Instant::now() + session.step * 480 + session.timeout; + while Instant::now() < deadline { + let now = WorldSnapshot::capture(&session.p1.conn)?; + for cell in now.cells.values() { + if cell.owner == PLAYER_ONE + && !p1_before.contains(&cell.cell_id) + && captured.insert(cell.cell_id) + && !mask.contains(&cell.cell_id) + { + mask_violations += 1; + result.fail(format!( + "cell {} was captured outside the accepted target mask", + cell.cell_id + )); + } + } + for front in session.p1.conn.db.combat_front().iter() { + if front.attacker_player_id != PLAYER_ONE { + continue; + } + front_samples += 1; + let adjacent = now.neighbor_ids(front.from_cell).contains(&front.to_cell); + if !mask.contains(&front.to_cell) || !adjacent { + front_violations += 1; + result.fail(format!( + "combat front {} -> {} is not an edge onto the accepted mask", + front.from_cell, front.to_cell + )); + } + } + let enemy_components = now.owned_components(PLAYER_TWO).len(); + fragmented_into = fragmented_into.max(enemy_components); + let order = session.order(PLAYER_ONE, receipt.order_id)?; + // Once we have an in-mask capture and have sampled fronts, the mask + // claim is proven; no need to wait out the full combat budget. + if !captured.is_empty() + && front_samples > 0 + && mask_violations == 0 + && front_violations == 0 + { + break; + } + if order.status != OrderStatus::Active { + break; + } + thread::sleep(session.poll.max(session.step / 4)); + } + + result.note(format!( + "{} enemy cells captured; {} mask violations; {} attacker front samples with {} off-mask fronts", + captured.len(), + mask_violations, + front_samples, + front_violations + )); + if fragmented_into >= 2 { + result.note(format!( + "the wave split the defender into {fragmented_into} clusters while staying inside the mask" + )); + // Multi-cluster target selection across the fragments. + let now = WorldSnapshot::capture(&session.p1.conn)?; + let fragments = now.owned_components(PLAYER_TWO); + if fragments.len() >= 2 { + let seeds: Vec = fragments + .iter() + .take(2) + .filter_map(|fragment| fragment.first().copied()) + .collect(); + let second_id = session.command_id(PLAYER_ONE)?; + session.p1.issue_attack_clusters( + second_id, + &[source_seed], + &seeds, + 5_000, + session.timeout, + )?; + let second = session.fetch_receipt(PLAYER_ONE, second_id)?; + if second.status == ReceiptStatus::Accepted { + result.note(format!( + "multi-cluster attack accepted with target seeds {:?} spanning two enemy fragments", + seeds + )); + let cancel_id = session.command_id(PLAYER_ONE)?; + session + .p1 + .cancel_orders(cancel_id, &[second.order_id], session.timeout)?; + session.accepted_receipt(PLAYER_ONE, cancel_id)?; + } else { + result.note(format!( + "multi-fragment follow-up attack was rejected: {}", + second.message + )); + } + } + } else { + result.note( + "session note: the defender remained a single cluster within budget; mask/front \ + invariants were verified on that single-cluster target", + ); + } + if captured.is_empty() { + result.limit("no capture completed within the combat budget; the mask claim is untested"); + } else if mask_violations == 0 && front_violations == 0 { + result.note(format!( + "mask containment proven: {} capture(s) all inside the accepted footprint with zero off-mask fronts", + captured.len() + )); + } + + let leftovers = session.active_order_ids(PLAYER_ONE); + if !leftovers.is_empty() { + let cancel_id = session.command_id(PLAYER_ONE)?; + session + .p1 + .cancel_orders(cancel_id, &leftovers, session.timeout)?; + session.accepted_receipt(PLAYER_ONE, cancel_id)?; + } + Ok(result) +} + +// --------------------------------------------------------------------------- +// Strict idle window (baseline conservation evidence) +// --------------------------------------------------------------------------- + +pub fn strict_idle_window(session: &Session, steps: u64) -> Result<()> { + session.monitor.set_mode(Mode::Strict); + session.wait_steps(steps) +} + +pub fn map_summary(session: &Session) -> Result<(String, u64, u32)> { + let config = WorldSnapshot::capture(&session.p1.conn)?.config; + Ok(( + format!("{:?}", config.map_preset), + config.map_seed, + config.logical_step_ms, + )) +} diff --git a/tools/match-playtest/src/world.rs b/tools/match-playtest/src/world.rs new file mode 100644 index 0000000..e5a6920 --- /dev/null +++ b/tools/match-playtest/src/world.rs @@ -0,0 +1,494 @@ +//! Read-side world geometry over one client-cache snapshot. +//! +//! Every scenario decision (candidate selection, expected Share accounting, +//! strategic front derivation) works on an immutable [`WorldSnapshot`] taken +//! from the SDK cache, mirroring the authoritative rules in +//! `modules/match/src/rules.rs` for traversability and availability. + +use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; + +use anyhow::{Context, Result}; +use hex_core::{ + Axial, StrategicExterior, UNIFORM_ALLOCATION_WEIGHT, redistribution_targets_dense_with_weights, + strategic_front_index_for_seed, strategic_fronts, +}; +use match_bindings::{ + CellStateTableAccess, CellTerrainTableAccess, DbConnection, MatchConfig, + MatchConfigTableAccess, TerrainClass, TransitPacketTableAccess, +}; +use spacetimedb_sdk::Table; + +pub const SINGLETON_ID: u8 = 0; +pub const NEUTRAL_PLAYER: u16 = 0; + +/// One cell merged from the public terrain and state tables. +#[derive(Clone, Debug)] +pub struct WorldCell { + pub cell_id: u32, + pub coordinate: Axial, + pub terrain: TerrainClass, + pub elevation: i16, + pub passable: bool, + pub capturable: bool, + pub owner: u16, + pub infantry: u64, + pub military_capacity: u64, +} + +pub struct WorldSnapshot { + pub config: MatchConfig, + pub cells: HashMap, + pub by_coordinate: HashMap, + /// Live transit-packet strength per (owner, cell), i.e. allocated infantry + /// that is unavailable to a later Share commitment. + pub allocated: HashMap<(u16, u32), u64>, +} + +impl WorldSnapshot { + pub fn capture(conn: &DbConnection) -> Result { + let config = conn + .db + .match_config() + .singleton_id() + .find(&SINGLETON_ID) + .context("match config is missing from the client cache")?; + let mut cells = HashMap::new(); + let mut by_coordinate = HashMap::new(); + let states: HashMap = conn + .db + .cell_state() + .iter() + .map(|state| (state.cell_id, state)) + .collect(); + for terrain in conn.db.cell_terrain().iter() { + let Some(state) = states.get(&terrain.cell_id) else { + continue; + }; + let coordinate = Axial::new(terrain.q, terrain.r); + by_coordinate.insert(coordinate, terrain.cell_id); + cells.insert( + terrain.cell_id, + WorldCell { + cell_id: terrain.cell_id, + coordinate, + terrain: terrain.terrain, + elevation: terrain.elevation, + passable: terrain.passable, + capturable: terrain.capturable, + owner: state.owner_player_id, + infantry: state.infantry, + military_capacity: state.military_capacity, + }, + ); + } + let mut allocated = HashMap::new(); + for packet in conn.db.transit_packet().iter() { + *allocated + .entry((packet.owner_player_id, packet.current_cell)) + .or_insert(0) += packet.infantry; + } + Ok(Self { + config, + cells, + by_coordinate, + allocated, + }) + } + + pub fn cell(&self, cell_id: u32) -> Result<&WorldCell> { + self.cells + .get(&cell_id) + .with_context(|| format!("cell {cell_id} is missing from the snapshot")) + } + + pub fn neighbor_ids(&self, cell_id: u32) -> Vec { + let Some(cell) = self.cells.get(&cell_id) else { + return Vec::new(); + }; + let mut ids: Vec = cell + .coordinate + .neighbors() + .into_iter() + .filter_map(|coordinate| self.by_coordinate.get(&coordinate).copied()) + .collect(); + ids.sort_unstable(); + ids + } + + /// Mirrors the authoritative ground-traversal rule: both endpoints + /// passable and the elevation step within the configured limit. + pub fn edge_traversable(&self, from: u32, to: u32) -> bool { + let (Some(a), Some(b)) = (self.cells.get(&from), self.cells.get(&to)) else { + return false; + }; + a.passable + && b.passable + && a.elevation.abs_diff(b.elevation) <= u16::from(self.config.max_elevation_step) + } + + /// Free (unallocated) infantry available to a new Share commitment. + pub fn available_infantry(&self, player: u16, cell_id: u32) -> u64 { + let Some(cell) = self.cells.get(&cell_id) else { + return 0; + }; + cell.infantry + .saturating_sub(self.allocated.get(&(player, cell_id)).copied().unwrap_or(0)) + } + + /// Complete owned traversable components, ordered by their lowest cell ID. + pub fn owned_components(&self, player: u16) -> Vec> { + let mut unvisited: BTreeSet = self + .cells + .values() + .filter(|cell| cell.owner == player && cell.passable) + .map(|cell| cell.cell_id) + .collect(); + let mut components = Vec::new(); + while let Some(seed) = unvisited.pop_first() { + let mut component = BTreeSet::from([seed]); + let mut pending = VecDeque::from([seed]); + while let Some(current) = pending.pop_front() { + for neighbor in self.neighbor_ids(current) { + if unvisited.contains(&neighbor) && self.edge_traversable(current, neighbor) { + unvisited.remove(&neighbor); + component.insert(neighbor); + pending.push_back(neighbor); + } + } + } + components.push(component); + } + components.sort_by_key(|component| component.first().copied().unwrap_or(u32::MAX)); + components + } + + /// Directed eligible neutral perimeter edges of a component: owned source + /// cell to unclaimed passable capturable traversable neighbor. + pub fn neutral_perimeter_edges(&self, component: &BTreeSet) -> Vec<(u32, u32)> { + let mut edges = Vec::new(); + for &source in component { + for target in self.neighbor_ids(source) { + if component.contains(&target) { + continue; + } + let Some(cell) = self.cells.get(&target) else { + continue; + }; + if cell.owner == NEUTRAL_PLAYER + && cell.passable + && cell.capturable + && self.edge_traversable(source, target) + { + edges.push((source, target)); + } + } + } + edges.sort_unstable(); + edges.dedup(); + edges + } + + /// Whether the two players currently share a traversable hostile front. + pub fn players_share_front(&self, player: u16, enemy: u16) -> bool { + self.cells.values().any(|cell| { + cell.owner == player + && self.neighbor_ids(cell.cell_id).into_iter().any(|neighbor| { + self.cells + .get(&neighbor) + .is_some_and(|other| other.owner == enemy) + && self.edge_traversable(cell.cell_id, neighbor) + }) + }) + } + + /// Nearest neutral cell (BFS through neutral traversable ground from the + /// component perimeter) that touches enemy territory. Returns + /// `(distance, focus_cell)`. + pub fn nearest_neutral_focus_toward_enemy( + &self, + component: &BTreeSet, + enemy: u16, + ) -> Option<(u32, u32)> { + let mut reached = BTreeSet::new(); + let mut pending = VecDeque::new(); + for (_, target) in self.neutral_perimeter_edges(component) { + if reached.insert(target) { + pending.push_back((target, 1_u32)); + } + } + while let Some((current, distance)) = pending.pop_front() { + let neighbors = self.neighbor_ids(current); + if neighbors.iter().any(|&neighbor| { + self.cells + .get(&neighbor) + .is_some_and(|cell| cell.owner == enemy) + && self.edge_traversable(current, neighbor) + }) { + return Some((distance, current)); + } + for neighbor in neighbors { + let Some(cell) = self.cells.get(&neighbor) else { + continue; + }; + if cell.owner == NEUTRAL_PLAYER + && cell.passable + && cell.capturable + && self.edge_traversable(current, neighbor) + && reached.insert(neighbor) + { + pending.push_back((neighbor, distance.saturating_add(1))); + } + } + } + None + } +} + +pub fn basis_point_share(value: u64, basis_points: u32) -> u64 { + u64::try_from(u128::from(value) * u128::from(basis_points) / 10_000) + .expect("basis-point share cannot exceed the input value") +} + +/// Expected Share-once commitments for a set of participating source cells. +pub fn expected_shares( + snapshot: &WorldSnapshot, + player: u16, + sources: &BTreeSet, + commitment_bps: u32, +) -> BTreeMap { + sources + .iter() + .map(|&cell_id| { + ( + cell_id, + basis_point_share(snapshot.available_infantry(player, cell_id), commitment_bps), + ) + }) + .collect() +} + +/// One planned front-rebalance payload plus the expectation baseline used by +/// the scenario asserts. The derivation mirrors `plan_front_rebalance` in +/// `modules/match/src/orders.rs` and the front-seed selection proven in +/// `tools/match-perf`. +#[derive(Clone, Debug)] +pub struct FrontRebalancePlan { + pub source_front_seed: u32, + pub target_front_seed: u32, + pub source_front_cells: BTreeSet, + pub target_front_cells: BTreeSet, + pub front_count: usize, +} + +pub fn plan_front_rebalance( + snapshot: &WorldSnapshot, + player: u16, + component: &BTreeSet, +) -> Result { + let component_coordinates: BTreeSet = component + .iter() + .filter_map(|cell_id| snapshot.cells.get(cell_id).map(|cell| cell.coordinate)) + .collect(); + anyhow::ensure!( + component_coordinates.len() == component.len(), + "front rebalance component has cells without terrain" + ); + let classify = |source: Axial, target: Axial| -> StrategicExterior { + let Some(&source_id) = snapshot.by_coordinate.get(&source) else { + return StrategicExterior::Ignored; + }; + let Some(&target_id) = snapshot.by_coordinate.get(&target) else { + return StrategicExterior::Ignored; + }; + let Some(target_cell) = snapshot.cells.get(&target_id) else { + return StrategicExterior::Ignored; + }; + if !target_cell.passable + || !target_cell.capturable + || !snapshot.edge_traversable(source_id, target_id) + || target_cell.owner == player + { + return StrategicExterior::Ignored; + } + if target_cell.owner == NEUTRAL_PLAYER { + StrategicExterior::Neutral + } else { + StrategicExterior::Opponent(u32::from(target_cell.owner)) + } + }; + let fronts = strategic_fronts(component_coordinates.iter().copied(), |source, target| { + classify(source, target) + }) + .map_err(|error| anyhow::anyhow!("component has no strategic boundary: {error:?}"))?; + anyhow::ensure!( + fronts.len() >= 2, + "component exposes only {} strategic front(s)", + fronts.len() + ); + + let mut resolvable = Vec::new(); + for index in 0..fronts.len() { + let mut seed_candidates: Vec<(u32, Axial)> = fronts[index] + .source_cells() + .into_iter() + .filter_map(|coordinate| { + snapshot + .by_coordinate + .get(&coordinate) + .map(|&cell_id| (cell_id, coordinate)) + }) + .collect(); + seed_candidates.sort_unstable_by_key(|(cell_id, _)| *cell_id); + for (cell_id, coordinate) in seed_candidates { + if strategic_front_index_for_seed(&fronts, coordinate) == Some(index) { + resolvable.push((index, cell_id)); + break; + } + } + } + anyhow::ensure!( + resolvable.len() >= 2, + "could not resolve two distinct strategic front seeds" + ); + + let front_cell_ids = |index: usize| -> BTreeSet { + fronts[index] + .source_cells() + .into_iter() + .filter_map(|coordinate| snapshot.by_coordinate.get(&coordinate).copied()) + .collect() + }; + let mut candidates: Vec<(u32, FrontRebalancePlan)> = Vec::new(); + for &(source_index, source_seed) in &resolvable { + let source_cells = front_cell_ids(source_index); + for &(target_index, target_seed) in &resolvable { + if source_index == target_index || source_seed == target_seed { + continue; + } + let target_cells = front_cell_ids(target_index); + let movable_sources: BTreeSet = source_cells + .difference(&target_cells) + .copied() + .filter(|&cell_id| snapshot.available_infantry(player, cell_id) > 0) + .collect(); + let target_headroom = target_cells.iter().any(|&cell_id| { + snapshot + .cells + .get(&cell_id) + .is_some_and(|cell| cell.infantry < cell.military_capacity) + }); + if !movable_sources.is_empty() && target_headroom { + // Prefer the longest source→target hop so client polling can + // observe route-index progression instead of an instant settle. + let mut route_score = 0_u32; + for &from in &movable_sources { + for &to in &target_cells { + if let Some(distance) = + component_bfs_distance(snapshot, component, from, to) + { + route_score = route_score.max(distance); + } + } + } + candidates.push(( + route_score, + FrontRebalancePlan { + source_front_seed: source_seed, + target_front_seed: target_seed, + source_front_cells: source_cells + .difference(&target_cells) + .copied() + .collect(), + target_front_cells: target_cells, + front_count: fronts.len(), + }, + )); + } + } + } + candidates.sort_by_key(|(score, _)| std::cmp::Reverse(*score)); + candidates + .into_iter() + .next() + .map(|(_, plan)| plan) + .ok_or_else(|| { + anyhow::anyhow!("no strategic front pair has movable troops and destination headroom") + }) +} + +fn component_bfs_distance( + snapshot: &WorldSnapshot, + component: &BTreeSet, + from: u32, + to: u32, +) -> Option { + let mut reached: BTreeMap = BTreeMap::from([(from, 0)]); + let mut pending = VecDeque::from([from]); + while let Some(current) = pending.pop_front() { + let distance = reached[¤t]; + if current == to { + return Some(distance); + } + for neighbor in snapshot.neighbor_ids(current) { + if component.contains(&neighbor) + && snapshot.edge_traversable(current, neighbor) + && !reached.contains_key(&neighbor) + { + reached.insert(neighbor, distance.saturating_add(1)); + pending.push_back(neighbor); + } + } + } + None +} + +/// Mirrors capped Share-once source commitments for [`issue_front_rebalance`]. +/// Returns `(per-source commits, headroom_capped)`. +pub fn expected_front_rebalance_commits( + snapshot: &WorldSnapshot, + player: u16, + plan: &FrontRebalancePlan, + commitment_bps: u32, +) -> Result<(BTreeMap, bool)> { + let mut source_limits = BTreeMap::new(); + let mut total_supply = 0_u64; + for &cell_id in &plan.source_front_cells { + let share = basis_point_share(snapshot.available_infantry(player, cell_id), commitment_bps); + if share > 0 { + source_limits.insert(cell_id, share); + total_supply = total_supply.saturating_add(share); + } + } + let mut total_headroom = 0_u64; + for &cell_id in &plan.target_front_cells { + let cell = snapshot + .cell(cell_id) + .with_context(|| format!("target front cell {cell_id} is missing"))?; + total_headroom = + total_headroom.saturating_add(cell.military_capacity.saturating_sub(cell.infantry)); + } + let deliverable = total_supply.min(total_headroom); + let capped = deliverable < total_supply; + if capped && deliverable > 0 { + let entries: Vec<(u32, u64)> = source_limits.into_iter().collect(); + let coordinates: Vec = entries + .iter() + .map(|(cell_id, _)| snapshot.cell(*cell_id).map(|cell| cell.coordinate)) + .collect::>()?; + let capacities: Vec = entries.iter().map(|(_, limit)| *limit).collect(); + let distribution = redistribution_targets_dense_with_weights( + &coordinates, + &capacities, + deliverable, + vec![UNIFORM_ALLOCATION_WEIGHT; entries.len()], + ) + .map_err(|error| anyhow::anyhow!("front rebalance source cap mirror failed: {error:?}"))?; + source_limits = entries + .into_iter() + .zip(distribution.targets) + .filter(|(_, amount)| *amount > 0) + .map(|((cell_id, _), amount)| (cell_id, amount)) + .collect(); + } + Ok((source_limits, capped)) +}