# 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. ```json {"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 ```json {"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 `if`s 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](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 ```json {"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. ```json {"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 `/-.decisions.jsonl`, next to the match result: ```json {"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 ` renders one as HTML, and `sds watch ` 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 `/-.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: ```json {"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`](https://crates.io/crates/jaded) and [`java-serialization`](https://lib.rs/crates/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` 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 `serialVersionUID`s 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.