Something went wrong. Try again.
bayes for days
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771//! 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<Chosen> { 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<DeployHex> = 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<Coord> = 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<Reach> = Vec::new(); let mut index: BTreeMap<StandKey, usize> = BTreeMap::new(); let mut candidates: Vec<Candidate> = 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<usize> = Vec::with_capacity(reached.len()); let mut ground: Vec<Coord> = 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<MountedWeapon> = 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<Foe<'_>> = 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<usize>, /// 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::<BTreeMap<String, i32>>(), 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<DeployHex>, units: Vec<serde_json::Value>) -> 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<i32>, y: i32) -> Vec<DeployHex> { 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<serde_json::Value> = (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" ); }}