bayes for days
sds docs PROTOCOL.md
35 kB

The bot protocol #

Newline-delimited JSON, one document per line, on the bot process's stdin and stdout. The host writes; the bot answers. stderr is the bot's own, passed through to the match log.

The protocol is the product. Everything else in this repository — the JVM, the container, Princess — is scaffolding that can be replaced around it, and this document is the part that is meant to outlive them.

Why a pipe #

MegaMek's own bot is a client: it holds a live, mutable Game and reaches into it. That is why Princess cannot be replayed, cannot be unit-tested, cannot be run outside a game, and cannot be written in anything but Java. A bot behind a pipe has none of those properties fused to it. The cost is a serialisation step per decision; on a 4v4 that is well under a millisecond against the tens of milliseconds Princess spends thinking, so it does not show up.

No Princess, anywhere, ever #

SdsClient extends BotClient, not Princess, and no decision on an SDS seat is ever handed to Princess — not on a pass, not on a timeout, not on a crash.

This is the rule the rest of the design bends around, and it is about debugging rather than purity. If a seat sometimes plays Princess's move, then no line in a match log tells you whose decision you are looking at, and every investigation starts by trying to work out whether the thing you are staring at is even yours. Holding the rule costs a worse-playing bot early on and buys a match log that means exactly one thing.

The line between what may be borrowed and what may not: MegaMek's rules yes, MegaMek's bot no. Board.isLegalDeployment and WeaponAttackAction.toHit answer "what does the game allow" and every human player has them too. The ranking inside BotClient.getStartingCoordsArray is a tactical opinion, and is not called even though SdsClient inherits it.

Documents #

board — host to bot, once per match #

Sent before the first observation.

{"type": "board", "width": 16, "height": 17, "kinds": 24,
 "hexes": [{"x": 3, "y": 4, "level": 1, "terrain": {"woods": 1}, "kind": 2}]}

Sparse: a hex at level 0 with no terrain is omitted. Coordinates are MegaMek's zero-based (x, y), not the one-based numbers printed on a map sheet.

Terrain that changes mid-fight — fire, smoke, a collapsed building — is not tracked. A bot that cares must currently do without.

kind is which of the board's distinct hexes this is: hexes with the same terrain share one, kinds is how many there are, and kind 0 is the clear hex — which is what every hex the sparse message leaves out is. It exists so a unit's terrain answers can be one entry per kind rather than one per hex; a thousand-hex board is a few dozen kinds. A hex's level is not part of its kind, because neither answer reads it.

level and terrain are what a hex costs geometrically: crates/sds-core/src/pathfind.rs takes its floor from level less the water depth. What terrain costs, and whether a mover may enter it at all, is a property of the mover and is on the unit as terrain — see below.

observation — host to bot, before each decision #

{"type": "observation", "seq": 7, "round": 3, "phase": "MOVEMENT",
 "playerId": 1, "team": 2, "actor": 12, "eligible": [12, 14, 15],
 "units": [{"id": 12, "name": "Griffin GRF-1N", "ownerId": 1, "team": 2,
            "friendly": true, "x": 8, "y": 14, "facing": 0,
            "secondaryFacing": 0, "twists": [0, 1, 5], "elevation": 0,
            "weight": 55.0, "bv": 1272, "bvOriginal": 1387,
            "walkMp": 5, "runMp": 8, "jumpMp": 5,
            "movementMode": "BIPED", "maxElevationChange": 2,
            "terrain": {"mp": [0, 1, 2], "barred": [false, false, true]},
            "armor": 128, "armorMax": 144, "internal": 91, "internalMax": 91,
            "heat": 4, "heatCapacity": 10, "heatBuildup": 2,
            "explodableAmmo": true,
            "prone": false, "shutDown": false, "destroyed": false,
            "crippled": false, "done": false,
            "gunnery": 4, "piloting": 5,
            "role": "SNIPER", "forcePath": ["Alpha Company", "1st Lance"],
            "locations": [{"name": "CT", "armor": 20, "armorMax": 22,
                           "rearArmor": 6, "rearArmorMax": 8,
                           "internal": 18, "internalMax": 18}],
            "weapons": [{"id": 3, "name": "LRM 10", "location": "RT",
                         "heat": 4,
                         "short": 7, "medium": 14, "long": 21,
                         "rackSize": 10, "damagePerPacket": 1.0,
                         "packetsPerLocation": 5,
                         "infantryDamageClass": 3, "infantryTroopers": 2.0,
                         "minimum": 6, "avgDamageMinimum": 12.0,
                         "toHitModifier": -2,
                         "fireModes": [{"name": "Single", "shots": 1},
                                       {"name": "Ultra", "shots": 2}],
                         "currentMode": "Single",
                         "usable": true,
                         "ammo": {"type": "LRM", "munition": "Standard",
                                  "damagePerShot": 1, "rackSize": 10,
                                  "shotsLeft": 12}}]}]}

infantryTroopers is what one shot of this weapon takes off a plain foot platoon, and infantryDamageClass is which row of MegaMek's conventional-infantry table it resolves on. Neither is derivable from the damage figures beside them, and that is the point: classes 8 to 14 are n d6 and never read the weapon's damage at all, so a Flamer's two points remove 4d6 troopers where an AC/20's twenty remove two.

The trooper figure is measured, not computed from the class. A weapon's declared class is not always the row the shot resolves on — an LB 20-X firing cluster rounds declares class 0 and the server takes three troopers rather than two, because the handler puts cluster rounds on the ballistic-cluster row. So bridge/sds/InfantryConversion.java builds the handler Weapon.getCorrectHandler would build, points it at a real platoon and calls calcDamagePerHit, the same way packetsPerLocation below asks about grouping. The class is still sent because it decides which way one adjustment goes: a non-infantry attacker doubles the direct-fire classes against mechanised infantry and halves the burst ones. -1 is "not on that table", which is what MegaMek's own sentinel means and what 358 of its weapons report.

The platoon's own three — mechanized, damageDivisor and dugIn — are on the unit record and are sent for a ConvInfantry and nothing else. None is derivable from the rest of the record: a hover platoon and a hovercraft report the same movementMode, the divisor is a scalar no location pool can express, and nothing else says whether the platoon has dug in. scenarios/infantry/observed.txt is the recording all of this was settled against.

packetsPerLocation is how many packets share one hit-location roll. A rack does not roll a location per missile: an LB-X rolls per projectile, an SRM per missile, an LRM per group of five with the remainder, a HAG per five, and an ATM per five with the round setting the damage per missile. An ordinary autocannon firing Flak groups by five as well, which is the round rather than the gun — so this is a property of the pair, and it appears on each entry of modes as well as on the weapon.

It is measured, per weapon and per round, off the handler Weapon.getCorrectHandler says would resolve the shot; ./sds.sh cluster-dump records the whole table and docs/CLUSTERS.txt is the summary. Nothing matches on a name, so a rack nobody has met answers for itself — including Follow-The-Leader, which puts its entire salvo in one location and which no list of five families would have contained. Absent means one, which is what the damage model assumed for every weapon before the grouping was measured.

Where a name test is unavoidable, and what it costs when it is not #

packetsPerLocation replaced a guess with a property, and the same sweep that produced it asked whether brackets::Excluded — the list of weapons this bot will not fire — could stop matching on names too. It cannot, entirely, and the negative result is worth recording so the next person does not repeat it.

An anti-missile system is excluded because it fires in a phase this bot does not model. That exclusion cannot be moved onto a property: MegaMek resolves an Anti-Missile System through AmmoWeaponHandler and a Laser AMS through EnergyWeaponHandler, the same generic handlers an ordinary autocannon and an ordinary laser use. MegaMek's own taxonomy does not separate them, and nothing else on the wire does either. So that exclusion keeps a name test, and the name test is principled rather than lazy.

The same sweep showed what a name test costs when it is not exact. Matching substrings rather than whole words, bomb caught Bombast Laser — a twelve-point Mek weapon the bot then refused as aerospace ordnance — and tag caught vin-tag-e, reporting five infantry weapons as designators. A name test earns its place only where a property genuinely cannot be had, and it has to be exact even there.

avgDamageMinimum is the fourth damage figure, and it exists because three brackets cannot carry one shape. A weapon whose damage changes at a bracket boundary is already expressed — a Snub-Nose PPC reads 10/8/5 and a VSP laser 9/7/5, and a sweep of every weapon MegaMek has finds no gap for either. A Thunderbolt halves inside its minimum range, which is within the short bracket, so it needs a figure of its own; an artillery cannon reads zero there, which is the same statement more strongly. Absent means the weapon carries no separate figure and its bracket's damage applies at every hex.

toHitModifier is the weapon's own to-hit modifier, from EquipmentType.getToHitModifier: -2 for a pulse laser, +1 for a Clan heavy laser, absent for everything else. It is not a range term and not gunnery. The shots list already has it — those numbers are WeaponAttackAction.toHit — so the reader that needs it is a bot projecting a to-hit of its own, in the movement phase.

fireModes is the rates of fire the mount offers, and appears only when one of them throws more than one shot: Single/Ultra for an Ultra autocannon, Single through 6-shot for a Rotary AC/5. shots is Mounted.getNumShots(type, mode, false). These are not the ammunition alternatives modes carries — one declaration, more projectiles, rather than a different projectile — and currentMode says which the mount is set to now.

terrain is what this board's hexes cost this mover and which of them are shut to it, one entry per board kind, in kind order. mp is Hex.movementCost(entity) — MegaMek's own per-mover branch, so a tracked tank pays two for the swamp a Mek pays one for and a hovercraft pays nothing for either. barred is Entity.isLocationProhibited, which is the half a cost model cannot express: heavy woods to a tracked tank, any woods to a hovercraft, ultra-heavy woods to a Mek, water at any depth to a battle armor squad. Every one of those hexes costs less than the mover's walk allowance, so a bot that reads only mp proposes the hex and the server answers with a refused move.

The answers are per whole hex and not per terrain, because they do not decompose: water bars a squad at any depth, and water with ice on it does not. A unit with no terrain — an observation recorded before the field existed — is read as the ported biped Mek column, which is what every bot had before. Movement is the same rule the weapons follow: a mover is its properties, never its name, and a movement mode nobody has thought about comes out right because the question was put to it.

locations is armour and structure where damage actually lands. The totals above it cannot answer a fire decision — a shot hits one location, so "does this allocation open the centre torso" needs the centre torso. rearArmor and rearArmorMax appear only on the locations the rules give rear armour: centre and side torsos. Every number is a count; MegaMek's negative armour states (ARMOR_NA, ARMOR_DOOMED, ARMOR_DESTROYED, which are -1, -2 and -3) are converted to zero, and a location that is gone carries "destroyed": true instead. A location the unit does not have is left out.

A weapon is its properties, never its name #

Every property on the wire is one the bot handles, and an unknown weapon works completely. Nothing here matches on a weapon's name, and nothing should: the name is for a person reading a log. A weapon arrives as brackets, damage, heat, rack size, a mount and a to-hit modifier, and a weapon nobody has thought about is scored correctly because those fields are the whole of what a decision reads.

That is not an aspiration; it is what the field list is for. When something a weapon does is not in the fields, the fix is to put it on the wire — measured out of MegaMek, per weapon, like every other rule here — rather than to add a branch that recognises it. The failure mode this avoids is a table of names that is right about the weapons somebody listed and silently wrong about the rest, and wrong in the direction that looks like it is working.

Genuine special cases are rare and are named as such. A handful of weapons break the rules rather than sitting at an unusual point in them - a Mek Taser disables rather than damages, an Ultra or Rotary autocannon has a rate that is a choice - and those have epics of their own (plan/exotic-weapons.md, plan/fire-modes.md). Everything outside that handful is a property, and the test of a new field is whether it lets an unlisted weapon answer for itself.

The cluster grouping is the worked example. A rack does not roll one hit location per missile: an LB-X rolls per projectile, an SRM per missile, an LRM per group of five with the remainder, a HAG per five, and an ATM multiplies damage per missile before grouping by five. Five families, five answers, and a sixth that nobody has met yet. The property is "how many packets share a location roll", learned from MegaMek per weapon; the alternative is five ifs and a bug waiting for the next rack.

A weapon's location is where it is bolted on, by MegaMek's own abbreviation — the same names locations uses. The mount is what decides which hexes a weapon bears on: Mek.getWeaponArc reads it, and Mek.isSecondaryArcWeapon is false for the legs, so a leg weapon fires along the unit's facing and a torso twist does not move it while an arm or torso weapon follows the twist. rearMounted is sent only when true and beats the location outright: the weapon fires into the rear arc wherever it sits.

Two readings on the bot's side, and they are not the same. A weapon with no location — an observation recorded before the field existed — is read as a centre-torso weapon, which fires forward and follows the twist like every mount but the arms and the legs. A location the bot does not model is read as firing forward and never following the twist: MegaMek has locations a biped does not, and the one that exists today is a tripod's centre leg (CL), which is a leg. Reading it as a centre torso would hand a leg the torso twist.

ammo is what is in the tube, and is absent for an energy weapon and for an empty rack. A launcher's damage is not a property of the launcher: an SRM-6 throws two points a missile with standard ammo and none with Inferno, which deals heat instead. damagePerShot is per missile for a cluster rack.

The three heat fields are not interchangeable and one of them has been misread before. heat is the scale as it stands, carried from last turn. heatBuildup is Entity.heatBuildup: what this turn has already banked — running, jumping, an engine crit — and not yet applied. heatCapacity is Entity.getHeatCapacity(), which is dissipation, what the sinks shed in the heat phase. It is not a ceiling: the shutdown, to-hit and ammunition thresholds are fixed points on the scale (14, 18, 22, 26, 30) and have nothing to do with it. The bot's Unit::end_of_turn_heat is the only thing that should combine them.

explodableAmmo is whether the unit carries ammunition a heat roll could set off. It is MegaMek's own set, taken from TWGameManager.explodeAmmoFromHeat: an undestroyed ammo bin that reports EquipmentType.isExplosive, still has hittable shots, and is not a coolant pod, vehicle or heavy flamer ammo, or a bin loaded with coolant munitions. False for an energy boat, which is a real answer and not a missing one.

role is MegaMek's own role for the design and forcePath is the unit's place in MegaMek's force tree, outermost first. Both are data the game already keeps, and both are hints rather than instructions — see HIERARCHY.md for why a role must never bind a decision.

eligible is every unit of ours that may take this turn, ids only; each is described in units like any other. Which of a side's units acts next is a real decision MegaMek's turn structure hands to the player — turn order is initiative-based and alternates between sides, so the choice is "which of ours goes now" rather than free ordering of the round. It is present in the movement and firing phases and absent in deployment, where MegaMek's own ordering decides and there is no choice to offer.

The set is Game.getFirstEntityNum(GameTurn) widened from "the first" to "all of them" — MegaMek's own enumeration, filtered by nothing but GameTurn.isValidEntity — so eligible[0] is the host's own pick by construction. isValidEntity is the whole rule and it is what the game options act through: it checks ownership against the turn's player, isSelectableThisTurn(), and, in movement, inf_move_later and protos_move_later. A SpecificEntityTurn overrides it to admit exactly one unit, which is the case where there is genuinely nothing to choose.

actor is the unit the host will act on if the bot does not choose one. It is a default, not a fact: a reply naming a unit from eligible overrides it. Name anything else and the host refuses it, falls back to actor, and counts a substituted — a silently substituted unit would make the log say one machine moved while another did.

facing is where the hull points and secondaryFacing is where the torso points. They differ whenever a unit has twisted, and the arc every weapon but a leg mount bears through is measured off the secondary facing. twists is every secondary facing the unit could still declare, ascending: MegaMek's own answer, from Entity.canChangeSecondaryFacing and Entity.isValidSecondaryFacing, which already fold in prone, bracing, having twisted once already, the no_twist and ext_twist quirks, and a chassis with no torso to turn. A unit that cannot twist carries the one facing it is at.

In the firing phase shots is priced for every unit in eligible, and each shot names the shooter it belongs to. A weapon id is meaningless without the unit it is mounted on: equipment numbers are per unit and mixed, so number 7 is an autocannon on one Mek and a ton of ammo on the next. A shot with no shooter is an observation older than this field, where every shot was the actor's.

units is every unit with a position, both sides: there is no fog of war, because MegaMek's own client has none and a bot that pretended otherwise would be playing a different game than the human across the table.

Undeployed and removed units are omitted rather than sent with a null position.

action — bot to host #

{"type": "action", "seq": 7, "kind": "move", "unit": 14, "steps": ["TURN_LEFT", "FORWARDS", "FORWARDS"]}
{"type": "action", "seq": 7, "kind": "fire", "attacks": [{"weapon": 3, "target": 9, "mode": "Ultra"}], "secondaryFacing": 1}
{"type": "action", "seq": 7, "kind": "deploy", "x": 4, "y": 16, "facing": 0}
{"type": "action", "seq": 7, "kind": "pass"}

unit names which of ours the action is for, and must be one of the observation's eligible ids. Leave it out and the host uses actor.

An attack may carry mode, one of the mount's fireModes names. Leave it out and the mount is left where it is. A mode the mount does not offer is refused, counted as illegal and printed, and costs that one shot — the same treatment a torso twist the rules will not take gets, except that a bad twist costs the whole volley and a bad mode costs one weapon.

An action may also carry turn, the turn-order record: which units the force planned for, which it picked and at what value, and the replan that produced those orders. The host copies it onto the decision log line. Identities and values only — a feature vector per eligible unit per turn would multiply a log that is already megabytes for a 2v2.

{"turn": {"phase": "movement", "eligible": [12, 14], "chose": 14,
          "value": 0.81, "differed": true,
          "considered": [{"unit": 14, "label": "close on 9", "value": 0.81},
                         {"unit": 12, "label": "hold the ridge", "value": 0.10}],
          "replan": {"settled": [9], "reason": "enemy 9 settled"},
          "replans_this_round": 2}}

An action may also carry rationale, any JSON the bot likes. The host does not read it and writes it to the decision log. It may also carry training, an object whose keys the host copies onto the decision log line — see below. A hierarchy whose reasoning cannot be read back is a hierarchy nobody can debug, which is the original complaint against Princess; the SDS bot puts each force's stance, what changed its mind, and every proposal it rejected in there.

  • seq must echo the observation's. A mismatched reply is discarded, because a stale answer is a move for a unit that has already moved.
  • kind: "move" — steps are megamek.common.enums.MoveStepType names. An empty list is legal and means "stand still", which is not the same as passing.
  • kind: "pass" — take the harness's default for this decision. The defaults are ours, listed below, and are deliberately inert rather than competent: a bot that passes on everything stands in its deployment hex and is shot to pieces. Passing is how a bot that only handles movement declines to have an opinion about firing, and the cost of that is visible on the scoreboard rather than hidden behind a second bot playing well on its behalf.

Steps rather than a destination, on purpose. A destination would need a pathfinder on the host side, and how a unit gets somewhere — which facing it ends on, what it walks through, whether it jumps — is most of what a move is. Handing that to the harness would mean the harness is playing.

Every one of these is counted and reported in the match result. None of them ends the match: a bot that fails half its decisions still finishes, and the counters are what say so.

The decision log #

Each SDS seat appends one JSON line per decision to <run-dir>/<match-tag>-<seat>.decisions.jsonl, next to the match result:

{"seat": "North", "seq": 7, "round": 3, "phase": "MOVEMENT", "actor": 12,
 "eligible": [12, 14], "actedOn": 14, "turn": { ... },
 "outcome": "answered", "taken": "[MovePath] Length: 3; To: Coords (7, 2)",
 "action": {"kind": "move", "steps": ["FORWARDS"]}, "rationale": [ ... ],
 "positions": [{"id": 12, "name": "Griffin GRF-1N", "friendly": true,
                "x": 7, "y": 2, "facing": 3, "done": false, "destroyed": false}],
 "unit": 12,
 "candidates": [
   {"label": "close on Griffin GRF-1N",
    "phi": {"expected_damage": 0.41, "p_kill": 0.02}, "value": 0.63},
   {"label": "hold position",
    "phi": {"expected_damage": 0.10, "p_kill": 0.00}, "value": 0.21}],
 "chosen": 0,
 "learnable": ["expected_damage", "p_kill"],
 "local": ["damage_lead"]}

outcome is the same word the counters below are named after, and taken is what the host actually did — which is not the same claim as action, because a path the bot sent and the host rejected leaves the unit standing still. The gap between those two is the entire content of illegal and defaulted.

positions is where every unit on the board stood when the question was asked, both sides, from the same observation the bot was given. It is the host's record rather than anything the bot believed, and it is what lets a reader say where an enemy actually went next to where the bot thought it might go. A unit with no position - undeployed, or off the board - is not in the list.

eligible is the set the host offered and actedOn is the unit that actually acted; actor is still what the host would have picked on its own. The three together are what tell "we sent the wrong unit" apart from "no other unit could have gone". turn is the bot's own record of the choice, copied through untouched.

The fields from unit onward are the training row, and they are present only when the bot sent a training object on its reply. They are what a fit reads:

  • candidates — every option the bot measured, not only the one it took. A fit needs the alternatives; a record of the winner alone has nothing to learn from, because there is nothing the winner beat.
  • phi — feature name to normalised value, per candidate. Names are exactly the ones in sds_core::features. A feature that was not measured is omitted rather than written as null.
  • value — the dot product the bot ranked with. Nothing is multiplied onto it, so a reader can recompute it from phi and the weights and check the corpus against what was actually played.
  • chosen — index into candidates.
  • learnable and local — the feature names by normalisation class. A local feature is min-maxed across this decision's own candidates, so it ranks a menu and means nothing across two decisions; anything fitting weights must drop those columns. Naming them here is how that guarantee reaches a reader that does not have Rust's type system.

The host copies the keys of training onto the line rather than nesting them, so one decision is one flat document. round and phase are already on the line and are not repeated inside the row.

Written from SdsClient and flushed per line, so the file can be read while the match is still being played. sds view <run-dir> renders one as HTML, and sds watch <run-dir> serves it on loopback while it is still being appended to, tailing by byte offset.

The directory is the one named by --log-dir, defaulting to wherever --out writes the result. The bot process is told about it, and about which match it is playing, through the environment:

variable set by meaning
SDS_LOG_DIR SdsClient where this run's logs go
SDS_MATCH_TAG SdsClient the match tag, so a file the bot writes itself can be joined to the result
SDS_SEAT SdsClient this seat's name
SDS_SEED the harness the seed a stochastic bot must be repeatable from
SDS_IMITATE the harness, on bench --imitate write the imitation corpus below

The imitation corpus #

<run-dir>/<match-tag>-<seat>.imitation.jsonl, written by the bot rather than by the host, and only when SDS_IMITATE is set. One line per opposing move the bot could reconstruct from two consecutive observations of one movement phase:

{"round": 3, "phase": "MOVEMENT", "owner": 1, "observer": "North",
 "unit": 7,
 "candidates": [
   {"label": "close on Griffin GRF-1N", "phi": { ... }, "value": 0.63},
   {"label": "observed move", "phi": { ... }, "value": 0.11}],
 "chosen": 1,
 "learnable": [ ... ], "local": [ ... ],
 "policy": "observed"}

The training row is the same schema, so a fit reads it with no new parsing. Two fields are not on a decision-log line:

  • owner — the id of the player whose move this was. The observation carries player ids and no names; the match result carries both, and sds train resolves the one to the other so the row is labelled with that player's outcome. observer is the seat that watched, and is not the author.
  • policy — argmax on the bot's own rows, observed here. On an observed row chosen is somebody else's move and value is what our weights thought of it, so chosen is deliberately not the argmax. Nothing downstream can tell a corpus check from the point of the row without being told.

Separate file and separate flag on both sides (bench --imitate, train --imitation): what it teaches is another bot's opinion, faults included. docs/PRINCESS.md and plan/training.md say which faults.

The defaults #

decision default
movement stand still — an empty path
firing declare no attacks
deployment the first legal hex, facing the middle of the board
physical attacks declare no attack
artillery, morale, prephase none, always

Physical attacks are worth a note: MegaMek's PhysicalCalculator would answer them well and does not live in the princess package, so using it would even be defensible. It is still somebody else's tactics showing up in our unit's turn, so a bot that declines gets no attack rather than MegaMek's choice.

Every one of these is counted and reported in the match result. None of them ends the match: a bot that fails half its decisions still finishes, and the counters are what say so.

what host does counter
replies pass takes the default defaulted
sends an illegal path takes the default illegal
sends an unknown step name takes the default illegal
names an unknown weapon or a dead target drops the whole declaration illegal
asks to deploy outside the legal hexes uses the first legal hex illegal
answers the wrong seq discards, takes the default defaulted
takes longer than --timeout-ms takes the default, keeps the bot defaulted
names a unit that cannot act this turn acts on actor instead substituted
closes stdout, or crashes defaults for the rest of the match disabled

substituted is not defaulted: the bot did answer, and the answer was refused rather than missing. Three more counters ride beside it, per phase and in total: eligible is the eligible-set sizes summed over decisions, so eligible / decisions is the mean size of the set the bot chose from and a mean of one means there was nothing to choose; chose is how often the bot named a unit the game accepted; and differed is how often that was not the host's own first-eligible pick, which is the number that says the choice is being used rather than merely offered.

Read answered against decisions before reading any win rate. A bot that answered 10% of its decisions and "won" did not play the match — the defaults did, and they are inert.

Phases the bot may answer #

Deployment, movement, firing and physical attacks. Artillery and morale are declined by the harness and are not on the wire.

Which unit acts next is the bot's: the eligible set travels on the observation and the reply may name one of it. The first eligible unit stays as the host's default, so a bot that does not name one still plays.

Phase-specific fields #

observation carries extra fields depending on the phase.

  • FIRING adds shots: every shot the rules permit, one object per shot. Shots that are impossible or automatic failures are left out. A physical attack rides here too, carrying a nested physical; bridge/sds/Observation.java is what writes the fields, and is the list.

    The list is priced once per legal torso twist, and secondaryFacing says which one a shot belongs to. They are alternatives. A twist is a unit-level choice — every secondary-arc weapon fires along the facing that is declared — so one group is taken and the shots in the others do not exist. A reader that pools them offers the same tube several times. A shot with no secondaryFacing is an observation older than this field, where everything was priced at the facing the unit was already at.

    A fire action may carry secondaryFacing: the torso facing to declare before the guns, sent as MegaMek's own TorsoTwistAction. It is optional and absent means "leave the torso alone". It is a request, not an instruction — the host puts it through canChangeSecondaryFacing and isValidSecondaryFacing and, if the rules refuse it, counts an illegal, prints it, and holds fire rather than firing a volley priced at a facing the unit will not be standing at.

    Handing a bot the to-hit number is not doing its thinking. Every human player has that number on screen before choosing, and a bot made to reimplement range, movement, terrain, heat and actuator damage would be measured on how well it copied a rulebook rather than on how it fights. Which shots to take, and whether to take any, is untouched.

  • DEPLOYMENT adds deployHexes and deployUnit.

    deployHexes is the legal hexes, in board order, unranked, each {x, y, elevation}. MegaMek's bot sorts these by elevation and expected damage before choosing; that sort is a tactical opinion and does not belong in a document whose job is to describe the world. The elevation is not a ranking but half of the answer: a hex is legal at an elevation, a Mek wading in depth-1 water stands at -1, and a unit put down at an elevation the rules refuse cannot move for the rest of the match.

    deployUnit is the machine being put down, described like any other unit. It is not in units and cannot be - that list is everything with a position - so its x and y are -1: a machine that has not been put down is not standing anywhere. Everything else about it is true.

The route not taken #

A bot could skip the JVM entirely and speak MegaMek's own protocol. That protocol is Java native serialisation (NativeSerializationMarshaller), and Rust can already read it — jaded and java-serialization both parse the stream, and MegaMek is unusually amenable: two custom writeObject implementations in the whole tree, and MovePath marks its game and entity fields transient, so an outbound move is a Vector<MoveStep> and some scalars rather than the object graph.

Nothing writes that format from Rust, so it would be a bespoke encoder pinned to MegaMek's serialVersionUIDs and field layouts. Entity alone is 370 fields. The adapter's coupling is to MegaMek's API, which breaks at compile time; an encoder's coupling is to its wire format, which breaks silently at runtime and looks like a bot playing badly.

Worth revisiting if the JVM ever becomes the bottleneck. The documents above do not change if it does — that is the point of writing them down separately from the thing that currently produces them.