diff --git a/crates/sds-core/examples/common/mod.rs b/crates/sds-core/examples/common/mod.rs
index cf17105..23bc99a 100644
--- a/crates/sds-core/examples/common/mod.rs
+++ b/crates/sds-core/examples/common/mod.rs
@@ -1,11 +1,18 @@
-//! The scene both renders read: one board, us, and three enemies who have not
-//! moved.
+//! The scene both renders read: one board, a force of ours and a force of
+//! theirs, none of whom have moved.
//!
//! Shared so that `stands.rs` and `heatmap.rs` are two pictures of the same
//! numbers rather than two fixtures that drift apart. Everything here is a
//! fixture choice - who is on the board, how far apart, carrying what - and
//! none of it is a model change.
//!
+//! **Both sides are built by the same call.** [`Side::muster`] takes an
+//! [`Allegiance`] and nothing else varies: same unit, same guns, same search,
+//! same blocking rule. A fixture where "us" was assembled by one code path and
+//! "them" by another is a fixture that can grow an asymmetry nobody chose, and
+//! the force layer this scene exists for is exactly the thing such an asymmetry
+//! would hide.
+//!
//! The boards are the real ones from `tests/corpus/pathfind.jsonl`, dumped from
//! MegaMek: `Map Set 5/16x17 Open Terrain 1`, `Map Set 4/16x17 Heavy Forest 1`
//! and `Map Set 2/16x17 Lake Area`.
@@ -20,9 +27,7 @@ use sds_core::arc::MekLocation;
use sds_core::facts::Gait;
use sds_core::hex::Stand;
use sds_core::los::{LosCache, Rules};
-use sds_core::pathfind::{
- reachable, search, MoveBoard, Reach, Search, Walker, MEK_MAX_ELEVATION_CHANGE,
-};
+use sds_core::pathfind::{search, MoveBoard, Reach, Search, Walker, MEK_MAX_ELEVATION_CHANGE};
use sds_core::stands::{Combatant, Foe, Mover, Presence};
use sds_core::volley::MountedWeapon;
use sds_core::wire::{Board, BoardHex, Coord, Unit, Weapon};
@@ -33,18 +38,26 @@ pub const OUR_MP: i32 = 6;
pub const THEIR_MP: i32 = 4;
pub const TOP_K: usize = 8;
-/// Where everybody starts. Us in the south, three of them in the north.
-pub const START: Coord = Coord::new(4, 13);
+/// Where our force starts. Two of them, in the south.
+///
+/// **Two, not one.** A single unit cannot show anything the force layer does:
+/// the case worth seeing - two of ours converging on one of theirs - needs two
+/// movers whose reachable sets overlap, and it cannot occur in a fixture with
+/// one. Three hexes apart, so the sets overlap over the middle of the board
+/// without the two units starting on top of each other.
+pub const OURS: [Coord; 2] = [Coord::new(4, 13), Coord::new(7, 13)];
+/// Where their force starts, in the north.
+///
/// Close enough that most of our reachable set is inside a weapon bracket, and
/// north of the woods belt at `x = 8..11, y = 7..11` so that belt lies between
/// the two sides. Further apart and the picture is about distance rather than
/// about the fight.
///
-/// Three of them, not one: against a single enemy, offence-takes-best and
+/// Two of them, not one: against a single enemy, offence-takes-best and
/// defence-takes-sum are the same number, and the case worth seeing - good
-/// against one, exposed to three - cannot occur.
-pub const ENEMIES: [Coord; 3] = [Coord::new(5, 6), Coord::new(9, 6), Coord::new(12, 7)];
+/// against one, exposed to another - cannot occur.
+pub const THEIRS: [Coord; 2] = [Coord::new(5, 6), Coord::new(9, 6)];
/// The three exponents, worst case to average.
pub const EXPONENTS: [(&str, f32); 3] = [
@@ -56,6 +69,69 @@ pub const EXPONENTS: [(&str, f32); 3] = [
/// The three corpus boards, in the order a page shows them.
pub const BOARDS: [&str; 3] = ["open", "forest", "water"];
+/// Which force a unit belongs to.
+///
+/// Every way the two sides differ is a method on this, so the list of
+/// differences is one screen long and nothing else may add to it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Allegiance {
+ Ours,
+ Theirs,
+}
+
+impl Allegiance {
+ pub fn starts(self) -> &'static [Coord] {
+ match self {
+ Self::Ours => &OURS,
+ Self::Theirs => &THEIRS,
+ }
+ }
+
+ /// The movement allowance the side's reachable sets are drawn on.
+ pub fn mp(self) -> i32 {
+ match self {
+ Self::Ours => OUR_MP,
+ Self::Theirs => THEIR_MP,
+ }
+ }
+
+ /// Facing each other down the board.
+ pub fn facing(self) -> i32 {
+ match self {
+ Self::Ours => 0,
+ Self::Theirs => 3,
+ }
+ }
+
+ /// Unit ids, so a number in a table says which side it is on.
+ fn first_id(self) -> i32 {
+ match self {
+ Self::Ours => 1,
+ Self::Theirs => 11,
+ }
+ }
+
+ fn team(self) -> i32 {
+ match self {
+ Self::Ours => 1,
+ Self::Theirs => 2,
+ }
+ }
+
+ fn friendly(self) -> bool {
+ matches!(self, Self::Ours)
+ }
+
+ /// `U1`, `E2`: the label the renders put on a unit.
+ pub fn label(self, index: usize) -> String {
+ let letter = match self {
+ Self::Ours => 'U',
+ Self::Theirs => 'E',
+ };
+ format!("{letter}{}", index + 1)
+ }
+}
+
/// Pull one named board out of the movement corpus.
pub fn board(name: &str) -> Board {
for line in CORPUS.lines() {
@@ -76,11 +152,17 @@ pub fn board(name: &str) -> Board {
/// A middleweight Mek, whole. Through the wire's own deserialiser, so the
/// example cannot describe a unit the bot could not be handed.
-pub fn mek(id: i32, at: Coord, facing: i32) -> Unit {
+///
+/// The chassis is the same on both sides. Only `team`, `friendly` and the id
+/// come from the [`Allegiance`], so a difference in the numbers is a difference
+/// in position and never a difference in what was mustered.
+pub fn mek(id: i32, at: Coord, facing: i32, side: Allegiance) -> Unit {
+ let team = side.team();
+ let friendly = side.friendly();
let mut unit: Unit = serde_json::from_str(&format!(
r#"{{
- "id": {id}, "name": "Mek {id}", "ownerId": 2, "team": 2,
- "friendly": false, "x": {}, "y": {}, "facing": {facing},
+ "id": {id}, "name": "Mek {id}", "ownerId": {team}, "team": {team},
+ "friendly": {friendly}, "x": {}, "y": {}, "facing": {facing},
"weight": 55.0, "walkMp": 4, "runMp": 6,
"armor": 100, "armorMax": 100, "internal": 50, "internalMax": 50,
"heat": 0, "heatCapacity": 10, "gunnery": 4, "piloting": 5,
@@ -181,7 +263,108 @@ pub fn loadout() -> Vec The roll a weapon needs is assembled from five named terms, and this is them for the stands below. The to-hit, by name
G, A, T and O are one answer
@@ -1021,7 +1066,7 @@ most damage, not at the hex it is standing in now. From its own hex it has not m
);
for pick in featured(sweep, ranking) {
let _ = write!(out, r##"{}
"##, escape(&pick.why));
- out.push_str(&gator_table(scene, foes, &sweep.scored[pick.at]));
+ out.push_str(&gator_table(scene, us, foes, &sweep.scored[pick.at]));
}
out
}
@@ -1261,7 +1306,7 @@ colour, and the two agree only at p = 1.
The boundary is movement points against the walk allowance and nothing else. A pure
turn spends MP and moves no hexes, and MegaMek calls that a walk; the corpus at
tests/corpus/pathfind.jsonl says so for every such state.
M deals nothing, so a per-channel minimax prints M. The third is how many
fire at the position that actually hurts us most, which is the one an enemy settling both channels
would pick.
-| board | pairs whose worst member deals nothing | +
|---|
| board and unit | pairs whose worst member deals nothing | of those, firing somewhere in M |
of those, firing where they hurt us most | most damage there |
|---|
{} stands over {} hexes we can stop in · {} enemy positions in M · {} exchanges scored per direction · {} values in each rank pool
{} stands over {} hexes this unit can stop in · {} enemy positions in M · {} exchanges scored per direction · {} values in each rank pool
The outlined regions are the three enemies’ M: every hex each of them can be standing
+
The outlined regions are the enemies’ M: every hex each of them can be standing
in when we arrive, on {THEIR_MP} MP from where they are now. Each outline runs along hexsides and
contains exactly the hexes that were evaluated - not a hull over them, which would have claimed ground
-the enemy cannot reach. Every scored hex on the panel was scored against every position inside all
-three.
One Mek, {OUR_MP} movement points, three enemies with {THEIR_MP} each. Nobody has moved yet. The -question is which hex to end the turn in, and which way to be facing when the shooting starts.
+Two Meks of ours with {OUR_MP} movement points each, against two of theirs with {THEIR_MP} each. +Nobody has moved yet. The question is which hex each of ours should end the turn in, and which way to +be facing when the shooting starts. Each unit of ours gets its own set of panels, because each starts +somewhere else and so reaches somewhere else.
LM, so each of our stands is scored not against one enemy position but against all of
them. Drawn on every panel as a tinted region outlined along hexsides, one per enemy.
NEvery (L, M, N) triple is run through the volley estimator twice, once in each
@@ -1693,17 +1765,19 @@ are the same claim as a number.
N: best target against summed incomingOffence takes the best enemy: we fire at one thing. Defence takes the
sum: all of them shoot us. A stand that is excellent against one enemy and exposed to
-the other two is good offence and bad defence, and no single operator over N can say
+the other is good offence and bad defence, and no single operator over N can say
so.
Which is also why there are two ranked lists and no combined one. Ranking on a single number would mean fixing an exchange rate between damage dealt and damage taken, and that rate is a tactical opinion that changes with the unit's role, with how much armour it has left, and with whether its side is winning. It belongs to whatever is choosing, not to the estimator.
-This fixture is one of ours against three of theirs. Because offence -is a maximum over one target and defence is a sum over all three shooters, the absolute damage taken -runs about three times the damage dealt at every hex on the board, before position enters at all. The -maxima here are {:.1} taken against {:.1} dealt, a ratio of {:.2} on {} enemies.
+Each panel is one of ours against every one of theirs. Because +offence is a maximum over one target and defence is a sum over all the shooters, the absolute damage +taken runs about as many times the damage dealt as there are enemies, at every hex on the board and +before position enters at all. The maxima here are {:.1} taken against {:.1} dealt, a +ratio of {:.2} on {} enemies. Nothing on this page nets our own force’s fire against theirs - +that is a force-level sum and this is a unit-level estimate.
That ratio is the headcount, not a judgement about the ground. Colouring a hex by
deal / (deal + take) would therefore have encoded how outnumbered we are - the same
statement at every hex, and no information about where to stand. The tooltips keep the absolute pair,
@@ -1713,7 +1787,7 @@ saying so. It is only the colour that has to be scale-free.
{}A per-channel minimax over M reports the worst member, and one member of
M that blanks our guns is enough to zero the column however well the stand does
everywhere else. See the pairing table for how many pairs that is.
| board | regime | top defence stand | +
|---|
| board and unit | regime | top defence stand | damage it deals | top {TOP_K} that deal nothing | no line, share of M |
firing, share of M |
|---|
Across all three boards, {worst} of the {listed_total} stands the defence list names at minimax deal
@@ -1905,7 +1979,7 @@ picks one arbitrarily. The reported facing at such a stand carries no informatio
/// Printed whichever way it falls. A page tuned until it agreed with the
/// prediction would be worth nothing.
fn findings(
- scenes: &[Scene],
+ views: &[View<'_>],
sweeps: &[Sweep],
rankings: &[Vec The prediction on record is that a lower exponent moves the answer towards cover. Cover here is a
stand in woods or with woods next door, because a clearing ringed by trees is cover too - nothing can
see into it - and counting only the trees would miss the hex the defence list picks first. Generated by Hover any scored hex for its numbers. Nothing on this page is tuned to make the picture look
good.board regime top offence
+ "##,
);
let mut verdicts: Vecboard and unit regime top offence
top defence offence in cover defence in cover "##,
- escape(&scene.name),
+ escape(&view.label()),
chip(regime),
top(ranking.best_offence().next()),
top(ranking.best_defence().next()),
@@ -1963,20 +2038,20 @@ see into it - and counting only the trees would miss the hex the defence list pi
verdicts.push(format!(
"{} {} {}{}{}/{TOP_K} {}/{TOP_K} ");
for verdict in &verdicts {
out.push_str(verdict);
}
- let spreads: Vec
crates/sds-core/examples/heatmap.rs from
crates/sds-core/tests/corpus/pathfind.jsonl, which holds three real boards dumped from
MegaMek. No MegaMek and no match are involved in drawing this: it is the estimator run over a fixture.
-Us at (4, 13); enemies at (5, 6), (9, 6) and
-(12, 7). The unit and its guns are a fixture choice, picked so that all five volley
+Ours at (4, 13) and (7, 13); theirs at (5, 6) and
+(9, 6). The units and their guns are a fixture choice, picked so that all five volley
outputs carry information, and are not a model change. The reach outlines are traced here by
sds_core::heatmap::outline, which keeps the edges of a set of hexes that have no hex on
the far side and chains them into closed loops. Not MegaMek’s ConvexBoardArea: that
@@ -2010,8 +2085,8 @@ different thing from a description of what a unit can reach.