//! Where to put a machine down, scored the way a move is scored. //! //! **Deployment is a movement decision one ply early.** A deploy hex is not //! worth anything on its own: it is worth what the unit can do from it on the //! turn after, and that is a question the movement machinery already answers. //! So this reuses it whole - [`search`] for what is reachable, the enemy //! envelope for where the enemy could be, [`stands::score_stands`] for what the //! exchange would look like from each of those places, and the same [`Weights`] //! the force scores a move with. What this module adds is the two features in //! [`sds_core::features::deployment`] and the loop that pairs a deploy hex with //! the best move it opens. //! //! It replaces a one-line placeholder - the legal hex nearest the middle of the //! board, facing in - which read no enemy position, no terrain and no force //! shape, and gave the same answer whether it was our first unit or our last. //! Over one 418-game benchmark it put **115** units into water against //! Princess's 45, because on a river board the middle of the board is the //! river. //! //! # The two regimes are one policy //! //! Deployment alternates, so by our third and fourth machines the enemy's //! earlier placements are on the board and our first machine's were not. Those //! are the two regimes the design asks for, and they are not two code paths: //! //! - **Nobody down yet.** There are no foes, so the sweep prices no exchange //! and every enemy-facing feature reads the value its own documentation gives //! for an empty enemy list. What is left is what the hex intrinsically //! offers: the ground it opens, what it can see, the movement modifier the //! moves it allows would earn, and how near the rest of our force it is. //! - **Somebody down.** The same sweep now has an enemy envelope to price //! against, so the same features carry the exchange, and the hex that wins is //! the one whose best next-turn stand has the best line onto where the enemy //! could go. //! //! One menu, one weight vector, one argmax. A regime is what the policy //! degenerates to when the board is empty, not a branch. //! //! # What is approximated, and why //! //! A stand is scored **once**, per `(hex, facing, jumped)`, at the cheapest //! arrival any candidate has to it. Scoring it once per candidate would be //! exact and would multiply the sweep by the number of candidates - which is //! the deployment zone times six - and the sweep is the expensive half. Scoring //! it once also makes the comparison between two deploy hexes mean something: //! they are compared on the same stand rather than on two prices for it. What //! is lost is the target movement modifier of the more expensive arrival, and //! that is a hexes-moved difference inside one hex. //! //! **Every deployed enemy gets a full envelope, whatever `done` says.** In the //! movement phase `done` means "has taken its turn", and //! `candidates::may_be` reads it to collapse an enemy to a point. In //! the deployment phase it means "has been put down", and every one of them //! will move before anybody shoots. Reading it here would price every candidate //! against a board of statues. //! //! # Cost //! //! There is no time budget on this. It is one decision per unit per match, //! against movement's one per unit per round, so a full sweep per candidate is //! affordable where in movement it is not. It is measured anyway - see [`Cost`], //! which the bot prints - because "affordable" is a claim and the counters are //! the evidence. use std::collections::BTreeMap; use sds_core::features::decision::Decision; use sds_core::features::deployment::Placement; use sds_core::features::{FeatureVector, Weights}; use sds_core::los::LosCache; use sds_core::pathfind::{search, MoveBoard, OptionsCache, Reach, Walker}; use sds_core::stands::{self, Combatant, Foe, Mover, Params}; use sds_core::volley::{MountedWeapon, VolleyCache}; use sds_core::wire::{Action, Board, Coord, DeployHex, Observation, Unit}; use crate::candidates; use crate::unit::SideCaches; /// What one deployment decision cost, in counters and in nanoseconds. /// /// **Observability only.** Nothing here is read by a decision: the clocks are /// started and stopped around whole phases and their values go to stderr. That /// is what keeps "results must not depend on completion order" true with a /// timer in the file. #[derive(Debug, Clone, Copy, Default)] pub struct Cost { /// Legal hexes the observation offered. pub hexes: u64, /// Distinct stands scored, after the pooling this module does. pub stands: u64, /// Stands before pooling: what scoring each candidate on its own would have /// cost. The two together are what says whether the pooling is doing /// anything. pub stands_unpooled: u64, /// Enemy positions summed over the deployed enemies: `sum(M)`. pub positions: u64, /// `(stand, enemy position)` pairs, counted once per direction of fire. pub exchanges: u64, pub search_nanos: u64, pub sweep_nanos: u64, pub score_nanos: u64, /// Line-of-sight queries this decision made, and how many the cache answered. pub los_asked: u64, pub los_hits: u64, /// Pairs that had a line but produced no shot, and how many of those the /// hopeless bound ended before a weapon was touched. Round zero is where /// this should bite hardest: an enemy that has not deployed is priced /// against its whole movement envelope, so the target movement modifier /// runs to its maximum while the range stays long. pub nothing_bears: u64, pub hopeless: u64, } impl Cost { /// One line for a match log. pub fn summary(&self) -> String { let us = |nanos: u64| nanos / 1_000; format!( "{} hexes x 6 facings, {} stands pooled from {}, {} enemy positions, \ {} exchanges, {} los asked ({}% hit), {} bore nothing ({}% of those \ refused before a weapon walk); search {}us, sweep {}us, score {}us", self.hexes, self.stands, self.stands_unpooled, self.positions, self.exchanges, self.los_asked, (self.los_hits * 100) .checked_div(self.los_asked) .unwrap_or(0), self.nothing_bears, (self.hopeless * 100) .checked_div(self.nothing_bears) .unwrap_or(0), us(self.search_nanos), us(self.sweep_nanos), us(self.score_nanos), ) } } /// A deployment, chosen and recorded. pub struct Chosen { /// What to send the host. pub action: Action, /// The row the decision log carries: every candidate, its features, its /// value, and which one won. /// /// **Built by the same call that chose.** [`Decision::score`] is the argmax /// *and* the record, so the hex in `action` is the hex the row says was /// taken. A row that describes a different candidate than the one played is /// a bug this shape cannot have. pub decision: Decision, pub cost: Cost, } /// The facings a unit may be put down on. const FACINGS: i32 = 6; /// One pooled stand: the key everything about its score depends on. /// /// Hex, facing and gait are what a volley is priced from, so two arrivals that /// agree on these three are the same stand however they were reached. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] struct StandKey { x: i32, y: i32, facing: i32, jumped: bool, } impl StandKey { fn of(reach: &Reach) -> Self { Self { x: reach.stand.hex.x, y: reach.stand.hex.y, facing: reach.stand.facing, jumped: reach.jumped, } } } /// Where to put this unit down, or `None` when the question cannot be answered. /// /// `None` when the observation names no unit to deploy, offers no legal hex, or /// carries no board. The caller sends nothing and the host's own inert default /// - the first legal hex, facing the middle - stands, counted `defaulted`. pub fn choose( observation: &Observation, board: &Board, weights: &Weights, caches: &SideCaches, ) -> Option { let me = deploying(observation)?; if observation.deploy_hexes.is_empty() { return None; } // Sorted and deduplicated before anything is scored. The bridge lists these // in board order already; sorting here means the answer does not depend on // that promise being kept, which is half of what makes the choice // reproducible from the log. let mut hexes: Vec = observation.deploy_hexes.clone(); hexes.sort_by_key(|hex| (hex.x, hex.y, hex.elevation)); hexes.dedup_by_key(|hex| (hex.x, hex.y)); let enemies: Vec<&Unit> = observation .units .iter() .filter(|unit| !unit.friendly && !unit.destroyed) .collect(); let friends: Vec<&Unit> = observation .units .iter() .filter(|unit| unit.friendly && unit.id != me.id && !unit.destroyed) .collect(); // Everything already on the board, both sides. A hex somebody is standing // in is not a hex we can end a move in. let blocked: Vec = observation .units .iter() .filter(|unit| !unit.destroyed) .map(|unit| Coord::new(unit.x, unit.y)) .collect(); // The side's own memos, not a pair built here and thrown away. Deployment // is the front half of round one's movement: same board, and the enemy // envelopes drawn here are the ones round one asks for again, because // nothing has moved in between. Both caches carry the board every answer // was computed against, so a cached answer and the board cannot disagree. let held_options = caches.options(board); let options: &OptionsCache = &held_options; let move_board = options.board(); let held_los = caches.los(board); let los: &LosCache = &held_los; // Both counters are the match's now, so this decision's share is the // difference across it rather than the reading at the end. let (los_hits_before, los_misses_before) = los.counters(); // Every candidate's reachable set, pooled. `pool` holds the stands, `opens` // holds one entry per candidate: the stands it can reach and how many // distinct hexes those cover. let search_clock = std::time::Instant::now(); let mut pool: Vec = Vec::new(); let mut index: BTreeMap = BTreeMap::new(); let mut candidates: Vec = Vec::new(); let mut unpooled = 0u64; for hex in &hexes { for facing in 0..FACINGS { let placed = place(me, hex, facing, move_board); let mut reached = search(move_board, &Walker::running(&placed), &blocked).reached; if let Some(walker) = Walker::jumping(&placed) { reached.extend(search(move_board, &walker, &blocked).reached); } unpooled += reached.len() as u64; let mut mine: Vec = Vec::with_capacity(reached.len()); let mut ground: Vec = Vec::with_capacity(reached.len()); for reach in &reached { ground.push(reach.stand.hex); let key = StandKey::of(reach); match index.get(&key) { Some(at) => { // The cheapest arrival any candidate has, so two // candidates that both reach a stand are compared on // one price for it. Ties on MP go to the arrival that // covered more ground, which is the better target // movement modifier of the two. let held = &mut pool[*at]; if (reach.mp_spent, -reach.hexes_moved) < (held.mp_spent, -held.hexes_moved) { *held = *reach; } mine.push(*at); } None => { index.insert(key, pool.len()); mine.push(pool.len()); pool.push(*reach); } } } ground.sort_unstable(); ground.dedup(); candidates.push(Candidate { hex: *hex, facing, stands: mine, opens: ground.len() as i32, }); } } let search_nanos = search_clock.elapsed().as_nanos() as u64; if pool.is_empty() { return None; } // The mover is one machine for the whole pool, so its position cannot be // any one candidate's. It is the deployment zone's own middle, which is // what `StandFacts::elevation_delta` then reads as "how much higher than // the zone this stand is". Nothing else in the sweep reads it: a volley is // priced from the stand, not from where the shooter started. let anchor = middle_of(&hexes); let mine: Vec = me .weapons .iter() .filter(|weapon| weapon.usable) .map(MountedWeapon::from_wire) .collect(); let anchored = place(me, &anchor, 0, move_board); let mover = Mover { who: Combatant::mek(&anchored, mine, me.gunnery.max(0)), // A standing Mek on the floor of whatever hex it ends in, which is the // same assumption `Posture::survey` makes about every candidate it // traces. The elevation the *deploy* hex puts us at is a different // number and is read where it belongs, by `deploy_outlook`. elevation: 0, terrain: Some(options), }; let foes: Vec> = enemies .iter() .map(|unit| Foe { who: Combatant::mek( unit, unit.weapons .iter() .filter(|weapon| weapon.usable) .map(MountedWeapon::from_wire) .collect(), unit.gunnery.max(0), ), // Not `candidates::may_be`: see the module note on `done`. may_be: candidates::envelope(observation, move_board, unit), }) .collect(); let positions: u64 = foes.iter().map(|foe| foe.may_be.len() as u64).sum(); let sweep_clock = std::time::Instant::now(); let mut cache = VolleyCache::new(); let sweep = stands::score_stands(&mut cache, los, &mover, &pool, &foes); let scored = stands::collapse(&sweep, &Params::default()); let measured = stands::propose(los, &mover, &sweep, &scored, &foes, &friends); let sweep_nanos = sweep_clock.elapsed().as_nanos() as u64; // Read once, after the sweep: the cache is this decision's own, so these // counters describe exactly this placement. let volley_stats = cache.stats(); // Each candidate takes the value of the best move it opens. The stands were // all scored in one sweep, so these numbers are on one scale - which is the // whole reason the pool exists. let score_clock = std::time::Instant::now(); let board_hexes = (board.width * board.height).max(1); let mut menu: Vec<(String, FeatureVector)> = Vec::with_capacity(candidates.len()); for candidate in &candidates { let mut best: Option<(f32, &FeatureVector)> = None; for at in &candidate.stands { let Some(proposal) = measured.get(*at) else { continue; }; let value = weights.score(&proposal.features); // Strictly greater, so the earliest stand in the pool wins a tie // and the pool is built in candidate order. Nothing here reads a // clock or a hash map. if best.is_none_or(|(held, _)| value > held) { best = Some((value, &proposal.features)); } } let mut features = best.map(|(_, held)| held.clone()).unwrap_or_default(); features.extend( &Placement { board: los.board(), rules: los.rules(), at: candidate.hex.at(), elevation: candidate.hex.elevation, opens: candidate.opens, board_hexes, } .features(), ); menu.push(( format!( "deploy ({}, {}) facing {}", candidate.hex.x, candidate.hex.y, candidate.facing ), features, )); } let decision = Decision::score(me.id, &menu, weights)?; let score_nanos = score_clock.elapsed().as_nanos() as u64; let taken = candidates.get(decision.chosen)?; let (los_hits, los_misses) = los.counters(); let (los_hits, los_misses) = (los_hits - los_hits_before, los_misses - los_misses_before); Some(Chosen { action: Action::Deploy { x: taken.hex.x, y: taken.hex.y, facing: taken.facing, }, decision, cost: Cost { hexes: hexes.len() as u64, stands: pool.len() as u64, stands_unpooled: unpooled, positions, exchanges: sweep.stats.exchanges, search_nanos, sweep_nanos, score_nanos, los_asked: los_hits + los_misses, los_hits, nothing_bears: volley_stats.nothing_bears, hopeless: volley_stats.hopeless, }, }) } /// One `(hex, facing)` the unit could be put down on. struct Candidate { hex: DeployHex, facing: i32, /// Indices into the pooled stand list: everywhere it could stand next turn. stands: Vec, /// Distinct hexes those stands cover. The ground this candidate opens. opens: i32, } /// The unit this observation is asking about. /// /// [`Observation::deploy_unit`] first, because a unit waiting to deploy has no /// position and so is not in `units` at all. The `units` list is the fallback /// for a host older than that field, which described no such unit and left this /// answerable only when the actor happened to be on the board already. fn deploying(observation: &Observation) -> Option<&Unit> { if let Some(unit) = observation.deploy_unit.as_ref() { return Some(unit); } let actor = observation.actor?; observation.units.iter().find(|unit| unit.id == actor) } /// The unit as it would be standing in a candidate hex. /// /// The wire's `jumpMpTerrain` belongs to wherever the unit is standing now, and /// a unit waiting to deploy is standing nowhere - so it is recomputed here. /// **A Mek in water cannot use its jets**, which is `getJumpMPWithTerrain` /// answering zero, and a search that missed it would offer jump stands the /// server refuses. fn place(me: &Unit, hex: &DeployHex, facing: i32, board: &MoveBoard) -> Unit { let mut placed = me.clone(); placed.x = hex.x; placed.y = hex.y; placed.facing = facing; placed.elevation = hex.elevation; // The one reading of the rule, not a second copy of it: `pathfind` decides // what water does to a mover, and it decides it from what MegaMek said // about that unit. Written out again here it drifted for free - a // hovercraft is on the surface of a pond rather than in it, and this copy // could not tell. placed.jump_mp_terrain = Some(sds_core::pathfind::jump_allowance_at( &placed, board, hex.at(), )); placed } /// The deployment zone's own middle: the listed hex nearest the mean of them. /// /// Ties go to the lowest `(x, y)`, so this is a function of the set and not of /// the order it arrived in. fn middle_of(hexes: &[DeployHex]) -> DeployHex { let count = hexes.len().max(1) as i32; let sum_x: i32 = hexes.iter().map(|hex| hex.x).sum(); let sum_y: i32 = hexes.iter().map(|hex| hex.y).sum(); let mean = Coord::new(sum_x / count, sum_y / count); *hexes .iter() .min_by_key(|hex| (sds_core::hex::distance(hex.at(), mean), hex.x, hex.y)) .unwrap_or(&DeployHex { x: mean.x, y: mean.y, elevation: 0, }) } #[cfg(test)] mod tests { use super::*; use sds_core::features::Weights; use sds_core::wire::BoardHex; use std::collections::BTreeMap; fn hex(x: i32, y: i32, level: i32, terrain: &[(&str, i32)]) -> BoardHex { BoardHex { x, y, level, terrain: terrain .iter() .map(|(name, value)| ((*name).to_string(), *value)) .collect::>(), kind: 0, } } fn board(width: i32, height: i32) -> Board { let mut hexes = Vec::new(); for y in 0..height { for x in 0..width { hexes.push(hex(x, y, 0, &[])); } } Board { width, height, hexes, } } fn mek(id: i32, x: i32, y: i32, friendly: bool) -> serde_json::Value { serde_json::json!({ "id": id, "name": format!("Mek {id}"), "ownerId": if friendly { 1 } else { 2 }, "team": if friendly { 1 } else { 2 }, "friendly": friendly, "x": x, "y": y, "facing": 0, "elevation": 0, "weight": 55.0, "bv": 1200, "walkMp": 5, "runMp": 8, "jumpMp": 0, "maxElevationChange": 2, "armor": 100, "armorMax": 100, "internal": 50, "internalMax": 50, "heat": 0, "heatCapacity": 10, "heatBuildup": 0, "gunnery": 4, "piloting": 5, "role": "brawler", "locations": [], "weapons": [{ "id": 1, "name": "Medium Laser", "location": "RA", "heat": 3, "short": 3, "medium": 6, "long": 9, "avgDamageShort": 5.0, "avgDamageMedium": 5.0, "avgDamageLong": 5.0, "damagePerPacket": 5.0, "rackSize": 1, "usable": true }] }) } /// An observation asking us to put `deploy` down in `hexes`, with `units` /// already on the board. fn observation(hexes: Vec, units: Vec) -> Observation { let mut deploy = mek(9, -1, -1, true); deploy["x"] = serde_json::json!(-1); deploy["y"] = serde_json::json!(-1); let mut observation: Observation = serde_json::from_value(serde_json::json!({ "seq": 1, "round": 0, "phase": "DEPLOYMENT", "playerId": 1, "team": 1, "actor": 9, "eligible": [], "units": units, "shots": [], "deployHexes": [], "deployUnit": deploy, })) .expect("an observation this test wrote"); observation.deploy_hexes = hexes; observation } fn zone(xs: std::ops::Range, y: i32) -> Vec { xs.map(|x| DeployHex { x, y, elevation: 0 }).collect() } #[test] fn with_nobody_down_it_still_chooses() { // The first regime: no enemy on the board, so the sweep prices no // exchange and the deployment features and the terrain decide. let board = board(12, 12); let observation = observation(zone(2..10, 10), Vec::new()); let chosen = choose( &observation, &board, &Weights::hand_authored(), &SideCaches::new(), ) .expect("a legal zone and a unit to put in it"); assert!(matches!(chosen.action, Action::Deploy { .. })); assert_eq!(chosen.decision.candidates.len(), 8 * FACINGS as usize); } #[test] fn a_hex_that_strands_the_unit_loses_to_one_that_does_not() { // Depth-3 water: the floor is three levels down, a Mek may climb two, // so every step out of it is refused and the unit's whole next turn is // the hex it is standing in. Nothing in the scoring names water - the // hex loses because of the ground it does not open. let mut ground = board(12, 12); for cell in ground.hexes.iter_mut() { if cell.x == 5 && cell.y == 10 { cell.terrain.insert("water".into(), 3); } } let observation = observation( vec![ DeployHex { x: 5, y: 10, elevation: -3, }, DeployHex { x: 8, y: 10, elevation: 0, }, ], Vec::new(), ); let chosen = choose( &observation, &ground, &Weights::hand_authored(), &SideCaches::new(), ) .expect("two legal hexes and a unit to put in one"); let Action::Deploy { x, y, .. } = chosen.action else { panic!("deployment did not produce a deployment"); }; assert_eq!( (x, y), (8, 10), "the sunk hex opens no ground and should have lost: {}", chosen.decision.candidates[chosen.decision.chosen].label ); } #[test] fn with_an_enemy_down_it_prices_the_exchange() { // The second regime. One enemy at the far edge: the candidate hexes are // a line across the board and the one that wins should be the one whose // next turn can reach the enemy's envelope, not the one at the end of // the line. let board = board(16, 16); let observation = observation(zone(1..15, 14), vec![mek(1, 3, 2, false)]); let chosen = choose( &observation, &board, &Weights::hand_authored(), &SideCaches::new(), ) .expect("a legal zone, a unit, and an enemy to price against"); let Action::Deploy { x, .. } = chosen.action else { panic!("deployment did not produce a deployment"); }; assert!( x <= 8, "the enemy is at x=3 and the chosen hex is at x={x}: nothing was priced against it" ); assert!(chosen.cost.positions > 1, "the enemy got no envelope"); } #[test] fn the_recorded_row_is_the_hex_that_was_played() { let board = board(12, 12); let observation = observation(zone(2..10, 10), vec![mek(1, 4, 1, false)]); let chosen = choose( &observation, &board, &Weights::hand_authored(), &SideCaches::new(), ) .expect("a legal zone and an enemy"); let Action::Deploy { x, y, facing } = chosen.action else { panic!("deployment did not produce a deployment"); }; let row = &chosen.decision.candidates[chosen.decision.chosen]; assert_eq!(row.label, format!("deploy ({x}, {y}) facing {facing}")); // And it is the argmax of the values the row itself carries, which is // what a fit will read it as. let best = chosen .decision .candidates .iter() .fold(f32::NEG_INFINITY, |held, candidate| { held.max(candidate.value) }); assert_eq!(row.value, best); } /// What a real-sized decision costs, printed rather than asserted. /// /// `#[ignore]`d because it is a measurement and not a check: a threshold /// here would fail on a busy machine and say nothing about the bot. Run it /// with `cargo test -p sds-bot -- --ignored --nocapture` when the shape of /// the sweep changes, and put the line it prints in the commit. /// /// The shape is a suite board - 16x17 - with a three-deep deployment zone /// along one edge, and a full lance of enemies already down. #[test] #[ignore] fn what_one_decision_costs() { let ground = board(16, 17); let mut hexes = Vec::new(); for y in 14..17 { hexes.extend(zone(0..16, y)); } let enemies: Vec = (0..4).map(|at| mek(at + 1, 3 + at * 3, 2, false)).collect(); let observation = observation(hexes, enemies); let started = std::time::Instant::now(); let chosen = choose( &observation, &ground, &Weights::hand_authored(), &SideCaches::new(), ) .expect("a legal zone, a unit, and four enemies"); println!( "[deploy] {} in {:.0}ms", chosen.cost.summary(), started.elapsed().as_secs_f64() * 1000.0 ); // The top of the menu beside the bottom of it. A spread of zero means // every candidate measured the same and the choice was the tie-break, // which is a finding and not a pass. let mut ranked: Vec<&sds_core::features::decision::Candidate> = chosen.decision.candidates.iter().collect(); ranked.sort_by(|a, b| b.value.total_cmp(&a.value)); for candidate in ranked.iter().take(3) { println!("[deploy] {} {:.3}", candidate.label, candidate.value); } if let Some(worst) = ranked.last() { println!("[deploy] worst: {} {:.3}", worst.label, worst.value); } } #[test] fn the_choice_does_not_depend_on_the_order_the_hexes_arrived_in() { let board = board(12, 12); let mut hexes = zone(2..10, 10); let observation = observation(hexes.clone(), vec![mek(1, 4, 1, false)]); hexes.reverse(); let shuffled = observation.clone(); let mut shuffled = shuffled; shuffled.deploy_hexes = hexes; let weights = Weights::hand_authored(); // Deliberately one cache across all three. A memo that lives for the // match means the second and third calls are answered from the first's // work, and "the same answer warm as cold" is the thing that has to // hold. let caches = SideCaches::new(); let first = choose(&observation, &board, &weights, &caches).expect("a choice"); let again = choose(&observation, &board, &weights, &caches).expect("the same choice"); let reversed = choose(&shuffled, &board, &weights, &caches).expect("a choice from the same set"); let played = |chosen: &Chosen| match chosen.action { Action::Deploy { x, y, facing } => (x, y, facing), _ => panic!("deployment did not produce a deployment"), }; assert_eq!(played(&first), played(&again), "two runs, two answers"); assert_eq!( played(&first), played(&reversed), "the answer moved when the hex list was reversed" ); } }