diff --git a/crates/sds-core/examples/common/mod.rs b/crates/sds-core/examples/common/mod.rs new file mode 100644 index 0000000..5ce643a --- /dev/null +++ b/crates/sds-core/examples/common/mod.rs @@ -0,0 +1,273 @@ +//! The scene both renders read: one board, us, and three enemies who have not +//! 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. +//! +//! 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`. + +// Each example uses a different part of this, and cargo compiles the module +// once per example. +#![allow(dead_code)] + +use std::collections::BTreeSet; + +use sds_core::arc::MekLocation; +use sds_core::hex::Stand; +use sds_core::los::{LosCache, Rules}; +use sds_core::pathfind::{reachable, MoveBoard, Reach, 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}; + +const CORPUS: &str = include_str!("../../tests/corpus/pathfind.jsonl"); + +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); + +/// 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 +/// 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)]; + +/// The three exponents, worst case to average. +pub const EXPONENTS: [(&str, f32); 3] = [ + ("minimax p -> -inf", f32::NEG_INFINITY), + ("middling p = -1", -1.0), + ("mean p = 1", 1.0), +]; + +/// The three corpus boards, in the order a page shows them. +pub const BOARDS: [&str; 3] = ["open", "forest", "water"]; + +/// Pull one named board out of the movement corpus. +pub fn board(name: &str) -> Board { + for line in CORPUS.lines() { + let value: serde_json::Value = serde_json::from_str(line).expect("corpus line parses"); + if value["type"] != "board" || value["board"] != name { + continue; + } + let hexes: Vec = + serde_json::from_value(value["hexes"].clone()).expect("hexes parse"); + return Board { + width: value["width"].as_i64().unwrap() as i32, + height: value["height"].as_i64().unwrap() as i32, + hexes, + }; + } + panic!("no board named {name} in the corpus"); +} + +/// 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 { + serde_json::from_str(&format!( + r#"{{ + "id": {id}, "name": "Mek {id}", "ownerId": 2, "team": 2, + "friendly": false, "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, + "locations": [ + {{"name": "HD", "armor": 9, "armorMax": 9, + "internal": 3, "internalMax": 3, "cockpit": true}}, + {{"name": "CT", "armor": 20, "armorMax": 22, + "rearArmor": 6, "rearArmorMax": 8, + "internal": 18, "internalMax": 18, + "engine": true, "gyro": true, "weapons": 1, "weaponDamage": 5.0}}, + {{"name": "RT", "armor": 16, "armorMax": 16, + "rearArmor": 5, "rearArmorMax": 5, + "internal": 13, "internalMax": 13, "engine": true}}, + {{"name": "LT", "armor": 16, "armorMax": 16, + "rearArmor": 5, "rearArmorMax": 5, + "internal": 13, "internalMax": 13, + "engine": true, "weapons": 1, "weaponDamage": 10.0}}, + {{"name": "RA", "armor": 12, "armorMax": 12, + "internal": 9, "internalMax": 9, "actuators": 4, + "weapons": 1, "weaponDamage": 10.0}}, + {{"name": "LA", "armor": 12, "armorMax": 12, + "internal": 9, "internalMax": 9, "actuators": 4, + "weapons": 1, "weaponDamage": 10.0}}, + {{"name": "RL", "armor": 16, "armorMax": 16, + "internal": 13, "internalMax": 13, "actuators": 4}}, + {{"name": "LL", "armor": 16, "armorMax": 16, + "internal": 13, "internalMax": 13, "actuators": 4}} + ], + "weapons": [] + }}"#, + at.x, at.y + )) + .expect("unit parses") +} + +/// A single-packet weapon at a stated mount, through the wire's deserialiser. +/// +/// `location` is a wire field now, so the mount arrives the way a real one does +/// rather than being pushed in afterwards. +fn wire_weapon( + id: i32, + damage: f32, + short: i32, + medium: i32, + long: i32, + location: MekLocation, +) -> Weapon { + let mount = location.abbreviation(); + serde_json::from_str(&format!( + r#"{{ + "id": {id}, "name": "Gun {id}", "heat": 3, "location": "{mount}", + "short": {short}, "medium": {medium}, "long": {long}, + "avgDamageShort": {damage}, "avgDamageMedium": {damage}, + "avgDamageLong": {damage}, "rackSize": 0, + "damagePerPacket": {damage}, "usable": true + }}"# + )) + .expect("weapon parses") +} + +/// An AC/20 in the right arm, a PPC in the left torso and an LRM-10 in the +/// right torso. +/// +/// Spread across three locations, so a twist changes what bears. Heavy on +/// purpose: a pair of light guns leaves `p_kill` at 0.0002 and breaches under +/// 0.1 everywhere, which makes two of the five columns carry no information. +pub fn loadout() -> Vec { + let lrm: Weapon = serde_json::from_str( + r#"{ + "id": 3, "name": "LRM-10", "heat": 4, "location": "RT", + "short": 7, "medium": 14, "long": 21, + "avgDamageShort": 6.0, "avgDamageMedium": 6.0, "avgDamageLong": 6.0, + "rackSize": 10, "damagePerPacket": 1.0, "usable": true + }"#, + ) + .expect("weapon parses"); + vec![ + MountedWeapon::from_wire(&wire_weapon(1, 20.0, 3, 6, 9, MekLocation::RightArm)), + MountedWeapon::from_wire(&wire_weapon(2, 10.0, 6, 12, 18, MekLocation::LeftTorso)), + MountedWeapon::from_wire(&lrm), + ] +} + +/// One board with both sides 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. +pub struct Scene { + pub name: String, + 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`. + pub stands: Vec, +} + +impl Scene { + pub fn build(name: &str) -> Self { + 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 stands = reachable( + &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(); + Self { + name: name.to_string(), + message, + board: move_board, + los, + our_unit, + enemy_units, + stands, + } + } + + pub fn mover(&self) -> Mover<'_> { + Mover { + who: Combatant::mek(&self.our_unit, loadout(), 4), + elevation: 0, + jumped: false, + } + } + + /// 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 + .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, + }) + .collect(); + Foe { + who: Combatant::mek(unit, loadout(), 4), + may_be, + } + }) + .collect() + } + + pub fn enemy_hexes(&self) -> BTreeSet<(i32, i32)> { + ENEMIES.iter().map(|at| (at.x, at.y)).collect() + } + + /// In woods, or with woods next door. + /// + /// A clearing ringed by trees is cover - nothing can see into it - and + /// counting only the trees themselves would miss exactly the hex the + /// defence list picks first. + pub fn in_cover(&self, at: Coord) -> bool { + std::iter::once(at) + .chain(sds_core::hex::neighbours(at)) + .any(|hex| self.board.contains(hex) && self.board.hex(hex).terrain_mp >= 1) + } +} diff --git a/crates/sds-core/examples/stands.rs b/crates/sds-core/examples/stands.rs index 200bc93..2ba64ec 100644 --- a/crates/sds-core/examples/stands.rs +++ b/crates/sds-core/examples/stands.rs @@ -12,154 +12,23 @@ //! 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 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`. +//! 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. //! //! Not a test. The numbers move whenever the model does, and the point of it is //! to read them. use std::collections::{BTreeMap, BTreeSet}; -use sds_core::arc::MekLocation; -use sds_core::hex::Stand; -use sds_core::los::{LosCache, Rules}; -use sds_core::pathfind::{reachable, MoveBoard, Reach, Walker, MEK_MAX_ELEVATION_CHANGE}; -use sds_core::stands::{ - prune_selectivity, score_stands, Combatant, Foe, Mover, Params, Presence, Ranking, -}; -use sds_core::volley::{MountedWeapon, VolleyCache}; -use sds_core::wire::{Board, BoardHex, Coord, Unit, Weapon}; +use sds_core::heatmap::HexMap; +use sds_core::pathfind::MoveBoard; +use sds_core::stands::{prune_selectivity, score_stands, Params, Ranking}; +use sds_core::volley::VolleyCache; +use sds_core::wire::Coord; -const CORPUS: &str = include_str!("../tests/corpus/pathfind.jsonl"); - -const OUR_MP: i32 = 6; -const THEIR_MP: i32 = 4; -const TOP_K: usize = 8; - -/// Where everybody starts. Us in the south, three of them in the north. -const START: Coord = Coord::new(4, 13); -/// 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. -const ENEMIES: [Coord; 3] = [Coord::new(5, 6), Coord::new(9, 6), Coord::new(12, 7)]; - -/// The three exponents, worst case to average. -const EXPONENTS: [(&str, f32); 3] = [ - ("minimax p -> -inf", f32::NEG_INFINITY), - ("middling p = -1", -1.0), - ("mean p = 1", 1.0), -]; - -/// Pull one named board out of the movement corpus. -fn board(name: &str) -> Board { - for line in CORPUS.lines() { - let value: serde_json::Value = serde_json::from_str(line).expect("corpus line parses"); - if value["type"] != "board" || value["board"] != name { - continue; - } - let hexes: Vec = - serde_json::from_value(value["hexes"].clone()).expect("hexes parse"); - return Board { - width: value["width"].as_i64().unwrap() as i32, - height: value["height"].as_i64().unwrap() as i32, - hexes, - }; - } - panic!("no board named {name} in the corpus"); -} - -/// A middleweight Mek, whole. Through the wire's own deserialiser, so the -/// example cannot describe a unit the bot could not be handed. -fn mek(id: i32, at: Coord, facing: i32) -> Unit { - serde_json::from_str(&format!( - r#"{{ - "id": {id}, "name": "Mek {id}", "ownerId": 2, "team": 2, - "friendly": false, "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, - "locations": [ - {{"name": "HD", "armor": 9, "armorMax": 9, - "internal": 3, "internalMax": 3, "cockpit": true}}, - {{"name": "CT", "armor": 20, "armorMax": 22, - "rearArmor": 6, "rearArmorMax": 8, - "internal": 18, "internalMax": 18, - "engine": true, "gyro": true, "weapons": 1, "weaponDamage": 5.0}}, - {{"name": "RT", "armor": 16, "armorMax": 16, - "rearArmor": 5, "rearArmorMax": 5, - "internal": 13, "internalMax": 13, "engine": true}}, - {{"name": "LT", "armor": 16, "armorMax": 16, - "rearArmor": 5, "rearArmorMax": 5, - "internal": 13, "internalMax": 13, - "engine": true, "weapons": 1, "weaponDamage": 10.0}}, - {{"name": "RA", "armor": 12, "armorMax": 12, - "internal": 9, "internalMax": 9, "actuators": 4, - "weapons": 1, "weaponDamage": 10.0}}, - {{"name": "LA", "armor": 12, "armorMax": 12, - "internal": 9, "internalMax": 9, "actuators": 4, - "weapons": 1, "weaponDamage": 10.0}}, - {{"name": "RL", "armor": 16, "armorMax": 16, - "internal": 13, "internalMax": 13, "actuators": 4}}, - {{"name": "LL", "armor": 16, "armorMax": 16, - "internal": 13, "internalMax": 13, "actuators": 4}} - ], - "weapons": [] - }}"#, - at.x, at.y - )) - .expect("unit parses") -} - -/// A single-packet weapon at a stated mount, through the wire's deserialiser. -/// -/// `location` is a wire field now, so the mount arrives the way a real one does -/// rather than being pushed in afterwards. -fn wire_weapon( - id: i32, - damage: f32, - short: i32, - medium: i32, - long: i32, - location: MekLocation, -) -> Weapon { - let mount = location.abbreviation(); - serde_json::from_str(&format!( - r#"{{ - "id": {id}, "name": "Gun {id}", "heat": 3, "location": "{mount}", - "short": {short}, "medium": {medium}, "long": {long}, - "avgDamageShort": {damage}, "avgDamageMedium": {damage}, - "avgDamageLong": {damage}, "rackSize": 0, - "damagePerPacket": {damage}, "usable": true - }}"# - )) - .expect("weapon parses") -} - -/// An AC/20 in the right arm, a PPC in the left torso and an LRM-10 in the -/// right torso. -/// -/// Spread across three locations, so a twist changes what bears. Heavy on -/// purpose: a pair of light guns leaves `p_kill` at 0.0002 and breaches under -/// 0.1 everywhere, which makes two of the five columns carry no information. -/// This is a fixture choice and not a model change. -fn loadout() -> Vec { - let lrm: Weapon = serde_json::from_str( - r#"{ - "id": 3, "name": "LRM-10", "heat": 4, "location": "RT", - "short": 7, "medium": 14, "long": 21, - "avgDamageShort": 6.0, "avgDamageMedium": 6.0, "avgDamageLong": 6.0, - "rackSize": 10, "damagePerPacket": 1.0, "usable": true - }"#, - ) - .expect("weapon parses"); - vec![ - MountedWeapon::from_wire(&wire_weapon(1, 20.0, 3, 6, 9, MekLocation::RightArm)), - MountedWeapon::from_wire(&wire_weapon(2, 10.0, 6, 12, 18, MekLocation::LeftTorso)), - MountedWeapon::from_wire(&lrm), - ] -} +mod common; +use common::{Scene, ENEMIES, EXPONENTS, OUR_MP, START, THEIR_MP, TOP_K}; fn glyph(board: &MoveBoard, at: Coord) -> char { let hex = board.hex(at); @@ -193,23 +62,14 @@ fn digit(value: Option, full: f32) -> char { } } -/// Per hex: the most we can deal from it at any facing, and the least we can be -/// made to take at it at any facing. -/// -/// Two extremes over the facings rather than one facing's pair, because -/// choosing a facing to display would need a scalar to choose it with, and -/// there is deliberately no such scalar - see the note on -/// [`sds_core::stands::Ranking`]. They can be different facings, and the -/// per-stand lists below are where the real `(hex, facing)` states are. +/// Per hex, from the shared [`HexMap`] the SVG render also reads: the most we +/// can deal from it at any facing, and the least we can be made to take at it. fn by_hex(ranking: &Ranking) -> BTreeMap<(i32, i32), (f32, f32)> { - let mut best: BTreeMap<(i32, i32), (f32, f32)> = BTreeMap::new(); - for score in &ranking.scored { - let at = (score.reach.stand.hex.x, score.reach.stand.hex.y); - let entry = best.entry(at).or_insert((0.0, f32::INFINITY)); - entry.0 = entry.0.max(score.offence.expected_damage); - entry.1 = entry.1.min(score.defence.expected_damage); - } - best + HexMap::of(ranking) + .cells() + .iter() + .map(|cell| ((cell.hex.x, cell.hex.y), (cell.deal, cell.take))) + .collect() } /// Two text lines per board row, because odd columns sit half a hex lower. @@ -285,73 +145,14 @@ fn main() { let name = std::env::args() .nth(1) .unwrap_or_else(|| "open".to_string()); - let message = board(&name); - let (width, height) = (message.width, message.height); - let move_board = MoveBoard::new(&message); - let los = LosCache::new(&message, Rules::default()); - - let our_unit = mek(1, START, 0); - let mover = Mover { - who: Combatant::mek(&our_unit, loadout(), 4), - elevation: 0, - jumped: false, - }; - - let occupied: Vec = ENEMIES.to_vec(); - let stands: Vec = reachable( - &move_board, - &Walker { - start: Stand::new(START, 0), - mp: OUR_MP, - max_elevation_change: MEK_MAX_ELEVATION_CHANGE, - prone: false, - }, - &occupied, - ); - - // 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. - let enemy_units: Vec = ENEMIES - .iter() - .enumerate() - .map(|(i, at)| mek(2 + i as i32, *at, 3)) - .collect(); - let foes: Vec> = enemy_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( - &move_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, - }) - .collect(); - Foe { - who: Combatant::mek(unit, loadout(), 4), - may_be, - } - }) - .collect(); - - let enemy_hexes: BTreeSet<(i32, i32)> = ENEMIES.iter().map(|at| (at.x, at.y)).collect(); + 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"); @@ -392,7 +193,7 @@ fn main() { }; rankings.push(( label, - score_stands(&mut cache, &los, &mover, &stands, &foes, ¶ms), + score_stands(&mut cache, &scene.los, &mover, stands, &foes, ¶ms), )); } @@ -409,7 +210,7 @@ fn main() { } let picture = Picture { - board: &move_board, + board: move_board, width, height, enemies: &enemy_hexes, @@ -432,15 +233,7 @@ fn main() { ); println!("be made to take at it at any facing. They may be different facings."); - // In woods, or with woods next door. A clearing ringed by trees is cover - - // nothing can see into it - and counting only the trees themselves would - // miss exactly the hex the defence list picks first. - let in_cover = |score: &sds_core::stands::StandScore| { - let at = score.reach.stand.hex; - std::iter::once(at) - .chain(sds_core::hex::neighbours(at)) - .any(|hex| move_board.contains(hex) && move_board.hex(hex).terrain_mp >= 1) - }; + let in_cover = |score: &sds_core::stands::StandScore| scene.in_cover(score.reach.stand.hex); let describe = |score: &sds_core::stands::StandScore| { format!( "({:>2},{:>2}) f{}", @@ -552,7 +345,7 @@ fn main() { // 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(&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,