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 legal, int seq) { + ObjectNode root = base(game, me, unit, seq); + ArrayNode hexes = root.putArray("deployHexes"); + for (Coords c : legal) { + ObjectNode hex = hexes.addObject(); + hex.put("x", c.getX()); + hex.put("y", c.getY()); + } + return root; + } + /** * The state a bot decides from. * @@ -104,7 +191,7 @@ public final class Observation { * @param seq the request number; the bot must echo it, which is what keeps * a slow bot's late answer from being read as an early one */ - public ObjectNode observation(Game game, Player me, Entity actor, int seq) { + private ObjectNode base(Game game, Player me, Entity actor, int seq) { ObjectNode root = mapper.createObjectNode(); root.put("type", "observation"); root.put("seq", seq); @@ -171,6 +258,27 @@ public final class Observation { u.put("piloting", e.getCrew().getPiloting()); } + // The role MegaMek's own unit file gives the design - SNIPER, BRAWLER, + // SCOUT. Data, not tactics: it says what the machine was built for, and + // a bot is free to disagree with it. See sds-core's role module for why + // it has to be free to. + u.put("role", e.getRole() == null ? "UNDETERMINED" : e.getRole().name()); + + // Where the unit sits in the force tree, outermost first: company, then + // lance. This is the structure the hierarchy reasons over, and MegaMek + // already keeps it - there is nothing to invent, only to pass on. A + // scenario that declares no forces sends an empty list, and a bot puts + // everyone in one bucket. + ArrayNode path = u.putArray("forcePath"); + Force force = game.getForces().getForce(e.getForceId()); + List names = new ArrayList<>(); + while (force != null) { + names.add(force.getName()); + force = force.isTopLevel() ? null : game.getForces().getForce(force.getParentId()); + } + Collections.reverse(names); + names.forEach(path::add); + ArrayNode weapons = u.putArray("weapons"); List list = e.getWeaponList(); for (WeaponMounted w : list) { diff --git a/bridge/sds/SdsClient.java b/bridge/sds/SdsClient.java index 8b9eaf7..3b1f01c 100644 --- a/bridge/sds/SdsClient.java +++ b/bridge/sds/SdsClient.java @@ -7,7 +7,11 @@ import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Vector; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -18,39 +22,53 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; -import megamek.client.bot.princess.Princess; +import megamek.client.bot.BotClient; +import megamek.client.bot.PhysicalOption; +import megamek.common.actions.EntityAction; +import megamek.common.actions.WeaponAttackAction; +import megamek.common.annotations.Nullable; +import megamek.common.board.BoardLocation; +import megamek.common.board.Coords; import megamek.common.enums.MoveStepType; +import megamek.common.event.player.GamePlayerChatEvent; import megamek.common.game.Game; import megamek.common.moves.MovePath; import megamek.common.units.Entity; /** - * A seat played by an external process. + * A seat played entirely by an external process. * - *

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 command; private final long timeoutMillis; @@ -67,14 +85,25 @@ public final class SdsClient extends Princess { private boolean disabled = false; private int seq = 0; - // What actually happened, reported in the match result. A bot that quietly - // passes on every decision and one that plays well are the same win rate - // against a weak opponent; these are what tell them apart. - int decisions = 0; - int answered = 0; - int passed = 0; - int illegal = 0; - int failed = 0; + // What actually happened, per phase. A win rate is unreadable without these: + // a bot that defaulted on most of its decisions did not play the match it + // won. Split by phase because "the bot answered 90% of its decisions" hides + // "and every one it missed was a firing decision" - which is the question + // you are actually asking when a bot loses a fight it should have won. + private final Map tallies = new LinkedHashMap<>(); + + /** One phase's worth of counters. */ + private static final class Tally { + int decisions; + int answered; + int defaulted; + int illegal; + int failed; + } + + private Tally tally(String phase) { + return tallies.computeIfAbsent(phase, k -> new Tally()); + } public SdsClient(String name, String host, int port, List command, long timeoutMillis, Path logDir) { @@ -84,59 +113,249 @@ public final class SdsClient extends Princess { this.logDir = logDir; } + // ---- Phases the bot may answer ----------------------------------------- + /** - * Movement, when the bot wants it. + * Which of our units moves next, then where it goes. * - *

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 attacks = new Vector<>(); + try { + JsonNode reply = ask(observation.firing(getGame(), getLocalPlayer(), shooter, ++seq)); + if (reply == null || !"fire".equals(reply.path("kind").asText(""))) { + tally.defaulted++; + } else { + int rejected = 0; + for (JsonNode shot : reply.path("attacks")) { + int weaponId = shot.path("weapon").asInt(-1); + int targetId = shot.path("target").asInt(-1); + if (getGame().getEntity(targetId) == null || shooter.getEquipment(weaponId) == null) { + rejected++; + continue; + } + attacks.add(new WeaponAttackAction(shooter.getId(), targetId, weaponId)); + } + if (rejected > 0) { + // Counted, not silently dropped. A bot shooting at a unit + // that is already dead is a bug in the bot, and a harness + // that hides it is a harness that trains you to write one. + tally.illegal++; + System.err.println("[sds] " + getName() + " named " + rejected + + " attacks with an unknown weapon or target"); + } else { + tally.answered++; + } + } + } catch (Exception e) { + tally.failed++; + System.err.println("[sds] " + getName() + " firing failed, holding fire: " + e); } + sendAttackData(shooter.getId(), attacks); } + @Override + protected void calculateDeployment() { + Entity unit = getGame().getFirstEntity(getMyTurn()); + if (unit == null) { + return; + } + Tally tally = tally("deployment"); + tally.decisions++; + List legal = legalDeployment(unit); + if (legal.isEmpty()) { + // The server asked us to deploy somewhere we can find nothing legal. + // Nothing useful to send; log it loudly rather than guess. + tally.failed++; + System.err.println("[sds] " + getName() + " found no legal deployment hex for " + + unit.getShortName()); + return; + } + + Coords where = legal.get(0); + int facing = facingTowardCentre(where); + try { + JsonNode reply = ask(observation.deployment( + getGame(), getLocalPlayer(), unit, legal, ++seq)); + if (reply != null && "deploy".equals(reply.path("kind").asText(""))) { + Coords asked = new Coords(reply.path("x").asInt(-1), reply.path("y").asInt(-1)); + if (legal.contains(asked)) { + where = asked; + facing = Math.floorMod(reply.path("facing").asInt(facing), 6); + tally.answered++; + } else { + tally.illegal++; + System.err.println("[sds] " + getName() + " asked to deploy " + + unit.getShortName() + " to " + asked + ", which is not a legal hex"); + } + } else { + tally.defaulted++; + } + } catch (Exception e) { + tally.failed++; + System.err.println("[sds] " + getName() + " deployment failed, using first legal hex: " + e); + } + deploy(unit.getId(), where, unit.getBoardId(), facing, 0, List.of(), false); + } + + // ---- Phases we always decline, explicitly ------------------------------- + /** - * Turn a list of step names into a path, or null if the result is not a move - * MegaMek would accept. + * No physical attacks, ever, for now. * - *

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 calculateArtyAutoHitHexes() { + return new Vector<>(); + } + + @Override + protected void calculatePreEndDeclarationsTurn() { + sendAttackData(getGame().getFirstEntityNum(getMyTurn()), new Vector<>(0)); + sendDone(true); + } + + @Override + protected void calculateInfantryVsInfantryCombatTurn() { + sendAttackData(getGame().getFirstEntityNum(getMyTurn()), new Vector<>(0)); + sendDone(true); + } + + @Override + public void initialize() { + } + + @Override + protected void processChat(GamePlayerChatEvent event) { + } + + @Override + protected void initMovement() { + } + + @Override + protected void initFiring() { + } + + @Override + protected void postMovementProcessing() { + } + + @Override + protected void checkMorale() { + } + + // ---- Rules, not tactics ------------------------------------------------- + + /** + * Every hex this unit may legally deploy into. + * + *

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 legalDeployment(Entity unit) { Game game = getGame(); - MovePath path = new MovePath(game, entity); + List legal = new ArrayList<>(); + var board = game.getBoard(unit); + for (int y = 0; y < board.getHeight(); y++) { + for (int x = 0; x < board.getWidth(); x++) { + Coords candidate = new Coords(x, y); + if (!board.isLegalDeployment(candidate, unit)) { + continue; + } + if (unit.isLocationProhibited(candidate, unit.getBoardId(), 0)) { + continue; + } + if (megamek.common.compute.Compute.stackingViolation(game, unit, candidate, + 0, candidate, null, unit.climbMode(), true) != null) { + continue; + } + legal.add(candidate); + } + } + return legal; + } + + /** Point at the middle of the board: a default that is at least not backwards. */ + private int facingTowardCentre(Coords from) { + var board = getGame().getBoard(getLocalPlayer().getStartingPos() >= 0 ? 0 : 0); + Coords centre = new Coords(board.getWidth() / 2, board.getHeight() / 2); + return from.equals(centre) ? 0 : from.direction(centre); + } + + private @Nullable MovePath buildPath(Entity entity, JsonNode reply) { + MovePath path = new MovePath(getGame(), entity); JsonNode steps = reply.path("steps"); if (!steps.isArray()) { System.err.println("[sds] " + getName() + " sent a move with no steps array"); @@ -160,26 +379,21 @@ public final class SdsClient extends Princess { return path; } + // ---- The pipe ----------------------------------------------------------- + /** * One request, one reply, with a deadline. * - *

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 entry : tallies.entrySet()) { + Tally t = entry.getValue(); + decisions += t.decisions; + answered += t.answered; + defaulted += t.defaulted; + illegal += t.illegal; + failed += t.failed; + ObjectNode phase = phases.putObject(entry.getKey()); + phase.put("decisions", t.decisions); + phase.put("answered", t.answered); + phase.put("defaulted", t.defaulted); + phase.put("illegal", t.illegal); + phase.put("failed", t.failed); + } n.put("decisions", decisions); n.put("answered", answered); - n.put("passed", passed); + n.put("defaulted", defaulted); n.put("illegal", illegal); n.put("failed", failed); n.put("disabled", disabled); + n.set("phases", phases); return n; } } diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 609945f..d66462a 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -17,6 +17,24 @@ 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 @@ -47,10 +65,16 @@ tracked. A bot that cares must currently do without. "prone": false, "shutDown": false, "destroyed": false, "crippled": false, "done": false, "gunnery": 4, "piloting": 5, + "role": "SNIPER", "forcePath": ["Alpha Company", "1st Lance"], "weapons": [{"id": 3, "name": "PPC", "damage": 10, "heat": 10, "short": 6, "medium": 12, "long": 18, "usable": true}]}]} ``` +`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. + `actor` is the unit being asked about. `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 @@ -62,56 +86,99 @@ Undeployed and removed units are omitted rather than sent with a null position. ```json {"type": "action", "seq": 7, "kind": "move", "steps": ["TURN_LEFT", "FORWARDS", "FORWARDS"]} +{"type": "action", "seq": 7, "kind": "fire", "attacks": [{"weapon": 3, "target": 9}]} +{"type": "action", "seq": 7, "kind": "deploy", "x": 4, "y": 16, "facing": 0} {"type": "action", "seq": 7, "kind": "pass"} ``` +An action may also carry `rationale`, any JSON the bot likes. The host does not +read it and writes it to the match log. 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"` — Princess plays this decision. Not a failure: a bot that only - has an opinion about movement passes on everything else, and that is the - intended way to build one up. +- `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. -## What happens when a bot misbehaves - 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 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 | none, always | +| 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 the answer is none until the protocol carries physicals and a bot chooses +them. + +#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` | asks Princess | `passed` | -| sends an illegal path | asks Princess | `illegal` | -| sends an unknown step name | asks Princess | `illegal` | -| answers the wrong `seq` | discards, asks Princess | `passed` | -| takes longer than `--timeout-ms` | asks Princess, keeps the bot | `passed` | -| closes stdout, or crashes | Princess plays the rest of the match | `disabled` | +| 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` | +| closes stdout, or crashes | defaults for the rest of the match | `disabled` | Read `answered` against `decisions` before reading any win rate. A bot that -answered 10% of its decisions and "won" was Princess wearing a hat. +answered 10% of its decisions and "won" did not play the match — the defaults +did, and they are inert. ## Phases the bot may answer -Movement only, today. Everything else is Princess. +Deployment, movement and firing. Physical attacks, artillery and morale are +declined by the harness and are not yet on the wire. + +Which unit moves next is **not** the bot's yet: the harness moves the first unit +that has not moved. Move order is a real tactical decision and should end up +with the bot; it is a stated gap rather than an oversight. + +## Phase-specific fields + +`observation` carries extra fields depending on the phase. -This is a deliberate first step rather than a limitation to apologise for: with -firing, target selection and withdrawal identical on both sides, an SDS-vs- -Princess result is a measurement of *movement*. Adding a phase widens what is -being measured, so add them one at a time and rerun the control. +- **`FIRING`** adds `shots`: every shot the rules permit, as + `{"weapon", "target", "toHit", "range", "damage", "heat"}`. Shots that are + impossible or automatic failures are left out. -## Known asymmetry + 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. -Princess runs every path it picks through a private `performPathPostProcessing` -— unjamming autocannons, evading, end-of-path housekeeping. It is private, so a -path arriving over this protocol does not get it. The cost falls on the external -bot, so a measured SDS win is still a win; read a narrow SDS loss with it in -mind. +- **`DEPLOYMENT`** adds `deployHexes`: the legal hexes, in board order, + **unranked**. 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 route not taken diff --git a/sds/stats.py b/sds/stats.py index 1e894fc..4dfddc7 100644 --- a/sds/stats.py +++ b/sds/stats.py @@ -52,7 +52,7 @@ class SideSummary: units_remaining_total: int = 0 decisions: int = 0 answered: int = 0 - passed: int = 0 + defaulted: int = 0 illegal: int = 0 failed: int = 0 @@ -88,7 +88,7 @@ class Summary: if stats: side.decisions += stats.get("decisions", 0) side.answered += stats.get("answered", 0) - side.passed += stats.get("passed", 0) + side.defaulted += stats.get("defaulted", 0) side.illegal += stats.get("illegal", 0) side.failed += stats.get("failed", 0) @@ -159,22 +159,23 @@ class Summary: if bridged: lines.append("") lines.append( - f"{'bridge':<14} {'decisions':>10} {'answered':>9} {'passed':>7} " + f"{'bridge':<14} {'decisions':>10} {'answered':>9} {'defaulted':>10} " f"{'illegal':>8} {'failed':>7}" ) for side in bridged: lines.append( f"{side.name:<14} {side.decisions:>10} {side.answered:>9} " - f"{side.passed:>7} {side.illegal:>8} {side.failed:>7}" + f"{side.defaulted:>10} {side.illegal:>8} {side.failed:>7}" ) - # A bot that answered nothing was Princess wearing a hat, and its win - # rate says nothing about the bot. Worth saying out loud rather than - # leaving in a column. + # A bot that answered nothing stood in its deployment hex and was + # shot to pieces. Its win rate is a fact about the defaults, not + # about the bot, and that is worth saying out loud rather than + # leaving in a column to be missed. for side in bridged: share = side.answered / side.decisions if side.decisions else 0.0 if share < 0.5: lines.append( f" note: {side.name} answered only {share:.0%} of its decisions; " - f"Princess played the rest" + f"the rest took the harness default (stand still, hold fire)" ) return "\n".join(lines)