--- id: protocol title: The observation carries what a bot needs to reason status: open dependsOn: [] exitCriterion: > Every feature in the basis can be computed from what the wire carries, without the bot reaching into MegaMek. --- # protocol The observation is the interface. What is not in it does not exist for any bot. Adding a field is deliberate, with a reason in the commit message. - [x] **Per-location armour and structure** - blocks breach state, so it blocks punch-versus-sandblast allocation and [cripple](tactics.md). On the wire as `locations`, with rear armour on the three locations that have it - [x] **Per-location contents** - armour says how hard a location is to break, not what breaking it costs the target. A weapon count and their damage, live ammunition and whether CASE contains it, engine, gyro and cockpit slots, and an actuator count. One shared addition: [cripple](tactics.md) needs it for arc-aligned targeting, and a mission kill needs it before "all its guns are gone" can mean anything. Deliberately a summary and not a critical slot listing - [x] **Ammo type per weapon** - per-missile damage varies by ammo; [ev](ev.md) cannot be right without it. On the wire as a weapon's `ammo`. `damagePerPacket` reads the munition wherever the munition redefines the weapon - [x] **Mount location per weapon** - which weapons bear from a candidate position depends on where they are bolted on, and the wire carried ranges and damage and nothing else. On the wire as a weapon's `location`, in MegaMek's own abbreviations, with `rearMounted` beside it. The case that makes it load-bearing is the legs: `Mek.isSecondaryArcWeapon` is false for them, so a leg weapon does not follow the torso twist, and a candidate generator that assumed a torso mount would overvalue twisting. An absent location and one the bot does not model are read differently on purpose - the first is a centre torso, the second declines the twist, because the location known to be missing is a tripod's centre leg - [x] **Minimum range per weapon** - every PPC variant but the Snub-Nose has one, an LRM has six hexes of it, an ATM four, a Gauss Rifle two, and the wire carried none of it. `Compute.getRangeMods` adds `minimum - distance + 1` at or inside the minimum, so a weapon at exactly its minimum is already at +1. On the wire as a weapon's `minimum`, with MegaMek's `Integer.MIN_VALUE` sentinel clamped to zero by the bridge - [x] **Ammo alternatives per launcher** - an MML fires LRM or SRM ammo out of one tube and an ATM has three loads, each with its own brackets, its own minimum range and its own damage. `WeaponType.getRanges(weapon, ammo)` is the only accessor that knows: the plain `getShortRange()` family returns **zero for every bracket** of an MML, so an MML on the wire was a weapon that reached no hex at all. On the wire as a weapon's `modes`, emitted only when there are at least two. They are alternatives and never additions - one round is in the tube - [x] **Torso facing, and the twists still available** - the wire carried the hull facing alone, and every weapon but a leg mount fires along the *secondary* facing. So the bot read the wrong arc for a unit that had twisted, and had nowhere to say "twist" even if it had wanted to: over a 2v2 batch 63% of in-bracket weapon-by-enemy pairs were out of arc and a legal twist would have opened two thirds of them. On the wire as a unit's `secondaryFacing` and `twists`, the latter being MegaMek's own set from `canChangeSecondaryFacing` and `isValidSecondaryFacing`, and as a `secondaryFacing` on every shot: the shot list is priced once per legal twist, and the groups are alternatives rather than additions - [ ] **A `modelled` flag per weapon** - artillery, TAG, AMS, pods, mortars, a VGL, a taser, a TSEMP, an extinguisher, a bomb and Narc all fire under rules this bot does not implement, and treating one as an ordinary gun puts damage on the board that cannot happen. `sds-core/src/brackets.rs` classifies them today by matching `WeaponType.getName()`, which is a name list and will drift. The jar knows properly through `WeaponType.getFlags()`. The same call answers a pulse weapon's -2 to hit, which is an accuracy term the range brackets deliberately refuse to carry because nothing on the wire declares it - [x] **Per-unit levels in the round tick** - on the match result as `entities`, `{seat: {entity id: {...}}}`, beside the side totals it is the per-unit form of. A destroyed unit drops out, the way it already drops out of `bv` and `armor`. Unblocks the `unit` and `force` label tiers for `bv_lost` and `damage_taken`, and `force` is the only place lance membership is recorded at all. Carries `bv`, `armor`, `internal`, `potential` and `force`, and five more that exist for labels no side total can carry: `x`/`y` for a force's shape, `rear` for the arc damage arrived in, `heat` and `shutdown` for what heat actually cost, and `crippled` for a mission kill. `boardSpan` sits on the result root beside it, so a shape label divides by the same span `cohesion` does. See [training](training.md) - [ ] **Damage attribution in the round tick** - on the match result as `credits`, `{seat: {entity id: {bv, damage, units}}}`, what that machine took off the enemy during the round the tick heads. A flow, not a level, and the reason it has to be recorded rather than derived: a snapshot says which enemy lost armour and never says who took it off. Blocks the `unit` and `force` tiers for `bv_loss_inflicted`, `damage_inflicted` and `kills_secured`, and so for the three ratio labels built on them - [ ] **AMS on targets** - reduces cluster size - [ ] **Terrain deltas per round** - fire, smoke, collapse and cleared woods change LOS. The board is sent once and never updated, so [los](los.md) would drift correctly-implemented and wrong - [ ] **Scenario objectives** - MMS V2 carries per-player `victory:` conditions and composable triggers. Blocks [objectives](objectives.md) - [ ] **Minefields** - the bot is blind to them - [ ] **Buildings as objects** - a building spans hexes and has an identity, and the board message sends only per-hex terrain. Every terrain attribute [los](los.md) reads is already on the wire, so building *terrain* blocks correctly; what cannot be answered is `getBuildingAt` and `Compute.isInBuilding`, so a shot traced from inside a building, and infantry protected by one, are both wrong - [ ] **Entities in an intervening hex** - a grounded DropShip is ten levels of cover to anything shooting past it, and [los](los.md) cannot see it Fixed already: `WeaponType.getDamage()` returns `-2` for cluster weapons, so the bot valued every missile below nothing. There is now no plain `damage` field. The armour getters have the same shape of trap - `ARMOR_NA`, `ARMOR_DOOMED` and `ARMOR_DESTROYED` are -1, -2 and -3 - and the bridge converts them to zero. ## The corpus tests the arithmetic and never the arguments Three bugs in one week where the differential passed and real play failed. They are one bug. - **MASC.** The observation sent `getRunMP()`, which folds in a boost this bot never declares. A Stalking Spider with walk 5 was offered a run of 10 where the rules give 8. - **Deployment elevation.** Units were deployed at a hard-coded elevation 0. A Mek in depth-1 water belongs at -1, and from an elevation `isElevationValid` refuses, MegaMek refuses every path out of the hex for the rest of the match. - **Carried MP.** `MovePath.getMpUsed` starts at `Entity.mpUsed` rather than at zero, so the allowance the bot was handed was larger than the one MegaMek enforced, by whatever the unit had already spent. The movement corpus could not see any of the three, and the reason is the same each time: **it is handed the inputs and asked about the arithmetic.** A prone case is `--prone-case x,y,facing,budget`. The budget is an argument to the fixture and a derived quantity in a match, so the fixture can prove "given allowance B we agree with MegaMek about what B reaches" and can never prove "B is the allowance". Every one of the three bugs was in B. The tell is in the fixture itself. `SdsPathfind.search` calls `unit.fixElevation()`, `unit.setDone(false)`, `unit.setHullDown(false)` and, until this epic, left `mpUsed` at whatever a freshly loaded scenario has. Each of those lines normalises a piece of entity state that a live movement phase does not normalise - the second bug is literally the harness repairing the state the host creates. **A line in a fixture that cleans up entity state is a class of bug that fixture can no longer see**, and there are four of them in eleven lines. The same split runs through the Rust side. `pathfind_differential.rs` builds its `Walker` as a struct literal, so it tests `search`, which was correct, and never `Walker::running(&unit)`, which was not: `mpUsed` had been on the wire for months, documented, and read by the standing plan's key, and nothing ever read it for the budget. A field can be carried, parsed, and dropped, and no test here was shaped to notice. What to do about it, cheapest first. - [x] **Make the normalised state a parameter.** `--carry-case` and `--prone-carry-case` set `Entity.mpUsed` before the search, and the case carries it so `SdsRoutes` can put it back before it replays the routes. Three cases, one dump, and the corpus now states what a path's running total starts at instead of assuming it. The same move is available for every other line in that normalising block, and `fixElevation` is the next one worth spending. - [x] **Print the input beside the refusal.** The illegal-path line carried the path's total and the allowance and not the third number the comparison is made of. `mpUsed` is on it now. A refusal that cannot distinguish "our cost model is wrong" from "the allowance was smaller than we were told" is 174 lines of ambiguity, and this one field is the difference between reading the answer off the log and reconstructing it in a JVM. - [x] **Send whether the rules will move the unit at all.** `immobile`, `stuck`, `canUnstickByJumping` and `elevationValid`. `shutDown` was on the wire and read by nothing, and it is only half of `Entity.isImmobile()` - the other half is an unconscious crew, which was not sent. Twelve of the twenty-one refusals in the 101-match run were a machine that was switched off; see [candidates](candidates.md). - [ ] **Dump the actor's observation beside a refusal.** Replaying these 174 needed the scenario out of the run manifest, the unit guessed by matching walk MP, and the start facing brute-forced over all six until the hexes lined up - which is why 65 of them could not be replayed at all (44 had taken damage, so no fresh unit matched their walk MP). `SdsClient` is holding the observation JSON when it refuses. Writing it into the run directory turns a refusal into a fixture in one step, and costs a file write on a path that is already an error path. - [x] **Build the walker through the wire in the differential.** `Case::walker()` fills a `Unit` and calls `Walker::running` rather than writing the struct out field by field, so a value the observation carries and the search never reads now fails 195 cases instead of none. This is the general form of the bug and the cheapest guard against the next field that gets carried and dropped. - [ ] **A conformance case from a live match.** Keep one recorded match's observations as a fixture and replay the generator against them, asking MegaMek to validate each route. It covers every field at once rather than the ones somebody thought to parameterise, which is what the four items above cannot do. It costs a match to record and a corpus to carry, so it is worth doing when this class repeats a fourth time and not before. Not yet needed: the observation is sent per decision, ~450KB a round at 8v8. Sending it once per phase would remove most of that. Measured at 291us against ~10ms of thinking, so it is tidiness, not performance. ## A refused move is answered again, not overruled The host refused a move, stood the unit still, and told the bot nothing. The bot holds no state across a question, so the next ask was the identical question - same round, same settled set, same positions, because the unit had not moved - and the plan cache returned the identical answer. **One unit sent `TURN_RIGHT, FORWARDS x3` for thirty-seven consecutive rounds.** A Bishamon on a Mek suite was refused in 36 of 40 rounds and never moved at all, cycling ten variants of a blocked step list. The shape is jmm's: **log the refusal loudly, mark that move rejected, ask the bot again, repeat until the budget is gone.** - The observation grows `rejected`: the step lists this decision has already had turned down, per unit. It is the whole of the bot's memory that anything was refused. - `SdsClient.askMovement` loops. A legal path returns; a refused one is appended to `rejected` and the same question goes back. - `unit::propose` drops any proposal whose step list is on that list, after the sweep has measured the menu rather than before it. - The plan cache is keyed on the number of rejections as well, or the re-ask would be answered out of the cache without rebuilding anything. **This preserves "every order has exactly one possible author" rather than bending it.** Every proposal is still the bot's, still made on its own ranking, now made knowing what has been refused. The host contributes the refusal and nothing else. The two obvious alternatives both break it: a client walking a ranked list is choosing between the bot's options, and a client picking a hex is Princess by another name. ### Three things that are load-bearing - **The budget is a count, and the clock behind it is real.** jmm: *"we're trying to be deterministic, but there will always be a 'match clock'."* Determinism is the aim rather than an absolute - `MOVE_ATTEMPTS` is a fixed four so a match replays the same way on a loaded machine as an idle one - but the turn is spending real time whatever this file says, and the way that is handled is **not to hide it**. Every refusal is logged on its own line, so a decision that burned its budget leaves a trail of exactly what it tried instead of a silent stall. - **A pass ends the decision at once and is never retried.** `candidates::generate` offers nothing for a machine the rules will not move, and says so by passing. Retrying that would spin the budget on a unit that is correctly going nowhere - all 64 defaulted movement decisions on one vehicle bench were an immobilised unit. *"This move was refused"* and *"this machine does not move"* are different silences and the loop must not confuse them. - **Identity is the route, not the destination.** Two routes to one hex are not interchangeable - `pathfind_routes.jsonl` exists because a reachable set can be right while a route into it is refused - so rejecting the hex would throw away a legal way in along with the illegal one. ### One refusal answers for many, and that is measured rather than assumed jmm's constraint: *"if we have 1 rejected candidate we might actually have 100 backed up behind that."* A loop that rediscovers a blocked neighbourhood one candidate at a time spends its budget learning what the first refusal already said. So the refusal has to carry more than "this route". **What a refusal actually tells you**, read off 249 of them across three Mek corpora rather than from the field names: - `prohibited` is true **once in 249**, so "that hex is barred to this mover" is almost never the reason and cannot be the generalisation. - `class` is `MOVE_ILLEGAL` and `endPos` is `true` on every single one. The top-level fields do not discriminate at all. - The discriminating fact is in the per-step trace, and it is a strong one: **80% of refusals fail on their very first step.** That last one is the lever, and it generalises exactly rather than heuristically. `MoveStep.compileMove` walks a path in order and compiles each step from the one before, so a step's legality is a function of the prefix that reaches it: **every route sharing an illegal prefix is illegal for the same reason.** Excluding the prefix can therefore only drop routes MegaMek would also refuse - it can never cost a legal alternative, which is the property that makes it safe to do at all. Replaying 229 refusals with each rule shows what it buys: | exclusion rule | later refusals it pre-empts | |---|---| | the whole route | 33% | | the refused prefix | **62%** | So `Rejected` carries `refusedAt` beside the step list, and a refusal with no step-level verdict - an unknown step name, refused before MegaMek saw it - degrades to excluding that route alone. **And the honest bound: it is 62%, not 100%.** A budget of four attempts does not make a unit whose whole neighbourhood is illegal into one that moves; it makes it a unit that tried four genuinely different things and said so. The remaining 38% is candidate generation offering routes that fail for unrelated reasons, and no amount of retrying fixes that - which is what the per-rejection log lines are for. ### What it reports Each refusal is logged on its own line with the unit, the round, the attempt and MegaMek's own diagnosis, and each is recorded individually in the decision's `rejections`. A count would summarise away the step list, which is the only part that says which rule was missed - and this repository has just spent a night on records that summarised away the thing somebody needed. ### What a match's behaviour is made of jmm asked for a `rejected` count and a `never answered` count beside `answered`/`decisions`, "to get a full idea of what happened with behavior during a match". Two of the three were already here under other names; the third was hidden inside a number that could not tell two things apart. - **`rejected` counts individual rejections, not decisions that had one.** A decision may be refused four times, and the reason the loop exists is that one rejected candidate can have many behind it - so this can exceed `decisions` and is deliberately outside the outcome partition. It was `refusals`; the name is jmm's word and the semantics were already right. - **`neverAnswered` is a decision that ended with no accepted move**, derived as `unanswered + illegal + failed`. Derived rather than counted so it cannot drift from its parts. - **`passed` is not one of them.** A bot that declines because the rules will not move the machine *has answered*: `candidates::generate` offers nothing for an immobilised unit, and on one vehicle bench all 64 such decisions were exactly that. Counting it as never-answered would report the design working as a failure. `defaulted` could not make that distinction, and that is what this change is really about: **a reply that never came and a bot that deliberately passed both landed in it.** It is now the sum of `passed` and `unanswered`, kept so every existing reader means what it always meant, with the halves beside it. **`exhausted` is gone.** It was incremented on the same line as `illegal` and was always equal to it within a phase - a duplicate counter, written by the same change that added the one it duplicates, which is the defect the review had just caught in a different form. `answered + passed + neverAnswered == decisions`, and `test_the_outcomes_account_for_every_decision` reads the client's source and fails on any `tally.++` the partition does not know about. **That is the guard that matters**: an outcome added later without a counter would otherwise vanish into the gap rather than announce itself, which is precisely how two counters here once read zero forever and were believed. **Two counting faults the loop introduced and the review caught.** Neither would have thrown; both would have produced a plausible number. - `chosenActor` tallies `chose`, `differed` and `substituted`, and the loop calls it on every answer. A decision refused three times would have credited four choices against a `decisions` of one, and `chose / decisions` - the turn-order statistic - would have read past 100%. Counted on the first answer only: the retries are the host's doing, not the bot's pick. - The counter below. **One counter nearly shipped dead.** `rejected` and its neighbour were added to the tally and to `runstats.py`'s `COUNTS` and left out of the JSON the seat writes. Nothing failed: `runstats` reads counters out of a `Counter`, so a name the client never writes reads **0** for every match forever - the column would have said "re-asking never happens" and been believed. `tests/test_decision_record.py` now asserts every counter the stats expect is one the client writes, which is the same guard the record's own fields got and for the same reason. - [ ] Measure how many attempts a real match actually needs. If it is usually one, four is generous; if units routinely burn all four, the interesting question is why the menu's second and third entries are refused too. `recovered` in `sds stats` is the figure: rejections that were not fatal, `rejected - neverAnswered`.