diff --git a/CLAUDE.md b/CLAUDE.md index b3948cc..3e025d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,16 @@ path rankers and a chat command per tuning knob. ## Rules of the road +- **Never call Princess, and never fall back to it.** `SdsClient` extends + `BotClient`. A decision an SDS seat cannot answer takes an inert default that + is ours — stand still, hold fire, first legal hex — and is counted. The cost + is a worse bot early; the return is a match log where every order has exactly + one possible author. +- **MegaMek's rules yes, MegaMek's bot no.** `Board.isLegalDeployment` and + `WeaponAttackAction.toHit` answer "what does the game allow" and belong in the + observation. `BotClient.getStartingCoordsArray` ranks hexes by elevation and + damage — that is a tactical opinion, and inheriting one silently is the thing + this design exists to avoid. - **The observation is the interface.** `bridge/sds/Observation.java` decides what a bot may know. Adding a field is a deliberate act with a reason in the commit message, not a convenience. The whole point of the pipe is that a bot @@ -36,8 +46,8 @@ path rankers and a chat command per tuning knob. - **Run `sds control` after touching the harness.** Princess against itself must come out near 50/50. If it does not, the harness is biased and every number it has ever printed is suspect. -- **Report `answered`/`decisions` next to any win rate.** A bot that passed on - most decisions was Princess wearing a hat. +- **Report `answered`/`decisions` next to any win rate.** A bot that defaulted + on most decisions did not play the match it is credited with. - **No unit-count-only metrics.** BV remaining is the one that can see a bot getting better at trading. diff --git a/README.md b/README.md index 20f25c5..5a84048 100644 --- a/README.md +++ b/README.md @@ -67,21 +67,36 @@ harness first, bot second. ## How a bot plugs in A bot is a process. It reads newline-delimited JSON on stdin and writes it on -stdout — see [docs/PROTOCOL.md](docs/PROTOCOL.md). It may answer a decision or -pass, and passing means Princess plays that one. - -That last part is the trick that makes incremental work possible. `SdsClient` -extends `Princess` rather than `BotClient`, so a bot that only has an opinion -about movement passes on deployment, firing, physicals, artillery and morale, -and Princess covers them. An SDS-vs-Princess match then differs in exactly one -thing, and the win rate measures that thing. +stdout — see [docs/PROTOCOL.md](docs/PROTOCOL.md). It answers a decision, or it +passes and takes the harness's default. + +**No SDS decision is ever played by Princess.** `SdsClient` extends `BotClient`, +shares no code with Princess, and never falls back to it — not on a pass, a +timeout, or a crash. The reason is debugging: if a seat sometimes plays +Princess's move, no line in a match log tells you whose decision you are looking +at, and every investigation starts by working out whether the thing you are +staring at is even yours. + +The defaults are ours and deliberately inert — stand still, hold fire, deploy in +the first legal hex. A bot that answers nothing therefore stands where it landed +and is shot to pieces, which is the correct and legible outcome rather than a +respectable opponent wearing our name. + +The line between what may be borrowed and what may not: **MegaMek's rules yes, +MegaMek's bot no.** `WeaponAttackAction.toHit` is rules — every human player has +that number on screen before choosing — so the observation carries it. The hex +ranking in `BotClient.getStartingCoordsArray` is tactics, and is not called even +though `SdsClient` inherits it. Two bots ship, and neither is meant to be good: -- `bots/passthrough_bot.py` passes on everything. It is the control's control: - a passthrough seat is Princess with an extra process in the loop. -- `bots/random_bot.py` walks somewhere legal without looking at the enemy. It is - the floor. A bot that cannot beat it is not an improvement whatever it scores +- `bots/passthrough_bot.py` passes on everything, so its lance deploys, stands + still and never fires. That is the floor beneath the floor, and the fastest + check that the bridge is carrying decisions at all: if a real bot scores the + same as this, its answers are not arriving. +- `bots/random_bot.py` walks somewhere legal without looking at the enemy, + shoots everything at whatever it is most likely to hit, and never thinks about + heat. A bot that cannot beat it is not an improvement whatever it scores against Princess. ## Requirements diff --git a/bots/passthrough_bot.py b/bots/passthrough_bot.py index 3a696bf..4f84690 100755 --- a/bots/passthrough_bot.py +++ b/bots/passthrough_bot.py @@ -1,15 +1,17 @@ #!/usr/bin/env python3 """A bot that never decides anything. Passes on every request. -This is not a joke entry: it is the harness's control. A passthrough seat is -played entirely by Princess, so passthrough-vs-Princess is Princess against -itself with one extra process in the loop. If that match-up does not come out -near 50/50 over enough games, the difference is the harness - seat order, -deployment edge, the RNG, the bridge's latency - and not any bot. +Not a joke entry, and no longer a control against Princess: a pass now means +the harness's own default - stand still, hold fire, deploy in the first legal +hex - and never means Princess plays instead. Nothing in an SDS seat is ever +played by Princess. -Run the control before believing any result, and again whenever the harness -changes. A measuring stick nobody has checked against a known length is a -source of confident wrong numbers. +So this measures the floor beneath the floor. A lance that deploys, stands +where it landed and never fires is what "answered nothing" looks like on the +scoreboard, and it is the number every real bot must beat by a distance. It is +also the fastest way to check that the bridge is carrying decisions at all: if +a passthrough seat scores the same as a real bot, the real bot's answers are +not arriving. """ import json diff --git a/bots/random_bot.py b/bots/random_bot.py index 1d81082..a9073e4 100755 --- a/bots/random_bot.py +++ b/bots/random_bot.py @@ -6,10 +6,15 @@ against two ends of a range - Princess at the top and this at the bottom - and a change that cannot beat this one is not an improvement whatever its win rate against Princess happens to be that evening. -It does the least work that keeps its paths legal: it knows the board, it knows -which way it is facing, and it walks forward hex by hex until the next one would -be off the map, under water, or occupied. It has no idea where the enemy is. -That is the point. +It does the least work that keeps its orders legal. It walks forward hex by hex +until the next one would be off the map, under water, or occupied, with no idea +where the enemy is. It shoots everything it has at whichever enemy it is most +likely to hit, and never thinks about heat. It deploys wherever the first legal +hex is, pointed roughly at the middle of the map. + +None of that is good play, and none of it is borrowed. Every order it gives is +one of these thirty lines, which is the property that makes a match log worth +reading. Seeded from SDS_SEED so a match can be repeated. """ @@ -21,7 +26,7 @@ import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from hexes import translated # noqa: E402 +from hexes import distance, translated # noqa: E402 # Terrain a walking Mek should not stroll into. Not a rules implementation - # MegaMek decides legality and the host checks it - just enough to keep the @@ -74,6 +79,57 @@ class Bot: points += abs(target.get("level", 0) - source.get("level", 0)) return points + def on_deployment(self, message: dict) -> dict: + hexes = message.get("deployHexes", []) + if not hexes: + return {"kind": "pass"} + spot = self.rng.choice(hexes) + return { + "kind": "deploy", + "x": spot["x"], + "y": spot["y"], + "facing": self.facing_toward_centre(spot["x"], spot["y"]), + } + + def facing_toward_centre(self, x: int, y: int) -> int: + """The facing whose next hex is closest to the middle of the board. + + Six candidates, one distance each. Cheaper to write than an angle, and + it cannot disagree with the hex geometry the moves use. + """ + cx, cy = self.width // 2, self.height // 2 + best, best_distance = 0, None + for facing in range(6): + nx, ny = translated(x, y, facing) + d = distance(nx, ny, cx, cy) + if best_distance is None or d < best_distance: + best, best_distance = facing, d + return best + + def on_firing(self, message: dict) -> dict: + """Everything at one target: the one we are most likely to hit. + + No heat management at all, so this bot will shut itself down on a hot + machine. Left in deliberately - the floor should lose for reasons that + are easy to name. + """ + shots = message.get("shots", []) + if not shots: + return {"kind": "fire", "attacks": []} + best = {} + for shot in shots: + target = shot["target"] + current = best.get(target) + if current is None or shot["toHit"] < current: + best[target] = shot["toHit"] + target = min(best, key=lambda t: best[t]) + attacks = [ + {"weapon": s["weapon"], "target": target} + for s in shots + if s["target"] == target + ] + return {"kind": "fire", "attacks": attacks} + def on_observation(self, message: dict) -> dict: actor_id = message.get("actor") units = {u["id"]: u for u in message["units"]} @@ -130,7 +186,13 @@ def main() -> None: continue if kind != "observation": continue - reply = bot.on_observation(message) + phase = message.get("phase", "") + if phase == "DEPLOYMENT": + reply = bot.on_deployment(message) + elif phase == "FIRING": + reply = bot.on_firing(message) + else: + reply = bot.on_observation(message) reply["type"] = "action" reply["seq"] = message["seq"] sys.stdout.write(json.dumps(reply) + "\n") diff --git a/bridge/sds/Observation.java b/bridge/sds/Observation.java index 1445d9f..168be4b 100644 --- a/bridge/sds/Observation.java +++ b/bridge/sds/Observation.java @@ -1,5 +1,7 @@ package sds; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import com.fasterxml.jackson.databind.ObjectMapper; @@ -8,9 +10,13 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import megamek.common.Hex; import megamek.common.Player; +import megamek.common.ToHitData; +import megamek.common.actions.WeaponAttackAction; +import megamek.common.rolls.TargetRoll; import megamek.common.board.Board; import megamek.common.board.Coords; import megamek.common.equipment.WeaponMounted; +import megamek.common.force.Force; import megamek.common.equipment.WeaponType; import megamek.common.game.Game; import megamek.common.units.Entity; @@ -94,6 +100,87 @@ public final class Observation { return root; } + /** A movement decision: where does this unit go. */ + public ObjectNode movement(Game game, Player me, Entity actor, int seq) { + return base(game, me, actor, seq); + } + + /** + * A firing decision, with every shot the rules allow already worked out. + * + *
The to-hit numbers come from {@link WeaponAttackAction#toHit}, which is + * MegaMek's own rules code - range, movement, terrain, heat, damaged + * actuators, the lot. Handing a bot the answer to "what would this roll + * need" is not doing its thinking for it: every human player has that + * number on screen before they choose, and a bot forced to reimplement it + * 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 left entirely alone. + */ + public ObjectNode firing(Game game, Player me, Entity shooter, int seq) { + ObjectNode root = base(game, me, shooter, seq); + ArrayNode shots = root.putArray("shots"); + for (Entity target : game.getEntitiesVector()) { + if (target.getPosition() == null || target.isDestroyed()) { + continue; + } + Player owner = target.getOwner(); + if (owner != null && owner.getTeam() == me.getTeam() && me.getTeam() != Player.TEAM_NONE) { + continue; + } + if (target.getOwnerId() == me.getId()) { + continue; + } + for (WeaponMounted weapon : shooter.getWeaponList()) { + if (!weapon.canFire()) { + continue; + } + int weaponId = shooter.getEquipmentNum(weapon); + ToHitData toHit; + try { + toHit = WeaponAttackAction.toHit(game, shooter.getId(), target, weaponId, false); + } catch (Exception e) { + // A weapon/target pair the rules cannot even evaluate is not + // a shot. Skipped rather than reported as impossible, which + // would be a different and misleading claim. + continue; + } + if (toHit == null || toHit.getValue() == TargetRoll.IMPOSSIBLE + || toHit.getValue() == TargetRoll.AUTOMATIC_FAIL) { + continue; + } + ObjectNode shot = shots.addObject(); + shot.put("weapon", weaponId); + shot.put("target", target.getId()); + shot.put("toHit", toHit.getValue()); + shot.put("range", shooter.getPosition().distance(target.getPosition())); + WeaponType type = weapon.getType(); + shot.put("damage", type == null ? 0 : type.getDamage()); + shot.put("heat", type == null ? 0 : type.getHeat()); + } + } + return root; + } + + /** + * A deployment decision, with the legal hexes listed. + * + *
Listed in board order and not ranked. MegaMek's own bot sorts these by
+ * elevation and expected damage before choosing; that sort is a tactical
+ * opinion and does not belong in the thing that describes the world.
+ */
+ public ObjectNode deployment(Game game, Player me, Entity unit, List Extends {@link Princess} rather than {@code BotClient}, which is the whole
- * trick: {@code BotClient} has thirteen abstract methods - deployment,
- * physicals, artillery, morale, prephase, infantry-vs-infantry - and a new bot
- * has an opinion about roughly one of them. Inheriting from Princess means the
- * external bot answers the phases it wants and returns {@code pass} for the
- * rest, and Princess covers the tail.
+ * Nothing here inherits from Princess, and nothing here falls back to it.
+ * That is a hard rule, not a preference. A bot that quietly hands a decision to
+ * Princess produces a match log in which no line tells you who chose the move,
+ * and every debugging session after that starts by trying to work out whose
+ * mistake you are looking at. The whole reason for putting a bot behind a pipe
+ * is to know exactly what it did.
*
- * That also buys the experiment its control. With the bot answering movement
- * and passing on everything else, an SDS-vs-Princess match differs in exactly
- * one thing, and a win rate means something. Whole-bot-vs-whole-bot would
- * confound movement with gunnery, target selection and withdrawal, and no
- * number of matches would separate them again.
+ * So every decision has a default that is unmistakably ours, and every use of
+ * one is counted:
*
- * Known asymmetry. Princess runs every path it picks through a private
- * {@code performPathPostProcessing} - unjamming autocannons, evading, and other
- * end-of-path housekeeping. It is private, so a path from an external bot does
- * not get it. This costs SDS a little, not Princess, so a measured SDS win is
- * still a win; read a narrow SDS loss with it in mind.
+ * A bot that answers nothing therefore stands in its deployment hex and is
+ * shot to pieces, which is the correct and legible outcome. It is not a
+ * respectable opponent wearing our name.
+ *
+ * The line this class holds is MegaMek's rules yes, MegaMek's bot no.
+ * {@code Board.isLegalDeployment} and {@code WeaponAttackAction.toHit} are rules
+ * - they answer "what does the game allow" and every player needs them. The
+ * ranking inside {@code BotClient.getStartingCoordsArray} is tactics, and this
+ * class does not call it even though it inherits it.
*
* The wire is newline-delimited JSON on the process's stdin and stdout, one
- * document per line, described in {@code docs/PROTOCOL.md}. The process's stderr
- * is left attached to the host's, so a bot that crashes says why in the match log.
+ * document per line, described in {@code docs/PROTOCOL.md}.
*/
-public final class SdsClient extends Princess {
+public final class SdsClient extends BotClient {
private final List Note what is not overridden: {@code getEntityToMove}, so the
- * order units move in is still Princess's. Move order is a real tactical
- * decision and one SDS should own eventually; leaving it here for now keeps
- * the first comparison to one variable.
+ * Move order is ours too: the first unit that has not moved. Dull, and
+ * deliberately so - a defensible order is a real tactical decision, and
+ * until a bot asks for it, a rule you can state in one sentence beats an
+ * inherited heuristic nobody here chose.
*/
+ @Override
+ protected MovePath calculateMoveTurn() {
+ Entity mover = null;
+ for (Entity entity : getEntitiesOwned()) {
+ if (!entity.isDone() && entity.getPosition() != null
+ && getGame().getTurn() != null
+ && getGame().getTurn().isValidEntity(entity, getGame())) {
+ mover = entity;
+ break;
+ }
+ }
+ if (mover == null) {
+ return null;
+ }
+ return continueMovementFor(mover);
+ }
+
@Override
protected MovePath continueMovementFor(final Entity entity) {
- if (disabled || entity == null) {
- return super.continueMovementFor(entity);
+ if (entity == null) {
+ return null;
}
- decisions++;
+ Tally tally = tally("movement");
+ tally.decisions++;
+ MovePath standStill = new MovePath(getGame(), entity);
try {
- ObjectNode request = observation.observation(getGame(), getLocalPlayer(), entity, ++seq);
- JsonNode reply = exchange(request);
+ JsonNode reply = ask(observation.movement(getGame(), getLocalPlayer(), entity, ++seq));
if (reply == null) {
- passed++;
- return super.continueMovementFor(entity);
+ tally.defaulted++;
+ return standStill;
}
- String kind = reply.path("kind").asText("pass");
- if (!"move".equals(kind)) {
- passed++;
- return super.continueMovementFor(entity);
+ if (!"move".equals(reply.path("kind").asText(""))) {
+ tally.defaulted++;
+ return standStill;
}
MovePath path = buildPath(entity, reply);
if (path == null) {
- illegal++;
- return super.continueMovementFor(entity);
+ tally.illegal++;
+ return standStill;
}
- answered++;
+ tally.answered++;
return path;
} catch (Exception e) {
- failed++;
- System.err.println("[sds] " + getName() + " movement failed, falling back to Princess: " + e);
- return super.continueMovementFor(entity);
+ tally.failed++;
+ System.err.println("[sds] " + getName() + " movement failed, standing still: " + e);
+ return standStill;
+ }
+ }
+
+ @Override
+ protected void calculateFiringTurn() {
+ Entity shooter = getGame().getFirstEntity(getMyTurn());
+ if (shooter == null) {
+ return;
+ }
+ Tally tally = tally("firing");
+ tally.decisions++;
+ Vector The bot sends steps rather than a destination on purpose: a destination
- * would need a pathfinder on this side to reach it, and then the harness
- * would be making tactical decisions the bot is supposed to be making - how
- * to get there is most of what a move is. An empty step list is a
- * legal answer and means "stand still", which is different from passing.
+ * MegaMek's {@code PhysicalCalculator} would answer this well, and it is
+ * not in the princess package, so using it would even be defensible. It is
+ * still someone else's tactics appearing in our unit's turn, so: none, until
+ * the protocol carries physicals and a bot chooses them.
*/
- private MovePath buildPath(Entity entity, JsonNode reply) {
+ @Override
+ protected PhysicalOption calculatePhysicalTurn() {
+ return null;
+ }
+
+ @Override
+ protected Vector Unranked, in board order. {@code BotClient.getStartingCoordsArray}
+ * would return these sorted by elevation and expected damage, which is a
+ * tactical opinion, and inheriting one silently is the thing this class
+ * exists to avoid.
+ */
+ private List The deadline is not a nicety. A bot that hangs would hang the match,
- * and a harness that can be hung by the thing it is measuring cannot be left
- * to run a hundred games unattended. A bot that misses it once is passed
- * over; a bot that misses it is not killed, because the next decision may be
- * cheaper and a half-played match is worse evidence than a slow one.
- *
- * Returns null when the bot passed, timed out, or answered something this
- * cannot use - all of which mean the same thing to the caller: ask Princess.
+ * Null means "no usable answer" - passed, timed out, crashed, or answered
+ * the wrong question. Every caller turns that into its own default, and
+ * counts it. A bot that misses a deadline is not killed: the next decision
+ * may be cheaper, and a half-played match is worse evidence than a slow one.
*/
- private JsonNode exchange(ObjectNode request) throws IOException {
+ private @Nullable JsonNode ask(ObjectNode request) throws IOException {
if (!ensureStarted()) {
return null;
}
if (!boardSent) {
- // The board is static and large, so it goes once. It has to go
- // before the first observation, or a bot has units and nowhere to
- // put them.
write(observation.board(getGame().getBoard()));
boardSent = true;
}
@@ -201,7 +415,8 @@ public final class SdsClient extends Princess {
return null;
}
if (raw == null) {
- System.err.println("[sds] " + getName() + " closed its output; Princess plays the rest");
+ System.err.println("[sds] " + getName()
+ + " closed its output; every later decision takes the default");
disable();
return null;
}
@@ -209,9 +424,8 @@ public final class SdsClient extends Princess {
JsonNode reply = mapper.readTree(raw);
int got = reply.path("seq").asInt(-1);
if (got != want) {
- // A reply to an earlier question is worse than no reply: it is a
- // move for a unit that has already moved, and it would be applied to
- // whichever unit is asking now.
+ // A reply to an earlier question is worse than none: it is an order
+ // for a unit that has already acted.
System.err.println("[sds] " + getName() + " answered seq " + got
+ " when asked seq " + want + "; discarding");
return null;
@@ -234,7 +448,7 @@ public final class SdsClient extends Princess {
}
if (process != null) {
System.err.println("[sds] " + getName() + " bot process exited with "
- + process.exitValue() + "; Princess plays the rest");
+ + process.exitValue() + "; every later decision takes the default");
disable();
return false;
}
@@ -288,15 +502,36 @@ public final class SdsClient extends Princess {
}
}
- /** What this seat did, for the match result. */
+ /** What this seat did, in total and per phase. */
public ObjectNode stats() {
ObjectNode n = mapper.createObjectNode();
+ int decisions = 0;
+ int answered = 0;
+ int defaulted = 0;
+ int illegal = 0;
+ int failed = 0;
+ ObjectNode phases = mapper.createObjectNode();
+ for (Map.Entry
+ *
+ *
+ *