From 2dcd06747406f704bd49972b4b3e2008d7bfa40f Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 4 Sep 2026 13:01:58 -0400 Subject: [PATCH] fix(tactics): put bombard in the set a force is scored over `Bombard` had an `Assignment` variant and declared `TransitionEligible`, but was absent from `situation::orderable`. That made it unholdable rather than merely unproposable: `harmonise::decide` reads a held tactic's value out of the proposals and takes nought when it is not among them, so a force opening on it was displaced in round one and could never be proposed back. The stale `IMPLEMENTED_NAMES` list is replaced by the invariant that would have caught it. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I6ae2e7c4910a269c15d27a932dce33bd5753f1be --- crates/sds-core/src/harmonise.rs | 31 ++++++++++++++---- crates/sds-core/src/situation.rs | 5 +-- crates/sds-core/src/tactic.rs | 55 ++++++++++++++++---------------- 3 files changed, 55 insertions(+), 36 deletions(-) diff --git a/crates/sds-core/src/harmonise.rs b/crates/sds-core/src/harmonise.rs index 29c1fb7..d4122c5 100644 --- a/crates/sds-core/src/harmonise.rs +++ b/crates/sds-core/src/harmonise.rs @@ -710,14 +710,17 @@ mod tests { assert_eq!(named, vec!["1st Lance", "2nd Lance"]); } - /// Nothing a force can be ordered onto today obliges another force. + /// `bombard` is the one orderable tactic that creates a duty, and it + /// creates it on itself. /// - /// The assertion is the finding: every orderable tactic is something a - /// force does by itself, and the one that is not - `Bombard` - cannot be - /// ordered. If this ever fails, a tactic has gained a duty and the + /// The assertion is the finding twice over: every other tactic a force can + /// be ordered onto is something it does by itself, and the exception taxes + /// nobody. A bombard force spots with one of its own units, so an + /// obligation names the force that raised it. If a second name appears + /// here, a tactic has started spending another force's machines and the /// coordinator is the place that noticed. #[test] - fn no_orderable_tactic_obliges_another_force() { + fn bombard_is_the_only_orderable_tactic_that_obliges_anything() { let standings: Vec = crate::situation::orderable() .into_iter() .enumerate() @@ -728,8 +731,24 @@ mod tests { forced: None, }) .collect(); + let bombarding = standings + .iter() + .find(|standing| standing.held.name() == "bombard") + .expect("`bombard` is orderable") + .force + .clone(); let harmony = harmonise(&standings); - assert!(harmony.overrides.is_empty()); + let raised: Vec<(&str, &str)> = harmony + .overrides + .iter() + .map(|task| (task.on.as_str(), task.because.as_str())) + .collect(); + assert_eq!( + raised, + vec![(bombarding.as_str(), bombarding.as_str())], + "a tactic other than `bombard` has gained a duty, or `bombard` has \ + started taxing another force" + ); } /// The control arm holds every force on `Engage`, whatever it proposed. diff --git a/crates/sds-core/src/situation.rs b/crates/sds-core/src/situation.rs index 4cb563a..6669aa7 100644 --- a/crates/sds-core/src/situation.rs +++ b/crates/sds-core/src/situation.rs @@ -59,8 +59,8 @@ use crate::features::{Bounded, Feature, Learnable, Score}; use crate::formation::Formed; use crate::hex::distance; use crate::tactic::{ - Advance, Assignment, Break, Engage, Entrench, Flank, Harass, Regroup, Tactic, Untargeted, - Withdraw, + Advance, Assignment, Bombard, Break, Engage, Entrench, Flank, Harass, Regroup, Tactic, + Untargeted, Withdraw, }; use crate::wire::{Coord, Unit}; @@ -115,6 +115,7 @@ pub fn orderable() -> Vec { Harass::anywhere(), Flank::anywhere(), Entrench::anywhere(), + Bombard::anywhere(), Break::anywhere(), Regroup::anywhere(), Withdraw::anywhere(), diff --git a/crates/sds-core/src/tactic.rs b/crates/sds-core/src/tactic.rs index 9a0e83e..8a53b59 100644 --- a/crates/sds-core/src/tactic.rs +++ b/crates/sds-core/src/tactic.rs @@ -1068,6 +1068,8 @@ pub const MOSTLY_A_RENAME: f32 = 0.8; #[cfg(test)] mod tests { + use std::collections::BTreeSet; + use super::*; use crate::features::Dispersion; use crate::Role; @@ -1612,38 +1614,35 @@ mod tests { } } - /// Nothing may be ordered until it has been measured to be distinct, and - /// nothing measured to be distinct may be unorderable. + /// The declared status and the scored set are one fact held in two places. /// - /// [`Status`] and [`crate::situation::orderable`] are two statements of one - /// fact, so the set is recomputed from the scored list rather than written - /// down here: a variant added without a status, or a status changed without - /// a variant, fails on whichever side was left behind. + /// `Status::TransitionEligible` says a force can be ordered onto it, and + /// `situation::orderable` is what decides whether that is true. The gap + /// between them is silent everywhere else, and it is silent in both + /// directions: a tactic in the set that nothing declares is scored without + /// being documented, and one declared but absent from the set is worse + /// than unproposable - it is **unholdable**. `harmonise::decide` reads a + /// held tactic's value out of the proposals and takes nought when it is not + /// among them, so the first proposal above the switching cost displaces it + /// in round one and no later round can offer it back. A force that opened + /// on it would have lost it immediately, and every log after that reads as + /// an ordinary transition. #[test] - fn status_and_the_orderable_set_agree() { - let orderable: BTreeSet<&str> = crate::situation::orderable() + fn what_a_force_can_transition_into_is_what_is_scored() { + let declared: BTreeSet<&str> = catalogue() .iter() - .map(|assignment| assignment.name()) + .filter(|entry| entry.status == Status::TransitionEligible) + .map(|entry| entry.name) .collect(); - for entry in catalogue() { - let eligible = entry.status == Status::TransitionEligible; - assert_eq!( - eligible, - orderable.contains(entry.name), - "`{}` is {} and {}", - entry.name, - if eligible { - "transition-eligible" - } else { - "a candidate" - }, - if orderable.contains(entry.name) { - "orderable" - } else { - "not orderable" - } - ); - } + let scored: BTreeSet<&str> = crate::situation::orderable() + .iter() + .map(|tactic| tactic.name()) + .collect(); + assert_eq!( + declared, scored, + "`Status::TransitionEligible` and `situation::orderable` disagree \ + about which tactics a force can be ordered onto" + ); } /// Every weighting is over the named basis and nothing else. The -- 2.51.2