diff --git a/crates/sds-bot/src/carry.rs b/crates/sds-bot/src/carry.rs new file mode 100644 index 0000000..d3aaeec --- /dev/null +++ b/crates/sds-bot/src/carry.rs @@ -0,0 +1,758 @@ +//! Carry a rebuild's `(stand, enemy position)` work into the next rebuild. +//! +//! The plan is keyed on `(round, settled enemies)` and rebuilt once per +//! decision, which is deliberate: movement alternates by initiative, so a plan +//! made at the top of the round would hand our last mover the uncertainty our +//! first mover faced. See `plan/candidates.md`. The rebuilds are not the waste; +//! what a rebuild *recomputes* is. On a pinned 4v4, rebuilds two to four of a +//! round were 5664 ms of 9951 - 57% of the round's planning. +//! +//! **Why the grain is a pair and not a column.** Reuse per enemy was built +//! first and measured at 0% to 0.4% of the work avoided. +//! [`crate::candidates::envelope`] draws `may_be` blocking on every unit on the +//! board, so one of ours taking its turn redraws every unmoved enemy's +//! reachable set, and a column keyed on the whole envelope is thrown away when +//! two hexes of it move. But the envelope *shifts*; it does not churn. Measured +//! over three rounds of the same match, the share of this rebuild's +//! `(stand, enemy position)` pairs that the previous rebuild had already +//! computed was **91% to 99%**, median 97%. +//! +//! A pair is the right key because it is exactly what an exchange is a function +//! of: our unit at one stand, one enemy [`Presence`], and the board. +//! [`sds_core::los::AttackInfo`] carries two positions and no third unit, and +//! intervening units are not modelled, so no other enemy's position can change +//! the answer. That independence is what makes the carry sound. +//! +//! **What has to be true for a pair to be carried**, all of it checked rather +//! than assumed: +//! +//! - Our own wire record is unchanged. A Mek that failed its roll to stand is +//! in the same hex facing somewhere else, and every exchange it has is +//! different. The whole record is the key, so anything the observation says +//! about us retires the memo. +//! - The enemy's wire record is unchanged. Its armour is the target profile a +//! volley is priced against. An enemy that moved fails this and has all of +//! its positions swept afresh - which costs nothing, because a settled enemy +//! is one position. +//! - The stand is the same [`Reach`], every field. Two stands in one hex that +//! differ on MP spent price differently: the gait is what our to-hit reads. +//! - The enemy position is the same [`Presence`], every field, for the mirror +//! of the same reason. +//! +//! **What is rebuilt rather than carried.** A [`TargetExchanges`] is not just +//! its stored entries. `outgoing` and `incoming` count *why* over the whole of +//! `M`, and `range` spreads the distances over it, so both are rebuilt from the +//! assembled positions - the reason counts from +//! [`sds_core::stands::absent_halves`] and the stored halves, the range from +//! arithmetic that needs no line of sight. `at` is re-indexed into the new +//! `may_be`, because a carried entry describes a position, not a slot. + +use std::collections::HashMap; +use std::sync::Mutex; + +use sds_core::facts::{Gait, ReasonCounts, Spread, StandFacts}; +use sds_core::hex::distance as hex_distance; +use sds_core::pathfind::Reach; +use sds_core::stands::{ + absent_halves, Blocked, Exchange, Foe, Half, Presence, StandRecord, StandStats, Sweep, + TargetExchanges, +}; + +/// A [`Reach`] reduced to something hashable. Every field: see the module note. +type StandKey = (i32, i32, i32, i32, i32, bool); +/// A [`Presence`] reduced to something hashable. Likewise. +type PlaceKey = (i32, i32, i32, i32, i32, bool, u8); + +fn stand_key(reach: &Reach) -> StandKey { + ( + reach.stand.hex.x, + reach.stand.hex.y, + reach.stand.facing, + reach.mp_spent, + reach.hexes_moved, + reach.jumped, + ) +} + +fn gait_code(gait: Gait) -> u8 { + match gait { + Gait::Still => 0, + Gait::Walked => 1, + Gait::Ran => 2, + Gait::Jumped => 3, + } +} + +fn place_key(at: &Presence) -> PlaceKey { + ( + at.stand.hex.x, + at.stand.hex.y, + at.stand.facing, + at.elevation, + at.hexes_moved, + at.jumped, + gait_code(at.gait), + ) +} + +/// A unit's wire record, as a string to compare against. +/// +/// Compared rather than hashed: a collision here would answer with another +/// machine's guns. A record that will not serialise gets a fingerprint nothing +/// matches, which retires the memo - the safe direction, since a memo wrongly +/// dropped only costs the sweep we were running anyway. +pub fn fingerprint(unit: &sds_core::wire::Unit) -> String { + serde_json::to_string(unit).unwrap_or_else(|_| String::from("\u{0}unserialisable")) +} + +/// One enemy as the rebuild that filled the memo saw it. +#[derive(Debug, Clone)] +struct CarriedFoe { + who: String, + /// Where each of its positions sat in that rebuild's `may_be`. + places: HashMap, +} + +/// What one unit's last rebuild of this round produced. +#[derive(Debug, Clone)] +pub struct Carried { + round: i32, + mover: String, + foes: HashMap, + stands: HashMap, + rows: Vec, +} + +impl Carried { + /// Keep what this sweep can answer for the next rebuild. + pub fn of(round: i32, mover: String, foes: &[Foe<'_>], sweep: &Sweep) -> Self { + Self { + round, + mover, + foes: foes + .iter() + .map(|foe| { + ( + foe.who.unit.id, + CarriedFoe { + who: fingerprint(foe.who.unit), + places: foe + .may_be + .iter() + .enumerate() + .map(|(at, place)| (place_key(place), at as u32)) + .collect(), + }, + ) + }) + .collect(), + stands: sweep + .scored + .iter() + .enumerate() + .map(|(row, record)| (stand_key(&record.reach), row)) + .collect(), + rows: sweep.scored.clone(), + } + } +} + +/// Where every position of this rebuild has to come from. +/// +/// One entry per foe, in the caller's foe order; each is one entry per position +/// of that foe's new `may_be`, in `may_be` order. +#[derive(Debug, Clone, Default)] +pub struct Plan { + /// For each foe, for each new position: the index it had in the previous + /// rebuild's `may_be`, or `None` because it is new to this one. + held: Vec>>, + /// For each foe, the positions with no previous index, and where each sits + /// in the new `may_be`. + fresh: Vec>, + /// For each stand, its row in the previous rebuild, or `None`. + rows: Vec>, +} + +impl Plan { + /// Split a stand list the way [`assemble`] expects it: the stands the memo + /// already had, then the ones it did not, each in the caller's order. + /// + /// One place, so the two sub-sweeps and the assembly cannot disagree about + /// which stand is in which list. + pub fn split<'r>(&self, stands: &'r [Reach]) -> (Vec, Vec) { + let mut held = Vec::new(); + let mut fresh = Vec::new(); + for (reach, row) in stands.iter().zip(self.rows.iter()) { + if row.is_some() { + held.push(*reach); + } else { + fresh.push(*reach); + } + } + (held, fresh) + } +} + +impl Plan { + /// Is there anything at all to carry? A plan that carries nothing is a full + /// sweep with extra bookkeeping, and the caller runs one instead. + pub fn worth_it(&self) -> bool { + self.rows.iter().any(|row| row.is_some()) + && self + .held + .iter() + .any(|foe| foe.iter().any(|place| place.is_some())) + } +} + +/// What a rebuild carried and what it still had to sweep, in `(stand, position)` +/// pairs - the unit the sweep's cost is linear in. +#[derive(Debug, Clone, Copy, Default)] +pub struct Tally { + pub carried: u64, + pub swept: u64, + pub stands: u64, + pub stands_carried: u64, +} + +impl Carried { + /// Work out what this rebuild still has to compute. + pub fn plan(&self, foes: &[Foe<'_>], stands: &[Reach]) -> Plan { + let mut out = Plan { + rows: stands + .iter() + .map(|reach| self.stands.get(&stand_key(reach)).copied()) + .collect(), + ..Default::default() + }; + for foe in foes { + // An enemy whose record changed is an enemy whose volleys changed. + // Nothing of it is carried, and nothing of it needs to be: the one + // that moved is now a single placed hex. + let held = self + .foes + .get(&foe.who.unit.id) + .filter(|held| held.who == fingerprint(foe.who.unit)); + let mut mine: Vec> = Vec::with_capacity(foe.may_be.len()); + let mut fresh: Vec<(usize, Presence)> = Vec::new(); + for (at, place) in foe.may_be.iter().enumerate() { + let was = held.and_then(|held| held.places.get(&place_key(place)).copied()); + if was.is_none() { + fresh.push((at, *place)); + } + mine.push(was); + } + out.held.push(mine); + out.fresh.push(fresh); + } + out + } +} + +/// The foes to sweep for the positions the memo cannot answer. +/// +/// One `Foe` per original, carrying only its new positions, in the order +/// [`Plan::fresh`] lists them. A foe with nothing new keeps an empty `may_be` +/// and costs the sweep one loop iteration. +pub fn fresh_foes<'a>(foes: &[Foe<'a>], plan: &Plan) -> Vec> { + foes.iter() + .enumerate() + .map(|(at, foe)| Foe { + who: foe.who.clone(), + may_be: plan.fresh[at].iter().map(|(_, place)| *place).collect(), + }) + .collect() +} + +fn count(counts: &mut ReasonCounts, half: &Half) { + match half { + Half::Fired { .. } => counts.firing += 1, + Half::Blocked(Blocked::NoLine) => counts.no_line += 1, + Half::Blocked(Blocked::ArcBlocked) => counts.arc_blocked += 1, + Half::Blocked(Blocked::OutOfRange) => counts.out_of_range += 1, + } +} + +/// Put a rebuild together out of what the last one answered and what this one +/// swept. +/// +/// `over_old` is the sweep of the new positions against the stands the memo +/// already had; `over_new` is the sweep of every position against the stands it +/// did not. Both are indexed positionally against the stand lists they were +/// given, which are derived from `stands` in order. +/// +/// Returns `None` rather than guessing when something it expected is missing. +/// The caller then sweeps in full, which is what it did before this existed. +pub fn assemble( + foes: &[Foe<'_>], + stands: &[Reach], + plan: &Plan, + memo: &Carried, + over_old: &Sweep, + over_new: &Sweep, +) -> Option<(Sweep, Tally)> { + let mut tally = Tally { + stands: stands.len() as u64, + ..Default::default() + }; + let mut scored: Vec = Vec::with_capacity(stands.len()); + // Which row of each sub-sweep a stand landed in, walked in the same order + // the caller built those lists in. + let (mut old_row, mut new_row) = (0usize, 0usize); + + for (at, reach) in stands.iter().enumerate() { + let was = plan.rows.get(at).copied().flatten(); + let (source, sub) = match was { + Some(row) => { + tally.stands_carried += 1; + let sub = over_old.scored.get(old_row)?; + old_row += 1; + (Some(memo.rows.get(row)?), sub) + } + None => { + let sub = over_new.scored.get(new_row)?; + new_row += 1; + (None, sub) + } + }; + if sub.reach != *reach { + return None; + } + + let mut exchanges: Vec = Vec::with_capacity(foes.len()); + for (which, foe) in foes.iter().enumerate() { + // A stand the memo never had carries nothing: every position of it + // was swept into `over_new`, in `may_be` order already. + let held = source.and_then(|record| record.about(foe.who.unit.id)); + let swept = sub.about(foe.who.unit.id)?; + let places = &plan.held[which]; + + let mut present: Vec = Vec::new(); + let mut absent: Vec = Vec::with_capacity(foe.may_be.len()); + let mut outgoing = ReasonCounts::default(); + let mut incoming = ReasonCounts::default(); + let mut ranges: Vec = Vec::with_capacity(foe.may_be.len()); + // Where in the swept sub-envelope each new position sits. + let mut next_fresh = 0usize; + + for (position, place) in foe.may_be.iter().enumerate() { + ranges.push(hex_distance(reach.stand.hex, place.stand.hex)); + // A carried position when the memo had this stand and had this + // position; otherwise one this decision swept. + let (entry, byte) = match (held, places.get(position).copied().flatten()) { + (Some(record), Some(was)) => { + tally.carried += 1; + ( + record.at(was), + record.absent.get(was as usize).copied().unwrap_or(0), + ) + } + _ => { + tally.swept += 1; + let sub_at = next_fresh as u32; + next_fresh += 1; + ( + swept.at(sub_at), + swept.absent.get(sub_at as usize).copied().unwrap_or(0), + ) + } + }; + match entry { + Some(entry) => { + count(&mut outgoing, &entry.deal); + count(&mut incoming, &entry.take); + present.push(Exchange { + at: position as u32, + deal: entry.deal, + take: entry.take, + }); + absent.push(0); + } + None => { + // Nothing fired either way, and the byte says why. A + // byte that does not unpack is a memo that cannot + // rebuild the counts, and a wrong count is a wrong + // feature - so it fails the assembly rather than + // guessing. + let (out, into) = absent_halves(byte)?; + count(&mut outgoing, &Half::Blocked(out)); + count(&mut incoming, &Half::Blocked(into)); + absent.push(byte); + } + } + } + + present.shrink_to_fit(); + exchanges.push(TargetExchanges { + id: foe.who.unit.id, + positions: foe.may_be.len() as u32, + present, + absent, + outgoing, + incoming, + range: Spread::of(ranges), + }); + } + + // The facts that are about the stand rather than about an enemy do not + // depend on which enemy was swept, and our own record is the memo's + // key, so a carried row's are still true. `per_target` is derived from + // the assembled columns, the same call `score_stands` makes. + let base = source.unwrap_or(sub); + let facts = StandFacts { + per_target: exchanges.iter().map(TargetExchanges::facts).collect(), + ..base.facts.clone() + }; + scored.push(StandRecord { + reach: *reach, + facts, + exchanges, + }); + } + + // Counters describe work done, so a carried rebuild's are smaller than a + // full one's by exactly the work it did not do. The wall-clock fields could + // not be reproduced in any case. + let stats = StandStats { + stands: stands.len() as u64, + exchanges: over_old.stats.exchanges + over_new.stats.exchanges, + stored: over_old.stats.stored + over_new.stats.stored, + skipped: over_old.stats.skipped + over_new.stats.skipped, + out_of_reach: over_old.stats.out_of_reach + over_new.stats.out_of_reach, + volley_nanos: over_old.stats.volley_nanos + over_new.stats.volley_nanos, + sweep_nanos: over_old.stats.sweep_nanos + over_new.stats.sweep_nanos, + frontier: 0, + emitted: 0, + summary_frontier: 0, + rescued: 0, + }; + Some((Sweep { scored, stats }, tally)) +} + +/// One memo per unit, held for the round it was built in. +/// +/// Beside the line-of-sight and reachability caches, because that is where the +/// thinker already keeps something that outlives a decision. Units think +/// concurrently but each reads and writes only its own entry, and the rebuilds +/// themselves are sequential: a force hears from every unit before it decides. +#[derive(Debug, Default)] +pub struct Memos { + held: Mutex>, +} + +impl Memos { + /// This unit's memo, if it is from this round and was built with our unit + /// in the state the observation now says it is in. + /// + /// Taken rather than borrowed: the caller replaces it, and a sweep is + /// megabytes that would otherwise be copied out under the lock. Entries + /// from an older round are dropped on the way past, which is the only thing + /// that bounds this. + pub fn take(&self, unit: i32, round: i32, mover: &str) -> Option { + let mut held = self.held.lock().ok()?; + held.retain(|_, memo| memo.round == round); + let memo = held.remove(&unit)?; + (memo.mover == mover).then_some(memo) + } + + pub fn keep(&self, unit: i32, memo: Carried) { + if let Ok(mut held) = self.held.lock() { + held.insert(unit, memo); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sds_core::los::{LosCache, Rules}; + use sds_core::pathfind::{search, MoveBoard, Walker}; + use sds_core::stands::{score_stands, Combatant, Mover}; + use sds_core::volley::{MountedWeapon, VolleyCache}; + use sds_core::wire::{Board, BoardHex, Coord, Unit, Weapon}; + use std::collections::BTreeMap; + + fn board() -> Board { + let mut hexes = Vec::new(); + for x in 0..24 { + for y in 0..24 { + hexes.push(BoardHex { + x, + y, + level: 0, + terrain: BTreeMap::new(), + }); + } + } + Board { + width: 24, + height: 24, + hexes, + } + } + + fn unit(id: i32, friendly: bool, x: i32, y: i32) -> Unit { + let team = if friendly { 1 } else { 2 }; + let mut me: Unit = 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": [] + }}"# + )) + .expect("unit parses"); + me.weapons = vec![Weapon { + id: 1, + name: "PPC".into(), + location: Some("RT".into()), + rear_mounted: false, + heat: 10, + short: 6, + medium: 12, + long_range: 18, + minimum: 3, + avg_damage_short: 10.0, + avg_damage_medium: 10.0, + avg_damage_long: 10.0, + rack_size: 0, + damage_per_packet: 10.0, + to_hit_modifier: 0, + fire_modes: Vec::new(), + current_mode: None, + usable: true, + ammo: None, + modes: Vec::new(), + }]; + me + } + + fn combatant(unit: &Unit) -> Combatant<'_> { + let guns: Vec = unit + .weapons + .iter() + .filter(|w| w.usable) + .map(MountedWeapon::from_wire) + .collect(); + Combatant::mek(unit, guns, unit.gunnery.max(0)) + } + + /// An enemy that has not moved: everywhere it could run to, blocking on the + /// units that are on the board - which is what makes an envelope shift when + /// somebody else steps. + fn envelope(board: &MoveBoard, unit: &Unit, blocked: &[Coord]) -> Vec { + search(board, &Walker::running(unit), blocked) + .reached + .iter() + .map(|reach| Presence { + stand: reach.stand, + elevation: unit.elevation, + hexes_moved: reach.hexes_moved, + jumped: reach.jumped, + gait: Gait::of(reach.mp_spent, unit.walk_mp, reach.jumped), + }) + .collect() + } + + fn stands_for(board: &MoveBoard, me: &Unit, blocked: &[Coord]) -> Vec { + search(board, &Walker::running(me), blocked).reached + } + + fn at(units: &[&Unit]) -> Vec { + units.iter().map(|u| Coord::new(u.x, u.y)).collect() + } + + /// A carried rebuild is the rebuild a full sweep would have produced. + /// + /// The scene is what a real rebuild sees, and it carries all three hazards + /// at once rather than one at a time: + /// + /// - an enemy takes its turn, so its `may_be` collapses from a running + /// envelope to one placed hex and its wire record changes; + /// - one of ours moves, so the stand set gains and loses stands; + /// - and that same step lands inside another enemy's envelope, so an enemy + /// that has not moved at all has its positions *shift* - the case the + /// column-grain memo could not carry and this one must. + /// + /// The assertions below check the fixture produced all three before they + /// check the answer, because a pass on a scene where nothing moved would + /// prove nothing. + #[test] + fn a_carried_rebuild_is_what_a_full_one_would_have_been() { + let board = board(); + let move_board = MoveBoard::new(&board); + let los = LosCache::new(&board, Rules::default()); + + let me = unit(1, true, 3, 6); + let friend = unit(2, true, 4, 6); + let first = unit(10, false, 8, 6); + // Close enough to `friend` that its envelope covers where `friend` + // stands and where it is about to stand: this is the enemy whose + // positions shift without it moving. + let neighbour = unit(11, false, 7, 11); + let far = unit(12, false, 20, 4); + + let blocked_then = at(&[&friend, &first, &neighbour, &far]); + let stands_then = stands_for(&move_board, &me, &blocked_then); + let foes_then: Vec> = [&first, &neighbour, &far] + .iter() + .map(|unit| Foe { + who: combatant(unit), + may_be: envelope(&move_board, unit, &blocked_then), + }) + .collect(); + let mover = Mover { + who: combatant(&me), + elevation: me.elevation, + terrain: None, + }; + let mut cache = VolleyCache::new(); + let full_then = score_stands(&mut cache, &los, &mover, &stands_then, &foes_then); + let memo = Carried::of(3, fingerprint(&me), &foes_then, &full_then); + + // The rebuild. `first` has taken its turn; `friend` has stepped, which + // both frees hexes for us and moves through `neighbour`'s envelope. + let mut settled = first.clone(); + settled.x = 7; + settled.y = 7; + settled.done = true; + // Into a hex `neighbour` could have run to, and out of one beside us. + let mut moved = friend.clone(); + moved.x = 6; + moved.y = 10; + let blocked_now = at(&[&moved, &settled, &neighbour, &far]); + let stands_now = stands_for(&move_board, &me, &blocked_now); + let foes_now: Vec> = vec![ + Foe { + who: combatant(&settled), + may_be: vec![Presence::placed(&settled)], + }, + Foe { + who: combatant(&neighbour), + may_be: envelope(&move_board, &neighbour, &blocked_now), + }, + Foe { + who: combatant(&far), + may_be: envelope(&move_board, &far, &blocked_now), + }, + ]; + let plan = memo.plan(&foes_now, &stands_now); + + // The fixture is the interesting one, checked rather than assumed. + assert!( + foes_then[0].may_be.len() > 1 && foes_now[0].may_be.len() == 1, + "an enemy went from an envelope to a placed hex" + ); + assert!( + plan.held[0].iter().all(|place| place.is_none()), + "and carries nothing, because its record changed" + ); + let shifted = &plan.held[1]; + assert!( + shifted.iter().any(|place| place.is_none()) + && shifted.iter().any(|place| place.is_some()), + "an enemy that did not move had its envelope shift: some positions \ + are new and most are not" + ); + assert_eq!( + foes_then[1].may_be.len() != foes_now[1].may_be.len() + || foes_then[1].may_be != foes_now[1].may_be, + true, + "and its envelope really is a different set" + ); + assert!( + plan.rows.iter().any(|row| row.is_none()), + "the stand set gained stands" + ); + assert!( + plan.rows.iter().any(|row| row.is_some()), + "and kept some of them" + ); + assert!(plan.worth_it()); + + let (old_stands, new_stands) = plan.split(&stands_now); + let fresh = fresh_foes(&foes_now, &plan); + let over_old = score_stands(&mut cache, &los, &mover, &old_stands, &fresh); + let over_new = score_stands(&mut cache, &los, &mover, &new_stands, &foes_now); + let (carried, tally) = assemble( + &foes_now, + &stands_now, + &plan, + &memo, + &over_old, + &over_new, + ) + .expect("the assembly had every position it needed"); + + assert!(tally.carried > 0, "pairs actually came from the memo"); + assert!(tally.swept > 0, "and pairs were actually swept"); + assert!( + tally.carried > tally.swept, + "the carry is the majority of the work, not a rounding error" + ); + + // The bar. A carried sweep is the sweep a full one would have been, + // field for field, over everything a decision reads: the stored + // entries, the reason counts the features are built from, the ranges, + // and the facts derived from all of them. `stats` counts work done, and + // doing less of it is the point. + let mut fresh_cache = VolleyCache::new(); + let full_now = score_stands(&mut fresh_cache, &los, &mover, &stands_now, &foes_now); + assert_eq!(carried.scored.len(), full_now.scored.len()); + for (mine, theirs) in carried.scored.iter().zip(full_now.scored.iter()) { + assert_eq!(mine, theirs, "a carried stand differs from a swept one"); + } + } + + /// A memo from another round, or from before we fell over, is not read. + #[test] + fn a_memo_is_only_read_for_the_state_that_built_it() { + let me = unit(1, true, 3, 6); + let store = Memos::default(); + let memo = Carried { + round: 3, + mover: fingerprint(&me), + foes: HashMap::new(), + stands: HashMap::new(), + rows: Vec::new(), + }; + store.keep(me.id, memo.clone()); + assert!(store.take(me.id, 4, &fingerprint(&me)).is_none(), "round"); + store.keep(me.id, memo.clone()); + let mut fallen = me.clone(); + fallen.prone = true; + assert!( + store.take(me.id, 3, &fingerprint(&fallen)).is_none(), + "our own state" + ); + store.keep(me.id, memo); + assert!(store.take(me.id, 3, &fingerprint(&me)).is_some()); + assert!( + store.take(me.id, 3, &fingerprint(&me)).is_none(), + "taken rather than borrowed" + ); + } + + /// Every packing of an absent position survives the round trip. + #[test] + fn an_absent_reason_unpacks_to_what_was_packed() { + use sds_core::stands::absent_reason; + let all = [Blocked::NoLine, Blocked::ArcBlocked, Blocked::OutOfRange]; + for out in all { + for into in all { + let code = absent_reason(out, into); + assert_ne!(code, 0, "zero is reserved for a stored entry"); + assert_eq!(absent_halves(code), Some((out, into))); + } + } + assert_eq!(absent_halves(0), None); + } +} diff --git a/crates/sds-core/src/stands.rs b/crates/sds-core/src/stands.rs index 903739c..521f74d 100644 --- a/crates/sds-core/src/stands.rs +++ b/crates/sds-core/src/stands.rs @@ -414,6 +414,20 @@ pub struct TargetExchanges { pub positions: u32, /// The positions where at least one direction fired, in `may_be` order. pub present: Vec, + /// Why each position that stored **no** entry stored none, in `may_be` + /// order and one byte each: `0` where [`Self::present`] has the position, + /// otherwise [`absent_reason`]'s packing of the two [`Blocked`] halves. + /// + /// The aggregate is already in [`Self::outgoing`] and [`Self::incoming`], + /// and this says nothing they do not - per position rather than summed. + /// That is the difference between a sweep that can be carried into the next + /// rebuild and one that cannot: a rebuild reuses a `(stand, position)` pair + /// only when both are unchanged, and it has to be able to put the pair's + /// contribution back into the counts. Without this, an absent position is + /// indistinguishable from any other absent position and the counts cannot + /// be rebuilt. About one byte per `(stand, enemy, position)` triple, which + /// measured under 1 MB for a unit's whole sweep at 4v4. + pub absent: Vec, /// Why we did or did not shoot, over all of `M`. pub outgoing: ReasonCounts, /// Why they did or did not shoot us, over all of `M`. @@ -1180,6 +1194,7 @@ pub fn score_stands( let their_reach = longest_bracket(&foe.who.weapons); let furthest = our_reach.max(their_reach); let mut present: Vec = Vec::new(); + let mut absent: Vec = Vec::with_capacity(foe.may_be.len()); let mut ranges: Vec = Vec::with_capacity(foe.may_be.len()); let mut out_counts = ReasonCounts::default(); let mut in_counts = ReasonCounts::default(); @@ -1207,6 +1222,7 @@ pub fn score_stands( // saving invisible in the column that measures it. count(&mut out_counts, &Half::Blocked(Blocked::OutOfRange)); count(&mut in_counts, &Half::Blocked(Blocked::OutOfRange)); + absent.push(absent_reason(Blocked::OutOfRange, Blocked::OutOfRange)); stats.out_of_reach += 1; stats.skipped += 1; continue; @@ -1262,7 +1278,16 @@ pub fn score_stands( if entry.happened() { stats.stored += 1; present.push(entry); + absent.push(0); } else { + // Both halves are `Blocked` here by construction: + // `happened` is false only when neither fired. + absent.push(match (deal, take) { + (Half::Blocked(out), Half::Blocked(into)) => absent_reason(out, into), + // Unreachable, and a zero rather than a panic: a wrong + // byte costs a rebuild that recomputes the position. + _ => 0, + }); // Nothing either way. The reasons are counted; a stored // zero would say less than they do. stats.skipped += 1; @@ -1274,6 +1299,7 @@ pub fn score_stands( id: foe.who.unit.id, positions: foe.may_be.len() as u32, present, + absent, outgoing: out_counts, incoming: in_counts, range: Spread::of(ranges), @@ -1682,6 +1708,40 @@ fn half( } /// Add one [`Half`] to the four-way partition it belongs to. +/// One [`Blocked`] as a code. Private to the packing below. +fn blocked_code(why: Blocked) -> u8 { + match why { + Blocked::NoLine => 0, + Blocked::ArcBlocked => 1, + Blocked::OutOfRange => 2, + } +} + +/// The inverse. `None` for a code that is not one of the nine. +fn blocked_of(code: u8) -> Option { + match code { + 0 => Some(Blocked::NoLine), + 1 => Some(Blocked::ArcBlocked), + 2 => Some(Blocked::OutOfRange), + _ => None, + } +} + +/// Pack the two halves of a position that stored no entry. +/// +/// A position stores no entry only when neither direction fired, so both halves +/// are [`Blocked`] and three by three fits in the nine values `1..=9`. Zero is +/// reserved for "this position is in `present`". +pub fn absent_reason(outgoing: Blocked, incoming: Blocked) -> u8 { + 1 + blocked_code(outgoing) * 3 + blocked_code(incoming) +} + +/// Unpack [`absent_reason`]. `None` for zero, and for anything not packed here. +pub fn absent_halves(code: u8) -> Option<(Blocked, Blocked)> { + let code = code.checked_sub(1)?; + Some((blocked_of(code / 3)?, blocked_of(code % 3)?)) +} + fn count(counts: &mut ReasonCounts, half: &Half) { match half { Half::Fired { .. } => counts.firing += 1, @@ -3589,6 +3649,8 @@ mod tests { let exchanges = vec![TargetExchanges { id: 2, positions: 2, + // Both positions stored an entry. + absent: vec![0, 0], present: vec![ Exchange { at: 0, @@ -3631,6 +3693,7 @@ mod tests { let exchanges = vec![TargetExchanges { id: 2, positions: 1, + absent: vec![0], present: vec![Exchange { at: 0, deal: Half::Fired {