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); + } }