From a6be53e9d16778272daa9c5c79d5b5e70bdcddf5 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 20 Aug 2026 22:02:17 -0400 Subject: [PATCH] fix(candidates)!: an unmoved enemy is its reachable set, not its hex Every enemy was built as one Presence, so M = 1 always and the power mean, the risk exponent and the paired joint outcome all collapsed to a point. M is now the enemy's own reachable set when Entity.isDone() is false, from its own start and its own MP, and its placed hex when true. The sweep logs sum(M), the unmoved count and the LOS cache's hit rate beside it. --- crates/sds-bot/src/candidates.rs | 80 +++++++++++++++++++++++++++----- crates/sds-bot/src/unit.rs | 77 ++++++++++++++++++++++++++++-- 2 files changed, 143 insertions(+), 14 deletions(-) diff --git a/crates/sds-bot/src/candidates.rs b/crates/sds-bot/src/candidates.rs index 22a8588..3d77ee7 100644 --- a/crates/sds-bot/src/candidates.rs +++ b/crates/sds-bot/src/candidates.rs @@ -16,20 +16,32 @@ //! it can only be asked if both sets are in one decision. [`is_generated`] is //! how a chosen label is attributed, and `main.rs` keeps the tally. //! -//! Two things are deliberately cheaper here than in the offline renders: +//! **An enemy's `M` is decided by whether it has moved.** MegaMek alternates +//! movement by initiative, so when our unit moves some enemies have taken their +//! turn and some have not. `wire::Unit::done` is MegaMek's own answer - the +//! server clears it in `TWGameManager.resetEntityPhase` at the top of every +//! phase and sets it in `endCurrentTurn` - so an enemy with `done` is a point +//! and an enemy without it is its own reachable set, exactly as the offline +//! scenes build one. That is what the power mean over `M` and the risk exponent +//! were written to operate on; a point per enemy made both inert. //! -//! - **Enemies are where they are**, one [`Presence`] each, not their own -//! reachable sets. `M = 1` per enemy rather than a few hundred, which is the -//! difference between a sweep that fits a turn and one that does not. It -//! under-models a foe that has not moved yet, and that is a known cost stated -//! rather than hidden. -//! - **Walk MP, not run MP.** The state count is what the time is linear in. +//! Under double blind we only see what MegaMek lets us see, and an enemy we +//! cannot see is not in the observation at all. An enemy we can see carries the +//! `done` the server last sent us, which can be stale if it moved out of sight; +//! stale reads as "not yet moved", which widens `M` rather than narrowing it. +//! Erring wide is the safe direction. +//! +//! One thing is still deliberately cheaper than the offline renders: +//! +//! - **Walk MP, not run MP**, for us and for them. The state count is what the +//! time is linear in. use std::collections::BTreeSet; +use sds_core::facts::Gait; use sds_core::hex::Stand; use sds_core::los::LosCache; -use sds_core::pathfind::{search, MoveBoard, Search, Walker}; +use sds_core::pathfind::{reachable, search, MoveBoard, Search, Walker}; use sds_core::plan::Proposal; use sds_core::stance::Stance; use sds_core::stands::{self, Combatant, Foe, Mover, Params, Presence}; @@ -75,6 +87,12 @@ pub struct Cost { pub states: u64, pub exchanges: u64, pub surfaced: u64, + /// Enemy positions summed over the force: `sum(M)`. The sweep is + /// `states x this`, so it is the number the time is linear in. + pub positions: u64, + /// How many enemies had not taken their turn when this ran. These are the + /// ones contributing more than one position. + pub unmoved: u64, } /// The generated half of a unit's menu. @@ -127,8 +145,8 @@ pub fn generate( jumped: false, terrain: Some(&move_board), }; - // One position each. See the module note: this is the cheap half of the - // trade, and it is the half that makes the sweep fit inside a turn. + // `M` per enemy: a point for one that has taken its turn, its own reachable + // set for one that has not. See the module note for what `done` is. let foes: Vec> = enemies .iter() .map(|unit| Foe { @@ -141,9 +159,11 @@ pub fn generate( .collect(), unit.gunnery.max(0), ), - may_be: vec![Presence::placed(unit)], + may_be: may_be(observation, &move_board, unit), }) .collect(); + let unmoved = enemies.iter().filter(|unit| !unit.done).count() as u64; + let positions: u64 = foes.iter().map(|foe| foe.may_be.len() as u64).sum(); let mut cache = VolleyCache::new(); let sweep = stands::score_stands(&mut cache, los, &mover, &found.reached, &foes); @@ -206,10 +226,48 @@ pub fn generate( states: sweep.stats.stands, exchanges: sweep.stats.exchanges, surfaced: out.len() as u64, + positions, + unmoved, }; Some((out, cost)) } +/// Where one enemy could be when we arrive. +/// +/// A single [`Presence`] where the wire puts it once it has taken its turn, and +/// its own reachable set before it has. The set is built from *its* start, *its* +/// walk MP and *its* `maxElevationChange`, blocked by every other unit on the +/// board - both sides, the same rule the offline scenes use. +/// +/// An empty search falls back to the placed position rather than to no +/// positions at all: an enemy that cannot move is still standing somewhere, and +/// an empty `may_be` would drop it out of the scoring silently. +fn may_be(observation: &Observation, board: &MoveBoard, unit: &Unit) -> Vec { + if unit.done { + return vec![Presence::placed(unit)]; + } + let blocked: Vec = observation + .units + .iter() + .filter(|other| other.id != unit.id && !other.destroyed) + .map(|other| Coord::new(other.x, other.y)) + .collect(); + let reached = reachable(board, &Walker::walking(unit), &blocked); + if reached.is_empty() { + return vec![Presence::placed(unit)]; + } + reached + .iter() + .map(|reach| Presence { + stand: reach.stand, + elevation: unit.elevation, + hexes_moved: reach.hexes_moved, + jumped: false, + gait: Gait::of(reach.mp_spent, unit.walk_mp, false), + }) + .collect() +} + /// The pathfinder's route, as the step list the host plays. /// /// The route is a list of `(hex, facing)` states one edge apart, which is diff --git a/crates/sds-bot/src/unit.rs b/crates/sds-bot/src/unit.rs index 2ecc3cf..e26acbf 100644 --- a/crates/sds-bot/src/unit.rs +++ b/crates/sds-bot/src/unit.rs @@ -536,11 +536,28 @@ pub fn propose_including( // The flag is read here and nowhere else on this path. With it off not one // line of `candidates` runs and this function is what it was. if candidates::enabled() { + // The LOS cache's own counters, over this unit's sweep alone. Sweeping + // `M` asks about hexes the cache has very likely already seen while + // scoring our other stands, so the hit rate should hold up as `M` grows + // rather than collapse - and this is how that is checked rather than + // assumed. + let (hits_before, misses_before) = los.counters(); if let Some((generated, cost)) = candidates::generate(observation, board, los, &me, stance) { + let (hits, misses) = los.counters(); + let (hits, misses) = (hits - hits_before, misses - misses_before); + let asked = hits + misses; eprintln!( - "[sds-bot] unit {}: {} states, {} exchanges, {} surfaced", - me.id, cost.states, cost.exchanges, cost.surfaced + "[sds-bot] unit {}: {} states, {} enemy positions ({} unmoved), \ + {} exchanges, {} surfaced, {} los asked ({}% hit)", + me.id, + cost.states, + cost.positions, + cost.unmoved, + cost.exchanges, + cost.surfaced, + asked, + (hits * 100).checked_div(asked).unwrap_or(0) ); proposals.extend(generated); } @@ -774,7 +791,11 @@ mod tests { player_id: 1, team: 1, actor: Some(1), - units: vec![unit(1, true, 3, 9), unit(11, false, 6, 3)], + units: vec![ + unit(1, true, 3, 9), + unit(11, false, 6, 3), + unit(12, false, 8, 3), + ], shots: Vec::new(), deploy_hexes: Vec::new(), }; @@ -880,4 +901,54 @@ mod tests { assert!(proposals.len() <= candidates::MAX_GENERATED); assert!(cost.states > proposals.len() as u64, "{cost:?}"); } + + /// `M` is the enemy's own reachable set until it has taken its turn, and a + /// single position afterwards, and it collapses one enemy at a time as the + /// movement phase runs. Everything about the uncertainty model - the power + /// mean over `M`, the risk exponent, the paired joint outcome - operates on + /// that set, so a point per enemy makes the lot inert. + /// + /// This is the collapse as data: two enemies, neither moved, then one, then + /// both, with `M` measured at each step. Our first mover faces both + /// envelopes and our last faces neither, which is the asymmetry the design + /// is for. + #[test] + fn an_enemy_envelope_collapses_to_a_point_when_it_moves() { + let (mut observation, board) = scene(); + let los = LosCache::new(&board, Rules::default()); + let me = observation.units[0].clone(); + let sweep = |observation: &Observation| { + candidates::generate(observation, &board, &los, &me, &Stance::default()) + .expect("a unit with somewhere to go generates") + .1 + }; + + let both = sweep(&observation); + assert_eq!(both.unmoved, 2, "{both:?}"); + assert!( + both.positions > 2, + "two enemies that have not moved are two points: {both:?}" + ); + + observation.units[1].done = true; + let one = sweep(&observation); + assert_eq!(one.unmoved, 1, "{one:?}"); + assert!(one.positions < both.positions, "{one:?} vs {both:?}"); + assert!(one.positions > 1, "{one:?}"); + + observation.units[2].done = true; + let none = sweep(&observation); + assert_eq!(none.unmoved, 0, "{none:?}"); + // One position each, which is what a placed enemy is. + assert_eq!(none.positions, 2, "{none:?}"); + + // Our stands never changed; only the enemy half of the sweep did. The + // exchange count is `states x positions`, so it tracks the collapse. + assert_eq!(both.states, none.states); + assert_eq!( + both.exchanges / both.positions, + none.exchanges / none.positions + ); + assert!(both.exchanges > one.exchanges && one.exchanges > none.exchanges); + } } -- 2.51.2