--- 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 | - [x] Feature trait with a declared normalisation type; make an unnormalised feature impossible rather than discouraged - [x] Positional, the nine that line of sight answers: `exposure`, `los_in`, `los_out`, `cover_quality`, `range_band_fit`, `elevation_gain`, `tmm_gained`, `cohesion`, `rear_arc_gain`. `features::positional`, all `bounded`, all measured from one `Posture::survey` per candidate so a line is traced once however many features read it - [x] `incoming_damage`, which replaces the retired `exposure` family: real volleys through real terrain, allocated at the force by how likely each enemy is to shoot this machine rather than one of ours standing next to it. `features::incoming`, `bounded`, read as a share of what the machine has left. Written by the force before it scores anything, so the corpus row describes the scoring that decided the move. See [hierarchy](hierarchy.md) - [x] `percent_options`: the share of its open-field movement a hex leaves the machine, at the full run-and-jump allowance, through the match-lifetime `pathfind::OptionsCache`. Carries the jump-in-water rule, which is the first use of the "Encoded here, not read out of the jar." marker - [x] `heat_cost`: the move's own heat, in the movement vector. `Gait::heat` was computed for every stand and read by no feature, so the phase that creates movement heat was the one phase blind to it while the firing phase priced heat four ways - and standing still, the only gait that costs nothing, got that discount for free. It arrives as part of `heat_incurred` rather than as a column of its own: the two heats add on one scale and MegaMek does not ask which half a point came from. The claim that stopped being true is that every candidate walks; the sweep ranks still, walked, ran and jumped stands together - [ ] Positional, the two still open, and what each waits for: - `distance_to_focus` waits on the force's focus target reaching the unit level. A unit proposes before its force has chosen whom to concentrate on, so there is nothing to measure a distance to; the fix is a field on `Request::Propose`, not a rule the unit can guess - `psr_risk` waits on the path carrying its own piloting modifiers. Only the mover knows what a path crosses, and a version that guessed from the end hex would be wrong in exactly the cases it exists for - [x] Movement's basis is a superset of firing's, in the type system. `features::firing::Projected` is the set of features a candidate's hypothetical volley can be measured by, and it is the `Feature::Input` of every one of them, so there is no way to measure one without the builder both phases call. `FIRING_ONLY` is the single exception list - `ammo_spent`, `overkill`, `p_breach`, `p_psr_threshold`, each a property of a chosen allocation - and it is checked in both directions against a real movement proposal, so an excuse cannot be written for a feature that crosses anyway. What it replaces: four of the volley group crossed and the rest did not, with no principle in the code drawing the line - [x] Firing: `expected_damage`, `p_kill`, `p_psr_threshold`, `heat_incurred`, `ammo_spent`, `overkill`, `weapon_concentration`. `overkill` read zero on 8835 of the corpus's 8843 candidates under its old estimator, so the weight fitted against it came from eight rows; `waste_above` against the mission-kill threshold is the fixed form, and `thin_columns` in `sds/train.py` is the guard that would have caught it - [x] Firing, the heat chart: `heat_mp_penalty`, `heat_to_hit_penalty`, `heat_shutdown_risk`, `heat_ammo_explosion_risk`, `has_explodable_ammo`, all measured on the heat the *candidate* would leave the unit on rather than the heat it is at. One table in `features/heat.rs`, off `HeatEffects.0`..`HeatEffects.30` and cross-checked against `HeatResolver`. They replace a rule: `allocations()` truncated every volley to `heat_capacity - heat + 4`, and `heat_capacity` is dissipation rather than the shutdown threshold, so the bot never fired more than it could sink and no weight could see the wall - [x] Firing, per location: `p_kill`, `p_mission_kill` and `overkill` measured against the location the damage reaches rather than against the target's total, plus `p_breach` and `value_destroyed`. See `hitloc` below - [x] `p_mission_kill` is `P(a leg is destroyed)` and nothing else. It was `P(a leg goes or the target dies)`, a union strictly containing `p_kill`, so the two could not be weighted apart however much corpus was recorded: identical on 2506 of 2700 bench candidates, `r = +0.989`, and lower than `p_kill` on none of them. A fit on that pair put -0.70 on `value_destroyed` - the largest weight in the vector, and it said breaking what makes the enemy dangerous is bad. Same failure as `los_in`/`los_out` at +1.40/-1.25 ## 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](../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. - [x] The pipeline: two figure shapes (a board with a value per hex, a row of subjects with a value each), a deterministic SVG renderer, and `examples/featuredoc.rs` to regenerate the lot - [x] The enforcement, as `Documented` rather than as a list - [x] All 44 features - [x] **A movement figure is the reachable hull.** A positional feature is only ever asked about a hex the machine could end its move in, so the set a figure measures is the set the bot scores: the hull is outlined along its hexsides, and a test fails a figure whose readings and hull disagree in either direction. Ground *and* jump, because `submerged`'s whole subject is a hex a walk will not reach - [x] **A firing figure draws the brackets, as areas.** One scene for all ten - a Gauss rifle and an SRM-6 against one target, measured down the column between them - with the board tinted into the regions where both guns are doing one thing throughout. A ring says where a bracket ends and nothing about either side of it; the fact worth showing is that a bracket belongs to a *weapon*, and on this scene a naive short/medium/long picture would have three regions where there are seven. The bands are annuli around the **shooter**, because a bracket is a zone around the gun and the gun is on us: the minimum-range band is the ring of hexes next to *our* machine, and an enemy standing there is one the Gauss cannot aim at properly. The figures hold us still and move the target, which is also what a firing decision does - the machine shoots from where it already is. `range_band_fit` is the one that runs the other way, because it is a *movement* feature: there the enemy stands still and we choose a hex, so its bands radiate from the enemy and its legend reads "stand at" where the firing figures read "target at". Getting this backwards drew a minimum-range halo around the machine that was not holding the weapon, and the arithmetic was self-consistent throughout - which is why the figure is the thing that caught it. Whoever is being shot at wears a reticle, which is the only thing on a board that tells `cover_quality` from `los_out` - the same two machines on the same ground, measured in opposite directions. Gauss and SRM-6 rather than PPC and SRM-4 because fifteen points and twelve reach the twenty a piloting roll needs, and the lighter pair left `p_kill`, `p_mission_kill` and `p_psr_threshold` reading nothing - [x] **A heat figure is a thermometer**, because the scale is the explanation. One tick per line in `features::heat`'s chart, the feature's own lines named and the rest left as faint context, and the mercury at the heat the machine *ends the turn* on rather than the heat the volley made - [x] One browsable page: `--html` writes the whole catalogue grouped by family, figures inlined, with a light/dark switch that reaches inside them - the figures go in without their stylesheets and share the page's tokens, because an inline `` puts its ``, 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. - [x] `docs/figures.css` is the single definition, read by both generators - [x] `tests/test_figures.py` fails a figure whose palette is not that file, or that declares a token of its own - [x] Both prek hooks watch `docs/figures.css`, and the harness hook watches the generated label figures - editing one by hand touches no `.py` and used to run no check at all - [ ] Each figure still carries the whole sheet, and 63% of the class rules in a feature figure are unused - `rear_arc_exposure` uses 10 of 43. Emitting only what a figure draws would take ~185 KB off `docs/`, and needs care with compound selectors like `.glyph.wood.heavy` - [ ] `examples/heatmap.rs` carries a third palette with different values (`#f6f3ec` against the figures' `#fbfaf8`). It does not ship into `docs/`, so it is a separate tool rather than a fourth copy - but nothing says so ## Found by the spread table, and by the fit that followed - [ ] **Ten target-side columns are flat within every movement decision.** `overkill`, `p_breach`, `p_psr_threshold`, `target_breach`, `target_current_bv`, `target_gunnery`, `target_health`, `target_original_bv`, `target_piloting`, `target_tonnage` - they appear in 82 of 2,148 movement decisions and read the same value for every candidate in all of them. A difference-framed fit learns nothing from a constant, so their weights come from firing rows alone. Whether that is a defect or correct-by-design is the open question: if a movement projection only ever aims at the nearest enemy, the target's tonnage genuinely does not vary with our hex - [ ] **`defense_6plus` was excluded from the fit as thin**, 0.29% filled - matching the occupancy predicted when it was built. Fold it into `defense_5plus` rather than carrying a column that cannot be learned - [ ] **Stale ticks.** `target_threat`, the `level_tmm` ceiling, `overkill` and `target_skill` are still listed as open above; all four landed, on branches that ticked them separately ## 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. - [ ] Ablate the mobility group - `deploy_ground`, `deploy_outlook`, `percent_options` - and bench against the same suite. One run; no new features and no refit needed to ask the question ## `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. - [ ] Split it. `any_edge_distance` is what exists and is the right measure for anything about the board's shape - being cornered, running out of room. `our_edge_distance` is the new one, and it needs a side's home edge, which is a scenario fact rather than a board fact - [ ] Then re-price `Withdraw` against `our_edge_distance` and re-draw the figure. It is the one panel in `docs/TACTICS.md` that is wrong rather than surprising, so the before and after are already rendered - [ ] `Retreat`'s old wording said "leaving the map", and the deployment zone is what makes that a direction rather than a radius. Whatever carries the home edge is the same fact `Seize` and `Deny` will want for map objectives - see `plan/objectives.md` ## `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.md` holds 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::PKill` is 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 `Advance` is waiting on once it can price contact at all. - [x] `expected_criticals` as a **count** first. The arithmetic is one rule - an internal hit rolls 2d6, 8-9 is one critical, 10-11 is two, 12 is three or a limb off - plus the through-armour case, which needs a confirmation roll and so is worth less than the raw 1/36 the location table suggests. Armour-piercing and tandem-charge rounds are special cases the simple model will not cover, and should be named as excluded rather than silently wrong. **Check the arithmetic with jmm before fitting anything to it** - [x] Let the fitting decide its weight. Do not reach for expected critical *value* first: `Location::value()` exists and looks like the natural weighting, but its own documentation says the figures are round numbers chosen to rank locations and that nothing downstream should read them as calibrated. Weighting criticals by it would be reading it as exactly that - [x] Through-armour criticals are in, and they were the largest thing missing. See "The natural 2 was 100% of the answer" below - [ ] Only if the count proves too blunt, price a critical by what it hits. That is a different feature and wants its own argument ### 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. - [ ] Fit it. `EXPECTED_SIGNS` carries the direction and nothing else; the hand set declines to guess a size, the same way it declines for `p_breach` - [ ] The three things that were waiting on it: the battle armour leg attack, which is held at nought deliberately rather than given a constant; a kick or a punch into an open location, which is crit-fishing and nothing else; and the ammunition explosion that ends a match in round three, which `p_kill` cannot see because it is damage-based ## 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: - **`ACFlakHandler` groups 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. - **`LRMFollowTheLeaderHandler` reports `Integer.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. - **`ThunderBoltWeaponHandler` reports a group of five with no cluster table**, which is inert: one packet cannot be grouped. - [x] Measure it. Deterministic: a pinned dice source, because a Nail/Rivet Gun rolls inside `calcDamagePerHit` and two runs disagreed before it was pinned ### 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. - [x] **A Thunderbolt reaches the bot as zero damage at every range**, which is a bridge defect and not a MegaMek one. `getDamage()` returns the *cluster* sentinel (-2) for a Thunderbolt rather than the variable one (-3), so `averageDamage` takes the cluster path, `redefinesRanges` is false for Thunderbolt ammunition, and `getBattleForceDamage` answers 0. Measured on the wire: Thunderbolt 5, 15 and 20 report 0.0/0.0/0.0 and Thunderbolt 10 reports 10.0/10.0/0.0. This is the same failure the Snub-Nose PPC had before it was fixed - the bot is told the weapon does nothing and declines to fire it - and the fix is the same shape: compute from the round, whose `damagePerShot` carries the 5/10/15/20. Deliberately fixed by asking the handler that would resolve the shot, at that range, guarded on three measured conditions. See "What the Thunderbolt fix changed" below - [x] Carry the grouping on the wire per weapon and per round, beside `damagePerPacket`, as `packetsPerLocation`. Two numbers express every answer above including the ATM, whose damage per missile is already a per-round field and whose grouping is a flat five - so no branch and no family enum, and Follow-The-Leader's whole-salvo answer works without anybody having heard of it. `ClusterGrouping` is the one function that answers, memoised, and the dump prints what it says beside the evidence - [x] `ShotKey::group` carries it into the damage model, and a salvo is spread over its *groups* rather than its packets. The remainder is exact rather than smeared: a salvo is `packets / group` full groups and at most one short one, and the short one lands independently of the rest - [x] Re-probe `CRITICALS_CEILING`. Three stands: the tail it was set against fell from 37.9 to 9.4, which makes 3.0 a better ceiling than it was when it was chosen ### 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". - [x] `clusterRack` reports zero for an autocannon, so an AC firing Flak reaches the model as a single packet and its measured group of five never applies. Declared an unimplemented ammunition type rather than approximated - the gun is dropped from the volley while Flak is loaded, and `docs/CLUSTERS.txt` enumerates every affected pair. See `plan/exotic-ammo.md`, which also records that LB-X cluster is in the same bucket and is a defect rather than a limit - [ ] Re-fit. Every weight fitted against a corpus recorded before this landed was fitted against a `p_breach` that over-rated racks ## 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 Mech Mortar-1 change is collateral and is probably an improvement - `MekMortarAntiPersonnelHandler` says an AP shell is 1 point where the BattleForce average says 2 - but it is a rules question rather than a measurement, and mortars are `Excluded::Mortar` so nothing reads it. **Worth asking jmm** rather than claiming - [ ] A weapon whose damage varies *inside* a bracket is still averaged at the bracket's own range by `avgDamageShort/Medium/Long`, so a movement candidate pricing a Thunderbolt at three hexes uses 20 where the shot would do 10. The firing phase is right, because a `Shot` carries damage at its actual range; the projection is the approximate half. The same is true of a VSP laser and a Snub-Nose PPC and was true before this ## 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. - [x] Verify the transcription. It was correct ## 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. - [x] Fill a zero bracket from the nearest answered one. 502 pairs, and nothing else moves - [ ] Whether MegaMek intends this is a question for upstream rather than for us. The workaround is sound either way, because it only ever replaces an absence with a figure the same weapon already carries one bracket nearer ## 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. - [ ] A **stated limit**, not a fix, and the reason is that the firing phase is already right: a `Shot` carries damage at its actual range, so a volley is priced correctly. Only the *movement projection* averages, and it averages a weapon it has not yet decided to fire at a hex it has not yet moved to. More brackets on the wire would buy accuracy in the one place a candidate is a hypothesis anyway. Revisit if a fitted `range_band_fit` starts disagreeing with where missile boats actually stand ## 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::Artillery` already 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. - [x] `avgDamageMinimum` on the wire, and `brackets::projected` reads it inside the minimum. A movement candidate pricing a Thunderbolt at three hexes claimed 20 and now claims 10 - [x] Absent means no separate figure, which is every other weapon and every older observation - the same answer by two routes ## 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. - [ ] **`TAC_CONFIRMED` is 1.0, and that is an upper bound rather than a measurement.** jmm's rule is that a TAC still has to be confirmed. Whether that confirmation *is* the ordinary critical table - which already gives nothing on a 2 to 7, and would make his "less likely than 1/36" true at 1/36 x 15/36 - or a further roll on top of it, is not settled here. MegaMek resolves it inside `TWGameManager.criticalEntity`, whose body javap will not show. **Worth asking jmm**; the term is linear in the constant, so an answer is a one-line change. It ships at the bound because the alternative on offer was zero, and zero was 100% wrong for the case the feature is about ## 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: - [ ] `waterShort/waterMedium/waterLong` on the wire beside the ground three. Mechanical - [ ] A medium selector in `brackets::projected`. The movement work's `Footing::wades` already answers "is this mover in the water" for the shooter half - [ ] **Whose medium decides - the shooter's, the target's, or the path's?** A rules question, and the reason this is not a three-line change. **Worth asking jmm** 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. - [x] Sweep the library end to end. The instrument is committed as `examples/weapon_sweep.rs` and re-runs in two seconds - [x] Give `InfantryWeaponHandler` a declared exclusion off the handler rather than off the name. 648 pairs, one wire field, and the silent zeros fell from 402 to 44 ## 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 original objection to value-weighting stands and is about calibration rather than collinearity: `Location::value()`'s own documentation says its figures are round numbers chosen to rank locations and that nothing downstream should read them as calibrated. Importing them into a column whose purpose *is* calibration is the trade to argue about, and it has not changed - [ ] Re-run the ablation at ~190 matches against a corpus that carries the through-armour term, and split the column before weighting it ## 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. - [ ] **So #270's fix is a bridge, not a cure.** It fills an Alpha Strike zero from an Alpha Strike figure one bracket nearer. The cure is to stop reading damage out of the Alpha Strike conversion: the resolving handler answers direct fire exactly and answers a Narc correctly, and a rack needs one more number - the hit count - which is reachable from a bridge probe that builds a real attack, the way `SdsHitLocations` pins dice to read a table. **jmm should hear this before #270 merges** ## 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. - [ ] Build the live-attack probe for the rack hit count, and verify a Streak comes out at 12 rather than 8. A handler-derived figure still reading 8 means the probe is not exercising the Streak path - [ ] Replace `averageDamage`'s rack branch, delete both conversion calls and the `* 10.0` - [ ] Re-run `examples/weapon_sweep.rs` before and after. The "priced at some hexes and not others" row must stay empty and the "silently zero at every range" row must not grow - anything that loses its number must become a *declared* limit through `Excluded::from_handler`, not a silent zero ### 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. - [x] Remove both conversion calls and the `* 10.0` - [x] Verify the Streak: twelve points, not eight - [x] Silent-zero column must not grow: it fell from 44 to 7 - [ ] **Every fitted weight predates this.** 5,748 of 8,230 pairs - every ordinary rack - reached the bot through the conversion, so `fitted-r13` and the 240-match corpus behind it describe a different game. Nothing is blocked; nobody should reach for those weights without knowing - [ ] The seven remaining silent zeros are aerospace bays and a Centurion Weapon System, on mounts a ground unit does not carry ### 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. - [ ] Find what Dead-Fire and Incendiary still want. Game options are the next suspect: both are advanced munitions and a `new Game()` carries default options, so a rule that is switched off may be answering honestly - [ ] Then declare the "lands, no armour damage" case off the measured property and switch `averageDamage` over. The silent-zero column is the test ### If the probe stalls, these are the questions, in this order: - [ ] **Per-munition, from jmm**: which of Inferno, Incendiary, Fragmentation, Dead-Fire, Smoke, Thunder and Mine Clearance deliver ordinary armour damage the bot should price, and which are effects it should decline. That is seven rules answers and it settles it - [ ] **Or a fuller attack probe**, so `calcHits` answers for a munition round instead of returning zero. The handler needs more game around it than a constructed `Game` and a `WeaponAttackAction`; what exactly is unmeasured. This is the better answer if it is cheap, because it needs no rulings at all 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.