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.
seqmust 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"—stepsaremegamek.common.enums.MoveStepTypenames. 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 insds_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 fromphiand the weights and check the corpus against what was actually played.chosen— index intocandidates.learnableandlocal— the feature names by normalisation class. Alocalfeature 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, andsds trainresolves the one to the other so the row is labelled with that player's outcome.observeris the seat that watched, and is not the author.policy—argmaxon the bot's own rows,observedhere. On anobservedrowchosenis somebody else's move andvalueis what our weights thought of it, sochosenis 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.
-
FIRINGaddsshots: 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 nestedphysical;bridge/sds/Observation.javais what writes the fields, and is the list.The list is priced once per legal torso twist, and
secondaryFacingsays 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 nosecondaryFacingis an observation older than this field, where everything was priced at the facing the unit was already at.A
fireaction may carrysecondaryFacing: the torso facing to declare before the guns, sent as MegaMek's ownTorsoTwistAction. It is optional and absent means "leave the torso alone". It is a request, not an instruction — the host puts it throughcanChangeSecondaryFacingandisValidSecondaryFacingand, if the rules refuse it, counts anillegal, 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.
-
DEPLOYMENTaddsdeployHexesanddeployUnit.deployHexesis 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.deployUnitis the machine being put down, described like any other unit. It is not inunitsand cannot be - that list is everything with a position - so itsxandyare-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.