diff --git a/crates/sds-bot/src/force.rs b/crates/sds-bot/src/force.rs index 9e33586..97f68a8 100644 --- a/crates/sds-bot/src/force.rs +++ b/crates/sds-bot/src/force.rs @@ -298,6 +298,18 @@ impl ForceThinker { tactic.name(), ); + // **The tactic reaching the scorer.** This is the whole of what a + // tactic does: it re-prices the weight set this force scores its + // candidates with, and every force on a side may hold a different one - + // so the weights are per force per round rather than one set for the + // bot. + // + // A move rather than a replacement, because `self.weights` may be a + // fitted set and every tactic is written as the hand-authored baseline + // with one family changed. See `Assignment::retune`, which is where + // that composition lives and is tested. + let weights = tactic.retune(&self.weights); + // The damage coming back, priced before anything is scored. // // Here rather than inside the reconciliation, and before it rather than @@ -309,7 +321,7 @@ impl ForceThinker { // the scoring that actually decided the move. let mut thoughts = thoughts; let ours: Vec<&Unit> = sides.ours.iter().collect(); - let attention = allocate(&mut thoughts, &ours, &self.weights); + let attention = allocate(&mut thoughts, &ours, &weights); // One line whatever the force's size: who each enemy would rather // shoot, and how sure of it we are. A plan is rebuilt several times a // round, so a line per enemy per rebuild would be most of the log. @@ -347,7 +359,7 @@ impl ForceThinker { let orders = reconcile_exploring( &thoughts, sides.remaining, - &self.weights, + &weights, &Doctrine::default(), &self.explore, round.max(0) as u32, @@ -403,7 +415,7 @@ impl ForceThinker { dominant: stance.dominant().name().to_string(), held_rounds: held, changed_because, - weights: self.weights.clone(), + weights: weights.clone(), chosen, stalled, considered: thoughts, @@ -797,6 +809,107 @@ mod tests { assert!(thin.cohesion_lost); } + /// The proof that a tactic reaches the scorer: two tactics, two hexes. + /// + /// The same force, the same machine, the same two stands on offer, through + /// the bot's own command path rather than through the example rig - and the + /// hex it takes is different depending only on the tactic it was ordered + /// onto. Before this was wired, both answers were the same, because + /// `ForceThinker` scored every force with one weight set all match long. + /// + /// The two stands are chosen to separate exactly the families the two + /// tactics move, and between them they exercise both arms of + /// `Assignment::retune`: + /// + /// - `cover_quality` is a **ratio**: `Engage` prices it 2.0 and `Entrench` + /// 6.0, so a fitted base would keep its own number scaled by three. + /// - `edge_distance` is an **addition**: `Engage` does not price it at all + /// and `Withdraw` prices it -8.0, which is the largest weight in that + /// vector. + #[test] + fn a_force_scores_with_the_tactic_it_is_on() { + use sds_core::features::positional::{CoverQuality, EdgeDistance}; + use sds_core::features::Score; + + /// One unit with two stands: in cover and far from the edge, or in the + /// open and standing on it. + fn menu() -> UnitThought { + let stand = |label: &str, at: Coord, cover: f32, edge: f32| { + let mut features = FeatureVector::new(); + features.record::(Score::fraction(cover)); + features.record::(Score::fraction(edge)); + Proposal { + unit: 1, + label: label.into(), + action: Action::Move { steps: vec![] }, + end: Some(at), + features, + damage_dealt: 0.0, + damage_by_target: BTreeMap::new(), + damage_taken: 0.0, + feared: BTreeMap::new(), + risk: 0.0, + notes: vec![], + } + }; + UnitThought { + unit: 1, + role: "BRAWLER".into(), + appraisal: Appraisal::default(), + proposals: vec![ + stand("in the woods", Coord::new(8, 8), 1.0, 1.0), + stand("on the edge", Coord::new(0, 0), 0.0, 0.0), + ], + role_conflict: None, + threats: vec![], + } + } + + let ours = vec![scout(1, "1st Lance")]; + let thinker = ForceThinker::default(); + let taken = |tactic: &str| { + let decision = thinker.command( + Ordered { + tactic: Some(tactic.to_string()), + proposal: None, + }, + "1st Lance".into(), + vec![menu()], + Stance::default(), + Grit::default(), + 1, + &Sides { + remaining: &BTreeMap::new(), + ours: &ours, + }, + ); + assert_eq!(decision.tactic, tactic); + let chosen = decision + .chosen + .iter() + .find(|chosen| chosen.unit == 1) + .expect("the unit got an order"); + (chosen.label.clone(), decision.weights.clone()) + }; + + let (entrenched, entrench_weights) = taken("entrench"); + let (withdrawing, withdraw_weights) = taken("withdraw"); + + assert_eq!(entrenched, "in the woods"); + assert_eq!(withdrawing, "on the edge"); + assert_ne!( + entrenched, withdrawing, + "the tactic did not reach the scorer" + ); + + // And the recorded weights say why, so a reader of the decision can + // recompute the value rather than trusting the label next to it. + assert_eq!(entrench_weights.get("cover_quality"), 6.0); + assert_eq!(entrench_weights.get("edge_distance"), 0.0); + assert_eq!(withdraw_weights.get("cover_quality"), 2.0); + assert_eq!(withdraw_weights.get("edge_distance"), -8.0); + } + /// A force nobody harmonised for falls back to its formation's opening. /// /// The behaviour before there was a coordinator to ask, kept so a node diff --git a/docs/HIERARCHY.md b/docs/HIERARCHY.md index a1f03ca..9fd0297 100644 --- a/docs/HIERARCHY.md +++ b/docs/HIERARCHY.md @@ -285,12 +285,28 @@ agrees. Built: two-level planning, the barrier where a force waits for all its units, menu reconciliation, and node transports so a level can run on another machine. -Not built: the tactic layer described above. What exists today is a **stance** — -a weight over seven hand-scored intents, chosen by summing per-unit appraisals -whose arithmetic is hand-written rather than fitted. It is a parallel scoring -system to the feature basis, it prices defence at roughly half of offence by -hard-coded constant, and two of its seven intents are arithmetically unable to -win. See `plan/scoring.md` and `plan/tactics.md`. +Built, since: the tactic layer. Every force carries a Tactic, and that Tactic +selects the weights it scores its candidates with — a re-pricing of whatever +base the bot is playing with rather than a replacement of it, so a fitted +weights file survives a force changing tactic. A force is given one by its +Formation, proposes a new one each round over a basis that cannot read a hex, +and the coordinator harmonises the proposals. See +[FORMATIONS.md](FORMATIONS.md), [SITUATION.md](SITUATION.md) and +`sds_core::harmonise`. + +**Not fitted.** The tactic weights are hand-authored, and the one number that +decides which tactic a force proposes is a single weight on "is this what my +formation opens on". Everything else in the situational basis is measured, +recorded and weighted at nought, waiting for a fit. + +Still there, and deliberately: the **stance** — a weight over seven hand-scored +intents, chosen by summing per-unit appraisals whose arithmetic is hand-written +rather than fitted. It no longer decides anything. What it still does is +*surfacing*: `surface::vectors_for("stance", …)` picks which candidates are +carried up with a readable label, which is a reader's concern rather than a +decision. Two scoring systems is not the end state, but cutting the list down +for a reader is a different job from choosing, and the two want separating on +purpose rather than at once. See `plan/scoring.md` and `plan/tactics.md`. ## Where a node runs is an address diff --git a/plan/tactics.md b/plan/tactics.md index 10e6257..a58d8cc 100644 --- a/plan/tactics.md +++ b/plan/tactics.md @@ -350,10 +350,11 @@ as. See [FORMATIONS.md](../docs/FORMATIONS.md). particular, and `Engage` again when the formation loses cohesion - [x] Every force carries the answer into its decision, so a person reading a match log can see which tactic each force was on -- [ ] **Nothing scores with it yet.** The force still reconciles with the one - hand-authored weight set, so this changes no play. Scoring with - `Assignment::weights` is a measured change and needs a benchmark beside - it, not a one-line swap +- [x] A force scores with the tactic it is on: `Assignment::retune` re-prices + the base the bot is playing with, so a fitted weights file survives a + force changing tactic. **This changes play and has not been measured** - + it wants a benchmark against the same build with the layer pinned to + `Engage` - [x] A weapon's family on the wire, per weapon, by MegaMek's own type system - which unblocked the Rifle, Anvil, Anti-Air, Artillery Fire and Fire Support lances at once. Only the Order Lance is still declined, because