From 4e31726231dcaafd5b96650a9d2d9d944a05fa15 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 20 Aug 2026 20:23:33 -0400 Subject: [PATCH] refactor(candidates)!: split Aggregate into Outcome and Prospect One struct was doing two jobs: one volley at one enemy position, and those outputs collapsed over M. `Outcome` is the per-position record and `Prospect` is the collapse over `M`, which now carries firing heat as well as the five damage outputs. `Prospect::collapse` takes `&[Outcome]`, so the direction is in the type. `twist` and `shots` are still dropped, and the conversion says why. --- crates/sds-core/examples/stands.rs | 7 +- crates/sds-core/src/facts.rs | 2 +- crates/sds-core/src/heatmap.rs | 10 +- crates/sds-core/src/stands.rs | 298 +++++++++++++++++++++++------ crates/sds-core/src/surface.rs | 16 +- 5 files changed, 257 insertions(+), 76 deletions(-) diff --git a/crates/sds-core/examples/stands.rs b/crates/sds-core/examples/stands.rs index 7e75518..0f65c09 100644 --- a/crates/sds-core/examples/stands.rs +++ b/crates/sds-core/examples/stands.rs @@ -419,9 +419,13 @@ fn main() { .iter() .map(|(id, value)| format!("{id}:{:.1}", value.expected_damage)) .collect(); + // `heat` is the offence column's, so it is the price of the + // damage printed beside it: heat collapsed over `M` against + // the best target, in heat points. println!( " {} deal {:>6.2} take {:>6.2} p_kill {:.4} \ - mission {:.4} breaches {:.2} value {:>6.2} target {} [{}]", + mission {:.4} breaches {:.2} value {:>6.2} heat {:>5.2} \ + target {} [{}]", describe(score), score.offence.expected_damage, score.defence.expected_damage, @@ -429,6 +433,7 @@ fn main() { score.offence.p_mission_kill, score.offence.expected_breaches, score.offence.value_destroyed, + score.offence.heat, score .best_target .map(|id| id.to_string()) diff --git a/crates/sds-core/src/facts.rs b/crates/sds-core/src/facts.rs index 712bfd0..f17ed60 100644 --- a/crates/sds-core/src/facts.rs +++ b/crates/sds-core/src/facts.rs @@ -168,7 +168,7 @@ impl ReasonCounts { /// damages. Splitting a power mean by category is not defined: the exponent is /// a statement about which member of `M` the enemy picks, and a category share /// of that statement means nothing. So the arc split is deliberately a -/// different collapse from [`crate::stands::Aggregate`], and the two agree only +/// different collapse from [`crate::stands::Prospect`], and the two agree only /// at `p = 1`. #[derive(Debug, Clone, Copy, Default, PartialEq)] pub struct ArcSplit { diff --git a/crates/sds-core/src/heatmap.rs b/crates/sds-core/src/heatmap.rs index f6cc00d..4d98ac7 100644 --- a/crates/sds-core/src/heatmap.rs +++ b/crates/sds-core/src/heatmap.rs @@ -697,7 +697,7 @@ mod tests { use crate::facts::ReasonCounts; use crate::hex::Stand; use crate::pathfind::Reach; - use crate::stands::{Aggregate, StandScore, StandStats}; + use crate::stands::{Prospect, StandScore, StandStats}; fn score(x: i32, y: i32, facing: i32, deal: f32, take: f32) -> StandScore { StandScore { @@ -706,13 +706,13 @@ mod tests { mp_spent: 0, hexes_moved: 0, }, - offence: Aggregate { + offence: Prospect { expected_damage: deal, - ..Aggregate::default() + ..Prospect::default() }, - defence: Aggregate { + defence: Prospect { expected_damage: take, - ..Aggregate::default() + ..Prospect::default() }, best_target: None, per_target: Vec::new(), diff --git a/crates/sds-core/src/stands.rs b/crates/sds-core/src/stands.rs index 8890ee1..29fdef2 100644 --- a/crates/sds-core/src/stands.rs +++ b/crates/sds-core/src/stands.rs @@ -28,11 +28,17 @@ //! against one enemy and exposed to three others is good offence and bad //! defence, and one operator over `N` cannot say so. //! -//! **Five outputs stay five.** [`Aggregate`] carries expected damage, `p_kill`, -//! `p_mission_kill`, expected breaches and `value_destroyed` through both +//! **The outputs stay apart.** [`Outcome`] carries expected damage, `p_kill`, +//! `p_mission_kill`, expected breaches, `value_destroyed` and the firing heat +//! at one enemy position; [`Prospect`] carries the same six through both //! aggregations, because a feature that wants `p_kill` collapsed over `M` must //! not have to re-derive it from a scalar that already threw it away. //! +//! **Two types, because they are two things.** An [`Outcome`] is one volley at +//! one member of `M` and nothing about it is averaged. A [`Prospect`] is a set +//! of those read under one exponent. They have nearly the same fields and mean +//! entirely different things, so a signature that takes one says which. +//! //! What this does not do: nothing here makes the bot move. `sds-bot` still //! offers its four verbs. This is the estimator and the instrument for reading //! it. @@ -123,27 +129,46 @@ pub fn robust_mean(values: &[f32], p: f32, sense: Sense) -> f32 { } } -/// Five volley outputs collapsed over a set, kept apart. +/// What one volley did at one enemy position. /// -/// Field for field, never folded into a scalar: `p_kill` aggregated over `M` is -/// a different question from expected damage aggregated over `M`, and a caller -/// that wants the first must not be handed the second. +/// A single member of `M`, not a collapse over one. Nothing here is averaged: +/// this is the outcome recorded when we fire from a chosen stand at an enemy +/// standing in a particular hex, and it is what [`Exchange`] holds in each +/// direction. +/// +/// Six numbers, kept apart and never folded into a scalar. Five are what the +/// volley did to the target; [`Self::heat`] is what it cost the firer, which +/// is an outcome of the shot in the same sense the damage is. #[derive(Debug, Clone, Copy, Default, PartialEq)] -pub struct Aggregate { +pub struct Outcome { pub expected_damage: f32, pub p_kill: f32, pub p_mission_kill: f32, pub expected_breaches: f32, pub value_destroyed: f32, + /// Heat this volley incurs, in heat points, from [`VolleyOutcome::heat`]. + /// + /// An integer, because one volley's heat is a count of heat points and not + /// a mean of anything. Firing heat only: movement heat is + /// [`crate::facts::Gait::heat`]. + pub heat: i32, } -impl Aggregate { - /// The five outputs of one volley, without the twist and the shot count - /// that produced them. +impl Outcome { + /// One volley's outputs, without the twist and the shot count that + /// produced them. /// - /// What a per-position record stores. The dropped fields describe a chosen - /// weapon allocation rather than an outcome, and nothing downstream of the - /// collapse reads them. + /// **What is dropped and why.** `twist` and `shots` describe a chosen + /// weapon allocation - which secondary facing was taken and how many + /// weapons got to fire under it - rather than what the volley achieved. + /// Two allocations that come out at the same damage, kill chance and heat + /// are the same proposition to anything ranking positions, so a + /// per-position record keeps the achievement and not the arrangement. + /// Nothing downstream of the collapse reads either field. + /// + /// Heat is not in that bucket. It is what firing cost, it varies by twist + /// the same way the damage does, and it is the one number here that says a + /// position is expensive rather than merely poor. pub fn of(outcome: &VolleyOutcome) -> Self { Self { expected_damage: outcome.expected_damage, @@ -151,10 +176,51 @@ impl Aggregate { p_mission_kill: outcome.p_mission_kill, expected_breaches: outcome.expected_breaches, value_destroyed: outcome.value_destroyed, + heat: outcome.heat, } } +} - /// Collapse a set of outcomes with the power mean at `p`. +/// A set of [`Outcome`]s over `M`, collapsed under one power-mean exponent. +/// +/// A prospect is a distribution over outcomes, and the exponent is the risk +/// attitude it is read under: `p -> -inf` takes the worst member of `M`, `p = +/// 1` takes the mean. One of these describes a whole set of enemy positions +/// and never a single one, which is the difference from [`Outcome`]. +/// +/// Field for field, never folded into a scalar: `p_kill` collapsed over `M` is +/// a different question from expected damage collapsed over `M`, and a caller +/// that wants the first must not be handed the second. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct Prospect { + pub expected_damage: f32, + pub p_kill: f32, + pub p_mission_kill: f32, + pub expected_breaches: f32, + pub value_destroyed: f32, + /// Firing heat over `M`, collapsed the same way the other five are. + /// + /// **It survives the collapse deliberately.** We do not choose `M`; we + /// fire from `L` at wherever the enemy actually ends up, so "what this + /// stand costs in heat against this enemy" is a real quantity. A hex that + /// is cheap against most of `M` and ruinous against a few is a different + /// proposition from one that is uniformly hot, and only a collapsed heat + /// figure can tell a doctrine so. + /// + /// A float here where [`Outcome::heat`] is an integer: a mean of heat + /// points is not a heat point. + /// + /// **It is oriented by the channel, not by the sign of the cost.** + /// [`Sense`] is applied once per channel - the whole of `dealt` as a gain, + /// the whole of `taken` as a loss - so on the offence channel a pessimistic + /// exponent reads the *lowest* heat, which is the cheap end of a cost. Read + /// it as "the heat that comes with the damage this exponent priced", not as + /// a worst-case heat bill. + pub heat: f32, +} + +impl Prospect { + /// Collapse a set of per-position outcomes with the power mean at `p`. /// /// Every field of every outcome is present in the set, including the zeros /// a position with no line contributes. Dropping those would answer "what @@ -163,8 +229,8 @@ impl Aggregate { /// **A caller applies this, not the generator.** Field by field and one /// channel at a time, which is a reading of the set and not a fact about /// it: see [`Joint`] for what the pairing says that this cannot. - pub fn collapse(outcomes: &[Self], p: f32, sense: Sense) -> Self { - let pull = |get: fn(&Self) -> f32| { + pub fn collapse(outcomes: &[Outcome], p: f32, sense: Sense) -> Self { + let pull = |get: fn(&Outcome) -> f32| { let values: Vec = outcomes.iter().map(get).collect(); robust_mean(&values, p, sense) }; @@ -174,6 +240,7 @@ impl Aggregate { p_mission_kill: pull(|o| o.p_mission_kill), expected_breaches: pull(|o| o.expected_breaches), value_destroyed: pull(|o| o.value_destroyed), + heat: pull(|o| o.heat as f32), } } @@ -184,6 +251,7 @@ impl Aggregate { self.p_mission_kill += other.p_mission_kill; self.expected_breaches += other.expected_breaches; self.value_destroyed += other.value_destroyed; + self.heat += other.heat; } /// What one target is ranked on when offence picks the best of them. @@ -191,7 +259,8 @@ impl Aggregate { /// Expected damage first, because it is the finest discriminator; the /// tiebreaks make the choice total, so it does not depend on the order the /// enemies were walked in. The same shape [`VolleyOutcome`] uses to choose - /// a twist, for the same reason. + /// a twist, for the same reason. Heat is not in it: what a position costs + /// does not decide which enemy we shoot at. fn objective(&self) -> (f32, f32, f32) { (self.expected_damage, self.value_destroyed, self.p_kill) } @@ -239,7 +308,7 @@ pub enum Half { /// one arc. That is the per-arc split, recorded where it happens rather /// than pre-averaged. side: Side, - value: Aggregate, + value: Outcome, }, } @@ -248,12 +317,12 @@ impl Half { matches!(self, Half::Fired { .. }) } - /// The five outputs. All zero when nothing fired, which is the same number - /// the old dense collapse folded in for such a position. - pub fn value(&self) -> Aggregate { + /// The volley's outputs at this one position. All zero when nothing + /// fired, heat included: nothing fired, so nothing was spent. + pub fn value(&self) -> Outcome { match self { Half::Fired { value, .. } => *value, - Half::Blocked(_) => Aggregate::default(), + Half::Blocked(_) => Outcome::default(), } } @@ -286,8 +355,8 @@ impl Half { /// minimax over each channel is a statement about two. #[derive(Debug, Clone, Copy, Default, PartialEq)] pub struct Joint { - pub deal: Aggregate, - pub take: Aggregate, + pub deal: Outcome, + pub take: Outcome, } /// One `(stand, enemy position)` pair, both directions. @@ -367,12 +436,12 @@ impl TargetExchanges { } /// What we deal at each position, dense. See [`Self::joint`]. - pub fn dealt(&self) -> Vec { + pub fn dealt(&self) -> Vec { self.joint().into_iter().map(|pair| pair.deal).collect() } /// What we take at each position, dense. See [`Self::joint`]. - pub fn taken(&self) -> Vec { + pub fn taken(&self) -> Vec { self.joint().into_iter().map(|pair| pair.take).collect() } @@ -641,9 +710,9 @@ pub struct StandScore { pub reach: Reach, /// The best single target's value, collapsed over where that target could /// be. We fire at one thing. - pub offence: Aggregate, + pub offence: Prospect, /// Every enemy's value against us, summed. They all fire. - pub defence: Aggregate, + pub defence: Prospect, /// Which enemy `offence` describes, or `None` when nothing can be shot. pub best_target: Option, /// What this stand deals to **each** enemy, by id, in the order the enemy @@ -655,7 +724,7 @@ pub struct StandScore { /// enemy is the hex that lets a lance concentrate, and a maximum cannot say /// so. Kept keyed, so a caller asks about a target rather than about a /// position in a list. - pub per_target: Vec<(i32, Aggregate)>, + pub per_target: Vec<(i32, Prospect)>, /// What **each** enemy does to us, by id, in the same order. /// /// The mirror of `per_target`, and `defence` is its sum. Summing at the @@ -663,7 +732,7 @@ pub struct StandScore { /// fire to a shooter: a unit's exposure is properly a conditional - "if /// this enemy shoots me from there, this is what I eat" - and who actually /// shoots whom is an allocation the force makes, not a multiplication. - pub taken_from: Vec<(i32, Aggregate)>, + pub taken_from: Vec<(i32, Prospect)>, } /// What the sweep did, in counters. @@ -1138,7 +1207,7 @@ pub fn score_stands( /// Collapse a [`Sweep`] over `M`, and draw the instruments through it. /// /// **This is the collapse, and it is a caller's.** Field by field with -/// [`Aggregate::collapse`], ours as a gain and theirs as a loss, at +/// [`Prospect::collapse`], ours as a gain and theirs as a loss, at /// `params.exponent`. It is deliberately the same arithmetic the generator used /// to run inline, so a `Ranking` here is the `Ranking` that was there - what /// moved is who asks for it. @@ -1153,16 +1222,16 @@ pub fn rank(sweep: &Sweep, params: &Params) -> Ranking { let mut scored: Vec = Vec::with_capacity(sweep.scored.len()); for (at, record) in sweep.scored.iter().enumerate() { - let mut offence: Option<(i32, Aggregate)> = None; - let mut defence = Aggregate::default(); - let mut per_target: Vec<(i32, Aggregate)> = Vec::with_capacity(record.exchanges.len()); - let mut taken_from: Vec<(i32, Aggregate)> = Vec::with_capacity(record.exchanges.len()); + let mut offence: Option<(i32, Prospect)> = None; + let mut defence = Prospect::default(); + let mut per_target: Vec<(i32, Prospect)> = Vec::with_capacity(record.exchanges.len()); + let mut taken_from: Vec<(i32, Prospect)> = Vec::with_capacity(record.exchanges.len()); for target in &record.exchanges { // Ours is a gain and theirs is a loss, so the same exponent reads // from opposite ends of the two sets. - let mine = Aggregate::collapse(&target.dealt(), params.exponent, Sense::Gain); - let theirs = Aggregate::collapse(&target.taken(), params.exponent, Sense::Loss); + let mine = Prospect::collapse(&target.dealt(), params.exponent, Sense::Gain); + let theirs = Prospect::collapse(&target.taken(), params.exponent, Sense::Loss); per_target.push((target.id, mine)); taken_from.push((target.id, theirs)); @@ -1184,7 +1253,7 @@ pub fn rank(sweep: &Sweep, params: &Params) -> Ranking { // Every target came out at nothing. Naming one of them would be // naming an arbitrary enemy we cannot shoot. Some((_, value)) => (None, value), - None => (None, Aggregate::default()), + None => (None, Prospect::default()), }; scored.push(StandScore { @@ -1275,11 +1344,15 @@ pub fn rank(sweep: &Sweep, params: &Params) -> Ranking { /// and there is no single volley left to hand the measurement. Four of the /// firing group are expressible that way - `expected_damage`, `p_kill`, /// `p_mission_kill` and `value_destroyed` - plus `damage_lead` across this -/// menu. The rest of that group needs a chosen shot: `heat_incurred`, -/// `ammo_spent`, `overkill`, `weapon_concentration` and `p_psr_threshold` are -/// properties of a weapon allocation, and `p_breach` is a probability where -/// [`Aggregate`] carries an expected count. A movement proposal has never -/// carried them and does not start here. +/// menu. The rest of that group needs a chosen shot: `ammo_spent`, `overkill`, +/// `weapon_concentration` and `p_psr_threshold` are properties of a weapon +/// allocation, and `p_breach` is a probability where [`Prospect`] carries an +/// expected count. A movement proposal has never carried them and does not +/// start here. +/// +/// `heat_incurred` is the one that has stopped being impossible: [`Prospect`] +/// now carries firing heat over `M`. Wiring it into the vector is a feature +/// change with its own control run and is not made here. /// /// `damage_by_target` and `damage_taken` are the frontier's own axes, /// unnormalised, which is what a reader wants next to a normalised vector. @@ -1400,7 +1473,7 @@ fn half( match key { Ok(key) => Half::Fired { side: key.side, - value: Aggregate::of(outcome), + value: Outcome::of(outcome), }, Err(NoVolley::SameHex) | Err(NoVolley::NoLine) => Half::Blocked(Blocked::NoLine), Err(NoVolley::NothingBears) => Half::Blocked(if arc_only() { @@ -1567,6 +1640,22 @@ mod tests { } } + /// The same, with a stated heat. `fixture::gun` pins every weapon at 3, + /// which cannot show heat varying over `M`. + fn hot_gun( + id: i32, + damage: f32, + short: i32, + medium: i32, + long: i32, + heat: i32, + ) -> MountedWeapon { + MountedWeapon { + heat, + ..gun(id, damage, short, medium, long) + } + } + fn unit(id: i32, at: Coord, facing: i32) -> Unit { let mut unit = fixture::mek(id); unit.x = at.x; @@ -1610,11 +1699,11 @@ mod tests { best.1 } - /// The five outputs, with everything else left at zero. - fn value(damage: f32) -> Aggregate { - Aggregate { + /// A collapsed column with only damage set. + fn value(damage: f32) -> Prospect { + Prospect { expected_damage: damage, - ..Aggregate::default() + ..Prospect::default() } } @@ -1675,23 +1764,25 @@ mod tests { #[test] fn collapse_runs_field_by_field() { let outcomes = [ - Aggregate { + Outcome { expected_damage: 10.0, p_kill: 0.1, p_mission_kill: 0.4, expected_breaches: 2.0, value_destroyed: 30.0, + heat: 4, }, - Aggregate { + Outcome { expected_damage: 20.0, p_kill: 0.3, p_mission_kill: 0.2, expected_breaches: 1.0, value_destroyed: 10.0, + heat: 12, }, ]; - let worst = Aggregate::collapse(&outcomes, f32::NEG_INFINITY, Sense::Gain); - // Each field takes its own minimum, so the aggregate describes no single + let worst = Prospect::collapse(&outcomes, f32::NEG_INFINITY, Sense::Gain); + // Each field takes its own minimum, so the prospect describes no single // outcome. That is the point: a feature asking for p_kill over M must // not be handed the p_kill of whichever position had the least damage. assert!((worst.expected_damage - 10.0).abs() < 1e-5); @@ -1699,10 +1790,95 @@ mod tests { assert!((worst.p_mission_kill - 0.2).abs() < 1e-5); assert!((worst.expected_breaches - 1.0).abs() < 1e-5); assert!((worst.value_destroyed - 10.0).abs() < 1e-5); + // Heat is one of the fields and takes its own minimum with the rest. + // On a gain that is the cheap end, which the field's note says out + // loud: it is the heat that comes with the damage this exponent + // priced, not a worst-case heat bill. + assert!((worst.heat - 4.0).abs() < 1e-5); - let mean = Aggregate::collapse(&outcomes, 1.0, Sense::Gain); + let mean = Prospect::collapse(&outcomes, 1.0, Sense::Gain); assert!((mean.expected_damage - 15.0).abs() < 1e-4); assert!((mean.p_mission_kill - 0.3).abs() < 1e-5); + assert!((mean.heat - 8.0).abs() < 1e-4); + + // And it is monotone in p like everything else, between those two. + let mut previous = f32::NEG_INFINITY; + for p in [f32::NEG_INFINITY, -2.0, 0.0, 0.5, 1.0] { + let heat = Prospect::collapse(&outcomes, p, Sense::Gain).heat; + assert!(heat >= previous - 1e-4, "p = {p} gave {heat}"); + assert!((4.0..=8.0 + 1e-4).contains(&heat), "p = {p} gave {heat}"); + previous = heat; + } + } + + /// Heat reaches a per-position record intact: the figure `best_volley` + /// reported for that volley is the figure the [`Outcome`] carries, and it + /// is not the same at every position. + #[test] + fn an_outcome_carries_the_volley_heat_at_that_position() { + let board = flat(24, 24); + let los = LosCache::new(&board, Rules::default()); + let here = Coord::new(12, 16); + let our_unit = unit(1, here, 0); + // Two brackets, two heats. A near enemy position has both in range and + // a far one only the long gun, so the heat differs across M. + let mover = Mover { + who: Combatant::mek( + &our_unit, + vec![ + hot_gun(6, 4.0, 3, 6, 9, 4), + hot_gun(10, 12.0, 8, 16, 24, 12), + ], + 4, + ), + elevation: 0, + jumped: false, + terrain: None, + }; + let foe_unit = unit(2, Coord::new(12, 10), 3); + let foes = vec![Foe { + who: Combatant::mek(&foe_unit, vec![gun(20, 10.0, 6, 12, 18)], 4), + may_be: (6..=15) + .map(|y| Presence { + stand: Stand::new(Coord::new(12, y), 3), + elevation: 0, + hexes_moved: 3, + jumped: false, + gait: Gait::Walked, + }) + .collect(), + }]; + + let mut cache = VolleyCache::new(); + let sweep = score_stands(&mut cache, &los, &mover, &[reach(here, 0)], &foes); + let target = &sweep.scored[0].exchanges[0]; + + let mut seen = BTreeSet::new(); + let mut fired = 0; + for entry in &target.present { + let Half::Fired { value, .. } = entry.deal else { + continue; + }; + fired += 1; + seen.insert(value.heat); + // Whatever fired, it cost something, and the cost is one of the + // sums the weapon list can produce. + assert!( + [4, 12, 16].contains(&value.heat), + "position {} came out at heat {}", + entry.at, + value.heat + ); + } + assert!(fired > 1, "the fixture fired at only {fired} positions"); + assert!( + seen.len() > 1, + "heat is uniform over M, so this proves nothing: {seen:?}" + ); + + // And the collapse carries it through rather than dropping it. + let mine = Prospect::collapse(&target.dealt(), 1.0, Sense::Gain); + assert!(mine.heat > 0.0, "heat did not survive the collapse"); } /// The case `plan/candidates.md` names: excellent against one enemy and @@ -1991,15 +2167,15 @@ mod tests { /// The same, with one damage column per enemy. Enemy ids are `2..`, the way /// the example numbers them. fn against(x: i32, y: i32, facing: i32, deals: &[f32], take: f32) -> StandScore { - let per_target: Vec<(i32, Aggregate)> = deals + let per_target: Vec<(i32, Prospect)> = deals .iter() .enumerate() .map(|(i, deal)| { ( 2 + i as i32, - Aggregate { + Prospect { expected_damage: *deal, - ..Aggregate::default() + ..Prospect::default() }, ) }) @@ -2026,7 +2202,7 @@ mod tests { } } - fn best_id(per_target: &[(i32, Aggregate)]) -> Option { + fn best_id(per_target: &[(i32, Prospect)]) -> Option { per_target .iter() .max_by(|a, b| a.1.expected_damage.total_cmp(&b.1.expected_damage)) @@ -2913,9 +3089,9 @@ mod tests { // Two channels, two collapses, no shared position. let apart_deal = - Aggregate::collapse(&target.dealt(), f32::NEG_INFINITY, Sense::Gain).expected_damage; + Prospect::collapse(&target.dealt(), f32::NEG_INFINITY, Sense::Gain).expected_damage; let apart_take = - Aggregate::collapse(&target.taken(), f32::NEG_INFINITY, Sense::Loss).expected_damage; + Prospect::collapse(&target.taken(), f32::NEG_INFINITY, Sense::Loss).expected_damage; assert!( !joint.iter().any(|pair| { (pair.deal.expected_damage - apart_deal).abs() < 1e-4 @@ -3014,7 +3190,7 @@ mod tests { at: 1, deal: Half::Fired { side: Side::Front, - value: Aggregate::default(), + value: Outcome::default(), }, take: Half::Blocked(Blocked::ArcBlocked), }); @@ -3085,7 +3261,7 @@ mod tests { for score in &ranking.scored { let record = &sweep.scored[score.at]; - let mut summed = Aggregate::default(); + let mut summed = Prospect::default(); for (at, target) in record.exchanges.iter().enumerate() { let joint = target.joint(); let over = joint.len() as f32; diff --git a/crates/sds-core/src/surface.rs b/crates/sds-core/src/surface.rs index 3312c13..7687cbe 100644 --- a/crates/sds-core/src/surface.rs +++ b/crates/sds-core/src/surface.rs @@ -504,20 +504,20 @@ mod tests { use super::*; use crate::hex::Stand; use crate::pathfind::Reach; - use crate::stands::Aggregate; + use crate::stands::Prospect; use crate::wire::Coord; /// A stand with stated axis values and nothing else. fn stand(x: i32, y: i32, facing: i32, deals: &[f32], take: f32) -> StandScore { - let per_target: Vec<(i32, Aggregate)> = deals + let per_target: Vec<(i32, Prospect)> = deals .iter() .enumerate() .map(|(i, deal)| { ( 2 + i as i32, - Aggregate { + Prospect { expected_damage: *deal, - ..Aggregate::default() + ..Prospect::default() }, ) }) @@ -534,9 +534,9 @@ mod tests { hexes_moved: 2, }, offence: best.map(|(_, v)| v).unwrap_or_default(), - defence: Aggregate { + defence: Prospect { expected_damage: take, - ..Aggregate::default() + ..Prospect::default() }, best_target: best.map(|(id, _)| id), taken_from: per_target @@ -544,9 +544,9 @@ mod tests { .map(|(id, _)| { ( *id, - Aggregate { + Prospect { expected_damage: take / per_target.len().max(1) as f32, - ..Aggregate::default() + ..Prospect::default() }, ) }) -- 2.51.2