diff --git a/crates/sds-core/examples/common/mod.rs b/crates/sds-core/examples/common/mod.rs index cf17105..23bc99a 100644 --- a/crates/sds-core/examples/common/mod.rs +++ b/crates/sds-core/examples/common/mod.rs @@ -1,11 +1,18 @@ -//! The scene both renders read: one board, us, and three enemies who have not -//! moved. +//! The scene both renders read: one board, a force of ours and a force of +//! theirs, none of whom have moved. //! //! Shared so that `stands.rs` and `heatmap.rs` are two pictures of the same //! numbers rather than two fixtures that drift apart. Everything here is a //! fixture choice - who is on the board, how far apart, carrying what - and //! none of it is a model change. //! +//! **Both sides are built by the same call.** [`Side::muster`] takes an +//! [`Allegiance`] and nothing else varies: same unit, same guns, same search, +//! same blocking rule. A fixture where "us" was assembled by one code path and +//! "them" by another is a fixture that can grow an asymmetry nobody chose, and +//! the force layer this scene exists for is exactly the thing such an asymmetry +//! would hide. +//! //! The boards are the real ones from `tests/corpus/pathfind.jsonl`, dumped from //! MegaMek: `Map Set 5/16x17 Open Terrain 1`, `Map Set 4/16x17 Heavy Forest 1` //! and `Map Set 2/16x17 Lake Area`. @@ -20,9 +27,7 @@ use sds_core::arc::MekLocation; use sds_core::facts::Gait; use sds_core::hex::Stand; use sds_core::los::{LosCache, Rules}; -use sds_core::pathfind::{ - reachable, search, MoveBoard, Reach, Search, Walker, MEK_MAX_ELEVATION_CHANGE, -}; +use sds_core::pathfind::{search, MoveBoard, Reach, Search, Walker, MEK_MAX_ELEVATION_CHANGE}; use sds_core::stands::{Combatant, Foe, Mover, Presence}; use sds_core::volley::MountedWeapon; use sds_core::wire::{Board, BoardHex, Coord, Unit, Weapon}; @@ -33,18 +38,26 @@ pub const OUR_MP: i32 = 6; pub const THEIR_MP: i32 = 4; pub const TOP_K: usize = 8; -/// Where everybody starts. Us in the south, three of them in the north. -pub const START: Coord = Coord::new(4, 13); +/// Where our force starts. Two of them, in the south. +/// +/// **Two, not one.** A single unit cannot show anything the force layer does: +/// the case worth seeing - two of ours converging on one of theirs - needs two +/// movers whose reachable sets overlap, and it cannot occur in a fixture with +/// one. Three hexes apart, so the sets overlap over the middle of the board +/// without the two units starting on top of each other. +pub const OURS: [Coord; 2] = [Coord::new(4, 13), Coord::new(7, 13)]; +/// Where their force starts, in the north. +/// /// Close enough that most of our reachable set is inside a weapon bracket, and /// north of the woods belt at `x = 8..11, y = 7..11` so that belt lies between /// the two sides. Further apart and the picture is about distance rather than /// about the fight. /// -/// Three of them, not one: against a single enemy, offence-takes-best and +/// Two of them, not one: against a single enemy, offence-takes-best and /// defence-takes-sum are the same number, and the case worth seeing - good -/// against one, exposed to three - cannot occur. -pub const ENEMIES: [Coord; 3] = [Coord::new(5, 6), Coord::new(9, 6), Coord::new(12, 7)]; +/// against one, exposed to another - cannot occur. +pub const THEIRS: [Coord; 2] = [Coord::new(5, 6), Coord::new(9, 6)]; /// The three exponents, worst case to average. pub const EXPONENTS: [(&str, f32); 3] = [ @@ -56,6 +69,69 @@ pub const EXPONENTS: [(&str, f32); 3] = [ /// The three corpus boards, in the order a page shows them. pub const BOARDS: [&str; 3] = ["open", "forest", "water"]; +/// Which force a unit belongs to. +/// +/// Every way the two sides differ is a method on this, so the list of +/// differences is one screen long and nothing else may add to it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Allegiance { + Ours, + Theirs, +} + +impl Allegiance { + pub fn starts(self) -> &'static [Coord] { + match self { + Self::Ours => &OURS, + Self::Theirs => &THEIRS, + } + } + + /// The movement allowance the side's reachable sets are drawn on. + pub fn mp(self) -> i32 { + match self { + Self::Ours => OUR_MP, + Self::Theirs => THEIR_MP, + } + } + + /// Facing each other down the board. + pub fn facing(self) -> i32 { + match self { + Self::Ours => 0, + Self::Theirs => 3, + } + } + + /// Unit ids, so a number in a table says which side it is on. + fn first_id(self) -> i32 { + match self { + Self::Ours => 1, + Self::Theirs => 11, + } + } + + fn team(self) -> i32 { + match self { + Self::Ours => 1, + Self::Theirs => 2, + } + } + + fn friendly(self) -> bool { + matches!(self, Self::Ours) + } + + /// `U1`, `E2`: the label the renders put on a unit. + pub fn label(self, index: usize) -> String { + let letter = match self { + Self::Ours => 'U', + Self::Theirs => 'E', + }; + format!("{letter}{}", index + 1) + } +} + /// Pull one named board out of the movement corpus. pub fn board(name: &str) -> Board { for line in CORPUS.lines() { @@ -76,11 +152,17 @@ pub fn board(name: &str) -> Board { /// A middleweight Mek, whole. Through the wire's own deserialiser, so the /// example cannot describe a unit the bot could not be handed. -pub fn mek(id: i32, at: Coord, facing: i32) -> Unit { +/// +/// The chassis is the same on both sides. Only `team`, `friendly` and the id +/// come from the [`Allegiance`], so a difference in the numbers is a difference +/// in position and never a difference in what was mustered. +pub fn mek(id: i32, at: Coord, facing: i32, side: Allegiance) -> Unit { + let team = side.team(); + let friendly = side.friendly(); let mut unit: Unit = serde_json::from_str(&format!( r#"{{ - "id": {id}, "name": "Mek {id}", "ownerId": 2, "team": 2, - "friendly": false, "x": {}, "y": {}, "facing": {facing}, + "id": {id}, "name": "Mek {id}", "ownerId": {team}, "team": {team}, + "friendly": {friendly}, "x": {}, "y": {}, "facing": {facing}, "weight": 55.0, "walkMp": 4, "runMp": 6, "armor": 100, "armorMax": 100, "internal": 50, "internalMax": 50, "heat": 0, "heatCapacity": 10, "gunnery": 4, "piloting": 5, @@ -181,7 +263,108 @@ pub fn loadout() -> Vec { .collect() } -/// One board with both sides on it, everything a scorer needs already built. +/// One force: its units, and one movement search per unit. +/// +/// The search is per unit and not per side. Two units start in different hexes, +/// so they reach different sets, and a scene that shared one set between them +/// would be a scene in which the force layer had nothing to reconcile. +pub struct Side { + pub allegiance: Allegiance, + pub units: Vec, + /// One per unit, in the same order as [`Self::units`]. + pub searches: Vec, +} + +impl Side { + /// Muster a force. The only code path either side is built by. + /// + /// Every other unit on the board blocks - the rest of this force and all of + /// the opposition - so no unit's set includes a hex somebody is standing + /// in. The rule is the same for both sides. + pub fn muster(board: &MoveBoard, allegiance: Allegiance, opposing: &[Coord]) -> Self { + let starts = allegiance.starts(); + let units: Vec = starts + .iter() + .enumerate() + .map(|(index, at)| { + mek( + allegiance.first_id() + index as i32, + *at, + allegiance.facing(), + allegiance, + ) + }) + .collect(); + let searches = starts + .iter() + .map(|at| { + let mut blocked: Vec = starts + .iter() + .filter(|other| *other != at) + .copied() + .collect(); + blocked.extend_from_slice(opposing); + search( + board, + &Walker { + start: Stand::new(*at, allegiance.facing()), + mp: allegiance.mp(), + max_elevation_change: MEK_MAX_ELEVATION_CHANGE, + prone: false, + }, + &blocked, + ) + }) + .collect(); + Self { + allegiance, + units, + searches, + } + } + + pub fn len(&self) -> usize { + self.units.len() + } + + pub fn is_empty(&self) -> bool { + self.units.is_empty() + } + + /// Every end state one unit can reach, in the order the search gives. + pub fn stands(&self, index: usize) -> &[Reach] { + &self.searches[index].reached + } + + /// One unit's reachable set as the places it could be when somebody else + /// arrives. + pub fn presences(&self, index: usize) -> Vec { + let unit = &self.units[index]; + self.searches[index] + .reached + .iter() + .map(|reach| Presence { + stand: reach.stand, + elevation: 0, + hexes_moved: reach.hexes_moved, + jumped: false, + gait: Gait::of(reach.mp_spent, unit.walk_mp, false), + }) + .collect() + } + + pub fn hexes(&self) -> BTreeSet<(i32, i32)> { + self.units.iter().map(|unit| (unit.x, unit.y)).collect() + } + + /// How many `(stand, position)` pairs this side's whole force generates + /// against one set of enemy positions. + pub fn stand_total(&self) -> usize { + (0..self.len()).map(|index| self.stands(index).len()).sum() + } +} + +/// One board with both forces on it, everything a scorer needs already built. /// /// The units are owned here and the borrowing types are handed out by method, /// because [`Mover`] and [`Foe`] borrow the [`Unit`] they describe. @@ -190,10 +373,8 @@ pub struct Scene { pub message: Board, pub board: MoveBoard, pub los: LosCache, - pub our_unit: Unit, - pub enemy_units: Vec, - /// Every `(hex, facing)` we can reach on `OUR_MP`, and the route to each. - pub search: Search, + pub ours: Side, + pub theirs: Side, } impl Scene { @@ -201,90 +382,98 @@ impl Scene { let message = board(name); let move_board = MoveBoard::new(&message); let los = LosCache::new(&message, Rules::default()); - let our_unit = mek(1, START, 0); - let occupied: Vec = ENEMIES.to_vec(); - let found = search( - &move_board, - &Walker { - start: Stand::new(START, 0), - mp: OUR_MP, - max_elevation_change: MEK_MAX_ELEVATION_CHANGE, - prone: false, - }, - &occupied, - ); - let enemy_units = ENEMIES - .iter() - .enumerate() - .map(|(i, at)| mek(2 + i as i32, *at, 3)) - .collect(); + let ours = Side::muster(&move_board, Allegiance::Ours, &THEIRS); + let theirs = Side::muster(&move_board, Allegiance::Theirs, &OURS); Self { name: name.to_string(), message, board: move_board, los, - our_unit, - enemy_units, - search: found, + ours, + theirs, } } - /// Every end state, in the order the search gives. - pub fn stands(&self) -> &[Reach] { - &self.search.reached + /// How many units of ours the examples iterate over. + pub fn movers(&self) -> usize { + self.ours.len() + } + + /// Every end state one of our units can reach, in the order the search + /// gives. + pub fn stands(&self, us: usize) -> &[Reach] { + self.ours.stands(us) } - pub fn mover(&self) -> Mover<'_> { + /// The search behind [`Self::stands`], for the route to a stand. + pub fn search(&self, us: usize) -> &Search { + &self.ours.searches[us] + } + + pub fn our_unit(&self, us: usize) -> &Unit { + &self.ours.units[us] + } + + pub fn start(&self, us: usize) -> Coord { + OURS[us] + } + + pub fn mover(&self, us: usize) -> Mover<'_> { Mover { - who: Combatant::mek(&self.our_unit, loadout(), 4), + who: Combatant::mek(&self.ours.units[us], loadout(), 4), elevation: 0, jumped: false, terrain: Some(&self.board), } } + /// The rest of our force, as the positional features want it. + /// + /// Where the enemies are is the other half of a position; where our own + /// units are is the half a single-unit fixture could not supply at all. + pub fn friends(&self, us: usize) -> Vec<&Unit> { + self.ours + .units + .iter() + .enumerate() + .filter(|(index, _)| *index != us) + .map(|(_, unit)| unit) + .collect() + } + /// The enemies have not moved, so `M` is each one's own reachable set /// rather than a point. This is what the exponent operates on. pub fn foes(&self) -> Vec> { - self.enemy_units + self.theirs + .units .iter() - .map(|unit| { - let start = Stand::new(Coord::new(unit.x, unit.y), unit.facing); - let mut blocked: Vec = ENEMIES - .iter() - .filter(|at| **at != start.hex) - .copied() - .collect(); - blocked.push(START); - let may_be: Vec = reachable( - &self.board, - &Walker { - start, - mp: THEIR_MP, - max_elevation_change: MEK_MAX_ELEVATION_CHANGE, - prone: false, - }, - &blocked, - ) - .into_iter() - .map(|reach| Presence { - stand: reach.stand, - elevation: 0, - hexes_moved: reach.hexes_moved, - jumped: false, - gait: Gait::of(reach.mp_spent, unit.walk_mp, false), - }) - .collect(); - Foe { - who: Combatant::mek(unit, loadout(), 4), - may_be, - } + .enumerate() + .map(|(index, unit)| Foe { + who: Combatant::mek(unit, loadout(), 4), + may_be: self.theirs.presences(index), }) .collect() } + /// Positions in `M`, summed over the enemy force. + pub fn enemy_positions(&self) -> usize { + (0..self.theirs.len()) + .map(|index| self.theirs.stands(index).len()) + .sum() + } + + /// `(stand, enemy, position)` triples the whole scene produces, over every + /// unit of ours. The size of the sweep, in one number. + pub fn triples(&self) -> usize { + self.ours.stand_total() * self.enemy_positions() + } + + pub fn our_hexes(&self) -> BTreeSet<(i32, i32)> { + self.ours.hexes() + } + pub fn enemy_hexes(&self) -> BTreeSet<(i32, i32)> { - ENEMIES.iter().map(|at| (at.x, at.y)).collect() + self.theirs.hexes() } /// In woods, or with woods next door. diff --git a/crates/sds-core/examples/heatmap.rs b/crates/sds-core/examples/heatmap.rs index e78d652..ec02056 100644 --- a/crates/sds-core/examples/heatmap.rs +++ b/crates/sds-core/examples/heatmap.rs @@ -5,7 +5,11 @@ //! //! cargo run --release -p sds-core --example heatmap -- /tmp/heatmap.html //! -//! Three boards by three power-mean exponents. `plan/candidates.md` asks for +//! Three boards by two units of ours by three power-mean exponents. A sweep is +//! about one mover, so a panel is about one mover: each unit of ours starts +//! somewhere else, reaches somewhere else, and gets its own row of maps. +//! +//! `plan/candidates.md` asks for //! the encoding: **lightness carries the total** - how much is happening at a //! hex, both directions together - and **hue carries the ratio**, our damage out //! against their damage in. An additive blend of two colours would put "high in @@ -16,9 +20,11 @@ //! board - see [`sds_core::heatmap::Ranks`] for why the absolute values cannot //! carry the hue. The absolute values stay in the tooltips. //! -//! Each board gets two rows: the scored hexes with each enemy's reachable set +//! Each view gets two rows: the scored hexes with each enemy's reachable set //! outlined along hexsides, and then the same three heatmaps with the top five walks -//! the defence list would take drawn over them. +//! the defence list would take drawn over them. The other unit of ours is drawn +//! on its start hex in every panel, faintly, so the force is visible even where +//! the numbers are one unit's. //! //! Nothing here is tuned to make the picture look good. If a board comes out //! flat, that is the result, and the page says so. @@ -43,7 +49,7 @@ use sds_core::volley::{Firer, TargetState, VolleyCache, MAX_TO_HIT}; use sds_core::wire::Coord; mod common; -use common::{loadout, loadout_wire, Scene, BOARDS, ENEMIES, OUR_MP, START, THEIR_MP, TOP_K}; +use common::{loadout, loadout_wire, Scene, BOARDS, OURS, OUR_MP, THEIRS, THEIR_MP, TOP_K}; /// Pixels per unit of the board's own geometry, where a hex is 2 units tall. const UNIT: f32 = 15.0; @@ -96,6 +102,24 @@ const REGIMES: [Regime; 3] = [ }, ]; +/// One board, and one of our units standing on it. +/// +/// A sweep is about a mover, so a panel is about a mover. With more than one +/// unit a side, "the open board" is no longer one picture: it is one picture +/// per unit of ours, because each starts somewhere else and reaches somewhere +/// else. +struct View<'a> { + scene: &'a Scene, + us: usize, +} + +impl View<'_> { + /// `open · U1`: what a table row and a section heading are keyed by. + fn label(&self) -> String { + format!("{} \u{b7} U{}", self.scene.name, self.us + 1) + } +} + /// Where a hex sits, in pixels. fn centre(hex: Coord) -> (f32, f32) { let cx = (3 * hex.x + 2) as f32 / 3.0_f32.sqrt(); @@ -305,17 +329,25 @@ fn reach(scene: &Scene, svg: &mut String) { } } -/// Both sides' start hexes, drawn over whatever else is in them. -fn starts(svg: &mut String) { - let (cx, cy) = centre(START); - let _ = write!( - svg, - r##"us"##, - corners(cx, cy, RADIUS * UNIT * INSET), - cx, - cy + 4.0, - ); - for (index, at) in ENEMIES.iter().enumerate() { +/// Both forces' start hexes, drawn over whatever else is in them. +/// +/// Every unit of ours, not only the one this panel is about. A panel that drew +/// one of ours would be a picture of a lone unit again, and the whole point of +/// the scene is that the rest of the force is on the board. +fn starts(us: usize, svg: &mut String) { + for (index, at) in OURS.iter().enumerate() { + let (cx, cy) = centre(*at); + let mine = if index == us { " mine" } else { "" }; + let _ = write!( + svg, + r##"U{}"##, + corners(cx, cy, RADIUS * UNIT * INSET), + cx, + cy + 4.0, + index + 1, + ); + } + for (index, at) in THEIRS.iter().enumerate() { let (cx, cy) = centre(*at); let _ = write!( svg, @@ -331,6 +363,8 @@ fn starts(svg: &mut String) { /// One scored map. struct Panel<'a> { scene: &'a Scene, + /// Which of our units this panel's scores belong to. + us: usize, map: &'a HexMap, /// What the hexes are, beside what they are worth. Read by the tooltips. facts: &'a FactMap, @@ -378,7 +412,7 @@ fn draw_heat(panel: &Panel<'_>, regime: &Regime) -> String { } } - starts(&mut svg); + starts(panel.us, &mut svg); svg.push_str(""); svg } @@ -551,7 +585,7 @@ fn draw_paths(panel: &Panel<'_>, regime: &Regime, routes: &[Route]) -> String { ); } - starts(&mut svg); + starts(panel.us, &mut svg); svg.push_str(""); svg } @@ -659,11 +693,16 @@ fn chip(regime: &Regime) -> String { } /// The top five stands on one of the two lists, with the walk to each. -fn routes_for(scene: &Scene, ranking: &Ranking, list: impl Fn(&Ranking) -> &[usize]) -> Vec { +fn routes_for( + scene: &Scene, + us: usize, + ranking: &Ranking, + list: impl Fn(&Ranking) -> &[usize], +) -> Vec { let mut routes: Vec = Vec::new(); for (rank, at) in list(ranking).iter().take(TOP_PATHS).enumerate() { let score = &ranking.scored[*at]; - let Some(walk) = scene.search.path_to(score.reach.stand) else { + let Some(walk) = scene.search(us).path_to(score.reach.stand) else { continue; }; routes.push(Route { @@ -867,8 +906,8 @@ fn worst_position(exchanges: &TargetExchanges) -> Against { } /// The to-hit for one stand against every enemy, as GATOR rows. -fn gator_table(scene: &Scene, foes: &[Foe<'_>], record: &StandRecord) -> String { - let mover = scene.mover(); +fn gator_table(scene: &Scene, us: usize, foes: &[Foe<'_>], record: &StandRecord) -> String { + let mover = scene.mover(us); let here = Presence { stand: record.reach.stand, elevation: 0, @@ -1007,7 +1046,13 @@ elevation {:+} · {} ways out next turn } /// Every featured stand on a board, broken down. -fn gator_section(scene: &Scene, foes: &[Foe<'_>], sweep: &Sweep, ranking: &Ranking) -> String { +fn gator_section( + scene: &Scene, + us: usize, + foes: &[Foe<'_>], + sweep: &Sweep, + ranking: &Ranking, +) -> String { let mut out = String::from( r##"

The to-hit, by name

The roll a weapon needs is assembled from five named terms, and this is them for the stands below. G, A, T and O are one answer @@ -1021,7 +1066,7 @@ most damage, not at the hex it is standing in now. From its own hex it has not m ); for pick in featured(sweep, ranking) { let _ = write!(out, r##"

{}

"##, escape(&pick.why)); - out.push_str(&gator_table(scene, foes, &sweep.scored[pick.at])); + out.push_str(&gator_table(scene, us, foes, &sweep.scored[pick.at])); } out } @@ -1261,7 +1306,7 @@ colour, and the two agree only at p = 1.

"##, /// /// One map, not three: how a hex was reached is a property of the walk and does /// not move with the exponent. -fn draw_gaits(scene: &Scene, facts: &FactMap) -> String { +fn draw_gaits(scene: &Scene, us: usize, facts: &FactMap) -> String { let (width, height) = extent(scene); let mut svg = String::new(); let _ = write!( @@ -1286,13 +1331,13 @@ fn draw_gaits(scene: &Scene, facts: &FactMap) -> String { ); } terrain(scene, &mut svg); - starts(&mut svg); + starts(us, &mut svg); svg.push_str(""); svg } /// The gait map and what the run costs, counted. -fn gait_section(scene: &Scene, sweep: &Sweep, facts: &FactMap) -> String { +fn gait_section(scene: &Scene, us: usize, sweep: &Sweep, facts: &FactMap) -> String { let counts = facts.by_gait(); let mut stands = [0usize; 4]; for record in &sweep.scored { @@ -1339,7 +1384,7 @@ That is why the two columns do not add up the same way.

The boundary is movement points against the walk allowance and nothing else. A pure turn spends MP and moves no hexes, and MegaMek calls that a walk; the corpus at tests/corpus/pathfind.jsonl says so for every such state.

"##, - draw_gaits(scene, facts), + draw_gaits(scene, us, facts), RUN_ATTACK_MODIFIER, ) } @@ -1351,9 +1396,9 @@ turn spends MP and moves no hexes, and MegaMek calls that a walk; the corpus at /// the stand does against the position the enemy would actually pick to hurt /// us. Counted here as: pairs whose worst member deals nothing, but where the /// position that hurts us most does fire. -fn pairing_section(scenes: &[Scene], sweeps: &[Sweep]) -> String { +fn pairing_section(views: &[View<'_>], sweeps: &[Sweep]) -> String { let mut rows = String::new(); - for (scene, sweep) in scenes.iter().zip(sweeps.iter()) { + for (view, sweep) in views.iter().zip(sweeps.iter()) { let (mut zero, mut anywhere, mut readable, mut best) = (0usize, 0usize, 0usize, 0.0_f32); for record in &sweep.scored { for exchanges in &record.exchanges { @@ -1386,7 +1431,7 @@ fn pairing_section(scenes: &[Scene], sweeps: &[Sweep]) -> String { let _ = write!( rows, r##"{}{zero}{anywhere}{readable}{best:.2}"##, - escape(&scene.name), + escape(&view.label()), ); } format!( @@ -1401,7 +1446,7 @@ worst member of M deals nothing, so a per-channel minimax prints somewhere in M. The third is how many fire at the position that actually hurts us most, which is the one an enemy settling both channels would pick.

-
+
boardpairs whose worst member deals nothing
{rows}
board and unitpairs whose worst member deals nothing of those, firing somewhere in M of those, firing where they hurt us mostmost damage there
@@ -1446,17 +1491,32 @@ fn main() { }); let scenes: Vec = BOARDS.iter().map(|name| Scene::build(name)).collect(); + // One view per (board, unit of ours). Going from one mover to several + // multiplies the page as well as the sweep, which is the cost this fixture + // exists to make visible. + let views: Vec> = scenes + .iter() + .flat_map(|scene| (0..scene.movers()).map(move |us| View { scene, us })) + .collect(); let mut rankings: Vec> = Vec::new(); let mut sweeps: Vec = Vec::new(); - for scene in &scenes { - let mover = scene.mover(); + let mut timings: Vec<(String, f32, usize)> = Vec::new(); + for view in &views { + let scene = view.scene; + let mover = scene.mover(view.us); let foes = scene.foes(); let mut cache = VolleyCache::new(); // One sweep, three collapses. The exponent is a reading of the // per-position record and not a parameter of the walk, so the three // regimes cost one `L x M x N` pass between them. - let sweep = score_stands(&mut cache, &scene.los, &mover, scene.stands(), &foes); + let started = std::time::Instant::now(); + let sweep = score_stands(&mut cache, &scene.los, &mover, scene.stands(view.us), &foes); + timings.push(( + view.label(), + started.elapsed().as_secs_f32() * 1000.0, + sweep.footprint(), + )); rankings.push( REGIMES .iter() @@ -1495,39 +1555,33 @@ fn main() { body.push_str(&how_to_read()); body.push_str(&missing_section()); - for (index, scene) in scenes.iter().enumerate() { + for (index, view) in views.iter().enumerate() { + let scene = view.scene; + let us = view.us; let row = &rankings[index]; let board_maps = &maps[index]; let board_ranks = &ranks[index]; let _ = write!( body, - r##"

{}

{} stands over {} hexes we can stop in · {} enemy positions in M · {} exchanges scored per direction · {} values in each rank pool

+ r##"

{}

{} stands over {} hexes this unit can stop in · {} enemy positions in M · {} exchanges scored per direction · {} values in each rank pool

Scored, with each enemy’s reach

-

The outlined regions are the three enemies’ M: every hex each of them can be standing +

The outlined regions are the enemies’ M: every hex each of them can be standing in when we arrive, on {THEIR_MP} MP from where they are now. Each outline runs along hexsides and contains exactly the hexes that were evaluated - not a hull over them, which would have claimed ground -the enemy cannot reach. Every scored hex on the panel was scored against every position inside all -three.

+the enemy cannot reach. Every scored hex on the panel was scored against every position inside all of +them. The other unit of ours is drawn on its own start hex; its own panel is the next section.

"##, - escape(&scene.name), - scene.stands().len(), + escape(&view.label()), + scene.stands(us).len(), board_maps[0].cells().len(), - scene - .foes() - .iter() - .map(|foe| foe.may_be.len()) - .sum::(), - scene.stands().len() - * scene - .foes() - .iter() - .map(|foe| foe.may_be.len()) - .sum::(), + scene.enemy_positions(), + scene.stands(us).len() * scene.enemy_positions(), board_ranks.len(), ); for (regime, (ranking, map)) in REGIMES.iter().zip(row.iter().zip(board_maps.iter())) { let panel = Panel { scene, + us, map, facts: &fact_maps[index], scale, @@ -1556,11 +1610,11 @@ three.

let defence: Vec> = row .iter() - .map(|ranking| routes_for(scene, ranking, |r| &r.by_defence)) + .map(|ranking| routes_for(scene, us, ranking, |r| &r.by_defence)) .collect(); let offence: Vec> = row .iter() - .map(|ranking| routes_for(scene, ranking, |r| &r.by_offence)) + .map(|ranking| routes_for(scene, us, ranking, |r| &r.by_offence)) .collect(); let _ = write!( body, @@ -1580,6 +1634,7 @@ the MP is usually more than the hexes.

{ let panel = Panel { scene, + us, map, facts: &fact_maps[index], scale, @@ -1602,9 +1657,10 @@ the MP is usually more than the hexes.

body.push_str("
"); body.push_str(&agreement(&defence, "defence")); body.push_str(&agreement(&offence, "offence")); - body.push_str(&gait_section(scene, &sweeps[index], &fact_maps[index])); + body.push_str(&gait_section(scene, us, &sweeps[index], &fact_maps[index])); body.push_str(&gator_section( scene, + us, &scene.foes(), &sweeps[index], &row[0], @@ -1614,7 +1670,7 @@ the MP is usually more than the hexes.

body.push_str("
"); } - body.push_str(&findings(&scenes, &sweeps, &rankings, &ranks, scale)); + body.push_str(&findings(&views, &sweeps, &rankings, &ranks, scale)); let page = format!( r##" @@ -1633,11 +1689,25 @@ the MP is usually more than the hexes.

scale.deal_max, scale.take_max, scale.take_max / scale.deal_max.max(0.001), - ENEMIES.len(), + THEIRS.len(), ); - for (name, pool) in BOARDS.iter().zip(ranks.iter()) { - println!("{name}: {} values in each rank pool", pool.len()); + for (view, pool) in views.iter().zip(ranks.iter()) { + println!("{}: {} values in each rank pool", view.label(), pool.len()); } + // What the scene costs, per unit of ours. The whole page is the sum. + let mut total = 0.0; + for (label, ms, bytes) in &timings { + total += ms; + println!( + "{label}: swept in {ms:.1} ms, per-position record {:.1} KB", + *bytes as f32 / 1024.0 + ); + } + println!( + "{} views over {} boards: {total:.1} ms of sweeping in all", + views.len(), + BOARDS.len() + ); } /// The opening, and the mechanism the pictures are of. @@ -1658,8 +1728,10 @@ human voice goes here. Everything below it is mechanism and can stay as written.

The decision being drawn

-

One Mek, {OUR_MP} movement points, three enemies with {THEIR_MP} each. Nobody has moved yet. The -question is which hex to end the turn in, and which way to be facing when the shooting starts.

+

Two Meks of ours with {OUR_MP} movement points each, against two of theirs with {THEIR_MP} each. +Nobody has moved yet. The question is which hex each of ours should end the turn in, and which way to +be facing when the shooting starts. Each unit of ours gets its own set of panels, because each starts +somewhere else and so reaches somewhere else.

L
Every end state we could reach: a hex and a facing, because turning costs movement points @@ -1671,7 +1743,7 @@ two different offers.
M, so each of our stands is scored not against one enemy position but against all of them. Drawn on every panel as a tinted region outlined along hexsides, one per enemy.
N
-
Which enemy. There are three, which is the smallest number that makes the next part interesting: +
Which enemy. There are two, which is the smallest number that makes the next part interesting: against a single enemy the two aggregations below are the same number.

Every (L, M, N) triple is run through the volley estimator twice, once in each @@ -1693,17 +1765,19 @@ are the same claim as a number.

Over N: best target against summed incoming

Offence takes the best enemy: we fire at one thing. Defence takes the sum: all of them shoot us. A stand that is excellent against one enemy and exposed to -the other two is good offence and bad defence, and no single operator over N can say +the other is good offence and bad defence, and no single operator over N can say so.

Which is also why there are two ranked lists and no combined one. Ranking on a single number would mean fixing an exchange rate between damage dealt and damage taken, and that rate is a tactical opinion that changes with the unit's role, with how much armour it has left, and with whether its side is winning. It belongs to whatever is choosing, not to the estimator.

-

Why “taken” is three times “dealt” everywhere

-

This fixture is one of ours against three of theirs. Because offence -is a maximum over one target and defence is a sum over all three shooters, the absolute damage taken -runs about three times the damage dealt at every hex on the board, before position enters at all. The -maxima here are {:.1} taken against {:.1} dealt, a ratio of {:.2} on {} enemies.

+

Why “taken” outruns “dealt” everywhere

+

Each panel is one of ours against every one of theirs. Because +offence is a maximum over one target and defence is a sum over all the shooters, the absolute damage +taken runs about as many times the damage dealt as there are enemies, at every hex on the board and +before position enters at all. The maxima here are {:.1} taken against {:.1} dealt, a +ratio of {:.2} on {} enemies. Nothing on this page nets our own force’s fire against theirs - +that is a force-level sum and this is a unit-level estimate.

That ratio is the headcount, not a judgement about the ground. Colouring a hex by deal / (deal + take) would therefore have encoded how outnumbered we are - the same statement at every hex, and no information about where to stand. The tooltips keep the absolute pair, @@ -1713,7 +1787,7 @@ saying so. It is only the colour that has to be scale-free.

scale.take_max, scale.deal_max, scale.take_max / scale.deal_max.max(0.001), - ENEMIES.len(), + THEIRS.len(), ) } @@ -1808,13 +1882,13 @@ over a coloured field is too many line weights at once. The heatmap under both r /// /// This is a gap in the model, not a fault in the render, and it is not fixed /// here. -fn hiding(scenes: &[Scene], sweeps: &[Sweep], rankings: &[Vec]) -> String { +fn hiding(views: &[View<'_>], sweeps: &[Sweep], rankings: &[Vec]) -> String { let mut rows = String::new(); // Blind stands per regime, summed over the boards, so the summary can say // which end of the exponent range does this rather than assume. let mut blind_by_regime = [0usize; REGIMES.len()]; let mut listed_total = 0usize; - for ((scene, sweep), row) in scenes.iter().zip(sweeps.iter()).zip(rankings.iter()) { + for ((view, sweep), row) in views.iter().zip(sweeps.iter()).zip(rankings.iter()) { for (at, (regime, ranking)) in REGIMES.iter().zip(row.iter()).enumerate() { let blind = ranking .best_defence() @@ -1847,7 +1921,7 @@ fn hiding(scenes: &[Scene], sweeps: &[Sweep], rankings: &[Vec]) -> Stri let _ = write!( rows, r##"{}{}{}{:.2}{blind}/{listed}{:.0}%{:.0}%"##, - escape(&scene.name), + escape(&view.label()), chip(regime), top.map(|s| format!( "({}, {}) f{}", @@ -1884,7 +1958,7 @@ is the collapse rather than the ground.

A per-channel minimax over M reports the worst member, and one member of M that blanks our guns is enough to zero the column however well the stand does everywhere else. See the pairing table for how many pairs that is.

-
+
boardregimetop defence stand
{rows}
board and unitregimetop defence stand damage it dealstop {TOP_K} that deal nothingno line, share of M firing, share of M

Across all three boards, {worst} of the {listed_total} stands the defence list names at minimax deal @@ -1905,7 +1979,7 @@ picks one arbitrarily. The reported facing at such a stand carries no informatio /// Printed whichever way it falls. A page tuned until it agreed with the /// prediction would be worth nothing. fn findings( - scenes: &[Scene], + views: &[View<'_>], sweeps: &[Sweep], rankings: &[Vec], ranks: &[Ranks], @@ -1916,11 +1990,12 @@ fn findings(

The prediction on record is that a lower exponent moves the answer towards cover. Cover here is a stand in woods or with woods next door, because a clearing ringed by trees is cover too - nothing can see into it - and counting only the trees would miss the hex the defence list picks first.

-
+
boardregimetop offence
"##, ); let mut verdicts: Vec = Vec::new(); - for (scene, row) in scenes.iter().zip(rankings.iter()) { + for (view, row) in views.iter().zip(rankings.iter()) { + let scene = view.scene; let mut defence_cover: Vec = Vec::new(); for (regime, ranking) in REGIMES.iter().zip(row.iter()) { let top = |score: Option<&StandScore>| { @@ -1943,7 +2018,7 @@ see into it - and counting only the trees would miss the hex the defence list pi out, r##""##, - escape(&scene.name), + escape(&view.label()), chip(regime), top(ranking.best_offence().next()), top(ranking.best_defence().next()), @@ -1963,20 +2038,20 @@ see into it - and counting only the trees would miss the hex the defence list pi verdicts.push(format!( "
  • On {}, the defence list goes from {first}/{TOP_K} in cover at \ minimax to {last}/{TOP_K} at the mean: it {verdict}.
  • ", - escape(&scene.name), + escape(&view.label()), )); } out.push_str("
    board and unitregimetop offence top defenceoffence in coverdefence in cover
    {}{}{}{} {}/{TOP_K}{}/{TOP_K}
      "); for verdict in &verdicts { out.push_str(verdict); } - let spreads: Vec = scenes + let spreads: Vec = views .iter() .zip(ranks.iter()) - .map(|(scene, pool)| { + .map(|(view, pool)| { format!( "
    • {}: {} values per rank pool.
    • ", - escape(&scene.name), + escape(&view.label()), pool.len() ) }) @@ -2000,8 +2075,8 @@ Both appear in every tooltip. Neither is used for a colour.

      Generated by crates/sds-core/examples/heatmap.rs from crates/sds-core/tests/corpus/pathfind.jsonl, which holds three real boards dumped from MegaMek. No MegaMek and no match are involved in drawing this: it is the estimator run over a fixture. -Us at (4, 13); enemies at (5, 6), (9, 6) and -(12, 7). The unit and its guns are a fixture choice, picked so that all five volley +Ours at (4, 13) and (7, 13); theirs at (5, 6) and +(9, 6). The units and their guns are a fixture choice, picked so that all five volley outputs carry information, and are not a model change. The reach outlines are traced here by sds_core::heatmap::outline, which keeps the edges of a set of hexes that have no hex on the far side and chains them into closed loops. Not MegaMek’s ConvexBoardArea: that @@ -2010,8 +2085,8 @@ different thing from a description of what a unit can reach.

      Hover any scored hex for its numbers. Nothing on this page is tuned to make the picture look good.

      "##, - hiding(scenes, sweeps, rankings), - pairing_section(scenes, sweeps), + hiding(views, sweeps, rankings), + pairing_section(views, sweeps), spreads.concat(), scale.deal_max, scale.take_max, @@ -2335,7 +2410,9 @@ svg.map { width: 100%; height: auto; display: block; background: var(--map-bg); .map .reach.f0, .map .start.f0 { fill: var(--foe-0); stroke: var(--foe-0); } .map .reach.f1, .map .start.f1 { fill: var(--foe-1); stroke: var(--foe-1); } .map .reach.f2, .map .start.f2 { fill: var(--foe-2); stroke: var(--foe-2); } -.map .start.us { fill: none; stroke: var(--us); stroke-dasharray: 4 3; } +.map .start.us { fill: none; stroke: var(--us); stroke-dasharray: 4 3; opacity: 0.45; } +/* The unit this panel is about, drawn solid; the rest of the force stays faint. */ +.map .start.us.mine { opacity: 1; stroke-dasharray: none; } .map .who { font-size: 9.5px; font-weight: 700; text-anchor: middle; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; paint-order: stroke; stroke: var(--map-bg); stroke-width: 2.2; } diff --git a/crates/sds-core/examples/stands.rs b/crates/sds-core/examples/stands.rs index 11801f6..19fbc9e 100644 --- a/crates/sds-core/examples/stands.rs +++ b/crates/sds-core/examples/stands.rs @@ -1,8 +1,12 @@ //! What the estimator thinks of a board, in text. //! -//! Every `(hex, facing)` a Mek can reach, scored against three enemies that -//! have not moved yet, printed as a board: what we deal from a hex, what we take -//! at it, and which stands are on the Pareto frontier through the two. +//! Every `(hex, facing)` each of our Meks can reach, scored against enemies +//! that have not moved yet, printed as a board: what we deal from a hex, what we +//! take at it, and which stands are on the Pareto frontier through the two. +//! +//! One section per unit of ours. A sweep is about a mover, and two units +//! starting in different hexes reach different ground, so a force is several +//! sweeps and not one. //! //! The frontier is the proposal set. The two top-*K* lists are printed beside //! it as instruments, and the defence list is worth reading: it is the one that @@ -23,9 +27,9 @@ //! saying whether it did, and it prints whichever way the answer comes out - //! a demo tuned until it agreed with the prediction would be worth nothing. //! -//! The scene - board, units, loadout, exponents - is `examples/common`, shared -//! with `examples/heatmap.rs` so the two renders are two pictures of the same -//! numbers. +//! The scene - board, both forces, loadout, exponents - is `examples/common`, +//! shared with `examples/heatmap.rs` so the two renders are two pictures of the +//! same numbers. //! //! Not a test. The numbers move whenever the model does, and the point of it is //! to read them. @@ -46,7 +50,7 @@ use sds_core::volley::VolleyCache; use sds_core::wire::Coord; mod common; -use common::{Scene, ENEMIES, EXPONENTS, OUR_MP, START, THEIR_MP, TOP_K}; +use common::{Scene, EXPONENTS, OUR_MP, THEIR_MP, TOP_K}; /// The most stands the frontier may emit. Below the frontier length on every /// board here, so the thinning is doing something visible rather than being @@ -103,6 +107,10 @@ struct Picture<'a> { board: &'a MoveBoard, width: i32, height: i32, + /// Where our force started. Every one of them is drawn, not just the unit + /// this panel is about: a reader has to be able to see the other half of + /// the force the scores are supposed to cooperate with. + ours: &'a BTreeSet<(i32, i32)>, enemies: &'a BTreeSet<(i32, i32)>, /// The top of the 0-9 scale, shared by all three boards so a digit means /// the same thing in each. @@ -121,6 +129,7 @@ fn render( board, width, height, + ours, enemies, deal_full, take_full, @@ -136,7 +145,7 @@ fn render( let at = Coord::new(x, y); let key = (x, y); let held = values.get(&key); - if at == START { + if ours.contains(&key) { line.push('@'); } else { line.push(glyph(board, at)); @@ -263,80 +272,148 @@ fn doctrines() -> Vec<(&'static str, Stance)> { ] } +/// One of our units, swept once and collapsed three ways. +struct Thinking { + sweep: Sweep, + rankings: Vec<(&'static str, Ranking)>, +} + fn main() { let name = std::env::args() .nth(1) .unwrap_or_else(|| "open".to_string()); let scene = Scene::build(&name); let (width, height) = (scene.message.width, scene.message.height); - let move_board = &scene.board; - let mover = scene.mover(); - let stands = scene.stands(); let foes = scene.foes(); - - let enemy_hexes: BTreeSet<(i32, i32)> = scene.enemy_hexes(); let m_total: usize = foes.iter().map(|foe| foe.may_be.len()).sum(); println!("board {name} ({width}x{height}), from tests/corpus/pathfind.jsonl"); - println!( - "us at ({}, {}) with {OUR_MP} MP: {} stands over {} hexes", - START.x, - START.y, - stands.len(), - stands - .iter() - .map(|r| (r.stand.hex.x, r.stand.hex.y)) - .collect::>() - .len() - ); - for (foe, at) in foes.iter().zip(ENEMIES.iter()) { + for us in 0..scene.movers() { + let at = scene.start(us); + let stands = scene.stands(us); println!( - " enemy {} at ({}, {}) with {THEIR_MP} MP: {} positions", - foe.who.unit.id, + "unit {} at ({}, {}) with {OUR_MP} MP: {} stands over {} hexes", + scene.our_unit(us).id, at.x, at.y, + stands.len(), + stands + .iter() + .map(|r| (r.stand.hex.x, r.stand.hex.y)) + .collect::>() + .len() + ); + } + for foe in &foes { + println!( + " enemy {} at ({}, {}) with {THEIR_MP} MP: {} positions", + foe.who.unit.id, + foe.who.unit.x, + foe.who.unit.y, foe.may_be.len() ); } + // The size of the sweep for the whole force, not for one unit. Going from + // one mover to several multiplies this, and it is the number to watch when + // the scene grows. println!( - "L x M x N = {} x {} = {} exchanges per direction", - stands.len(), + "L x M x N over the force = {} stands x {} positions = {} exchanges per direction", + scene.ours.stand_total(), m_total, - stands.len() * m_total + scene.triples() ); println!(); - let mut cache = VolleyCache::new(); - // One sweep. The exponent is a reading of what it recorded, so the three - // collapses below share a single `L x M x N` walk. - let sweep = score_stands(&mut cache, &scene.los, &mover, stands, &foes); - let mut rankings: Vec<(&str, Ranking)> = Vec::new(); - for (label, exponent) in EXPONENTS { - let params = Params { - exponent, - top_k: TOP_K, - frontier_max: FRONTIER_MAX, - }; - rankings.push((label, rank(&sweep, ¶ms))); + // One sweep per unit of ours. The exponent is a reading of what a sweep + // recorded, so the three collapses below share a single `L x M x N` walk + // per unit. + let mut thinking: Vec = Vec::new(); + for us in 0..scene.movers() { + let mut cache = VolleyCache::new(); + let started = std::time::Instant::now(); + let sweep = score_stands( + &mut cache, + &scene.los, + &scene.mover(us), + scene.stands(us), + &foes, + ); + let elapsed = started.elapsed(); + let mut rankings: Vec<(&str, Ranking)> = Vec::new(); + for (label, exponent) in EXPONENTS { + let params = Params { + exponent, + top_k: TOP_K, + frontier_max: FRONTIER_MAX, + }; + rankings.push((label, rank(&sweep, ¶ms))); + } + println!( + "unit {}: swept {} stands against {} enemy positions in {:.1} ms", + scene.our_unit(us).id, + scene.stands(us).len(), + m_total, + elapsed.as_secs_f32() * 1000.0 + ); + let stats = cache.stats(); + println!( + " volley cache: {} asked, {} hits, {} misses, dropped {} for no line and {} for nothing bearing", + stats.asked, stats.hits, stats.misses, stats.no_line, stats.nothing_bears + ); + footprint_report(&sweep); + thinking.push(Thinking { sweep, rankings }); } - footprint_report(&sweep); - // One scale across all three boards, so a digit means the same thing in - // each. Otherwise the pictures could not be compared, which is the only - // reason to print three of them. + // One scale across every unit of ours as well as every regime, so a digit + // means the same thing in each panel. Otherwise the pictures could not be + // compared, which is the only reason to print several of them. let mut deal_full: f32 = 0.0; let mut take_full: f32 = 0.0; - for (_, ranking) in &rankings { - for (deal, take) in by_hex(ranking).values() { - deal_full = deal_full.max(*deal); - take_full = take_full.max(*take); + for one in &thinking { + for (_, ranking) in &one.rankings { + for (deal, take) in by_hex(ranking).values() { + deal_full = deal_full.max(*deal); + take_full = take_full.max(*take); + } } } + for (us, one) in thinking.iter().enumerate() { + println!(); + println!( + "############ our unit {} at ({}, {}) ############", + scene.our_unit(us).id, + scene.start(us).x, + scene.start(us).y + ); + report(&scene, us, one, &foes, deal_full, take_full); + } +} + +/// Everything one of our units has to say about the board. +fn report( + scene: &Scene, + us: usize, + thinking: &Thinking, + foes: &[sds_core::stands::Foe<'_>], + deal_full: f32, + take_full: f32, +) { + let name = &scene.name; + let (width, height) = (scene.message.width, scene.message.height); + let move_board = &scene.board; + let mover = scene.mover(us); + let stands = scene.stands(us); + let sweep = &thinking.sweep; + let rankings = &thinking.rankings; + let our_hexes: BTreeSet<(i32, i32)> = scene.our_hexes(); + let enemy_hexes: BTreeSet<(i32, i32)> = scene.enemy_hexes(); + let picture = Picture { board: move_board, width, height, + ours: &our_hexes, enemies: &enemy_hexes, deal_full, take_full, @@ -346,7 +423,7 @@ fn main() { "cell: terrain, damage dealt, damage taken, then * for a stand on the frontier,\n o / d / b for a top {TOP_K} stand by offence / defence / both, E for an enemy start" ); println!( - "digits are 0-9 against one scale for all three boards: 9 = {deal_full:.1} dealt, 9 = {take_full:.1} taken" + "digits are 0-9 against one scale shared by every panel: 9 = {deal_full:.1} dealt, 9 = {take_full:.1} taken" ); println!( ". clear w light woods W heavy woods ~ water ^ level 1 + level 2 # impassable" @@ -367,7 +444,7 @@ fn main() { let mut verdict: Vec<(String, String, String, usize, usize)> = Vec::new(); - for (label, ranking) in &rankings { + for (label, ranking) in rankings { let values = by_hex(ranking); let hexes = |list: &mut dyn Iterator| { list.map(|score| (score.reach.stand.hex.x, score.reach.stand.hex.y)) @@ -526,8 +603,8 @@ fn main() { " {:<20} {:<11} {:>12} {:>8} {:>8} {:>8} fights", "exponent", "stance", "chosen", "deal", "take", "value" ); - for (label, ranking) in &rankings { - let proposals = propose(&scene.los, &mover, ranking, &foes, &[]); + for (label, ranking) in rankings { + let proposals = propose(&scene.los, &mover, ranking, foes, &scene.friends(us)); // How much room a weight has to work in. A feature that reads the same // on every proposal cannot move an argmax however it is weighted, so @@ -557,7 +634,7 @@ fn main() { println!(" {label:<20} {name:<11} {:>12}", "nothing"); continue; }; - let end = proposal.end.unwrap_or(START); + let end = proposal.end.unwrap_or_else(|| scene.start(us)); println!( " {label:<20} {name:<11} {:>12} {:>8.2} {:>8.2} {:>8.2} {}", format!("({:>2},{:>2})", end.x, end.y), @@ -582,7 +659,7 @@ fn main() { let facts = facts_of(&sweep); println!(" a weighted sum reaches only the convex hull; min-max reaches the concave middle"); println!(" the weight table is PLACEHOLDER-DOCTRINE-gallimaufry and is not a decision"); - for (label, ranking) in &rankings { + for (label, ranking) in rankings { let targets: Vec = ranking .scored .first() @@ -625,20 +702,14 @@ fn main() { // ground and the geometry, not of how pessimistic the collapse is - so // they are printed once rather than three times. if let Some((label, ranking)) = rankings.first() { - facts_report(label, &sweep, ranking); + facts_report(label, sweep, ranking); } println!(); - let stats = cache.stats(); - println!( - "volley cache: {} asked, {} hits, {} misses, dropped {} for no line and {} for nothing bearing", - stats.asked, stats.hits, stats.misses, stats.no_line, stats.nothing_bears - ); - // Which prune to run first is a property of the board, so it is measured // rather than assumed. Both prunes are run over every pair here, which is // exactly what the scoring path avoids. - let counts = prune_selectivity(&scene.los, &mover, stands, &foes); + let counts = prune_selectivity(&scene.los, &mover, stands, foes); println!( "prune selectivity on {name}: arc alone drops {}/{} ({:.1}%), line of sight alone drops {}/{} ({:.1}%), both {}", counts.arc_drops, diff --git a/crates/sds-core/tests/scene.rs b/crates/sds-core/tests/scene.rs new file mode 100644 index 0000000..f6e5ceb --- /dev/null +++ b/crates/sds-core/tests/scene.rs @@ -0,0 +1,252 @@ +//! The examples' scene, asserted. +//! +//! `examples/common/mod.rs` is a fixture and not a model, but the things it +//! has to get right have each been got wrong once: a unit on the wire with no +//! weapons, a side assembled by its own code path, and one reachable set stood +//! in for two units. Those are what this file pins. +//! +//! The module is the examples' own, included by path rather than copied, so a +//! change to the fixture is checked here rather than in a second copy of it. + +#[path = "../examples/common/mod.rs"] +mod common; + +use std::collections::BTreeSet; + +use common::{Allegiance, Scene, Side, BOARDS, OURS, THEIRS}; +use sds_core::pathfind::MoveBoard; +use sds_core::wire::Coord; + +fn stand_set(scene: &Scene, us: usize) -> BTreeSet<(i32, i32, i32)> { + scene + .stands(us) + .iter() + .map(|reach| (reach.stand.hex.x, reach.stand.hex.y, reach.stand.facing)) + .collect() +} + +/// At least two a side, or nothing about a force can be demonstrated. +#[test] +fn both_forces_are_plural() { + assert!(OURS.len() >= 2, "one unit cannot cooperate with anything"); + assert!(THEIRS.len() >= 2); + for name in BOARDS { + let scene = Scene::build(name); + assert_eq!(scene.movers(), OURS.len(), "{name}"); + assert_eq!(scene.theirs.len(), THEIRS.len(), "{name}"); + } +} + +/// Each of our units searched from its own hex. +/// +/// Two units starting three hexes apart cannot have the same set. A scene that +/// shared one search between them would report a force whose units all reach +/// the same ground, which is the one thing the force layer must not be handed. +#[test] +fn every_unit_of_ours_has_its_own_reachable_set() { + for name in BOARDS { + let scene = Scene::build(name); + for us in 0..scene.movers() { + assert!( + !scene.stands(us).is_empty(), + "{name}: unit {us} can reach nothing" + ); + assert_eq!( + scene.search(us).start.hex, + scene.start(us), + "{name}: unit {us} searched from the wrong hex" + ); + } + for us in 0..scene.movers() { + for other in (us + 1)..scene.movers() { + assert_ne!( + stand_set(&scene, us), + stand_set(&scene, other), + "{name}: units {us} and {other} share a reachable set" + ); + } + } + } +} + +/// Nobody's set contains a hex somebody is standing in, on either side. +#[test] +fn no_reachable_set_walks_through_an_occupied_hex() { + for name in BOARDS { + let scene = Scene::build(name); + let occupied: BTreeSet<(i32, i32)> = scene + .our_hexes() + .union(&scene.enemy_hexes()) + .copied() + .collect(); + for (side, count) in [(&scene.ours, scene.movers()), (&scene.theirs, THEIRS.len())] { + for index in 0..count { + let start = (side.units[index].x, side.units[index].y); + for reach in side.stands(index) { + let at = (reach.stand.hex.x, reach.stand.hex.y); + assert!( + at == start || !occupied.contains(&at), + "{name}: a stand at {at:?} sits on another unit" + ); + } + } + } + } +} + +/// Both sides come out of [`Side::muster`], so ours cannot silently diverge. +/// +/// Same chassis, same guns, same search. Mustering our own starts under the +/// enemy's allegiance must produce the enemy's differences and nothing else: +/// the team, the friendly flag and the id. If a second code path ever appears +/// for one side, this stops agreeing. +#[test] +fn both_sides_are_built_by_the_same_code_path() { + let scene = Scene::build("open"); + let board = MoveBoard::new(&scene.message); + for (side, allegiance) in [ + (&scene.ours, Allegiance::Ours), + (&scene.theirs, Allegiance::Theirs), + ] { + let again = Side::muster(&board, allegiance, opposing(allegiance)); + assert_eq!(again.units.len(), side.units.len()); + for (built, expected) in again.units.iter().zip(side.units.iter()) { + assert_eq!(built.id, expected.id); + assert_eq!(built.team, expected.team); + assert_eq!(built.friendly, expected.friendly); + assert_eq!(built.weapons.len(), expected.weapons.len()); + assert_eq!(built.locations.len(), expected.locations.len()); + } + } + // The chassis is one description, so the two sides differ only in the three + // fields the allegiance owns. + let ours = &scene.ours.units[0]; + let theirs = &scene.theirs.units[0]; + assert_ne!(ours.team, theirs.team); + assert!(ours.friendly && !theirs.friendly); + assert_eq!(ours.walk_mp, theirs.walk_mp); + assert_eq!(ours.armor, theirs.armor); + assert_eq!( + ours.weapons.iter().map(|w| w.id).collect::>(), + theirs.weapons.iter().map(|w| w.id).collect::>(), + ); +} + +fn opposing(allegiance: Allegiance) -> &'static [Coord] { + match allegiance { + Allegiance::Ours => &THEIRS, + Allegiance::Theirs => &OURS, + } +} + +/// The `"weapons": []` bug, asserted where it happened. +/// +/// `features::positional` reads `Unit::weapons` rather than the estimator's +/// `MountedWeapon`s. A unit with an empty wire list reads `exposure` and +/// `range_band_fit` as 0.00 on every candidate, which is a flat column and not +/// a measurement. Both sides carry them, and both are checked. +#[test] +fn every_unit_carries_its_weapons_on_the_wire() { + let scene = Scene::build("open"); + for side in [&scene.ours, &scene.theirs] { + for unit in &side.units { + assert_eq!( + unit.weapons.len(), + 3, + "unit {} lost its wire weapons", + unit.id + ); + assert!(unit.weapons.iter().any(|w| w.avg_damage_short >= 20.0)); + assert!(unit.weapons.iter().all(|w| w.usable)); + } + } + // And the estimator's copy is the same list. + for us in 0..scene.movers() { + assert_eq!(scene.mover(us).who.weapons.len(), 3); + } + for foe in scene.foes() { + assert_eq!(foe.who.weapons.len(), 3); + } +} + +/// The scene is a pure function of its name. +/// +/// Two builds must agree hex for hex and in order. Nothing here may depend on +/// the order a set was iterated in - see the invariant in `CLAUDE.md` - and a +/// fixture that did would make every number the examples print unrepeatable. +#[test] +fn the_scene_is_deterministic() { + for name in BOARDS { + let first = Scene::build(name); + let second = Scene::build(name); + for us in 0..first.movers() { + assert_eq!( + first.stands(us), + second.stands(us), + "{name}: our unit {us} moved between builds" + ); + } + let left = first.foes(); + let right = second.foes(); + assert_eq!(left.len(), right.len()); + for (a, b) in left.iter().zip(right.iter()) { + assert_eq!(a.who.unit.id, b.who.unit.id); + assert_eq!(a.may_be, b.may_be, "{name}: an enemy's M moved"); + } + assert_eq!(first.triples(), second.triples()); + } +} + +/// The other units of ours reach the positional features. +#[test] +fn a_unit_is_told_about_the_rest_of_its_force() { + let scene = Scene::build("open"); + for us in 0..scene.movers() { + let friends = scene.friends(us); + assert_eq!(friends.len(), scene.movers() - 1); + assert!( + friends.iter().all(|unit| unit.id != scene.our_unit(us).id), + "a unit was listed as its own friend" + ); + assert!(friends.iter().all(|unit| unit.friendly)); + } +} + +/// The fixture has to produce a fight, not a staring contest. +/// +/// The 1v3 scene had to be moved once because the enemies were too far away to +/// engage, which made a prune measurement meaningless. Every unit of ours must +/// have somewhere it can shoot every enemy from. +#[test] +fn every_unit_of_ours_can_engage_every_enemy() { + use sds_core::stands::{score_stands, Params}; + use sds_core::volley::VolleyCache; + + for name in BOARDS { + let scene = Scene::build(name); + let _ = Params::default(); + let foes = scene.foes(); + for us in 0..scene.movers() { + let mut cache = VolleyCache::new(); + let sweep = score_stands( + &mut cache, + &scene.los, + &scene.mover(us), + scene.stands(us), + &foes, + ); + for foe in &foes { + let hits = sweep + .scored + .iter() + .filter_map(|record| record.about(foe.who.unit.id)) + .any(|exchanges| exchanges.outgoing.firing > 0); + assert!( + hits, + "{name}: our unit {us} can never fire on enemy {}", + foe.who.unit.id + ); + } + } + } +} diff --git a/plan/candidates.md b/plan/candidates.md index bf59545..2e270f6 100644 --- a/plan/candidates.md +++ b/plan/candidates.md @@ -258,7 +258,7 @@ nothing between the estimator and the force removes one. The cap it replaced was written before anything was measured, and the measurements contradict it. 16v16 unpruned is **2,416 candidates**, about **198 KB**, and **41k multiply-adds** to score every one. Generating and -scoring 198 stands against three enemies costs **27 ms**; the pathfinding half +scoring 194 stands against two enemies costs **28 ms**; the pathfinding half is **29 us** per unit; the reachability search benchmarks **50-80x faster** than MegaMek's own `ShortestPathFinder` and `LongestPathFinder` on the same boards and starts. `ForceThinker::command` loops each unit's proposals and @@ -273,6 +273,33 @@ The frontier stays as an instrument, not a gate. Reading it beside the scored set is how the degenerate emission above was found, and the share of surfaced picks that sit on it is the diagnostic below. +### What a second unit costs + +Measured on the same build and machine, one run each. The demo fixture went +from one of ours against three of theirs to **2v2**, so `L` doubled and `M` +shrank by a third: + +| board | triples before | triples after | sweep before | sweep after | record before | record after | +| --- | --- | --- | --- | --- | --- | --- | +| open | 31,878 | 40,768 | 39.6 ms | 50.3 ms | 1,033 KB | 1,700 KB | +| forest | 1,960 | 2,494 | 4.0 ms | 4.3 ms | 52 KB | 98 KB | +| water | 6,887 | 9,720 | 7.9 ms | 12.6 ms | 225 KB | 360 KB | + +An entry is still **52 B**, and the sweep is close to linear in +`(stands) x (enemy positions)`: about **1.4 us** a pair on `open`. A 2v2 is not +uncomfortable - the whole nine-panel page sweeps in 67 ms against 47 ms before. + +Extrapolating that rate rather than guessing: on `open` a unit sees roughly 52 +enemy positions per enemy and has roughly 195 stands, so one unit against `n` +enemies is `195 * 52n` pairs at 52 B and 1.4 us. At **4v4** that is 40.5k pairs +a unit, **56 ms** and 2.1 MB, so 225 ms and 8 MB for the force - fine. At +**16v16** it is 162k pairs a unit, **225 ms** and 8.4 MB dense - 5 MB stored at +the sparsity `open` shows - so **3.6 s** and 80-135 MB for a force holding every +unit's record at once. That is over budget on both counts, and it is the +per-position record rather than the estimate that carries the memory. Neither +is a problem this scene has; both are problems the force layer will meet, and +the numbers are here so it meets them with figures rather than guesses. + ### Surfacing: the few options worth showing a person Selection is no longer a gate on what the force sees, so it is free to be about @@ -956,10 +983,13 @@ output we produce. worst case and should pull the good hexes toward cover; `p = 1` is the enemy moving without regard to us. The scenario test asserts this relationship as a number - the render is the same claim made visible -- [x] At least three enemies on one side, so the `N` aggregation is not - degenerate. Against a single enemy, offence-takes-best and - defence-takes-sum are the same number, and the case worth seeing - good - against one, exposed to three - cannot occur +- [x] At least two units a side, so neither aggregation is degenerate and the + force layer has something to reconcile. Against a single enemy, + offence-takes-best and defence-takes-sum are the same number, and the case + worth seeing - good against one, exposed to the other - cannot occur. + Against a single unit of ours, nothing about a force can be shown at all: + the fixture is 2v2, and each of our units gets its own reachable set and + its own panels - [x] Test what is asserted rather than what is drawn: the hex ordering the render produces, not its pixels. A colour is not a regression target - [x] Draw each enemy's `M` rather than leaving a reader to infer it, and the @@ -968,9 +998,9 @@ output we produce. **Hue cannot come from the absolute pair.** The two aggregations are different operators on purpose - offence takes the best single target, defence sums over -every shooter - so against three enemies "taken" runs about three times "dealt" -at every hex before position enters. The maxima came out at 92.1 taken against -31.7 dealt, a ratio of 2.90 on 3 enemies. A hue of `deal / (deal + take)` +every shooter - so against `n` enemies "taken" runs about `n` times "dealt" +at every hex before position enters. At 2v2 the maxima came out at 58.8 taken +against 28.3 dealt, a ratio of 2.08 on 2 enemies. A hue of `deal / (deal + take)` therefore encodes the headcount, which is the same everywhere and says nothing about the ground. Ranking each channel in its own pool and taking the hue from the *difference* of the ranks is scale-free and survives 1v1, 3v1 and 8v8 alike.