diff --git a/crates/sds-bot/src/candidates.rs b/crates/sds-bot/src/candidates.rs new file mode 100644 index 0000000..22a8588 --- /dev/null +++ b/crates/sds-bot/src/candidates.rs @@ -0,0 +1,297 @@ +//! The reachable-state generator, wired into the bot. +//! +//! Everything under `sds-core::stands` and `sds-core::surface` was built and +//! verified offline and never ran in a match. This module is the one call site +//! that puts it in front of a host: sweep the `(hex, facing)` states this unit +//! can reach, score every one of them against where the enemy is standing, +//! surface one per weight vector, and turn the pathfinder's route into the step +//! list the host wants. +//! +//! **Off unless `SDS_CANDIDATES` is set.** With it unset nothing here runs and +//! `unit::propose_including` builds the same four-verb menu it always did, byte +//! for byte - see `unit::tests::flag_off_is_the_four_verb_menu`. +//! +//! **The four verbs stay on the menu when it is on.** They are a transitional +//! instrument: `plan/candidates.md` asks how often the scorer prefers them, and +//! it can only be asked if both sets are in one decision. [`is_generated`] is +//! how a chosen label is attributed, and `main.rs` keeps the tally. +//! +//! Two things are deliberately cheaper here than in the offline renders: +//! +//! - **Enemies are where they are**, one [`Presence`] each, not their own +//! reachable sets. `M = 1` per enemy rather than a few hundred, which is the +//! difference between a sweep that fits a turn and one that does not. It +//! under-models a foe that has not moved yet, and that is a known cost stated +//! rather than hidden. +//! - **Walk MP, not run MP.** The state count is what the time is linear in. + +use std::collections::BTreeSet; + +use sds_core::hex::Stand; +use sds_core::los::LosCache; +use sds_core::pathfind::{search, MoveBoard, Search, Walker}; +use sds_core::plan::Proposal; +use sds_core::stance::Stance; +use sds_core::stands::{self, Combatant, Foe, Mover, Params, Presence}; +use sds_core::surface::{self, facts_of}; +use sds_core::volley::{MountedWeapon, VolleyCache}; +use sds_core::wire::{Action, Board, Coord, Observation, Unit}; + +/// The marker every generated label carries. +/// +/// A decision log is attributed by this and by nothing else, so a label that +/// did not come from here cannot be counted as one that did. +pub const MARK: &str = " @ "; + +/// Did this label come from the reachable-state sweep? +pub fn is_generated(label: &str) -> bool { + label.contains(MARK) +} + +/// Is the generator switched on? +/// +/// Read per call rather than cached in a static: a test that sets the variable +/// has to be able to see it, and this is a string compare against an +/// environment lookup once per unit per round. +pub fn enabled() -> bool { + matches!( + std::env::var("SDS_CANDIDATES").as_deref(), + Ok("1") | Ok("true") | Ok("yes") + ) +} + +/// The most generated states one unit puts on the menu. +/// +/// The candidates invariant: every performance estimate in this repository +/// assumes about twenty curated candidates per unit, and `ForceThinker::command` +/// is linear in what it is handed. The sweep scores every reachable state - a +/// hundred and up - and the surfacing rule is what cuts that to a menu. This is +/// the ceiling on the result, not a prune inside the sweep. +pub const MAX_GENERATED: usize = 16; + +/// What one unit's sweep cost, in counters. +#[derive(Debug, Clone, Copy, Default)] +pub struct Cost { + pub states: u64, + pub exchanges: u64, + pub surfaced: u64, +} + +/// The generated half of a unit's menu. +/// +/// `None` when the flag is off, when the unit is not on the board, or when +/// there is nothing to score against - each of which leaves the four verbs +/// doing the whole job rather than leaving the unit with no menu at all. +pub fn generate( + observation: &Observation, + board: &Board, + los: &LosCache, + me: &Unit, + stance: &Stance, +) -> Option<(Vec, Cost)> { + let enemies: Vec<&Unit> = observation + .units + .iter() + .filter(|u| !u.friendly && !u.destroyed) + .collect(); + if enemies.is_empty() { + return None; + } + let friends: Vec<&Unit> = observation + .units + .iter() + .filter(|u| u.friendly && u.id != me.id && !u.destroyed) + .collect(); + let blocked: Vec = observation + .units + .iter() + .filter(|u| u.id != me.id && !u.destroyed) + .map(|u| Coord::new(u.x, u.y)) + .collect(); + + let move_board = MoveBoard::new(board); + let found = search(&move_board, &Walker::walking(me), &blocked); + if found.reached.is_empty() { + return None; + } + + let mine: Vec = me + .weapons + .iter() + .filter(|w| w.usable) + .map(MountedWeapon::from_wire) + .collect(); + let mover = Mover { + who: Combatant::mek(me, mine, me.gunnery.max(0)), + elevation: me.elevation, + jumped: false, + terrain: Some(&move_board), + }; + // One position each. See the module note: this is the cheap half of the + // trade, and it is the half that makes the sweep fit inside a turn. + let foes: Vec> = enemies + .iter() + .map(|unit| Foe { + who: Combatant::mek( + unit, + unit.weapons + .iter() + .filter(|w| w.usable) + .map(MountedWeapon::from_wire) + .collect(), + unit.gunnery.max(0), + ), + may_be: vec![Presence::placed(unit)], + }) + .collect(); + + let mut cache = VolleyCache::new(); + let sweep = stands::score_stands(&mut cache, los, &mover, &found.reached, &foes); + let ranking = stands::rank(&sweep, &Params::default()); + let facts = facts_of(&sweep); + + // The surfacing rule. The lance's own stance goes in first - this is where + // `let _ = stance;` stopped being true - and the vocabulary spreads the + // rest of the menu over the intents it did not ask for, so a stance that + // turns out to be wrong is not the only thing the force gets to choose + // between. + let targets: Vec = ranking + .scored + .first() + .map(|score| score.per_target.iter().map(|(id, _)| *id).collect()) + .unwrap_or_default(); + let mut vectors = surface::vectors_for("stance", stance, &targets); + vectors.extend(surface::vocabulary(&targets)); + let surfaced = surface::surface(&ranking.scored, &facts, &vectors); + + // Every stand was scored; `stands::propose` measures all of them. Only the + // surfaced ones are carried up, in the order the vectors were run, each one + // once. + let measured = stands::propose(los, &mover, &ranking, &foes, &friends); + let mut seen: BTreeSet = BTreeSet::new(); + let mut out: Vec = Vec::new(); + for pick in &surfaced.picks { + if out.len() >= MAX_GENERATED || !seen.insert(pick.at) { + continue; + } + let Some(score) = ranking.scored.get(pick.at) else { + continue; + }; + let stand = score.reach.stand; + // A stand the route cannot be walked back to is dropped rather than + // sent with a guessed path. The host would reject the order and the + // unit would stand still, which is a worse answer than one fewer + // candidate. + let Some(steps) = steps_to(&found, stand, me.prone) else { + continue; + }; + let Some(mut proposal) = measured.get(pick.at).cloned() else { + continue; + }; + proposal.label = format!( + "{}{MARK}({}, {}) facing {}", + pick.label, stand.hex.x, stand.hex.y, stand.facing + ); + proposal.action = Action::Move { steps }; + proposal.notes.push(format!( + "cost {:.3}, {} mp, {} hexes", + pick.cost, score.reach.mp_spent, score.reach.hexes_moved + )); + out.push(proposal); + } + if out.is_empty() { + return None; + } + let cost = Cost { + states: sweep.stats.stands, + exchanges: sweep.stats.exchanges, + surfaced: out.len() as u64, + }; + Some((out, cost)) +} + +/// The pathfinder's route, as the step list the host plays. +/// +/// The route is a list of `(hex, facing)` states one edge apart, which is +/// exactly what a `MovePath` is: a hex change is `FORWARDS`, a facing change is +/// one hexside of turn. Nothing is inferred - a route that disagrees with +/// itself returns `None` rather than a path the server will reject. +pub fn steps_to(found: &Search, stand: Stand, prone: bool) -> Option> { + let route = found.path_to(stand)?; + let mut steps: Vec = Vec::new(); + // A Mek on its face stands up before it does anything else, and the search + // already spent the MP for it: see `Walker::budget`. + if prone { + steps.push("GET_UP".to_string()); + } + for pair in route.windows(2) { + let (from, to) = (pair[0], pair[1]); + if from.hex == to.hex { + let turn = (to.facing - from.facing).rem_euclid(6); + match turn { + 1 => steps.push("TURN_RIGHT".to_string()), + 5 => steps.push("TURN_LEFT".to_string()), + _ => return None, + } + } else if from.facing == to.facing && from.ahead() == to.hex { + steps.push("FORWARDS".to_string()); + } else { + return None; + } + } + Some(steps) +} + +#[cfg(test)] +mod tests { + use super::*; + use sds_core::hex::Stand; + + #[test] + fn a_generated_label_is_attributable() { + assert!(is_generated(&format!("hold{MARK}(3, 4) facing 1"))); + assert!(!is_generated("close on Mek 2")); + assert!(!is_generated("hold position")); + assert!(!is_generated("back off")); + } + + /// The route the pathfinder took, walked back into steps the host plays. + #[test] + fn a_route_becomes_a_step_list() { + let mut hexes = Vec::new(); + for x in 0..6 { + for y in 0..6 { + hexes.push(sds_core::wire::BoardHex { + x, + y, + level: 0, + terrain: std::collections::BTreeMap::new(), + }); + } + } + let board = Board { + width: 6, + height: 6, + hexes, + }; + let move_board = MoveBoard::new(&board); + let walker = Walker { + start: Stand::new(Coord::new(2, 4), 0), + mp: 4, + max_elevation_change: 2, + prone: false, + }; + let found = search(&move_board, &walker, &[]); + let mut checked = 0; + for reach in &found.reached { + let steps = steps_to(&found, reach.stand, false).expect("every reached stand routes"); + // Turning is free of ground and forward steps are not, so the two + // counts are the pathfinder's own numbers rather than a second + // opinion about them. + let forwards = steps.iter().filter(|s| *s == "FORWARDS").count() as i32; + assert_eq!(forwards, reach.hexes_moved, "{:?}", reach.stand); + checked += 1; + } + assert!(checked > 20, "only {checked} stands reached"); + } +} diff --git a/crates/sds-bot/src/main.rs b/crates/sds-bot/src/main.rs index 0b92561..36bf004 100644 --- a/crates/sds-bot/src/main.rs +++ b/crates/sds-bot/src/main.rs @@ -35,6 +35,7 @@ use sds_core::wire::{Action, Attack, Board, Coord, Incoming, Observation, Reply, use sds_node::{Node, Registry, Request}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +mod candidates; mod force; mod imitate; mod unit; @@ -51,6 +52,55 @@ static EMPTY_BOARD: std::sync::LazyLock = std::sync::LazyLock::new(Board: /// and is counted like one. const MAX_FIRE_CANDIDATES: usize = 19; +/// What the movement planner cost, and who won it. +/// +/// **Observability only.** Nothing here is read by a decision: the clock is +/// started and stopped around a whole planning cycle and its value goes to +/// stderr, never into a feature, a budget or a tie-break. That is what keeps +/// "results must not depend on completion order" true with a timer in the file. +#[derive(Default)] +struct PlanTally { + /// Planning cycles run. + cycles: u64, + /// Wall time over all of them, and the worst single one. + total_ms: f64, + worst_ms: f64, + /// Orders chosen, split by which generator produced the winning label. + chosen_generated: u64, + chosen_verbs: u64, +} + +impl PlanTally { + /// The share of chosen orders that came from the four verbs. + /// + /// The number `plan/candidates.md` asks for before the verbs are removed. + fn verb_share(&self) -> f64 { + let total = self.chosen_generated + self.chosen_verbs; + if total == 0 { + 0.0 + } else { + self.chosen_verbs as f64 / total as f64 + } + } + + fn summary(&self) -> String { + format!( + "{} cycles, {:.0} ms mean, {:.0} ms worst; \ +chosen {} generated / {} four-verb ({:.0}% verbs)", + self.cycles, + if self.cycles == 0 { + 0.0 + } else { + self.total_ms / self.cycles as f64 + }, + self.worst_ms, + self.chosen_generated, + self.chosen_verbs, + self.verb_share() * 100.0, + ) + } +} + /// The bot's memory between decisions. struct Bot { board: Option, @@ -79,6 +129,8 @@ struct Bot { latches: Latches, /// The `M` the firing phase scores with. The same set the forces use. weights: Weights, + /// What planning cost and who won it. Printed, never read by a decision. + tally: PlanTally, } /// Where the weights come from, and what to say when they cannot be read. @@ -162,6 +214,7 @@ impl Bot { declared: Vec::new(), latches: Latches::new(), weights, + tally: PlanTally::default(), }) } @@ -206,6 +259,9 @@ impl Bot { self.training.clear(); self.declared.clear(); self.latches.observe(observation); + // Observability only - see `PlanTally`. Read outside the decision, and + // the value never enters one. + let started = std::time::Instant::now(); let mine = Self::mine(observation); // What the other side has left, so a force can price finishing a @@ -302,6 +358,14 @@ impl Bot { for chosen in &decision.chosen { if let Some(thought) = decision.considered.iter().find(|t| t.unit == chosen.unit) { if let Some(proposal) = thought.proposals.get(chosen.proposal) { + // Which generator won, attributed by the label's own + // marker rather than by an index into a list whose + // shape depends on the flag. + if candidates::is_generated(&proposal.label) { + self.tally.chosen_generated += 1; + } else { + self.tally.chosen_verbs += 1; + } self.orders.insert(chosen.unit, proposal.action.clone()); } } @@ -311,6 +375,20 @@ impl Bot { self.decisions.push(decision); } + let spent = started.elapsed().as_secs_f64() * 1000.0; + self.tally.cycles += 1; + self.tally.total_ms += spent; + self.tally.worst_ms = self.tally.worst_ms.max(spent); + // Printed every round rather than only at the end. A match kills the + // bot rather than closing its stdin, so an end-of-stream summary is + // exactly the line that never reaches a log. + eprintln!( + "[sds-bot] round {}: planned {} unit(s) in {spent:.0} ms; {}", + observation.round, + self.orders.len(), + self.tally.summary() + ); + self.planned_round = Some(observation.round); Ok(()) } @@ -768,6 +846,7 @@ async fn main() -> Result<()> { stdout.write_all(b"\n").await?; stdout.flush().await?; } + eprintln!("[sds-bot] movement: {}", bot.tally.summary()); if let Some(recorder) = imitating.as_ref() { eprintln!("{}", recorder.summary()); } diff --git a/crates/sds-bot/src/unit.rs b/crates/sds-bot/src/unit.rs index b5682f2..2ecc3cf 100644 --- a/crates/sds-bot/src/unit.rs +++ b/crates/sds-bot/src/unit.rs @@ -29,6 +29,8 @@ use sds_core::wire::{Board, Coord, Observation, Shot, Unit}; use sds_core::Role; use sds_node::{Request, Response, Thinker}; +use crate::candidates; + /// The shots this unit would have at `target` from `range`, as the wire would /// describe them. /// @@ -523,14 +525,33 @@ pub fn propose_including( let tilt = preference.weight(intent) * 2.0; appraisal.set(intent, appraisal.get(intent) + tilt); } - let _ = stance; + + let mut proposals = measure(&me, &offers, observation, los, latches); + // The reachable-state sweep, appended rather than substituted. The four + // verbs are a transitional instrument and `plan/candidates.md` asks how + // often the scorer still prefers them; that question needs both sets in one + // decision. Appending also keeps `observed_index` - an index into what + // `measure` produced - pointing at the same row it always did. + // + // The flag is read here and nowhere else on this path. With it off not one + // line of `candidates` runs and this function is what it was. + if candidates::enabled() { + if let Some((generated, cost)) = candidates::generate(observation, board, los, &me, stance) + { + eprintln!( + "[sds-bot] unit {}: {} states, {} exchanges, {} surfaced", + me.id, cost.states, cost.exchanges, cost.surfaced + ); + proposals.extend(generated); + } + } ( UnitThought { unit: me.id, role: role.name().to_string(), appraisal, - proposals: measure(&me, &offers, observation, los, latches), + proposals, role_conflict, }, observed_index, @@ -677,3 +698,186 @@ fn measure( } proposals } + +#[cfg(test)] +mod tests { + use super::*; + use sds_core::wire::{BoardHex, Weapon}; + use std::collections::BTreeMap; + + fn board() -> Board { + let mut hexes = Vec::new(); + for x in 0..12 { + for y in 0..12 { + hexes.push(BoardHex { + x, + y, + level: 0, + terrain: BTreeMap::new(), + }); + } + } + Board { + width: 12, + height: 12, + hexes, + } + } + + fn unit(id: i32, friendly: bool, x: i32, y: i32) -> Unit { + let team = if friendly { 1 } else { 2 }; + serde_json::from_str::(&format!( + r#"{{ + "id": {id}, "name": "Mek {id}", "ownerId": {team}, "team": {team}, + "friendly": {friendly}, "x": {x}, "y": {y}, "facing": 0, + "weight": 55.0, "walkMp": 4, "runMp": 6, + "armor": 100, "armorMax": 100, "internal": 60, "internalMax": 60, + "heat": 0, "heatCapacity": 10, "gunnery": 4, "piloting": 5, + "role": "BRAWLER", "forcePath": ["Alpha"], + "locations": [ + {{"name": "CT", "armor": 20, "armorMax": 22, "rearArmor": 6, + "rearArmorMax": 8, "internal": 18, "internalMax": 18, + "engine": true, "gyro": true, "weapons": 1, "weaponDamage": 10.0}} + ], + "weapons": [] + }}"# + )) + .map(|mut u: Unit| { + u.weapons = vec![Weapon { + id: 1, + name: "PPC".into(), + location: Some("RT".into()), + rear_mounted: false, + heat: 10, + short: 6, + medium: 12, + long_range: 18, + avg_damage_short: 10.0, + avg_damage_medium: 10.0, + avg_damage_long: 10.0, + rack_size: 0, + damage_per_packet: 10.0, + usable: true, + ammo: None, + }]; + u + }) + .expect("unit parses") + } + + fn scene() -> (Observation, Board) { + let board = board(); + let observation = Observation { + seq: 1, + round: 1, + phase: "MOVEMENT".into(), + player_id: 1, + team: 1, + actor: Some(1), + units: vec![unit(1, true, 3, 9), unit(11, false, 6, 3)], + shots: Vec::new(), + deploy_hexes: Vec::new(), + }; + (observation, board) + } + + fn labels(thought: &UnitThought) -> Vec { + thought.proposals.iter().map(|p| p.label.clone()).collect() + } + + /// With the flag off the menu is the four verbs and nothing else, and + /// nothing from the sweep has leaked into it. + /// + /// Byte-identity is checked the only way a single build can check it: the + /// same call is made with the flag on in between, and the two flag-off + /// serialisations must match exactly. Nothing the generator does may leave + /// a mark on the path that does not run it. + #[test] + fn flag_off_is_the_four_verb_menu() { + let (observation, board) = scene(); + let los = LosCache::new(&board, Rules::default()); + + std::env::remove_var("SDS_CANDIDATES"); + let before = propose( + &observation, + &board, + &los, + 1, + &Stance::default(), + &Latches::new(), + ); + assert_eq!( + labels(&before), + vec![ + "hold position".to_string(), + "close on Mek 11".to_string(), + "back off".to_string(), + "flank Mek 11".to_string(), + "take brawler range".to_string(), + ] + ); + assert!(!labels(&before).iter().any(|l| candidates::is_generated(l))); + + std::env::set_var("SDS_CANDIDATES", "1"); + let on = propose( + &observation, + &board, + &los, + 1, + &Stance::default(), + &Latches::new(), + ); + assert!( + on.proposals.len() > before.proposals.len(), + "the flag added nothing: {} proposals", + on.proposals.len() + ); + assert!(labels(&on).iter().any(|l| candidates::is_generated(l))); + // Every generated candidate carries a step list the host could play. + for proposal in on + .proposals + .iter() + .filter(|p| candidates::is_generated(&p.label)) + { + let sds_core::wire::Action::Move { steps } = &proposal.action else { + panic!("a movement candidate that is not a move"); + }; + assert!( + steps.iter().all(|s| { + matches!( + s.as_str(), + "FORWARDS" | "TURN_LEFT" | "TURN_RIGHT" | "GET_UP" + ) + }), + "unplayable steps {steps:?}" + ); + } + + std::env::remove_var("SDS_CANDIDATES"); + let after = propose( + &observation, + &board, + &los, + 1, + &Stance::default(), + &Latches::new(), + ); + assert_eq!( + serde_json::to_string(&before).expect("thought serialises"), + serde_json::to_string(&after).expect("thought serialises"), + ); + } + + /// The generator is capped. Every performance estimate in this repository + /// assumes about twenty candidates a unit, and a sweep produces hundreds. + #[test] + fn the_generated_menu_stays_capped() { + let (observation, board) = scene(); + let los = LosCache::new(&board, Rules::default()); + let me = observation.units[0].clone(); + let generated = candidates::generate(&observation, &board, &los, &me, &Stance::default()); + let (proposals, cost) = generated.expect("a unit with somewhere to go generates"); + assert!(proposals.len() <= candidates::MAX_GENERATED); + assert!(cost.states > proposals.len() as u64, "{cost:?}"); + } +}