diff --git a/crates/sds-core/tests/common/mod.rs b/crates/sds-core/tests/common/mod.rs new file mode 100644 index 0000000..3f05513 --- /dev/null +++ b/crates/sds-core/tests/common/mod.rs @@ -0,0 +1,217 @@ +//! The board, the unit and the enemies both stand tests run on. +//! +//! One `Fight` per board, built from the MegaMek dump in +//! `corpus/pathfind.jsonl`: our Mek at a fixed start with 6 MP, three enemies +//! that have not moved yet with 4 MP each. Shared rather than copied, so a +//! surprise in one file can be read in the other. + +#![allow(dead_code)] + +use sds_core::arc::MekLocation; +use sds_core::hex::Stand; +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}; + +pub const CORPUS: &str = include_str!("../corpus/pathfind.jsonl"); + +pub const OUR_MP: i32 = 6; +pub const THEIR_MP: i32 = 4; +pub const START: Coord = Coord::new(4, 13); +pub const ENEMIES: [Coord; 3] = [Coord::new(5, 6), Coord::new(9, 6), Coord::new(12, 7)]; + +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"); +} + +pub fn mek(id: i32, at: Coord, facing: i32) -> Unit { + 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}, + "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"); + // The same guns, on the wire this time. + // + // `Combatant` is handed `MountedWeapon`s and the volley estimator reads + // those, but `features::positional` reads `Unit::weapons` - it projects + // damage at a range rather than estimating a volley. A unit with an empty + // wire list is a unit `exposure` and `range_band_fit` both read as zero + // for, on every candidate, which is a flat column and not a measurement. + unit.weapons = loadout_wire(); + unit +} + +pub 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, an LRM-10 in the right. +pub fn loadout_wire() -> 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![ + wire_weapon(1, 20.0, 3, 6, 9, MekLocation::RightArm), + wire_weapon(2, 10.0, 6, 12, 18, MekLocation::LeftTorso), + lrm, + ] +} + +pub fn loadout() -> Vec { + loadout_wire() + .iter() + .map(MountedWeapon::from_wire) + .collect() +} + +/// Everything one board needs, held together so the borrows outlive the sweep. +pub struct Fight { + pub message: Board, + pub our_unit: Unit, + pub enemy_units: Vec, + pub stands: Vec, +} + +impl Fight { + pub fn on(name: &str) -> Self { + let message = board(name); + let move_board = MoveBoard::new(&message); + 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 { + message, + our_unit: mek(1, START, 0), + enemy_units, + stands, + } + } + + pub fn foes(&self) -> Vec> { + let move_board = MoveBoard::new(&self.message); + 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 = 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() + } + + pub fn mover(&self) -> Mover<'_> { + Mover { + who: Combatant::mek(&self.our_unit, loadout(), 4), + elevation: 0, + jumped: false, + } + } +} diff --git a/crates/sds-core/tests/stands_frontier.rs b/crates/sds-core/tests/stands_frontier.rs index 97510cb..b58c779 100644 --- a/crates/sds-core/tests/stands_frontier.rs +++ b/crates/sds-core/tests/stands_frontier.rs @@ -15,215 +15,12 @@ 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::{dominates, propose, score_stands, Combatant, Foe, Mover, Params, Presence}; -use sds_core::volley::{MountedWeapon, VolleyCache}; -use sds_core::wire::{Board, BoardHex, Coord, Unit, Weapon}; +use sds_core::stands::{dominates, propose, score_stands, Params}; +use sds_core::volley::VolleyCache; -const CORPUS: &str = include_str!("corpus/pathfind.jsonl"); - -const OUR_MP: i32 = 6; -const THEIR_MP: i32 = 4; -const START: Coord = Coord::new(4, 13); -const ENEMIES: [Coord; 3] = [Coord::new(5, 6), Coord::new(9, 6), Coord::new(12, 7)]; - -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"); -} - -fn mek(id: i32, at: Coord, facing: i32) -> Unit { - 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}, - "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"); - // The same guns, on the wire this time. - // - // `Combatant` is handed `MountedWeapon`s and the volley estimator reads - // those, but `features::positional` reads `Unit::weapons` - it projects - // damage at a range rather than estimating a volley. A unit with an empty - // wire list is a unit `exposure` and `range_band_fit` both read as zero - // for, on every candidate, which is a flat column and not a measurement. - unit.weapons = loadout_wire(); - unit -} - -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, an LRM-10 in the right. -fn loadout_wire() -> 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![ - wire_weapon(1, 20.0, 3, 6, 9, MekLocation::RightArm), - wire_weapon(2, 10.0, 6, 12, 18, MekLocation::LeftTorso), - lrm, - ] -} - -fn loadout() -> Vec { - loadout_wire() - .iter() - .map(MountedWeapon::from_wire) - .collect() -} - -/// Everything one board needs, held together so the borrows outlive the sweep. -struct Fight { - message: Board, - our_unit: Unit, - enemy_units: Vec, - stands: Vec, -} - -impl Fight { - fn on(name: &str) -> Self { - let message = board(name); - let move_board = MoveBoard::new(&message); - 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 { - message, - our_unit: mek(1, START, 0), - enemy_units, - stands, - } - } - - fn foes(&self) -> Vec> { - let move_board = MoveBoard::new(&self.message); - 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 = 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() - } - - fn mover(&self) -> Mover<'_> { - Mover { - who: Combatant::mek(&self.our_unit, loadout(), 4), - elevation: 0, - jumped: false, - } - } -} +mod common; +use common::Fight; /// **The regression guard for the whole problem.** ///