From 846733f061f43a0078ea37308e4afde2ee70dfb4 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 20 Aug 2026 14:14:26 -0400 Subject: [PATCH] docs(candidates): render what the bot could propose against what it does `examples/reachable.rs` prints one board twice: every stand a 6 MP Mek can reach, then the four hardcoded verbs from the same hex. Three distinct end hexes offered out of forty-five reachable, which is the argument for this epic in one picture. Runnable, so it cannot drift from the code it illustrates. --- crates/sds-core/examples/reachable.rs | 269 ++++++++++++++++++++++++++ plan/candidates.md | 12 +- 2 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 crates/sds-core/examples/reachable.rs diff --git a/crates/sds-core/examples/reachable.rs b/crates/sds-core/examples/reachable.rs new file mode 100644 index 0000000..92fbd4f --- /dev/null +++ b/crates/sds-core/examples/reachable.rs @@ -0,0 +1,269 @@ +//! What the bot could propose, against what it does propose. +//! +//! Prints one board twice: once with every `(hex, facing)` state a 6 MP Mek can +//! actually reach and stop in, and once with the four hardcoded verbs the +//! current generator offers from the same hex. The contrast is the argument for +//! the `candidates` epic - no weight vector can select a hex that was never +//! proposed. +//! +//! cargo run -p sds-core --example reachable + +use std::collections::BTreeMap; + +use sds_core::hex::{distance, facing_toward, translated, Stand}; +use sds_core::pathfind::{hexes_in, reachable, MoveBoard, Walker, MEK_MAX_ELEVATION_CHANGE}; +use sds_core::wire::{Board, BoardHex, Coord}; + +const WIDTH: i32 = 17; +const HEIGHT: i32 = 13; +const START: Coord = Coord::new(4, 8); +const FACING: i32 = 0; +const MP: i32 = 6; +const ENEMY: Coord = Coord::new(12, 3); + +/// A small board with something on it: a belt of woods, a two-level ridge with +/// one saddle a Mek can cross, and a pond. +fn board_message() -> Board { + let mut hexes: Vec = Vec::new(); + let mut put = |x: i32, y: i32, level: i32, terrain: &[(&str, i32)]| { + hexes.push(BoardHex { + x, + y, + level, + terrain: terrain + .iter() + .map(|(k, v)| ((*k).to_string(), *v)) + .collect::>(), + }); + }; + + // Woods, thickening toward the middle. + for y in 5..=10 { + put(6, y, 0, &[("woods", 1)]); + put(7, y, 0, &[("woods", 2)]); + } + // A ridge, two levels up, with a saddle at y = 7 that a Mek can climb. + for y in 2..=11 { + let level = if y == 7 { 1 } else { 2 }; + put(10, y, level, &[]); + put(11, y, level, &[]); + } + // A pond. + for (x, y) in [(3, 4), (4, 4), (3, 3), (4, 3)] { + put(x, y, 0, &[("water", 1)]); + } + + Board { + width: WIDTH, + height: HEIGHT, + hexes, + } +} + +fn glyph(board: &MoveBoard, at: Coord) -> char { + let hex = board.hex(at); + if hex.impassable { + '#' + } else if hex.depth > 0 { + '~' + } else if hex.terrain_mp >= 2 { + 'W' + } else if hex.terrain_mp >= 1 { + 'w' + } else if hex.level >= 2 { + '+' + } else if hex.level == 1 { + '^' + } else { + '.' + } +} + +/// Two text lines per board row, because odd columns sit half a hex lower. +fn render(board: &MoveBoard, mark: &dyn Fn(Coord) -> char) -> String { + let mut out = String::new(); + for y in 0..HEIGHT { + for parity in [0, 1] { + let mut line = String::new(); + if parity == 1 { + line.push_str(" "); + } + for x in (parity..WIDTH).step_by(2) { + let at = Coord::new(x, y); + line.push(glyph(board, at)); + line.push(mark(at)); + line.push_str(" "); + } + out.push_str(line.trim_end()); + out.push('\n'); + } + } + out +} + +/// `walk_toward` from `sds-bot/src/unit.rs`, transcribed so the two pictures +/// come out of the same run. A greedy stepper: turn to face the target, then +/// walk while each step is closer than the last. +fn walk_toward(board: &Board, from: Coord, facing: i32, target: Coord, budget: i32) -> Coord { + let mut position = from; + let mut heading = facing; + let mut left = budget; + + let wanted = facing_toward(position, target); + let mut turns = (wanted - heading).rem_euclid(6); + let right = turns <= 3; + if !right { + turns = 6 - turns; + } + for _ in 0..turns.min(left.max(0)) { + heading = if right { + (heading + 1) % 6 + } else { + (heading + 5) % 6 + }; + left -= 1; + } + while left > 0 { + let next = translated(position, heading, 1); + if distance(next, target) >= distance(position, target) || !board.passable(position, next) { + break; + } + let cost = board.step_cost(position, next); + if cost > left { + break; + } + position = next; + left -= cost; + } + position +} + +/// The four verbs, in the order `sds-bot` offers them. +fn verbs(board: &Board) -> Vec<(&'static str, Coord)> { + // The enemy is facing north, so two hexes off its left shoulder. + const ENEMY_FACING: i32 = 0; + let flank = translated(ENEMY, (ENEMY_FACING + 2) % 6, 2); + let opposite = (facing_toward(START, ENEMY) + 3) % 6; + let mut refuge = START; + for step in 1..=MP { + let candidate = translated(START, opposite, step); + if !board.contains(candidate) { + break; + } + refuge = candidate; + } + // "Take range" walks toward the hex that puts us at our ideal bracket. + let mut ideal = ENEMY; + for step in 1..=6 { + let candidate = translated(ENEMY, facing_toward(ENEMY, START), step); + if board.contains(candidate) { + ideal = candidate; + } + } + vec![ + ( + "close on the enemy", + walk_toward(board, START, FACING, ENEMY, MP), + ), + ("back off", walk_toward(board, START, FACING, refuge, MP)), + ( + "flank the enemy", + walk_toward(board, START, FACING, flank, MP), + ), + ("take range", walk_toward(board, START, FACING, ideal, MP)), + ] +} + +fn main() { + let message = board_message(); + let board = MoveBoard::new(&message); + let walker = Walker { + start: Stand::new(START, FACING), + mp: MP, + max_elevation_change: MEK_MAX_ELEVATION_CHANGE, + prone: false, + }; + + let reached = reachable(&board, &walker, &[]); + let mut facings: BTreeMap<(i32, i32), i32> = BTreeMap::new(); + for state in &reached { + *facings + .entry((state.stand.hex.x, state.stand.hex.y)) + .or_insert(0) += 1; + } + + println!( + "board {WIDTH}x{HEIGHT}, a Mek with {MP} MP at ({}, {}) facing {FACING}, enemy at ({}, {})", + START.x, START.y, ENEMY.x, ENEMY.y + ); + println!(". clear w light woods W heavy woods ~ depth-1 water ^ level 1 + level 2"); + println!(); + + println!( + "after: {} states over {} hexes, digit = reachable facings", + reached.len(), + hexes_in(&reached) + ); + println!( + "{}", + render(&board, &|at| { + match facings.get(&(at.x, at.y)) { + _ if at == START => '@', + Some(n) => char::from_digit(*n as u32, 10).unwrap_or('+'), + None => ' ', + } + }) + ); + + let offered = verbs(&message); + println!("before: {} proposals, one facing each", offered.len()); + println!( + "{}", + render(&board, &|at| { + match offered.iter().position(|(_, end)| *end == at) { + _ if at == START => '@', + Some(i) => char::from_digit(i as u32 + 1, 10).unwrap_or('+'), + None => ' ', + } + }) + ); + for (i, (name, end)) in offered.iter().enumerate() { + println!(" {} {:<20} ends at ({}, {})", i + 1, name, end.x, end.y); + } + + let distinct: std::collections::BTreeSet<_> = offered.iter().map(|(_, end)| *end).collect(); + println!(); + println!( + "{} distinct end hexes offered out of {} the unit can reach", + distinct.len(), + hexes_in(&reached) + ); + + // MP spent and hexes covered are different numbers, and the second is the + // one a shooter cares about. + let mut by_modifier: BTreeMap = BTreeMap::new(); + for state in &reached { + *by_modifier + .entry(state.target_movement_modifier()) + .or_insert(0) += 1; + } + let spread: Vec = by_modifier + .iter() + .map(|(modifier, count)| format!("+{modifier}: {count}")) + .collect(); + println!( + "target movement modifiers across those states - {}", + spread.join(", ") + ); + let dearest = reached.iter().max_by_key(|r| r.mp_spent - r.hexes_moved); + if let Some(state) = dearest { + println!( + "dearest ground covered: ({}, {}) facing {} costs {} MP for {} hexes", + state.stand.hex.x, + state.stand.hex.y, + state.stand.facing, + state.mp_spent, + state.hexes_moved + ); + } +} diff --git a/plan/candidates.md b/plan/candidates.md index 1f6bded..e3d916a 100644 --- a/plan/candidates.md +++ b/plan/candidates.md @@ -86,7 +86,7 @@ good offence and bad defence, and a single operator over `N` cannot say so. **Foundations** -- [ ] `Stand { hex, facing }` in `hex.rs` with parity-correct neighbour and turn +- [x] `Stand { hex, facing }` in `hex.rs` with parity-correct neighbour and turn helpers. Test: hand-checked adjacency on both column parities; six turns returns to start - [ ] `bridge/sds/SdsPathfind.java` dumps MegaMek's own legal end states for a @@ -94,19 +94,21 @@ good offence and bad defence, and a single operator over `N` cannot say so. offline fixture, committed once**, the way the LOS corpus and the hit tables are - nothing dumps during a match. Bound it to a handful of `(board, start, MP)` combinations -- [ ] Whatever movement inputs the board message lacks - terrain kind, +- [x] Whatever movement inputs the board message lacks - terrain kind, elevation - added to the wire. Cover is *not* among them: the LOS map - already carries it + already carries it. The board already carried both; what it could not + carry is the *unit*, so `movementMode` and `maxElevationChange` went on + the observation instead **Ground movement** -- [ ] `pathfind.rs`: Dijkstra over `Stand` with terrain MP, elevation delta +- [x] `pathfind.rs`: Dijkstra over `Stand` with terrain MP, elevation delta (illegal above two levels), 1 MP per hexside. Test: every state within budget; the set for MP *n* is a subset of MP *n+1*; deterministic - [ ] **Differential test against the dumped corpus.** Our reachable state set equals MegaMek's. This is the one that matters - the LOS port and the hit tables were both validated this way and both turned up real bugs -- [ ] `hexes_moved` recorded separately from `mp_spent`. Test: a path through +- [x] `hexes_moved` recorded separately from `mp_spent`. Test: a path through woods spends more MP than hexes; a straight clear path spends equal - [ ] TMM from the existing `target_movement_modifier(hexes_moved, jumped)`. Test: differential against a pinned dump of -- 2.51.2