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.
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
isElevationValidrefuses, MegaMek refuses every path out of the hex for the rest of the match. - Carried MP.
MovePath.getMpUsedstarts atEntity.mpUsedrather 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.
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.askMovementloops. A legal path returns; a refused one is appended torejectedand the same question goes back.unit::proposedrops 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_ATTEMPTSis 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::generateoffers 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.jsonlexists 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:
prohibitedis true once in 249, so "that hex is barred to this mover" is almost never the reason and cannot be the generalisation.classisMOVE_ILLEGALandendPosistrueon 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.
rejectedcounts 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 exceeddecisionsand is deliberately outside the outcome partition. It wasrefusals; the name is jmm's word and the semantics were already right.neverAnsweredis a decision that ended with no accepted move, derived asunanswered + illegal + failed. Derived rather than counted so it cannot drift from its parts.passedis not one of them. A bot that declines because the rules will not move the machine has answered:candidates::generateoffers 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.<x>++ 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.
chosenActortallieschose,differedandsubstituted, and the loop calls it on every answer. A decision refused three times would have credited four choices against adecisionsof one, andchose / 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.