id: features title: A normalised, named feature basis status: open dependsOn: [protocol, los, ev] exitCriterion: > Every scoring decision is a dot product of named, normalised features, and each feature is explainable to a player in one sentence. #
features #
Replaces the current Intent enum. A proposal's value stops being a self-report
(Proposal.serves) and becomes a measurement.
Normalisation is the crux. Raw features in mixed units reproduce Princess's actual defect - whichever term has the biggest numbers on this board dominates the argmax. Each feature declares its normalisation type, and the type decides whether it can be used for learning.
| type | meaning | learnable |
|---|---|---|
| bounded | a rule gives the domain (to-hit 2-12, facing 0-5) | yes |
| rank | ordinal among units on the board; force-size invariant | yes |
| local | min-max across this decision's candidates only | argmax only |
A sentence is not enough to argue with #
The exit criterion above asks for a sentence per feature, and every feature has one. A sentence does not say what the number is: "how much the ground between us and the enemy would spoil their aim at this hex" does not tell a reader whether one stand of light woods reads 0.10 or 0.90, and that is the question somebody holding a fitted weight actually has. Two of the three feature pairs that turned out to be one column were found by reading numbers, not sentences.
So each feature also has a vignette: a small hand-built situation measured
through the feature's own measure, drawn as an SVG, with the value written on
every hex. Nothing in it reimplements a feature, so a figure that disagrees with
the bot is impossible rather than unlikely.
docs/FEATURES.md is the page.
crates/sds-core/examples/vignettes/ is the registry,
cargo run -j 2 -p sds-core --example featuredoc regenerates every figure and
the index, and crates/sds-core/tests/vignettes.rs fails when the checked-in
files stop matching what the registry renders. A docs page that can go quietly
stale is worse than no docs page, because it is read and believed.
The figures are drawings, not screenshots. A screenshot is not byte-reproducible - fonts and rasterisers differ between machines - so it cannot be what a staleness check compares, and an approximate staleness check does not hold a docs build true.
The obligation is a trait bound #
There is no list of features owing an example, and no test that walks one.
for_each_feature! in features::mod is the list of every feature there is,
and it is what builds the catalogue. The docs build expands the same macro and
asks each type in it for Documented::example, which has no default:
error[E0277]: the trait bound `firing::NewIdea: Documented` is not satisfied
A feature added to that macro without a worked example does not compile. There is nowhere to add a name to make that go away, which is the difference between this and the exception list it replaced.
The name, normalisation and sentence are not part of an example: they are read off the feature type. A vignette that named the wrong feature, or quoted a stale sentence, is unrepresentable rather than merely tested for.
Worked examples for the labels #
Twenty-nine labels, each with one sentence and nothing else. A label is harder to picture than a feature and needs it more: a feature is a number read off a board a reader can see, and a label is a number read off the rest of the match through a discount, a credit rule and a tier. Nobody can check a sentence like "discounted enemy BV removed from the decision's round to the end" against anything.
Why the trait trick does not reach them. Documented works because
for_each_feature! is a list of Rust types and the compiler can demand an impl
per type. LABEL_MEANING is a dict in sds/train.py, so there is no type to
hang a bound on and no compile step to fail. The enforcement has to be a test:
one that walks LABEL_MEANING and fails on any key with no worked example, in
tests/, run by the harness-tests prek hook. Weaker than the compiler, and
the closest thing available on that side of the wire.
What one example is. Not a board. A label is a function of a window, so
the figure is a timeline: a handful of rounds, each with what happened in it -
BV removed either way, damage either way, machines lost, where our force stood -
and one decision marked as the row being labelled. Then a column per label
showing what it makes of that same window. The interesting part is that the
columns disagree: a round where we blew a gun off scores high on bv_per_damage
and low on damage, and a fit told to regress on one is being told to ignore
the other.
The shape is a small table plus a strip of per-round bars, and the same SVG
discipline as the features: written by a generator, checked in, compared byte
for byte by a test. It should reuse render.rs if the Rust side can be given
the window, and otherwise draw its own - a duplicate renderer is the thing to
avoid, so try the first before accepting the second.
What has to be on it, per label:
- The number, from
sds/train.pyitself rather than recomputed - the same rule as the features, and for the same reason gamma, because every discounted label is a different number at 0.9 and 1.0 and the sentence does not say which was used- The
--credit(flatorratio) and--tier(unit,force,team) it was taken at, since those change what the row even refers to - Whether it is
ONE_SIDED,INVERTEDor aRATIO, because those three lists already decide howEXPECTED_SIGNSreads a fitted weight, and a reader looking at a suspicious sign needs to know which bucket the label was in - For the two in
TEAM_ONLY, why a per-unit share of a differential is not a quantity -train.pyrefuses it at run time and the page should say so before somebody tries
One window for all of them, the way the firing figures share one scene. Two
lances, six or seven rounds, one round where a machine dies and one where only
armour moves, so the labels that cannot disagree on a quiet window do disagree
on this one. If two labels produce the same column on the shared window that is
worth seeing: it is the thin_columns question asked of labels instead of
features, and nothing currently asks it.
Exit criterion. Every key of LABEL_MEANING has a figure and a paragraph;
a key without one fails python3 -m unittest discover -s tests -t .; and the
page is reachable from docs/FEATURES.md, or from whatever the docs site
becomes.
What drawing them turned up #
Every item here was found by building a figure and reading the number off it, which is the argument for the figures. None is fixed: each is a change to what the bot computes, and one of them is a rules question rather than a code one.
-
**Settled by probing, not by asking.** It was written up here as a rules question. It was not one: MegaMek is the specification for what a shot needs, and `sds.SdsMinimumRange` asks it. The probe walks a target down a clear column of a real board and reads `WeaponAttackAction.toHit` and `Compute.getRangeMods` back at every hex, for every weapon on seven shooters chosen for their minimum ranges. 566 cells against MegaMek 0.51.0, recorded in `scenarios/minrange/observed.txt`. What MegaMek charges: `minimum - range + 1` at or inside the minimum, **added to** the bracket step rather than replacing it, and never a refusal. An LRM 20 is +6 at one hex on top of a short bracket of +0, and +1 at exactly six. A weapon's `mm - R` is constant over the whole walk, so the range term is the entire range dependence of the to-hit. The rule was already right in `brackets::minimum_range_penalty`; only the projections were missing it. Both now call one `brackets::projected`, so they cannot drift on it again, and `projections_price_a_minimum_range_alike` rebuilds one projection out of the other at every range from 1 to 24. `crates/sds-core/tests/minimum_range.rs` holds the Rust to every cell the probe recorded. **What it moves.** Over the 24 mirrored scenarios of the benchmark suite: 43.3% of units (78 of 180) carry at least one weapon with a minimum range, 14.3% of weapons (142 of 996) have one, and force-wide projected damage at one hex falls 14.0% - 11.7% at two, 8.6% at three, 3.3% at six. `./sds.sh minrange-probe` prints that census beside the table -
**What it moves**, from the same 24 mirrored scenarios the minimum-range census walks: 30.0% of units (54 of 180) carry at least one weapon with a to-hit modifier of its own, 13.9% of weapons (138 of 996) have one, and force-wide projected damage rises 9.4% at one hex, 8.7% at three, 10.2% at six, 11.5% at nine and 14.4% at twelve. It is one-directional in the opposite sense from a minimum range: almost every own modifier in the suite is a pulse laser's -2, so the term only ever makes a machine look better, and it made the machines carrying them look worse than they fire
Normalisation, asked of the figures #
Every one of these is a question about what a feature's 0.0 and 1.0 mean.
They came from reading the pictures, they are all small, and none is fixed here.
edge_distance above belongs with them and is written up there rather than
twice - one job should appear once on a list somebody is working through.
A basis wants measurements that can disagree #
Two features that cannot disagree are one feature and a coin toss over how to split its weight. The arithmetic is indifferent to the split, so the fit picks one arbitrarily, and a large confident weight can appear on either half with the opposite sign on the other. It has now happened twice, and both times the giveaway was a weight whose sign was absurd rather than whose size was.
The cheap check, before recording a corpus rather than after fitting one: for each pair of features, the share of candidates where they are equal, and their correlation. A pair over about 0.95 is one column.
Damage lands somewhere #
Three features compared the whole volley's distribution against a single
location's health, as if every point arrived in the same place. The centre
torso takes 7 rolls in 36 and a leg takes 4, so p_kill was overconfident - and
not by a constant. A threshold needing k packets in one place falls off like
share^k, so measured against a 20-point centre torso two AC/20s were 3.5x
overconfident, three PPCs 11.7x and four LRM-20s over a thousand times. No
fitted weight can absorb an error that swings with the shape of the volley, and
a fit correcting for one pushes the feature negative - which is what p_kill
drew across 17 overnight runs.
crates/sds-core/src/hitloc.rs holds the model. Tables and the transfer chain
come out of MegaMek through bridge/sds/SdsHitLocations.java, never a rulebook.
What the migration cost #
Movement candidates are measured against a projected volley from the hex they
end in - range bracket and gunnery, no to-hit number, because the rules compute
none for a shot nobody can take yet. That half is honest. The other half arrived with
los: exposure prices the share of the enemy's firepower that has a line to
a hex, cover_quality prices what the ground in between does to their aim, and
los_in prices being seen at all. Before them the bot scored a move on what it
could shoot and not on what could shoot it, and closed harder than it should.
Two features are deliberately left out of a movement vector: heat_incurred
and ammo_spent. A move commits to neither, and a column that is the same
saturated number on every candidate teaches a fit nothing.
Named ideas, from a player #
These came from jmm and are worth more than the average backlog item: each one names a shape the basis currently cannot express, not just a quantity it does not measure. Verify every threshold against MegaMek before implementing - the numbers below are a player's recollection and MegaMek is the authority on what this simulator does.
Heat wants a feature per breakpoint, not a curve #
Done, less TSM. See crates/sds-core/src/features/heat.rs.
Heat does nothing gradually. It crosses a line and something specific breaks:
movement, then aim, then shutdown, then ammo. TSM turns on at a line too, and
that one is a benefit. Verified in MegaMek: Mek.hasActiveTSM tests heat >= 9.
Neither a linear nor an exponential term can express "17 is barely worse than 16, and 19 is a different game". One bounded feature per breakpoint can, and each is separately explainable to a player - which is the whole point of the basis.
Breakpoints to confirm against Mek and the heat effect tables: movement
penalty, to-hit penalty, shutdown roll, ammo explosion roll, TSM activation.
Read them out of MegaMek rather than transcribing from a rulebook. Four of the
five are in; TSM is not, and wants Mek.hasActiveTSM on the wire before it can
be measured on anything but a guess about the chassis.
Melee is a cliff, and it constrains what you may fire #
Ending a move adjacent, in a hex you can actually attack from, is worth a great
deal of damage. Ending one hex short leaves all of it on the table. A distance
feature cannot say that; a melee_reach feature can.
Three complications, each arguably its own feature:
- Firing an arm weapon blocks that arm's punch; firing a leg weapon blocks the kick. So a firing decision spends a melee option, and the bot has to be able to notice it is doing that.
- Closing to melee usually gives the enemy the same option against us.
- So "run in" and "hold and shoot" are a real fork, and the basis should let a weight decide it rather than a rule.
Side arcs are not the rear arc #
Flanking is currently framed as the rear arc. A side arc is a different thing and interacts with damage already taken: if their left leg armour is gone, reaching their left side is worth far more than their right. That needs the per-location armour the observation now carries, crossed with which arc a candidate hex falls in.
TMM is a ratio, not a quantity #
Movement buys a target movement modifier and costs accuracy. Players do not ask "how much TMM did I gain", they ask whether it was worth it - jumping for +1 TMM while taking +3 to your own hits is a bad trade, and a feature that only counts TMM gained will happily make it.
So: a TMM-per-point-of-self-penalty feature rather than a TMM feature. Possibly also a term for TMM lost to turning, which would discourage running in circles.
Cripple wants arc crossed with what is already open #
cripple is not only legs and kicks. The larger part is aligning shots at
locations that are already breached, and that is an interaction between the
arc a shot comes from and the armour that arc's hit table can reach.
The mechanism is exact rather than vague. A shot rolls on a hit-location table chosen by attack direction - front, left, right, rear. Being in an enemy's rear arc means rolling on the rear table, which reaches rear torso armour and legs. If their rear centre torso armour is already gone, every hit there goes to internal structure and rolls for criticals instead of chipping paint.
So the feature is not rear_arc_gain and not target_breach separately. It is:
expected_internal_damage(arc)
= sum over locations of P(location | arc) * already_open(location) * damage
P(location | arc) is a fixed 2d6 table. already_open(location) is the
per-location armour the observation now carries. The damage distribution is
ev. Everything needed exists except the table.
This is also the general form of the side-arc note above: reaching their left side matters because their left leg is open, not on its own. One feature, and the arc and the damage are no longer two unrelated terms a linear model has to multiply for itself - which it cannot.
Port the table, do not call the roller #
MegaMek has Mek.rollHitLocation(table, side). Do not call it. It rolls,
which means it draws from Compute's RNG - the same stream the match runs on -
so asking it a question during deliberation would change what happens in the
match. That is the determinism invariant, and it would be violated silently.
Port the table into Rust the way the cluster table in ev was ported: transcribed from MegaMek, checked against a differential corpus, with a test that fails if a row drifts.
Why this one interaction, when interactions are otherwise deferred #
Interaction features are listed above as budgeted and named, and the note stands
that they should wait until the linear terms have weights that agree with their
own descriptions. This one is the exception worth making, because it is not a
fishing expedition: the mechanism is a rule of the game, the term is named in
advance, and cripple cannot be expressed without it.
Our own fall risk is a threshold nothing prices #
Falling drove the death spiral jmm found by playing: 88 falls against Princess's
22 in one 30-match batch, 27 of them from failed stand attempts. The causes
MegaMek records are 20+ damage, leg/foot actuator hit and Pilot unconscious - all damage taken, none a movement choice.
p_psr_threshold (+1.5) prices the chance we knock them down.
There is no counterpart for the chance we are knocked down. exposure (-3.0) is
the nearest thing and is linear in expected incoming damage, so it cannot see
the cliff: standing where 19 points are expected and where 21 are expected score
almost identically, and the second one falls over. Same shape as the heat chart,
where the fix was to measure the crossing rather than the level.
What was found while scoping it, so the next attempt starts here:
- The probability exists and is cheap.
DamagePmf::piloting_cliff()isat_least(PILOTING_CLIFF)withPILOTING_CLIFF = 20, andsolveinvolley.rshas bothev: &mut EvCacheandoption.shots: Vec<ShotKey>, soev.allocation(&option.shots).piloting_cliff()is one memoised line besidep_kill. VolleyOutcomeandstands::Outcomewould each need the field, threaded the wayp_killandp_mission_killalready are.- The seam is the missing part. A generated stand's feature vector is built
in
stands.rsfromPostureand nothing else, andPosturedescribes what a hex can see rather than what an exchange produced.AmongCandidatesis a min-max over a candidate set and is the wrong shape for a probability, whose absolute value is the point. So the feature needs either a new input carrying the incoming half, orPostureextended with it - a design decision, not a mechanical change.
Do not approximate it from damage_taken. A mean is exactly what the feature
exists to stop being the answer.
Equipment we carry but do not model #
jmm walked the full equipment list of the scenario suite and named the caveats. Recorded here so each is a decision rather than an oversight. None of these is a blocker; all of them are real effects the estimator is currently blind to.
Anti-Missile System. Never fired offensively - brackets::Excluded already
keeps it out of a volley, and jmm confirms firing it offensively is misuse. What
is missing is the other half: an AMS on the target shoots down incoming
missiles, so our expected damage against a unit carrying one is overstated.
Minor, and it is the mirror of Artemis below.
Artemis IV. Changes the cluster roll, which is exactly what the volley estimator computes and exactly what it does not account for. Understates our own missile damage where a launcher is Artemis-linked.
ECM. Marginal, and mostly through Artemis - a Guardian suite switches it off. Ignored for now, but it means the Artemis correction above is conditional on the target's ECM rather than absolute.
Plasma Rifle. Does damage and heat to the target. Treated as damage only for now, which understates it against a unit already running hot.
Narc / iNarc. Excluded and never used. It attaches a pod rather than doing damage; the pod then helps missiles that follow.
MASC. Affects movement. The sweep does not know how to use it, so it is ignored - which costs reach on the designs that carry one.
Triple Strength Myomer. Very complex to model and with no negative side effect, so ignoring it is safe. It makes those designs slightly better than we score them.
Rotary AC/5, and Ultra autocannons. Both have firing modes rather than
ammunition choices: more shots off one declaration, at more risk of a jam. Both
are now on the wire (fireModes) and both are fired under a fixed policy —
Weapon::preferred_mode puts an Ultra in Ultra and a Rotary in 3-shot,
jmm's rules, with no feature and no weight behind either.
What is not modelled is the jam. A jam takes the weapon out for the rest of the match, which is a real cost against the extra damage, and nothing here prices it: the bot fires at the higher rate unconditionally. Modelling it needs a probability the observation does not carry and a way to value a weapon the unit still owns but cannot fire, and neither is a small change.
Two smaller caveats on the same change. The extra shots are estimated as n
independent rolls at the same to-hit, which matches jmm's "double rate of fire"
and gets the expectation right; the spread is what that glosses. And heat is
scaled by the shot count, because the shots are what generate it.
Rocket Launcher 20. Expressible as cluster shots, but it is one shot - once fired it is spent for the match. Nothing currently stops it being offered again.
Physical weapons (Hatchet, Sword). Dead weight on the designs that carry them, since physical attacks are out of scope. Not a reason to drop a design.
A-Pod / B-Pod. Cannot be fired by us and are already excluded. No issue.
Our own machine, and why it cannot be a feature on its own #
There are six target_* features describing the enemy and, until now, none
describing the unit doing the moving. The obvious repair - add own_armour,
own_run_mp, own_tonnage - does not work, and the reason is worth writing
down so it is not attempted twice.
The fit runs on the difference between a chosen candidate and the ones it beat,
within one decision. A unit's armour, movement allowance and tonnage are the
same on every candidate for that unit's move, so every one of those columns
differences to exactly zero. They would land in the report's "Dropped as
constant" list beside has_explodable_ammo and honour_broken - which is what
that list already is: properties of a unit or a match, in a framing that can
only see properties of a candidate.
Scoring is a dot product, so the model also cannot form "being seen matters less
when we are hard to hit" out of exposure and tmm_gained side by side. That
is a product, and there is no weight vector over two columns that expresses one.
Both problems have the same answer: hand over the product.
exposure_covered- exposure scaled by what this turn's movement takes off the enemy's gunnery.exposure_when_hurt- exposure scaled by how little of the machine is left.
These are fittable even though the modifier is constant within a decision:
d(c * f) = c * df, so the constant scales how much the varying half matters
and the weight is identified. Measured over one 2v2, against the 0.2-0.9%
non-zero rate that got the heat family dropped: exposure_unmitigated is 72.0%
non-zero and varies within 100% of decisions; exposure_when_hurt is 36.4% and
varies within 62.5%, the gap being that an undamaged machine reads zero by
construction and most machines start a match undamaged.
Neither is in EXPECTED_SIGNS. exposure is not either, and for the same
reason: a unit has to be exposed to shoot, so the sign is a question this bot
exists to answer rather than something already known.
Still wanted, and needing state this bot does not keep. How far a unit has
ranged from where it deployed. Nothing on the wire carries a start position -
deploy_hexes exists only during the deployment phase - so this needs the bot
to record each unit's first position and carry it, which is a change to Bot
and not to a feature. cover_distance is built.
cover_distance #
Hexes to the nearest cover, as against cover_quality's "is there cover
here". What it adds is whether an exposed hex is somewhere we can leave next
turn or somewhere we are committed to.
Searched to four hexes, which is a running move for most machines, so a hit inside the radius is cover a unit could actually reach. Open ground reads 1.0 - the top of the range rather than an error - because past a running move the difference between "far" and "further" is not one this bot can act on in a turn. The radius also bounds the cost: this is measured once per candidate and candidates run to thousands per decision.
Needed a board reference on Posture, which had none: every other positional
feature asks about the hex it is standing on or about lines the survey already
traced, and this is the first to ask about ground nobody looked at.
Measured over one 2v2: 95.6% non-zero, varying within 100% of decisions.
A note on reading these numbers #
exposure_when_hurt measured 36.4% non-zero on one match and 4.8% on another,
and the difference is not noise in the feature - it is how much damage had been
done by the time the log was written. A feature gated on damage is sparse in a
short match and dense in a long one, so its density has to be read off a corpus
rather than off whichever match was to hand.
enemy_nearest and enemy_centroid #
Where the fight is, as against whether our guns reach it. range_band_fit asks
a question about the guns; these two ask about the position.
They are a pair on purpose. A hex on the flank of a spread-out enemy is close to
one machine and a long way from the mass of them, and either number alone reads
that hex wrong. enemy_centroid is also the per-unit counterpart to
dispersion, which is a label about the shape of our own force and has no
feature that sees the other side's.
An empty board reads 1.0 on both - as far away as the board allows - rather than
zero, which is the same choice cover_distance makes for open ground.
overlook #
The share of the board a hex has a line to. cover_quality reads the terrain a
shot passes through; this reads the shape of the ground the hex sits in, which
is how a hollow or the back of a ridge breaks line of sight with nothing
standing in it. It is also independent of where the enemy is this turn, so it is
a property of the position rather than of the moment.
Sampled on a fixed lattice - every third hex, anchored at the board's origin and never at the candidate's. The anchor matters: a lattice anchored on the candidate would make the answer depend on where the unit happened to be, which is the one thing a positional feature must not do.
Deliberately not cached. The line-of-sight cache is for the unit-to-unit questions a whole side asks in overlapping sets. A lattice keyed on the candidate's own hex would add tens of thousands of entries per decision and hit almost none of them, which is not a cache but a leak.
What its cost is known to be. On a span-17 board the lattice is 36 traces per candidate. Timed end to end, three runs each way on a 2v2: 21.4/22.1/22.0 seconds without it against 21.9/22.2/22.0 with, which is inside run-to-run variance. At 4v4 wall clock varies by 30% between identical runs and cannot resolve a difference this size in either direction - the two runs without it came out slower than the two with, which is noise and not a finding.
What its cost is not known to be. 8v8, and a board wide enough for the lattice to grow - a 32-wide map is about 120 traces rather than 36. If it shows up there, the fallback is local relief: how much of the ring around this hex stands above it, which is a dozen hex reads instead of a trace each and answers most of the same question.
What the first fit said about the interaction pair #
Both survived every drop filter and took real weight - exposure_when_hurt
+0.1692 and exposure_unmitigated +0.1364 against exposure itself at -0.1893,
seventh and eighth of twenty-two. That is also the signature of two columns
measuring one thing, so the next thing to check was collinearity rather than
tactics.
In difference space, over 84,368 rows:
| column | |r| against exposure |
|---|---|
exposure * (1 - tmm) - as first written |
0.97 |
exposure * tmm |
0.76 |
exposure * (tmm - 0.5), centred |
0.82 |
exposure * (1 - health) - as first written |
0.69 |
exposure * health |
0.99 |
exposure * (health - 0.5), centred |
0.93 |
exposure * (1 - tmm) was exposure again: most candidates move slowly enough
that the multiplier sits near one, so the column is a scaled copy. It is now the
complement, exposure_covered, which is zero for a unit that stood still and
rises only where speed bought something. exposure_when_hurt was already the
better half of its pair and is unchanged.
Neither weight is separately interpretable, and 0.76 is the floor. Every
product of exposure correlates with exposure; centring made both worse
rather than better. The pair's weights have to be read together, which is what
the ridge penalty is there for. A reading of exposure_covered alone is a
reading of exposure with extra steps.
Five more positional features #
Each was added against a question an existing feature answers with the wrong average.
edge_distance- a unit against the board edge has lost half its escape routes and can be pinned there.cohesionandenemy_nearestread a corner and the middle identically whenever the distances happen to match.friend_support- the share of our other machines with a line to this hex.cohesionis a distance: a lancemate six hexes off across open ground supports this hex and one three hexes off behind a hill does not.crowding- the share of friends within two hexes. The other end ofcohesionand not its inverse: a force wants to be in supporting distance and does not want to be stacked where one position covers all of it.range_spread-range_band_fitaverages, so it reads a hex facing one enemy at optimum the same as a hex caught between two at point blank and extreme. This is the spread itself.
friend_support traces lines, and like overlook it does so outside the cache -
one line per friend per candidate, which is a handful rather than a lattice.
percent_options #
Of all the hexes a machine could reach on flat empty ground with its allowance, what share can it reach from this hex, under the real rules and the real terrain. Everything else in the positional group prices the hex we are standing in; this one prices the hexes we could still get to.
Water is the case it was built for. A Mek standing in depth-one water may
not use its jump jets at all, and wading costs three movement points a hex at
depth two. So jumping into a lake spends the jets and strands the machine.
submerged says the legs are wet; it says the same 1.0 for a lake with a shore
and a lake without one.
The denominator is not clipped to the board, and that is deliberate. A
corner scores low because backing into one is a real loss of routes out, and it
also correctly reduces the angles anything can flank from. Both effects belong
in the one number. edge_distance says where the edge is; this says what
standing near it costs.
The rule it encodes. Entity.getJumpMPWithTerrain() answers for the hex the
unit occupies now, and that one answer is all the wire carries. A candidate hex
is by definition one the unit is not standing in, so "may this machine jump from
there" is a rule the bot has to state itself. That is a new kind of thing in
this codebase - the inverse of "Read out of the jar, not recited." - and it now
has a marker of its own: "Encoded here, not read out of the jar.", followed
by Rule:, Why not read: and Check:. A test fails a use that leaves one
out. Grep for it; every hit is a place the bot could be wrong about the rules
with nothing failing.
What percent_options costs, measured #
One reachability search per candidate, where the decision already runs two for
the whole menu. Release build, the four movement-corpus boards, a menu built the
way candidates::generate builds one, against the 1,930ms movement decision
measured further up this file.
The first measurement, uncached, said it was too expensive. At the full run-and-jump allowance it was 7% to 18% of a movement decision - for one feature, on the phase that is already 96.8% of the time and already times out on 9.2% of 8v8 decisions. Truncating the search to a three-point horizon brought that to 3%-8%, and truncating to two points to 1%-5%. That was the shape of the trade before the cache existed, and it is why the cache exists.
A horizon is a truncation of the answer, and it is not needed. The thing the
first measurement missed is what options_from depends on: the board, the
stand, and the machine's MovementProfile. Not occupancy - where the other
machines stand this instant is not where they will stand when the move is taken.
Not the round, not the phase, not a clock. So the memo is valid for the whole
match, unlike LosCache, which is per-round precisely because a line of sight
does depend on where the machines are.
pathfind::OptionsCache is that memo. It owns the movement board, so a cached
answer and the board it was computed on cannot disagree; it is keyed on
(Stand, MovementProfile), so a Mek that has lost a leg cannot be served its
undamaged reach; and it is shared across the side, which cannot change an answer
because the function is pure.
| board | candidates | existing paths | cold | warm | amortised | hit rate | stands held |
|---|---|---|---|---|---|---|---|
| open | 891 | 0.5ms | 187ms (9.7%) | 0.07ms | 26.7ms (1.4%) | 86.2% | 8,819 |
| forest | 558 | 0.2ms | 98ms (5.1%) | 0.04ms | 16.7ms (0.9%) | 82.0% | 7,422 |
| mountain | 499 | 0.2ms | 57ms (3.0%) | 0.03ms | 16.4ms (0.8%) | 82.4% | 7,624 |
| water | 708 | 0.3ms | 107ms (5.6%) | 0.05ms | 18.8ms (1.0%) | 84.6% | 8,316 |
cold is the first decision on an empty cache, which is the uncached cost -
every candidate misses. warm is the same decision again. amortised is one
eight-unit, nine-round match through one cache, per unit-decision, with all
eight machines carrying different movement profiles: a lance that shared
numbers would share entries and flatter the result.
So the full allowance costs about one per cent of a movement decision, and
OPTIONS_HORIZON is gone. A cold decision still pays 3%-10%, which is the first
unit to move in round one and nobody else.
Memory is bounded and the bound is not close. A 16x17 board is 1,632 stands
per profile, and a match settled at 7,400-8,800 entries across eight profiles.
OPTIONS_CACHE_MAX is 65,536, a few megabytes, and hitting it clears the table
rather than evicting from it - the values are pure, so a clear costs
recomputation and nothing else, while a policy choosing which entry to drop
would be a policy that could depend on the order they arrived in.
The hit rate is reported rather than assumed. unit N options cache: ... asked, ...% hit sits beside the line-of-sight and volley cache lines. It should
climb as a match goes on; one that never climbs means the key is slicing on
something it should not be.
What it reads at the full allowance, which is the half a cost table cannot say:
| board | min | median | max | dry mean | wet mean |
|---|---|---|---|---|---|
| open | 0.23 | 0.69 | 0.97 | 0.67 | - |
| forest | 0.23 | 0.53 | 0.69 | 0.54 | - |
| mountain | 0.10 | 0.37 | 0.65 | 0.39 | 0.18 |
| water | 0.04 | 0.54 | 0.77 | 0.54 | 0.13 |
A candidate whose own hex holds water reads a third to a quarter of what a dry
candidate on the same board reads. The medians sit well below one because the
denominator is an infinite flat field and these boards are 16x17: a machine with
eight run and five jump genuinely cannot use all of that on a board this size.
That is the un-clipped denominator doing what it was asked to do, and it does
mean the feature carries some of what edge_distance carries whenever the
machine is fast relative to the board.
Every table here is printed by crates/sds-core/tests/optionality_cost.rs,
which is #[ignore]d and release-only for the same reasons tests/performance.rs
is in helm: an unoptimised Dijkstra has a different shape, and a wall clock in
a checked test fails on a busy machine rather than telling anyone anything. The
memo's own correctness is not left to an instrument -
the_memo_agrees_with_the_search_it_stands_in_for checks the cached and
uncached answers against each other over every third stand of every board at
three profiles, and who_asked_first_does_not_change_the_answer checks the
order invariant directly.
Three cheaper things were considered and rejected.
stands::retreat_breadth - how many of the six steps out are legal - already
exists and costs 0.03ms for a whole menu, but it cannot see a lake at all:
wading out is legal, it is merely ruinous. Precomputing the whole board up front
does work nobody asked for, and the memo gets the same benefit lazily. Reading
candidate reachability out of one two-turn search from the current position is
wrong: distance measured through the start hex is an upper bound on distance
from a candidate, so it under-counts - and it under-counts worst exactly where
terrain forces a detour, which is the case the feature exists for.
Collinearity, expected and not solved here. This will correlate with
submerged and with terrain generally - a lake reads low on both - and the heat
family has already shown what a pair of collinear columns does to a fitted
weight. The hand-authored set prices it at +2.0 under submerged's -4.0 on the
argument that the rules cost of wading is the larger half; a fit is entitled to
split it differently, and neither weight will be separately interpretable when
it does.
height_advantage, built and removed the same day #
Our level against the mean enemy level, on the argument that elevation_gain
compares a hex to the ones the unit could have reached and height against the
enemy is a different question. It measured +0.99 against elevation_gain in
difference space and was removed.
The reason is structural and worth keeping. The enemy does not move during our
unit's move, so their mean level is a constant within a decision. height_advantage
is then our level - a constant and elevation_gain is our level - a different constant, and the fit sees only differences between candidates: both constants
subtract out and the two columns are the same column.
The general rule this is the second instance of. A feature that differs from
an existing one only by something constant within a decision is that feature. It
caught exposure_unmitigated (0.97 against exposure, because the multiplier
sat near one for most candidates) and it caught this. Check a new positional
feature against the ones it is near before running a corpus: the test is
whether the two differ by anything that varies candidate to candidate.
Height could still earn a column, but only as something that does not reduce to a level - an interaction with range, or a non-linear term.
heat_mp_penalty was a percent of the wrong thing #
It divided the movement the heat scale takes away by MAX_MP, the worst penalty
the scale can inflict. That is a real ceiling and the wrong denominator: three
points of movement is most of a 4/6 machine and a quarter of an 8/12 one, and
against a fixed five those two read identically. The reading that came out of
that is "a slow machine should be happy to run hot".
It now divides by the machine's own running allowance. A unit with none recorded
- immobile, or an observation from before the field existed - reads zero rather than one: the heat is not costing it something it no longer had.
heat_to_hit_penalty divides by MAX_TO_HIT and has the same shape of problem
in a milder form, since a to-hit penalty is worth roughly the same to everyone.
Left alone for now.
Backlog: immobility is not modelled anywhere #
target_movement_modifier floors at zero - if hexes_moved == 0 && !jumped
returns 0, and everything after it is a non-negative band plus the jump bonus.
It cannot express a negative, so it cannot express an immobile target, which the
rules make easier to hit rather than merely no harder.
Nothing else covers it either. immobile and shutdown appear nowhere in the
positional features or in the line-of-sight code, and Unit carries prone and
crippled on the wire but neither of the other two - so the data to fix it is
not there yet.
The gap runs in both directions, because target_movement_modifier has three
call sites: tmm_gained, exposure_covered, and heatmap.rs where it prices
enemy threat. A shut-down enemy reads as no easier to hit than a stationary one,
and our own shutdown reads as costing us nothing.
That last one is why this matters more than it looks. heat_shutdown_risk says
"you might shut down" and nothing anywhere says what shutting down costs - and
what it costs is that everything shooting at you gets a large bonus. The feature
cannot see the consequence it exists to warn about.
Not being fixed now, on jmm's call. When it is: it needs shutdown and
immobile on the wire, then a signed movement modifier, and three call sites
re-checked. That is a protocol change, not a rename.
The firing side is already correct and needs nothing: real shots go through
MegaMek's WeaponAttackAction.toHit, which handles immobility itself. It is our
own estimates that are blind to it.
Making the M-leak impossible rather than remembered #
Ten features read enemies where they stand now and three read friends the same way, while the exchange arithmetic beside them reasons over the reachable set M. Every one of those was written by somebody who knew about M. The problem is not that the rule is unknown, it is that nothing enforces it, so the next feature will leak too and nothing will fail.
There is already a mechanism for exactly this shape of problem in this crate. A
local feature may not have a weight fitted against it, and that is not a
convention - Weights::with refuses it, a compile_fail doctest proves the
refusal, and a runtime check covers the vector. The same treatment applies here.
Unit.done already says whether a machine has moved this round, so the
distinction costs no protocol change:
- A machine that has already moved has a committed position. It is real, and a feature may use it.
- A machine that has not has only an envelope - the hexes it could reach. Its current hex is a fact about the past.
Under alternating movement both kinds exist at once, which is why a blanket "use M for everything" rule would be wrong: by the time our third unit moves, several machines on both sides have committed and their positions are the best information available.
The change is to stop handing features a Unit at all. Posture today
carries friends: &[&Unit] and sightings whose at: Coord is a current hex,
so a feature that wants a stale position only has to ask. Replace both with a
type that cannot answer that question by default:
- envelope-derived accessors - centroid, nearest reachable hex, highest level reachable, threat summed over the envelope
- one accessor that returns a concrete hex,
committed() -> Option<Coord>,Someonly for a machine whose turn is done
Then a feature reading a real position has to name the assumption at the call
site, and the default path cannot leak. self is exempt throughout: our own
current hex is what we are moving from, and position_value_elevation_change
needs it.
The cost is that friends need an envelope too, which today only enemies get. That is real work, and it is the same work the elevation and centroid fixes need, so it should be done once rather than three times.
The retired table, and why a removal must not orphan a file #
Dropping a column broke weights/hand-authored.json the moment it landed:
Weights::from_document refuses an UnknownFeature, and that file names
exposure. Every stored vector, every recorded baseline and every saved corpus
manifest outlives the basis it was written against, so a hard refusal orphans
all of them each time a column goes.
Weights::RETIRED is the fix. A name on it is accepted from a stored file and
ignored, with a comment saying why the feature went. An unknown name is still a
hard error, because that is a typo or a file from another tree.
A rename is not a removal and must not go on that list. A retired name silently contributes nothing; a renamed one still means something, and belongs in an alias table mapping old to new so an old vector keeps its column instead of quietly losing it. Nothing is renamed yet - when the redline lands, the alias table lands with it.
Retired this round: the three exposure features (threat priced on range with no
to-hit), height_advantage (the same formula as elevation_gain), crowding,
weapon_concentration (uniformity, not hole-punching), target_threat (a worse
copy of a number the survey already computes), has_explodable_ammo (redundant
once the explosion chance multiplies by it), and the three sticky memory flags
(constant within a decision, so a difference-framed fit can never move them).
And one bug found while removing them. rear_arc_exposure had a name, a
registry entry, a hand-authored weight of -2.0 and two tests, and was never
measured into a feature vector - the key is absent from every decision log in
every corpus. It is now registered beside rear_arc_gain, which is where it
always belonged.
Seven features the bot could never use #
Weights::from_document checks a name against the catalogue, and the catalogue
is hand-maintained separately from what features() actually measures. Nothing
checked that the two agreed for positional features - the existing guard covered
volleys only, and its own comment says it was written because p_breach and
value_destroyed had shipped uncatalogued.
So seven shipped the same way: cover_distance, enemy_nearest,
enemy_centroid, overlook, edge_distance, friend_support and
range_spread. Every one was measured, logged into every decision, and fitted a
weight - and no weights file naming any of them could be loaded at all.
The failure is not quiet. A vector that names an uncatalogued feature is refused
outright, the bot answers no decision, and every decision takes the harness
default. A forty-match evaluation against Princess ran that way: sds answered only 0% of its decisions, zero shots fired against Princess's 832. The
answered/decisions invariant is what caught it, which is what that invariant
is for.
submerged had the reverse bug: in the catalogue, never measured, so its
hand-authored weight of -4.0 has never done anything. Same class as
rear_arc_exposure, found the same day.
Both directions are now a test. It builds a Posture, measures it, and asserts
the measured set and the positional half of the catalogue are the same set -
each way round, with a message saying what breaks. A feature missing from the
catalogue cannot be weighted; a feature in the catalogue but unmeasured has a
weight that does nothing.
The three retired latch memories are no longer measured either. They were still going into every decision log after being removed from the catalogue, which is the same disagreement in miniature. The latch bookkeeping stays - it is what a non-linear model would read and it costs nothing - but the feature vector is empty and says why.
Three vectors that move differently and win the same #
fitted-r6, fitted-r7 and fitted-r8 were scored against the same 1051
decisions from honest-movement-r8, taking each vector's own argmax over the
menu the bot was actually offered:
| phase | mean candidates | r6=r8 | r6=r7 | r7=r8 |
|---|---|---|---|---|
| FIRING | 546.0 | 81.8% | 88.6% | 88.4% |
| MOVEMENT | 293.8 | 37.4% | 70.1% | 34.3% |
fitted-r8 picks a different hex from fitted-r6 on 62.6% of movement
decisions, and from fitted-r7 on 65.7%. These are not neighbouring vectors:
cos(r6, r8) is +0.611.
And the match results do not move. r6 against r8, paired on identical scenarios and seeds, is 13-12 discordant at p=1.000. r6 against r7 is 29-17 at p=0.105. Three vectors that disagree about where to stand on roughly two movement decisions in three finish in a statistical dead heat against Princess.
What that is evidence for. Movement scoring is low-leverage as the problem is currently posed. With 294 candidates in an average menu, most of them are near-equivalent in outcome, and which of them a weight vector prefers is mostly not the thing that decides the fight. Every weight fitted this session has been tuning that.
What it is not. It does not show movement is unimportant - only that the differences spanned by these three vectors do not matter. A movement policy outside their span could matter a great deal, and the candidate generator, not the scorer, is what decides what that span contains.
Where the leverage might be instead. Firing, where the three vectors already agree 82-89% of the time and so have barely been varied. The candidate menu itself, which decides what can be chosen at all. Or the force reconciliation, which is already overriding the unit's pick on 15.9% of movement decisions. Continuing to fit movement weights against a new label is the one direction this measurement argues against.
Ablating the positional basis #
weights/r6-no-positional.json is fitted-r6 with all 17 positional weights
set to zero - a third of the vector's magnitude, 0.0866 against 0.1794 for what
remains. It asks whether ranking hexes by cover, elevation, arcs and distances
adds anything over ranking them by the damage the stand expects to do.
It is a clean intervention, checked before the result was read:
| phase | decisions | picks the same hex as r6 |
|---|---|---|
| FIRING | 332 | 100.0% |
| MOVEMENT | 415 | 32.3% |
Firing is untouched, because a firing candidate carries no positional feature at
all. Movement changes on 67.7% of decisions. A movement candidate carries 22
features - the 17 positional ones plus expected_damage, p_kill,
p_mission_kill, value_destroyed and the locally-normalised damage_lead -
so the ablated bot is not moving at random. It is moving by expected damage
alone.
Worth stating why the check came first: an ablation that changed nothing about the bot's choices would produce the same null and mean nothing at all, and there is no way to tell those two nulls apart from the win rate.
The positional basis does nothing measurable #
fitted-r6 with all 17 positional weights set to zero, over the same 144-
scenario suite with the same seeds:
| vector | record | rate | 95% CI |
|---|---|---|---|
fitted-r6 |
66-45 of 111 | 59.5% | 50.2%-68.1% |
r6-no-positional |
69-46 of 115 | 60.0% | 50.9%-68.5% |
Paired over the 111 pairs both runs decided: 40 both won, 21 both lost, 24 to r6, 26 to the ablation. McNemar p = 0.888. The ablated vector is fractionally ahead, which is noise, and the two are indistinguishable.
A third of the weight vector by magnitude - cover, elevation, arcs, line of sight, distances to friends, enemies and edges, jump-modified target movement - contributes nothing to whether the match is won. The ablation changes 67.7% of movement picks and leaves firing untouched, so this is not an intervention that failed to intervene.
It replicates the earlier result rather than merely agreeing with it. Three fitted vectors that disagree with each other on about two thirds of movement decisions also finish level; this is a fourth policy, arrived at by deleting rather than by fitting, landing in the same place.
What is left when position is removed. A movement candidate keeps
expected_damage, p_kill, p_mission_kill and value_destroyed, so the
ablated bot still picks the hex that expects to do the most damage. The claim is
not that where a machine stands does not matter - it is that the positional
half of the scoring adds nothing over choosing a stand by the damage it
expects, at this candidate count, on these maps.
What this argues for next. Not more positional features, and not another label fitted against the same basis. Either the leverage is in firing - where every vector tried so far agrees 82-100% of the time and so has barely been varied - or in the candidate generator, which decides what the scorer is allowed to choose between, or in the force reconciliation that is already overriding the unit's pick on 15.9% of movement decisions.
What would overturn it. These are eleven fixed maps and mirrored forces at one scale. The tiered suite now running varies terrain, era, machine and crew far more widely, and position may matter where the fights are less uniform. Re-running the ablation there is the check, and it is cheap.
Offence is priced exactly and defence is not priced at all #
The bot stands still on 70.6% of its movement decisions (real weights, on
honest-movement-r8), and its machines are stationary on 66.7% of
round-to-round steps in a 418-match benchmark against Princess's 16.8%.
That is not a bug in the movement code. It is the objective answering honestly.
A movement candidate's score is dominated by expected damage, which is computed
exactly: GATOR to-hit per weapon, range brackets, line of sight, and now the
woods the target stands in. Gait::Still costs 0 to our own to-hit, walking
costs 1, running 2, jumping 3. So the highest-scoring stand is almost always the
one we are already on.
What should push back is the cost of being shot at from there. Nothing does.
exposure, exposure_covered, exposure_unmitigated and exposure_when_hurt
are all in Weights::RETIRED - correctly, the note says they "priced threat on
range alone with no to-hit, so it measured what could reach us rather than what
we expected to take". Nothing replaced them. What is left of defence is
cover_quality, level_tmm, los_in and rear_arc_exposure: four geometric
proxies which the positional ablation measured at no effect on the win rate at
all (69-46 against 66-45, paired p=0.888).
So the model prices one side of an exchange to the individual weapon and the other side not at all, and then picks the stand that maximises the side it can see. Standing still is the correct answer to the question being asked.
Two things this explains that were previously separate. Why the positional basis measures as worthless - it is the entire defensive half of a fight, expressed as geometry rather than as damage, and geometry is a weak proxy. And why more labels did not help: no label can teach a weight vector to avoid fire when no column in it varies with how much fire there is.
What it does not explain is the cluttered-map collapse specifically. Ties and stand-still rates are near-flat across terrain (tied top value 29.5% open against 32.8% cluttered; stand-still 59.6% against 55.9%). This is a general defect that costs everywhere. The cluttered-map gap is still the illegal-move rate (1.42% to 5.32%), the collapsed firing menu (256 to 82 candidates) and, until this branch, the unpriced woods the target was standing in.
The defence a hex has, as a level and a curve #
The section above says defence is not priced. This is the column set that prices it, and it is three things rather than one.
Bin occupancy, measured rather than guessed. Every stand a run or a jump
reaches, from every third hex of the four boards in
crates/sds-core/tests/corpus/pathfind.jsonl, for six movement profiles from
a 3/5/3 assault to an 8/12/8 light - 525,463 candidates:
| bin | all four boards | the forest board | the other three |
|---|---|---|---|
defense_1plus |
91.0% | 97.1% | 89.1-89.5% |
defense_2plus |
70.7% | 87.1% | 65.6-66.8% |
defense_3plus |
46.0% | 69.9% | 38.0-40.6% |
defense_4plus |
22.3% | 47.5% | 14.0-16.5% |
defense_5plus |
6.1% | 23.5% | 1.2-1.7% |
defense_6plus |
1.5% | 6.3% | 0.1-0.3% |
The top bin is thin and is not empty, and how thin depends entirely on the map:
six times more often on the wooded board than the overall figure, and a
twentieth as often on the bare ones. Nothing above 6 occurred at all, because
none of these boards carries ultra-heavy woods and 7 needs either that or a
ten-hex jump - which is what the + on the top bin is for.
One palette, inlined into every figure #
A figure is referenced as  and renders through <img>, which is an
isolated document: it cannot load an external stylesheet, and custom properties
do not cross the boundary. So the palette is duplicated into all 78 figures
whatever we do, and 324 KB of the 1.5 MB under docs/ is that duplication.
What can be fixed is where the copies come from. docs/figures.css is now the
only place the tokens are written; vignettes/render.rs takes it with
include_str! and sds/labeldoc.py reads it, and each generator keeps its own
rules, because only the tokens have to agree. The file's header comment is
dropped before inlining - it is a page of prose for whoever edits it, and 78
copies of it would be most of what the figures weigh.
They were two copies before and had already drifted: the label figures were
missing the [data-theme="dark"] block, so an explicit light/dark choice moved
the feature figures on a page and left the label ones alone. Both answered the
OS preference, which is why it looked right.
Regenerating after the extraction produced byte-identical feature figures, which is the check that it was a move rather than a rewrite.
Found by the spread table, and by the fit that followed #
Mobility may be overvalued #
deploy_ground is the largest weight in fitted-r11 at +0.1952, more than
twice the next one, and deploy_outlook (+0.0426) and percent_options
(+0.0036) push the same way: keep our options open.
The 330-match asymmetric run says the opposite about outcomes. Among the matches the force decided, the faster side won 29.6% - being quicker was worth less than nothing at matched battle value, against being heavier (72.3%) or better-armed (71.1%).
That is not proof the weight is wrong. Optionality at deployment and speed in a fight are different quantities, and the fit had no matchup column to attribute the effect to, so a mobility feature is the nearest thing it had. But a term that large, pointing the way the outcomes do not, is worth one ablation.
edge_distance does not know which edge is ours #
positional::EdgeDistance measures the distance to whichever board edge is
nearest, and nothing in the basis names the one a force entered from. Withdraw
prices it at -8.0, the largest weight in that vector, so the tactic is satisfied
by any exit - and docs/TACTICS.md shows it: on the shared board the Heavy
Missile Boat starts forward at (7,7) and leaves at (7,1), one hex from the north
edge, straight through the enemy lance. A retreat across their deployment zone
is not a retreat.
expected_criticals, the currency the basis is missing #
ev.rs already argues for this and stops short of computing it:
Ten points in one location and ten points spread over six are the same mean and completely different objects: the first opens a location, the second fishes for criticals through one already open. Princess never distinguishes them.
That distinction is expected criticals, described and then not measured. The
parts are present: Location::to_bare_structure gives the damage that opens a
location, firing::PBreach already prices reaching it, ShotKey::location_outcomes
carries the per-location marginal exactly and cheaply, and expected packet count
already rides along with the damage.
Three things want it, and none of them can be answered without it.
-
The leg attack has no value. Its 4 damage is nearly noise; the payoff is a through-armour critical on the leg, and the criticals force piloting rolls that slow and fell the target.
plan/battle-armor.mdholds it at nought deliberately rather than giving it a plausible constant, and that hold ends here. -
Matches end on criticals, not on damage totals.
firing::PKillis damage-based; the ammunition explosion that ends a match in round three is not in it. -
Melee is crit-fishing. A kick or a punch into an open location is the same object as the second half of the quotation above, which is what
Advanceis waiting on once it can price contact at all.
What landed #
22/36 = 0.611 criticals per internal hit, where the internal damage is
E[max(0, damage - to_bare)] per location - exact off that location's own
distribution - divided by what one packet carries, because the roll is per hit
and not per point.
It rides on firing::Projected, so a movement candidate carries it as well as a
firing one, and stands::Outcome and Prospect carry the count through the
collapse over M the way expected_breaches already did.
Three exclusions are named rather than guessed at: through-armour criticals need a confirmation roll, armour-piercing and tandem-charge rounds change the rule rather than the numbers, and a mixed volley has no single packet size so the packet-weighted mean stands in for one.
What MegaMek says about cluster grouping #
./sds.sh cluster-dump mounts every weapon in MegaMek's equipment tables with
every round it can take - 8,230 pairs - asks Weapon.getCorrectHandler for the
handler that would resolve the shot, and reads usesClusterTable() and
calculateNumCluster() off it. docs/CLUSTERS.txt is the summary, one row per
distinct answer; the full dump is regenerable and not committed.
A row is a weapon and a round, not a weapon. An LB-X firing a slug resolves
through ACWeaponHandler and puts ten points in one place; the same gun firing
cluster resolves through LBXHandler and rolls a location per pellet. The first
run of this probe asked the weapon alone, got whichever round was first in the
table, and reported every LB-X as a solid slug.
The five families jmm named come out as he said. Four things nobody named came out too, and each is a reason not to write the table by hand:
-
ACFlakHandlergroups by five. An ordinary autocannon firing Flak ammunition behaves like an LRM. That is the round, not the gun, and no rule about weapon families would have found it. -
LRMFollowTheLeaderHandlerreportsInteger.MAX_VALUE- the whole salvo in one location. A sixth answer, and the one a five-family enum would get most wrong. -
Infantry weapons group by two, across 644 pairs.
-
ThunderBoltWeaponHandlerreports a group of five with no cluster table, which is inert: one packet cannot be grouped.
A row is a weapon, a round, and a range #
Two traps, the same shape, both paid for once.
The first: asking the weapon what its grouping is gets whichever round was first in the ammunition table, so the probe reported every LB-X as a solid slug. An LB-X firing a slug and the same gun firing cluster are two different handlers.
The second: this probe reported a Thunderbolt's calcDamagePerHit as exactly
half the round's damage at every size and called it possibly a MegaMek defect.
It is not. Thunderbolts do half damage inside minimum range, and the probe
asked at four hexes against a minimum of five - one range, inside it, and
generalised. Damage varying with range is a family, not an oddity: a
Variable-Speed Pulse Laser and a Snub-Nose PPC do it too, and
Observation.averageDamage already has a paragraph on the DAMAGE_VARIABLE
sentinel for exactly that reason.
Verified rather than assumed. getDamage(range) across the brackets:
| weapon | short | medium | long |
|---|---|---|---|
| Snub-Nose PPC | 10 | 8 | 5 |
| Medium VSP Laser | 9 | 7 | 5 |
| Large VSP Laser | 11 | 9 | 7 |
and the three wire fields carry those through, each computed at its own bracket's range. That half of the bridge works.
What the correction moved #
Not a uniform division. Grouping makes a salvo less scattered and its chunks bigger, so it cuts a stripped target's numbers and raises a whole one's.
| measure | before | after |
|---|---|---|
expected_criticals, six LRM-20s into a stripped Mek |
37.9 | 9.4 |
expected_criticals, six LRM-20s into whole armour |
0.77 | 0.96 |
expected_packets, one LRM-10 |
3.68 | 1.07 |
p_breach ratio, Gauss against LRM-15 into a centre torso |
>20x | 12.5x |
p_kill, an LRM-20 salvo through a head |
0 | 2.2e-5 |
The last two are the ones to read twice. A missile boat's p_breach was
flattered by the old model and its p_kill through a head was a flat
impossibility; five-point chunks reach thresholds that one-point missiles never
could, so the correction is not "missiles are worth less" but "missiles are
worth differently".
What the Thunderbolt fix changed #
The guard is three conditions, and each one was paid for by measuring what a looser one did. Damage that is stated through the cluster sentinel, a rack of one, and a pair MegaMek says does not use the cluster table. Then the handler that would resolve the shot answers, at that range.
Measured over the 8,230 weapon-and-round pairs the cluster dump walks. The first guard tried was "not on the cluster table" alone and moved 757 pairs; the narrowed one moves 16:
| weapon | before | after |
|---|---|---|
| Thunderbolt 5 (3 mounts) | 0 / 0 / 0 | 5 / 5 / 5 |
| Thunderbolt 10 (3 mounts) | 10 / 10 / 0 | 10 / 10 / 10 |
| Thunderbolt 15 (3 mounts) | 0 / 0 / 0 | 15 / 15 / 15 |
| Thunderbolt 20 (3 mounts) | 0 / 0 / 0 | 20 / 20 / 20 |
| Mech Mortar-1, AP round (4 pairs) | 2 | 1 |
Before this, 828 pairs reached the bot reading zero at all three brackets; a
weapon reading zero everywhere is one the bot declines to fire, which is what a
Thunderbolt boat was doing. ShotKey::single refuses a shot of no damage, so
the round never became a candidate at all.
The range half is right too, and it is jmm's rule rather than an inference: a
Thunderbolt 20 now reads 10 inside its minimum range and 20 outside it,
measured hex by hex out to long. Only the handler knows that, which is why the
handler is asked instead of the round's damagePerShot.
What the looser guards would have taken with them, and why each is excluded:
infantry weapons state damage through DAMAGE_VARIABLE and their damage is a
function of the platoon; artillery states DAMAGE_ARTILLERY; and a Mech Mortar
4 has a rack of four, so "not on the standard cluster table" does not mean one
packet - asking the handler gives one shell where getBattleForceDamage gives
the salvo.
The cluster table was the last transcription, and it is right #
hitloc reads MegaMek's hit-location tables out of the code that resolves the
shots, and its module note says why a transcription is not good enough.
ev::CLUSTER_HITS was the one table that had not had that done to it: typed
from the printed page, and checked at nine of its 341 cells. Every missile
ever fired goes through it.
./sds.sh cluster-table pins the 2d6 and asks Compute.missilesHit for every
rack to 40 and every total - 440 cells, oversized-rack decomposition included.
All 440 agree. The corpus is 40 lines and is committed, so the test always
runs rather than skipping when a dump is missing.
missilesHit and not calculateClusterHitTableAmount, and the difference
matters: the second is the raw row lookup and answers zero for racks 31 to 39,
which have no row. Comparing against it reported 99 disagreements that were not
disagreements at all - the first accessor is the one that resolves a salvo and
splits an oversized one, and it is what cluster_hits claims to mirror. Asking
the wrong one of two similarly named accessors is the same trap as asking the
weapon instead of the pair.
The wire's three damage fields agree with each other #
A check the cluster dump can now make of itself: for every modelled
weapon-and-round pair, does rackSize packets of damagePerPacket, resolved on
the cluster table, reconstruct the damage the same record carries?
7,388 pairs with positive damage, zero disagreements. Before the LB-X fix this could not have been asked - a cluster round carried the slug's rack and the slug's packet - so it is a check the correction made possible as well as one it passes.
Clan missile weapons did no damage past their short bracket #
The largest thing the dump has turned up, and it is not mine: it predates every change on this branch, and the same measurement that found it says so.
getBattleForceDamage answers zero for a Clan missile launcher past its
short bracket, while answering for the Inner Sphere twin of the same weapon at
the same ranges:
| weapon | ranges | BattleForce damage at short / medium / long |
|---|---|---|
ISLRM20 |
7/14/21 | 1.20 / 1.20 / 1.20 |
CLLRM20 |
7/14/21 | 1.20 / 1.20 / 0.00 |
ISSRM6 |
3/6/9 | 0.80 / 0.80 / 0.80 |
CLSRM6 |
3/6/9 | 0.80 / 0.00 / 0.00 |
CLStreakLRM20 |
7/14/21 | 2.00 / 2.00 / 0.00 |
CLHAG20 |
8/16/24 | 1.20 / 1.20 / 0.00 |
brackets::projected refuses a bracket that lands nothing - "a weapon whose bin
is empty or whose bracket lands nothing. Not a shot" - so the bot would not
fire a Clan SRM past three hexes, and would not fire a Clan LRM at long range
at all. Clan units are half the game.
502 weapon-and-round pairs carried a zero bracket with a positive one nearer. After the fix, none do, and no damage figure changed - only zeros were filled.
The rule is narrow and follows from where it sits. Damage that genuinely falls
with range never reaches getBattleForceDamage, because getDamage(range)
answers it first - that is what a Snub-Nose PPC and a VSP laser take. So
anything still asking is range-flat, and the nearest bracket BattleForce does
answer for is that flat figure.
The intra-bracket gap is smaller than it looked #
avgDamageShort/Medium/Long are each computed at one representative range, so a
movement candidate prices every hex in a bracket alike. Measured across every
weapon and round, the gap between what the bracket figure claims and what the
shot does at that hex:
-
509 pairs across 69 weapons show any gap at all, and 502 of those were the Clan zero above - an absence rather than a variation, now fixed.
-
What is left is the genuine intra-bracket variation, and it is the minimum-range weapons: a Thunderbolt 20 prices three hexes at 20 where the shot does 10, and a VSP laser and a Snub-Nose PPC vary inside a bracket by design.
Three range-varying weapons, and only one needed a mechanism #
The question was whether a Thunderbolt, a VSP laser and a Snub-Nose PPC want a common mechanism or three special cases. Measured, the answer is neither: two of them were already exact.
Sweeping every weapon and round for the gap between what a bracket figure claims at a hex and what the shot does there, after the Clan zero was fixed, leaves 16 pairs across 14 weapons - down from 509:
- Twelve are Thunderbolts. They halve inside their minimum range, which is jmm's rule and is taken as given. The halving happens within the short bracket, so no number of brackets reaches it.
- Four are artillery cannons, a Sniper and a Thumper, which do nothing at
all inside their minimum.
Excluded::Artilleryalready refuses them. - The VSP laser and the Snub-Nose PPC do not appear. Their damage changes
at a bracket boundary - 9/7/5 and 10/8/5 - which is exactly what three
bracket figures express, and
getDamage(range)already answers it. Zero gap at any hex.
So the shape that needed carrying is not "damage varies with range" but "damage
differs inside the minimum range", and it is one figure rather than a
mechanism: avgDamageMinimum, measured at hex one and flat across the band. A
Thunderbolt 20 reads 10 at every hex from one to five and 20 from six.
The natural 2 was 100% of the answer against intact armour #
Of the open items expected_criticals shipped with, this was worth the most and
the measurement is why. It was excluded, not overcounted: the doc said a TAC
"takes a confirmation roll to stand, so it is worth less than the raw 1/36" and
left it out rather than guessing. Left out is an undercount, and here is its
size, measured over four-gun volleys against the fixture 55-tonner:
| volley | target | internal term | TAC term | TAC's share |
|---|---|---|---|---|
| 4x LB-X cluster | whole armour | 0.000 | 0.392 | 100% |
| 4x LB-X cluster | half armour | 0.101 | 0.392 | 79% |
| 4x LRM-20 | whole armour | 0.260 | 0.194 | 43% |
| 4x Gauss | whole armour | 0.480 | 0.062 | 12% |
| any of them | stripped | dominant | 0.06-0.39 | 3-4% |
The feature read exactly zero for a sandblast volley against a machine nothing had opened yet - which is the crit-fishing-through-armour case it exists to price, and the same "reads zero so it never matters" failure as the Clan missiles and the Thunderbolt, in our own arithmetic this time.
The term scales with hit-location rolls rather than with damage, so it is the
half of the feature that separates a rack from a slug while the plates are still
on. TAC_ON_LOCATION_ROLL is measured, not transcribed:
Mek.rollHitLocation sets HitData.EFFECT_CRITICAL on a natural 2 and on
nothing else, on all four sides.
It inverted a test, and the test was the thing that was wrong.
ten_points_in_one_place_beats_ten_spread_thin asserted that concentration wins
outright. Against armour the slug can open it does. Against whole armour the
salvo wins, because neither gets inside and the salvo rolls six locations to the
slug's one. The test now says both, because both are true and the pair is the
point.
Every weapon MegaMek has, through the volley path #
The claim this project has been making without evidence is that an unknown
weapon works completely, because a weapon is its properties and the properties
are on the wire. cargo run -j 2 -p sds-core --example weapon_sweep checks it:
every one of the 8,229 weapon-and-round pairs in the cluster dump, rebuilt
into a wire::Weapon through the real deserialiser and put through the three
gates a candidate passes in a match - Excluded::for_weapon,
brackets::projected at every hex out to long range, and ShotKey::from_wire.
| pairs | share | verdict | |
|---|---|---|---|
| priced at every hex it reaches | 6,806 | 82.7% | complete |
| declared unmodelled, 13 named reasons | 916 | 11.1% | pass - the absence is representable |
| no ground range on the wire | 463 | 5.6% | torpedoes |
| no shot at any range | 44 | 0.5% | silent zeros |
| priced at some hexes and not others | 0 | 0.0% | - |
93.8% either price completely or say why they do not, after the infantry declaration below. Before it the figure was 87.0% and the silent zeros were 402; they are 44 now, and every one of them is an aerospace bay, a Narc beacon, a TAG, a Nail/Rivet Gun or a Centurion Weapon System - devices that genuinely put no damage on a target, on mounts a ground unit does not carry.
The empty row is the one worth reading twice: no weapon is priced at some ranges and not others. That bucket held 502 pairs before the Clan fix - it is what the fix bought, stated as a property of the whole library rather than of the weapons that led to it.
The infantry declaration. Excluded::Infantry matched names containing
"infantry", "battle armor" or "ba ", which caught four of 562; an
Auto-Pistol (Nissan SG-12) contains none of those, passed the exclusion, and
then read zero damage because BattleForce has no figure for a weapon whose
damage is a function of the platoon. The same defect as bomb matching Bombast
Laser, at 140 times the scale.
MegaMek resolves every one of them through InfantryWeaponHandler, so the
weapon record now carries handler - the simple class name of the handler
getCorrectHandler returns - and Excluded::from_handler reads it before the
name list. 648 pairs moved from a silent zero to a declared limit.
Nothing about the bot's behaviour changes and that is the point: neither a
silent zero nor a declared limit fires, and only one of them can be enumerated.
Sixteen pairs that had been priced are now excluded - infantry weapons that
happened to carry a positive damage figure - which is the same judgement
Excluded::Infantry was already making for the four it caught. Nothing is
exposed today either way, because infantry weapons only mount on infantry and
battle armour and this bot does not play them; claude/battle-armor is making
those unit types playable, so it stops being hypothetical soon.
The map holds one entry deliberately. It is not a second taxonomy - it is the
list of handlers where MegaMek is decisive and the name list was demonstrably
wrong at scale. Anti-Mek attacks are not in it on purpose: "Swarm Mek" and
"Leg Attack" have the same defect and claude/battle-armor is fixing them with
weapon.anti_mek, so adding their handlers here would collide with that branch
rather than help it.
Water ranges are a design question, not a field #
The 463 pairs with no ground range are torpedoes, and the fix is not the small one it looked like. Measured across all 1,071 weapons:
| weapons | |
|---|---|
| ground ranges only | 768 |
| water ranges only (torpedoes) | 68 |
| both, and they differ | 85 |
| both, identical | 1 |
| neither (bays, infantry) | 149 |
So it is not a torpedo edge case. 154 weapons have water ranges and 85 of them carry a second set the wire never reads: a Medium Laser is 3/6/9 on land and 2/4/6 in water, and an LRT is 0/0/0 on land and 7/14/21 in it. The bot prices a submerged Mek's whole loadout at its dry ranges and cannot fire a torpedo at all - including from the water, where it is the weapon that works and everything else does not.
Three fields would carry it. What they cannot carry is which set applies, because that is a property of the shot rather than of the weapon - it depends on the medium the attack crosses, and the same weapon has both. That makes it a design question, and it is scoped rather than built:
The last 44 are honest: aerospace bays that never ride a ground unit, and Narc beacons, TAG, a Nail/Rivet Gun and a Centurion Weapon System that genuinely put no damage on a target.
On value-weighting expected_criticals, and the +0.77 collinearity #
The ablation on the 45-match corpus put expected_criticals at +0.77
collinear with value_destroyed, r² +0.0042 (an upper bound, since least
squares cannot do worse with an extra column) and agreement −0.0044, which
is the informative half and went the wrong way. The suggested remedy is to
weight criticals by Location::value() so the column becomes independent rather
than collinear.
I think value-weighting would make the collinearity worse, not better, and
the reason is what value_destroyed already is.
value_destroyed is sum over locations of P(destroyed) x value(location). It
is already the value-weighted column. Weighting criticals by the same function
would give the two columns a shared weighting basis on top of a shared driver -
both would be "damage getting into locations, priced by Location::value()" and
differ only in whether the location dies. That aligns them; it does not separate
them.
What is actually orthogonal is the through-armour term, and it is orthogonal by construction rather than by choice. Four LB-X firing cluster, measured:
| target | expected_criticals |
of which TAC | value_destroyed |
|---|---|---|---|
| whole armour | 0.393 | 100% | 0.00000 |
| half armour | 0.494 | 79.5% | 0.00000 |
| stripped | 10.160 | 3.9% | 0.00573 |
Where this column is entirely the natural 2, the other column is exactly zero -
a critical through intact armour destroys nothing, so value_destroyed cannot
see it at any weighting. The 0.77 has to be coming from the stripped regime,
where the internal term dominates and the two are measuring the same event from
two sides. That part is meant to correlate: the volley that destroys a
location is the volley that got inside it.
Two things worth checking before anyone fits to that number.
The ablation may have measured a column without its orthogonal half. The
through-armour term landed late in this branch; a corpus recorded before it
carries the internal term alone, which is precisely the half collinear with
value_destroyed. Whoever repeats this at ~190 matches should check which
version the corpus holds - the answer is in whether a volley against intact
armour scores anything at all.
And the corpus was recorded by a bot whose weights did not price the column, so it contains no decision that was taken to fish for criticals. A feature that exists to enable a behaviour the recording bot never exhibited will look uninformative on that corpus close to by construction. That is not an argument that the column is good; it is an argument that this corpus cannot say.
If decorrelation is wanted, split the column rather than weighting it. The two terms are already separate in the arithmetic, they have different drivers - one scales with internal damage, the other with the count of hit-location rolls - and they mean different things a player would name differently: exploiting a hole, and fishing for one. Splitting costs nothing but a column and would let the fit price them apart. Value-weighting costs the thing the feature was built to be.
jmm's answers, and what getBattleForceDamage turned out to be #
The TAC rule confirms the arithmetic rather than correcting it #
"Yes, it's the ordinary crit table. By default this is CT, but many players use the 'floating tac' optional rule that re-rolls the TAC's location."
The confirmation is the ordinary critical table, which this term already
multiplies by, so TAC_CONFIRMED stays 1.0 and no constant changes. The number
that is "less than 1/36" is 1/36 x 15/36, about one shot in 86 - that is the
chance of any critical, and the 22/36 in the term is the expected count
given one. Same distinction CRITICALS_PER_INTERNAL_HIT was written to draw.
The centre torso half is new and does not touch this column: a count over the whole target does not care where the critical lands. It would matter to a value-weighted column - every TAC would carry a centre torso's value, and the default is CT rather than the rolled location - which is one more reason that is a separate feature.
Rare weapons and water, deprioritised #
M-Pod, iATM-3 and the Mech Mortar AP round: "rare weapons, not important to get right, right now." Water ranges and whose-medium-decides: "out of scope for now." The measurements stay above; nothing is built on them.
getBattleForceDamage is the Alpha Strike conversion #
Read out of the jar rather than recalled. It is declared on
megamek.common.equipment.WeaponType, and its neighbours name it:
public double getBattleForceDamage(int);
public double getBattleForceDamage(int, megamek.common.equipment.Mounted<?>);
public double adjustBattleForceDamageForMinRange(double);
public int getBattleForceClass();
public int getAlphaStrikeHeat();
public int getAlphaStrikeHeatDamage(int);
megamek.common.alphaStrike.AlphaStrikeElement is in the same jar. It returns
Alpha Strike damage points, which is why Observation.averageDamage multiplies
by ten to get BattleTech damage back. So the bot's per-weapon damage for every
cluster weapon is being read out of the Alpha Strike conversion, which is
ruled out of scope for this project.
The actual field set, IS against Clan #
ISLRM20 and CLLRM20, identical ranges 7/14/21 and identical rack 20, every
hex from 1 to 21:
ISLRM20: getBattleForceDamage(hex) 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2
CLLRM20: getBattleForceDamage(hex) 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 1.2 0.0 0.0 0.0 0.0 0.0
ISLRM20: getDamage(hex) -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2
CLLRM20: getDamage(hex) -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2 -2
ISLRM20: getShortRange()=7 getMediumRange()=14 getLongRange()=21 getRackSize()=20 getMinimumRange()=6
CLLRM20: getShortRange()=7 getMediumRange()=14 getLongRange()=21 getRackSize()=20 getMinimumRange()=none
The cutoff is hex 17, not a bracket boundary, and it is the Clan weapon
only. getDamage(hex) is the cluster sentinel for both, which is why the code
reaches for the Alpha Strike figure at all.
Is there a non-BattleForce source? Partly, and the gap is specific #
ISLRM20 bfDamage(hex9)= 12.0 handler perHit=1 handler=LRMHandler
CLLRM20 bfDamage(hex9)= 12.0 handler perHit=1 handler=LRMHandler
ISStreakSRM6 bfDamage(hex9)= 12.0 handler perHit=2 handler=StreakHandler
ISNarcBeacon bfDamage(hex9)= 0.0 handler perHit=0 handler=NarcHandler
ISMediumLaser bfDamage(hex9)= 5.0 handler perHit=5 handler=EnergyWeaponHandler
ISAC20 bfDamage(hex9)= 20.0 handler perHit=20 handler=ACWeaponHandler
For direct fire the resolver is exact and BattleForce is unnecessary: a Medium Laser and an AC/20 read 5 and 20 from the handler, matching Alpha Strike exactly. For a Narc the handler is right where the cluster table is wrong - 0 against the cluster table's 2.
For a rack the resolver gives damage per missile but not how many land.
calculateNumHits needs a live attack and is not reachable from a static probe.
The cluster table fills it for an ordinary rack - 12 missiles x 1 = 12, which
is exactly what Alpha Strike says - and is wrong for a Streak, which always
lands every missile: 4 x 2 = 8 against the true 12.
Scope: removing Alpha Strike from the damage path #
jmm's ruling is absolute - nothing from Alpha Strike or BattleForce, full stop - so this is the scope before any of it is built.
Every call site, and it is two #
bridge/sds/Observation.java:505 getBattleForceDamage(range, null) * 10.0
bridge/sds/Observation.java:540 getBattleForceDamage(nearer, null) * 10.0
Both in averageDamage, and nothing else calls the conversion anywhere:
getBattleForceClass, getAlphaStrikeHeat, getAlphaStrikeHeatDamage and
adjustBattleForceDamageForMinRange have no callers in the bridge, the crates
or the harness. The * 10.0 exists only to turn Alpha Strike points back into
BattleTech damage and goes with them.
What is not a reader, and must be left alone: "alpha strike" is also the
BattleTech term for firing every weapon at once, and that is what
sds-bot::main, ev::BUCKETS, features::heat, decision.rs and four files in
sds/ mean by it. sds/scenario.py names a MegaMek map set called
BattleForce 2. Fifteen or so sites, all ordinary vocabulary.
How much of the library goes through it: 5,748 of 8,230 pairs, 69.8% #
Every ordinary rack - LRM, SRM, MRM, HAG, Streak and their munition variants. The other 30% already comes from somewhere else, and four of those five paths were added by this branch:
| path | source | Alpha Strike? |
|---|---|---|
| direct fire | getDamage(range) |
no |
| LB-X and Flak cluster | cluster table x handler damagePerHit |
no |
| MML and ATM | cluster table x round damagePerShot |
no |
| Thunderbolt (rack 1, no cluster table) | handler damagePerHit |
no |
| Narc explosive (cluster table, rack 1) | handler damagePerHit |
no |
| ordinary rack | getBattleForceDamage x 10 |
yes |
The replacement, and the one gap in it #
For an ordinary rack the resolver plus MegaMek's own cluster table answers it:
calculateClusterHitTableAmount(7, rack) x ClusterGrouping.damagePerHit(...).
Measured, it reproduces what Alpha Strike says where Alpha Strike is right, and
is right where Alpha Strike is not:
ISLRM20 cluster table 12 missiles x damagePerShot 1 = 12 (Alpha Strike: 12)
CLLRM20 cluster table 12 missiles x damagePerShot 1 = 12 (Alpha Strike: 12 to hex 16, then 0)
So the Clan zero disappears rather than being filled, and the nearest-bracket
fill this branch shipped becomes unnecessary rather than wrong-but-kept. That
cluster table is the one ev::CLUSTER_HITS is verified against at all 440 cells,
so it is not a new unverified source.
Streak is the one family the static path cannot answer. A Streak lands every
missile, so the cluster table gives 4 x 2 = 8 against a true 12.
calculateNumHits is the resolver's own answer and needs a live attack, which is
the bridge probe to build - the shape SdsHitLocations already uses to pin dice
and read a table. Which handlers behave that way is to be measured, not assumed.
Alpha Strike is out of the damage path #
Both call sites gone, the * 10.0 with them, and no rules answers were needed.
ClusterGrouping.resolvedDamage does what handle() does without a game: set
nDamPerHit from calcDamagePerHit, take calcHits, then read nDamPerHit
back and multiply, averaged over the 2d6 curve.
Three things had to be right, and each was found by measuring rather than guessing.
A crew and a deployment. A handler asked about a munition round on an
uncrewed, undeployed machine answers zero hits rather than throwing - the same
shape the vehicles work found in a bare new Tank(), one level up. An Inferno
SRM-6 went from calcHits -> 0 to 4 on that alone.
Reading nDamPerHit back. LRMDeadFireHandler.calcHits is
hits = nDamPerHit * super.calcHits(...) and then sets the field to one so its
caller does not multiply twice - read out of the bytecode, because a Dead-Fire
round was answering zero and no accessor said why. A probe that multiplied by
the value it put in would double it; one that never set it reads zero.
Game options were the wrong suspect and the test said so.
tacops_ammunition on and off gives identical answers for Dead-Fire and
Incendiary. A clean negative, and it is why the bytecode was read next.
Verified against every shape the library has:
| pair | resolver | Alpha Strike said |
|---|---|---|
| LRM-20 | 12.7 | 12 |
| Clan LRM-20 | 12.7 | 12 to hex 16, then 0 |
| LRM-20 Dead-Fire | 18.2 | - |
| Streak SRM-6 | 12.0 | 12 |
| SRM-6 | 8.0 | 8 |
| AC/20 | 20.0 | 20 |
| Medium Laser | 5.0 | 5 |
| Gauss Rifle | 15.0 | 15 |
| SRM-6 Inferno | 0, declared | a damage figure it does not have |
And the silent-zero column went down rather than up.
| Alpha Strike | resolver | |
|---|---|---|
| priced at every hex | 6,806 (82.7%) | 6,064 (73.7%) |
| declared unmodelled | 916 (11.1%) | 1,730 (21.0%) |
| no ground range | 463 | 428 |
| silently zero | 44 | 7 |
| priced at some hexes and not others | 0 | 0 |
Complete-or-declared rises from 93.8% to 94.7%, and the 742 that left "priced" went to "declared" rather than to silence: rounds like Inferno and Fragmentation that Alpha Strike had been handing a damage figure they do not have.
The declaration needed no ruling. noArmourDamage asks the resolver two
things - does anything arrive, and does what arrives carry armour damage - and
packets landing with nothing is the same statement Excluded::HeatOnly already
makes about a Plasma Cannon. A Thunder round comes back with a full 12.7
points and is untouched, which is the figure that justifies not having
declared the munition families by name: doing so would have stopped a Mek firing
an LRM because the bin held Thunder rounds.
The replacement works; flipping it over is blocked on a rules decision #
Built and verified, and then deliberately not switched on. The source is
ClusterGrouping.expectedHits - WeaponHandler.calcHits asked once per 2d6
total with the dice pinned and averaged over the curve, which is the resolver's
own answer and not a table of ours. It is right where it matters:
| weapon | resolver | Alpha Strike says |
|---|---|---|
ISLRM20 |
12.69 | 12 |
CLLRM20 |
12.69 | 12 to hex 16, then 0 |
ISStreakSRM6 |
12.00 | 12 |
ISSRM6 |
8.00 | 8 |
ISAC20 |
20.00 | 20 |
ISMediumLaser |
5.00 | 5 |
The Streak check passes, which is what proves the probe exercises the real
path rather than a table: calcHits answers 6 6 6 6 6 6 6 6 6 6 6 - every
missile at every roll - where the Cluster Hits Table would say four. An LRM 20
comes back 6 6 9 12 12 12 12 16 16 20 20, which is that table's row for a rack
of twenty. And the Clan zero does not need filling because it never occurs.
What blocks it. Switching averageDamage over moves the library sweep's
silently-zero column from 44 to 985. The 941 are specialised munitions -
Inferno, Incendiary, Fragmentation, Dead-Fire, Smoke, Thunder, Mine Clearance -
for which calcHits returns 0 rather than throwing, in the synthetic attack
the bridge builds. That is a refusal wearing a measurement's clothes, one level
below the ones this branch has been fixing, and it must not become a damage
figure.
Declaring them is not a free move either. Some genuinely do no armour damage and
plan/exotic-ammo.md already says they are not modelled - Smoke, Flare, Mine
Clearance, Inferno's damage is heat. But Thunder, Incendiary and Dead-Fire
rounds do ordinary damage on top of their effect, and declaring those would
stop the bot firing an LRM because the bin happens to hold Thunder rounds. A
first attempt at declaring the whole set moved 4,193 pairs into "unmodelled" and
dropped "priced at every hex" from 6,806 to 3,114, which is plainly too far.
The probe was mine, and it dissolved most of the question #
Attempted before escalating, and it was the right order: the fuller attack needs no rulings and it removed most of the set.
A crew and a deployment were what was missing. SdsVehicleLiveFire in the
vehicles work says a bare new Tank() answers with an uninitialised object
rather than a result, and this is the same shape one level up: a handler asked
about a munition round on an uncrewed, undeployed machine answers zero hits
rather than throwing. Crew and setDeployed(true) on both entities is the
whole fix. An Inferno SRM-6 went from calcHits -> 0 to calcHits -> 4.
With that in, the resolver separates three cases by property, and only the third is unmeasured:
| round | damagePerHit |
expectedHits |
reading |
|---|---|---|---|
| LRM-20 Smoke | 1 | 12.69 | 12.69 damage - works |
| LRM-20 Thunder | 1 | 12.69 | 12.69 damage - works |
| LRM-20 Fragmentation | 0 | 12.69 | lands, does no armour damage |
| SRM-6 Inferno | 0 | 4.00 | lands, does no armour damage |
| LRM-20 Dead-Fire | 2 | 0 | refusal: real missiles, uncounted |
| LRM-20 Incendiary | -1 | 0 | refusal: no answer at all |
The middle case is declarable without any ruling. "The resolver says packets
land and each carries no armour damage" is a measured property, and it is the
same statement Excluded::HeatOnly already makes about a Plasma Cannon - its
damage is heat and this bot has no model for making an enemy hot. Inferno and
Fragmentation are that, and naming them by that property rather than by munition
declares them without anybody ruling on what a Fragmentation round does.
Thunder and Incendiary were the ones a hasty declaration would have broken, and this is the measurement that proves it: Thunder reads a full 12.69 points. Declaring the munition set wholesale, as the first attempt did, would have stopped the bot firing an LRM because the bin held Thunder rounds.
So what is left unmeasured is Dead-Fire and Incendiary shaped rounds, not the
941. That is a much smaller question and it is still a probe question rather
than a rules one - LRMDeadFireHandler.calcHits returns zero for missiles that
plainly fly, so something in the attack is still absent.
If the probe stalls, these are the questions, in this order: #
Until one of them lands, averageDamage still reads the Alpha Strike
conversion, which is against jmm's ruling and is recorded here rather than
quietly left. ClusterGrouping.expectedHits ships now so the measurement is
preserved and the switch is a small change when the decision arrives.