From c5ebc2cb3d7d9dc3249a38fd770e2dec5680126a Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sun, 30 Aug 2026 01:07:30 -0400 Subject: [PATCH] fix(candidates)!: price a level and a step per mover, not per Mek A Mek pays one point a level and everything else pays two, climbing or dropping alike; infantry pay the terrain instead of the step. Both read off sds step-cost. routes-check goes from 44 refused to 0, with the reachable sets still matching MegaMek's. Change-Id: I6e87a512d99831106a050988f79293388ae429a4 --- crates/sds-core/examples/common/mod.rs | 3 + crates/sds-core/examples/perf.rs | 2 + crates/sds-core/examples/reachable.rs | 3 + crates/sds-core/examples/tactics/lance.rs | 4 + crates/sds-core/examples/vignettes/scene.rs | 2 + crates/sds-core/src/pathfind.rs | 176 +++++++++++++++++- crates/sds-core/src/stands.rs | 4 + crates/sds-core/tests/common/mod.rs | 5 + .../tests/corpus/pathfind_routes.jsonl | 18 +- .../sds-core/tests/pathfind_differential.rs | 56 ++++-- docs/features/deploy_ground.svg | 2 +- docs/features/elevation_gain.svg | 2 +- docs/features/submerged.svg | 2 +- docs/tactics/lance-entrench.svg | 2 +- docs/tactics/lance-flank.svg | 2 +- docs/tactics/lance-regroup.svg | 2 +- plan/candidates.md | 55 ++++-- 17 files changed, 298 insertions(+), 42 deletions(-) diff --git a/crates/sds-core/examples/common/mod.rs b/crates/sds-core/examples/common/mod.rs index 7dd5459..2a2b54c 100644 --- a/crates/sds-core/examples/common/mod.rs +++ b/crates/sds-core/examples/common/mod.rs @@ -332,6 +332,9 @@ impl Side { walk_mp: allegiance.mp(), jump_mp: 0, max_elevation_change: MEK_MAX_ELEVATION_CHANGE, + // An abstract Mek, like the terrain answers beside it. + level_mp: sds_core::pathfind::MEK_LEVEL_MP, + pays_step: true, prone: false, mp_used: 0, // A Mek, which is what a vignette draws. diff --git a/crates/sds-core/examples/perf.rs b/crates/sds-core/examples/perf.rs index 42b0810..a40c273 100644 --- a/crates/sds-core/examples/perf.rs +++ b/crates/sds-core/examples/perf.rs @@ -736,6 +736,8 @@ impl Scene { walk_mp: mp, jump_mp: 0, max_elevation_change: MEK_MAX_ELEVATION_CHANGE, + level_mp: sds_core::pathfind::MEK_LEVEL_MP, + pays_step: true, prone: false, mp_used: 0, // A Mek's point per hexside. Battle armor turns for diff --git a/crates/sds-core/examples/reachable.rs b/crates/sds-core/examples/reachable.rs index 7fe36c6..815f6e6 100644 --- a/crates/sds-core/examples/reachable.rs +++ b/crates/sds-core/examples/reachable.rs @@ -184,6 +184,9 @@ fn main() { walk_mp: MP, jump_mp: 0, max_elevation_change: MEK_MAX_ELEVATION_CHANGE, + // A Mek, like the terrain answers beside it. + level_mp: sds_core::pathfind::MEK_LEVEL_MP, + pays_step: true, prone: false, mp_used: 0, // A Mek, which is what this example draws. diff --git a/crates/sds-core/examples/tactics/lance.rs b/crates/sds-core/examples/tactics/lance.rs index e53fe3f..b36c6d1 100644 --- a/crates/sds-core/examples/tactics/lance.rs +++ b/crates/sds-core/examples/tactics/lance.rs @@ -446,6 +446,10 @@ fn sweep(moves: &MoveBoard, side: &[Unit], other: &[Unit]) -> Vec { walk_mp: unit.walk_mp, jump_mp: 0, max_elevation_change: MEK_MAX_ELEVATION_CHANGE, + // Off the unit: a tank pays two points a level where a Mek + // pays one, and a vignette fielding one should say so. + level_mp: sds_core::pathfind::Footing::of(unit).level_mp, + pays_step: sds_core::pathfind::Footing::of(unit).pays_step, prone: false, mp_used: 0, // The unit's own charge for a hexside, not a constant: a diff --git a/crates/sds-core/examples/vignettes/scene.rs b/crates/sds-core/examples/vignettes/scene.rs index bf25f44..d213f2b 100644 --- a/crates/sds-core/examples/vignettes/scene.rs +++ b/crates/sds-core/examples/vignettes/scene.rs @@ -296,6 +296,8 @@ pub fn reachable_on(board: &MoveBoard, unit: &Unit, blocked: &[Coord], gait: Gai walk_mp: profile.walk_mp, jump_mp: profile.jump_mp, max_elevation_change: profile.max_elevation_change, + level_mp: profile.level_mp, + pays_step: profile.pays_step, // A candidate is a hex the unit ends upright in: a prone Mek emits // `GET_UP` before anything else. prone: false, diff --git a/crates/sds-core/src/pathfind.rs b/crates/sds-core/src/pathfind.rs index 942cf5a..50b2a1e 100644 --- a/crates/sds-core/src/pathfind.rs +++ b/crates/sds-core/src/pathfind.rs @@ -80,6 +80,15 @@ use crate::wire::{Board, BoardHex, Coord, TerrainSeen, Unit, NO_TERRAIN}; /// nothing else here uses this constant. pub const MEK_MAX_ELEVATION_CHANGE: i32 = 2; +/// What one level of climb or drop costs a Mek. +pub const MEK_LEVEL_MP: i32 = 1; + +/// What it costs everything else: a tank, a hovercraft, a squad, a platoon. +/// +/// Read off `sds step-cost` rather than recited, and symmetric - a drop is +/// charged what a climb is. See [`Footing::level_mp`]. +pub const OTHER_LEVEL_MP: i32 = 2; + /// What standing up costs a Mek, in MP. pub const GET_UP_MP: i32 = 2; @@ -266,6 +275,25 @@ pub struct Footing<'a> { pub terrain: &'a TerrainSeen, /// Levels it may climb or drop in one step, from `Entity.getMaxElevationChange`. pub max_elevation_change: i32, + /// What one level of climb or drop costs this mover. + /// + /// **A Mek pays one and everything else pays two**, climbing or dropping + /// alike. Read off `sds step-cost`, which prices every step of the four + /// boards for every specimen: a tracked tank on flat ground pays 1 and one + /// level up pays 3, and the same is true of a hovercraft and of both kinds + /// of infantry. "Two points a level" is not a hovercraft rule - the Mek is + /// the exception - and it is symmetric, so a drop costs what a climb costs. + /// + /// This was 36 of the 44 routes `routes-check` refused: a Manticore priced + /// at 6 by the port and 7 by MegaMek, which is one level's difference. + pub level_mp: i32, + /// Does this mover pay for the step itself, on top of the terrain? + /// + /// **Infantry pay the terrain instead of the step.** Light woods costs a + /// platoon 1 where it costs a Mek 2, and clear ground costs both 1 - so it + /// is `max(1, terrain)` rather than `1 + terrain`. Measured by the same + /// probe, over every step of four boards. + pub pays_step: bool, } impl<'a> Footing<'a> { @@ -274,6 +302,12 @@ impl<'a> Footing<'a> { Self { terrain: &unit.terrain, max_elevation_change: unit.max_elevation_change(), + level_mp: if unit.movement_mode == "BIPED" || unit.movement_mode == "QUAD" { + MEK_LEVEL_MP + } else { + OTHER_LEVEL_MP + }, + pays_step: !unit.movement_mode.starts_with("INF_"), } } @@ -285,6 +319,8 @@ impl<'a> Footing<'a> { pub const PORTED: Footing<'static> = Footing { terrain: &NO_TERRAIN, max_elevation_change: MEK_MAX_ELEVATION_CHANGE, + level_mp: MEK_LEVEL_MP, + pays_step: true, }; /// What this hex costs this mover, in terrain alone. @@ -443,7 +479,15 @@ impl MoveBoard { } else { 0 }; - let cost = 1 + mover.terrain_mp(&target) + water + delta.abs(); + // The step and the terrain, per mover: a Mek pays for both and infantry + // pay the terrain alone. See [`Footing::pays_step`]. + let terrain = mover.terrain_mp(&target); + let ground = if mover.pays_step { + 1 + terrain + } else { + terrain.max(1) + }; + let cost = ground + water + mover.level_mp * delta.abs(); // Terrain arms can be negative for a superheavy or a mountaineer, and // magma crust is a plain zero. A step is never free. Some(cost.max(1)) @@ -554,6 +598,11 @@ pub struct Walker<'a> { pub jump_mp: i32, /// Levels this unit may climb or drop in one step. pub max_elevation_change: i32, + /// What one level costs it: see [`Footing::level_mp`]. + pub level_mp: i32, + /// Whether it pays for the step as well as the terrain: see + /// [`Footing::pays_step`]. + pub pays_step: bool, /// A Mek on its face stands up before it does anything else, and standing /// costs [`GET_UP_MP`] out of the same allowance. pub prone: bool, @@ -591,6 +640,8 @@ impl<'a> Walker<'a> { walk_mp: unit.walk_mp, jump_mp: unit.jump_allowance(), max_elevation_change: unit.max_elevation_change(), + level_mp: Footing::of(unit).level_mp, + pays_step: Footing::of(unit).pays_step, prone: unit.prone, mp_used: unit.mp_used.max(0), ground_turn_mp: unit.turn_mp(), @@ -696,6 +747,8 @@ impl<'a> Walker<'a> { Footing { terrain: self.terrain, max_elevation_change: self.max_elevation_change, + level_mp: self.level_mp, + pays_step: self.pays_step, } } } @@ -1490,6 +1543,8 @@ pub fn options_from( // the only one available here. ground_turn_mp: GROUND_TURN_MP, max_elevation_change: profile.max_elevation_change, + level_mp: profile.level_mp, + pays_step: profile.pays_step, // The candidate is somewhere the unit ends its move upright: a prone // Mek emits `GET_UP` before anything else, so no candidate is a hex it // is lying in. Searching as prone would charge every hex on the board @@ -1586,6 +1641,16 @@ pub struct MovementProfile { /// of that question is a function of the hex, which is already in the key. pub jump_mp: i32, pub max_elevation_change: i32, + /// What a level costs this mover, and whether it pays for the step as well + /// as the terrain. + /// + /// **In the key because they are not terrain.** [`Self::terrain`] is a + /// digest of what the hexes cost, and two movers can agree on every hex and + /// still price a climb differently - a Mek pays one a level and a tank pays + /// two. Left out, the two would share a cache entry and the second would be + /// answered with the first's reachable set. + pub level_mp: i32, + pub pays_step: bool, } impl MovementProfile { @@ -1596,6 +1661,8 @@ impl MovementProfile { walk_mp: unit.walk_mp.max(0), jump_mp: unit.jump_mp.max(0), max_elevation_change: unit.max_elevation_change(), + level_mp: Footing::of(unit).level_mp, + pays_step: Footing::of(unit).pays_step, } } @@ -1611,6 +1678,8 @@ impl MovementProfile { let footing = Footing { terrain, max_elevation_change: self.max_elevation_change, + level_mp: self.level_mp, + pays_step: self.pays_step, }; if footing.wades(&board.hex(at)) { 0 @@ -1812,6 +1881,8 @@ mod tests { walk_mp: mp, jump_mp: mp, max_elevation_change: MEK_MAX_ELEVATION_CHANGE, + level_mp: MEK_LEVEL_MP, + pays_step: true, prone: false, mp_used: 0, // A Mek, which is what these fixtures are. @@ -3059,6 +3130,8 @@ mod tests { let hover = Footing { terrain: &shut, max_elevation_change: 1, + level_mp: OTHER_LEVEL_MP, + pays_step: true, }; assert_eq!( board.step_mp(Coord::new(4, 5), Coord::new(4, 4), &hover), @@ -3070,6 +3143,8 @@ mod tests { let mek = Footing { terrain: &open_to, max_elevation_change: MEK_MAX_ELEVATION_CHANGE, + level_mp: MEK_LEVEL_MP, + pays_step: true, }; assert_eq!( board.step_mp(Coord::new(4, 5), Coord::new(4, 4), &mek), @@ -3088,6 +3163,11 @@ mod tests { let footing = Footing { terrain: &squad, max_elevation_change: 1, + level_mp: OTHER_LEVEL_MP, + // A tank, which is one of the movers this case names. Infantry pay + // the terrain instead of the step and are their own case - see + // `infantry_pay_the_terrain_and_not_the_step`. + pays_step: true, }; assert_eq!( board.step_mp(Coord::new(4, 5), Coord::new(4, 4), &footing), @@ -3096,6 +3176,96 @@ mod tests { ); } + /// Infantry pay the terrain, and pay nothing for the step on top of it. + /// + /// Light woods costs a platoon 1 where it costs a Mek 2, and clear ground + /// costs both 1 - so it is `max(1, terrain)` and not `1 + terrain`. Read + /// off `sds step-cost`, which prices every step of the four boards for + /// every specimen; see `scenarios/move/stepcost.txt`. + #[test] + fn infantry_pay_the_terrain_and_not_the_step() { + let board = wooded(); + let seen = answers(0, 2, false); + let platoon = Footing { + terrain: &seen, + max_elevation_change: 1, + level_mp: OTHER_LEVEL_MP, + pays_step: false, + }; + assert_eq!( + board.step_mp(Coord::new(4, 5), Coord::new(4, 4), &platoon), + Some(2), + "the hex, and not the hex plus the step" + ); + // And clear ground still costs a point: the terrain column is nought + // there, and a step is never free. + assert_eq!( + board.step_mp(Coord::new(4, 6), Coord::new(4, 5), &platoon), + Some(1) + ); + } + + /// A Mek pays one point a level. Everything else pays two. + /// + /// Climbing or dropping alike, and it is not the hovercraft rule it was + /// taken for: a tracked tank, a hovercraft and both kinds of infantry all + /// pay two, and the Mek is the exception. This was 36 of the 44 routes + /// `routes-check` refused before it was measured. + #[test] + fn a_level_costs_a_mek_one_and_everything_else_two() { + // A ridge one level high down the middle column, so the same walk + // crosses flat ground, a climb and a drop. + let mut hexes = Vec::new(); + for x in 0..3 { + for y in 0..3 { + hexes.push(hex(x, y, if x == 1 { 1 } else { 0 }, &[])); + } + } + let board = MoveBoard::new(&Board { + width: 3, + height: 3, + hexes, + }); + let seen = answers(0, 0, false); + let mek = Footing { + terrain: &seen, + max_elevation_change: MEK_MAX_ELEVATION_CHANGE, + level_mp: MEK_LEVEL_MP, + pays_step: true, + }; + let tank = Footing { + level_mp: OTHER_LEVEL_MP, + ..mek + }; + // Flat: both pay for the step alone, so the level term is the whole of + // the difference below rather than an offset in it. + assert_eq!( + board.step_mp(Coord::new(0, 0), Coord::new(0, 1), &mek), + Some(1) + ); + assert_eq!( + board.step_mp(Coord::new(0, 0), Coord::new(0, 1), &tank), + Some(1) + ); + // One level up, and one level back down: symmetric for both. + assert_eq!( + board.step_mp(Coord::new(0, 1), Coord::new(1, 1), &mek), + Some(2) + ); + assert_eq!( + board.step_mp(Coord::new(0, 1), Coord::new(1, 1), &tank), + Some(3) + ); + assert_eq!( + board.step_mp(Coord::new(1, 1), Coord::new(2, 1), &mek), + Some(2) + ); + assert_eq!( + board.step_mp(Coord::new(1, 1), Coord::new(2, 1), &tank), + Some(3) + ); + } + /// A mover the host could not answer for gets what it always got. /// /// Every observation recorded before the terrain answers existed is one, @@ -3142,6 +3312,8 @@ mod tests { walk_mp: 4, jump_mp: 4, max_elevation_change: 1, + level_mp: OTHER_LEVEL_MP, + pays_step: false, prone: false, mp_used: 0, mode: Mode::Jump, @@ -3195,6 +3367,8 @@ mod tests { walk_mp: 6, jump_mp: 0, max_elevation_change: 1, + level_mp: OTHER_LEVEL_MP, + pays_step: true, prone: false, mp_used: 0, mode: Mode::Ground, diff --git a/crates/sds-core/src/stands.rs b/crates/sds-core/src/stands.rs index 184bada..b4f269a 100644 --- a/crates/sds-core/src/stands.rs +++ b/crates/sds-core/src/stands.rs @@ -5002,6 +5002,10 @@ mod tests { walk_mp: mp, jump_mp: 0, max_elevation_change: MEK_MAX_ELEVATION_CHANGE, + // A Mek, like the terrain answers beside it: one point a + // level, and it pays for the step as well as the terrain. + level_mp: crate::pathfind::MEK_LEVEL_MP, + pays_step: true, prone: false, mp_used: 0, ground_turn_mp: crate::pathfind::GROUND_TURN_MP, diff --git a/crates/sds-core/tests/common/mod.rs b/crates/sds-core/tests/common/mod.rs index 5498024..95a7662 100644 --- a/crates/sds-core/tests/common/mod.rs +++ b/crates/sds-core/tests/common/mod.rs @@ -163,6 +163,9 @@ impl Fight { walk_mp: OUR_MP, jump_mp: 0, max_elevation_change: MEK_MAX_ELEVATION_CHANGE, + // An abstract Mek, like the terrain answers beside it. + level_mp: sds_core::pathfind::MEK_LEVEL_MP, + pays_step: true, prone: false, mp_used: 0, ground_turn_mp: sds_core::pathfind::GROUND_TURN_MP, @@ -206,6 +209,8 @@ impl Fight { walk_mp: THEIR_MP, jump_mp: 0, max_elevation_change: MEK_MAX_ELEVATION_CHANGE, + level_mp: sds_core::pathfind::MEK_LEVEL_MP, + pays_step: true, prone: false, mp_used: 0, ground_turn_mp: sds_core::pathfind::GROUND_TURN_MP, diff --git a/crates/sds-core/tests/corpus/pathfind_routes.jsonl b/crates/sds-core/tests/corpus/pathfind_routes.jsonl index 4239ca8..6b5ae65 100644 --- a/crates/sds-core/tests/corpus/pathfind_routes.jsonl +++ b/crates/sds-core/tests/corpus/pathfind_routes.jsonl @@ -47,11 +47,11 @@ {"board":"forest","specimen":"Phoenix_Hawk_PXH-1","x":4,"y":8,"facing":1,"mp":6,"mode":"ground","prone":true,"mpUsed":0,"routes":42,"digest":"895730d977290bb0","illegal":[]} {"board":"forest","specimen":"Phoenix_Hawk_PXH-1","x":4,"y":8,"facing":1,"mp":9,"mode":"ground","prone":true,"mpUsed":0,"routes":89,"digest":"7c465fe756d1f385","illegal":[]} {"board":"forest","specimen":"Phoenix_Hawk_PXH-1","x":6,"y":5,"facing":0,"mp":9,"mode":"ground","prone":false,"mpUsed":3,"routes":39,"digest":"5a6e985145a7dcaa","illegal":[]} -{"board":"forest","specimen":"Manticore_Heavy_Tank","x":6,"y":5,"facing":0,"mp":4,"mode":"ground","prone":false,"mpUsed":0,"routes":21,"digest":"0d97dc8d17b55d3a","illegal":[]} -{"board":"forest","specimen":"Manticore_Heavy_Tank","x":6,"y":5,"facing":0,"mp":6,"mode":"ground","prone":false,"mpUsed":0,"routes":38,"digest":"4e5492bf03750ce1","illegal":["FORWARDS FORWARDS -> step 1 FORWARDS is MOVE_ILLEGAL at 8 mp (walk 4, run 6)","TURN_RIGHT FORWARDS TURN_LEFT FORWARDS -> step 3 FORWARDS is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","FORWARDS TURN_RIGHT FORWARDS -> step 2 FORWARDS is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_RIGHT TURN_RIGHT FORWARDS FORWARDS -> step 3 FORWARDS is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_LEFT FORWARDS TURN_RIGHT TURN_RIGHT -> step 3 TURN_RIGHT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_LEFT FORWARDS TURN_LEFT TURN_LEFT -> step 3 TURN_LEFT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_LEFT TURN_LEFT FORWARDS TURN_RIGHT TURN_RIGHT -> step 4 TURN_RIGHT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_LEFT TURN_LEFT FORWARDS TURN_LEFT TURN_LEFT -> step 4 TURN_LEFT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","FORWARDS TURN_LEFT TURN_LEFT TURN_LEFT -> step 3 TURN_LEFT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)"]} -{"board":"forest","specimen":"Pegasus_Scout_Hover_Tank_(Missile)","x":6,"y":5,"facing":0,"mp":4,"mode":"ground","prone":false,"mpUsed":0,"routes":13,"digest":"4662a939fa7f982e","illegal":[]} -{"board":"forest","specimen":"Pegasus_Scout_Hover_Tank_(Missile)","x":6,"y":5,"facing":0,"mp":6,"mode":"ground","prone":false,"mpUsed":0,"routes":17,"digest":"1eac04c6dc9f96a7","illegal":[]} -{"board":"forest","specimen":"Elemental_Battle_Armor_[Laser](Sqd5)","x":4,"y":8,"facing":0,"mp":4,"mode":"ground","prone":false,"mpUsed":0,"routes":12,"digest":"b7e82dff319762db","illegal":[]} +{"board":"forest","specimen":"Manticore_Heavy_Tank","x":6,"y":5,"facing":0,"mp":4,"mode":"ground","prone":false,"mpUsed":0,"routes":17,"digest":"630129128c21b34e","illegal":[]} +{"board":"forest","specimen":"Manticore_Heavy_Tank","x":6,"y":5,"facing":0,"mp":6,"mode":"ground","prone":false,"mpUsed":0,"routes":29,"digest":"5eab6b7bbe3a108f","illegal":[]} +{"board":"forest","specimen":"Pegasus_Scout_Hover_Tank_(Missile)","x":6,"y":5,"facing":0,"mp":4,"mode":"ground","prone":false,"mpUsed":0,"routes":12,"digest":"2ea028c098c1e4de","illegal":[]} +{"board":"forest","specimen":"Pegasus_Scout_Hover_Tank_(Missile)","x":6,"y":5,"facing":0,"mp":6,"mode":"ground","prone":false,"mpUsed":0,"routes":15,"digest":"9d42407532ee0607","illegal":[]} +{"board":"forest","specimen":"Elemental_Battle_Armor_[Laser](Sqd5)","x":4,"y":8,"facing":0,"mp":4,"mode":"ground","prone":false,"mpUsed":0,"routes":4,"digest":"7bf3202ba453de80","illegal":[]} {"board":"forest","specimen":"Pegasus_Scout_Hover_Tank_(Missile)","x":11,"y":12,"facing":3,"mp":6,"mode":"ground","prone":false,"mpUsed":0,"routes":18,"digest":"35fe4fa43fad5753","illegal":[]} {"board":"forest","specimen":"Phoenix_Hawk_PXH-1@walk1","x":4,"y":8,"facing":0,"mp":2,"mode":"ground","prone":false,"mpUsed":0,"routes":6,"digest":"c4af1993941b2120","illegal":[]} {"board":"forest","specimen":"Phoenix_Hawk_PXH-1@walk2","x":4,"y":8,"facing":0,"mp":3,"mode":"ground","prone":false,"mpUsed":0,"routes":7,"digest":"b86851bfe8cf278b","illegal":[]} @@ -107,7 +107,7 @@ {"board":"mountain","specimen":"Phoenix_Hawk_PXH-1","x":4,"y":9,"facing":1,"mp":4,"mode":"ground","prone":true,"mpUsed":0,"routes":17,"digest":"8349973668cf504a","illegal":[]} {"board":"mountain","specimen":"Phoenix_Hawk_PXH-1","x":4,"y":9,"facing":1,"mp":6,"mode":"ground","prone":true,"mpUsed":0,"routes":44,"digest":"a69cd432a0582c09","illegal":[]} {"board":"mountain","specimen":"Phoenix_Hawk_PXH-1","x":4,"y":9,"facing":1,"mp":9,"mode":"ground","prone":true,"mpUsed":0,"routes":91,"digest":"14ff6272488dfe70","illegal":[]} -{"board":"mountain","specimen":"Manticore_Heavy_Tank","x":2,"y":6,"facing":0,"mp":6,"mode":"ground","prone":false,"mpUsed":0,"routes":37,"digest":"649870401b75487a","illegal":["TURN_RIGHT FORWARDS FORWARDS TURN_RIGHT FORWARDS -> step 4 FORWARDS is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_RIGHT FORWARDS TURN_RIGHT FORWARDS TURN_LEFT -> step 4 TURN_LEFT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_RIGHT FORWARDS TURN_RIGHT FORWARDS TURN_RIGHT -> step 4 TURN_RIGHT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)"]} +{"board":"mountain","specimen":"Manticore_Heavy_Tank","x":2,"y":6,"facing":0,"mp":6,"mode":"ground","prone":false,"mpUsed":0,"routes":34,"digest":"825ea0161a6a1a4b","illegal":[]} {"board":"mountain","specimen":"Phoenix_Hawk_PXH-1@walk1","x":2,"y":6,"facing":0,"mp":2,"mode":"ground","prone":false,"mpUsed":0,"routes":7,"digest":"775592b7151c7702","illegal":[]} {"board":"mountain","specimen":"Manticore_Heavy_Tank@walk2","x":2,"y":6,"facing":0,"mp":3,"mode":"ground","prone":false,"mpUsed":0,"routes":11,"digest":"a1fd03f0e991cc84","illegal":[]} {"board":"open","specimen":"Phoenix_Hawk_PXH-1","x":8,"y":8,"facing":0,"mp":4,"mode":"ground","prone":false,"mpUsed":0,"routes":75,"digest":"6d4ef1afce50d4c2","illegal":[]} @@ -211,9 +211,9 @@ {"board":"water","specimen":"Phoenix_Hawk_PXH-1","x":12,"y":12,"facing":1,"mp":4,"mode":"ground","prone":true,"mpUsed":0,"routes":25,"digest":"92e940a3b23ed5a2","illegal":[]} {"board":"water","specimen":"Phoenix_Hawk_PXH-1","x":12,"y":12,"facing":1,"mp":6,"mode":"ground","prone":true,"mpUsed":0,"routes":69,"digest":"1c9bc5b4a38d6416","illegal":[]} {"board":"water","specimen":"Phoenix_Hawk_PXH-1","x":12,"y":12,"facing":1,"mp":9,"mode":"ground","prone":true,"mpUsed":0,"routes":250,"digest":"fc5a3af813a6ad50","illegal":[]} -{"board":"water","specimen":"Elemental_Battle_Armor_[Laser](Sqd5)","x":12,"y":12,"facing":0,"mp":4,"mode":"ground","prone":false,"mpUsed":0,"routes":22,"digest":"851b4d91382f380a","illegal":["TURN_RIGHT FORWARDS FORWARDS -> step 2 FORWARDS is MOVE_ILLEGAL at 2 mp (walk 1, run 1)","TURN_LEFT TURN_LEFT FORWARDS FORWARDS -> step 3 FORWARDS is MOVE_ILLEGAL at 2 mp (walk 1, run 1)","TURN_RIGHT FORWARDS FORWARDS TURN_LEFT -> step 2 FORWARDS is MOVE_ILLEGAL at 2 mp (walk 1, run 1)","TURN_RIGHT FORWARDS FORWARDS TURN_RIGHT -> step 2 FORWARDS is MOVE_ILLEGAL at 2 mp (walk 1, run 1)"]} -{"board":"water","specimen":"Foot_Platoon_(Rifle_Light)","x":12,"y":12,"facing":0,"mp":4,"mode":"ground","prone":false,"mpUsed":0,"routes":22,"digest":"851b4d91382f380a","illegal":["TURN_RIGHT FORWARDS FORWARDS -> step 2 FORWARDS is MOVE_ILLEGAL at 2 mp (walk 1, run 1)","TURN_LEFT TURN_LEFT FORWARDS FORWARDS -> step 3 FORWARDS is MOVE_ILLEGAL at 2 mp (walk 1, run 1)","TURN_RIGHT FORWARDS FORWARDS TURN_LEFT -> step 2 FORWARDS is MOVE_ILLEGAL at 2 mp (walk 1, run 1)","TURN_RIGHT FORWARDS FORWARDS TURN_RIGHT -> step 2 FORWARDS is MOVE_ILLEGAL at 2 mp (walk 1, run 1)"]} -{"board":"water","specimen":"Manticore_Heavy_Tank","x":12,"y":12,"facing":0,"mp":6,"mode":"ground","prone":false,"mpUsed":0,"routes":72,"digest":"a3424936dc521322","illegal":["TURN_LEFT FORWARDS FORWARDS -> step 2 FORWARDS is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_LEFT TURN_LEFT FORWARDS FORWARDS FORWARDS -> step 4 FORWARDS is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_RIGHT FORWARDS FORWARDS FORWARDS TURN_LEFT -> step 4 TURN_LEFT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_RIGHT FORWARDS FORWARDS FORWARDS TURN_RIGHT -> step 4 TURN_RIGHT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_RIGHT FORWARDS FORWARDS TURN_RIGHT FORWARDS -> step 4 FORWARDS is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_RIGHT FORWARDS TURN_RIGHT FORWARDS FORWARDS -> step 4 FORWARDS is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_RIGHT TURN_RIGHT FORWARDS FORWARDS FORWARDS -> step 4 FORWARDS is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_LEFT FORWARDS FORWARDS TURN_RIGHT -> step 2 FORWARDS is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_LEFT FORWARDS FORWARDS TURN_LEFT -> step 2 FORWARDS is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_LEFT FORWARDS TURN_LEFT FORWARDS -> step 3 FORWARDS is MOVE_ILLEGAL at 8 mp (walk 4, run 6)","TURN_LEFT TURN_LEFT FORWARDS TURN_LEFT FORWARDS -> step 4 FORWARDS is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_LEFT TURN_LEFT TURN_LEFT FORWARDS FORWARDS -> step 4 FORWARDS is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_RIGHT FORWARDS TURN_LEFT FORWARDS TURN_RIGHT -> step 4 TURN_RIGHT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_RIGHT FORWARDS TURN_LEFT FORWARDS TURN_LEFT -> step 4 TURN_LEFT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_RIGHT TURN_RIGHT FORWARDS TURN_RIGHT FORWARDS -> step 4 FORWARDS is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_RIGHT FORWARDS TURN_RIGHT FORWARDS TURN_LEFT -> step 4 TURN_LEFT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_RIGHT FORWARDS TURN_RIGHT FORWARDS TURN_RIGHT -> step 4 TURN_RIGHT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_RIGHT TURN_RIGHT FORWARDS FORWARDS TURN_LEFT -> step 4 TURN_LEFT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_RIGHT TURN_RIGHT FORWARDS FORWARDS TURN_RIGHT -> step 4 TURN_RIGHT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_LEFT FORWARDS TURN_LEFT TURN_LEFT TURN_LEFT -> step 4 TURN_LEFT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_LEFT TURN_LEFT TURN_LEFT FORWARDS TURN_LEFT -> step 4 TURN_LEFT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_LEFT TURN_LEFT TURN_LEFT FORWARDS TURN_RIGHT -> step 4 TURN_RIGHT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_RIGHT TURN_RIGHT FORWARDS TURN_LEFT TURN_LEFT -> step 4 TURN_LEFT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)","TURN_RIGHT TURN_RIGHT FORWARDS TURN_RIGHT TURN_RIGHT -> step 4 TURN_RIGHT is MOVE_ILLEGAL at 7 mp (walk 4, run 6)"]} +{"board":"water","specimen":"Elemental_Battle_Armor_[Laser](Sqd5)","x":12,"y":12,"facing":0,"mp":4,"mode":"ground","prone":false,"mpUsed":0,"routes":3,"digest":"16da137fe19df484","illegal":[]} +{"board":"water","specimen":"Foot_Platoon_(Rifle_Light)","x":12,"y":12,"facing":0,"mp":4,"mode":"ground","prone":false,"mpUsed":0,"routes":3,"digest":"16da137fe19df484","illegal":[]} +{"board":"water","specimen":"Manticore_Heavy_Tank","x":12,"y":12,"facing":0,"mp":6,"mode":"ground","prone":false,"mpUsed":0,"routes":48,"digest":"23c7b643226dd55d","illegal":[]} {"board":"water","specimen":"Phoenix_Hawk_PXH-1@walk1","x":6,"y":7,"facing":0,"mp":2,"mode":"ground","prone":false,"mpUsed":0,"routes":6,"digest":"d566a813e7e95160","illegal":[]} {"board":"water","specimen":"Phoenix_Hawk_PXH-1@walk2","x":6,"y":7,"facing":0,"mp":3,"mode":"ground","prone":false,"mpUsed":0,"routes":9,"digest":"ecda91d2d9e8e826","illegal":[]} {"board":"water","specimen":"Elemental_Battle_Armor_[Laser](Sqd5)@walk1","x":12,"y":12,"facing":0,"mp":1,"mode":"ground","prone":false,"mpUsed":0,"routes":3,"digest":"16da137fe19df484","illegal":[]} diff --git a/crates/sds-core/tests/pathfind_differential.rs b/crates/sds-core/tests/pathfind_differential.rs index 7876057..7dd9ca1 100644 --- a/crates/sds-core/tests/pathfind_differential.rs +++ b/crates/sds-core/tests/pathfind_differential.rs @@ -229,11 +229,23 @@ impl Case { unit.facing = self.facing; unit.prone = self.prone; unit.mp_used = self.mp_used; - // The case's own budget, which is what MegaMek's dump was held to. + // The case's own budget, which is what MegaMek's dump was held to - + // **or the mover's own run, whichever is smaller.** + // + // The dump is bounded by both and the port was only ever told about + // one. `SdsPathfind` stops expanding at the case budget, and + // `isMoveLegal` independently holds every step to the entity's real + // allowance; for a Phoenix Hawk running 9 a budget of 4 is the binding + // one, but for a platoon running 1 a budget of 4 is not a budget at + // all. Writing 4 in asked our search for everything inside four points + // while MegaMek had answered for everything inside one, and the extra + // states came back as nine refused routes - every one of them a squad + // or a platoon taking a *second* step. + // // `walk_mp` is the machine's own and is left alone: a ground path that // wades forfeits its run and is held to that instead, which is // `MoveBoard::forfeits_run`. - unit.run_mp = self.mp; + unit.run_mp = self.mp.min(unit.run_mp); // The jets are worth what they are rated at, whatever hex the recorder // happened to leave the specimen standing in. // `Entity.getJumpMPWithTerrain` is a fact about a position, and a @@ -503,7 +515,7 @@ fn hexes_moved_is_the_most_at_the_same_price() { // it lists that we reach *more cheaply* are skipped above and counted in // `our_reachable_set_is_megameks`, which is where a price difference // belongs. - assert_eq!(compared, 37440, "every comparable state compared"); + assert_eq!(compared, 37470, "every comparable state compared"); // Where the two agree on the price and disagree on the ground covered, and // ours is always the one covering more - the assertion above is the half // that matters, because more hexes is a better target movement modifier @@ -550,6 +562,10 @@ fn the_longer_path_at_the_same_price_is_real() { walk_mp: WALK_MP, jump_mp: JUMP_MP, max_elevation_change: MEK_MAX_ELEVATION_CHANGE, + // A Mek: one point a level, and it pays for the step as well as the + // terrain. This case is about a hand-written path's price. + level_mp: sds_core::pathfind::MEK_LEVEL_MP, + pays_step: true, prone: false, mp_used: 0, ground_turn_mp: sds_core::pathfind::GROUND_TURN_MP, @@ -960,20 +976,34 @@ fn every_route_we_emit_is_one_megamek_accepts() { checked += routes.len(); } - // **Held, not lowered, and that is the reading.** The re-rated cases put - // six more movement profiles and 133 more routes through here, and the two - // rules behind these 44 - what a vehicle pays for a level, and what - // infantry pay for terrain - are open questions in `plan/candidates.md` - // rather than anything a measurement has settled. What the widening did - // find was five of its own: a mover let overspend *backwards*, which the - // whole-turn rule does not cover. Those are fixed, which is why this is - // still 44 and not 49. + // **Zero, and it was 44.** + // + // Every one of them was one of two rules the port did not have, and both + // are now read off `sds step-cost` rather than guessed: + // + // - **A Mek pays one point a level and everything else pays two**, + // climbing or dropping alike. That was 36 of the 44 - a Manticore priced + // at 6 by us and 7 by MegaMek. It is not the hovercraft rule it was taken + // for: tracked, hover and both kinds of infantry all pay two, and the Mek + // is the exception. + // - **Infantry pay the terrain instead of the step**, so light woods costs + // a platoon 1 where it costs a Mek 2. + // + // The last nine were not a rules gap at all but this file's own: a case + // budget of 4 was being written into the run allowance of a platoon that + // runs 1, so MegaMek had answered a smaller question than the port was + // asked. See `Case::unit`. + // + // A zero here is only worth as much as the set comparison beside it. It + // would also be zero if the search stopped emitting routes, which is what + // `our_reachable_set_is_megameks` exists to refuse: nothing MegaMek reaches + // is missing from ours, and nothing we reach costs more than its figure. assert_eq!( - mispriced, 44, + mispriced, 0, "routes a mover is offered that MegaMek prices above the budget" ); assert_eq!(verdicts.len(), 220, "every case has a verdict"); - assert_eq!(checked, 37772, "every route checked"); + assert_eq!(checked, 37683, "every route checked"); } /// MegaMek's answer for one case's routes. diff --git a/docs/features/deploy_ground.svg b/docs/features/deploy_ground.svg index 4f8b4a9..6473dd5 100644 --- a/docs/features/deploy_ground.svg +++ b/docs/features/deploy_ground.svg @@ -95,4 +95,4 @@ letter-spacing: 0.02em; fill: var(--wood); dominant-baseline: central; paint-order: stroke fill; stroke: var(--paper); stroke-width: 2.2px; } text.ground.heavy { fill: var(--wood-heavy); } -(0, 0) 12 hexes reachable next turn0.19(1, 0) 17 hexes reachable next turn0.27(2, 0) 18 hexes reachable next turn0.29(3, 0) 23 hexes reachable next turn0.37(4, 0) 21 hexes reachable next turn0.33(5, 0) 23 hexes reachable next turn0.37(6, 0) 18 hexes reachable next turn0.29(7, 0) 17 hexes reachable next turn0.27(8, 0) 12 hexes reachable next turn0.19(0, 1) 14 hexes reachable next turn0.22(1, 1) 18 hexes reachable next turn0.29(2, 1) 20 hexes reachable next turn0.32(3, 1) 24 hexes reachable next turn0.38(4, 1) 25 hexes reachable next turn0.40(5, 1) 24 hexes reachable next turn0.38(6, 1) 20 hexes reachable next turn0.32(7, 1) 18 hexes reachable next turn0.29(8, 1) 14 hexes reachable next turn0.22(0, 2) 15 hexes reachable next turn0.24(1, 2) 19 hexes reachable next turn0.30(2, 2) 21 hexes reachable next turn0.33(3, 2) 25 hexes reachable next turn0.40(4, 2) 27 hexes reachable next turn0.43(5, 2) 25 hexes reachable next turn0.40(6, 2) 21 hexes reachable next turn0.33(7, 2) 19 hexes reachable next turn0.30(8, 2) 15 hexes reachable next turn0.24(0, 3) 3 hexes reachable next turn0.05(1, 3) 5 hexes reachable next turn0.08(2, 3) 5 hexes reachable next turn0.08(3, 3) 7 hexes reachable next turn0.11(4, 3) 5 hexes reachable next turn0.08(5, 3) 7 hexes reachable next turn0.11(6, 3) 5 hexes reachable next turn0.08(7, 3) 5 hexes reachable next turn0.08(8, 3) 3 hexes reachable next turn0.05(0, 4) 2 hexes reachable next turn0.03(1, 4) 6 hexes reachable next turn0.10(2, 4) 12 hexes reachable next turn0.19(3, 4) 17 hexes reachable next turn0.27(4, 4) 18 hexes reachable next turn0.29(5, 4) 21 hexes reachable next turn0.33(6, 4) 18 hexes reachable next turn0.29(7, 4) 17 hexes reachable next turn0.27(8, 4) 12 hexes reachable next turn0.19(0, 5) 3 hexes reachable next turn0.05(1, 5) 10 hexes reachable next turn0.16(2, 5) 14 hexes reachable next turn0.22(3, 5) 18 hexes reachable next turn0.29(4, 5) 20 hexes reachable next turn0.32(5, 5) 21 hexes reachable next turn0.33(6, 5) 20 hexes reachable next turn0.32(7, 5) 18 hexes reachable next turn0.29(8, 5) 14 hexes reachable next turn0.22(0, 6) 2 hexes reachable next turn0.03(1, 6) 7 hexes reachable next turn0.11(2, 6) 15 hexes reachable next turn0.24(3, 6) 19 hexes reachable next turn0.30(4, 6) 21 hexes reachable next turn0.33(5, 6) 21 hexes reachable next turn0.33(6, 6) 21 hexes reachable next turn0.33(7, 6) 19 hexes reachable next turn0.30(8, 6) 15 hexes reachable next turn0.24+5+5+5+5+5+5+5+5+5DEEPDEEPDEEPDEEPDEEPDEEP0.001.00deploy_ground +(0, 0) 12 hexes reachable next turn0.19(1, 0) 17 hexes reachable next turn0.27(2, 0) 18 hexes reachable next turn0.29(3, 0) 23 hexes reachable next turn0.37(4, 0) 21 hexes reachable next turn0.33(5, 0) 23 hexes reachable next turn0.37(6, 0) 18 hexes reachable next turn0.29(7, 0) 17 hexes reachable next turn0.27(8, 0) 12 hexes reachable next turn0.19(0, 1) 14 hexes reachable next turn0.22(1, 1) 18 hexes reachable next turn0.29(2, 1) 20 hexes reachable next turn0.32(3, 1) 24 hexes reachable next turn0.38(4, 1) 25 hexes reachable next turn0.40(5, 1) 24 hexes reachable next turn0.38(6, 1) 20 hexes reachable next turn0.32(7, 1) 18 hexes reachable next turn0.29(8, 1) 14 hexes reachable next turn0.22(0, 2) 15 hexes reachable next turn0.24(1, 2) 19 hexes reachable next turn0.30(2, 2) 21 hexes reachable next turn0.33(3, 2) 25 hexes reachable next turn0.40(4, 2) 27 hexes reachable next turn0.43(5, 2) 25 hexes reachable next turn0.40(6, 2) 21 hexes reachable next turn0.33(7, 2) 19 hexes reachable next turn0.30(8, 2) 15 hexes reachable next turn0.24(0, 3) 3 hexes reachable next turn0.05(1, 3) 5 hexes reachable next turn0.08(2, 3) 5 hexes reachable next turn0.08(3, 3) 7 hexes reachable next turn0.11(4, 3) 5 hexes reachable next turn0.08(5, 3) 7 hexes reachable next turn0.11(6, 3) 5 hexes reachable next turn0.08(7, 3) 5 hexes reachable next turn0.08(8, 3) 3 hexes reachable next turn0.05(0, 4) 2 hexes reachable next turn0.03(1, 4) 3 hexes reachable next turn0.05(2, 4) 12 hexes reachable next turn0.19(3, 4) 17 hexes reachable next turn0.27(4, 4) 18 hexes reachable next turn0.29(5, 4) 21 hexes reachable next turn0.33(6, 4) 18 hexes reachable next turn0.29(7, 4) 17 hexes reachable next turn0.27(8, 4) 12 hexes reachable next turn0.19(0, 5) 3 hexes reachable next turn0.05(1, 5) 4 hexes reachable next turn0.06(2, 5) 14 hexes reachable next turn0.22(3, 5) 18 hexes reachable next turn0.29(4, 5) 20 hexes reachable next turn0.32(5, 5) 21 hexes reachable next turn0.33(6, 5) 20 hexes reachable next turn0.32(7, 5) 18 hexes reachable next turn0.29(8, 5) 14 hexes reachable next turn0.22(0, 6) 2 hexes reachable next turn0.03(1, 6) 3 hexes reachable next turn0.05(2, 6) 15 hexes reachable next turn0.24(3, 6) 19 hexes reachable next turn0.30(4, 6) 21 hexes reachable next turn0.33(5, 6) 21 hexes reachable next turn0.33(6, 6) 21 hexes reachable next turn0.33(7, 6) 19 hexes reachable next turn0.30(8, 6) 15 hexes reachable next turn0.24+5+5+5+5+5+5+5+5+5DEEPDEEPDEEPDEEPDEEPDEEP0.001.00deploy_ground diff --git a/docs/features/elevation_gain.svg b/docs/features/elevation_gain.svg index e9b0cf8..33e667f 100644 --- a/docs/features/elevation_gain.svg +++ b/docs/features/elevation_gain.svg @@ -95,4 +95,4 @@ letter-spacing: 0.02em; fill: var(--wood); dominant-baseline: central; paint-order: stroke fill; stroke: var(--paper); stroke-width: 2.2px; } text.ground.heavy { fill: var(--wood-heavy); } -22 hexes we could end this move in, running11 hexes we could end this move in, walking(4, 1) 0.500.50(3, 2) 0.330.33(4, 2) 0.500.50(5, 2) 0.670.67(3, 3) 0.330.33(4, 3) 0.500.50(5, 3) 0.670.67(2, 4) 0.170.17(3, 4) 0.330.33(4, 4) 0.500.50(5, 4) 0.670.67(6, 4) 0.830.83(2, 5) 0.170.17(3, 5) 0.330.33(4, 5) 0.500.50(5, 5) 0.670.67(6, 5) 0.830.83(2, 6) 0.170.17(3, 6) 0.330.33(4, 6) 0.500.50(5, 6) 0.670.67(6, 6) 0.830.83-4-3-2-1+1+2+3+4-4-3-2-1+1+2+3+4-4-3-2-1+1+2+3+4-4-3-2-1+1+2+3+4-4-3-2-1+1+2+3+4-4-3-2-1+1+2+3+4-4-3-2-1+1+2+3+4U1E10.001.00elevation_gaina hex no candidate reaches, so nothing measured it +14 hexes we could end this move in, running7 hexes we could end this move in, walking(4, 1) 0.500.50(4, 2) 0.500.50(3, 3) 0.330.33(4, 3) 0.500.50(5, 3) 0.670.67(3, 4) 0.330.33(4, 4) 0.500.50(5, 4) 0.670.67(3, 5) 0.330.33(4, 5) 0.500.50(5, 5) 0.670.67(3, 6) 0.330.33(4, 6) 0.500.50(5, 6) 0.670.67-4-3-2-1+1+2+3+4-4-3-2-1+1+2+3+4-4-3-2-1+1+2+3+4-4-3-2-1+1+2+3+4-4-3-2-1+1+2+3+4-4-3-2-1+1+2+3+4-4-3-2-1+1+2+3+4U1E10.001.00elevation_gaina hex no candidate reaches, so nothing measured it diff --git a/docs/features/submerged.svg b/docs/features/submerged.svg index 670a0ab..430e0c6 100644 --- a/docs/features/submerged.svg +++ b/docs/features/submerged.svg @@ -95,4 +95,4 @@ letter-spacing: 0.02em; fill: var(--wood); dominant-baseline: central; paint-order: stroke fill; stroke: var(--paper); stroke-width: 2.2px; } text.ground.heavy { fill: var(--wood-heavy); } -37 hexes we could end this move in, running11 hexes we could end this move in, walking(3, 2) 0.000.00(4, 2) 0.000.00(5, 2) 0.000.00(1, 3) 0.250.25(2, 3) 1.001.00(3, 3) 1.001.00(4, 3) 1.001.00(5, 3) 1.001.00(6, 3) 1.001.00(7, 3) 0.250.25(0, 4) 0.250.25(1, 4) 0.250.25(2, 4) 0.250.25(3, 4) 0.250.25(4, 4) 0.250.25(5, 4) 0.250.25(6, 4) 0.250.25(7, 4) 0.250.25(8, 4) 0.250.25(0, 5) 0.000.00(1, 5) 0.000.00(2, 5) 0.000.00(3, 5) 0.000.00(4, 5) 0.000.00(5, 5) 0.000.00(6, 5) 0.000.00(7, 5) 0.000.00(8, 5) 0.000.00(0, 6) 0.000.00(1, 6) 0.000.00(2, 6) 0.000.00(3, 6) 0.000.00(4, 6) 0.000.00(5, 6) 0.000.00(6, 6) 0.000.00(7, 6) 0.000.00(8, 6) 0.000.00SHOALSHOALDEEPDEEPDEEPDEEPDEEPSHOALSHOALSHOALSHOALSHOALSHOALSHOALSHOALSHOALSHOALSHOALU1E10.001.00submergeda hex no candidate reaches, so nothing measured it +37 hexes we could end this move in, running10 hexes we could end this move in, walking(3, 2) 0.000.00(4, 2) 0.000.00(5, 2) 0.000.00(1, 3) 0.250.25(2, 3) 1.001.00(3, 3) 1.001.00(4, 3) 1.001.00(5, 3) 1.001.00(6, 3) 1.001.00(7, 3) 0.250.25(0, 4) 0.250.25(1, 4) 0.250.25(2, 4) 0.250.25(3, 4) 0.250.25(4, 4) 0.250.25(5, 4) 0.250.25(6, 4) 0.250.25(7, 4) 0.250.25(8, 4) 0.250.25(0, 5) 0.000.00(1, 5) 0.000.00(2, 5) 0.000.00(3, 5) 0.000.00(4, 5) 0.000.00(5, 5) 0.000.00(6, 5) 0.000.00(7, 5) 0.000.00(8, 5) 0.000.00(0, 6) 0.000.00(1, 6) 0.000.00(2, 6) 0.000.00(3, 6) 0.000.00(4, 6) 0.000.00(5, 6) 0.000.00(6, 6) 0.000.00(7, 6) 0.000.00(8, 6) 0.000.00SHOALSHOALDEEPDEEPDEEPDEEPDEEPSHOALSHOALSHOALSHOALSHOALSHOALSHOALSHOALSHOALSHOALSHOALU1E10.001.00submergeda hex no candidate reaches, so nothing measured it diff --git a/docs/tactics/lance-entrench.svg b/docs/tactics/lance-entrench.svg index afb628a..1e7d754 100644 --- a/docs/tactics/lance-entrench.svg +++ b/docs/tactics/lance-entrench.svg @@ -116,4 +116,4 @@ started, so "held" reads as a decision and not as a missing figure. */ .held { fill: none; stroke-width: 2; stroke-dasharray: 3 2; } .held.us { stroke: var(--ours); } .held.them { stroke: var(--theirs); } -U1 Scout walks 4 hexesU2 Striker walks 5 hexesU3 Missile Boat walks 3 hexesU4 Juggernaut walks 4 hexesU1ScoutU2StrikerU3Missile BoatU4JuggernautE1ScoutE2StrikerE3Missile BoatE4Juggernaut +U1 Scout walks 4 hexesU2 Striker walks 5 hexesU3 Missile Boat walks 3 hexesU4 Juggernaut walks 3 hexesU1ScoutU2StrikerU3Missile BoatU4JuggernautE1ScoutE2StrikerE3Missile BoatE4Juggernaut diff --git a/docs/tactics/lance-flank.svg b/docs/tactics/lance-flank.svg index 40bba06..f078dd9 100644 --- a/docs/tactics/lance-flank.svg +++ b/docs/tactics/lance-flank.svg @@ -116,4 +116,4 @@ started, so "held" reads as a decision and not as a missing figure. */ .held { fill: none; stroke-width: 2; stroke-dasharray: 3 2; } .held.us { stroke: var(--ours); } .held.them { stroke: var(--theirs); } -U1 Scout walks 4 hexesU2 Striker walks 7 hexesU3 Missile Boat walks 3 hexesU4 Juggernaut walks 2 hexesU1ScoutU2StrikerU3Missile BoatU4JuggernautE1ScoutE2StrikerE3Missile BoatE4Juggernaut +U1 Scout walks 4 hexesU2 Striker walks 7 hexesU3 Missile Boat walks 3 hexesU4 Juggernaut walks 3 hexesU1ScoutU2StrikerU3Missile BoatU4JuggernautE1ScoutE2StrikerE3Missile BoatE4Juggernaut diff --git a/docs/tactics/lance-regroup.svg b/docs/tactics/lance-regroup.svg index 2631362..a82af9f 100644 --- a/docs/tactics/lance-regroup.svg +++ b/docs/tactics/lance-regroup.svg @@ -116,4 +116,4 @@ started, so "held" reads as a decision and not as a missing figure. */ .held { fill: none; stroke-width: 2; stroke-dasharray: 3 2; } .held.us { stroke: var(--ours); } .held.them { stroke: var(--theirs); } -U1 Scout walks 5 hexesU2 Striker walks 5 hexesU3 Missile Boat walks 4 hexesU4 Juggernaut walks 3 hexesU1ScoutU2StrikerU3Missile BoatU4JuggernautE1ScoutE2StrikerE3Missile BoatE4Juggernaut +U1 Scout walks 5 hexesU2 Striker walks 4 hexesU3 Missile Boat walks 4 hexesU4 Juggernaut walks 3 hexesU1ScoutU2StrikerU3Missile BoatU4JuggernautE1ScoutE2StrikerE3Missile BoatE4Juggernaut diff --git a/plan/candidates.md b/plan/candidates.md index 8ea3058..742da6b 100644 --- a/plan/candidates.md +++ b/plan/candidates.md @@ -2857,24 +2857,53 @@ costs routes, and that is the one that still sends an order the server refuses. after two cancelling turns, same hex and same facing, is refused. The port holds every mover to its allowance from the first step, so a damaged Mek's menu is empty where the game's is not. `tests/test_afford_probe.py` pins it. -- **A vehicle pays two points a level, not one.** Measured: a hovercraft - climbing one level is priced 2 by the port and 3 by MegaMek; a tank climbing - one level in woods, 3 against 4. Every route in `pathfind_routes.jsonl` that - MegaMek refuses is this rule or the one above - each says `MOVE_ILLEGAL at n - mp` beside it - and the count is pinned at 44 so it can only go down. *A rules - question for jmm: is that the levels rule for ground vehicles, and does it - apply to a drop as well as a climb?* -- **Infantry pay the terrain and not the step.** Light woods costs a platoon 1 - where the port charges 1 for the step and 1 for the woods. Measured again by - the afford probe - a platoon's entry into a hex costing 2 comes back - `mpUsed=2` where a Mek's comes back 3 - but *why* is still unconfirmed: this - is the shape of the answer and not the rule behind it. **A rules question for - jmm.** +- **A Mek pays one point a level. Everything else pays two.** *Landed, and it + is not the rule it was taken for.* The question here was whether two a level + is a ground-vehicle rule; jmm's answer was that it is a hovercraft thing, and + that left the tank-in-woods figure - ours 3, MegaMek 4 - unexplained. `sds + step-cost` prices every step of the four boards for every specimen and settles + it: **tracked, hover and both kinds of infantry all pay two, and the Mek is + the exception.** It is symmetric, so a drop costs what a climb costs. That was + 36 of the 44 refused routes. +- **Infantry pay the terrain and not the step.** *Landed.* `max(1, terrain)` + rather than `1 + terrain`: light woods costs a platoon 1 and heavy woods 2, + where a Mek pays 2 and 3, and clear ground costs both a point. Measured over + every step of four boards. *Why* it is so is still unconfirmed - this is the + shape of the rule read off the jar, not the rule from the book. **A rules + question for jmm**, though nothing now depends on the answer. Two more limits that are not new but are now measured: a VTOL is not a ground mover and `pathfind` does not describe one, and buildings and bridges are still walked over at ground level. +### Every route we emit is one MegaMek accepts, and now that is 0 refused + +It was 44 for as long as the check existed, pinned so it could only go down. +`sds step-cost` took it to **0** over 37,683 routes, and the two rules above are +the whole of it - 36 the level pricing, and the rest infantry terrain - bar the +last nine, which were not a rules gap at all: + +- **A case budget was being written into a run allowance it did not fit.** + `Case::unit` set `run_mp` to the case's budget of 4, and a foot platoon runs 1. + `SdsPathfind` stops expanding at the budget *and* `isMoveLegal` holds every + step to the entity's real allowance, so MegaMek had answered for everything + inside one point while the port was asked for everything inside four. The nine + extra states came back as refused routes, every one a squad or a platoon + taking a second step. It is `self.mp.min(unit.run_mp)` now. + +**A zero here is worth only as much as the set comparison beside it**, because +it would also be zero if the search stopped emitting routes. It did not: +`our_reachable_set_is_megameks` still reaches every stand MegaMek reaches, at no +greater price, and the 168 states we reach beyond the corpus are exactly what +this check exists to judge - and it accepts all of them. + +**What `step-cost` still does not model**, kept as a `MISMATCH` row in its own +recording rather than rounded off: a Mek entering water exactly two deep is +charged one more than the model predicts. Depth 1 and depth 3 both fit, which is +what makes it a corner and not a term. Eighteen steps, and *under*-predicted - +the direction that can send the server a move it refuses - so +`test_what_the_model_still_misses_is_written_down` pins it small. + ### A differential test whose oracle had been taught our assumption **This one is worth stating in general terms, because the usual defence was in -- 2.54.0