diff --git a/.gitignore b/.gitignore index b7f0a77..d196f0d 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,9 @@ results/ # `crates/sds-core/tests/arc_corpus.rs` says how to get it back, and fails # loudly when it is not there. crates/sds-core/tests/corpus/arcs.txt + +# Per-size copies of the suite, made by a batch driver. The suite itself is +# committed; these are scratch. +/suites/ +/overnight.log +/sweep.sh diff --git a/README.md b/README.md index a2a4ec7..50f5848 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ or is dice. ./sds.sh control --games 40 # the harness self-test ./sds.sh bench --games 40 \ --bot "python3 /work/bots/random_bot.py" # the measurement + ./sds.sh play # you, at MegaMek, against the bot ./sds.sh view runs/ # what the bot thought ./sds.sh los-dump scenarios/suite/*.mms \ --out crates/sds-core/tests/corpus/los.jsonl # regenerate the LOS corpus @@ -51,7 +52,7 @@ One idea, one branch, one comparison in the pull request: git checkout -b claude/flank-weighting origin/main # change the bot ./scripts/build.sh - ./sds.sh bench --games 60 --bot /work/target/release/sds-bot --against main + ./sds.sh bench --games 60 --against main That writes `comparison.md`, which is the PR body. See [docs/EXPERIMENTS.md](docs/EXPERIMENTS.md) — and note that the comparison @@ -64,7 +65,7 @@ The bot scores a candidate as a dot product: a weight per named feature. The features are hand-written and stay that way; only the weight vector `M` is fitted. - ./sds.sh bench --games 300 --bot /work/target/release/sds-bot + ./sds.sh bench --games 300 ./sds.sh corpus save runs/ overnight ./sds.sh train overnight --out weights/fitted.json @@ -148,11 +149,37 @@ hand-authored one — the fit itself proves nothing. - **`bench`** plays a bot against Princess, alternating which faction the bot takes, and reports a win rate with a Wilson interval. - **`one`** plays a single match and prints everything, for looking at. +- **`play`** puts *you* in one of the seats, at MegaMek's own client, and leaves + the same result, round report and decision log behind. For the failures a win + rate cannot describe. [docs/PLAYING.md](docs/PLAYING.md). `baselines` lists what has been recorded, `corpus` stores and lists training corpora, and `clean` kills match containers left by a harness that was killed rather than interrupted. +`bench` and `one` play `/work/target/release/sds-bot` unless `--bot` says +otherwise, and print which bot they are running in their first line. The floor +bot is still there and still worth running — `--bot "python3 +/work/bots/random_bot.py"` — but you have to ask for it. It used to be the +default, and twice now a run of it has been read as a run of the change under +test; see `plan/harness.md` for both. + +## Reading one decision + + ./sds.sh explain runs/ --phase FIRING + +Every decision line in a match's `.decisions.jsonl` already carries the whole +menu the bot ranked: each candidate's label, its measured features and the +value the argmax used. `sds-bot` writes the weight vector beside the log, and +`explain` prints the two together — one table per decision, a column per +candidate, a row per feature, showing the raw value, the weight and the +product. The default the bot could have taken instead (hold fire, stand still) +is a column like any other, so "it chose badly" and "everything on the menu was +worse than doing nothing" are different pictures. + +`sds view` renders a whole match as HTML; `explain` is for the one decision +that made no sense. + ## How the numbers avoid lying - **Mirrored forces.** `scenarios/mirror-lance.mms` gives both sides the same diff --git a/baselines/overnight-1v1.json b/baselines/overnight-1v1.json new file mode 100644 index 0000000..6658029 --- /dev/null +++ b/baselines/overnight-1v1.json @@ -0,0 +1,204 @@ +{ + "name": "overnight-1v1", + "bot": "/work/target/release/sds-bot", + "scenario": "suite:1v1:5f76636db8a2", + "games": 180, + "decided": 152, + "wins": 0, + "outcomes": { + "victory": 154, + "round-limit": 19, + "stalled": 7 + }, + "bv_left": 0.0, + "bv_left_opponent": 0.0, + "bv_destroyed_percent": 70, + "max_rounds": 40, + "opponent": "princess", + "epoch": 4, + "commit": "cd20dc5-dirty", + "recorded": "2026-08-21T05:07:41Z", + "notes": "generated-only, uncapped, ceiling denominator", + "seeds": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 117, + 118, + 119, + 120, + 121, + 122, + 123, + 124, + 125, + 126, + 127, + 128, + 129, + 130, + 131, + 132, + 133, + 134, + 135, + 136, + 137, + 138, + 139, + 140, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148, + 149, + 150, + 151, + 152, + 153, + 154, + 155, + 156, + 157, + 158, + 159, + 160, + 161, + 162, + 163, + 164, + 165, + 166, + 167, + 168, + 169, + 170, + 171, + 172, + 173, + 174, + 175, + 176, + 177, + 178, + 179, + 180 + ] +} diff --git a/baselines/overnight-2v2.json b/baselines/overnight-2v2.json new file mode 100644 index 0000000..d1aca2b --- /dev/null +++ b/baselines/overnight-2v2.json @@ -0,0 +1,203 @@ +{ + "name": "overnight-2v2", + "bot": "/work/target/release/sds-bot", + "scenario": "suite:2v2:ea8c61557e62", + "games": 180, + "decided": 176, + "wins": 0, + "outcomes": { + "victory": 176, + "round-limit": 4 + }, + "bv_left": 0.0, + "bv_left_opponent": 0.0, + "bv_destroyed_percent": 70, + "max_rounds": 40, + "opponent": "princess", + "epoch": 4, + "commit": "cd20dc5-dirty", + "recorded": "2026-08-21T05:57:46Z", + "notes": "generated-only, uncapped, ceiling denominator", + "seeds": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 117, + 118, + 119, + 120, + 121, + 122, + 123, + 124, + 125, + 126, + 127, + 128, + 129, + 130, + 131, + 132, + 133, + 134, + 135, + 136, + 137, + 138, + 139, + 140, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148, + 149, + 150, + 151, + 152, + 153, + 154, + 155, + 156, + 157, + 158, + 159, + 160, + 161, + 162, + 163, + 164, + 165, + 166, + 167, + 168, + 169, + 170, + 171, + 172, + 173, + 174, + 175, + 176, + 177, + 178, + 179, + 180 + ] +} diff --git a/baselines/overnight-4v4.json b/baselines/overnight-4v4.json new file mode 100644 index 0000000..58e6e90 --- /dev/null +++ b/baselines/overnight-4v4.json @@ -0,0 +1,174 @@ +{ + "name": "overnight-4v4", + "bot": "/work/target/release/sds-bot", + "scenario": "suite:4v4:672f58082a0e", + "games": 150, + "decided": 138, + "wins": 0, + "outcomes": { + "victory": 141, + "round-limit": 2, + "stalled": 7 + }, + "bv_left": 0.0, + "bv_left_opponent": 0.0, + "bv_destroyed_percent": 70, + "max_rounds": 40, + "opponent": "princess", + "epoch": 4, + "commit": "cd20dc5-dirty", + "recorded": "2026-08-21T07:29:43Z", + "notes": "generated-only, uncapped, ceiling denominator", + "seeds": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 117, + 118, + 119, + 120, + 121, + 122, + 123, + 124, + 125, + 126, + 127, + 128, + 129, + 130, + 131, + 132, + 133, + 134, + 135, + 136, + 137, + 138, + 139, + 140, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148, + 149, + 150 + ] +} diff --git a/baselines/overnight-8v8.json b/baselines/overnight-8v8.json new file mode 100644 index 0000000..3ca87e2 --- /dev/null +++ b/baselines/overnight-8v8.json @@ -0,0 +1,143 @@ +{ + "name": "overnight-8v8", + "bot": "/work/target/release/sds-bot", + "scenario": "suite:8v8:3cb5075e96b2", + "games": 120, + "decided": 105, + "wins": 0, + "outcomes": { + "victory": 110, + "stalled": 10 + }, + "bv_left": 0.0, + "bv_left_opponent": 0.0, + "bv_destroyed_percent": 70, + "max_rounds": 40, + "opponent": "princess", + "epoch": 4, + "commit": "cd20dc5-dirty", + "recorded": "2026-08-21T10:27:37Z", + "notes": "generated-only, uncapped, ceiling denominator", + "seeds": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 117, + 118, + 119, + 120 + ] +} diff --git a/bots/ineligible_bot.py b/bots/ineligible_bot.py new file mode 100755 index 0000000..5dea043 --- /dev/null +++ b/bots/ineligible_bot.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""A bot that names a unit which cannot act, on purpose. + +The turn-order reply may carry a `unit`, and the host acts on that unit instead +of its own first-eligible pick. That opens a failure the harness has to refuse +rather than obey: a bot naming a unit MegaMek will not accept this turn. If the +host substituted its own pick silently, the decision log would say one machine +moved while another one did - the same failure class as an illegal path. + +So this bot answers every question it is asked - it is not a passthrough, and +its decisions must not come back `defaulted` - and names a unit id that exists +in no game. Every movement and firing decision should come back `answered`, on +the host's own pick, with `substituted` equal to the number of those decisions, +and a line on stderr for each. Anything else means the refusal is not working. + +No environment switch, because the harness passes the bot an allowlist of +variables and a test switch does not belong in it. This is a whole bot instead, +and it is named for the one thing it does. + + ./sds.sh one --sds-seat North --bot /work/bots/ineligible_bot.py +""" + +import json +import sys + +# Not a unit id. MegaMek numbers entities from zero and never negatively, so +# nothing in any game can collide with this. +INELIGIBLE = -99999 + + +def main() -> None: + for line in sys.stdin: + line = line.strip() + if not line: + continue + message = json.loads(line) + if message.get("type") != "observation": + continue + phase = message.get("phase", "") + reply = {"type": "action", "seq": message["seq"]} + if phase == "MOVEMENT": + # Stand still, but claim it is for a unit that cannot act. An empty + # step list is a legal path, so the only thing left for the host to + # object to is the unit. + reply.update({"kind": "move", "steps": [], "unit": INELIGIBLE}) + elif phase == "FIRING": + reply.update({"kind": "fire", "attacks": [], "unit": INELIGIBLE}) + else: + reply["kind"] = "pass" + sys.stdout.write(json.dumps(reply) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/bots/random_bot.py b/bots/random_bot.py index 7b251b9..f00588f 100755 --- a/bots/random_bot.py +++ b/bots/random_bot.py @@ -17,6 +17,11 @@ 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. + +It never chooses which of its units acts: it answers about the host's `actor` +and lets the host's own first-eligible pick stand. That is the floor for turn +order as well, and it is what keeps this bot a check that a host offering an +eligible set still works with a bot that ignores one. """ import json diff --git a/bridge/Dockerfile b/bridge/Dockerfile index 92c4972..8dcee9d 100644 --- a/bridge/Dockerfile +++ b/bridge/Dockerfile @@ -16,8 +16,21 @@ FROM eclipse-temurin:21-jdk # the stub bot uses the standard library, so a dependency here would be a # dependency in every match container forever. A bot that needs more brings its # own image. +# +# The X11 libraries are for `sds play`, where a person sits at MegaMek's own +# Swing client with the display forwarded to the host's X server. A JDK image +# ships libawt_xawt.so but none of the libraries it links against, so without +# these every AWT call fails with UnsatisfiedLinkError; `ldd` on that file is +# the list. x11-utils is xdpyinfo, which is how `sds play --check-display` +# answers "can this container reach your X server" without starting a match. +# Nothing on the batch path loads any of it - that path still runs with +# -Djava.awt.headless=true and never touches AWT. RUN apt-get update \ - && apt-get install --no-install-recommends -y python3 \ + && apt-get install --no-install-recommends -y \ + python3 \ + libx11-6 libxext6 libxrender1 libxtst6 libxi6 \ + libfreetype6 fontconfig fonts-dejavu-core \ + x11-utils \ && rm -rf /var/lib/apt/lists/* ENV PYTHONUNBUFFERED=1 diff --git a/bridge/sds/Observation.java b/bridge/sds/Observation.java index 49bbe06..ed10df9 100644 --- a/bridge/sds/Observation.java +++ b/bridge/sds/Observation.java @@ -2,7 +2,9 @@ package sds; import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -107,9 +109,17 @@ 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 movement decision: which of ours goes now, and where. + * + *

{@code eligible} is every unit MegaMek would accept for this turn, and + * it is the whole point of the document. Which of a side's units acts next + * is a real decision the turn structure hands to the player, and a host that + * only reported the one it happened to pick first was making that decision + * on the bot's behalf. + */ + public ObjectNode movement(Game game, Player me, Entity actor, List eligible, int seq) { + return base(game, me, actor, eligible, seq); } /** @@ -125,9 +135,29 @@ public final class Observation { * *

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); + public ObjectNode firing(Game game, Player me, Entity shooter, List eligible, int seq) { + ObjectNode root = base(game, me, shooter, eligible, seq); ArrayNode shots = root.putArray("shots"); + // Every unit that could declare, not only the one the host would have + // picked. A bot choosing which of ours shoots first needs each + // candidate priced, and a weapon id means nothing without the unit it + // is mounted on: equipment number 7 is an autocannon on one Mek and a + // ton of ammo on the next, which is exactly how naming a unit whose + // shots had never been worked out crashed MegaMek's packet pump. + List shooters = (eligible == null || eligible.isEmpty()) + ? List.of(shooter) + : eligible; + for (Entity candidate : shooters) { + shotsFor(game, me, candidate, shots); + } + return root; + } + + /** Every shot the rules allow one unit, appended to {@code shots}. */ + private void shotsFor(Game game, Player me, Entity shooter, ArrayNode shots) { + if (shooter.getPosition() == null) { + return; + } for (Entity target : game.getEntitiesVector()) { if (target.getPosition() == null || target.isDestroyed()) { continue; @@ -158,18 +188,28 @@ public final class Observation { continue; } ObjectNode shot = shots.addObject(); + // Which unit takes this shot. Absent on an observation older + // than the eligible set, and read as "the actor" there. + shot.put("shooter", shooter.getId()); shot.put("weapon", weaponId); shot.put("target", target.getId()); shot.put("toHit", toHit.getValue()); int range = shooter.getPosition().distance(target.getPosition()); shot.put("range", range); WeaponType type = weapon.getType(); - shot.put("damage", type == null ? 0.0 : averageDamage(type, range)); - shot.put("damagePerPacket", type == null ? 0.0 : damagePerPacket(type)); + // The round in the tube, so an MML shot is priced as the ammo + // it will actually throw. `getRanges` is the only accessor that + // knows an MML has brackets at all. + AmmoMounted linked = weapon.getLinkedAmmo(); + AmmoType loaded = linked == null ? null : linked.getType(); + int shotLong = type == null ? 0 : type.getRanges(weapon, linked)[3]; + shot.put("damage", + type == null ? 0.0 : averageDamage(type, loaded, range, shotLong)); + shot.put("damagePerPacket", + type == null ? 0.0 : damagePerPacket(type, loaded, range, shotLong)); shot.put("heat", type == null ? 0 : type.getHeat()); } } - return root; } /** @@ -180,7 +220,10 @@ public final class Observation { * 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); + // No eligible set. Deployment is one unit at a time by MegaMek's own + // ordering and the host does not get to reorder it; claiming a choice + // that does not exist would be worse than saying nothing. + ObjectNode root = base(game, me, unit, null, seq); ArrayNode hexes = root.putArray("deployHexes"); for (Coords c : legal) { ObjectNode hex = hexes.addObject(); @@ -193,14 +236,20 @@ public final class Observation { /** * The state a bot decides from. * - * @param game the live game - * @param me the player the bot is playing - * @param actor the unit being asked about, or null when the decision is not - * about one unit - * @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 + * @param game the live game + * @param me the player the bot is playing + * @param actor the unit the host will use if the bot does not choose one, + * or null when the decision is not about one unit. A + * default, not a fact: a bot that names a unit from + * {@code eligible} on its reply overrides it. + * @param eligible every unit that may take this turn, or null when the phase + * offers no choice. Ids only; each is described in + * {@code units} like any other. + * @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 */ - private ObjectNode base(Game game, Player me, Entity actor, int seq) { + private ObjectNode base(Game game, Player me, Entity actor, List eligible, int seq) { ObjectNode root = mapper.createObjectNode(); root.put("type", "observation"); root.put("seq", seq); @@ -211,6 +260,12 @@ public final class Observation { if (actor != null) { root.put("actor", actor.getId()); } + if (eligible != null && !eligible.isEmpty()) { + ArrayNode ids = root.putArray("eligible"); + for (Entity e : eligible) { + ids.add(e.getId()); + } + } ArrayNode units = root.putArray("units"); for (Entity e : game.getEntitiesVector()) { @@ -246,11 +301,174 @@ public final class Observation { * cluster-sentinel workaround is how one of them ends up not having it. */ static double averageDamage(WeaponType wt, int range) { - int declared = wt.getDamage(); + return averageDamage(wt, null, range, wt.getLongRange()); + } + + /** + * The same, told what is loaded and how far the loaded round reaches. + * + *

Two sentinels, not one. {@code getDamage()} returns + * {@code DAMAGE_BY_CLUSTER_TABLE} (-2) for a cluster weapon, which the + * paragraph above is about, and {@code DAMAGE_VARIABLE} (-3) for a weapon + * whose damage falls with range. The Snub-Nose PPC is the second kind: it + * throws 10, 8 and 5 points across its three brackets, and + * {@code getBattleForceDamage} answers zero at all three - so the + * bot was told a Snub-Nose PPC does no damage at any range and declined to + * fire it. {@code getDamage(range)} is the accessor that knows, and the + * subclasses override it; the base class returns the field, so every + * ordinary weapon is unaffected. + * + *

For an ordinary rack {@code getBattleForceDamage} stays: it knows + * things the cluster table does not, and recomputing them makes them + * worse - a Streak SRM-6 lands all six missiles and is twelve points, and + * a Narc is a beacon and is zero. Only where the round redefines the + * launcher, which is an MML or an ATM, is the damage computed from the + * round instead. See {@link #redefinesRanges}. + */ + static double averageDamage(WeaponType wt, AmmoType at, int range, int longRange) { + if (range > longRange) { + return 0.0; + } + int declared = wt.getDamage(range); if (declared >= 0) { - return range <= wt.getLongRange() ? declared : 0.0; + return declared; } - return wt.getBattleForceDamage(range, (Mounted) null) * 10.0; + if (redefinesRanges(at)) { + int rack = at.getRackSize() > 0 ? at.getRackSize() : wt.getRackSize(); + int hits = rack > 0 ? Compute.calculateClusterHitTableAmount(7, rack) : 0; + if (hits > 0) { + return (double) hits * Math.max(at.getDamagePerShot(), 0); + } + } + // Never negative. `getDamage()` has a third sentinel, DAMAGE_ARTILLERY + // (-5), and getBattleForceDamage passes it straight through - an Arrow + // IV was reaching the bot as minus five points of expected damage. + return Math.max(wt.getBattleForceDamage(range, (Mounted) null) * 10.0, 0.0); + } + + /** + * Whether this round changes what the launcher is, rather than only what it + * is loaded with. + * + *

The line between the two paths above. For an ordinary rack + * {@code getBattleForceDamage} is MegaMek's own averaging and knows things + * the cluster table does not - a Streak SRM-6 lands all six missiles and is + * twelve points, not the eight an average roll would give, and a Narc is a + * beacon and is zero. Recomputing those from the cluster table makes them + * worse. + * + *

An MML or an ATM is the case where it cannot be right by construction: + * one figure has to stand for two or three sets of brackets, so there the + * damage is computed from the round instead. + */ + private static boolean redefinesRanges(AmmoType at) { + if (at == null) { + return false; + } + AmmoType.AmmoTypeEnum kind = at.getAmmoType(); + return kind == AmmoType.AmmoTypeEnum.MML + || kind == AmmoType.AmmoTypeEnum.ATM + || kind == AmmoType.AmmoTypeEnum.IATM; + } + + /** + * A minimum range, with MegaMek's sentinel taken out. + * + *

{@code WeaponType.getMinimumRange()} returns {@link Integer#MIN_VALUE} + * for a weapon that has none - the same shape of trap as the damage + * sentinels. {@code Compute.getRangeMods} guards it with {@code > 0} and + * never notices; anything that reports the number has to. + */ + private static int minimumRange(int declared) { + return Math.max(declared, 0); + } + + /** + * The alternatives one launcher can choose between, when it has more than + * one. + * + *

An MML fires LRM ammo or SRM ammo out of the same tube. + * {@code WeaponType.getRanges(weapon, ammo)} returns + * {@code {6, 7, 14, 21, 28}} for the first and {@code {0, 3, 6, 9, 12}} for + * the second - different brackets, different minimum range, different + * damage per missile. An ATM has three: standard, extended range + * {@code {4, 9, 18, 27, 36}} and high explosive {@code {0, 3, 6, 9, 12}}. + * The plain {@code getShortRange()} family knows about none of it, and for + * an MML it returns zero for every bracket - so an MML on the wire + * was a weapon that could not reach any hex at all. + * + *

These are modes of one weapon, never separate weapons. Only one round + * is in the tube: a bracket resolves to the best mode that reaches it, and + * nothing may fire two of them at once. + * + *

Emitted only when there are at least two, so an ordinary gun costs + * nothing on the wire and an older reader that ignores the key still sees + * the design's own brackets. + */ + private void modes(Entity e, WeaponMounted w, WeaponType wt, ObjectNode wn) { + List bins = e.getAmmo(); + if (bins == null || bins.isEmpty()) { + return; + } + Map found = new LinkedHashMap<>(); + for (AmmoMounted bin : bins) { + AmmoType at = bin.getType(); + if (at == null || bin.getUsableShotsLeft() <= 0 || bin.isDestroyed()) { + continue; + } + if (!AmmoType.isAmmoValid(bin, wt)) { + continue; + } + int[] ranges = wt.getRanges(w, bin); + int minimum = minimumRange(ranges[0]); + int shortRange = ranges[1]; + int mediumRange = ranges[2]; + int longRange = ranges[3]; + String key = minimum + "/" + shortRange + "/" + mediumRange + "/" + longRange + + "/" + at.getDamagePerShot() + "/" + at.getRackSize(); + ObjectNode mn = found.get(key); + if (mn != null) { + // A second bin of the same thing is more rounds, not another + // mode. Artemis ammo and plain ammo share every number here. + mn.put("shotsLeft", mn.get("shotsLeft").asInt() + bin.getUsableShotsLeft()); + continue; + } + mn = wn.objectNode(); + mn.put("name", at.getName()); + mn.put("type", at.getAmmoType().name()); + mn.put("munition", at.getSubMunitionName()); + mn.put("minimum", minimum); + mn.put("short", shortRange); + mn.put("medium", mediumRange); + mn.put("long", longRange); + mn.put("avgDamageShort", averageDamage(wt, at, shortRange, longRange)); + mn.put("avgDamageMedium", averageDamage(wt, at, mediumRange, longRange)); + mn.put("avgDamageLong", averageDamage(wt, at, longRange, longRange)); + mn.put("damagePerPacket", damagePerPacket(wt, at, shortRange, longRange)); + mn.put("rackSize", at.getRackSize() > 0 ? at.getRackSize() : wt.getRackSize()); + mn.put("shotsLeft", bin.getUsableShotsLeft()); + found.put(key, mn); + } + if (found.size() < 2) { + return; + } + // Longest reach first, so a reader taking the first mode as the + // weapon's headline brackets gets the one that describes the most of + // the range line. Ties break on the minimum range, then on damage, so + // the order is a function of the numbers and not of bin order. + List ordered = new ArrayList<>(found.values()); + ordered.sort((x, y) -> { + int by = Integer.compare(y.get("long").asInt(), x.get("long").asInt()); + if (by != 0) { + return by; + } + by = Integer.compare(x.get("minimum").asInt(), y.get("minimum").asInt()); + return by != 0 ? by + : Double.compare(y.get("avgDamageShort").asDouble(), + x.get("avgDamageShort").asDouble()); + }); + ArrayNode out = wn.putArray("modes"); + ordered.forEach(out::add); } /** @@ -261,8 +479,13 @@ public final class Observation { * packet, so this reduces to its damage and the two cases need no branch. */ private double damagePerPacket(WeaponType wt) { - double total = averageDamage(wt, wt.getShortRange()); - int rack = wt.getRackSize(); + return damagePerPacket(wt, null, wt.getShortRange(), wt.getLongRange()); + } + + /** The same, for one loaded round at one bracket. */ + private double damagePerPacket(WeaponType wt, AmmoType at, int shortRange, int longRange) { + double total = averageDamage(wt, at, shortRange, longRange); + int rack = redefinesRanges(at) && at.getRackSize() > 0 ? at.getRackSize() : wt.getRackSize(); if (wt.getDamage() >= 0 || rack <= 0) { return total; } @@ -344,7 +567,17 @@ public final class Observation { u.put("internal", e.getTotalInternal()); u.put("internalMax", e.getTotalOInternal()); u.put("heat", e.getHeat()); + // DISSIPATION, not the shutdown threshold. getHeatCapacity() is what + // the sinks shed in a turn; the scale a Mek shuts down on is 30 and has + // nothing to do with this number. A bot that read it as a ceiling would + // never fire more than it could sink, which is exactly what happened. u.put("heatCapacity", e.getHeatCapacity()); + // Heat already banked this turn - movement, engine crits, external + // sources - that has not reached the scale yet. Without it a firing + // decision prices a volley against last turn's heat and misses the + // running it did to get here. + u.put("heatBuildup", e.heatBuildup); + u.put("explodableAmmo", explodableAmmo(e)); // Per location, because a total is not a target. Damage lands on one // location at a time, so "will this shot open the centre torso" is the @@ -432,34 +665,55 @@ public final class Observation { wn.put("rearMounted", true); } wn.put("heat", wt.getHeat()); - wn.put("short", wt.getShortRange()); - wn.put("medium", wt.getMediumRange()); - wn.put("long", wt.getLongRange()); - wn.put("avgDamageShort", averageDamage(wt, wt.getShortRange())); - wn.put("avgDamageMedium", averageDamage(wt, wt.getMediumRange())); - wn.put("avgDamageLong", averageDamage(wt, wt.getLongRange())); + // What is loaded now, which decides both the damage and - for an + // MML or an ATM - the brackets themselves. Read before the ranges + // so those can be the loaded round's rather than the design's. + AmmoMounted linked = w.getLinkedAmmo(); + AmmoType loaded = linked == null ? null : linked.getType(); + int[] ranges = wt.getRanges(w, linked); + int minimum = minimumRange(ranges[0]); + int shortRange = ranges[1]; + int mediumRange = ranges[2]; + int longRange = ranges[3]; + wn.put("short", shortRange); + wn.put("medium", mediumRange); + wn.put("long", longRange); + // Every PPC variant but the Snub-Nose has one, an LRM has six, an + // ATM has four, a Gauss Rifle has two. + // Compute.getRangeMods adds `minimum - distance + 1` at or inside + // it, so a weapon at exactly its minimum range is already at +1. + // Nothing downstream could price that before this field existed. + wn.put("minimum", minimum); + wn.put("avgDamageShort", averageDamage(wt, loaded, shortRange, longRange)); + wn.put("avgDamageMedium", averageDamage(wt, loaded, mediumRange, longRange)); + wn.put("avgDamageLong", averageDamage(wt, loaded, longRange, longRange)); wn.put("rackSize", wt.getRackSize()); // Damage per hit location, which is the axis a player calls "punch // holes" against "sandblast". A PPC puts ten points in one place; an // LRM-10 spreads about six across the cluster table. Same total is a // completely different tactical object: the first opens a location, // the second fishes for criticals through one already open. - wn.put("damagePerPacket", damagePerPacket(wt)); + wn.put("damagePerPacket", damagePerPacket(wt, loaded, shortRange, longRange)); wn.put("usable", w.canFire()); + // The launcher's alternatives, when it has more than one. An MML + // fires LRM or SRM ammo out of one tube and an ATM fires three + // kinds, each with its own brackets, its own minimum range and its + // own damage - and one averaged figure is neither of them. Only one + // can be chosen per shot, which is why these are modes of one + // weapon and not two weapons. + modes(e, w, wt, wn); // What is actually loaded. An LRM-20 firing Inferno is not an // LRM-20 firing standard, and the weapon alone cannot say which: // damage per missile, and the cluster roll, both move with the // munition. Absent for energy weapons and for an empty rack, which // is the one case where "no ammo" is the whole story. - AmmoMounted ammo = w.getLinkedAmmo(); - AmmoType at = ammo == null ? null : ammo.getType(); - if (at != null) { + if (loaded != null) { ObjectNode an = wn.putObject("ammo"); - an.put("type", at.getAmmoType().name()); - an.put("munition", at.getSubMunitionName()); - an.put("damagePerShot", at.getDamagePerShot()); - an.put("rackSize", at.getRackSize()); - an.put("shotsLeft", ammo.getUsableShotsLeft()); + an.put("type", loaded.getAmmoType().name()); + an.put("munition", loaded.getSubMunitionName()); + an.put("damagePerShot", loaded.getDamagePerShot()); + an.put("rackSize", loaded.getRackSize()); + an.put("shotsLeft", linked.getUsableShotsLeft()); } } return u; @@ -554,4 +808,46 @@ public final class Observation { } } + + /** + * Whether this unit carries ammunition a heat roll could set off. + * + *

MegaMek has no predicate for this - the set is defined inline by + * {@code TWGameManager.explodeAmmoFromHeat}, which walks the critical slots + * and takes any bin that is an {@link AmmoType}, is not already destroyed or + * hit, reports {@code isExplosive}, still has hittable shots, and is none of + * the three the rule exempts: a coolant pod, vehicle flamer ammo or heavy + * flamer ammo, plus any bin loaded with coolant munitions. This is that walk + * with the explosion arithmetic left out. + * + *

The ammo list rather than the slots. It is the same bins - a split + * mount appears in both slots and once in the list - and the question here + * is "is there any", not "which one goes off". + */ + private static boolean explodableAmmo(Entity e) { + for (AmmoMounted ammo : e.getAmmo()) { + AmmoType type = ammo.getType(); + if (type == null || ammo.isDestroyed() || ammo.isHit()) { + continue; + } + if (!type.isExplosive(ammo)) { + continue; + } + AmmoType.AmmoTypeEnum kind = type.getAmmoType(); + if (kind == AmmoType.AmmoTypeEnum.COOLANT_POD + || kind == AmmoType.AmmoTypeEnum.VEHICLE_FLAMER + || kind == AmmoType.AmmoTypeEnum.HEAVY_FLAMER) { + continue; + } + if (type.getMunitionType().contains(AmmoType.Munitions.M_COOLANT)) { + continue; + } + if (ammo.getHittableShotsLeft() <= 0) { + continue; + } + return true; + } + return false; + } + } diff --git a/bridge/sds/SdsClient.java b/bridge/sds/SdsClient.java index f365a6a..3da8ae2 100644 --- a/bridge/sds/SdsClient.java +++ b/bridge/sds/SdsClient.java @@ -25,6 +25,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import megamek.client.bot.BotClient; +import megamek.common.Report; import megamek.client.bot.PhysicalOption; import megamek.common.actions.EntityAction; import megamek.common.actions.WeaponAttackAction; @@ -34,6 +35,7 @@ import megamek.common.board.Coords; import megamek.common.enums.MoveStepType; import megamek.common.event.player.GamePlayerChatEvent; import megamek.common.game.Game; +import megamek.common.game.GameTurn; import megamek.common.moves.MovePath; import megamek.common.units.Entity; @@ -80,6 +82,12 @@ public final class SdsClient extends BotClient { /** Where this seat's decisions are appended, or null when there is no log dir. */ private final Path decisionLog; + /** + * Whether this seat writes MegaMek's round report. Off unless the host + * turns it on; see {@code SdsHost.setKeepGameLogOn}. + */ + private boolean keepsGameLog; + private Process process; private BufferedWriter decisionWriter; private boolean decisionLogBroken = false; @@ -107,6 +115,22 @@ public final class SdsClient extends BotClient { int defaulted; int illegal; int failed; + /** + * Turn order. Summed over decisions, so the mean set size is + * {@code eligible / decisions} - the number that says whether the bot + * had a choice at all. + */ + int eligible; + /** Decisions where the bot named a unit the game would accept. */ + int chose; + /** Of those, the ones that were not the host's own first pick. */ + int differed; + /** + * The bot named a unit that cannot act this turn. Its own counter and + * not `defaulted`: the bot did answer, and a silently substituted unit + * is the same failure class as an illegal path. + */ + int substituted; } private Tally tally(String phase) { @@ -129,6 +153,32 @@ public final class SdsClient extends BotClient { : logDir.resolve(matchTag + "-" + safeName(name) + ".decisions.jsonl"); } + /** + * Turn MegaMek's own round report on for this seat. + * + *

The report - to-hit numbers, rolls, hit locations, damage, criticals - + * is written by `AbstractClient` from the reports the server already sends, + * and is gated on `keepGameLog()`. `BotClient` hard-codes that to `false`, + * so a bot-only match writes nothing however `ClientPreferences` is set. + * This puts the preference back in charge for one seat. + */ + void setKeepsGameLog(boolean keep) { + this.keepsGameLog = keep; + } + + @Override + protected boolean keepGameLog() { + return keepsGameLog; + } + + @Override + public String receiveReport(List reports) { + if (!keepsGameLog) { + return super.receiveReport(reports); + } + return SeatReports.format(this, reports); + } + /** * A seat name as a file name. Faction names carry spaces and punctuation. * @@ -147,83 +197,161 @@ public final class SdsClient extends BotClient { /** * Which of our units moves next, then where it goes. * - *

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. + *

Both halves are the bot's. MegaMek's turn structure genuinely lets a + * player pick which of its eligible units acts next - 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 - and this used to + * break on the first valid unit and hand the bot the result as a fact. The + * eligible set now goes on the wire and the bot may name one of it. + * + *

The first eligible unit stays as the host's default, for a bot that + * does not choose and for a bot that chooses something the game will not + * accept. */ @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) { + List eligible = eligibleThisTurn(); + if (eligible.isEmpty()) { return null; } - return continueMovementFor(mover); + return askMovement(eligible); + } + + /** + * Every unit the current turn would accept, in MegaMek's own order. + * + *

This is a copy of {@code Game.getFirstEntityNum(GameTurn)}, widened + * from "the first" to "all of them", and it is deliberately a copy rather + * than a filter of our own. That method walks + * {@code Game.inGameTWEntities()} and keeps whatever + * {@code GameTurn.isValidEntity} admits - nothing else - so building the + * set the same way makes {@code eligible.get(0)} the host's own pick by + * construction, which is what makes "the bot chose differently" a claim + * about the bot rather than about two different enumerations. + * + *

Two things a hand-rolled filter gets wrong here, both silently: + * + *

    + *
  • {@code BotClient.getEntitiesOwned()} also requires a position and + * {@code !isOffBoard()}. An off-board artillery unit is eligible for + * its turn and would have been dropped from the set, narrowing the + * choice back towards one with nothing in any log to say so.
  • + *
  • {@code getMyTurn()} is not {@code getGame().getTurn()}. Under a + * simultaneous phase - {@code simultaneous_movement} and friends are + * ordinary game options - it is + * {@code getTurnForPlayer(localPlayerNumber)}, and the game's current + * turn belongs to somebody else.
  • + *
+ * + *

{@code isValidEntity} is the whole rule and covers what the options do + * to it: it checks ownership against the turn's player, + * {@code isSelectableThisTurn()} - which is where "has already moved", + * loaded cargo and abandoned units live - and, in movement, + * {@code inf_move_later} and {@code protos_move_later}. A + * {@code SpecificEntityTurn} overrides it to admit exactly one unit, which + * is the case where there is genuinely nothing to choose. + */ + private List eligibleThisTurn() { + List eligible = new ArrayList<>(); + GameTurn turn = getMyTurn(); + if (turn == null) { + return eligible; + } + for (Entity entity : getGame().inGameTWEntities()) { + if (turn.isValidEntity(entity, getGame())) { + eligible.add(entity); + } + } + return eligible; } + /** + * MegaMek naming one unit itself, which happens for a + * {@code SpecificEntityTurn}. There is no choice to offer, and saying there + * is one would invite the bot to make a call the game would then refuse. + */ @Override protected MovePath continueMovementFor(final Entity entity) { if (entity == null) { return null; } + return askMovement(List.of(entity)); + } + + private MovePath askMovement(List eligible) { + Entity fallback = eligible.get(0); Tally tally = tally("movement"); tally.decisions++; - MovePath standStill = new MovePath(getGame(), entity); - ObjectNode request = observation.movement(getGame(), getLocalPlayer(), entity, ++seq); + tally.eligible += eligible.size(); + ObjectNode request = + observation.movement(getGame(), getLocalPlayer(), fallback, eligible, ++seq); + Entity mover = fallback; try { JsonNode reply = ask(request); if (reply == null || !"move".equals(reply.path("kind").asText(""))) { tally.defaulted++; - record(request, reply, "defaulted", "stand still"); - return standStill; + record(request, reply, "defaulted", "stand still", mover.getId()); + return new MovePath(getGame(), mover); } - MovePath path = buildPath(entity, reply); + mover = chosenActor(reply, eligible, fallback, tally); + MovePath path = buildPath(mover, reply); if (path == null) { tally.illegal++; - record(request, reply, "illegal", "stand still"); - return standStill; + record(request, reply, "illegal", "stand still", mover.getId()); + return new MovePath(getGame(), mover); } tally.answered++; - record(request, reply, "answered", path.toString()); + record(request, reply, "answered", path.toString(), mover.getId()); return path; } catch (Exception e) { tally.failed++; System.err.println("[sds] " + getName() + " movement failed, standing still: " + e); - record(request, null, "failed", "stand still"); - return standStill; + record(request, null, "failed", "stand still", mover.getId()); + return new MovePath(getGame(), mover); } } @Override protected void calculateFiringTurn() { - Entity shooter = getGame().getFirstEntity(getMyTurn()); - if (shooter == null) { + Entity first = getGame().getFirstEntity(getMyTurn()); + if (first == null) { return; } + List eligible = eligibleThisTurn(); + if (eligible.isEmpty()) { + // The turn admits nobody but MegaMek handed us a shooter anyway. + // Trust the game over our copy of its enumeration. + eligible = List.of(first); + } Tally tally = tally("firing"); tally.decisions++; + tally.eligible += eligible.size(); Vector attacks = new Vector<>(); - ObjectNode request = observation.firing(getGame(), getLocalPlayer(), shooter, ++seq); + ObjectNode request = + observation.firing(getGame(), getLocalPlayer(), first, eligible, ++seq); + Entity shooter = first; JsonNode reply = null; try { reply = ask(request); if (reply == null || !"fire".equals(reply.path("kind").asText(""))) { tally.defaulted++; - record(request, reply, "defaulted", "hold fire"); + record(request, reply, "defaulted", "hold fire", shooter.getId()); } else { + shooter = chosenActor(reply, eligible, first, tally); 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) { + // `getWeapon`, not `getEquipment`. Equipment numbers are + // per unit and mixed: number 7 is an autocannon on one Mek + // and a ton of ammo on the next, and `getEquipment` returns + // the ammo happily. MegaMek then casts it to a weapon deep + // inside the packet pump, throws `ClassCastException` on a + // thread with no handler, and the match stalls with the + // server unable to end the phase. A rejected shot has to be + // rejected here, where it is counted. + if (getGame().getEntity(targetId) == null + || shooter.getWeapon(weaponId) == null) { rejected++; continue; } @@ -236,20 +364,53 @@ public final class SdsClient extends BotClient { tally.illegal++; System.err.println("[sds] " + getName() + " named " + rejected + " attacks with an unknown weapon or target"); - record(request, reply, "illegal", attacks.size() + " attacks declared"); + record(request, reply, "illegal", attacks.size() + " attacks declared", + shooter.getId()); } else { tally.answered++; - record(request, reply, "answered", attacks.size() + " attacks declared"); + record(request, reply, "answered", attacks.size() + " attacks declared", + shooter.getId()); } } } catch (Exception e) { tally.failed++; System.err.println("[sds] " + getName() + " firing failed, holding fire: " + e); - record(request, reply, "failed", "hold fire"); + record(request, reply, "failed", "hold fire", shooter.getId()); } sendAttackData(shooter.getId(), attacks); } + /** + * The unit the bot named, if the game will accept it. + * + *

A bot that names a unit which cannot act this turn is refused rather + * than obeyed, the host's own pick stands in, and the substitution is + * counted and printed. A silently substituted unit is the same failure + * class as an illegal path: the log would say the bot moved one machine + * while another one actually moved. + */ + private Entity chosenActor(JsonNode reply, List eligible, Entity fallback, + Tally tally) { + if (!reply.has("unit")) { + return fallback; + } + int named = reply.path("unit").asInt(Entity.NONE); + for (Entity candidate : eligible) { + if (candidate.getId() == named) { + tally.chose++; + if (named != fallback.getId()) { + tally.differed++; + } + return candidate; + } + } + tally.substituted++; + System.err.println("[sds] " + getName() + " chose unit " + named + + ", which cannot act this turn; using " + fallback.getShortName() + + " (#" + fallback.getId() + ") instead"); + return fallback; + } + @Override protected void calculateDeployment() { // getFirstDeployableEntityNum, not getFirstEntity. They are not the @@ -310,7 +471,7 @@ public final class SdsClient extends BotClient { outcome = "failed"; System.err.println("[sds] " + getName() + " deployment failed, using first legal hex: " + e); } - record(request, reply, outcome, where + " facing " + facing); + record(request, reply, outcome, where + " facing " + facing, unit.getId()); deploy(unit.getId(), where, unit.getBoardId(), facing, 0, List.of(), false); } @@ -455,7 +616,7 @@ public final class SdsClient extends BotClient { * otherwise fine, so the first failure is reported and the rest are silent. */ private void record(ObjectNode request, @Nullable JsonNode reply, String outcome, - String taken) { + String taken, int actedOn) { if (decisionLog == null || decisionLogBroken) { return; } @@ -468,6 +629,15 @@ public final class SdsClient extends BotClient { if (request.has("actor")) { line.put("actor", request.path("actor").asInt()); } + // The whole set the bot was offered, and the one that actually + // acted. "we sent the wrong unit" and "no other unit could have + // gone" must not look the same in this file. + if (request.has("eligible")) { + line.set("eligible", request.get("eligible")); + } + if (actedOn != Entity.NONE) { + line.put("actedOn", actedOn); + } line.put("outcome", outcome); line.put("taken", taken); if (reply != null) { @@ -475,8 +645,15 @@ public final class SdsClient extends BotClient { // question, and the rationale is lifted out so a reader does not // have to dig the interesting field out of the boring one. ObjectNode action = reply.deepCopy(); - action.remove(List.of("type", "seq", "rationale", "training")); + action.remove(List.of("type", "seq", "rationale", "training", "turn")); line.set("action", action); + // Why this unit and not another: the identities and values the + // bot ranked, and the replan that produced them. Identities and + // values only - a feature vector per eligible unit per turn + // would multiply a log that is already megabytes. + if (reply.path("turn").isObject()) { + line.set("turn", reply.get("turn")); + } if (reply.has("rationale")) { line.set("rationale", reply.get("rationale")); } @@ -665,6 +842,10 @@ public final class SdsClient extends BotClient { int defaulted = 0; int illegal = 0; int failed = 0; + int eligible = 0; + int chose = 0; + int differed = 0; + int substituted = 0; ObjectNode phases = mapper.createObjectNode(); for (Map.Entry entry : tallies.entrySet()) { Tally t = entry.getValue(); @@ -673,18 +854,33 @@ public final class SdsClient extends BotClient { defaulted += t.defaulted; illegal += t.illegal; failed += t.failed; + eligible += t.eligible; + chose += t.chose; + differed += t.differed; + substituted += t.substituted; 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); + phase.put("eligible", t.eligible); + phase.put("chose", t.chose); + phase.put("differed", t.differed); + phase.put("substituted", t.substituted); } n.put("decisions", decisions); n.put("answered", answered); n.put("defaulted", defaulted); n.put("illegal", illegal); n.put("failed", failed); + // Turn order, in total. `eligible` is a sum, not a count of decisions: + // divided by `decisions` it is the mean size of the set the bot was + // choosing from, and a mean of one means there was nothing to choose. + n.put("eligible", eligible); + n.put("chose", chose); + n.put("differed", differed); + n.put("substituted", substituted); n.put("disabled", disabled); if (decisionLog != null) { // Named in the result so a renderer given only the result document diff --git a/bridge/sds/SdsHost.java b/bridge/sds/SdsHost.java index 74ad884..a0e8a76 100644 --- a/bridge/sds/SdsHost.java +++ b/bridge/sds/SdsHost.java @@ -31,6 +31,8 @@ import megamek.common.game.IGame; import megamek.common.loaders.MekSummaryCache; import megamek.common.net.connections.AbstractConnection; import megamek.common.options.OptionsConstants; +import megamek.common.preference.ClientPreferences; +import megamek.common.preference.PreferenceManager; import megamek.common.scenario.Scenario; import megamek.common.scenario.ScenarioLoader; import megamek.common.equipment.WeaponMounted; @@ -40,13 +42,13 @@ import megamek.server.Server; import megamek.server.totalWarfare.TWGameManager; /** - * Run one headless match and write down what happened. + * Run one match and write down what happened. * - *

No renderer, no browser, no human seat: a scenario goes in, a result - * document comes out, and the process exits. That is the whole job. Everything - * about running a hundred of these, varying the seed and deciding whether a - * difference is real lives in the Python harness, because none of it needs a JVM - * and all of it is easier to change than this is. + *

No renderer and no browser: a scenario goes in, a result document comes + * out, and the process exits. That is the whole job. Everything about running a + * hundred of these, varying the seed and deciding whether a difference is real + * lives in the Python harness, because none of it needs a JVM and all of it is + * easier to change than this is. * *

  * SdsHost --scenario scenarios/lance-v-lance.mms --out result.json --seed 7 \
@@ -56,6 +58,13 @@ import megamek.server.totalWarfare.TWGameManager;
  * 

Every seat is named by its faction in the {@code .mms}. A seat with no * {@code --seat} is Princess, so the Princess-vs-Princess baseline needs no * flags at all. + * + *

A seat may also be {@code human}. The host then attaches nothing to it and + * leaves the slot open: MegaMek's own Swing client connects to this server over + * the loopback and a person plays it. The host synthesises no packet for that + * seat and never readies it - a scenario-hosted game opens in deployment rather + * than in the lounge, and the person's client is given its deployment turn when + * it arrives, however long it took to load. {@code sds play} starts both ends. */ public final class SdsHost { @@ -84,6 +93,33 @@ public final class SdsHost { // Before anything rolls a die, including the unit cache warm-up below. Compute.setRNG(new SeededRandom(a.seed)); + // MegaMek's own round report, written by MegaMek. + // + // Everything a match log is actually wanted for - to-hit numbers, the + // rolls behind them, hit locations, damage and criticals - is already + // assembled by the server and sent to every client as reports. A client + // whose `keepGameLog` is true writes them straight to disk. Nothing here + // formats a report, and nothing here walks `Game.getAllReports`: the + // only thing missing was the switch. + // + // `GameLog` resolves its directory once, in a static initialiser, from + // `ClientPreferences.getLogDirectory()` - default "logs", relative to the + // process working directory, which is the shape of `docs/UPSTREAM.md` + // entry 1. So it is left at the default on purpose and the *harness* + // gives each match its own `logs/` (see `sds/match.py`), rather than + // this trying to guess a path. Set before any client exists, because a + // static initialiser only runs once. + // + // Stamping is off: the name is already unique per match, and a date + // stamp would only make the file harder to find from the result. + ClientPreferences clientPrefs = PreferenceManager.getClientPreferences(); + clientPrefs.setKeepGameLog(a.report); + clientPrefs.setStampFilenames(false); + String reportName = matchTag(a) == null ? "gamelog.html" : matchTag(a) + ".report.html"; + clientPrefs.setGameLogFilename(reportName); + System.out.printf(" report : %s%n", + a.report ? clientPrefs.getLogDirectory() + "/" + reportName : "off"); + MekSummaryCache cache = MekSummaryCache.getInstance(); while (!cache.isInitialized()) { Thread.sleep(250L); @@ -249,10 +285,36 @@ public final class SdsHost { // BotClient, not Princess: an SDS seat shares no code with Princess and // the only thing the host needs from either is "connect, ready, play". + List humanSeats = new ArrayList<>(); List bots = new ArrayList<>(); Map sdsSeats = new LinkedHashMap<>(); for (Player p : slots) { String kind = a.seats.getOrDefault(p.getName(), "princess"); + // Say which bot a seat is, in the seat's own name, so MegaMek's + // report reads "Atlas II (sds North)" rather than "(North)". The + // seat that plays sds alternates between games in a bench, so the + // faction name alone does not say who is who. + p.setName(kind + " " + p.getName()); + // The seat map is keyed by name and is read again when the result + // is written, so the new name has to answer too or every seat comes + // back as the default. + a.seats.put(p.getName(), kind); + + // A human seat is a slot nobody here fills. + // + // The scenario already put this player and its units in the game; + // what is missing is a connection, and the whole point is that a + // person supplies it from MegaMek's own client. Attaching anything + // at all - even something inert that only says "ready" - would put + // a second author on this seat's orders, and would ready the seat + // before the person had looked at the map. + if ("human".equals(kind)) { + humanSeats.add(p.getName()); + System.out.printf(" seat : %-12s %s (team %d) - connect as this name%n", + p.getName(), kind, p.getTeam()); + continue; + } + BotClient bot; if ("sds".equals(kind)) { if (a.bot == null) { @@ -264,7 +326,7 @@ public final class SdsHost { sdsSeats.put(p.getName(), client); bot = client; } else if ("princess".equals(kind)) { - Princess princess = new Princess(p.getName(), LOCALHOST, a.port); + Princess princess = new ReportingBot(p.getName(), LOCALHOST, a.port); if (behaviour != null) { princess.setBehaviorSettings(behaviour); } @@ -275,12 +337,30 @@ public final class SdsHost { bot = princess; } else { throw new IllegalStateException("seat " + p.getName() - + " has unknown kind '" + kind + "'; want 'princess' or 'sds'"); + + " has unknown kind '" + kind + "'; want 'princess', 'sds' or 'human'"); + } + // Exactly one seat writes the round report. + // + // The report is the whole game's, not this player's - double blind + // is off - so one copy is the complete record and a second would be + // a duplicate. Worse than duplicate: `GameLog`'s filename is a + // single global preference, so two keepers would open the same file + // twice and interleave into it. + // + // The first seat, deterministically, so which file you get does not + // depend on connection order. + if (a.report && bots.isEmpty()) { + setKeepGameLogOn(bot); + System.out.printf(" reporter : %s%n", p.getName()); } System.out.printf(" seat : %-12s %s (team %d)%n", p.getName(), kind, p.getTeam()); bot.connect(); bots.add(bot); } + if (!humanSeats.isEmpty()) { + System.out.printf(" join : %s:%d as %s%n", LOCALHOST, a.port, + String.join(", ", humanSeats)); + } System.out.println("================================================="); for (BotClient bot : bots) { @@ -301,6 +381,20 @@ public final class SdsHost { } }, "sds-host-shutdown")); +// "The server is up and every bot seat is in it." + // + // `sds play` has to start MegaMek's client, and starting it against a + // socket that is not listening yet is a connection-refused dialog in a + // person's face. Polling the port would answer a different question - + // the server accepts connections some seconds before the bot seats have + // their units - and would do it by opening a connection the server then + // has to reject. One file, written once, after the seats are seated. + if (a.readyFile != null) { + Files.createDirectories(a.readyFile.toAbsolutePath().getParent()); + Files.writeString(a.readyFile, "ready\n", StandardCharsets.UTF_8); + System.out.println("[host] ready: " + a.readyFile); + } + ArrayNode roundLog = MAPPER.createArrayNode(); long start = System.currentTimeMillis(); long lastReady = 0L; @@ -365,6 +459,14 @@ public final class SdsHost { // acknowledges every report phase, and AbstractClient covers // EXCHANGE and STARTING_SCENARIO. Any extra Done is a surplus // packet looking for a phase to break. + // + // A human seat needs nothing from this. A scenario-hosted game does + // not sit in the lounge at all - `ScenarioLoader` hands the server + // a game that opens on "Initiative Phase for Start of Game + // Deployment" - so a person's client joins a game already in + // deployment and is given its deployment turn when it arrives. + // Nothing here readies anybody on their behalf, and nothing needs + // to. if (!bots.isEmpty() && bots.get(0).getGame().getPhase().isLounge()) { long now = System.currentTimeMillis(); if (now - lastReady > 2_000L) { @@ -443,7 +545,12 @@ public final class SdsHost { lastActivity = activity; lastProgress = System.currentTimeMillis(); } - if (System.currentTimeMillis() - lastProgress > a.stallSeconds * 1000L) { + // Both watchdogs are off at 0, which is what a human seat needs. + // A person takes minutes over a movement phase and would trip a + // stall detector tuned for two bots; a game called "stalled" + // halfway through is a game thrown away. + if (a.stallSeconds > 0 + && System.currentTimeMillis() - lastProgress > a.stallSeconds * 1000L) { dumpThreads(); // Nothing has happened for long enough that nothing is going to. // Recorded, not retried: a harness that silently reruns stalls @@ -451,7 +558,7 @@ public final class SdsHost { outcome = "stalled"; break; } - if (System.currentTimeMillis() > wallDeadline) { + if (a.wallClockSeconds > 0 && System.currentTimeMillis() > wallDeadline) { outcome = "timeout"; break; } @@ -459,6 +566,29 @@ public final class SdsHost { Thread.sleep(200L); } + // Let the clients receive END_OF_GAME before the server is torn down. + // + // `Client.saveEntityStatus` writes `entitystatus.txt` - every unit's + // armour diagram, pilot, and component list at the moment the match + // ended - and it runs on the END_OF_GAME packet, which the server sends + // *after* the victory phase change this loop latches on. Without a + // grace period the host wins that race about as often as it loses it, + // so the file existed for some matches and not others with nothing in + // the run to say which. + // + // Bounded, and polled for the artefact rather than slept blind, so a + // match that never gets the packet costs five seconds rather than + // hanging. + if ("victory".equals(outcome)) { + Path endState = Path.of( + PreferenceManager.getClientPreferences().getLogDirectory(), "entitystatus.txt"); + long endDeadline = System.currentTimeMillis() + 5_000L; + while (!Files.exists(endState) && System.currentTimeMillis() < endDeadline) { + Thread.sleep(100L); + } + System.out.printf("[host] end-of-game state written: %b%n", Files.exists(endState)); + } + long elapsed = System.currentTimeMillis() - start; // The latched snapshot if the game was decided, otherwise whatever the // game looks like now - which for a round limit or a stall is exactly @@ -469,11 +599,75 @@ public final class SdsHost { } report(a, state, outcome, elapsed, sdsSeats, roundLog); + // Stay up so a person can read the end-of-game screen. + // + // The result is already written by here, so nothing is waiting on this; + // what it buys is that MegaMek's client is not yanked off its victory + // report by the server vanishing under it. Zero on the batch path, + // where nobody is looking. + if (a.lingerSeconds > 0) { + System.out.printf("[host] result written; holding the server open for %ds%n", + a.lingerSeconds); + Thread.sleep(a.lingerSeconds * 1000L); + } + sdsSeats.values().forEach(SdsClient::stop); server.die(); System.exit(0); } + /** + * Ask one seat to keep the round report. + * + *

`AbstractClient.keepGameLog` reads the preference set in `main`, but + * `BotClient` overrides it to a hard `false` - a bot-only match therefore + * writes no report however the preference is set. Both seat kinds here are + * `BotClient`s, so each carries a three-line override that puts the + * preference back in charge, and this turns it on for one of them. + * + *

Nothing else about either client changes. This is a logging predicate; + * it is not consulted anywhere a decision is made. + */ + private static void setKeepGameLogOn(BotClient bot) { + if (bot instanceof SdsClient sds) { + sds.setKeepsGameLog(true); + } else if (bot instanceof ReportingBot princess) { + princess.setKeepsGameLog(true); + } + } + + /** + * A Princess seat that will write the round report when asked to. + * + *

The only override is `keepGameLog`, and it makes no decision. This + * exists because the flag it overrides is `protected` on `BotClient` and + * hard-coded to `false` there; there is no setter to call instead. + */ + static final class ReportingBot extends Princess { + private boolean keepsGameLog; + + ReportingBot(String name, String host, int port) { + super(name, host, port); + } + + void setKeepsGameLog(boolean keep) { + this.keepsGameLog = keep; + } + + @Override + protected boolean keepGameLog() { + return keepsGameLog; + } + + @Override + public String receiveReport(java.util.List reports) { + if (!keepsGameLog) { + return super.receiveReport(reports); + } + return SeatReports.format(this, reports); + } + } + /** * What every thread is doing, printed when a match stalls. * @@ -720,6 +914,8 @@ public final class SdsHost { Map gameOptions = new LinkedHashMap<>(); Path out; Path logDir; + /** Touched once the lounge is up and every bot seat is in it. */ + Path readyFile; int port = 8850; long seed = 1L; int maxRounds = 40; @@ -731,7 +927,11 @@ public final class SdsHost { // which is a bias no sample size removes. long wallClockSeconds = 1800L; long stallSeconds = 300L; + /** Seconds to hold the server open after the result is written. */ + long lingerSeconds = 0L; String behavior; + /** Whether one seat writes MegaMek's round report. */ + boolean report = true; int bvDestroyedPercent = 70; List bot; final Map seats = new LinkedHashMap<>(); @@ -744,6 +944,8 @@ public final class SdsHost { case "--scenario" -> a.scenario = argv[++i]; case "--out" -> a.out = Path.of(argv[++i]); case "--log-dir" -> a.logDir = Path.of(argv[++i]); + case "--ready-file" -> a.readyFile = Path.of(argv[++i]); + case "--linger-seconds" -> a.lingerSeconds = Long.parseLong(argv[++i]); case "--port" -> a.port = Integer.parseInt(argv[++i]); case "--seed" -> a.seed = Long.parseLong(argv[++i]); case "--game-option" -> { @@ -755,6 +957,7 @@ public final class SdsHost { case "--wall-clock" -> a.wallClockSeconds = Long.parseLong(argv[++i]); case "--stall-seconds" -> a.stallSeconds = Long.parseLong(argv[++i]); case "--behavior" -> a.behavior = argv[++i]; + case "--report" -> a.report = Boolean.parseBoolean(argv[++i]); case "--bv-destroyed-percent" -> a.bvDestroyedPercent = Integer.parseInt(argv[++i]); // Split on whitespace, so a bot command with a space in a diff --git a/bridge/sds/SeatReports.java b/bridge/sds/SeatReports.java new file mode 100644 index 0000000..291e19a --- /dev/null +++ b/bridge/sds/SeatReports.java @@ -0,0 +1,87 @@ +package sds; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.util.List; + +import megamek.client.Client; +import megamek.common.Report; + +/** + * Let one bot seat write MegaMek's own round report. + * + *

The report - to-hit numbers, the rolls behind them, hit locations, damage + * and criticals - is assembled by the server and sent to every client, and + * {@code Client} already writes it to disk. Nothing in this repository formats + * a report or walks {@code Game.getAllReports}; the file is MegaMek's, written + * by MegaMek. + * + *

What this class exists for. {@code BotClient} opts out of that + * machinery twice, and both are hard-coded rather than settings: + * + *

    + *
  • {@code keepGameLog()} returns {@code false}, ignoring + * {@code ClientPreferences.keepGameLog}. A seat can override it.
  • + *
  • {@code receiveReport(List)} returns {@code ""}, discarding the reports + * before they reach the log. This one replaces the formatter, so + * overriding it is not enough - the override has to reach the + * implementation {@code BotClient} shadowed.
  • + *
+ * + *

{@code super.receiveReport} from a subclass of {@code BotClient} resolves + * to {@code BotClient}'s empty one; Java has no syntax for reaching a + * grandparent's. So the handle below is an {@code invokespecial} to + * {@code Client.receiveReport}, which is exactly what a human seat runs. The + * alternative was to reimplement it, and a second report formatter that drifts + * from MegaMek's is worth less than no report at all. + * + *

Recorded in {@code docs/UPSTREAM.md}. + */ +final class SeatReports { + + private static final MethodType TYPE = MethodType.methodType(String.class, List.class); + + /** + * A handle on {@code Client.receiveReport}, bypassing {@code BotClient}'s + * override. + * + *

The special caller has to be {@code Client} itself, not the seat. + * {@code findSpecial} has {@code invokespecial} semantics, and + * {@code invokespecial} from a class whose resolved method sits in a + * *super*class starts its search at the direct superclass - which for any + * seat here is {@code BotClient}, so a handle made from the seat's own + * lookup lands right back on the empty override. Measured, not assumed: + * with the seat as special caller the handle returns {@code ""} for a null + * report list, and with {@code Client} it returns + * {@code "[null report vector]"}, which is the line only + * {@code Client.receiveReport} has. + */ + private static final MethodHandle FORMATTER = formatter(); + + static MethodHandle formatter() { + try { + MethodHandles.Lookup lookup = + MethodHandles.privateLookupIn(Client.class, MethodHandles.lookup()); + return lookup.findSpecial(Client.class, "receiveReport", TYPE, Client.class); + } catch (ReflectiveOperationException e) { + // A MegaMek upgrade that moves or renames this is a thing to notice + // at once, not a report that quietly goes empty again. + throw new IllegalStateException( + "MegaMek's Client.receiveReport is not where it was; the round report needs it", + e); + } + } + + /** MegaMek's own rendering of one report list, as a human seat gets it. */ + static String format(Client seat, List reports) { + try { + return (String) FORMATTER.invoke(seat, reports); + } catch (Throwable t) { + throw new IllegalStateException("MegaMek's report formatter threw", t); + } + } + + private SeatReports() { + } +} diff --git a/crates/sds-bot/src/candidates.rs b/crates/sds-bot/src/candidates.rs index 3d77ee7..74f3901 100644 --- a/crates/sds-bot/src/candidates.rs +++ b/crates/sds-bot/src/candidates.rs @@ -38,6 +38,7 @@ use std::collections::BTreeSet; +use sds_core::brackets::RangeTable; use sds_core::facts::Gait; use sds_core::hex::Stand; use sds_core::los::LosCache; @@ -72,15 +73,6 @@ pub fn enabled() -> bool { ) } -/// The most generated states one unit puts on the menu. -/// -/// The candidates invariant: every performance estimate in this repository -/// assumes about twenty curated candidates per unit, and `ForceThinker::command` -/// is linear in what it is handed. The sweep scores every reachable state - a -/// hundred and up - and the surfacing rule is what cuts that to a menu. This is -/// the ceiling on the result, not a prune inside the sweep. -pub const MAX_GENERATED: usize = 16; - /// What one unit's sweep cost, in counters. #[derive(Debug, Clone, Copy, Default)] pub struct Cost { @@ -93,6 +85,15 @@ pub struct Cost { /// How many enemies had not taken their turn when this ran. These are the /// ones contributing more than one position. pub unmoved: u64, + /// Spans in this unit's own range line, from + /// [`sds_core::brackets::RangeTable`]. Rebuilt every decision: an ammo bin + /// running out moves the boundaries. + pub brackets: u64, + /// Weapons this unit carries that the bot does not model - artillery, a + /// designator, an anti-missile system. Counted rather than silently + /// dropped: a unit whose whole armament is unmodelled should look like a + /// unit with no guns. + pub unmodelled: u64, } /// The generated half of a unit's menu. @@ -139,6 +140,10 @@ pub fn generate( .filter(|w| w.usable) .map(MountedWeapon::from_wire) .collect(); + // Built from this observation and thrown away with it. An empty ammo bin + // invalidates a range line the way a destroyed weapon does, so there is + // deliberately no cache here to go stale between rounds. + let line = RangeTable::of(&mine); let mover = Mover { who: Combatant::mek(me, mine, me.gunnery.max(0)), elevation: me.elevation, @@ -190,32 +195,40 @@ pub fn generate( let measured = stands::propose(los, &mover, &ranking, &foes, &friends); let mut seen: BTreeSet = BTreeSet::new(); let mut out: Vec = Vec::new(); - for pick in &surfaced.picks { - if out.len() >= MAX_GENERATED || !seen.insert(pick.at) { + // Every scored stand goes up, not just the surfaced ones. The unit does not + // prune: the force chooses. `surfaced` only supplies a readable label where + // one exists. + let labelled: std::collections::BTreeMap = surfaced + .picks + .iter() + .map(|pick| (pick.at, pick.label.as_str())) + .collect(); + for at in 0..ranking.scored.len() { + let pick_at = at; + let pick_label = labelled.get(&at).copied().unwrap_or("stand"); + if !seen.insert(pick_at) { continue; } - let Some(score) = ranking.scored.get(pick.at) else { + let Some(score) = ranking.scored.get(pick_at) else { continue; }; let stand = score.reach.stand; - // A stand the route cannot be walked back to is dropped rather than - // sent with a guessed path. The host would reject the order and the - // unit would stand still, which is a worse answer than one fewer - // candidate. - let Some(steps) = steps_to(&found, stand, me.prone) else { - continue; - }; - let Some(mut proposal) = measured.get(pick.at).cloned() else { + // The stand came out of this same search, so its predecessor chain is + // there by construction. A miss is a bug in the search, not a candidate + // to drop quietly. + let steps = steps_to(&found, stand, me.prone) + .expect("a stand from this search has a route back through it"); + let Some(mut proposal) = measured.get(pick_at).cloned() else { continue; }; proposal.label = format!( "{}{MARK}({}, {}) facing {}", - pick.label, stand.hex.x, stand.hex.y, stand.facing + pick_label, stand.hex.x, stand.hex.y, stand.facing ); proposal.action = Action::Move { steps }; proposal.notes.push(format!( - "cost {:.3}, {} mp, {} hexes", - pick.cost, score.reach.mp_spent, score.reach.hexes_moved + "{} mp, {} hexes", + score.reach.mp_spent, score.reach.hexes_moved )); out.push(proposal); } @@ -228,6 +241,8 @@ pub fn generate( surfaced: out.len() as u64, positions, unmoved, + brackets: line.brackets().len() as u64, + unmodelled: line.excluded().len() as u64, }; Some((out, cost)) } diff --git a/crates/sds-bot/src/imitate.rs b/crates/sds-bot/src/imitate.rs index 6b5cfe0..c4a71ff 100644 --- a/crates/sds-bot/src/imitate.rs +++ b/crates/sds-bot/src/imitate.rs @@ -308,6 +308,10 @@ fn mirror(observation: &Observation, subject: &Unit) -> Observation { mirrored.player_id = subject.owner_id; mirrored.team = subject.team; mirrored.actor = Some(subject.id); + // Ours too, and about our units: an eligible set from our seat would offer + // another player's force a choice between our machines. A reconstruction is + // about one unit that we watched move, so that unit is the whole set. + mirrored.eligible = vec![subject.id]; // Ours, and about our units. Neither survives the change of seat, and // leaving them in would offer another player's unit our shots. mirrored.shots.clear(); @@ -346,7 +350,7 @@ pub struct Recorder { /// /// It has to be the same or the tag/seat split in `sds/viewer.py` stops working /// on one of the two files. -fn safe_name(name: &str) -> String { +pub fn safe_name(name: &str) -> String { let mut out = String::new(); let mut last_underscore = false; for c in name.chars() { @@ -513,6 +517,8 @@ mod tests { internal_max: 60, heat: 0, heat_capacity: 10, + heat_buildup: 0, + explodable_ammo: false, prone: false, shut_down: false, destroyed: false, @@ -532,6 +538,8 @@ mod tests { short: 6, medium: 12, long_range: 18, + // A real PPC has one. See `wire::Weapon::minimum`. + minimum: 3, avg_damage_short: 10.0, avg_damage_medium: 10.0, avg_damage_long: 10.0, @@ -539,6 +547,7 @@ mod tests { damage_per_packet: 10.0, usable: true, ammo: None, + modes: Vec::new(), }], } } @@ -551,6 +560,7 @@ mod tests { player_id: 0, team: 1, actor: None, + eligible: Vec::new(), units, shots: Vec::new(), deploy_hexes: Vec::new(), diff --git a/crates/sds-bot/src/main.rs b/crates/sds-bot/src/main.rs index e366637..169e737 100644 --- a/crates/sds-bot/src/main.rs +++ b/crates/sds-bot/src/main.rs @@ -23,13 +23,14 @@ use sds_core::ev::EvCache; use sds_core::features::decision::Decision; use sds_core::features::firing::Volley; use sds_core::features::force::JointAssignment; +use sds_core::features::heat::HeatOutlook; use sds_core::features::latch::Latches; use sds_core::features::target::TargetView; use sds_core::features::{FeatureVector, Weights}; use sds_core::hex::facing_toward; use sds_core::hitloc; use sds_core::los::side_table; -use sds_core::plan::ForceDecision; +use sds_core::plan::{ForceDecision, Replan, TurnChoice, TurnOption}; use sds_core::stance::{Grit, Stance}; use sds_core::wire::{Action, Attack, Board, Coord, Incoming, Observation, Reply, Shot, Unit}; use sds_node::{Node, Registry, Request}; @@ -68,6 +69,12 @@ struct PlanTally { /// Orders chosen, split by which generator produced the winning label. chosen_generated: u64, chosen_verbs: u64, + /// Turn-order decisions taken, the eligible units summed over them, and how + /// often our pick was not the host's own first-eligible. The mean set size + /// is `eligible / turns`; a mean of one means there was nothing to choose. + turns: u64, + eligible: u64, + differed: u64, } impl PlanTally { @@ -86,7 +93,8 @@ impl PlanTally { fn summary(&self) -> String { format!( "{} cycles, {:.0} ms mean, {:.0} ms worst; \ -chosen {} generated / {} four-verb ({:.0}% verbs)", +chosen {} generated / {} four-verb ({:.0}% verbs); \ +{} turns, {:.2} eligible mean, {} differed from first-eligible", self.cycles, if self.cycles == 0 { 0.0 @@ -97,10 +105,30 @@ chosen {} generated / {} four-verb ({:.0}% verbs)", self.chosen_generated, self.chosen_verbs, self.verb_share() * 100.0, + self.turns, + if self.turns == 0 { + 0.0 + } else { + self.eligible as f64 / self.turns as f64 + }, + self.differed, ) } } +/// One decision, and which of our units it is for. +/// +/// The unit is the change: the host's `actor` is a default it will use if we do +/// not choose, and naming one here is what makes turn order the force's +/// decision rather than the bridge's list order. +struct Turn { + action: Action, + unit: Option, + /// The turn-choice record, for the decision log. Absent in a phase with no + /// choice to make. + choice: Option, +} + /// One unit's standing order, and which generator produced it. /// /// The label is not carried: attribution is a boolean taken from @@ -111,6 +139,11 @@ chosen {} generated / {} four-verb ({:.0}% verbs)", struct Order { action: Action, generated: bool, + /// What the force scored this order at. The turn-order rule reads it; see + /// [`Bot::rank`]. + value: f32, + /// The proposal's own short label, for the turn-choice record. + label: String, } /// The bot's memory between decisions. @@ -148,6 +181,12 @@ struct Bot { weights: Weights, /// What planning cost and who won it. Printed, never read by a decision. tally: PlanTally, + /// The rebuild the last [`Bot::plan`] call performed, or `None` when it + /// reused the standing plan. Read once, by the decision that caused it. + replan: Option, + /// Rebuilds so far this round, and the round they belong to. + replans_this_round: u32, + replan_round: i32, } /// Where the weights come from, and what to say when they cannot be read. @@ -205,6 +244,30 @@ fn load_weights(path: &std::path::Path) -> Result { Ok(weights) } +/// Which prefix lengths of an `n`-weapon ladder to offer, at most `keep` of +/// them. +/// +/// Every length when they all fit. When they do not, an even spread that always +/// keeps both ends: one gun and the alpha strike. Lengths are 1-based and come +/// back ascending and unique. +fn rungs(n: usize, keep: usize) -> Vec { + if n == 0 { + return Vec::new(); + } + if keep >= n || keep <= 1 { + return if keep <= 1 { + vec![n] + } else { + (1..=n).collect() + }; + } + let mut sizes: Vec = (0..keep) + .map(|i| 1 + (i * (n - 1) + (keep - 1) / 2) / (keep - 1)) + .collect(); + sizes.dedup(); + sizes +} + impl Bot { fn new(registry: &Registry, weights: Weights) -> Result { // Addresses, not types. The defaults keep everything in this process; @@ -232,6 +295,9 @@ impl Bot { latches: Latches::new(), weights, tally: PlanTally::default(), + replan: None, + replans_this_round: 0, + replan_round: -1, }) } @@ -297,8 +363,46 @@ impl Bot { async fn plan(&mut self, observation: &Observation) -> Result<()> { let settled = Self::settled(observation); if self.planned.as_ref() == Some(&(observation.round, settled.clone())) { + self.replan = None; return Ok(()); } + // Why this rebuild happened, named before the old key is thrown away. + // A plan is keyed on the round and on which enemies will not move + // again, so one of those two changed and the reader deserves to be told + // which - and, when it was an enemy, which enemy. + self.replan = Some(match self.planned.as_ref() { + Some((round, was)) if *round == observation.round => { + let newly: Vec = settled.difference(was).copied().collect(); + if newly.is_empty() { + Replan { + settled: Vec::new(), + reason: "the settled set changed".to_string(), + } + } else { + Replan { + reason: format!( + "enemy {} settled", + newly + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(", ") + ), + settled: newly, + } + } + } + _ => Replan { + settled: Vec::new(), + reason: format!("round {} began", observation.round), + }, + }); + if self.replan_round == observation.round { + self.replans_this_round += 1; + } else { + self.replan_round = observation.round; + self.replans_this_round = 1; + } self.orders.clear(); self.stalled.clear(); self.decisions.clear(); @@ -420,6 +524,8 @@ impl Bot { Order { action: proposal.action.clone(), generated: candidates::is_generated(&proposal.label), + value: chosen.value, + label: chosen.label.clone(), }, ); } @@ -479,19 +585,128 @@ impl Bot { } } - async fn movement(&mut self, observation: &Observation) -> Result { + /// Rank the eligible units by what their standing orders are worth. + /// + /// **The rule, stated:** play the most valuable standing order first, ties + /// on the lower unit id. Simple and defensible rather than clever, and the + /// point of this change is that the choice is ours to make - not that this + /// first rule is the right one. + /// + /// Defensible because an order's value was measured against the enemy + /// envelope as it stands *now*, and that envelope collapses every time an + /// enemy finishes its move. The order we most want is therefore the one we + /// should secure before the world it was priced in changes. It reads from + /// what `reconcile` already produced - one order per unit with a value + /// attached - rather than inventing a second ranking. + /// + /// Deterministic: `f32::total_cmp` is a total order and the unit id breaks + /// every tie, so the same set ranks the same way whatever order the host + /// listed it in and whatever order the units finished thinking in. + fn rank(&self, eligible: &[i32]) -> Vec<(i32, f32)> { + let mut ranked: Vec<(i32, f32)> = eligible + .iter() + .filter_map(|id| self.orders.get(id).map(|order| (*id, order.value))) + .collect(); + ranked.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0))); + ranked + } + + /// The turn-choice record: who could have gone, who did, and why now. + fn turn_choice( + &self, + phase: &str, + eligible: &[i32], + chose: i32, + value: Option, + considered: Vec, + replan: Option, + ) -> TurnChoice { + TurnChoice { + phase: phase.to_string(), + eligible: eligible.to_vec(), + chose, + value, + differed: eligible.first() != Some(&chose), + considered, + replan, + replans_this_round: self.replans_this_round, + } + } + + /// Movement: which of ours goes now, and where. + /// + /// Returns the unit as well as the action. The host's `actor` is only a + /// default; naming the unit is what makes the choice ours. + async fn movement(&mut self, observation: &Observation) -> Result { self.plan(observation).await?; - let actor = observation.actor.unwrap_or(-1); - let Some(order) = self.orders.remove(&actor) else { + let eligible = observation.eligible_units(); + let ranked = self.rank(&eligible); + let considered: Vec = eligible + .iter() + .map(|id| match self.orders.get(id) { + Some(order) => TurnOption { + unit: *id, + label: order.label.clone(), + value: Some(order.value), + }, + None if self.stalled.contains(id) => TurnOption { + unit: *id, + label: "stalled: every stand was claimed".to_string(), + value: None, + }, + None => TurnOption { + unit: *id, + label: "no order".to_string(), + value: None, + }, + }) + .collect(); + + let Some(&(actor, value)) = ranked.first() else { + // Nothing eligible has an order. Fall back to the host's own pick + // so the record names the unit that will actually act. + let actor = eligible + .first() + .copied() + .or(observation.actor) + .unwrap_or(-1); + let choice = self.turn_choice( + "movement", + &eligible, + actor, + None, + considered, + self.replan.clone(), + ); // A unit whose every stand was in a hex somebody else claimed has // no answer, and saying "stand still" would be counted as one. // Pass: the host stands it still and counts the decision defaulted. - if self.stalled.contains(&actor) { - return Ok(Action::Pass); - } - // Ours, and no order for it. The inert default, not Princess. - return Ok(Action::Move { steps: Vec::new() }); + let action = if self.stalled.contains(&actor) { + Action::Pass + } else { + // Ours, and no order for it. The inert default, not Princess. + Action::Move { steps: Vec::new() } + }; + self.count_turn(&eligible, &choice); + return Ok(Turn { + action, + unit: Some(actor), + choice: Some(choice), + }); }; + let choice = self.turn_choice( + "movement", + &eligible, + actor, + Some(value), + considered, + self.replan.clone(), + ); + self.count_turn(&eligible, &choice); + let order = self + .orders + .remove(&actor) + .expect("rank only returns units with an order"); // Counted when the order is played, not when it was planned: a rebuilt // plan would otherwise count one turn several times. if order.generated { @@ -499,7 +714,20 @@ impl Bot { } else { self.tally.chosen_verbs += 1; } - Ok(order.action) + Ok(Turn { + action: order.action, + unit: Some(actor), + choice: Some(choice), + }) + } + + /// Observability only. Never read by a decision. + fn count_turn(&mut self, eligible: &[i32], choice: &TurnChoice) { + self.tally.turns += 1; + self.tally.eligible += eligible.len() as u64; + if choice.differed { + self.tally.differed += 1; + } } /// Firing: build a menu of allocations, measure each, take the best. @@ -509,15 +737,84 @@ impl Bot { /// the weapons is 2^n and puts a "thinking..." message in front of a /// player. /// - /// Heat is a weight rather than a rule, with one rule left: an allocation - /// that would exceed what the unit can shed plus a small overrun is not - /// offered at all. The bot may cook itself a little for a kill and may not - /// shut itself down for a scratch. - fn firing(&mut self, observation: &Observation) -> Action { - let actor = observation.actor.unwrap_or(-1); - let Some(me) = observation.units.iter().find(|u| u.id == actor) else { - return Action::Fire { attacks: vec![] }; + /// Heat is a weight and no longer a rule. The rule that used to be left - + /// no allocation above what the unit can shed plus a small overrun - was + /// reading dissipation as a shutdown threshold, and it is gone; see + /// [`Bot::allocations`]. + /// Firing: which of ours declares now, and at what. + /// + /// The same rule movement uses, over the same kind of number: every + /// eligible shooter's menu is measured, and the one whose best allocation + /// is worth most declares first. Ties on the lower unit id. + /// + /// Declaration order is load-bearing here in a way it is not in movement. + /// `JointAssignment` measures concentration against `self.declared`, so + /// whoever fires first sets the target the rest concentrate on - and until + /// now that was whichever unit MegaMek happened to list first. + fn firing(&mut self, observation: &Observation) -> Turn { + let eligible = observation.eligible_units(); + let mut ranked: Vec<(i32, f32, String)> = Vec::new(); + let mut rows: HashMap)> = HashMap::new(); + for id in &eligible { + let Some(me) = observation.units.iter().find(|u| u.id == *id) else { + continue; + }; + let Some((action, row, target)) = self.fire_for(observation, me) else { + continue; + }; + let taken = row.taken(); + ranked.push((*id, taken.value, taken.label.clone())); + rows.insert(*id, (row, action, target)); + } + ranked.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0))); + let considered: Vec = ranked + .iter() + .map(|(unit, value, label)| TurnOption { + unit: *unit, + label: label.clone(), + value: Some(*value), + }) + .collect(); + + let Some((actor, value, _)) = ranked.first().cloned() else { + let actor = eligible + .first() + .copied() + .or(observation.actor) + .unwrap_or(-1); + let choice = self.turn_choice("firing", &eligible, actor, None, considered, None); + self.count_turn(&eligible, &choice); + return Turn { + action: Action::Fire { attacks: vec![] }, + unit: Some(actor), + choice: Some(choice), + }; }; + let choice = self.turn_choice("firing", &eligible, actor, Some(value), considered, None); + self.count_turn(&eligible, &choice); + let (row, action, target) = rows.remove(&actor).expect("the ranked unit was measured"); + self.training.insert(actor, row); + if let Some(target) = target { + self.declared.push((actor, target)); + } + Turn { + action, + unit: Some(actor), + choice: Some(choice), + } + } + + /// One unit's firing menu, measured and won. + /// + /// Read-only: nothing is declared and no training row is filed until the + /// force has picked which unit actually shoots. Returns the action, the + /// row, and the target declared against - `None` for "hold fire", which is + /// a decision and is measured as one. + fn fire_for( + &self, + observation: &Observation, + me: &Unit, + ) -> Option<(Action, Decision, Option)> { let here = Coord::new(me.x, me.y); let field: Vec<&Unit> = observation .units @@ -533,12 +830,20 @@ impl Bot { } let spread: Vec = damages.iter().map(|pmf| pmf.mean()).collect(); - // Holding fire is always on the menu, and it measures as nothing rather - // than as a low number: a volley whose heat and waste outweigh its - // damage should lose to not firing, and it can only do that if not - // firing is a candidate with a value. + // Holding fire is always on the menu, and it is measured rather than + // left out: a volley whose heat and waste outweigh its damage should + // lose to not firing, and it can only do that if not firing is a + // candidate with a value. + // + // The heat features are the one family it carries, at zero extra heat. + // They read where the unit ends the turn, and holding fire does not + // undo the heat it is already carrying - so a warm unit scored against + // an all-zero hold-fire vector would see every firing candidate as + // worse by the heat it was already committed to, and would stop + // shooting for the rest of the match. Everything else stays zero: not + // firing does no damage, at nobody. let mut menu: Vec<(String, FeatureVector)> = - vec![("hold fire".to_string(), FeatureVector::new())]; + vec![("hold fire".to_string(), HeatOutlook::new(me, 0).features())]; // Menu index to allocation index. Not matched by label on the way back: // two enemies of the same chassis produce the same label, and a lookup // by name would sometimes declare against the wrong machine while the @@ -590,17 +895,13 @@ impl Bot { from_menu.push(Some(index)); } - let Some(row) = Decision::score(me.id, &menu, &self.weights) else { - return Action::Fire { attacks: vec![] }; - }; + let row = Decision::score(me.id, &menu, &self.weights)?; let taken = from_menu[row.chosen]; - self.training.insert(me.id, row); // `None` is "hold fire", which is a decision and is recorded as one. let Some((_, target_id, shots)) = taken.map(|index| &allocations[index]) else { - return Action::Fire { attacks: vec![] }; + return Some((Action::Fire { attacks: vec![] }, row, None)); }; - self.declared.push((me.id, *target_id)); - Action::Fire { + let action = Action::Fire { attacks: shots .iter() .map(|shot| Attack { @@ -608,26 +909,52 @@ impl Bot { target: shot.target, }) .collect(), - } + }; + let target = *target_id; + Some((action, row, Some(target))) } /// The menu of allocations: some of this unit's weapons, all at one target. /// - /// Per target, the shots are ranked by expected damage per point of heat - /// and three prefixes are offered - the best gun alone, half the guns, and - /// everything that fits the heat allowance. Three is a judgement: it spans - /// "snipe and stay cool" to "alpha strike" without enumerating the subsets - /// between them, and the features decide which end of that a situation - /// wants. + /// A ladder, not a budget. Per target the weapons are ordered heaviest + /// packet first and every prefix of that order is offered, so the weights + /// can pick any point between one gun and the alpha strike. + /// + /// **The heat budget is gone.** This used to truncate every volley to + /// `heat_capacity - heat + 4`, and `heat_capacity` is *dissipation* - what + /// the sinks shed in a turn, not the shutdown threshold. The bot therefore + /// never fired more than it could sink, as a rule no weight could see or + /// overrule, and fired about 63% as often as Princess. Heat is priced by + /// `sds_core::features::heat` now, on the scale the volley would leave the + /// unit on. + /// + /// **Heaviest packet first, not best damage per point of heat.** This is + /// mechanical rather than a preference: damage inside a volley resolves + /// one weapon at a time, so a big gun that breaches armour first means + /// every later hit lands in open structure and buys its own critical roll. + /// Against 8 points of armour, a PPC then two SRM hits is three rolls on + /// the critical table; the same three hits in the other order is one. fn allocations(&self, observation: &Observation, me: &Unit) -> Vec<(String, i32, Vec)> { - let budget = (me.heat_capacity - me.heat).max(0) + 4; // By target id, so the menu is the same list in the same order on every // run whatever order the host listed the shots in. + // + // This unit's shots and nobody else's. The observation prices every + // eligible shooter now, and a weapon id is only meaningful against the + // unit it is mounted on - building a menu for one Mek out of another's + // equipment numbers is how an ammo bin got declared as a weapon. A shot + // with no `shooter` is from a host older than the field, where every + // shot belonged to the actor and this is a no-op. let mut by_target: BTreeMap> = BTreeMap::new(); for shot in &observation.shots { + if shot.shooter.is_some_and(|id| id != me.id) { + continue; + } by_target.entry(shot.target).or_default().push(shot.clone()); } - let mut out = Vec::new(); + + // One ladder per target, before any thinning, so the cap is spent + // evenly over the targets rather than on whichever has the lowest id. + let mut ladders: Vec<(i32, String, Vec)> = Vec::new(); for (target, mut shots) in by_target { let name = observation .units @@ -635,44 +962,59 @@ impl Bot { .find(|u| u.id == target) .map(|u| u.name.clone()) .unwrap_or_else(|| target.to_string()); - // Best expected damage per point of heat, so a big hot gun has to - // earn its place against two cool ones. Ties break on weapon id. shots.sort_by(|a, b| { - let value = |s: &Shot| s.expected_damage() / (s.heat.max(1) as f32); - value(b) - .partial_cmp(&value(a)) + let packet = |s: &Shot| { + if s.damage_per_packet > 0.0 { + s.damage_per_packet + } else { + // An observation older than the field, where the whole + // shot is one packet as far as anything here knows. + s.damage + } + }; + packet(b) + .partial_cmp(&packet(a)) .unwrap_or(std::cmp::Ordering::Equal) .then(a.weapon.cmp(&b.weapon)) }); - let mut affordable: Vec = Vec::new(); - let mut used: Vec = Vec::new(); - let mut left = budget; + // One shot per weapon: a launcher with two loads offers the same + // tube twice, and firing it twice is not a thing the rules allow. + let mut ordered: Vec = Vec::new(); for shot in shots { - if used.contains(&shot.weapon) || shot.heat > left { + if ordered.iter().any(|kept| kept.weapon == shot.weapon) { continue; } - left -= shot.heat; - used.push(shot.weapon); - affordable.push(shot); + ordered.push(shot); } - if affordable.is_empty() { - continue; + if !ordered.is_empty() { + ladders.push((target, name, ordered)); } - let mut sizes = vec![1, affordable.len().div_ceil(2), affordable.len()]; - sizes.dedup(); - for size in sizes { + } + if ladders.is_empty() { + return Vec::new(); + } + + // The cap, spent evenly. Each target gets the same number of rungs, and + // thinning takes the middle of a ladder rather than its tail: the top + // rung is the alpha strike, which is the whole reason the budget was + // removed, and the bottom is the single heaviest gun. Losing either end + // would put the wall back in a different place. + let share = (MAX_FIRE_CANDIDATES / ladders.len()).max(1); + let mut out = Vec::new(); + for (target, name, ordered) in ladders { + for size in rungs(ordered.len(), share) { out.push(( // The id is in the label because two enemies of the same // chassis are common and two identical labels in one // decision are unreadable. format!("{size} at {name} #{target}"), target, - affordable[..size].to_vec(), + ordered[..size].to_vec(), )); } } - // The cap, applied where it can be seen. Targets are in id order, so a - // truncation drops the same allocations every run. + // The final guard, for the rounding in `share`. Targets are in id + // order, so a truncation drops the same allocations every run. out.truncate(MAX_FIRE_CANDIDATES); out } @@ -710,6 +1052,31 @@ impl Bot { } } +/// Write the weights the bot is playing beside its decision log. +/// +/// Never fatal, and silent when the host did not say where the log is - the +/// same rule the imitation corpus and the decision log itself follow. A bot run +/// by hand on a terminal has no log directory and wants no file. +fn write_weights_sidecar(weights: &Weights) { + let (Ok(dir), Ok(tag)) = (std::env::var("SDS_LOG_DIR"), std::env::var("SDS_MATCH_TAG")) else { + return; + }; + let seat = std::env::var("SDS_SEAT").unwrap_or_default(); + let path = std::path::Path::new(&dir) + .join(format!("{tag}-{}.weights.json", imitate::safe_name(&seat))); + let document = weights.to_document(); + let write = std::fs::create_dir_all(&dir).and_then(|()| { + std::fs::write( + &path, + serde_json::to_vec_pretty(&document).unwrap_or_default(), + ) + }); + match write { + Ok(()) => eprintln!("[sds-bot] weights: {}", path.display()), + Err(error) => eprintln!("[sds-bot] could not write {}: {error}", path.display()), + } +} + #[tokio::main(flavor = "multi_thread", worker_threads = 4)] async fn main() -> Result<()> { // Two flags and no argument-parsing crate. `--weights ` is the one @@ -796,6 +1163,19 @@ async fn main() -> Result<()> { } }; + // The weights, beside the decision log they explain. + // + // Every decision line already carries each candidate's `phi` and the `value` + // the bot ranked with, but not the vector that turns one into the other - + // so "which feature made the difference" could only be answered by guessing + // which weights the run was played with. A run that used `--weights` and one + // that used the baseline produce identical-looking logs otherwise, and the + // wrong guess silently reports the wrong culprit. + // + // Written once at startup and never again: it is a few hundred bytes against + // a decision log that is megabytes. `sds explain` reads it. + write_weights_sidecar(&weights); + // Deliberately overruling the weights, for a training corpus only. It makes // the bot play worse; a benchmark run with this on is measuring noise. let explore = sds_core::explore::Explore { @@ -889,28 +1269,50 @@ async fn main() -> Result<()> { ); } - let action = match observation.phase.as_str() { - "DEPLOYMENT" => bot.deployment(&observation), + let turn = match observation.phase.as_str() { + "DEPLOYMENT" => Turn { + action: bot.deployment(&observation), + unit: None, + choice: None, + }, "FIRING" => bot.firing(&observation), "MOVEMENT" => match bot.movement(&observation).await { - Ok(action) => action, + Ok(turn) => turn, Err(error) => { // Never a silent pass. A planning failure has to be visible // in the match log or it becomes a bot that mysteriously // stands still. eprintln!("[sds-bot] planning failed: {error}"); - Action::Move { steps: Vec::new() } + Turn { + action: Action::Move { steps: Vec::new() }, + unit: None, + choice: None, + } } }, - _ => Action::Pass, + _ => Turn { + action: Action::Pass, + unit: None, + choice: None, + }, }; - let mut reply = Reply::new(observation.seq, action); + let mut reply = Reply::new(observation.seq, turn.action).for_unit(turn.unit); if let Some(rationale) = bot.rationale() { reply = reply.with_rationale(rationale); } - if let Some(row) = observation - .actor + if let Some(choice) = turn + .choice + .as_ref() + .and_then(|choice| serde_json::to_value(choice).ok()) + { + reply = reply.with_turn(choice); + } + // The row for the unit that is actually acting, not for the host's + // default. They are the same only when we did not choose. + if let Some(row) = turn + .unit + .or(observation.actor) .and_then(|actor| bot.training.get(&actor)) .and_then(|row| serde_json::to_value(row).ok()) { @@ -936,7 +1338,8 @@ mod tests { fn observation(units: serde_json::Value) -> Observation { serde_json::from_value(serde_json::json!({ "seq": 1, "round": 3, "phase": "MOVEMENT", "playerId": 1, "team": 1, - "actor": 1, "units": units, "shots": [], "deployHexes": [] + "actor": 1, "eligible": [1, 2], "units": units, "shots": [], + "deployHexes": [] })) .expect("observation parses") } @@ -951,6 +1354,288 @@ mod tests { }) } + fn testing_bot() -> Bot { + let mut registry = Registry::new(); + registry.register("unit", Arc::new(unit::UnitThinker::new())); + registry.register( + "force", + Arc::new(force::ForceThinker::new( + Grit::default(), + Weights::hand_authored(), + )), + ); + Bot::new(®istry, Weights::hand_authored()).expect("a bot") + } + + fn shot_from(shooter: i32, weapon: i32, target: i32) -> Shot { + Shot { + shooter: Some(shooter), + weapon, + target, + to_hit: 7, + range: 6, + damage: 5.0, + damage_per_packet: 5.0, + heat: 3, + } + } + + fn order(label: &str, value: f32) -> Order { + Order { + action: Action::Move { steps: Vec::new() }, + generated: true, + value, + label: label.to_string(), + } + } + + /// The eligible set is the whole point of the change: the host used to + /// break on the first valid unit and hand the bot the result as a fact. + #[test] + fn the_eligible_set_reaches_the_bot() { + let seen = observation(serde_json::json!([ + unit(1, true, false, false), + unit(2, true, false, false), + ])); + assert_eq!(seen.eligible, vec![1, 2]); + assert_eq!(seen.eligible_units(), vec![1, 2]); + + // A host older than the field offers no set, and `actor` is then the + // whole of it. That is a fallback, not a choice. + let mut older = seen.clone(); + older.eligible.clear(); + assert_eq!(older.eligible_units(), vec![1]); + } + + /// The rule, pinned: the most valuable standing order goes first, and only + /// a unit the game will accept is considered at all. + #[test] + fn the_force_sends_its_most_valuable_standing_order() { + let mut bot = testing_bot(); + bot.orders.insert(1, order("hold", 0.2)); + bot.orders.insert(2, order("close", 0.9)); + bot.orders.insert(3, order("flank", 0.5)); + + assert_eq!(bot.rank(&[1, 2, 3]).first().map(|r| r.0), Some(2)); + // 2 is not eligible this turn, so it is not chosen however good it is. + assert_eq!(bot.rank(&[1, 3]).first().map(|r| r.0), Some(3)); + // Nothing eligible has an order. + assert!(bot.rank(&[9]).is_empty()); + } + + /// Results must not depend on completion order. The same set of orders + /// ranks the same way whatever order the host listed the units in, and a + /// tie goes to the lower id rather than to whoever finished thinking first. + #[test] + fn the_same_observation_yields_the_same_choice() { + let mut bot = testing_bot(); + for id in [4, 7, 2] { + bot.orders.insert(id, order("all the same", 0.5)); + } + for listed in [vec![4, 7, 2], vec![2, 4, 7], vec![7, 2, 4]] { + assert_eq!(bot.rank(&listed).first().map(|r| r.0), Some(2)); + assert_eq!( + bot.rank(&listed).iter().map(|r| r.0).collect::>(), + vec![2, 4, 7] + ); + } + } + + /// The reply names the unit, and the record says who else could have gone. + #[tokio::test] + async fn the_reply_names_the_unit_that_acts() { + let mut bot = testing_bot(); + let seen = observation(serde_json::json!([ + unit(1, true, false, false), + unit(2, true, false, false), + unit(9, false, false, false), + ])); + // Pre-planned for exactly this world, so `plan` reuses the standing + // orders rather than rebuilding them from an empty board. + bot.planned = Some((seen.round, Bot::settled(&seen))); + bot.orders.insert(1, order("hold the ridge", 0.1)); + bot.orders.insert(2, order("close on 9", 0.8)); + + let turn = bot.movement(&seen).await.expect("a turn"); + assert_eq!(turn.unit, Some(2), "the host's actor was 1"); + let choice = turn.choice.expect("a turn choice"); + assert_eq!(choice.chose, 2); + assert_eq!(choice.eligible, vec![1, 2]); + assert!(choice.differed, "1 was the host's own first-eligible"); + assert_eq!(choice.considered.len(), 2, "both rows, not only the winner"); + assert_eq!(choice.value, Some(0.8)); + // Played, and therefore gone: the same unit must not take two turns. + assert!(!bot.orders.contains_key(&2)); + } + + /// The ladder: every prefix of a heaviest-packet-first order, with no heat + /// budget cutting it short. + /// + /// Six weapons on one target used to give three rungs, and only ever as + /// many as `heat_capacity - heat + 4` would pay for. This is the shape that + /// replaced it. + #[test] + fn the_menu_is_a_ladder_of_every_prefix() { + let bot = testing_bot(); + let mut seen = observation(serde_json::json!([ + unit(1, true, false, false), + unit(9, false, false, false), + ])); + let mut shots = Vec::new(); + for (weapon, packet, heat) in [ + (3, 2.0, 2), + (4, 10.0, 10), + (5, 5.0, 4), + (6, 1.0, 1), + (7, 8.0, 8), + ] { + let mut one = shot_from(1, weapon, 9); + one.damage_per_packet = packet; + one.damage = packet; + one.heat = heat; + shots.push(one); + } + seen.shots = shots; + let me = seen.units.iter().find(|u| u.id == 1).expect("unit 1"); + // The shooter has nowhere near the sinks for all five; the old budget + // would have refused most of them. + assert!(me.heat_capacity < 25); + + let menu = bot.allocations(&seen, me); + let sizes: Vec = menu.iter().map(|(_, _, shots)| shots.len()).collect(); + assert_eq!( + sizes, + vec![1, 2, 3, 4, 5], + "every rung, alpha strike included" + ); + + // Heaviest packet first, ties on weapon id. + let alpha = &menu.last().expect("the alpha strike").2; + let order: Vec = alpha.iter().map(|shot| shot.weapon).collect(); + assert_eq!(order, vec![4, 7, 5, 3, 6]); + } + + /// The cap is spent evenly over the targets, and thinning takes the middle + /// of a ladder rather than its tail. Losing the alpha strike would put the + /// heat wall back under another name. + #[test] + fn the_cap_keeps_both_ends_of_every_ladder() { + let bot = testing_bot(); + let mut seen = observation(serde_json::json!([ + unit(1, true, false, false), + unit(9, false, false, false), + unit(10, false, false, false), + ])); + let mut shots = Vec::new(); + for target in [9, 10] { + for weapon in 3..15 { + let mut one = shot_from(1, weapon, target); + one.damage_per_packet = weapon as f32; + shots.push(one); + } + } + seen.shots = shots; + let me = seen.units.iter().find(|u| u.id == 1).expect("unit 1"); + + let menu = bot.allocations(&seen, me); + assert!(menu.len() <= MAX_FIRE_CANDIDATES); + for target in [9, 10] { + let sizes: Vec = menu + .iter() + .filter(|(_, id, _)| *id == target) + .map(|(_, _, shots)| shots.len()) + .collect(); + assert_eq!(sizes.first(), Some(&1), "one gun at {target}"); + assert_eq!(sizes.last(), Some(&12), "the alpha strike at {target}"); + } + } + + /// Every label in one decision is distinct, or the log names the wrong + /// machine. + #[test] + fn ladder_labels_stay_unique() { + let bot = testing_bot(); + let mut seen = observation(serde_json::json!([ + unit(1, true, false, false), + unit(9, false, false, false), + unit(10, false, false, false), + ])); + let mut shots = Vec::new(); + for target in [9, 10] { + for weapon in 3..6 { + shots.push(shot_from(1, weapon, target)); + } + } + seen.shots = shots; + let me = seen.units.iter().find(|u| u.id == 1).expect("unit 1"); + let menu = bot.allocations(&seen, me); + let mut labels: Vec<&str> = menu.iter().map(|(label, _, _)| label.as_str()).collect(); + labels.sort_unstable(); + let count = labels.len(); + labels.dedup(); + assert_eq!(labels.len(), count); + } + + #[test] + fn rungs_keep_both_ends_and_stay_ascending() { + assert_eq!(rungs(5, 9), vec![1, 2, 3, 4, 5]); + assert_eq!(rungs(0, 9), Vec::::new()); + assert_eq!(rungs(7, 1), vec![7], "one rung is the alpha strike"); + for n in 1..40usize { + for keep in 1..12usize { + let sizes = rungs(n, keep); + assert!(!sizes.is_empty()); + assert!(sizes.len() <= keep.max(1)); + assert_eq!(*sizes.last().unwrap(), n, "n={n} keep={keep}"); + assert!(sizes.windows(2).all(|w| w[0] < w[1])); + assert!(sizes.iter().all(|s| (1..=n).contains(s))); + if keep > 1 { + assert_eq!(sizes[0], 1, "n={n} keep={keep}"); + } + } + } + } + + /// A weapon id is only meaningful against the unit it is mounted on. + /// + /// This is the bug that stalled the first match run of this change. The + /// firing observation used to price one shooter, the bot then measured a + /// second unit's menu out of the first one's shots, and the equipment + /// number that was an autocannon on one Mek was a ton of ammo on the other. + /// MegaMek cast it to a weapon inside the packet pump, threw on a thread + /// with no handler, and the server could not end the phase. + #[test] + fn a_units_menu_is_built_from_its_own_shots() { + let bot = testing_bot(); + let mut seen = observation(serde_json::json!([ + unit(1, true, false, false), + unit(2, true, false, false), + unit(9, false, false, false), + ])); + seen.shots = vec![shot_from(1, 7, 9), shot_from(2, 7, 9), shot_from(2, 3, 9)]; + let me = seen.units.iter().find(|u| u.id == 2).expect("unit 2"); + for (_, _, shots) in bot.allocations(&seen, me) { + for shot in shots { + assert_eq!( + shot.shooter, + Some(2), + "unit 2 was offered another unit's gun" + ); + } + } + + // A host older than the field tags nothing, and every shot is the + // actor's. Filtering must not throw those away. + for shot in seen.shots.iter_mut() { + shot.shooter = None; + } + let me = seen.units.iter().find(|u| u.id == 2).expect("unit 2"); + assert!( + !bot.allocations(&seen, me).is_empty(), + "an untagged shot belongs to whoever is asking" + ); + } + /// The plan's cache key is the enemies that will not move again, so a plan /// is reused only for a decision taken in the world it was built for. This /// is what makes the envelope collapse reach our next mover: if this set diff --git a/crates/sds-bot/src/unit.rs b/crates/sds-bot/src/unit.rs index e26acbf..6b581e1 100644 --- a/crates/sds-bot/src/unit.rs +++ b/crates/sds-bot/src/unit.rs @@ -56,6 +56,7 @@ fn projected_shots(me: &Unit, target: &Unit, range: i32) -> Vec { }; let to_hit = (me.gunnery.max(0) as f32) + bracket_penalty + 4.0; shots.push(Shot { + shooter: Some(me.id), weapon: weapon.id, target: target.id, to_hit: to_hit.round() as i32, @@ -516,7 +517,7 @@ pub fn propose_including( observed_pushed = true; } } - let observed_index = observed_pushed.then(|| offers.len() - 1); + let mut observed_index = observed_pushed.then(|| offers.len() - 1); // The role's preference, folded into the appraisal rather than applied as a // rule. It tilts the lance; it does not bind it. @@ -549,17 +550,33 @@ pub fn propose_including( let asked = hits + misses; eprintln!( "[sds-bot] unit {}: {} states, {} enemy positions ({} unmoved), \ - {} exchanges, {} surfaced, {} los asked ({}% hit)", + {} exchanges, {} surfaced, {} range brackets, \ + {} weapons not modelled, {} los asked ({}% hit)", me.id, cost.states, cost.positions, cost.unmoved, cost.exchanges, cost.surfaced, + cost.brackets, + cost.unmodelled, asked, (hits * 100).checked_div(asked).unwrap_or(0) ); - proposals.extend(generated); + // Generated only. The four verbs come off the menu so the force + // chooses among generated states or nothing. + // + // The observed move is the exception and must survive: `imitate` + // records what the other seat did by finding that row, and a menu + // without it teaches a corpus that the opponent moved nowhere. + if !generated.is_empty() { + let observed = observed_index.and_then(|at| proposals.get(at).cloned()); + proposals = generated; + observed_index = observed.map(|row| { + proposals.push(row); + proposals.len() - 1 + }); + } } } @@ -718,6 +735,9 @@ fn measure( #[cfg(test)] mod tests { + /// Guards `SDS_CANDIDATES`, which is process-global. + static FLAG: std::sync::Mutex<()> = std::sync::Mutex::new(()); + use super::*; use sds_core::wire::{BoardHex, Weapon}; use std::collections::BTreeMap; @@ -769,6 +789,8 @@ mod tests { short: 6, medium: 12, long_range: 18, + // A real PPC has one. See `wire::Weapon::minimum`. + minimum: 3, avg_damage_short: 10.0, avg_damage_medium: 10.0, avg_damage_long: 10.0, @@ -776,6 +798,7 @@ mod tests { damage_per_packet: 10.0, usable: true, ammo: None, + modes: Vec::new(), }]; u }) @@ -791,6 +814,7 @@ mod tests { player_id: 1, team: 1, actor: Some(1), + eligible: vec![1], units: vec![ unit(1, true, 3, 9), unit(11, false, 6, 3), @@ -818,6 +842,9 @@ mod tests { let (observation, board) = scene(); let los = LosCache::new(&board, Rules::default()); + // The flag is process-global and tests run in parallel, so every test + // that touches it takes this lock. + let _guard = FLAG.lock().unwrap_or_else(|e| e.into_inner()); std::env::remove_var("SDS_CANDIDATES"); let before = propose( &observation, @@ -889,17 +916,20 @@ mod tests { ); } - /// The generator is capped. Every performance estimate in this repository - /// assumes about twenty candidates a unit, and a sweep produces hundreds. + /// The unit does not prune. Every stand it scored goes up; the force + /// chooses. A cap here would lose the one candidate the force needed. #[test] - fn the_generated_menu_stays_capped() { + fn every_scored_stand_reaches_the_force() { let (observation, board) = scene(); let los = LosCache::new(&board, Rules::default()); let me = observation.units[0].clone(); let generated = candidates::generate(&observation, &board, &los, &me, &Stance::default()); let (proposals, cost) = generated.expect("a unit with somewhere to go generates"); - assert!(proposals.len() <= candidates::MAX_GENERATED); - assert!(cost.states > proposals.len() as u64, "{cost:?}"); + assert_eq!(proposals.len() as u64, cost.surfaced, "{cost:?}"); + assert!( + proposals.len() > 16, + "a real sweep offers more than a capped menu" + ); } /// `M` is the enemy's own reachable set until it has taken its turn, and a diff --git a/crates/sds-core/src/brackets.rs b/crates/sds-core/src/brackets.rs new file mode 100644 index 0000000..774eba0 --- /dev/null +++ b/crates/sds-core/src/brackets.rs @@ -0,0 +1,1169 @@ +//! Where the range line changes, and what each weapon does over each span. +//! +//! A **bracket** is a span of hexes over which nothing about the range line +//! changes. Walk a unit outwards from an enemy and the numbers move only at +//! points a weapon declares: the hex after its short bracket, the hex after its +//! medium, the hex after its long, the hex after its minimum, and - because +//! `Compute.getRangeMods` adds `minimum - distance + 1` at or inside the +//! minimum - **every single hex inside a minimum range**, each of which is a +//! different penalty. Union those over every weapon and every round it could +//! have in the tube, drop anything past the longest long range, insert 1, and +//! the result partitions `1..` into spans on which the whole unit's line is +//! constant. [`RangeTable`] is that partition with the answers filled in. +//! +//! **The bracket is kept, not collapsed to its modifier.** It would be smaller +//! to store `+2` and forget that it came from the medium bracket, and three +//! rules would immediately break: a Snub-Nose PPC and a variable-speed pulse +//! laser change *damage* by bracket, stealth armour adds a penalty keyed to the +//! incoming weapon's bracket, and C3 resolves range against a spotter while the +//! shooter's own bracket still decides its damage. A modifier cannot answer any +//! of the three. +//! +//! **A bracket cannot say which weapons bear.** Bearing depends on facing, +//! twist and where the target is standing, which belong to the pair and not to +//! the range. For the same reason a bracket carries only the **R** of GATOR; +//! G, A, T and O come off [`crate::volley::FixedToHit`]. +//! +//! **A launcher's alternatives stay alternatives.** An MML fires LRM or SRM +//! ammo out of one tube. The two have different brackets, different minimum +//! ranges and different damage, and at one range both may be available - so a +//! bracket holds a [`OneOf`], which has no `sum`, no `iter` and no way to score +//! every member. Exactly one is fired. Which one cannot be decided here: +//! +//! > An MML 7 at ranges 8-9. Against a base to-hit of 4 the SRM load is worth +//! > 5.83 and the LRM load 5.06; against a base to-hit of 6 the SRM load is +//! > worth 2.33 and the LRM load 2.92. Same range, same weapon, opposite +//! > answer, decided entirely by G, A, T and O. +//! +//! So the table prunes to the non-dominated set - a mode is dropped only when +//! another is at least as good on *both* the modifier and the damage, which is +//! provable without knowing the pair - and [`crate::volley::gather`], which has +//! the whole to-hit, resolves what survives as the argmax of +//! `damage x P(hit)`. +//! +//! **Build one per observation. Never cache one across rounds.** An ammo bin +//! running out invalidates a table exactly the way a destroyed weapon does: an +//! MML that has fired off its LRM bin has one mode, not two, and its bracket +//! boundaries move. [`crate::wire::Weapon::usable`] and a mode's `shots_left` +//! are both read here, and both change under the bot's feet during a match. A +//! table is cheap - it is a handful of integer thresholds - and a stale one is +//! a bot shooting ammunition it does not have. + +use std::collections::BTreeSet; +use std::fmt; + +use crate::volley::{MountedWeapon, LONG_RANGE_MODIFIER, MEDIUM_RANGE_MODIFIER}; +use crate::wire::WeaponModeWire; + +/// Which range bracket a distance falls in, by name. +/// +/// [`MountedWeapon::bracket`] answers with a modifier and a damage figure, +/// which is what an estimate needs and not what a report needs: a reader wants +/// the bracket's *name* beside the number, and wants "past long" to be a value +/// rather than an absent one. The thresholds are the weapon's own and the +/// modifiers are [`MEDIUM_RANGE_MODIFIER`] and [`LONG_RANGE_MODIFIER`], so the +/// two agree by construction wherever both answer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Bracket { + /// Range zero. `Compute.getRangeMods` refuses it for everything but + /// infantry weapons, and there is no shot to describe. + SameHex, + Short, + Medium, + Long, + /// Past the long bracket. No roll exists. + Beyond, +} + +impl Bracket { + pub fn of(range: i32, weapon: &MountedWeapon) -> Self { + Self::between(range, weapon.short, weapon.medium, weapon.long) + } + + /// The bracket a range falls in, given the three thresholds. The minimum + /// range is deliberately not an argument: being inside it is a *penalty*, + /// not a bracket, and a weapon at one hex is still at short range. + pub fn between(range: i32, short: i32, medium: i32, long: i32) -> Self { + if range <= 0 { + Bracket::SameHex + } else if range <= short { + Bracket::Short + } else if range <= medium { + Bracket::Medium + } else if range <= long { + Bracket::Long + } else { + Bracket::Beyond + } + } + + pub fn name(self) -> &'static str { + match self { + Bracket::SameHex => "same hex", + Bracket::Short => "short", + Bracket::Medium => "medium", + Bracket::Long => "long", + Bracket::Beyond => "beyond", + } + } + + /// One letter, for a table cell that has no room for a word. + pub fn letter(self) -> char { + match self { + Bracket::SameHex => '.', + Bracket::Short => 'S', + Bracket::Medium => 'M', + Bracket::Long => 'L', + Bracket::Beyond => '-', + } + } + + /// The `R` of GATOR *before* any minimum-range penalty, or `None` where + /// there is no shot to take. + pub fn modifier(self) -> Option { + match self { + Bracket::SameHex | Bracket::Beyond => None, + Bracket::Short => Some(0), + Bracket::Medium => Some(MEDIUM_RANGE_MODIFIER), + Bracket::Long => Some(LONG_RANGE_MODIFIER), + } + } +} + +/// `Compute.getRangeMods`: at or inside the minimum, the shot takes +/// `minimum - distance + 1`. A weapon standing at exactly its minimum range is +/// already at `+1`, which is the part that is easy to get wrong. +/// +/// Zero minimum is "no minimum range", and is also what an observation older +/// than the field says. The two are the same answer on purpose. +pub fn minimum_range_penalty(minimum: i32, range: i32) -> i32 { + if minimum > 0 && range > 0 && range <= minimum { + minimum - range + 1 + } else { + 0 + } +} + +/// Why a weapon is not modelled. +/// +/// Every one of these fires under rules this bot does not implement - an +/// indirect artillery flight time, a spotting attack that does no damage, a +/// defensive system that shoots at missiles rather than at Meks. Treating one +/// as an ordinary gun would put damage on the board that cannot happen. +/// +/// **Excluded loudly and countably.** The weapon still appears in +/// [`RangeTable`] with its reason, and the count is reported per decision. A +/// silent drop is the failure mode this enum exists to prevent: a unit whose +/// whole armament is unmodelled should look like a unit with no guns, not like +/// a unit that was never asked. +/// +/// **Matched on MegaMek's `WeaponType.getName()`.** The jar knows this properly +/// through `getFlags()`, and the honest fix is a `modelled` flag on the wire +/// beside `minimum` and `modes`. Until then this is a name list, which is why +/// each arm names the strings it matches. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Excluded { + /// `Arrow IV`, `Long Tom`, `Sniper`, `Thumper`, `Cruise Missile`, and the + /// artillery cannons. Off-board flight times and a scatter template. + Artillery, + /// `TAG`, `Light TAG`, `C3 Master`. Spotting and networking, no damage. + Designator, + /// `Anti-Missile System`, `Laser AMS`. Fires at incoming missiles, in a + /// phase this bot does not model. + AntiMissile, + /// `A-Pod`, `B-Pod`, `M-Pod`. One-shot defensive charges against infantry + /// and Meks in the same hex. + Pod, + /// Infantry and Battle Armor weapons. Damage is a function of the platoon + /// rather than of the mount. + Infantry, + /// `Mech Mortar`. Indirect, and cluster-resolved against a different table. + Mortar, + /// `Vehicular Grenade Launcher`. Lays smoke, does no damage. + Grenade, + /// `Taser`, `TSEMP`. Effect weapons that disable rather than damage. + Effect, + /// `Fluid Gun`, `Sprayer`. Extinguishes fires. + Extinguisher, + /// Bombs. Aerospace ordnance, dropped rather than fired. + Bomb, + /// `Narc`, `iNarc`. Attaches a pod, does no damage. + Narc, +} + +impl Excluded { + /// The reason a weapon is not modelled, or `None` for an ordinary gun. + /// + /// Case-insensitive substring matching over MegaMek's weapon names. The + /// order matters where two arms could both match: `Laser AMS` is an + /// anti-missile system and not a laser, so [`Self::AntiMissile`] is tested + /// before anything else could claim it. + pub fn of(name: &str) -> Option { + let name = name.to_ascii_lowercase(); + let any = |needles: &[&str]| needles.iter().any(|needle| name.contains(needle)); + + if any(&["anti-missile", "antimissile", "ams"]) { + return Some(Excluded::AntiMissile); + } + if any(&[ + "arrow iv", + "long tom", + "sniper", + "thumper", + "cruise missile", + "artillery", + ]) { + return Some(Excluded::Artillery); + } + if any(&["tag", "c3 master", "c3m"]) { + return Some(Excluded::Designator); + } + if any(&["a-pod", "b-pod", "m-pod"]) { + return Some(Excluded::Pod); + } + if any(&["mortar"]) { + return Some(Excluded::Mortar); + } + if any(&["grenade launcher", "vgl"]) { + return Some(Excluded::Grenade); + } + if any(&["taser", "tsemp"]) { + return Some(Excluded::Effect); + } + if any(&["fluid gun", "sprayer", "extinguisher"]) { + return Some(Excluded::Extinguisher); + } + if any(&["bomb", "tag pod"]) { + return Some(Excluded::Bomb); + } + if any(&["narc"]) { + return Some(Excluded::Narc); + } + if any(&["infantry", "battle armor", "ba "]) { + return Some(Excluded::Infantry); + } + None + } + + pub fn reason(self) -> &'static str { + match self { + Excluded::Artillery => "artillery", + Excluded::Designator => "designator", + Excluded::AntiMissile => "anti-missile", + Excluded::Pod => "pod", + Excluded::Infantry => "infantry", + Excluded::Mortar => "mortar", + Excluded::Grenade => "grenade launcher", + Excluded::Effect => "effect weapon", + Excluded::Extinguisher => "extinguisher", + Excluded::Bomb => "bomb", + Excluded::Narc => "narc", + } + } +} + +/// One round a launcher could have in the tube, with that round's brackets. +/// +/// An ordinary gun has none of these and is its own single mode; the fields on +/// [`MountedWeapon`] are that mode. An MML has two and an ATM three, and +/// `WeaponType.getRanges(weapon, ammo)` is the only accessor that knows - +/// `getShortRange()` returns zero for every bracket of an MML. +#[derive(Debug, Clone, PartialEq)] +pub struct WeaponMode { + /// MegaMek's name for the round: `MML 7 LRM Ammo`. The only field that + /// tells an MML's two standard loads apart. + pub name: String, + pub minimum: i32, + pub short: i32, + pub medium: i32, + pub long: i32, + pub avg_damage_short: f32, + pub avg_damage_medium: f32, + pub avg_damage_long: f32, + pub damage_per_packet: f32, + pub rack_size: i32, + /// Rounds left in the bins that feed this mode. Zero drops the mode: an + /// empty bin is not an alternative, and it moves the unit's brackets. + pub shots_left: i32, +} + +impl WeaponMode { + pub fn from_wire(mode: &WeaponModeWire) -> Self { + Self { + name: mode.name.clone(), + minimum: mode.minimum, + short: mode.short, + medium: mode.medium, + long: mode.long_range, + avg_damage_short: mode.avg_damage_short, + avg_damage_medium: mode.avg_damage_medium, + avg_damage_long: mode.avg_damage_long, + damage_per_packet: mode.damage_per_packet, + rack_size: mode.rack_size, + shots_left: mode.shots_left, + } + } +} + +/// The boundaries one set of thresholds contributes. +/// +/// Every hex inside a minimum range is its own boundary, because the penalty +/// `minimum - distance + 1` genuinely differs at each one. That is what makes +/// a Gauss Rifle contribute hexes 1 and 2, and an LRM 20 contribute 1 through +/// 6. +fn thresholds_of(minimum: i32, short: i32, medium: i32, long: i32, into: &mut BTreeSet) { + for hex in 1..=minimum.max(0) { + into.insert(hex); + } + into.insert(minimum + 1); + into.insert(short + 1); + into.insert(medium + 1); + into.insert(long + 1); +} + +/// One of a weapon's rounds, priced at one range. +/// +/// `bracket` is kept beside `range_modifier` on purpose - see the module note. +#[derive(Debug, Clone, PartialEq)] +pub struct Mode { + /// MegaMek's name for the round, or the weapon's own name when the weapon + /// has only one. + pub name: String, + pub bracket: Bracket, + /// The `R` of GATOR: the bracket's modifier plus any minimum-range penalty. + pub range_modifier: i32, + pub damage: f32, + pub damage_per_packet: f32, + pub rack_size: i32, +} + +impl Mode { + /// Whether `self` is at least as good as `other` on both axes, and strictly + /// better on one - the only comparison that can be made without knowing the + /// to-hit. + fn dominates(&self, other: &Self) -> bool { + let no_worse = self.range_modifier <= other.range_modifier && self.damage >= other.damage; + let better = self.range_modifier < other.range_modifier || self.damage > other.damage; + no_worse && better + } + + fn same_offer(&self, other: &Self) -> bool { + self.range_modifier == other.range_modifier && self.damage == other.damage + } +} + +/// Drop every mode another mode beats on *both* axes at once. +/// +/// The only pruning that is provable without knowing the to-hit: if one round +/// needs no worse a roll *and* throws no less damage, the other can never be +/// the right choice whatever G, A, T and O turn out to be. Anything else +/// survives to [`OneOf::resolve`]. +/// +/// Two modes offering exactly the same modifier and damage are one offer, and +/// the first is kept - a deterministic tie-break, not an average. +pub fn non_dominated(offers: Vec) -> Vec { + let mut kept: Vec = Vec::with_capacity(offers.len()); + for (at, offer) in offers.iter().enumerate() { + if offers.iter().any(|other| other.dominates(offer)) { + continue; + } + if offers[..at].iter().any(|earlier| earlier.same_offer(offer)) { + continue; + } + kept.push(offer.clone()); + } + kept +} + +/// One or more alternatives, exactly one of which happens. +/// +/// **There is no `sum`, no `iter` and no `IntoIterator`, and that is the point +/// of the type.** An MML's two loads must never be added together, averaged, or +/// both scored: one round is in the tube. The only way to get a value out for +/// scoring is [`Self::resolve`], which forces the caller to say how it is +/// choosing. [`Self::display_alternatives`] exists for a report, and is named +/// so that using it to compute anything reads as the mistake it would be. +#[derive(Debug, Clone, PartialEq)] +pub struct OneOf { + alternatives: Vec, +} + +impl OneOf { + /// The case with no choice in it. + pub fn one(value: T) -> Self { + Self { + alternatives: vec![value], + } + } + + /// `None` for an empty list. A `OneOf` always holds at least one. + pub fn from_vec(alternatives: Vec) -> Option { + if alternatives.is_empty() { + None + } else { + Some(Self { alternatives }) + } + } + + pub fn len(&self) -> usize { + self.alternatives.len() + } + + /// Always false. Present so `len` does not read as the only size question. + pub fn is_empty(&self) -> bool { + false + } + + /// Whether there is anything to decide. + pub fn is_forced(&self) -> bool { + self.alternatives.len() == 1 + } + + /// The one alternative, when there is only one. + pub fn forced(&self) -> Option<&T> { + match self.alternatives.as_slice() { + [only] => Some(only), + _ => None, + } + } + + /// Pick exactly one, by a score the caller supplies. + /// + /// Ties go to the earlier alternative, so the answer does not depend on + /// float noise or on the order a `BTreeSet` happened to walk in. A `NaN` + /// score never wins. + pub fn resolve(&self, score: impl Fn(&T) -> f32) -> &T { + let mut best = &self.alternatives[0]; + let mut best_score = score(best); + for candidate in &self.alternatives[1..] { + let value = score(candidate); + if value > best_score { + best = candidate; + best_score = value; + } + } + best + } + + /// For printing a table and for nothing else. Deliberately not called + /// `iter`: a caller reaching for this to add two alternatives up has + /// written the one bug this type exists to prevent. + pub fn display_alternatives(&self) -> std::slice::Iter<'_, T> { + self.alternatives.iter() + } +} + +/// One weapon's answer over one bracket. +#[derive(Debug, Clone, PartialEq)] +pub struct WeaponAtRange { + pub weapon: i32, + pub name: String, + /// Every round that reaches, with the dominated ones already gone. + pub modes: OneOf, +} + +/// A span of the range line over which nothing changes. +#[derive(Debug, Clone, PartialEq)] +pub struct RangeBracket { + /// First hex in the span. + pub from: i32, + /// Last hex in the span, or `None` for the unbounded tail past every + /// weapon's long bracket - the span where nothing fires. + pub to: Option, + /// One entry per weapon that reaches, in the order the weapons were given. + /// Empty in the tail. + pub weapons: Vec, +} + +impl RangeBracket { + pub fn contains(&self, range: i32) -> bool { + range >= self.from && self.to.is_none_or(|to| range <= to) + } + + /// How the span prints: `8`, `8-9`, `24+`. + pub fn span(&self) -> String { + match self.to { + None => format!("{}+", self.from), + Some(to) if to == self.from => format!("{}", self.from), + Some(to) => format!("{}-{to}", self.from), + } + } + + pub fn find(&self, weapon: i32) -> Option<&WeaponAtRange> { + self.weapons.iter().find(|entry| entry.weapon == weapon) + } +} + +/// A weapon that is on the unit and is not modelled. +#[derive(Debug, Clone, PartialEq)] +pub struct ExcludedWeapon { + pub weapon: i32, + pub name: String, + pub reason: Excluded, +} + +/// A unit's whole range line, as spans. +/// +/// Built with [`Self::of`] from the weapons an observation carries. **Rebuild +/// it per observation** - see the module note. +#[derive(Debug, Clone, PartialEq)] +pub struct RangeTable { + brackets: Vec, + excluded: Vec, + modelled: usize, +} + +impl RangeTable { + /// Read the weapons and partition the range line. + pub fn of(weapons: &[MountedWeapon]) -> Self { + let live: Vec<&MountedWeapon> = weapons + .iter() + .filter(|weapon| weapon.usable && weapon.excluded.is_none()) + .collect(); + let excluded = weapons + .iter() + .filter_map(|weapon| { + weapon.excluded.map(|reason| ExcludedWeapon { + weapon: weapon.id, + name: weapon.name.clone(), + reason, + }) + }) + .collect(); + + let boundaries = boundaries(&live); + let mut brackets = Vec::with_capacity(boundaries.len()); + for (at, from) in boundaries.iter().copied().enumerate() { + let to = boundaries.get(at + 1).map(|next| next - 1); + let weapons = live + .iter() + .filter_map(|weapon| { + weapon.modes_at(from).map(|modes| WeaponAtRange { + weapon: weapon.id, + name: weapon.name.clone(), + modes, + }) + }) + .collect(); + brackets.push(RangeBracket { from, to, weapons }); + } + + Self { + brackets, + excluded, + modelled: live.len(), + } + } + + pub fn brackets(&self) -> &[RangeBracket] { + &self.brackets + } + + /// Every boundary, which is every span's first hex. + pub fn boundaries(&self) -> Vec { + self.brackets.iter().map(|bracket| bracket.from).collect() + } + + /// The weapons that are on the unit and are not modelled, with reasons. + pub fn excluded(&self) -> &[ExcludedWeapon] { + &self.excluded + } + + /// How many weapons this table actually answers for. + pub fn modelled(&self) -> usize { + self.modelled + } + + /// The span a range falls in. `None` below hex 1, and for a unit with no + /// modelled weapon at all. + pub fn at(&self, range: i32) -> Option<&RangeBracket> { + if range < 1 { + return None; + } + self.brackets + .iter() + .rev() + .find(|bracket| bracket.contains(range)) + } +} + +/// Every hex at which some weapon's line changes, in order. +/// +/// Hex 1 is always a boundary: the line has to start somewhere. Anything past +/// the longest long bracket plus one is dropped - past there every answer is +/// "nothing fires", which the unbounded tail already says. +pub fn boundaries(weapons: &[&MountedWeapon]) -> Vec { + let mut found = BTreeSet::new(); + let mut furthest = 0; + for weapon in weapons { + for (minimum, short, medium, long) in weapon.mode_thresholds() { + thresholds_of(minimum, short, medium, long, &mut found); + furthest = furthest.max(long); + } + } + if furthest < 1 { + return Vec::new(); + } + found.insert(1); + found + .into_iter() + .filter(|hex| *hex >= 1 && *hex <= furthest + 1) + .collect() +} + +/// The table as a person reads it: one row per span, one column per weapon. +/// +/// This is not debug output. jmm's rule for the whole type is that bracketing +/// is displayable - a player asking "where does my line change" gets this +/// table, and the bot scores off the same numbers. +impl fmt::Display for RangeTable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let columns = self.columns(); + let mut widths: Vec = columns + .iter() + .map(|(_, label)| label.chars().count()) + .collect(); + let mut rows: Vec<(String, Vec)> = Vec::with_capacity(self.brackets.len()); + for bracket in &self.brackets { + let cells: Vec = columns + .iter() + .map(|(id, _)| match bracket.find(*id) { + None => "-".to_string(), + Some(entry) => { + let tagged = !entry.modes.is_forced(); + entry + .modes + .display_alternatives() + .map(|mode| cell(mode, &entry.name, tagged)) + .collect::>() + .join(" | ") + } + }) + .collect(); + for (at, cell) in cells.iter().enumerate() { + widths[at] = widths[at].max(cell.chars().count()); + } + rows.push((bracket.span(), cells)); + } + + let span_width = rows + .iter() + .map(|(span, _)| span.chars().count()) + .chain(std::iter::once(5)) + .max() + .unwrap_or(5); + + writeln!( + f, + "{} weapons, {} spans, {} not modelled", + self.modelled, + self.brackets.len(), + self.excluded.len() + )?; + write!(f, "{: Vec<(i32, String)> { + let mut seen: Vec<(i32, String)> = Vec::new(); + for bracket in &self.brackets { + for entry in &bracket.weapons { + if !seen.iter().any(|(id, _)| *id == entry.weapon) { + seen.push((entry.weapon, entry.name.clone())); + } + } + } + let labels: Vec = seen + .iter() + .map(|(id, name)| { + if seen.iter().filter(|(_, other)| other == name).count() > 1 { + format!("{name} #{id}") + } else { + name.clone() + } + }) + .collect(); + seen.into_iter() + .zip(labels) + .map(|((id, _), label)| (id, label)) + .collect() + } +} + +/// `+2 15.0 S`, with the round named when the launcher has a choice to make. +fn cell(mode: &Mode, weapon: &str, tagged: bool) -> String { + let body = format!( + "{:+} {:.1} {}", + mode.range_modifier, + mode.damage, + mode.bracket.letter() + ); + if tagged { + format!("{} {body}", tag(&mode.name, weapon)) + } else { + body + } +} + +/// `MML 7 LRM Ammo` on an `MML 7` is `LRM`. The launcher's name is already the +/// column heading, and the round is the only part a reader needs repeated. +fn tag<'a>(mode: &'a str, weapon: &str) -> &'a str { + let short = mode.strip_suffix(" Ammo").unwrap_or(mode); + short.strip_prefix(weapon).unwrap_or(short).trim() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wire::Weapon; + + /// One weapon off the wire, the way an observation carries it. + #[allow(clippy::too_many_arguments)] + fn weapon( + id: i32, + name: &str, + minimum: i32, + short: i32, + medium: i32, + long: i32, + damage: f32, + rack: i32, + per_packet: f32, + ) -> Weapon { + serde_json::from_str(&format!( + r#"{{ + "id": {id}, "name": "{name}", "location": "RT", "heat": 4, + "minimum": {minimum}, "short": {short}, "medium": {medium}, + "long": {long}, + "avgDamageShort": {damage}, "avgDamageMedium": {damage}, + "avgDamageLong": {damage}, "rackSize": {rack}, + "damagePerPacket": {per_packet}, "usable": true + }}"# + )) + .expect("fixture parses") + } + + /// The Atlas II AS7-D-H2's six guns, from `meks/3075/Atlas II AS7-D-H2.mtf` + /// with the brackets MegaMek's own equipment table carries. Every minimum + /// range here is real: the Gauss Rifle has two hexes of it and the LRM 20 + /// has six, and the ER PPC - alone among PPCs with the Snub-Nose - has + /// none. + fn atlas() -> Vec { + [ + weapon(1, "ER PPC", 0, 7, 14, 23, 10.0, 0, 10.0), + weapon(2, "Gauss Rifle", 2, 7, 15, 22, 15.0, 0, 15.0), + weapon(3, "SRM 6", 0, 3, 6, 9, 12.0, 6, 2.0), + weapon(4, "Medium Pulse Laser", 0, 2, 4, 6, 6.0, 0, 6.0), + weapon(5, "Medium Pulse Laser", 0, 2, 4, 6, 6.0, 0, 6.0), + weapon(6, "LRM 20", 6, 7, 14, 21, 20.0, 20, 1.0), + ] + .iter() + .map(MountedWeapon::from_wire) + .collect() + } + + /// An MML 7 with both bins loaded, as `WeaponType.getRanges(weapon, ammo)` + /// reports them. The base fields are the round in the tube; `modes` are the + /// alternatives, and the plain `getShortRange()` family would have reported + /// zero for all three. + fn mml7(id: i32, lrm_shots: i32, srm_shots: i32) -> Weapon { + serde_json::from_str(&format!( + r#"{{ + "id": {id}, "name": "MML 7", "location": "RA", "heat": 4, + "minimum": 6, "short": 7, "medium": 14, "long": 21, + "avgDamageShort": 7.0, "avgDamageMedium": 7.0, + "avgDamageLong": 7.0, "rackSize": 7, + "damagePerPacket": 1.0, "usable": true, + "modes": [ + {{"name": "MML 7 LRM Ammo", "type": "MML", + "minimum": 6, "short": 7, "medium": 14, "long": 21, + "avgDamageShort": 7.0, "avgDamageMedium": 7.0, + "avgDamageLong": 7.0, "damagePerPacket": 1.0, + "rackSize": 7, "shotsLeft": {lrm_shots}}}, + {{"name": "MML 7 SRM Ammo", "type": "MML", + "minimum": 0, "short": 3, "medium": 6, "long": 9, + "avgDamageShort": 14.0, "avgDamageMedium": 14.0, + "avgDamageLong": 14.0, "damagePerPacket": 2.0, + "rackSize": 7, "shotsLeft": {srm_shots}}} + ] + }}"# + )) + .expect("fixture parses") + } + + /// The Deva C-DVA-OA Dominus, from + /// `meks/3075/Deva C-DVA-OA Dominus.mtf`. The interesting one: an MML 7 + /// with an LRM bin and an SRM bin, so one tube contributes two sets of + /// thresholds and two alternatives wherever both reach. + fn deva() -> Vec { + [ + weapon(1, "Rotary AC/5", 0, 5, 10, 15, 5.0, 0, 5.0), + weapon(2, "ER Small Laser", 0, 2, 4, 5, 3.0, 0, 3.0), + mml7(3, 17, 14), + weapon(4, "ER Medium Laser", 0, 4, 8, 12, 5.0, 0, 5.0), + weapon(5, "ER Medium Laser", 0, 4, 8, 12, 5.0, 0, 5.0), + weapon(6, "Light PPC", 3, 6, 12, 18, 5.0, 0, 5.0), + ] + .iter() + .map(MountedWeapon::from_wire) + .collect() + } + + /// Hand-verified. The ER PPC breaks the line at 8, 15 and 24; the Gauss at + /// 8, 16 and 23 and at hexes 1 and 2 for its minimum; the LRM 20 at 8, 15 + /// and 22 and at every hex from 1 to 6 and at 7; the SRM 6 at 4, 7 and 10; + /// the pulse lasers at 3, 5 and 7. Fourteen spans, the last of which is + /// where nothing fires. + #[test] + fn the_atlas_range_line_breaks_exactly_here() { + let table = RangeTable::of(&atlas()); + assert_eq!( + table.boundaries(), + vec![1, 2, 3, 4, 5, 6, 7, 8, 10, 15, 16, 22, 23, 24] + ); + let last = table.brackets().last().expect("a tail"); + assert_eq!((last.from, last.to), (24, None)); + assert!(last.weapons.is_empty(), "past 23 nothing fires"); + } + + /// The same derivation on the Deva. The MML 7 contributes both loads: the + /// LRM's 1-6 minimum and its 7, 8, 15 and 22, and the SRM's 4, 7 and 10. + /// + /// **Sixteen spans, not fifteen.** The design note this was built from said + /// fifteen. Every boundary below is a threshold some weapon really + /// declares, and `every_boundary_is_a_real_threshold` checks that for both + /// units, so the hand count is one short rather than the rule being wrong. + #[test] + fn the_deva_range_line_breaks_exactly_here() { + let table = RangeTable::of(&deva()); + assert_eq!( + table.boundaries(), + vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 16, 19, 22] + ); + } + + /// At one hex the minimum-range penalties are the whole story, and they are + /// `minimum - distance + 1` rather than `minimum - distance`: a weapon + /// standing at exactly its minimum is already at +1. + /// + /// **The pulse lasers are +0, not -2.** A pulse weapon really is -2 to hit, + /// but that is an accuracy bonus and not the `R` of GATOR, and nothing on + /// the wire says a weapon is a pulse weapon - `WeaponType.getFlags()` knows + /// and the observation does not carry it. Putting -2 in this column would + /// make the column stop meaning range. + #[test] + fn the_atlas_at_one_hex() { + let table = RangeTable::of(&atlas()); + let close = table.at(1).expect("hex 1 is a span"); + let modifier = |id: i32| { + close + .find(id) + .expect("reaches") + .modes + .forced() + .expect("one round") + .range_modifier + }; + assert_eq!(modifier(2), 2, "a Gauss Rifle at its minimum less one"); + assert_eq!(modifier(6), 6, "an LRM 20 six hexes inside its minimum"); + assert_eq!(modifier(1), 0, "an ER PPC has no minimum range"); + assert_eq!(modifier(4), 0, "range is not where a pulse bonus lives"); + assert_eq!(modifier(5), 0); + } + + /// No gap and no overlap: every hex from 1 to well past the last boundary + /// falls in exactly one span. + #[test] + fn brackets_partition_the_range_line() { + for weapons in [atlas(), deva()] { + let table = RangeTable::of(&weapons); + for range in 1..60 { + let hits = table + .brackets() + .iter() + .filter(|bracket| bracket.contains(range)) + .count(); + assert_eq!(hits, 1, "hex {range} is in {hits} spans"); + } + } + } + + /// A boundary that no weapon declares is a boundary that costs a row and + /// buys nothing. + #[test] + fn every_boundary_is_a_real_threshold() { + for weapons in [atlas(), deva()] { + let live: Vec<&MountedWeapon> = weapons.iter().collect(); + let mut declared = BTreeSet::new(); + for weapon in &live { + for (minimum, short, medium, long) in weapon.mode_thresholds() { + thresholds_of(minimum, short, medium, long, &mut declared); + } + } + for boundary in RangeTable::of(&weapons).boundaries() { + assert!( + boundary == 1 || declared.contains(&boundary), + "hex {boundary} is nobody's threshold" + ); + } + } + } + + /// The property that makes precomputing safe: reading the table at a range + /// is the same answer as asking every weapon directly at that range. + #[test] + fn a_bracket_lookup_equals_asking_each_weapon() { + for weapons in [atlas(), deva()] { + let table = RangeTable::of(&weapons); + for range in 1..40 { + let direct: Vec<(i32, Vec)> = weapons + .iter() + .filter_map(|weapon| { + weapon.modes_at(range).map(|modes| { + (weapon.id, modes.display_alternatives().cloned().collect()) + }) + }) + .collect(); + let looked_up: Vec<(i32, Vec)> = table + .at(range) + .map(|bracket| { + bracket + .weapons + .iter() + .map(|entry| { + ( + entry.weapon, + entry.modes.display_alternatives().cloned().collect(), + ) + }) + .collect() + }) + .unwrap_or_default(); + assert_eq!(direct, looked_up, "at hex {range}"); + } + } + } + + /// Worse-or-equal on both axes at once is the only thing that can be pruned + /// without knowing the to-hit, and it is pruned. + #[test] + fn a_dominated_mode_is_dropped() { + let offer = |modifier: i32, damage: f32| Mode { + name: format!("{modifier}/{damage}"), + bracket: Bracket::Short, + range_modifier: modifier, + damage, + damage_per_packet: damage, + rack_size: 0, + }; + let kept = non_dominated(vec![offer(2, 7.0), offer(4, 5.0)]); + assert_eq!(kept.len(), 1, "worse roll and less damage is never right"); + assert_eq!(kept[0].range_modifier, 2); + + // Identical offers are one offer, and the first is the one kept. + let kept = non_dominated(vec![offer(2, 7.0), offer(2, 7.0)]); + assert_eq!(kept.len(), 1); + } + + /// A worse roll for more damage is a real choice, and the type refuses to + /// let anyone treat it as two guns. There is no `sum` and no `iter` on + /// [`OneOf`]; getting a value out means saying how it was chosen. + #[test] + fn a_non_dominated_pair_survives_and_cannot_be_summed() { + let weapons: Vec = [mml7(3, 17, 14)] + .iter() + .map(MountedWeapon::from_wire) + .collect(); + let modes = weapons[0].modes_at(8).expect("both loads reach hex 8"); + assert_eq!(modes.len(), 2); + assert!(!modes.is_forced()); + assert!(modes.forced().is_none()); + + // The two really are the opposite trade, which is why neither can go. + let priced: Vec<(i32, f32)> = modes + .display_alternatives() + .map(|mode| (mode.range_modifier, mode.damage)) + .collect(); + assert_eq!(priced, vec![(2, 7.0), (4, 14.0)]); + + // Whatever a caller does, it gets one of them. + let picked = modes.resolve(|mode| mode.damage); + assert_eq!(picked.damage, 14.0); + assert_eq!( + modes.display_alternatives().count(), + 2, + "resolving does not consume the alternatives" + ); + } + + /// The measured case the whole type exists for. An MML 7 at hex 8: against + /// a base to-hit of 4 the SRM load is worth 5.83 and the LRM load 5.06; + /// against a base of 6 the SRM load is worth 2.33 and the LRM load 2.92. + /// Same range, same weapon, opposite answer - so this cannot be decided + /// when the table is built. + #[test] + fn an_mml_resolves_to_srm_up_close_and_lrm_at_range() { + let weapons: Vec = [mml7(3, 17, 14)] + .iter() + .map(MountedWeapon::from_wire) + .collect(); + let modes = weapons[0].modes_at(8).expect("both loads reach"); + let expected = |base: i32| { + move |mode: &Mode| mode.damage * crate::wire::hit_chance(base + mode.range_modifier) + }; + + let easy = modes.resolve(expected(4)); + assert_eq!(easy.name, "MML 7 SRM Ammo"); + assert!((easy.damage * crate::wire::hit_chance(8) - 5.83).abs() < 0.01); + + let hard = modes.resolve(expected(6)); + assert_eq!(hard.name, "MML 7 LRM Ammo"); + assert!((hard.damage * crate::wire::hit_chance(8) - 2.92).abs() < 0.01); + } + + /// An empty bin is the same class of change as a destroyed weapon: the mode + /// is gone and the unit's boundaries move with it. This is why a table is + /// built from the observation each time rather than cached across rounds. + #[test] + fn an_empty_bin_removes_a_mode_and_moves_the_boundaries() { + let full: Vec = [mml7(3, 17, 14)] + .iter() + .map(MountedWeapon::from_wire) + .collect(); + let spent: Vec = [mml7(3, 17, 0)] + .iter() + .map(MountedWeapon::from_wire) + .collect(); + + assert_eq!( + RangeTable::of(&full).boundaries(), + vec![1, 2, 3, 4, 5, 6, 7, 8, 10, 15, 22] + ); + assert_eq!( + RangeTable::of(&spent).boundaries(), + vec![1, 2, 3, 4, 5, 6, 7, 8, 15, 22], + "the SRM load's 4, 7 and 10 leave with its last round" + ); + assert!(spent[0] + .modes_at(8) + .expect("the LRM load reaches") + .is_forced()); + } + + /// Not modelled is not silently absent. The weapon stays on the unit with + /// its reason, and the count is what a decision reports. + #[test] + fn an_unmodelled_weapon_is_excluded_loudly() { + let mut weapons = atlas(); + weapons.push(MountedWeapon::from_wire(&weapon( + 7, "Arrow IV", 0, 1, 2, 9, 20.0, 0, 20.0, + ))); + weapons.push(MountedWeapon::from_wire(&weapon( + 8, + "Anti-Missile System", + 0, + 1, + 2, + 3, + 3.0, + 0, + 3.0, + ))); + + let table = RangeTable::of(&weapons); + assert_eq!(table.modelled(), 6); + let reasons: Vec = table.excluded().iter().map(|out| out.reason).collect(); + assert_eq!(reasons, vec![Excluded::Artillery, Excluded::AntiMissile]); + // And it changes no boundary: an unmodelled weapon declares nothing. + assert_eq!(table.boundaries(), RangeTable::of(&atlas()).boundaries()); + assert!( + weapons[6].modes_at(1).is_none(), + "and it never prices a shot" + ); + } + + #[test] + fn the_exclusions_are_the_agreed_list() { + for (name, reason) in [ + ("Arrow IV", Excluded::Artillery), + ("Long Tom Artillery Cannon", Excluded::Artillery), + ("TAG", Excluded::Designator), + ("Anti-Missile System", Excluded::AntiMissile), + ("Laser AMS", Excluded::AntiMissile), + ("B-Pod", Excluded::Pod), + ("M-Pod", Excluded::Pod), + ("Mech Mortar 4", Excluded::Mortar), + ("Vehicular Grenade Launcher", Excluded::Grenade), + ("Mek Taser", Excluded::Effect), + ("TSEMP Cannon", Excluded::Effect), + ("Fluid Gun", Excluded::Extinguisher), + ("Narc Missile Beacon", Excluded::Narc), + ("Bomb Bay", Excluded::Bomb), + ("Auto Rifle (Infantry)", Excluded::Infantry), + ] { + assert_eq!(Excluded::of(name), Some(reason), "{name}"); + } + for ordinary in ["ER PPC", "Gauss Rifle", "LRM 20", "Medium Pulse Laser"] { + assert_eq!(Excluded::of(ordinary), None, "{ordinary}"); + } + } + + /// The table is meant to be read. This is the shape a person gets. + #[test] + fn a_unit_renders_its_range_line() { + let rendered = RangeTable::of(&atlas()).to_string(); + println!("Atlas II AS7-D-H2\n{rendered}"); + println!("Deva C-DVA-OA Dominus\n{}", RangeTable::of(&deva())); + + let mut lines = rendered.lines(); + assert!(lines + .next() + .expect("a header") + .starts_with("6 weapons, 14 spans")); + assert!(lines + .next() + .expect("column headings") + .contains("Gauss Rifle")); + let first = lines.next().expect("hex 1"); + assert!(first.starts_with('1'), "{first}"); + assert!( + first.contains("+2 15.0 S"), + "the Gauss at its minimum: {first}" + ); + assert!( + rendered.contains("Medium Pulse Laser #4"), + "two of a kind are told apart" + ); + + // The launcher's alternatives print as alternatives. + let deva = RangeTable::of(&deva()).to_string(); + assert!( + deva.contains("SRM +4 14.0 L | LRM +2 7.0 M") + || deva.contains("LRM +2 7.0 M | SRM +4 14.0 L"), + "{deva}" + ); + } +} diff --git a/crates/sds-core/src/ev.rs b/crates/sds-core/src/ev.rs index 2b6baa1..040ce53 100644 --- a/crates/sds-core/src/ev.rs +++ b/crates/sds-core/src/ev.rs @@ -939,6 +939,7 @@ mod tests { #[test] fn keys_come_off_the_wire() { let cluster = Shot { + shooter: None, weapon: 1, target: 2, to_hit: 7, diff --git a/crates/sds-core/src/features/firing.rs b/crates/sds-core/src/features/firing.rs index e4b88c1..6bd3ecf 100644 --- a/crates/sds-core/src/features/firing.rs +++ b/crates/sds-core/src/features/firing.rs @@ -28,6 +28,7 @@ use crate::hitloc::{self, LocationDamage}; use crate::los::Side; use crate::wire::{Shot, Unit}; +use super::heat::HeatOutlook; use super::score::{Bounded, Local, Score}; use super::{AmongCandidates, Feature, FeatureVector}; @@ -85,13 +86,30 @@ impl<'a> Volley<'a> { .collect() } - /// Points of armour and structure the target has left, never below one. + /// The heat this volley generates. + pub fn heat(&self) -> i32 { + self.shots.iter().map(|shot| shot.heat.max(0)).sum() + } + + /// Where this volley leaves the shooter on the heat scale. See + /// [`crate::features::heat`]. + pub fn heat_outlook(&self) -> HeatOutlook<'a> { + HeatOutlook::new(self.shooter, self.heat()) + } + + /// Every point this shooter could put out in a turn, if each weapon fired + /// at its best bracket and every shot landed. /// - /// The denominator for damage and for overkill. One rather than zero - /// because a target with nothing left is about to be removed and dividing - /// by it would produce an infinity that outranks every real candidate. - fn target_remaining(&self) -> f32 { - (self.target.armor + self.target.internal).max(1) as f32 + /// The denominator for expected damage. Normalising by what is left of the + /// *target* asks how close a volley brings that target to death, which is a + /// focus-fire question wearing a damage name: the same ten points read 0.02 + /// against a fresh assault and 0.50 against a cripple, a twenty-three-fold + /// preference that comes from the denominator alone and never lets a bot + /// engage anything healthy. Normalising by our own ceiling asks whether we + /// are getting our guns' worth from this position, which is the same + /// question whatever we are shooting at. + pub fn shooter_ceiling(&self) -> f32 { + ceiling(self.shooter) } /// Locations whose loss destroys the target outright. @@ -169,6 +187,7 @@ impl<'a> Volley<'a> { vector.measure::(self); vector.measure::(self); vector.measure::(self); + vector.extend(&self.heat_outlook().features()); vector.measure::(self); vector.measure::(self); vector.measure::(self); @@ -180,7 +199,28 @@ impl<'a> Volley<'a> { } } -/// How much of what is left of the target this volley is expected to strip off. +/// Every point a unit could put out in a turn, if each weapon fired at its best +/// bracket and every shot landed. +/// +/// The denominator for expected damage, shared by both paths that record it so +/// they cannot drift. See [`Volley::shooter_ceiling`] for why it is our ceiling +/// and not what is left of the target. +pub fn ceiling(shooter: &Unit) -> f32 { + let total: f32 = shooter + .weapons + .iter() + .filter(|weapon| weapon.usable) + .map(|weapon| { + weapon + .avg_damage_short + .max(weapon.avg_damage_medium) + .max(weapon.avg_damage_long) + }) + .sum(); + total.max(1.0) +} + +/// How much of our own output this volley actually lands. pub struct ExpectedDamage; impl Feature for ExpectedDamage { @@ -188,10 +228,10 @@ impl Feature for ExpectedDamage { type Input<'a> = Volley<'a>; const NAME: &'static str = "expected_damage"; const DESCRIPTION: &'static str = - "The share of what is left of the target we expect to shoot off it this turn."; + "The share of everything our guns could put out that this volley lands."; fn measure(volley: &Volley<'_>) -> Score { - Score::fraction(volley.damage.mean() / volley.target_remaining()) + Score::fraction(volley.damage.mean() / volley.shooter_ceiling()) } } @@ -281,9 +321,8 @@ impl Feature for HeatIncurred { "How much of this unit's heat capacity firing these weapons uses up."; fn measure(volley: &Volley<'_>) -> Score { - let heat: i32 = volley.shots.iter().map(|shot| shot.heat.max(0)).sum(); let capacity = volley.shooter.heat_capacity.max(1) as f32; - Score::fraction(heat as f32 / capacity) + Score::fraction(volley.heat() as f32 / capacity) } } @@ -513,18 +552,74 @@ mod tests { } #[test] - fn expected_damage_is_a_share_of_what_is_left() { - let (shooter, mut target) = pair(); - target.armor = 10; - target.internal = 10; + fn expected_damage_is_a_share_of_our_own_output() { + let (shooter, target) = pair(); + let shots = vec![shot(1, 2, 4, 10.0, 3)]; + let mut cache = EvCache::new(); + let fired = Fired::new(&mut cache, &shooter, &shots, &target, Side::Front); + let volley = fired.volley(&shooter, &target, &shots, Side::Front); + let measured = ExpectedDamage::measure(&volley).get(); + assert!((measured - fired.damage.mean() / ceiling(&shooter)).abs() < 1e-6); + } + + /// Firing one gun of many is not a full volley. + /// + /// The denominator is every weapon still able to fire, so a hex where only + /// one gun bears reads as a fraction of what we could have done somewhere + /// else - not as a hundred per cent because everything that fired hit. + #[test] + fn one_gun_of_many_is_not_a_whole_volley() { + let (mut shooter, target) = pair(); + let one = shooter.weapons[0].clone(); + shooter.weapons = (0..5).map(|_| one.clone()).collect(); let shots = vec![shot(1, 2, 4, 10.0, 3)]; let mut cache = EvCache::new(); let fired = Fired::new(&mut cache, &shooter, &shots, &target, Side::Front); let volley = fired.volley(&shooter, &target, &shots, Side::Front); - // Ten points on a 4 is 8.33 expected, against twenty points left. let measured = ExpectedDamage::measure(&volley).get(); - assert!((measured - fired.damage.mean() / 20.0).abs() < 1e-6); - assert!(measured > 0.3 && measured < 0.5, "{measured}"); + assert!(measured < 0.35, "one of five guns read {measured}"); + + // The same shot from a machine that has lost the other four: the same + // damage, a larger share, because the ceiling fell with the guns. + shooter.weapons.truncate(1); + let fired = Fired::new(&mut cache, &shooter, &shots, &target, Side::Front); + let alone = ExpectedDamage::measure(&fired.volley(&shooter, &target, &shots, Side::Front)); + assert!( + alone.get() > measured * 2.0, + "{} vs {measured}", + alone.get() + ); + } + + /// The bug this denominator exists to remove. + /// + /// Dividing by what is left of the target made the same volley read + /// twenty-three times better against a cripple than against a fresh + /// assault, so nothing healthy was ever worth shooting. Our own ceiling + /// does not move when the target does. + #[test] + fn the_same_volley_reads_the_same_against_a_cripple_and_a_fresh_machine() { + let (shooter, mut target) = pair(); + let shots = vec![shot(1, 2, 4, 10.0, 3)]; + let mut cache = EvCache::new(); + + target.armor = 400; + target.internal = 200; + let fired = Fired::new(&mut cache, &shooter, &shots, &target, Side::Front); + let fresh = ExpectedDamage::measure(&fired.volley(&shooter, &target, &shots, Side::Front)); + + target.armor = 10; + target.internal = 10; + let fired = Fired::new(&mut cache, &shooter, &shots, &target, Side::Front); + let cripple = + ExpectedDamage::measure(&fired.volley(&shooter, &target, &shots, Side::Front)); + + assert!( + (fresh.get() - cripple.get()).abs() < 1e-6, + "fresh {} cripple {}", + fresh.get(), + cripple.get() + ); } #[test] diff --git a/crates/sds-core/src/features/fixture.rs b/crates/sds-core/src/features/fixture.rs index 7d6aed3..44c005d 100644 --- a/crates/sds-core/src/features/fixture.rs +++ b/crates/sds-core/src/features/fixture.rs @@ -82,6 +82,7 @@ pub fn gun(id: i32, damage: f32, heat: i32) -> Weapon { /// One shot from `weapon` at `target`, needing `to_hit`. pub fn shot(weapon: i32, target: i32, to_hit: i32, damage: f32, heat: i32) -> Shot { Shot { + shooter: None, weapon, target, to_hit, diff --git a/crates/sds-core/src/features/heat.rs b/crates/sds-core/src/features/heat.rs new file mode 100644 index 0000000..7653dee --- /dev/null +++ b/crates/sds-core/src/features/heat.rs @@ -0,0 +1,364 @@ +//! The heat scale, as things a weight can price. +//! +//! Heat does nothing gradually. It crosses a line and something specific +//! breaks: movement, then aim, then the reactor, then the ammunition. Neither a +//! linear nor an exponential term can say "17 is barely worse than 16, and 19 +//! is a different game", so there is one bounded feature per family of +//! breakpoints and each is separately explainable. +//! +//! **Measured on where the volley leaves us, not on where we are.** Every one +//! of these reads [`Unit::end_of_turn_heat`] with the candidate's own heat +//! added, so the question is "would firing this push me over 18" rather than +//! "am I over 18 now". A feature of the present cannot be traded against a +//! candidate: it reads the same for every option on the menu and so prices +//! none of them. +//! +//! What this replaced was worse than a bad feature. `allocations()` truncated +//! every volley to `heat_capacity - heat + 4`, and `heat_capacity` is +//! **dissipation** - what the sinks shed in a turn, not the shutdown threshold. +//! The bot therefore never fired more than it could sink, as a rule, with no +//! term in the vector that could see or overrule it. It fired about 63% as +//! often as Princess. + +use crate::wire::{hit_chance, Unit}; + +use super::score::{Bounded, Score}; +use super::Feature; + +/// What one point of the heat scale does. +/// +/// Source: MegaMek's own `megamek/client/messages.properties`, keys +/// `HeatEffects.0` through `HeatEffects.30`, cross-checked against +/// `megamek.server.totalWarfare.HeatResolver`. The properties file lists only +/// what is *new* at each level - row 19 names the ammunition check and not the +/// shutdown check that has applied since 18 - so the rows below are cumulative, +/// which is what `HeatResolver` actually implements: shutdown is +/// `4 + ((heat - 14) / 4) * 2` from 14 up, and the ammunition roll is +/// `4 + (heat >= 23) * 2 + (heat >= 28) * 2` from 19 up. +/// +/// This is the one table. Any number about heat in this crate comes from here, +/// so a reader checking us against the rulebook has one place to look. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Effects { + /// Movement points lost. + mp: i32, + /// Added to every to-hit number, ours. + to_hit: i32, + /// The 2d6 roll needed to stay running. Zero: no check. + shutdown: i32, + /// The 2d6 roll needed not to set a bin off. Zero: no check. + ammo: i32, +} + +const fn row(mp: i32, to_hit: i32, shutdown: i32, ammo: i32) -> Effects { + Effects { + mp, + to_hit, + shutdown, + ammo, + } +} + +/// A roll no 2d6 can make. Heat 30 is automatic shutdown, not a check, and +/// carrying it as an impossible target keeps one lookup and one arithmetic +/// instead of a special case: [`hit_chance`] answers 0.0 above 12, so the +/// failure probability comes out at exactly 1.0. +const AUTOMATIC: i32 = 13; + +/// The heaviest heat this table describes. Above it, the last row stands. +const TOP: i32 = 30; + +/// The scale, indexed by heat. +#[rustfmt::skip] +const CHART: [Effects; (TOP + 1) as usize] = [ + // MP BTH shutdown ammo + /* 0 */ row(0, 0, 0, 0), + /* 1 */ row(0, 0, 0, 0), + /* 2 */ row(0, 0, 0, 0), + /* 3 */ row(0, 0, 0, 0), + /* 4 */ row(0, 0, 0, 0), + /* 5 */ row(1, 0, 0, 0), + /* 6 */ row(1, 0, 0, 0), + /* 7 */ row(1, 0, 0, 0), + /* 8 */ row(1, 1, 0, 0), + /* 9 */ row(1, 1, 0, 0), + /* 10 */ row(2, 1, 0, 0), + /* 11 */ row(2, 1, 0, 0), + /* 12 */ row(2, 1, 0, 0), + /* 13 */ row(2, 2, 0, 0), + /* 14 */ row(2, 2, 4, 0), + /* 15 */ row(3, 2, 4, 0), + /* 16 */ row(3, 2, 4, 0), + /* 17 */ row(3, 3, 4, 0), + /* 18 */ row(3, 3, 6, 0), + /* 19 */ row(3, 3, 6, 4), + /* 20 */ row(4, 3, 6, 4), + /* 21 */ row(4, 3, 6, 4), + /* 22 */ row(4, 3, 8, 4), + /* 23 */ row(4, 3, 8, 6), + /* 24 */ row(4, 4, 8, 6), + /* 25 */ row(5, 4, 8, 6), + /* 26 */ row(5, 4, 10, 6), + /* 27 */ row(5, 4, 10, 6), + /* 28 */ row(5, 4, 10, 8), + /* 29 */ row(5, 4, 10, 8), + /* 30 */ row(5, 4, AUTOMATIC, 8), +]; + +/// The worst movement penalty on the scale. The denominator for +/// [`HeatMpPenalty`]. +const MAX_MP: f32 = 5.0; + +/// The worst to-hit penalty on the scale. The denominator for +/// [`HeatToHitPenalty`]. +const MAX_TO_HIT: f32 = 4.0; + +/// What the scale does at `heat`, clamped into the table at both ends. +fn effects(heat: i32) -> Effects { + CHART[heat.clamp(0, TOP) as usize] +} + +/// The chance of failing a 2d6 check needing `target`. Zero target: no check. +fn failure(target: i32) -> f32 { + if target <= 0 { + return 0.0; + } + 1.0 - hit_chance(target) +} + +/// Where a unit lands on the heat scale if it fires this much. +/// +/// The input of every feature here. A [`Volley`](super::firing::Volley) makes +/// one of itself; "hold fire" makes one with `extra` at zero, which is what +/// lets not shooting be compared with shooting on the same terms. Without that, +/// a unit already running hot would read every firing candidate as worse than a +/// zeroed hold-fire vector however cool the volley was, and would stop shooting +/// permanently once it got warm. +#[derive(Debug, Clone, Copy)] +pub struct HeatOutlook<'a> { + pub shooter: &'a Unit, + /// Heat the candidate under consideration would generate. + pub extra: i32, +} + +impl<'a> HeatOutlook<'a> { + pub fn new(shooter: &'a Unit, extra: i32) -> Self { + Self { shooter, extra } + } + + /// The scale position this outlook is measured at. + fn heat(&self) -> i32 { + self.shooter.end_of_turn_heat(self.extra) + } + + fn effects(&self) -> Effects { + effects(self.heat()) + } + + /// Every heat feature, measured in one pass. + pub fn features(&self) -> super::FeatureVector { + let mut vector = super::FeatureVector::new(); + vector.measure::(self); + vector.measure::(self); + vector.measure::(self); + vector.measure::(self); + vector.measure::(self); + vector + } +} + +/// Movement points this heat would cost us next turn. +pub struct HeatMpPenalty; + +impl Feature for HeatMpPenalty { + type Norm = Bounded; + type Input<'a> = HeatOutlook<'a>; + const NAME: &'static str = "heat_mp_penalty"; + const DESCRIPTION: &'static str = + "How much of a unit's movement the heat it would end the turn on takes away."; + + fn measure(outlook: &HeatOutlook<'_>) -> Score { + Score::fraction(outlook.effects().mp as f32 / MAX_MP) + } +} + +/// Aim this heat would cost us, on every shot after this one. +pub struct HeatToHitPenalty; + +impl Feature for HeatToHitPenalty { + type Norm = Bounded; + type Input<'a> = HeatOutlook<'a>; + const NAME: &'static str = "heat_to_hit_penalty"; + const DESCRIPTION: &'static str = + "How much of the worst heat aim penalty the heat a unit would end the turn on brings."; + + fn measure(outlook: &HeatOutlook<'_>) -> Score { + Score::fraction(outlook.effects().to_hit as f32 / MAX_TO_HIT) + } +} + +/// The chance this heat shuts the machine down. +pub struct HeatShutdownRisk; + +impl Feature for HeatShutdownRisk { + type Norm = Bounded; + type Input<'a> = HeatOutlook<'a>; + const NAME: &'static str = "heat_shutdown_risk"; + const DESCRIPTION: &'static str = + "The chance the heat a unit would end the turn on shuts it down."; + + fn measure(outlook: &HeatOutlook<'_>) -> Score { + Score::probability(failure(outlook.effects().shutdown)) + } +} + +/// The chance this heat sets our own ammunition off. +/// +/// Multiplied by whether there is any ammunition to set off, so an energy boat +/// reads exactly zero. That interaction is the point: the same heat is a +/// different decision for a machine that cannot explode. +pub struct HeatAmmoExplosionRisk; + +impl Feature for HeatAmmoExplosionRisk { + type Norm = Bounded; + type Input<'a> = HeatOutlook<'a>; + const NAME: &'static str = "heat_ammo_explosion_risk"; + const DESCRIPTION: &'static str = + "The chance the heat a unit would end the turn on sets off its own ammunition."; + + fn measure(outlook: &HeatOutlook<'_>) -> Score { + if !outlook.shooter.explodable_ammo { + return Score::probability(0.0); + } + Score::probability(failure(outlook.effects().ammo)) + } +} + +/// Whether this unit has ammunition that can go off at all. +pub struct HasExplodableAmmo; + +impl Feature for HasExplodableAmmo { + type Norm = Bounded; + type Input<'a> = HeatOutlook<'a>; + const NAME: &'static str = "has_explodable_ammo"; + const DESCRIPTION: &'static str = + "Whether this unit carries ammunition a heat roll could set off."; + + fn measure(outlook: &HeatOutlook<'_>) -> Score { + Score::flag(outlook.shooter.explodable_ammo) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::features::fixture; + + /// A shooter at a known place on the scale: `heat` on it already, nothing + /// banked, and dissipation zero so `extra` moves the scale one for one. + fn shooter(heat: i32, explodable: bool) -> Unit { + let mut unit = fixture::mek(1); + unit.heat = heat; + unit.heat_buildup = 0; + unit.heat_capacity = 0; + unit.explodable_ammo = explodable; + unit + } + + /// The rows the chart is checked against, transcribed from + /// `HeatEffects.N`. Every breakpoint, and one row past each. + #[test] + fn the_chart_matches_megamek() { + for (heat, mp, to_hit) in [ + (4, 0, 0), + (5, 1, 0), + (8, 1, 1), + (10, 2, 1), + (13, 2, 2), + (15, 3, 2), + (17, 3, 3), + (20, 4, 3), + (24, 4, 4), + (25, 5, 4), + (29, 5, 4), + ] { + let e = effects(heat); + assert_eq!(e.mp, mp, "MP at {heat}"); + assert_eq!(e.to_hit, to_hit, "BTH at {heat}"); + } + // Cumulative, not only on the row that names them. + for (heat, shutdown, ammo) in [ + (13, 0, 0), + (14, 4, 0), + (17, 4, 0), + (18, 6, 0), + (19, 6, 4), + (22, 8, 4), + (23, 8, 6), + (26, 10, 6), + (28, 10, 8), + (30, AUTOMATIC, 8), + ] { + let e = effects(heat); + assert_eq!(e.shutdown, shutdown, "shutdown at {heat}"); + assert_eq!(e.ammo, ammo, "ammo at {heat}"); + } + } + + /// The failure odds the risks are made of. Needing 4+ fails 3 in 36, + /// 6+ fails 10, 8+ fails 21, 10+ fails 30. + #[test] + fn shutdown_risk_is_the_exact_2d6_tail() { + let at = + |heat| HeatShutdownRisk::measure(&HeatOutlook::new(&shooter(heat, false), 0)).get(); + assert_eq!(at(13), 0.0); + assert!((at(14) - 3.0 / 36.0).abs() < 1e-6); + assert!((at(18) - 10.0 / 36.0).abs() < 1e-6); + assert!((at(22) - 21.0 / 36.0).abs() < 1e-6); + assert!((at(26) - 30.0 / 36.0).abs() < 1e-6); + // Automatic. Not a roll, and it reads as certainty. + assert_eq!(at(30), 1.0); + assert_eq!(at(45), 1.0); + } + + /// The interaction the feature exists for: identical heat, and only the + /// machine with bins to lose is priced for it. + #[test] + fn an_energy_boat_has_no_explosion_risk() { + let hot = shooter(23, false); + let outlook = HeatOutlook::new(&hot, 0); + assert_eq!(HeatAmmoExplosionRisk::measure(&outlook).get(), 0.0); + assert_eq!(HasExplodableAmmo::measure(&outlook).get(), 0.0); + + let loaded = shooter(23, true); + let outlook = HeatOutlook::new(&loaded, 0); + assert!((HeatAmmoExplosionRisk::measure(&outlook).get() - 10.0 / 36.0).abs() < 1e-6); + assert_eq!(HasExplodableAmmo::measure(&outlook).get(), 1.0); + } + + /// The whole point: the features move with the *candidate's* heat, not with + /// the unit's current heat. A cold machine that would alpha into a shutdown + /// check reads the risk; the same machine holding fire reads none. + #[test] + fn the_risk_is_the_volleys_and_not_the_present() { + let cold = shooter(0, true); + assert_eq!( + HeatShutdownRisk::measure(&HeatOutlook::new(&cold, 0)).get(), + 0.0 + ); + assert!(HeatShutdownRisk::measure(&HeatOutlook::new(&cold, 22)).get() > 0.5); + } + + /// Dissipation comes off before the scale is read. Two machines at the same + /// heat with different sinks are not in the same trouble. + #[test] + fn sinks_come_off_before_the_chart_is_read() { + let mut sinker = shooter(10, false); + sinker.heat_capacity = 20; + assert_eq!( + HeatMpPenalty::measure(&HeatOutlook::new(&sinker, 15)).get(), + 1.0 / 5.0, + "10 + 15 - 20 = 5, which is -1 MP" + ); + } +} diff --git a/crates/sds-core/src/features/latch.rs b/crates/sds-core/src/features/latch.rs index 4d6bed8..a61cd22 100644 --- a/crates/sds-core/src/features/latch.rs +++ b/crates/sds-core/src/features/latch.rs @@ -179,6 +179,7 @@ mod tests { player_id: 1, team: 1, actor: None, + eligible: Vec::new(), units: Vec::new(), shots: Vec::new(), deploy_hexes: Vec::new(), diff --git a/crates/sds-core/src/features/mod.rs b/crates/sds-core/src/features/mod.rs index 019d70e..867c4e9 100644 --- a/crates/sds-core/src/features/mod.rs +++ b/crates/sds-core/src/features/mod.rs @@ -27,6 +27,7 @@ pub mod firing; #[cfg(test)] pub(crate) mod fixture; pub mod force; +pub mod heat; pub mod latch; pub mod positional; pub mod score; @@ -276,7 +277,35 @@ impl Weights { .with::(1.5) // Heat is a loan against next turn's alpha. Priced under the damage // it buys, so a hot gun fires when it is worth it. - .with::(-2.0) + // Heat is a resource to spend, not a cost to avoid. At -2.0 the + // bot generated 5.5 heat a unit-round against princess's 15.9, + // carried none, fired 38% as often, and lost by 49 points of + // battle value. At +6.0 it fires 61% as often and finishes level. + // Six rather than two: at +2.0 it was still 31 points down. + .with::(6.0) + // The heat chart, priced by what each line actually costs. These + // are starting points chosen from the rules, not results: nothing + // has been fitted against them yet. `heat_incurred` above is + // untouched and stays the total-heat term; these say where on the + // scale that total lands us. + // + // A movement point is worth real position, and losing all five is + // worth about a light gun's damage. + .with::(-1.0) + // Twice that: an aim penalty degrades every shot we take next turn, + // not just where we stand for them. + .with::(-2.0) + // A shutdown is a free turn for the enemy against a target that + // cannot move, cannot shoot and is hit automatically. Near enough a + // mission kill, and priced just under our own `p_kill`. + .with::(-6.0) + // An ammunition explosion is usually the end of the machine, and it + // is our own doing. The largest cost on this list. + .with::(-8.0) + // Zero on purpose. Present so a fit can find it; a hand set should + // not prefer or avoid a machine for carrying bins, only for the + // risk it is actually running. + .with::(0.0) // Damage aimed past the target's death is spent on nothing. .with::(-3.0) // Ammunition is finite over a match and free within a turn, so it @@ -516,6 +545,11 @@ pub fn catalogue() -> Vec { entry::(), entry::(), entry::(), + entry::(), + entry::(), + entry::(), + entry::(), + entry::(), entry::(), entry::(), entry::(), diff --git a/crates/sds-core/src/heatmap.rs b/crates/sds-core/src/heatmap.rs index 4d98ac7..8191643 100644 --- a/crates/sds-core/src/heatmap.rs +++ b/crates/sds-core/src/heatmap.rs @@ -17,8 +17,7 @@ use crate::facts::{ArcSplit, Gait, Reasons}; use crate::los::{target_movement_modifier, AttackInfo, LosCache, IMPOSSIBLE}; use crate::stands::{Ranking, Sweep}; use crate::volley::{ - bears, twist_options, Firer, FixedToHit, Gator, MountedWeapon, TargetState, - LONG_RANGE_MODIFIER, MAX_TO_HIT, MEDIUM_RANGE_MODIFIER, + bears, twist_options, Firer, FixedToHit, Gator, MountedWeapon, TargetState, MAX_TO_HIT, }; use crate::wire::Coord; @@ -356,61 +355,7 @@ pub fn outline_contains(loops: &[Vec], point: Corner) -> bool { inside } -/// Which range bracket a distance falls in, by name. -/// -/// [`MountedWeapon::bracket`] answers with a modifier and a damage figure, -/// which is what the estimate needs and not what a report needs: a reader wants -/// the bracket's *name* beside the number, and wants "past long" to be a value -/// rather than an absent one. The thresholds are the weapon's own and the -/// modifiers are [`MEDIUM_RANGE_MODIFIER`] and [`LONG_RANGE_MODIFIER`], so the -/// two agree by construction wherever both answer. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Bracket { - /// Range zero. `Compute.getRangeMods` refuses it for everything but - /// infantry weapons, and there is no shot to describe. - SameHex, - Short, - Medium, - Long, - /// Past the long bracket. No roll exists. - Beyond, -} - -impl Bracket { - pub fn of(range: i32, weapon: &MountedWeapon) -> Self { - if range <= 0 { - Bracket::SameHex - } else if range <= weapon.short { - Bracket::Short - } else if range <= weapon.medium { - Bracket::Medium - } else if range <= weapon.long { - Bracket::Long - } else { - Bracket::Beyond - } - } - - pub fn name(self) -> &'static str { - match self { - Bracket::SameHex => "same hex", - Bracket::Short => "short", - Bracket::Medium => "medium", - Bracket::Long => "long", - Bracket::Beyond => "beyond", - } - } - - /// The `R` of GATOR, or `None` where there is no shot to take. - pub fn modifier(self) -> Option { - match self { - Bracket::SameHex | Bracket::Beyond => None, - Bracket::Short => Some(0), - Bracket::Medium => Some(MEDIUM_RANGE_MODIFIER), - Bracket::Long => Some(LONG_RANGE_MODIFIER), - } - } -} +pub use crate::brackets::Bracket; /// One weapon's line in a to-hit breakdown. /// @@ -513,7 +458,7 @@ impl ToHit { ShotRow { weapon: weapon.id, bracket, - gator: bracket.modifier().map(|r| fixed.at_range(r)), + gator: weapon.range_modifier(range).map(|r| fixed.at_range(r)), bears_ahead: bears(from, from.facing, weapon, at.hex), bears_twisted: twists .iter() diff --git a/crates/sds-core/src/lib.rs b/crates/sds-core/src/lib.rs index e1a15dc..08122ae 100644 --- a/crates/sds-core/src/lib.rs +++ b/crates/sds-core/src/lib.rs @@ -10,6 +10,7 @@ //! `sds-bot` for talking to the match host. pub mod arc; +pub mod brackets; pub mod ev; pub mod explore; pub mod facts; diff --git a/crates/sds-core/src/plan.rs b/crates/sds-core/src/plan.rs index 475d18c..2833715 100644 --- a/crates/sds-core/src/plan.rs +++ b/crates/sds-core/src/plan.rs @@ -122,6 +122,65 @@ pub struct ForceDecision { pub considered: Vec, } +/// Which of the force's units took a turn, and what the others were worth. +/// +/// The turn-order half of the same argument [`ForceDecision`] makes about +/// proposals: "we sent the wrong unit" and "no other unit could have gone" look +/// identical in a log that only records the one that acted. +/// +/// Identities and values only. The features behind a value are already in the +/// rationale for the unit that acted, and repeating a vector per eligible unit +/// per turn would multiply a decision log that is megabytes for a 2v2. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TurnChoice { + /// `movement` or `firing`. + pub phase: String, + /// Every unit the host said could act. + pub eligible: Vec, + /// The one we sent. + pub chose: i32, + /// What the chosen unit's order was worth. `None` when nothing scored - a + /// stalled unit, or a phase where no order existed. + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + /// True when `chose` is not the first of `eligible`, which is the pick the + /// host would have made on its own. + pub differed: bool, + /// One row per eligible unit, in the order they were ranked. + pub considered: Vec, + /// The rebuild that produced these orders, when this decision caused one. + /// + /// Movement only. Firing does not rebuild the standing plan, so a replan on + /// a firing row would be movement's, claiming a cause this decision did not + /// have. + #[serde(skip_serializing_if = "Option::is_none")] + pub replan: Option, + /// Rebuilds of the standing plan so far this round, whatever the phase. + pub replans_this_round: u32, +} + +/// One unit's standing order, as a name and a number. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TurnOption { + pub unit: i32, + pub label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, +} + +/// Why the standing plan was rebuilt. +/// +/// The plan is keyed on the round and on which enemies will not move again, so +/// a rebuild happens when an enemy finishes its move and collapses its +/// reachable set to a point. Naming the enemy is what makes that legible. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Replan { + /// Enemies that settled since the last plan. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub settled: Vec, + pub reason: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Chosen { pub unit: i32, diff --git a/crates/sds-core/src/stands.rs b/crates/sds-core/src/stands.rs index 29fdef2..1c20bb4 100644 --- a/crates/sds-core/src/stands.rs +++ b/crates/sds-core/src/stands.rs @@ -656,9 +656,12 @@ pub struct Params { } impl Default for Params { + /// The mean, not the minimax: `p -> -inf` puts blind hexes first by + /// construction, since nothing that can be shot at beats zero incoming. + /// The exponent is doctrine and belongs to whoever is choosing. fn default() -> Self { Self { - exponent: f32::NEG_INFINITY, + exponent: 1.0, top_k: 20, // How long the frontier instrument gets before it is thinned for // reading. Not a bound on anything reported upward. @@ -1400,7 +1403,7 @@ pub fn propose( let target = score .best_target .and_then(|id| enemies.iter().find(|unit| unit.id == id)); - let (remaining, at_stake) = match target { + let (_remaining, at_stake) = match target { Some(unit) => ( (unit.armor + unit.internal).max(1) as f32, unit.locations @@ -1410,8 +1413,10 @@ pub fn propose( ), None => (1.0, 0.0), }; + // Our own ceiling, not what is left of the target: see + // `firing::ceiling`. The same denominator both paths use. features.record::(Score::fraction( - score.offence.expected_damage / remaining, + score.offence.expected_damage / firing::ceiling(us.who.unit), )); features.record::(Score::probability(score.offence.p_kill)); features.record::(Score::probability(score.offence.p_mission_kill)); diff --git a/crates/sds-core/src/volley.rs b/crates/sds-core/src/volley.rs index 3cd27d5..0e53cd2 100644 --- a/crates/sds-core/src/volley.rs +++ b/crates/sds-core/src/volley.rs @@ -56,21 +56,31 @@ //! and that is a property of the path rather than of the volley: //! [`crate::facts::Gait::heat`] has it. //! -//! What is not modelled, and would flatter a shot if it were forgotten: minimum -//! range and extreme range. The wire carries no minimum range and no extreme -//! bracket, so a weapon is short, medium, long or out of range; -//! `Compute.getRangeMods` adds `minimum - distance + 1` inside minimum range and -//! nothing here can. +//! **The range line is [`crate::brackets`]'s.** A weapon's `R` - its bracket, +//! its minimum-range penalty, and which round a launcher would be firing - +//! comes from [`MountedWeapon::modes_at`], so the table a person reads and the +//! number the bot scores off are the same computation. What that leaves to +//! [`gather`] is the one decision the range cannot make: an MML reaching a +//! target with both loads available is resolved here, as the argmax of +//! `damage x P(hit)`, because the answer flips on G, A, T and O. +//! +//! What is still not modelled, and would flatter a shot if it were forgotten: +//! extreme range, which the wire carries no bracket for, and a pulse weapon's +//! -2, which is an accuracy bonus rather than a range term and which nothing on +//! the wire declares. use std::collections::BTreeMap; use crate::arc::{self, MekLocation}; +use crate::brackets::{ + minimum_range_penalty, non_dominated, Bracket, Excluded, Mode, OneOf, WeaponMode, +}; use crate::ev::{EvCache, ShotKey}; use crate::facts::Gait; use crate::hex; use crate::hitloc::{self, LocationDamage, LocationProfile}; use crate::los::{self, AttackInfo, LosCache, Side, IMPOSSIBLE}; -use crate::wire::{Coord, Unit, Weapon}; +use crate::wire::{hit_chance, Coord, Unit, Weapon}; /// To-hit penalty at medium range, from `Entity.getMediumRangeModifier`. pub const MEDIUM_RANGE_MODIFIER: i32 = 2; @@ -154,6 +164,10 @@ impl FixedToHit { #[derive(Debug, Clone, PartialEq)] pub struct MountedWeapon { pub id: i32, + /// MegaMek's name for the weapon, from `WeaponType.getName()`. Carried so + /// a [`crate::brackets::RangeTable`] can print a column heading and so + /// [`Excluded`] can classify what it cannot model. + pub name: String, /// Where it is mounted. Picks the arc, and decides whether a twist moves it. /// /// `None` is a mount this build does not model, from @@ -174,6 +188,19 @@ pub struct MountedWeapon { pub rack_size: i32, pub damage_per_packet: f32, pub usable: bool, + /// Hexes at or inside which the shot takes `minimum - distance + 1`, from + /// [`crate::wire::Weapon::minimum`]. Zero is no minimum range. + pub minimum: i32, + /// The rounds this one launcher could choose between, when there is more + /// than one. Empty for every ordinary gun, which is its own single mode. + /// + /// **Alternatives, never additions.** One round is in the tube. + pub modes: Vec, + /// `Some(reason)` when this weapon is on the unit but is not modelled - + /// artillery, a designator, an anti-missile system. `None` is an ordinary + /// gun. Read [`Self::modelled`] rather than this when all that is wanted is + /// the predicate. + pub excluded: Option, } impl MountedWeapon { @@ -181,6 +208,7 @@ impl MountedWeapon { pub fn from_wire(weapon: &Weapon) -> Self { Self { id: weapon.id, + name: weapon.name.clone(), location: weapon.mount(), rear_mounted: weapon.rear_mounted, heat: weapon.heat, @@ -193,7 +221,130 @@ impl MountedWeapon { rack_size: weapon.rack_size, damage_per_packet: weapon.damage_per_packet, usable: weapon.usable, + minimum: weapon.minimum, + modes: weapon.modes.iter().map(WeaponMode::from_wire).collect(), + excluded: Excluded::of(&weapon.name), + } + } + + /// Whether this weapon is one the bot prices at all. + /// + /// A weapon that is not modelled is never silently treated as an ordinary + /// gun: it is skipped by [`gather`], listed by + /// [`crate::brackets::RangeTable::excluded`] with its reason, and counted + /// per decision. + pub fn modelled(&self) -> bool { + self.excluded.is_none() + } + + /// `(minimum, short, medium, long)` for each round this weapon could be + /// firing. One tuple for an ordinary gun, two for an MML, three for an ATM. + /// + /// A mode with an empty bin is not among them. That is the same class of + /// change as a destroyed weapon and is why a table is built per + /// observation. + pub fn mode_thresholds(&self) -> Vec<(i32, i32, i32, i32)> { + if self.modes.is_empty() { + return vec![(self.minimum, self.short, self.medium, self.long)]; + } + self.modes + .iter() + .filter(|mode| mode.shots_left > 0) + .map(|mode| (mode.minimum, mode.short, mode.medium, mode.long)) + .collect() + } + + /// Every round that reaches this range, with the dominated ones dropped. + /// + /// `None` when nothing reaches - past every long bracket, at range zero, on + /// an unusable weapon, or on one that is not modelled. + /// + /// **The survivors are alternatives.** A [`OneOf`] has no `sum` and no + /// `iter`; the caller must [`OneOf::resolve`] exactly one, and the argument + /// for why that cannot happen here is in [`crate::brackets`]. + pub fn modes_at(&self, range: i32) -> Option> { + if !self.usable || !self.modelled() || range <= 0 { + return None; + } + let mut offers: Vec = Vec::new(); + if self.modes.is_empty() { + offers.extend(self.mode_at( + range, + &self.name, + self.minimum, + self.short, + self.medium, + self.long, + [ + self.avg_damage_short, + self.avg_damage_medium, + self.avg_damage_long, + ], + self.damage_per_packet, + self.rack_size, + )); + } else { + for mode in &self.modes { + if mode.shots_left <= 0 { + continue; + } + offers.extend(self.mode_at( + range, + &mode.name, + mode.minimum, + mode.short, + mode.medium, + mode.long, + [ + mode.avg_damage_short, + mode.avg_damage_medium, + mode.avg_damage_long, + ], + mode.damage_per_packet, + mode.rack_size, + )); + } } + OneOf::from_vec(non_dominated(offers)) + } + + #[allow(clippy::too_many_arguments)] + fn mode_at( + &self, + range: i32, + name: &str, + minimum: i32, + short: i32, + medium: i32, + long: i32, + damage: [f32; 3], + damage_per_packet: f32, + rack_size: i32, + ) -> Option { + let bracket = Bracket::between(range, short, medium, long); + let base = bracket.modifier()?; + let damage = match bracket { + Bracket::Short => damage[0], + Bracket::Medium => damage[1], + Bracket::Long => damage[2], + Bracket::SameHex | Bracket::Beyond => return None, + }; + Some(Mode { + name: name.to_string(), + bracket, + range_modifier: base + minimum_range_penalty(minimum, range), + damage, + damage_per_packet, + rack_size, + }) + } + + /// The `R` of GATOR for this weapon's own loaded round, or `None` past its + /// long bracket. A report's column; the estimate uses [`Self::modes_at`], + /// which knows about a launcher's alternatives and this does not. + pub fn range_modifier(&self, range: i32) -> Option { + let modifier = Bracket::of(range, self).modifier()?; + Some(modifier + minimum_range_penalty(self.minimum, range)) } /// `(to-hit penalty, average damage)` at this range, or `None` past long. @@ -202,19 +353,10 @@ impl MountedWeapon { /// `Entity.getShortRangeModifier` and its two siblings rather than recalled. /// A range of zero is nobody's bracket: `Compute.getRangeMods` refuses it /// for everything but infantry weapons. - fn bracket(&self, range: i32) -> Option<(i32, f32)> { - if range <= 0 { - return None; - } - if range <= self.short { - Some((0, self.avg_damage_short)) - } else if range <= self.medium { - Some((MEDIUM_RANGE_MODIFIER, self.avg_damage_medium)) - } else if range <= self.long { - Some((LONG_RANGE_MODIFIER, self.avg_damage_long)) - } else { - None - } + pub fn bracket(&self, range: i32) -> Option<(i32, f32)> { + let mode = self.modes_at(range)?; + let only = mode.forced()?; + Some((only.range_modifier, only.damage)) } } @@ -623,17 +765,26 @@ pub fn gather( if !bears(state, twist, weapon, target.hex) { continue; } - let Some((range_modifier, damage)) = weapon.bracket(range) else { + let Some(modes) = weapon.modes_at(range) else { continue; }; - let to_hit = fixed.at_range(range_modifier).total(); + // The launcher's round is chosen here and nowhere earlier. A + // bracket knows both loads are available; only this knows the whole + // to-hit, and the answer flips on it - see [`crate::brackets`] for + // the MML that is worth more as SRM against a base of 4 and more as + // LRM against a base of 6. Exactly one is fired: `OneOf` has no + // `sum` and no `iter`. + let mode = modes.resolve(|mode| { + mode.damage * hit_chance(fixed.at_range(mode.range_modifier).total()) + }); + let to_hit = fixed.at_range(mode.range_modifier).total(); if to_hit > MAX_TO_HIT { continue; } - let key = if weapon.rack_size >= 2 { - ShotKey::cluster(to_hit, weapon.rack_size, weapon.damage_per_packet) + let key = if mode.rack_size >= 2 { + ShotKey::cluster(to_hit, mode.rack_size, mode.damage_per_packet) } else { - ShotKey::single(to_hit, damage) + ShotKey::single(to_hit, mode.damage) }; if let Some(key) = key { shots.push(key); @@ -1169,6 +1320,97 @@ mod tests { assert_eq!(key, Err(NoVolley::NothingBears)); } + /// Minimum range reaches the to-hit. `Compute.getRangeMods` adds + /// `minimum - distance + 1` at or inside the minimum, so a Gauss Rifle two + /// hexes out is at `+1` and one hex out is at `+2`. Nothing priced this + /// before the wire carried the field, and every short-range stand was + /// flattered by it. + #[test] + fn a_shot_inside_minimum_range_pays_for_it() { + let mut gauss = gun_at(1, 15.0, MekLocation::RightTorso); + gauss.minimum = 2; + assert_eq!(gauss.range_modifier(1), Some(2)); + assert_eq!(gauss.range_modifier(2), Some(1)); + assert_eq!(gauss.range_modifier(3), Some(0), "one hex clear of it"); + + let board = flat(40, 40); + let los = cache(&board); + let unit = fixture::mek(1); + let mut held = VolleyCache::new(); + let mut shot = |range: i32, weapon: &MountedWeapon| { + let (key, _) = best_volley_keyed( + &mut held, + &los, + &firer(10, 20, 0), + &target(&unit, 10 + range, 20, 0), + std::slice::from_ref(weapon), + ); + key.expect("a line on a flat board").options[0].shots[0].to_hit + }; + // Gunnery 4, standing still, no cover: the only thing moving is R. + assert_eq!(shot(1, &gauss), 6); + assert_eq!(shot(3, &gauss), 4); + } + + /// One tube, one round. `gather` has the whole to-hit, which is the only + /// place the choice can be made: the same MML at the same range answers + /// differently as the base to-hit moves. What it must never do is fire + /// both, and `OneOf` has no way to express that. + #[test] + fn a_launcher_fires_one_of_its_loads() { + let mut mml = gun_at(1, 7.0, MekLocation::RightTorso); + mml.minimum = 6; + mml.short = 7; + mml.medium = 14; + mml.long = 21; + mml.modes = vec![ + WeaponMode { + name: "MML 7 LRM Ammo".to_string(), + minimum: 6, + short: 7, + medium: 14, + long: 21, + avg_damage_short: 7.0, + avg_damage_medium: 7.0, + avg_damage_long: 7.0, + damage_per_packet: 1.0, + rack_size: 7, + shots_left: 17, + }, + WeaponMode { + name: "MML 7 SRM Ammo".to_string(), + minimum: 0, + short: 3, + medium: 6, + long: 9, + avg_damage_short: 14.0, + avg_damage_medium: 14.0, + avg_damage_long: 14.0, + damage_per_packet: 2.0, + rack_size: 7, + shots_left: 14, + }, + ]; + + let board = flat(40, 40); + let los = cache(&board); + let unit = fixture::mek(1); + let mut held = VolleyCache::new(); + let (key, _) = best_volley_keyed( + &mut held, + &los, + &firer(10, 20, 0), + &target(&unit, 18, 20, 0), + std::slice::from_ref(&mml), + ); + let shots = &key.expect("a line").options[0].shots; + assert_eq!(shots.len(), 1, "one tube fires once, never twice"); + // Gunnery 4 and nothing else: the SRM load's +4 beats the LRM load's + // +2, because 14 points at 15/36 beats 7 points at 21/36. + assert_eq!(shots[0].to_hit, 8); + assert_eq!(shots[0].packet_tenths, 20, "two points a missile is SRM"); + } + #[test] fn range_brackets_are_megameks() { let weapon = gun_at(1, 10.0, MekLocation::RightTorso); diff --git a/crates/sds-core/src/wire.rs b/crates/sds-core/src/wire.rs index 74ff947..2183815 100644 --- a/crates/sds-core/src/wire.rs +++ b/crates/sds-core/src/wire.rs @@ -117,8 +117,18 @@ pub struct Observation { pub phase: String, pub player_id: i32, pub team: i32, + /// The unit the host will act on if the bot does not choose one. + /// + /// A default, not a fact. It used to be "the unit being asked about" - the + /// host broke on the first eligible unit and the bot was handed the result. + /// A reply naming a unit from [`Self::eligible`] overrides it. #[serde(default)] pub actor: Option, + /// Every unit of ours that may take this turn, ids only. Empty in a phase + /// that offers no choice, and empty on an observation from a host older + /// than this field. + #[serde(default)] + pub eligible: Vec, #[serde(default)] pub units: Vec, /// Present in the firing phase: every shot the rules allow. @@ -129,6 +139,21 @@ pub struct Observation { pub deploy_hexes: Vec, } +impl Observation { + /// Who this side may send now. + /// + /// Falls back to `actor` alone, which is what a host older than the + /// `eligible` field offers and is also the honest answer for a turn + /// MegaMek pinned to one unit. + pub fn eligible_units(&self) -> Vec { + if self.eligible.is_empty() { + self.actor.into_iter().collect() + } else { + self.eligible.clone() + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Unit { @@ -189,8 +214,31 @@ pub struct Unit { pub internal_max: i32, #[serde(default)] pub heat: i32, + /// What this unit sheds in a turn, from `Entity.getHeatCapacity()`. + /// + /// **Dissipation, not a ceiling.** This is the sinks' throughput. The + /// shutdown, to-hit and ammunition thresholds are fixed points on the heat + /// scale (14, 18, 22, 26, 30) and have nothing to do with this number. A + /// firing menu that truncated a volley to fit inside it fired what the unit + /// could sink and nothing more, which is a rule the weights could neither + /// see nor overrule; heat is priced by the heat features now. #[serde(default)] pub heat_capacity: i32, + /// Heat banked this turn that has not reached the scale yet, from + /// `Entity.heatBuildup` - running, jumping, engine crits. + /// + /// Zero on an observation recorded before the field existed, which reads as + /// "stood still", the same answer the bot gave before it had this. + #[serde(default)] + pub heat_buildup: i32, + /// Whether this unit carries ammunition a heat roll could set off. + /// + /// MegaMek's own set, from `TWGameManager.explodeAmmoFromHeat` - see the + /// bridge's `Observation.explodableAmmo`. False for an energy boat and for + /// an observation older than the field, and the two read the same: no + /// explosion risk to price. + #[serde(default)] + pub explodable_ammo: bool, #[serde(default)] pub prone: bool, #[serde(default)] @@ -273,6 +321,25 @@ impl Unit { self.heat as f32 / self.heat_capacity as f32 } + /// Where this unit sits on the heat scale at the end of the turn, if it + /// generates `extra` more points before the heat phase. + /// + /// `heat + heat_buildup + extra - heat_capacity`, floored at zero. + /// + /// Each term earns its place. `heat` is the scale as it stands, carried + /// from last turn. `heat_buildup` is what this turn has already banked and + /// not yet applied - the run that got the unit here. `extra` is the volley + /// being priced. `heat_capacity` is subtracted because it is + /// **dissipation**: what the sinks shed in the heat phase, which is the one + /// thing this whole calculation was got wrong by reading as a ceiling. + /// + /// The answer is the number the heat chart is indexed by, so the heat + /// features can ask "would this volley put me over 14" of + /// a candidate rather than "am I over 14" of the present. + pub fn end_of_turn_heat(&self, extra: i32) -> i32 { + (self.heat + self.heat_buildup + extra - self.heat_capacity).max(0) + } + /// Whether this unit walks on legs over the ground. /// /// The terrain costs in [`crate::pathfind`] are the biped/quad Mek column @@ -468,6 +535,19 @@ pub struct Weapon { pub medium: i32, #[serde(rename = "long")] pub long_range: i32, + /// Hexes at or inside which the shot takes a penalty, from + /// `WeaponType.getMinimumRange()`. + /// + /// Zero is "no minimum range", which is what most guns have. It is also + /// what an observation recorded before the field existed says, and the two + /// are the same answer: a bot that prices no penalty is exactly as wrong as + /// it was before the field landed, and no more. + /// + /// The bridge clamps MegaMek's sentinel. `getMinimumRange()` returns + /// `Integer.MIN_VALUE` for a weapon with none, and nothing here should ever + /// see it. + #[serde(default)] + pub minimum: i32, /// Real average damage, per range bracket. /// /// There is deliberately no plain `damage` field. MegaMek's @@ -492,6 +572,60 @@ pub struct Weapon { /// What is loaded. `None` for an energy weapon and for an empty rack. #[serde(default)] pub ammo: Option, + /// The rounds this one launcher could choose between, when there is more + /// than one to choose between. + /// + /// Empty for every ordinary gun, and for an observation recorded before the + /// field existed - both of which mean "the fields above are the whole + /// story". Present for an MML, which fires LRM or SRM ammo out of one tube, + /// and for an ATM, which has three. + /// + /// **Alternatives, never additions.** One round is in the tube. These + /// define where the range brackets fall; a bracket then resolves to the + /// best of them that reaches it. Reading them as separate weapons would let + /// one launcher fire twice. + #[serde(default)] + pub modes: Vec, +} + +/// One round a launcher could be firing, with the brackets that round has. +/// +/// From `WeaponType.getRanges(weapon, ammo)`, which is the only accessor that +/// knows an MML has brackets at all - the plain `getShortRange()` family +/// reports zero for every one of them. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WeaponModeWire { + /// MegaMek's name for the round: `MML 9 LRM Ammo`, `ATM 6 ER Ammo`. The one + /// field that tells an MML's two standard loads apart - `type` is `MML` for + /// both. + #[serde(default)] + pub name: String, + /// MegaMek's ammo family: `MML`, `ATM`, `IATM`. + #[serde(default, rename = "type")] + pub kind: String, + #[serde(default)] + pub munition: String, + #[serde(default)] + pub minimum: i32, + #[serde(default)] + pub short: i32, + #[serde(default)] + pub medium: i32, + #[serde(default, rename = "long")] + pub long_range: i32, + #[serde(default)] + pub avg_damage_short: f32, + #[serde(default)] + pub avg_damage_medium: f32, + #[serde(default)] + pub avg_damage_long: f32, + #[serde(default)] + pub damage_per_packet: f32, + #[serde(default)] + pub rack_size: i32, + #[serde(default)] + pub shots_left: i32, } /// The round in the tube. @@ -565,6 +699,14 @@ impl Weapon { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Shot { + /// Which of our units takes this shot. + /// + /// A weapon id means nothing 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. `None` is an observation older than the + /// eligible set, where every shot belonged to the actor. + #[serde(default)] + pub shooter: Option, pub weapon: i32, pub target: i32, pub to_hit: i32, @@ -668,6 +810,22 @@ pub struct Reply { /// only a sentence in a paragraph is a row nobody can fit. #[serde(skip_serializing_if = "Option::is_none")] pub training: Option, + /// Which of our units this action is for. + /// + /// The host acts on this unit instead of its own first-eligible pick. It + /// must be one of the observation's `eligible` ids: name anything else and + /// the host refuses it, falls back to its own pick, and counts a + /// `substituted`. Absent means "your pick is fine". + #[serde(skip_serializing_if = "Option::is_none")] + pub unit: Option, + /// Why this unit and not another: the identities and values the force + /// ranked, and the replan that produced them. 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. + #[serde(skip_serializing_if = "Option::is_none")] + pub turn: Option, } impl Reply { @@ -678,9 +836,21 @@ impl Reply { action, rationale: None, training: None, + unit: None, + turn: None, } } + pub fn for_unit(mut self, unit: Option) -> Self { + self.unit = unit; + self + } + + pub fn with_turn(mut self, turn: serde_json::Value) -> Self { + self.turn = Some(turn); + self + } + pub fn with_rationale(mut self, rationale: serde_json::Value) -> Self { self.rationale = Some(rationale); self @@ -706,7 +876,8 @@ mod tests { "weight": 55.0, "walkMp": 5, "runMp": 8, "jumpMp": 5, "movementMode": "BIPED", "maxElevationChange": 2, "armor": 128, "armorMax": 144, "internal": 91, "internalMax": 91, - "heat": 4, "heatCapacity": 10, + "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"], @@ -721,12 +892,30 @@ mod tests { ], "weapons": [{ "id": 3, "name": "LRM 10", "location": "RT", "heat": 4, - "short": 7, "medium": 14, "long": 21, + "short": 7, "medium": 14, "long": 21, "minimum": 6, "avgDamageShort": 6.0, "avgDamageMedium": 6.0, "avgDamageLong": 6.0, "rackSize": 10, "damagePerPacket": 1.0, "usable": true, "ammo": {"type": "LRM", "munition": "Standard", "damagePerShot": 1, "rackSize": 10, "shotsLeft": 12} + }, { + "id": 4, "name": "MML 7", "location": "RA", "heat": 4, + "short": 7, "medium": 14, "long": 21, "minimum": 6, + "avgDamageShort": 4.0, "avgDamageMedium": 4.0, + "avgDamageLong": 4.0, "rackSize": 7, + "damagePerPacket": 1.0, "usable": true, + "modes": [ + {"name": "MML 7 LRM Ammo", "type": "MML", + "minimum": 6, "short": 7, "medium": 14, "long": 21, + "avgDamageShort": 4.0, "avgDamageMedium": 4.0, + "avgDamageLong": 4.0, "damagePerPacket": 1.0, + "rackSize": 7, "shotsLeft": 17}, + {"name": "MML 7 SRM Ammo", "type": "MML", + "minimum": 0, "short": 3, "medium": 6, "long": 9, + "avgDamageShort": 8.0, "avgDamageMedium": 8.0, + "avgDamageLong": 8.0, "damagePerPacket": 2.0, + "rackSize": 7, "shotsLeft": 14} + ] }] }] }"#; @@ -755,6 +944,30 @@ mod tests { } } + /// The heat scale at the end of the turn, which is the number the heat + /// chart is indexed by. `heatCapacity` is dissipation, so it comes off. + #[test] + fn end_of_turn_heat_subtracts_dissipation() { + let unit = &parse(CURRENT).units[0]; + assert_eq!(unit.heat, 4); + assert_eq!(unit.heat_buildup, 2); + assert_eq!(unit.heat_capacity, 10); + // 4 banked, 2 from this turn's movement, 20 of volley, 10 shed. + assert_eq!(unit.end_of_turn_heat(20), 16); + // A volley that fits inside the sinks leaves the scale where it is. + assert_eq!(unit.end_of_turn_heat(4), 0); + assert_eq!(unit.end_of_turn_heat(0), 0); + } + + /// A unit with no ammunition to set off, and an observation from before the + /// field existed, give the same answer: nothing to price. + #[test] + fn explodable_ammo_comes_off_the_wire() { + assert!(parse(CURRENT).units[0].explodable_ammo); + assert!(!parse(LEGACY).units[0].explodable_ammo); + assert_eq!(parse(LEGACY).units[0].heat_buildup, 0); + } + #[test] fn locations_come_off_the_wire() { let unit = &parse(CURRENT).units[0]; @@ -803,6 +1016,50 @@ mod tests { assert_eq!(unit.max_elevation_change(), 2); } + /// Every PPC variant but the Snub-Nose has a minimum range, an LRM has + /// six, a Gauss Rifle two - and none of it reached the bot before this + /// field. The numbers are `WeaponType.getMinimumRange()`'s, read out of the + /// jar. + #[test] + fn minimum_range_comes_off_the_wire() { + let unit = &parse(CURRENT).units[0]; + assert_eq!(unit.weapons[0].minimum, 6, "an LRM 10"); + } + + /// An MML's two loads arrive as alternatives, with their own brackets and + /// their own minimum range. `WeaponType.getRanges(weapon, ammo)` is the + /// only accessor that has them: the plain `getShortRange()` family reports + /// zero for every bracket of an MML. + #[test] + fn a_launchers_ammo_alternatives_come_off_the_wire() { + let unit = &parse(CURRENT).units[0]; + let mml = &unit.weapons[1]; + assert_eq!(mml.modes.len(), 2); + + let lrm = &mml.modes[0]; + assert_eq!( + (lrm.minimum, lrm.short, lrm.medium, lrm.long_range), + (6, 7, 14, 21) + ); + assert_eq!(lrm.avg_damage_long, 4.0); + + let srm = &mml.modes[1]; + assert_eq!( + (srm.minimum, srm.short, srm.medium, srm.long_range), + (0, 3, 6, 9) + ); + assert_eq!(srm.avg_damage_short, 8.0); + + // Alternatives, not additions: one tube, and `name` is the only field + // that tells the two standard loads apart - `type` is `MML` for both. + assert_eq!(lrm.kind, srm.kind); + assert_ne!(lrm.name, srm.name); + + // An ordinary gun carries none, which is not a claim that it has one + // mode fewer than it has. + assert!(unit.weapons[0].modes.is_empty()); + } + #[test] fn an_observation_without_the_new_fields_still_parses() { let obs = parse(LEGACY); @@ -810,6 +1067,11 @@ mod tests { assert!(unit.locations.is_empty()); assert!(unit.location("CT").is_none()); assert!(unit.weapons[0].ammo.is_none()); + // A PPC really does have a minimum range of three. Zero here is the + // observation not saying so, and reads as no penalty - exactly as + // wrong as the bot was before the field landed, and no more. + assert_eq!(unit.weapons[0].minimum, 0); + assert!(unit.weapons[0].modes.is_empty()); // The fields that were always there still mean what they meant. assert_eq!(unit.armor, 128); assert!((unit.health() - 219.0 / 235.0).abs() < 1e-6); diff --git a/docs/EXPERIMENTS.md b/docs/EXPERIMENTS.md index 2cfe4bd..9c74722 100644 --- a/docs/EXPERIMENTS.md +++ b/docs/EXPERIMENTS.md @@ -5,7 +5,7 @@ One idea, one branch, one comparison in the pull request. git checkout -b claude/flank-weighting origin/main # change the bot ./scripts/build.sh - ./sds.sh bench --games 60 --bot /work/target/release/sds-bot --against main + ./sds.sh bench --games 60 --against main The last command prints a comparison and writes `comparison.md` into the run directory. That file is the PR body. @@ -18,7 +18,7 @@ large, derived, and regenerated by any run. Record one when `main` moves: - ./sds.sh bench --games 60 --bot /work/target/release/sds-bot \ + ./sds.sh bench --games 60 \ --save-baseline main --notes "after the terrain-cost fix" Then commit it. A baseline that lives on one machine cannot be compared against diff --git a/docs/PLAYING.md b/docs/PLAYING.md new file mode 100644 index 0000000..2bb801c --- /dev/null +++ b/docs/PLAYING.md @@ -0,0 +1,115 @@ +# Playing the bot yourself + + ./scripts/build.sh # once, and again after any bridge change + ./sds.sh play + +That opens MegaMek — the real one, its own Swing client — with you in one seat +and `sds-bot` in the other. Close the window to end the match. + +## What it is + +One container, three processes: + +- `SdsHost`, running a real MegaMek `Server` with the bot seats attached +- `sds-bot`, a child process of that JVM, on the other side +- MegaMek's own client, drawing on your X server + +Everything talks over `127.0.0.1` **inside** the container. Nothing is +published, so two of these cannot collide with each other or with a benchmark, +and the only thing that leaves the container is the display. + +## Picking a scenario and a side + + ./sds.sh play --scenario scenarios/suite/4v4-jihad-s102040.mms + ./sds.sh play --side South + +`--scenario` takes any `.mms` with exactly two factions; +`scenarios/suite/2v2-jihad-s102020.mms` is the default. `--side` takes a +faction name from that file's `Factions=` line, and defaults to the first one. +The command prints which seat is which before it starts anything: + + bot : /work/target/release/sds-bot + scenario : .../scenarios/suite/2v2-jihad-s102020.mms + you : human North + opponent : sds South + +`human North` is also the name to look for in MegaMek's own report and in the +unit tooltips, and the name the client connects under. + +`--bot` puts something else on the other side — the floor bot is +`--bot "python3 /work/bots/random_bot.py"` — and the first line always says +which one is actually playing. + +## What you get afterwards + +The same files a benchmarked match leaves, in the same place: + + runs/-play/ + .json the result document + .report.html MegaMek's round report: rolls, hits, criticals + -sds_South.decisions.jsonl every candidate the bot scored + -sds_South.weights.json the weights it scored them with + .logs/ MegaMek's own scratch for this match + +So the two readers work on a game you played exactly as they work on a +benchmarked one: + + ./sds.sh explain runs/ --phase FIRING + ./sds.sh view runs/ + +That is the point of playing. A win rate cannot tell you *why* the bot walked +into the open; the decision log can, and now you can provoke the situation +yourself instead of waiting for it to turn up in a batch. + +## There is no lounge + +A scenario-hosted game does not have one. `ScenarioLoader` hands the server a +game that opens on *Initiative Phase for Start of Game Deployment*, so the +window appears with the board already drawn and your first deployment turn +waiting. Nothing readies you and nothing is synthesised on your behalf: the +server simply holds your turn until your client is there to take it, however +long MegaMek spent loading its unit cache. The terminal says `server is up` +when the client is being started. + +The cost of no lounge is that the scenario's own force list, skills and camo +are what you get; there is no screen on which to change them. Edit the `.mms` +if you want different ones. + +## Timeouts + +Every watchdog is off on this path. `--max-rounds`, `--stall-seconds` and the +wall clock exist because two bots that stop making progress must not hang a +hundred-game batch, and all three are tuned for players that answer in +milliseconds. A person deciding a movement phase would trip them. `--play-max-rounds N` +puts a round limit back if you want one. + +The bot's own per-decision budget is unchanged, because it is a budget on the +bot and not on the match: it never waits for you. + +## X11, and the one-time setup + + ./sds.sh play --check-display + +runs `xdpyinfo` in the match image and says whether it can draw on your +display. If that works, `play` works. + +The mechanism is the X11 socket bind-mounted in, `DISPLAY` passed through, and +your session's X authority cookie mounted read-only at `/tmp/.sds-xauth`. On +this machine — a Wayland session with XWayland — that cookie is the file +`XAUTHORITY` points at, and no further setup is needed. + +Nothing here will run `xhost` for you. If `--check-display` fails it prints the +command that would grant access, and running it is yours to decide, because it +changes your whole session's X permissions and not just this container's. + +MegaMek's client settings — window sizes, the game options panel — are kept in +`~/.cache/sds-play/mmconf`, seeded from the install's `mmconf/` the first time +and left alone afterwards. The MegaMek install itself is not written to, so a +game here does not change what a benchmark or a sibling repo sees. + +## Container names + +A play container is named `sdsplay-`, deliberately **not** `sds-*`. +`sds clean` kills every `sds-*` container on the machine whoever started it; a +benchmark losing that race costs a rerun, and a person losing it costs the +game. Your own `play` still kills its container when it exits. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 57b313c..c9bfc90 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -62,14 +62,15 @@ the board is a property of the *unit*, which is why `movementMode` and ```json {"type": "observation", "seq": 7, "round": 3, "phase": "MOVEMENT", - "playerId": 1, "team": 2, "actor": 12, + "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, "elevation": 0, "weight": 55.0, "bv": 1272, "bvOriginal": 1387, "walkMp": 5, "runMp": 8, "jumpMp": 5, "movementMode": "BIPED", "maxElevationChange": 2, "armor": 128, "armorMax": 144, "internal": 91, "internalMax": 91, - "heat": 4, "heatCapacity": 10, + "heat": 4, "heatCapacity": 10, "heatBuildup": 2, + "explodableAmmo": true, "prone": false, "shutDown": false, "destroyed": false, "crippled": false, "done": false, "gunnery": 4, "piloting": 5, @@ -117,12 +118,58 @@ empty rack. A launcher's damage is not a property of the launcher: an SRM-6 throws two points a missile with standard ammo and none with Inferno, which deals heat instead. `damagePerShot` is per missile for a cluster rack. +The three heat fields are not interchangeable and one of them has been misread +before. `heat` is the scale as it stands, carried from last turn. `heatBuildup` +is `Entity.heatBuildup`: what this turn has already banked — running, jumping, +an engine crit — and not yet applied. `heatCapacity` is +`Entity.getHeatCapacity()`, which is **dissipation**, what the sinks shed in the +heat phase. It is *not* a ceiling: the shutdown, to-hit and ammunition +thresholds are fixed points on the scale (14, 18, 22, 26, 30) and have nothing +to do with it. The bot's `Unit::end_of_turn_heat` is the only thing that should +combine them. + +`explodableAmmo` is whether the unit carries ammunition a heat roll could set +off. It is MegaMek's own set, taken from `TWGameManager.explodeAmmoFromHeat`: +an undestroyed ammo bin that reports `EquipmentType.isExplosive`, still has +hittable shots, and is not a coolant pod, vehicle or heavy flamer ammo, or a bin +loaded with coolant munitions. False for an energy boat, which is a real answer +and not a missing one. + `role` is MegaMek's own role for the design and `forcePath` is the unit's place in MegaMek's force tree, outermost first. Both are data the game already keeps, and both are hints rather than instructions — see [HIERARCHY.md](HIERARCHY.md) for why a role must never bind a decision. -`actor` is the unit being asked about. `units` is every unit with a position, +`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. + +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. @@ -132,12 +179,30 @@ Undeployed and removed units are omitted rather than sent with a null position. ### `action` — bot to host ```json -{"type": "action", "seq": 7, "kind": "move", "steps": ["TURN_LEFT", "FORWARDS", "FORWARDS"]} +{"type": "action", "seq": 7, "kind": "move", "unit": 14, "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"} ``` +`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 action may also carry `turn`, the turn-order record: which units the force +planned for, which it picked and at what value, and the replan that produced +those orders. The host copies it onto the decision log line. Identities and +values only — a feature vector per eligible unit per turn would multiply a log +that is already megabytes for a 2v2. + +```json +{"turn": {"phase": "movement", "eligible": [12, 14], "chose": 14, + "value": 0.81, "differed": true, + "considered": [{"unit": 14, "label": "close on 9", "value": 0.81}, + {"unit": 12, "label": "hold the ridge", "value": 0.10}], + "replan": {"settled": [9], "reason": "enemy 9 settled"}, + "replans_this_round": 2}} +``` + An action may also carry `rationale`, any JSON the bot likes. The host does not read it and writes it to the decision log. It may also carry `training`, an object whose keys the host copies onto the decision log line — see below. A hierarchy whose reasoning cannot be @@ -172,6 +237,7 @@ Each SDS seat appends one JSON line per decision to ```json {"seat": "North", "seq": 7, "round": 3, "phase": "MOVEMENT", "actor": 12, + "eligible": [12, 14], "actedOn": 14, "turn": { ... }, "outcome": "answered", "taken": "[MovePath] Length: 3; To: Coords (7, 2)", "action": {"kind": "move", "steps": ["FORWARDS"]}, "rationale": [ ... ], "unit": 12, @@ -190,6 +256,12 @@ what the host actually did — which is not the same claim as `action`, because 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`. +`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: @@ -279,7 +351,7 @@ 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 +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. @@ -292,8 +364,18 @@ counters are what say so. | 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. diff --git a/overnight.sh b/overnight.sh new file mode 100755 index 0000000..fee54fc --- /dev/null +++ b/overnight.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Overnight corpus: every size, smallest first. Self-recovering, load-guarded. +set -uo pipefail +export PATH="$HOME/.cargo/bin:$PATH" +export SDS_IMAGE=lance-blue/sds-runner-report +cd "$(dirname "$0")" || exit 1 +BOT=/work/target/release/sds-bot +LOG=overnight.log +say() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG"; } + +# Wait until the machine can take another phase. Never proceeds blindly. +settle() { + for _ in $(seq 1 120); do + load=$(awk '{print int($1)}' /proc/loadavg) + free=$(free -g | awk '/^Mem:/{print $7}') + [ "$load" -lt 8 ] && [ "$free" -ge 4 ] && return 0 + say "waiting: load $load, ${free}G free" + sleep 60 + done + say "machine never settled; going ahead at low concurrency anyway" +} + +phase() { + size=$1; games=$2 + say "=== $size: $games games ===" + for attempt in 1 2 3; do + settle + if ./sds.sh bench --suite "suites/$size" --games "$games" --jobs 2 \ + --bot "$BOT" --candidates \ + --save-baseline "overnight-$size" \ + --notes "generated-only, uncapped, ceiling denominator" >>"$LOG" 2>&1; then + say "$size finished" + return 0 + fi + say "$size attempt $attempt failed; retrying after a pause" + sleep 120 + done + say "$size failed three times; moving on rather than stopping the night" +} + +say "start: $(uptime)" +phase 1v1 180 +phase 2v2 180 +phase 4v4 150 +phase 8v8 120 +say "all phases done" diff --git a/plan/candidates.md b/plan/candidates.md index 5073274..b358120 100644 --- a/plan/candidates.md +++ b/plan/candidates.md @@ -586,6 +586,68 @@ because the enemies in `M` now pay for their own movement too. control run applies, which is worth saying plainly because this one looks like it would change play. +### The range line, as brackets + +`sds-core/src/brackets.rs`. A **bracket** is a span of hexes over which nothing +about a unit's range line changes. The boundaries come only from thresholds a +weapon declares: the hex after its short, medium and long brackets, the hex +after its minimum, and **every hex inside a minimum range**, because +`Compute.getRangeMods` adds `minimum - distance + 1` and that is a different +number at each one. Union those over every weapon and every round it could be +firing, drop anything past the longest long bracket, insert 1. + +Two units, hand-verified against `data/mekfiles` and MegaMek's own equipment +table, and both are tests. An **Atlas II AS7-D-H2** has fourteen spans, starting +at 1, 2, 3, 4, 5, 6, 7, 8, 10, 15, 16, 22, 23 and 24-where-nothing-fires: at one +hex the Gauss Rifle is at `+2` and the LRM 20 at `+6`. A **Deva C-DVA-OA +Dominus** has sixteen, because its MML 7 contributes both loads' thresholds. + +**The bracket is kept, not collapsed to its modifier.** Storing `+2` and +forgetting it came from the medium bracket breaks three rules at once: a +Snub-Nose PPC and a VSPL change *damage* by bracket, stealth armour adds a +penalty keyed to the incoming weapon's bracket, and C3 resolves range against a +spotter while the shooter's own bracket still decides its damage. + +**A bracket cannot say which weapons bear**, because bearing depends on facing, +twist and target position, which belong to the pair. For the same reason it +carries only the `R` of GATOR. + +**A launcher's alternatives stay alternatives.** A bracket holds a `OneOf`, +which has no `sum`, no `iter` and no `IntoIterator`: an MML's two loads must +never be added or averaged, because one round is in the tube. Pruning at build +time is limited to what is provable without the pair - a mode is dropped only +when another is no worse on *both* the modifier and the damage. What survives is +resolved in `volley::gather`, which has the whole to-hit, as the argmax of +`damage x P(hit)`. + +The measurement that says resolution cannot happen earlier: an **MML 7 at hex +8**. Against a base to-hit of 4 the SRM load is worth 5.83 and the LRM load +5.06; against a base of 6 the SRM load is worth 2.33 and the LRM load 2.92. Same +range, same weapon, opposite answer. + +**Rebuild per observation. Never cache across rounds.** An ammo bin running out +invalidates a table exactly the way a destroyed weapon does - an MML that has +fired off its LRM bin has one mode and different boundaries. `Cost::brackets` +reports the span count per decision so a stale one would be visible. + +**Excluded loudly and countably.** Artillery, TAG and C3 master, AMS, A/B/M-pods, +infantry and BA weapons, mortars, VGL, taser, TSEMP, extinguisher, bombs and +Narc all fire under rules this bot does not implement. Each is dropped with a +reason, stays in the table as a `not modelled` row, and is counted per decision +as `Cost::unmodelled`. The classification is a name list matched in Rust, which +is the weak part: `WeaponType.getFlags()` knows properly, and a `modelled` flag +on the wire is the honest fix. See [protocol](protocol.md). + +**Not in `R`, and deliberately.** A pulse weapon is -2 to hit. That is an +accuracy bonus, not a range term, and nothing on the wire declares it, so the +range column says `+0` for a Medium Pulse Laser at one hex. Putting the -2 there +would make the column stop meaning range. + +**What it moved.** Minimum range now reaches the estimate for the first time, so +a Gauss Rifle, an LRM and every PPC but the Snub-Nose are priced worse up close +than they were, and an MML - which reached no hex at all before the wire carried +`modes` - now reaches. `sds-bot` reads this path, so it changes play. + ### Promoting a fact to a feature **Adding a fact is cheap. Adding a name to the feature basis is not.** @@ -908,6 +970,26 @@ does not fix them. ## Heat: reported, not budgeted +The firing menu now agrees. `Bot::allocations` used to truncate every volley to +`heat_capacity - heat + 4`, and `heat_capacity` is `Entity.getHeatCapacity()` - +**dissipation**, what the sinks shed in a turn, not the shutdown threshold. So +the bot never fired more than it could sink in one turn, as a rule no weight +could see or overrule, and it fired about 63% as often as Princess. The budget +is gone. What replaced it: + +- [x] Every prefix of a heaviest-packet-first order, so the weights can pick any + rung between one gun and the alpha strike. Heaviest first is mechanical + and not a preference: damage inside a volley resolves one weapon at a + time, so a gun that breaches armour first means every later hit lands in + open structure and buys its own critical roll +- [x] The cap is spent evenly over the targets, and thinning takes the middle of + a ladder rather than its tail. Cutting the tail would drop the alpha + strike, which is the wall again under another name +- [x] Hold fire is a scored candidate carrying the heat features at zero extra + heat, so not shooting and shooting are compared on the same terms +- [x] Heat itself is priced by `sds_core::features::heat`, on the scale the + volley would leave the unit on + `VolleyOutcome` reported expected damage, `p_kill`, `p_mission_kill`, expected breaches and `value_destroyed`, and said nothing about what the volley cost. `VolleyOutcome::heat` is the sixth output. @@ -1105,6 +1187,20 @@ the *difference* of the ranks is scale-free and survives 1v1, 3v1 and 8v8 alike. The absolute pair stays in the tooltips: being outnumbered really does mean taking three times the fire. +## Decisions on this path + +Not permanent rules. Each has been undone once by somebody working from the code +alone. + +- **The generator does not choose a risk attitude.** The exponent is doctrine. + `Params::default()` was pinned at `p -> -inf`, which puts blind hexes first by + construction. Revisit when a stance can carry one. +- **Every candidate on a menu is scored on the same features.** One weight + vector compares them, so a subset is a different ruler. +- **An enemy that has not moved is an envelope, not a hex.** Rebuilt per + decision. `M = 1` makes the whole collapse the identity. +- **A memo key names everything its computation reads, and nothing else.** + ## Deferred **Jump** is a second pathfinding pass, not a variant of the first: a jump cannot diff --git a/plan/features.md b/plan/features.md index 5ee993f..f251604 100644 --- a/plan/features.md +++ b/plan/features.md @@ -49,6 +49,15 @@ whether it can be used for learning. weight fitted against it came from eight rows; `waste_above` against the mission-kill threshold is the fixed form, and `thin_columns` in `sds/train.py` is the guard that would have caught it +- [x] Firing, the heat chart: `heat_mp_penalty`, `heat_to_hit_penalty`, + `heat_shutdown_risk`, `heat_ammo_explosion_risk`, `has_explodable_ammo`, + all measured on the heat the *candidate* would leave the unit on rather + than the heat it is at. One table in `features/heat.rs`, off + `HeatEffects.0`..`HeatEffects.30` and cross-checked against + `HeatResolver`. They replace a rule: `allocations()` truncated every + volley to `heat_capacity - heat + 4`, and `heat_capacity` is dissipation + rather than the shutdown threshold, so the bot never fired more than it + could sink and no weight could see the wall - [x] Firing, per location: `p_kill`, `p_mission_kill` and `overkill` measured against the location the damage reaches rather than against the target's total, plus `p_breach` and `value_destroyed`. See `hitloc` below @@ -166,6 +175,8 @@ this simulator does. ### Heat wants a feature per breakpoint, not a curve +**Done**, less TSM. See `crates/sds-core/src/features/heat.rs`. + Heat does nothing gradually. It crosses a line and something specific breaks: movement, then aim, then shutdown, then ammo. TSM turns *on* at a line too, and that one is a benefit. Verified in MegaMek: `Mek.hasActiveTSM` tests heat >= 9. @@ -177,7 +188,9 @@ basis. Breakpoints to confirm against `Mek` and the heat effect tables: movement penalty, to-hit penalty, shutdown roll, ammo explosion roll, TSM activation. -Read them out of MegaMek rather than transcribing from a rulebook. +Read them out of MegaMek rather than transcribing from a rulebook. Four of the +five are in; TSM is not, and wants `Mek.hasActiveTSM` on the wire before it can +be measured on anything but a guess about the chassis. ### Melee is a cliff, and it constrains what you may fire diff --git a/plan/harness.md b/plan/harness.md index 39412fe..0292114 100644 --- a/plan/harness.md +++ b/plan/harness.md @@ -154,10 +154,19 @@ which was the random bot proposing at random. `baseline.json` records the bot faithfully, so the evidence was there to read. That is not enough: a default nobody meant to use should not be quotable. -- [ ] Make the bot explicit. Any of: require `--bot`; print the bot, the seat - and the scenario at the top of every run; or refuse to write a baseline - whose bot is the random one without a flag saying so. Printing it is the - cheapest and would have caught this in the first line of output +It then happened a second time, to a two-game bench of the firing ladder. The +symptoms read as bugs in the change under test: every volley's weapon ids came +out in ascending order, which is exactly what a broken sort looks like, and +40% of firing decisions held fire. Both were the random bot - it declares the +shot list in the order the observation lists it, and it returns no attacks when +no shot is legal. Neither number was about the ladder or the weights, and a day +went into the two of them. + +- [x] Make the bot explicit. `--bot` now defaults to `/work/target/release/sds-bot` + and every bench prints which bot it is running in its first line. The floor + is still one flag away, and a default that is not built is refused before + any match starts rather than turning into a seat that defaults every + decision ## Selecting work by pattern takes other people's work diff --git a/plan/hierarchy.md b/plan/hierarchy.md index bcac68a..ee70c48 100644 --- a/plan/hierarchy.md +++ b/plan/hierarchy.md @@ -30,12 +30,59 @@ Built already: two-level planning, the barrier, stance/grit, node transports - [ ] Two-pass planning: cheap appraisal first so the force can pick a stance, expensive LOS-bearing refinement second and only where the stance says it matters. Unblocks the commander and cuts total work -- [ ] Move order is the harness's, not the bot's. It should be the bot's +- [x] Move order is the harness's, not the bot's. It should be the bot's. The + observation now carries `eligible` - MegaMek's own + `getFirstEntityNum(GameTurn)` enumeration widened to all of them - the + reply names the unit, and the host refuses a unit that cannot act and + counts a `substituted`. Both phases. The rule is the simplest one that + reads the joint plan: **play the most valuable standing order first**, + ties on the lower unit id. See "Turn order" below - [ ] Deployment is a placeholder (nearest legal hex to the middle); it deserves proposals and a lance-chosen shape - [ ] Determinism: fixed-order reduction, budgets in work units not wall-clock, per-unit seeded RNG streams, no clock reads in decision logic +## Turn order + +MegaMek's turn structure lets a player pick which of its eligible units acts +next. Turn order is initiative-based and alternates between sides, so the choice +is "which of ours goes now" and never a free reordering of the round - but it is +a real choice, and the host used to make it by breaking on the first valid unit +and handing the bot the result as a fact. + +It matters because the architecture already assumed the force owned it. +`reconcile` produces a joint plan for the whole force and then executed +whichever row MegaMek happened to ask for. And declaration order is +load-bearing: `JointAssignment` measures concentration against `declared`, so +whoever fires first sets the target the rest concentrate on. + +**The rule: play the most valuable standing order first, ties on the lower unit +id.** It reads what `reconcile` already produced - one order per unit with a +value attached - rather than ranking the units a second time with a second +opinion. The argument is that an order's value was measured against the enemy +envelope as it stands now, and that envelope collapses every time an enemy +finishes its move; the order we most want is the one to secure while the world +it was priced in still holds. First heuristic, not a claim to be right: the win +is that the choice is ours, and a better rule replaces `Bot::rank` and nothing +else. + +Firing needed the observation to change as well as the wire. It used to price +shots for the one shooter the host had picked, so measuring a second unit's menu +out of the first one's shots read equipment number 7 as an autocannon on one Mek +and a ton of ammo on the next. MegaMek cast the ammo to a weapon inside the +packet pump, threw on a thread with no handler, and the match stalled at round 1 +with the server unable to end the phase. `shots` is now priced per eligible +shooter and each shot names its `shooter`; `Entity.getWeapon` rather than +`getEquipment` is the host-side guard that would have caught it as `illegal`. + +Measured on 4v4-jihad-s102040, one match, sds North against Princess: mean +eligible set 2.06 over 36 movement decisions and 2.19 over 32 firing decisions, +`chose` on every one of them, `differed` from the host's own first-eligible 11 +times in movement and 13 in firing, `substituted` zero. `bots/ineligible_bot.py` +names a unit no game contains, and is how the refusal path is exercised against +a real match: 74 movement and firing decisions answered, 74 substituted, none +defaulted. + ## Reconciliation A force used to loop each unit's menu and take an argmax down it. Nothing diff --git a/plan/observability.md b/plan/observability.md index 7b592a3..f67d3b6 100644 --- a/plan/observability.md +++ b/plan/observability.md @@ -40,3 +40,9 @@ run directory. `sds view` renders one as a self-contained HTML page. - [x] Highlight latch transitions - "round 7, honour broken against Jade Falcon" is the story beat - [x] Per-phase `answered`/`defaulted`/`illegal` already exist; surface them here +- [x] Decompose one decision: `sds explain` prints every candidate on a menu + with its score broken into terms - feature, raw value, weight, product - + including the default the bot could have taken instead. The decision log + already carried each candidate's `phi` and `value`; what it lacked was the + weight vector, which `sds-bot` now writes beside the log as + `-.weights.json` diff --git a/plan/protocol.md b/plan/protocol.md index 718e5c2..27bf2e6 100644 --- a/plan/protocol.md +++ b/plan/protocol.md @@ -25,8 +25,8 @@ Adding a field is deliberate, with a reason in the commit message. a critical slot listing - [x] **Ammo type per weapon** - per-missile damage varies by ammo; [ev](ev.md) cannot be right without it. On the wire as a weapon's `ammo`. - `damagePerPacket` is still computed from the launcher alone; making it - read the munition belongs to [ev](ev.md) + `damagePerPacket` reads the munition wherever the munition + redefines the weapon - [x] **Mount location per weapon** - which weapons bear from a candidate position depends on where they are bolted on, and the wire carried ranges and damage and nothing else. On the wire as a weapon's `location`, in @@ -37,6 +37,29 @@ Adding a field is deliberate, with a reason in the commit message. location and one the bot does not model are read differently on purpose - the first is a centre torso, the second declines the twist, because the location known to be missing is a tripod's centre leg +- [x] **Minimum range per weapon** - every PPC variant but the Snub-Nose has + one, an LRM has six hexes of it, an ATM four, a Gauss Rifle two, and the + wire carried none of it. `Compute.getRangeMods` adds + `minimum - distance + 1` at or inside the minimum, so a weapon at exactly + its minimum is already at +1. On the wire as a weapon's `minimum`, with + MegaMek's `Integer.MIN_VALUE` sentinel clamped to zero by the bridge +- [x] **Ammo alternatives per launcher** - an MML fires LRM or SRM ammo out of + one tube and an ATM has three loads, each with its own brackets, its own + minimum range and its own damage. `WeaponType.getRanges(weapon, ammo)` is + the only accessor that knows: the plain `getShortRange()` family returns + **zero for every bracket** of an MML, so an MML on the wire was a weapon + that reached no hex at all. On the wire as a weapon's `modes`, emitted + only when there are at least two. They are alternatives and never + additions - one round is in the tube +- [ ] **A `modelled` flag per weapon** - artillery, TAG, AMS, pods, mortars, a + VGL, a taser, a TSEMP, an extinguisher, a bomb and Narc all fire under + rules this bot does not implement, and treating one as an ordinary gun + puts damage on the board that cannot happen. `sds-core/src/brackets.rs` + classifies them today by matching `WeaponType.getName()`, which is a name + list and will drift. The jar knows properly through + `WeaponType.getFlags()`. The same call answers a pulse weapon's -2 to hit, + which is an accuracy term the range brackets deliberately refuse to carry + because nothing on the wire declares it - [ ] **Per-unit levels in the round tick** - on the match result as `entities`, `{seat: {entity id: {bv, armor, internal, potential, force}}}`, beside the side totals it is the per-unit form of. A destroyed unit drops diff --git a/sds/board.py b/sds/board.py new file mode 100644 index 0000000..54b6461 --- /dev/null +++ b/sds/board.py @@ -0,0 +1,319 @@ +"""The ground a match was played on, read from MegaMek's own board files. + +A proposal carries the hex it ends in and nothing about that hex. Drawing an +evaluation over the map therefore needs the terrain from somewhere, and the +only correct somewhere is the board the scenario named. Nothing is vendored: +this reads `$MM_HOME/data/boards` at run time, the same way `sds.match` mounts +the install rather than copying it. + +Two things are resolved here: + +- **which board** - a scenario `.mms` names it in `Maps=`, and the run's own + files name the scenario. A live match has no result document yet, so the + host's stdout log and the match tag are both accepted as the name. +- **what is in it** - elevation and terrain per hex, in the coordinate space + the decision log already speaks: `end: {x, y}` is a zero-based MegaMek + `Coords`, and board files write the same hex one-based as `CCRR`. + +A board that cannot be found is not an error. The map still draws - flat, with +no terrain - and says so, because a viewer that refuses to open because an +install moved is a viewer nobody opens mid-match. +""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass, field +from pathlib import Path + +DEFAULT_MM_HOME = Path(os.environ.get("MM_HOME", Path.home() / ".cache" / "mul-build" / "megamek")) + +# The terrains worth drawing, and nothing else. A board carries a few dozen +# keys, most of which are bookkeeping (`foliage_elev`, `exits`, theme hints) +# rather than anything a person reading a heatmap needs to see. Each of these +# gets its own glyph on the page; the shapes are chosen to be distinguishable +# from each other rather than pretty, which is the note the first heatmap got. +DRAWN_TERRAIN = ( + "woods", + "jungle", + "water", + "rough", + "rubble", + "building", + "bridge", + "road", + "swamp", + "magma", + "fire", + "smoke", + "ice", + "pavement", +) + + +class BoardError(RuntimeError): + pass + + +@dataclass +class Hex: + x: int + y: int + elevation: int + terrain: dict[str, int] = field(default_factory=dict) + theme: str = "" + + +@dataclass +class Board: + width: int + height: int + hexes: dict[tuple[int, int], Hex] = field(default_factory=dict) + name: str = "" + source: str = "" + + def as_json(self) -> dict: + """The shape the page draws from: one flat row per hex, no nesting. + + Sent once per session and never again, so it is worth keeping small: + a 16x17 board is 272 hexes and a 4-key dict each. + """ + return { + "name": self.name, + "source": self.source, + "width": self.width, + "height": self.height, + "hexes": [ + { + "x": hex_.x, + "y": hex_.y, + "e": hex_.elevation, + "t": {k: v for k, v in hex_.terrain.items() if k in DRAWN_TERRAIN}, + } + for hex_ in sorted(self.hexes.values(), key=lambda h: (h.y, h.x)) + ], + } + + +def blank(width: int = 16, height: int = 17, note: str = "") -> Board: + """A board with no terrain, for when the real one cannot be found.""" + board = Board(width=width, height=height, name="(no board found)", source=note) + for y in range(height): + for x in range(width): + board.hexes[(x, y)] = Hex(x=x, y=y, elevation=0) + return board + + +# ---- parsing ----------------------------------------------------------------- + +_HEX_LINE = re.compile( + r'^hex\s+(\d+)\s+(-?\d+)\s+"([^"]*)"(?:\s+"([^"]*)")?', + re.IGNORECASE, +) +_SIZE_LINE = re.compile(r"^size\s+(\d+)\s+(\d+)", re.IGNORECASE) + + +def _split_coords(token: str) -> tuple[int, int]: + """`0704` is column 7, row 4, one-based. Returns it zero-based. + + Split down the middle, which is what MegaMek's own loader does: a board + wider than 99 hexes writes three digits a side, and a fixed `[:2]` would + read every hex on it into the wrong column with nothing failing. + """ + half = len(token) // 2 + if half == 0: + raise BoardError(f"{token!r} is not a hex coordinate") + return int(token[:half]) - 1, int(token[half:]) - 1 + + +def _parse_terrain(field_: str) -> dict[str, int]: + """`woods:1;foliage_elev:2` as names to levels. + + A terrain may carry a third colon-separated part (a building's exits, for + one). It is dropped: nothing here draws it, and keeping it would make the + value type depend on which terrain it came from. + """ + terrain: dict[str, int] = {} + for part in field_.split(";"): + part = part.strip() + if not part: + continue + bits = part.split(":") + name = bits[0].strip().lower() + level = 1 + if len(bits) > 1: + try: + level = int(bits[1]) + except ValueError: + level = 1 + terrain[name] = level + return terrain + + +def parse_board(path: Path) -> Board: + """One `.board` file. Comments and everything this does not draw ignored.""" + board = Board(width=0, height=0, name=path.stem, source=str(path)) + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + size = _SIZE_LINE.match(line) + if size: + board.width, board.height = int(size.group(1)), int(size.group(2)) + continue + found = _HEX_LINE.match(line) + if not found: + continue + try: + x, y = _split_coords(found.group(1)) + except (BoardError, ValueError): + continue + board.hexes[(x, y)] = Hex( + x=x, + y=y, + elevation=int(found.group(2)), + terrain=_parse_terrain(found.group(3) or ""), + theme=found.group(4) or "", + ) + if not board.hexes: + raise BoardError(f"{path} has no hex lines") + if not board.width or not board.height: + board.width = max(h.x for h in board.hexes.values()) + 1 + board.height = max(h.y for h in board.hexes.values()) + 1 + return board + + +# ---- finding it -------------------------------------------------------------- + +_MAPS_KEY = re.compile(r"^\s*maps\s*=\s*(.+?)\s*$", re.IGNORECASE) +_MAP_KEY = re.compile(r"^\s*map\d*\s*=\s*(.+?)\s*$", re.IGNORECASE) + + +def board_names(scenario: Path) -> list[str]: + """The board names a `.mms` asks for, in the order it lists them. + + Only the V1 `key=value` form, which is what `sds.scenario` writes. A V2 + (YAML) scenario returns nothing rather than a wrong guess. + """ + names: list[str] = [] + for line in scenario.read_text(encoding="utf-8", errors="replace").splitlines(): + if line.lstrip().startswith("#"): + continue + found = _MAPS_KEY.match(line) or _MAP_KEY.match(line) + if not found: + continue + for name in found.group(1).split(","): + name = name.strip() + if name and name.upper() not in ("SURPRISE", "RANDOM"): + names.append(name) + return names + + +def find_board_file(name: str, mm_home: Path = DEFAULT_MM_HOME) -> Path | None: + """The `.board` a scenario's `Maps=` entry means. + + Tried as written first, since that is a path relative to `data/boards` and + is what a generated scenario produces. Falling back to a recursive search + on the basename covers a board that moved between MegaMek releases, which + is common enough that failing on it would be failing on the usual case. + """ + boards = mm_home / "data" / "boards" + if not boards.is_dir(): + return None + direct = boards / f"{name}.board" + if direct.is_file(): + return direct + base = Path(name).name + for candidate in boards.rglob(f"{base}.board"): + return candidate + return None + + +_SCENARIO_LINE = re.compile(r"^\s*scenario\s*:\s*(\S+)\s*$") + + +def scenario_for(tag: str, directory: Path, repo: Path) -> Path | None: + """Which `.mms` a match tag was played from. + + Three sources, in the order they become available. The result document is + authoritative but only exists once the match is over, so the host's stdout + log - which prints the scenario in its opening banner, before round 0 - is + what answers this while a game is still being played. The tag itself is the + last resort: `sds.match` builds it as `-seed-`, so + the stem is recoverable from the filename alone. + """ + import json + + result = directory / f"{tag}.json" + if result.is_file(): + try: + named = json.loads(result.read_text()).get("scenario") + except (json.JSONDecodeError, OSError): + named = None + if named: + resolved = _unmount(str(named), repo) + if resolved is not None: + return resolved + + host_log = directory / f"{tag}.log" + if host_log.is_file(): + try: + # The banner is in the first few dozen lines. Reading the head + # rather than the file keeps this cheap on a match whose stdout has + # been growing for twenty minutes. + with host_log.open("r", encoding="utf-8", errors="replace") as handle: + for _ in range(200): + line = handle.readline() + if not line: + break + found = _SCENARIO_LINE.match(line) + if found: + resolved = _unmount(found.group(1), repo) + if resolved is not None: + return resolved + except OSError: + pass + + stem = re.sub(r"-seed\d+-[0-9a-f]+$", "", tag) + if stem != tag: + for candidate in (repo / "scenarios").rglob(f"{stem}.mms"): + return candidate + return None + + +def _unmount(named: str, repo: Path) -> Path | None: + """A path as the container saw it, as this side of the mount sees it. + + Everything the host prints is under `/work`, which is the repository. A + path that is already local is returned as-is so a scenario played outside + the container still resolves. + """ + path = Path(named) + if named.startswith("/work/"): + path = repo / named[len("/work/") :] + return path if path.is_file() else None + + +def load_for(tag: str, directory: Path, repo: Path, mm_home: Path = DEFAULT_MM_HOME) -> Board: + """The board a match was played on, or a blank one that says why not.""" + scenario = scenario_for(tag, directory, repo) + if scenario is None: + return blank(note="no scenario found for this match") + names = board_names(scenario) + if not names: + return blank(note=f"{scenario.name} names no board") + path = find_board_file(names[0], mm_home) + if path is None: + return blank(note=f"no board file for {names[0]!r} under {mm_home}") + try: + board = parse_board(path) + except (BoardError, OSError) as error: + return blank(note=str(error)) + if len(names) > 1: + # A multi-board scenario is stitched by MegaMek into one coordinate + # space, which this does not reproduce. Drawing the first board is + # right for its own corner and wrong everywhere else, so it says so + # rather than quietly misplacing every hex past the seam. + board.source += f" (first of {len(names)} boards; the rest are not drawn)" + return board diff --git a/sds/cli.py b/sds/cli.py index 9a61c27..77fc33a 100644 --- a/sds/cli.py +++ b/sds/cli.py @@ -1,11 +1,13 @@ """sds - run bot matches and say whether the difference is real. sds one one match, verbose, for looking at +sds play one match with you in a seat, at MegaMek's own client sds bench many matches, aggregated, for deciding with sds control the harness's self-test: Princess against itself sds validate every scenario in a suite loads and can deploy sds catalogue what the bot reasons with: features, intents, labels, goals sds view what the bot believed and chose, as HTML +sds explain one decision's candidates, feature by feature sds train fit M from a run's decision logs sds corpus store a run's decision logs where a worktree removal cannot reach """ @@ -25,6 +27,7 @@ from datetime import UTC, datetime from pathlib import Path from .baseline import Baseline, Incomparable, compare, from_run +from .explain import ExplainError from .match import REPO, MatchError, MatchSpec, Seat, kill_all_matches, kill_stragglers, run from .stats import Summary, games_needed @@ -42,6 +45,46 @@ from .viewer import write as write_view DEFAULT_SCENARIO = REPO / "scenarios" / "mirror-lance.mms" +# What `sds play` opens with when nobody says otherwise. A 2v2 rather than the +# 1v1: two units a side is enough for the bot's force layer to have something to +# do, and few enough that a person's turn is not a chore. +DEFAULT_PLAY_SCENARIO = REPO / "scenarios" / "suite" / "2v2-jihad-s102020.mms" + +# The bot a seat marked "sds" runs unless told otherwise. +# +# It used to be `bots/random_bot.py`, and that default cost a day. The floor bot +# is a deliberate baseline and it answers the protocol perfectly, so a bench run +# that forgot `--bot` produced a full run directory, a "sds" column and a +# baseline record - measuring the floor while every reader believed it was +# measuring the change under test. The floor is still one command away +# (`--bot "python3 /work/bots/random_bot.py"`); it is no longer what you get by +# saying nothing. +# +# `/work` is the repository, mounted into the match container by `sds.match`. +DEFAULT_BOT = "/work/target/release/sds-bot" + +# Where DEFAULT_BOT lives on this side of the mount, for the preflight below. +BOT_BINARY = REPO / "target" / "release" / "sds-bot" + + +def _check_bot(command: str | None) -> None: + """Refuse to start a run whose bot is not built. + + Only for the default binary: an arbitrary `--bot` command is the operator's + business and may be a script, an interpreter, or a path inside the image. + Without this the match still runs - the host cannot start the process, + disables the seat, and every decision takes an inert default - and the run + reads as a bot that chose to do nothing, which is the same failure the + default itself used to produce. + """ + if not command or DEFAULT_BOT not in command.split(): + return + if not BOT_BINARY.exists(): + raise MatchError( + f"{BOT_BINARY} is not built. `cargo build --release -j 2 -p sds-bot` first, " + "or pass --bot with something else." + ) + def _factions(scenario: Path) -> list[str]: """The faction names in an .mms, in file order. @@ -106,6 +149,7 @@ def _bot_env(args: argparse.Namespace) -> dict[str, str]: def cmd_one(args: argparse.Namespace) -> int: + _check_bot(args.bot if args.sds_seat else None) scenario = Path(args.scenario).resolve() factions = _factions(scenario) seats = [] @@ -132,7 +176,45 @@ def cmd_one(args: argparse.Namespace) -> int: return 0 if result.get("outcome") == "victory" else 1 +def cmd_play(args: argparse.Namespace) -> int: + """One match with a person in a seat.""" + from .play import PlaySpec, check_display + from .play import play as play_match + + if args.check_display: + return check_display() + + _check_bot(args.bot) + scenario = Path(args.scenario or DEFAULT_PLAY_SCENARIO).resolve() + factions = _factions(scenario) + if len(factions) != 2: + raise MatchError(f"{scenario} has {len(factions)} factions; play wants exactly 2") + # Which side is which. Named by faction when asked for, and otherwise the + # first faction in the file is yours - which is also the order the .mms + # deployment edges are written in, so "the first one" is a thing you can + # look up rather than a coin flip. + human = args.side or factions[0] + if human not in factions: + raise MatchError(f"no faction named '{human}' in {scenario} (has: {factions})") + sds = next(name for name in factions if name != human) + + spec = PlaySpec( + scenario=scenario, + human=human, + sds=sds, + bot=args.bot, + seed=args.seed, + port=args.port, + timeout_ms=args.timeout_ms, + bv_destroyed_percent=args.bv_destroyed_percent, + max_rounds=args.play_max_rounds, + env=_bot_env(args), + ) + return play_match(spec, _run_dir(Path(args.out), "play")) + + def _bench(args: argparse.Namespace, label: str, sds_bot: str | None) -> int: + _check_bot(sds_bot) if getattr(args, "suite", None): scenarios, scenario_label = _suite(Path(args.suite).resolve()) print(f"suite: {len(scenarios)} scenarios, {scenario_label}", file=sys.stderr) @@ -227,7 +309,15 @@ def _bench(args: argparse.Namespace, label: str, sds_bot: str | None) -> int: results: list[dict] = [] failures = 0 - print(f"{args.games} matches, {args.jobs} at a time -> {out_dir}", file=sys.stderr) + # Which bot, on every run, in the run's own output. The baseline record has + # carried this field all along and nothing printed it, so a run of the wrong + # bot looked exactly like a run of the right one until somebody opened a JSON + # file they had no reason to suspect. + print( + f"{args.games} matches, {args.jobs} at a time -> {out_dir}\n" + f" sds bot: {sds_bot or 'princess (no --bot seat)'}", + file=sys.stderr, + ) with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as pool: futures = {pool.submit(_play, spec, out_dir, roles): spec for spec, roles in plans} for done in concurrent.futures.as_completed(futures): @@ -268,6 +358,22 @@ def _bench(args: argparse.Namespace, label: str, sds_bot: str | None) -> int: # which are what lets anything read this directory later - `sds train`, a # person - compare it without guessing what it was measured under. (out_dir / "baseline.json").write_text(json.dumps(asdict(record), indent=2) + "\n") + + # What each side actually fired. The suites are mirrored, so the two columns + # should match: a gap is a bug, not tactics. It catches at once what a win + # rate cannot separate - a weapon one side never fires, an ammo choice only + # one side makes, a matchup that is not really symmetric, and a bot that + # simply shoots less often than the other. + try: + from . import weaponstats + + reports = sorted(out_dir.rglob("*.report.html")) + if reports: + print("\nweapons, both sides") + weaponstats.compare(reports) + except Exception as error: # a report is a diagnostic, never a reason to fail a run + print(f"\nweapon summary unavailable: {error}") + if failures: print(f"\n{failures} matches failed and are not in the numbers above") # The planning number, always: it is the difference between "we measured @@ -454,6 +560,48 @@ def cmd_corpus_list(args: argparse.Namespace) -> int: return 0 +def cmd_explain(args: argparse.Namespace) -> int: + """Print a decision's menu with every candidate's score broken into terms. + + The counterpart to `sds view`: that renders a match, this reads one + decision closely. Both come out of the same log, and neither recomputes + anything the bot did not record. + """ + from . import explain as ex + + try: + logs = ex.decision_logs(Path(args.target)) + override = Path(args.weights) if args.weights else None + shown = 0 + for log in logs: + weights = ex.weights_for(log, override) + rows = ex.read(log, args.phase) + if args.seq is not None: + rows = [r for r in rows if r["seq"] == args.seq] + if args.round is not None: + rows = [r for r in rows if r.get("round") == args.round] + if args.unit is not None: + rows = [r for r in rows if r.get("unit") == args.unit] + # Menus of one are the default with nothing to choose between. + # Kept in the tally, which is where they belong, and left out of + # the tables, where they are a page of zeroes. + print(f"== {log.name}") + print(f" {ex.tally(rows)}") + for row in rows: + if len(row["candidates"]) < 2 and not args.all: + continue + if shown >= args.limit: + print(f" ... stopping at --limit {args.limit}") + break + print() + print(ex.render(row, weights, full=args.full)) + shown += 1 + except ExplainError as error: + print(f"error: {error}", file=sys.stderr) + return 2 + return 0 + + def cmd_view(args: argparse.Namespace) -> int: """Render a match's decision log to a self-contained HTML file. @@ -626,19 +774,68 @@ def main(argv: list[str] | None = None) -> int: sub = parser.add_subparsers(dest="command", required=True) one = sub.add_parser("one", help="play a single match and print the result") - one.add_argument("--bot", default="python3 /work/bots/random_bot.py") + one.add_argument("--bot", default=DEFAULT_BOT) one.add_argument( "--sds-seat", default=None, help="faction the bot plays; omit for Princess on both sides" ) one.add_argument( "--candidates", action="store_true", - help="propose from the reachable-state sweep as well as the four verbs (SDS_CANDIDATES)", + help="propose from the reachable-state sweep instead of the four verbs (SDS_CANDIDATES)", ) one.set_defaults(func=cmd_one) + play = sub.add_parser( + "play", + help="one match with you in a seat, at MegaMek's own client", + description="Starts the server, seats the bot, and opens MegaMek's Swing " + "client on your display for the other side. One container, everything " + "over its own loopback. The result, the round report and the bot's " + "decision log land in the same run directory `sds one` uses, so `sds " + "explain` and `sds view` read a game you played exactly as they read a " + "benchmarked one.", + ) + play.add_argument( + "--scenario", + default=None, + help=f"the .mms to play; {DEFAULT_PLAY_SCENARIO.name} by default", + ) + play.add_argument( + "--side", + default=None, + metavar="FACTION", + help="the faction you play; the first in the .mms by default", + ) + play.add_argument("--bot", default=DEFAULT_BOT, help="the bot on the other side") + play.add_argument( + "--port", type=int, default=8850, help="server port inside the container; never published" + ) + play.add_argument( + "--play-max-rounds", + type=int, + default=0, + metavar="N", + help="stop after N rounds; 0 (the default) plays until somebody wins", + ) + play.add_argument( + "--candidates", + action="store_true", + help="propose from the reachable-state sweep instead of the four verbs (SDS_CANDIDATES)", + ) + play.add_argument( + "--check-display", + action="store_true", + help="only ask whether the container can reach your X server, and exit", + ) + play.set_defaults(func=cmd_play) + bench = sub.add_parser("bench", help="play many matches and aggregate") - bench.add_argument("--bot", default="python3 /work/bots/random_bot.py") + bench.add_argument( + "--candidates", + action="store_true", + help="propose from the reachable-state sweep instead of the four verbs (SDS_CANDIDATES)", + ) + bench.add_argument("--bot", default=DEFAULT_BOT) bench.add_argument("--games", type=int, default=20) # Two by default. A match is two thinking bots and a server in one container, # and this machine is shared with other agents; the sibling repos have all @@ -900,6 +1097,43 @@ def main(argv: list[str] | None = None) -> int: corpus_list = corpus_sub.add_parser("list", help="one line per stored corpus") corpus_list.set_defaults(func=cmd_corpus_list) + explain = sub.add_parser( + "explain", + help="one decision's candidates, feature by feature", + description="Reads a decision log and the weights sidecar the bot wrote " + "beside it, and prints each candidate's score broken into its terms: " + "feature, raw measured value, weight and product. The whole menu is " + "shown, including the default the bot could have taken instead.", + ) + explain.add_argument( + "target", + help="a run directory or a single .decisions.jsonl", + ) + explain.add_argument( + "--phase", + default="FIRING", + help="phase to explain; FIRING by default, MOVEMENT for the other one, " + "or an empty string for both", + ) + explain.add_argument("--seq", type=int, help="one decision, by its sequence number") + explain.add_argument("--round", type=int, help="only decisions in this round") + explain.add_argument("--unit", type=int, help="only decisions for this unit") + explain.add_argument( + "--limit", type=int, default=5, help="how many decisions to print (default 5)" + ) + explain.add_argument( + "--all", + action="store_true", + help="include decisions whose menu was only the default", + ) + explain.add_argument("--full", action="store_true", help="do not abbreviate candidate labels") + explain.add_argument( + "--weights", + metavar="PATH", + help="the weights this run was played with; the sidecar beside the log by default", + ) + explain.set_defaults(func=cmd_explain) + view = sub.add_parser( "view", help="render a match's decision log as HTML", diff --git a/sds/explain.py b/sds/explain.py new file mode 100644 index 0000000..4d86ef1 --- /dev/null +++ b/sds/explain.py @@ -0,0 +1,202 @@ +"""Why one decision came out the way it did, feature by feature. + +The decision log already holds everything this needs. Every `MOVEMENT` and +`FIRING` line carries the whole menu the bot ranked - each candidate's label, +its `phi` (the normalised value of every feature it measured) and the `value` +the argmax actually used. What it does not carry is the weight vector, so +`value` is a number you can compare but not decompose, and "which feature made +the difference between the alpha strike and one gun" was a question nobody +could answer from the file. + +`sds-bot` now writes its weights beside the log as `-.weights.json`. +With those, every candidate's score is exactly `sum(phi[f] * weights[f])`, and +this prints the sum with its terms shown. + +Nothing is recomputed and nothing is modelled. If the terms here do not add up +to the `value` the bot recorded, the log and the weights do not belong +together, and that is said out loud rather than papered over. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +# How far a rebuilt total may sit from the value the bot recorded before this +# stops being a decomposition of that decision. Both sides are f32 sums of a +# dozen terms; anything past this is a different weight vector, not rounding. +TOLERANCE = 1e-3 + + +class ExplainError(RuntimeError): + """The log and the weights cannot be read together.""" + + +@dataclass(frozen=True) +class Term: + """One feature's contribution to one candidate's score.""" + + name: str + raw: float + weight: float + + @property + def product(self) -> float: + return self.raw * self.weight + + +def decision_logs(target: Path) -> list[Path]: + """Every decision log under `target`, or `target` itself if it is one.""" + if target.is_file(): + return [target] + logs = sorted(target.glob("*.decisions.jsonl")) + if not logs: + logs = sorted(target.glob("*/*.decisions.jsonl")) + if not logs: + raise ExplainError(f"no *.decisions.jsonl under {target}") + return logs + + +def weights_for(log: Path, override: Path | None = None) -> dict[str, float]: + """The weights that log was played with. + + The sidecar the bot wrote, unless told otherwise. Refused rather than + guessed: the hand-authored baseline and a fitted vector produce logs that + look identical, and decomposing one with the other reports the wrong + culprit with no sign that anything is wrong. + """ + path = override + if path is None: + sidecar = log.with_name(log.name.replace(".decisions.jsonl", ".weights.json")) + if not sidecar.exists(): + raise ExplainError( + f"no weights beside {log.name}. This run predates the sidecar; " + "pass --weights with the file the run was played with " + "(`sds-bot --print-weights` writes the baseline)." + ) + path = sidecar + document = json.loads(path.read_text()) + weights = document.get("weights", document) + if not isinstance(weights, dict): + raise ExplainError(f"{path} is not a weights document") + return {name: float(value) for name, value in weights.items()} + + +def read(log: Path, phase: str | None = None) -> list[dict]: + """The decisions in one log that carry a menu. + + A line without `candidates` is a phase the bot does not score - deployment, + or a decision the host defaulted before the bot answered. Those are not + errors and are simply not explainable. + """ + rows = [] + for line in log.read_text().splitlines(): + if not line.strip(): + continue + row = json.loads(line) + if not row.get("candidates"): + continue + if phase and row.get("phase") != phase: + continue + rows.append(row) + return rows + + +def terms(candidate: dict, weights: dict[str, float]) -> list[Term]: + """One candidate's score, broken into its terms, biggest first. + + Ordered by absolute size because the question this answers is "what made + the difference", and a feature worth a hundredth of a point is noise + whichever way it points. Ties on the name, so two runs print the same file. + """ + rows = [ + Term(name, float(raw), weights.get(name, 0.0)) for name, raw in candidate["phi"].items() + ] + return sorted(rows, key=lambda t: (-abs(t.product), t.name)) + + +def rebuilt(candidate: dict, weights: dict[str, float]) -> float: + return sum(term.product for term in terms(candidate, weights)) + + +def mismatch(row: dict, weights: dict[str, float]) -> float: + """The worst gap between a rebuilt score and the recorded one.""" + return max(abs(rebuilt(c, weights) - float(c["value"])) for c in row["candidates"]) + + +def render(row: dict, weights: dict[str, float], full: bool = False) -> str: + """One decision as a table: features down, candidates across. + + A column per candidate rather than a block per candidate. The interesting + question is almost never "what did this candidate score" - it is "what + changes as the menu walks from one gun to the alpha strike", and a shape + that puts those side by side answers it by eye. + """ + candidates = row["candidates"] + chosen = row.get("chosen", 0) + names = sorted({name for c in candidates for name in c["phi"]}) + labels = [c["label"] for c in candidates] + width = max(9, max(len(label) for label in labels) + 1) if full else 10 + + head = [ + f"seq {row['seq']} round {row.get('round')} {row.get('phase')} " + f"unit {row.get('unit', row.get('actedOn'))} seat {row.get('seat')!r}", + f" took [{chosen}] {labels[chosen]!r} = {candidates[chosen]['value']:+.3f}" + f" ({len(candidates)} candidates)", + ] + gap = mismatch(row, weights) + if gap > TOLERANCE: + head.append( + f" WARNING: rebuilt scores differ from the recorded ones by up to {gap:.4f}; " + "these weights are not the ones this run was played with" + ) + + def cell(value: float) -> str: + return f"{value:>{width}.3f}" + + lines = list(head) + header = f" {'feature':<24}{'weight':>7} " + "".join( + f"{(label[: width - 1] if not full else label):>{width}}" for label in labels + ) + lines.append(header) + lines.append(" " + "-" * (len(header) - 2)) + for name in names: + weight = weights.get(name, 0.0) + products = [float(c["phi"].get(name, 0.0)) * weight for c in candidates] + if all(abs(p) < 1e-9 for p in products): + continue + lines.append(f" {name:<24}{weight:>7.2f} " + "".join(cell(p) for p in products)) + lines.append(" " + "-" * (len(header) - 2)) + lines.append(f" {'TOTAL':<24}{'':>7} " + "".join(cell(float(c["value"])) for c in candidates)) + marks = "".join( + f"{('<= took' if i == chosen else ''):>{width}}" for i in range(len(candidates)) + ) + lines.append(f" {'':<24}{'':>7} " + marks) + return "\n".join(lines) + + +def tally(rows: list[dict]) -> str: + """One line of arithmetic over the decisions being explained. + + The counts that make a firing log readable at all. "Held fire" and "had + nothing to shoot at" are different facts and were being added together: + a menu of one candidate is a unit with no legal shot, and counting it as a + decision to hold fire is how a positioning problem gets read as a scoring + problem. + """ + total = len(rows) + offered = [r for r in rows if len(r["candidates"]) > 1] + held = [r for r in offered if r.get("chosen", 0) == 0] + rungs = [r.get("chosen", 0) for r in offered if r.get("chosen", 0) > 0] + mean_rung = sum(rungs) / len(rungs) if rungs else 0.0 + mean_top = ( + sum(len(r["candidates"]) - 1 for r in offered if r.get("chosen", 0) > 0) / len(rungs) + if rungs + else 0.0 + ) + return ( + f"{total} scored decisions; {total - len(offered)} had no candidate but the default; " + f"of the {len(offered)} with a real menu, {len(held)} took the default " + f"and the rest took rung {mean_rung:.2f} of {mean_top:.2f} on average" + ) diff --git a/sds/match.py b/sds/match.py index 0760e94..de89224 100644 --- a/sds/match.py +++ b/sds/match.py @@ -133,6 +133,12 @@ class MatchSpec: # it most mirror matches never resolve; see SdsHost for the measurement that # forced it. 0 disables the condition and restores "fight to the last unit". bv_destroyed_percent: int = 70 + # Whether one seat writes MegaMek's round report into the match's logs + # directory. On: it is the only record of why a shot hit or missed, and a + # long match costs a couple of hundred KB. Off for a run that wants the + # files small, or to take the formatting work off the client's packet + # thread. + keep_report: bool = True image: str = DEFAULT_IMAGE mm_home: Path = DEFAULT_MM_HOME env: dict[str, str] = field(default_factory=dict) @@ -170,6 +176,23 @@ def run(spec: MatchSpec, out_dir: Path) -> dict: container = f"sds-{tag}" result_path = out_dir / f"{tag}.json" log_path = out_dir / f"{tag}.log" + # MegaMek's own per-match files, in a directory of this match's own. + # + # MegaMek writes several things nobody here had to build: the round report + # (to-hit numbers, rolls, hit locations, damage, criticals), + # `entitystatus.txt` - every unit's armour diagram and component list at the + # end - a `game_actions_*.tsv` row per unit per phase per round, and a `.mul` + # per bot. All of it lands in `logs/` relative to the working directory, + # which is the mounted MegaMek install: every match wrote into the *same* + # directory and overwrote the last one's, so a batch of five left one real + # set and four ghosts. + # + # Mounting over `/mm/logs` fixes that at the filesystem rather than by + # guessing paths, which matters because MegaMek builds several of these + # names itself. The install's own `logs/` is untouched; the mount exists + # only inside this container. + mm_logs = out_dir / f"{tag}.logs" + mm_logs.mkdir(parents=True, exist_ok=True) classpath = "MegaMek.jar:lib/*:/work/bridge/build/sds.jar" command = [ @@ -184,6 +207,8 @@ def run(spec: MatchSpec, out_dir: Path) -> dict: f"{REPO}:/work", "-v", f"{spec.mm_home}:/mm", + "-v", + f"{mm_logs}:/mm/logs", "-w", "/mm", ] @@ -222,6 +247,7 @@ def run(spec: MatchSpec, out_dir: Path) -> dict: if spec.behavior: command += ["--behavior", spec.behavior] command += ["--bv-destroyed-percent", str(spec.bv_destroyed_percent)] + command += ["--report", "true" if spec.keep_report else "false"] for seat in spec.seats: command += ["--seat", f"{seat.name}={seat.kind}"] if seat.kind == "sds" and seat.bot: @@ -249,6 +275,16 @@ def run(spec: MatchSpec, out_dir: Path) -> dict: result = json.loads(result_path.read_text()) result["logPath"] = str(log_path) result["resultPath"] = str(result_path) + result["megamekLogDir"] = str(mm_logs) + # The round report sits beside the result, not inside MegaMek's scratch + # directory. It is the file a person opens to read the match - rolls, hit + # locations, criticals - so it goes where the `.json` and `.log` already are + # rather than one level down among the TSV and the `.mul`s. + report = mm_logs / f"{tag}.report.html" + if report.is_file(): + beside = out_dir / f"{tag}.report.html" + report.replace(beside) + result["reportPath"] = str(beside) result["exitCode"] = completed.returncode return result diff --git a/sds/play.py b/sds/play.py new file mode 100644 index 0000000..61a5f93 --- /dev/null +++ b/sds/play.py @@ -0,0 +1,332 @@ +"""Sit a person down at MegaMek against the bot. + +One container, three processes: the `SdsHost` server with the bot seats +attached to it, the bot itself as a child of that JVM, and MegaMek's own Swing +client for the person. Everything talks over `127.0.0.1` inside the container, +so there is no port to publish and nothing for two of these to collide over. + +The display is the only thing that leaves the container. The X11 socket and the +X authority cookie are mounted in, `DISPLAY` is passed through, and the window +that opens is the same MegaMek anybody else plays. No `xhost`, no X access +control change: the cookie the session already has is enough, and rewriting a +person's X permissions to save them a mount is not this harness's business. + +The batch path is untouched. It still runs headless, still names its containers +`sds-*`, and still has every watchdog it had. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import uuid +from dataclasses import dataclass, field +from pathlib import Path + +from .match import ( + ADD_OPENS, + DEFAULT_IMAGE, + DEFAULT_MM_HOME, + REPO, + MatchError, + _track, + _untrack, +) + +# Where MegaMek's client keeps its settings between games. +# +# Not the install's own `mmconf/`. That directory belongs to an extracted +# release several worktrees and both other repos share, and a GUI writes to it +# on every exit - window sizes, the last player name, the game options panel. +# Seeded from the install the first time and then left alone, so a MegaMek bump +# does not silently revert a person's settings and a person's settings do not +# follow the install around. +PLAY_CONFIG = Path(os.environ.get("SDS_PLAY_CONFIG", Path.home() / ".cache" / "sds-play")) + +# Containers for `sds play` are deliberately NOT named `sds-*`. +# +# `sds clean` kills every `sds-*` container on the machine, whoever started it, +# and this machine has several agents on it. A benchmark being killed costs a +# rerun; a person's game being killed in the middle of a movement phase costs +# the game. This prefix keeps a human match out of that blast radius. +CONTAINER_PREFIX = "sdsplay-" + + +@dataclass +class PlaySpec: + scenario: Path + human: str + sds: str + bot: str + seed: int = 1 + port: int = 8850 + timeout_ms: int = 10_000 + bv_destroyed_percent: int = 70 + max_rounds: int = 0 + # How long the server stays up after the result is written, so MegaMek's + # end-of-game screen is readable rather than a disconnect dialog. + linger_seconds: int = 3600 + image: str = DEFAULT_IMAGE + mm_home: Path = DEFAULT_MM_HOME + env: dict[str, str] = field(default_factory=dict) + + +def display_environment() -> tuple[str, str]: + """The display and the authority file, or a refusal that says what is missing.""" + display = os.environ.get("DISPLAY") + if not display: + raise MatchError( + "DISPLAY is not set, so there is no X server to draw on. " + "`sds play` needs one; on Wayland that is XWayland, which a desktop " + "session normally already runs." + ) + if not Path("/tmp/.X11-unix").is_dir(): + raise MatchError("/tmp/.X11-unix does not exist; no local X server to connect to") + xauthority = os.environ.get("XAUTHORITY") or str(Path.home() / ".Xauthority") + if not Path(xauthority).is_file(): + raise MatchError( + f"no X authority file at {xauthority}. Set XAUTHORITY to the one your " + "session uses (under Wayland/mutter it is /run/user/$UID/.mutter-Xwaylandauth.*)." + ) + return display, xauthority + + +def _x11_arguments(display: str, xauthority: str) -> list[str]: + # The cookie is mounted read-only at a fixed path rather than bind-mounted + # over ~/.Xauthority, because the container's home directory is not the + # user's and the file name under Wayland is random per session. + return [ + "-e", + f"DISPLAY={display}", + "-e", + "XAUTHORITY=/tmp/.sds-xauth", + "-v", + "/tmp/.X11-unix:/tmp/.X11-unix", + "-v", + f"{xauthority}:/tmp/.sds-xauth:ro", + ] + + +def check_display(image: str = DEFAULT_IMAGE) -> int: + """Ask the image itself whether it can reach the X server. + + `xdpyinfo` and nothing else: it is the smallest question that has the same + answer as "will MegaMek open a window", and it answers in a second instead + of after a scenario load. + """ + display, xauthority = display_environment() + name = f"{CONTAINER_PREFIX}xdpyinfo-{uuid.uuid4().hex[:8]}" + command = ["docker", "run", "--rm", "--name", name, "--user", f"{os.getuid()}:{os.getgid()}"] + command += _x11_arguments(display, xauthority) + command += [image, "xdpyinfo"] + done = subprocess.run(command, capture_output=True, text=True, check=False) + if done.returncode != 0: + print(done.stdout.strip() or done.stderr.strip()) + print( + f"\nthe container cannot open {display}.\n" + "Nothing here will change your X access control for you. If this is a\n" + "display that needs one, the command is:\n" + f" xhost +si:localuser:{os.environ.get('USER', 'you')}\n" + "and it is yours to run, because it is your session's permissions." + ) + return done.returncode + for line in done.stdout.splitlines(): + if line.startswith(("name of display", "version number", "vendor string", "dimensions")): + print(line.strip()) + print(f"ok: {image} can draw on {display}") + return 0 + + +def _seed_config(mm_home: Path) -> Path: + """A settings directory of our own, seeded from the install's on first use.""" + mmconf = PLAY_CONFIG / "mmconf" + mmconf.mkdir(parents=True, exist_ok=True) + for source in sorted((mm_home / "mmconf").glob("*")): + target = mmconf / source.name + if source.is_file() and not target.exists(): + shutil.copy2(source, target) + return mmconf + + +def preflight(spec: PlaySpec) -> None: + if shutil.which("docker") is None: + raise MatchError("docker is not on PATH") + if not (spec.mm_home / "MegaMek.jar").is_file(): + raise MatchError(f"no MegaMek.jar under {spec.mm_home}; set MM_HOME") + if not (REPO / "bridge" / "build" / "sds.jar").is_file(): + raise MatchError("bridge/build/sds.jar is missing; run ./scripts/build.sh") + if not spec.scenario.is_file(): + raise MatchError(f"no scenario at {spec.scenario}") + if spec.human == spec.sds: + raise MatchError(f"'{spec.human}' cannot be both the human and the bot seat") + + +# The script the container runs: bring the server up, wait for it to say it is +# ready, then hand the terminal to MegaMek's client. The client is the process +# the container lives and dies with, so closing the window ends the match rather +# than leaving a server behind - and the server outliving the result is what the +# host's --linger-seconds is for, not this. +_ENTRYPOINT = r""" +set -u +java -Dlog4j2.configurationFile=/work/bridge/log4j2-quiet.xml -Dsentry.dsn= \ + -Djava.awt.headless=true {opens} -cp "{classpath}" sds.SdsHost {host_args} & +HOST=$! + +for _ in $(seq 1 2400); do + [ -e "{ready}" ] && break + kill -0 "$HOST" 2>/dev/null || break + sleep 0.25 +done +if [ ! -e "{ready}" ]; then + echo "[play] the host never came up; see the log above" >&2 + kill "$HOST" 2>/dev/null + wait "$HOST" 2>/dev/null + exit 1 +fi + +echo "[play] server is up; starting MegaMek for {player}" +java -Dlog4j2.configurationFile=/work/bridge/log4j2-quiet.xml -Dsentry.dsn= \ + {opens} -cp "{classpath}" megamek.MegaMek \ + -client -server 127.0.0.1 -port {port} -player_name "{player}" +status=$? + +kill "$HOST" 2>/dev/null +wait "$HOST" 2>/dev/null +exit $status +""" + + +def play(spec: PlaySpec, out_dir: Path) -> int: + """Play one match with a person in one of the seats. Returns an exit code.""" + preflight(spec) + display, xauthority = display_environment() + mmconf = _seed_config(spec.mm_home) + + out_dir.mkdir(parents=True, exist_ok=True) + tag = f"{spec.scenario.stem}-play-{uuid.uuid4().hex[:8]}" + container = f"{CONTAINER_PREFIX}{tag}" + result_path = out_dir / f"{tag}.json" + ready_path = out_dir / f"{tag}.ready" + # Same shape as the batch path: MegaMek's own scratch gets a directory per + # match, so the round report and entitystatus.txt are this game's and not + # whatever ran last. + mm_logs = out_dir / f"{tag}.logs" + mm_logs.mkdir(parents=True, exist_ok=True) + + classpath = "MegaMek.jar:lib/*:/work/bridge/build/sds.jar" + host_args = [ + "--scenario", + f"/work/{spec.scenario.relative_to(REPO)}", + "--out", + f"/work/{result_path.relative_to(REPO)}", + "--ready-file", + f"/work/{ready_path.relative_to(REPO)}", + "--seed", + str(spec.seed), + "--port", + str(spec.port), + "--timeout-ms", + str(spec.timeout_ms), + "--bv-destroyed-percent", + str(spec.bv_destroyed_percent), + # Every watchdog off. A person takes minutes over a movement phase, and + # all three of these were tuned for two bots that take milliseconds: + # left on, they end a human game as "stalled" or "timeout" and throw + # away the match that was the whole point of playing. + "--max-rounds", + str(spec.max_rounds), + "--stall-seconds", + "0", + "--wall-clock", + "0", + "--linger-seconds", + str(spec.linger_seconds), + "--report", + "true", + "--seat", + f"{spec.human}=human", + "--seat", + f"{spec.sds}=sds", + "--bot", + spec.bot, + ] + + script = _ENTRYPOINT.format( + opens=" ".join(ADD_OPENS), + classpath=classpath, + host_args=" ".join(f"'{argument}'" for argument in host_args), + ready=f"/work/{ready_path.relative_to(REPO)}", + player=f"human {spec.human}", + port=spec.port, + ) + + command = [ + "docker", + "run", + "--rm", + "--name", + container, + "--user", + f"{os.getuid()}:{os.getgid()}", + # java.util.prefs wants somewhere to write. Without this HOME is "/", + # and the client spends its first second failing to create /.java. + "-e", + "HOME=/tmp", + "-e", + f"SDS_SEED={spec.seed}", + "-v", + f"{REPO}:/work", + "-v", + f"{spec.mm_home}:/mm", + "-v", + f"{mm_logs}:/mm/logs", + "-v", + f"{mmconf}:/mm/mmconf", + "-w", + "/mm", + ] + command += _x11_arguments(display, xauthority) + for key, value in spec.env.items(): + command += ["-e", f"{key}={value}"] + command += [spec.image, "bash", "-c", script] + + print(f"bot : {spec.bot}") + print(f"scenario : {spec.scenario}") + print(f"you : human {spec.human}") + print(f"opponent : sds {spec.sds}") + print(f"display : {display}") + print(f"container: {container} (not sds-*, so `sds clean` leaves it alone)") + print(f"results : {out_dir}") + print("") + print("MegaMek takes a few seconds to load its unit cache before the window opens.") + print("The game opens in the deployment phase. Closing the window ends the match.") + print("") + + _track(container) + try: + completed = subprocess.run(command, check=False) + finally: + subprocess.run( + ["docker", "kill", container], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + _untrack(container) + ready_path.unlink(missing_ok=True) + + # The round report goes beside the result, exactly as `sds.match.run` puts + # it, so `sds view` and a person's own eyes find it in the same place. + report = mm_logs / f"{tag}.report.html" + if report.is_file(): + report.replace(out_dir / f"{tag}.report.html") + + if result_path.is_file(): + print(f"\nresult : {result_path}") + print(f"report : {out_dir / (tag + '.report.html')}") + print(f"explain : ./sds.sh explain {out_dir}") + print(f"view : ./sds.sh view {out_dir}") + else: + print(f"\nno result document; the match did not finish. See {mm_logs}") + return completed.returncode diff --git a/sds/stats.py b/sds/stats.py index 9048375..df67729 100644 --- a/sds/stats.py +++ b/sds/stats.py @@ -163,6 +163,15 @@ class SideSummary: defaulted: int = 0 illegal: int = 0 failed: int = 0 + # Turn order. `eligible` is a sum over decisions, so `eligible / decisions` + # is the mean size of the set the bot chose from - a mean of 1.0 means it + # never had a choice. `differed` is how often it picked something other than + # the host's own first-eligible, which is the only number that says the + # choice is being used rather than merely offered. + eligible: int = 0 + chose: int = 0 + differed: int = 0 + substituted: int = 0 class Summary: @@ -205,6 +214,10 @@ class Summary: side.defaulted += stats.get("defaulted", 0) side.illegal += stats.get("illegal", 0) side.failed += stats.get("failed", 0) + side.eligible += stats.get("eligible", 0) + side.chose += stats.get("chose", 0) + side.differed += stats.get("differed", 0) + side.substituted += stats.get("substituted", 0) winner = self._winner(result, self.key) if winner is not None: @@ -262,6 +275,25 @@ class Summary: f"{side.name:<14} {side.decisions:>10} {side.answered:>9} " f"{side.defaulted:>10} {side.illegal:>8} {side.failed:>7}" ) + turnorder = [s for s in bridged if s.eligible] + if turnorder: + lines.append("") + lines.append( + f"{'turn order':<14} {'decisions':>10} {'mean elig':>10} {'chose':>7} " + f"{'differed':>9} {'substituted':>12}" + ) + for side in turnorder: + mean = side.eligible / side.decisions if side.decisions else 0.0 + lines.append( + f"{side.name:<14} {side.decisions:>10} {mean:>10.2f} " + f"{side.chose:>7} {side.differed:>9} {side.substituted:>12}" + ) + for side in turnorder: + if side.substituted: + lines.append( + f" note: {side.name} named a unit that could not act " + f"{side.substituted} time(s); the host's own pick stood in" + ) # 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 diff --git a/sds/viewer.py b/sds/viewer.py index cdb9b5b..1648d14 100644 --- a/sds/viewer.py +++ b/sds/viewer.py @@ -259,9 +259,13 @@ def totals(counters: dict[str, dict[str, int]]) -> dict[str, int]: # ---- the model --------------------------------------------------------------- -def _unit_view(thought: dict, chosen: dict | None, weights: dict[str, float]) -> dict: +def unit_view(thought: dict, chosen: dict | None, weights: dict[str, float]) -> dict: """One unit's proposals, scored the way the force scored them. + Public because the live viewer scores the same proposals the same way. Two + renderers that each decide for themselves what regret means is exactly the + thing that makes two pages disagree about the same match. + `regret` is the whole reason this function exists. It is the value the force left on the table under its own stance: positive means a better proposal was offered and not taken, zero means the force took the best thing it was shown. @@ -278,6 +282,18 @@ def _unit_view(thought: dict, chosen: dict | None, weights: dict[str, float]) -> "label": str(proposal.get("label", "")), "value": proposal_value(proposal, weights), "chosen": index == chosen_index, + # Where the proposal puts the unit, and the raw feature vector + # behind its value. Neither is used by the HTML this module + # writes; both are what `sds watch` colours its map from, and + # they are carried here so there is one place that decides what + # a scored proposal is. + "end": proposal.get("end"), + "features": feature_values(proposal), + # A proposal with no steps leaves the unit where it stands, so + # its `end` is the unit's current hex. That is the only place + # the log says where a unit is, and the map needs it to draw + # the move as a move rather than as a destination. + "steps": len(((proposal.get("action") or {}).get("steps")) or []), "damage_dealt": float(proposal.get("damage_dealt", 0.0)), "damage_taken": float(proposal.get("damage_taken", 0.0)), "risk": float(proposal.get("risk", 0.0)), @@ -304,12 +320,12 @@ def _unit_view(thought: dict, chosen: dict | None, weights: dict[str, float]) -> } -def _force_view(decision: dict) -> dict: +def force_view(decision: dict) -> dict: weights = decision.get("weights") weights = weights if isinstance(weights, dict) else {} chosen_by_unit = {c.get("unit"): c for c in (decision.get("chosen") or [])} units = [ - _unit_view(thought, chosen_by_unit.get(thought.get("unit")), weights) + unit_view(thought, chosen_by_unit.get(thought.get("unit")), weights) for thought in (decision.get("considered") or []) ] return { @@ -358,7 +374,7 @@ def build_view(records: list[dict], result: dict | None = None, tag: str = "") - continue name = str(decision.get("force", "?")) if name not in by_round: - by_round[name] = _force_view(decision) + by_round[name] = force_view(decision) view_seats = [] for seat, entry in seats.items(): diff --git a/sds/weaponstats.py b/sds/weaponstats.py new file mode 100644 index 0000000..d115bdb --- /dev/null +++ b/sds/weaponstats.py @@ -0,0 +1,345 @@ +"""What a batch of matches actually did, read out of MegaMek's own round reports. + +Four views over the same files, because a win rate says a bot is losing and none +of these say the same thing: + +- **outcome** - final BV each side, and the differential. The metric that + survives a round cap: a match stopped at the cap while we were ahead scores as + undecided and vanishes from a win count. +- **weapons** - fired and hit per weapon, both sides. The suites are mirrored so + the columns should match; a gap is a bug rather than tactics. +- **heat** - what each side ran at, round by round. Heat is a resource being + spent or a cost being avoided and nothing else in the output shows which. +- **all** - the three together, which is what a batch summary prints. + +An aggregate win rate says a bot is losing. It does not say which gun it is +losing with. This walks the `.report.html` beside every match result and counts, +per weapon and per side, how often it was fired and how often it hit - so a +weapon the bot never fires, or fires and never lands, shows up as a row rather +than as a hunch. + +Streaks and cluster weapons report "N missile(s) hit" instead of "hits", and +that counts as a hit here: the question is whether the shot landed at all, not +how many packets arrived. + + python3 -m sds.weaponstats runs/ # everything + python3 -m sds.weaponstats runs/ --view heat # one view + python3 -m sds.weaponstats runs/A --era jihad +""" + +import argparse +import collections +import html +import json +import re +import sys +from pathlib import Path + +SHOOTER = re.compile(r"Weapons fire for (.+?) \( *(sds|princess) [^)]*\)") +# The unique part of a shot: the roll and its result. A weapon's name is read +# backwards from here. Matching it forwards goes wrong both ways - a consuming +# match starts mid-name after the previous one, counting `Snub-Nose PPC` as +# `Nose PPC`, and a zero-width one matches every suffix too, counting +# `Gauss Rifle` again as `Rifle`. +SHOT = re.compile( + r" at [^;]{1,80}; *needs (?P\d+) *, *rolls (?P\d+) *: *(?P[^.]{1,60})" +) +# What can sit immediately before a weapon's name: the close of the shooter's +# parenthesis, or the end of the previous shot's result. +LEAD = re.compile(r"[):.]|missile\(s\) hit|misses|hits") +NAME = re.compile(r"([A-Z][\w '/\-]*?)\s*$") + + +def weapon_before(text: str, at: int) -> str | None: + """The weapon name ending at `at`, bounded by the nearest delimiter.""" + window = text[max(0, at - 60) : at] + cut = 0 + for lead in LEAD.finditer(window): + cut = lead.end() + found = NAME.search(window[cut:]) + return found.group(1).strip() if found else None + + +def flatten(path: Path) -> str: + """One line per report entry, tags gone, runs of space collapsed.""" + raw = path.read_text(encoding="utf-8", errors="replace") + text = html.unescape(re.sub(r"<[^>]+>", "\n", raw)) + return re.sub(r"[ \t]+", " ", text) + + +def walk(path: Path): + """Every (side, weapon, needs, hit) in one report, in report order. + + The HTML puts each field on its own line, so the whole report is joined into + one string and scanned with a single pass. Line buffering was tried first + and got every cluster weapon wrong: a missile entry ends "4 missile(s) hit" + rather than "hits", and the buffer split it in the wrong place, so every + Streak and LRM read 0% and looked like a bot that never landed a missile. + """ + text = re.sub(r"\s+", " ", flatten(path)) + marks = [(m.start(), m.group(2)) for m in SHOOTER.finditer(text)] + + def side_at(pos: int): + return next((side for start, side in reversed(marks) if start < pos), None) + + for shot in SHOT.finditer(text): + side = side_at(shot.start()) + name = weapon_before(text, shot.start()) + if side is None or not name: + continue + result = shot.group("result").lower() + hit = ("hit" in result) and ("no hit" not in result) + yield side, name, int(shot.group("needs")), hit + + +def tally(reports): + """fired and hits per (side, weapon).""" + fired: collections.Counter = collections.Counter() + hits: collections.Counter = collections.Counter() + needs: dict = collections.defaultdict(list) + for report in reports: + for side, weapon, need, hit in walk(report): + fired[(side, weapon)] += 1 + hits[(side, weapon)] += hit + needs[(side, weapon)].append(need) + return fired, hits, needs + + +def compare(reports, out=sys.stdout) -> None: + """Both sides on every weapon, side by side. + + The suites are mirrored - same chassis, same guns, both sides - so the two + columns should match. They are a check on several things at once that no + win rate can separate: a weapon one side never fires, an ammo choice only + one side knows how to make, a matchup that is not actually symmetric, and a + bot that simply shoots less often than the other. + """ + fired, hits, needs = tally(reports) + weapons = sorted( + {weapon for _, weapon in fired}, key=lambda w: -(fired[("sds", w)] + fired[("princess", w)]) + ) + head = ( + f"{'weapon':26} {'sds fired':>10} {'hit%':>6} " + f"{'pri fired':>10} {'hit%':>6} {'fired diff':>11}" + ) + print(head, file=out) + print("-" * len(head), file=out) + for weapon in weapons: + sf, ph = fired[("sds", weapon)], fired[("princess", weapon)] + sh, pp = hits[("sds", weapon)], hits[("princess", weapon)] + srate = f"{100 * sh / sf:.1f}%" if sf else "-" + prate = f"{100 * pp / ph:.1f}%" if ph else "-" + # Mirrored scenarios: a large gap in *fired* is the interesting column. + gap = sf - ph + mark = " <<<" if ph and (sf == 0 or abs(gap) > max(6, 0.4 * max(sf, ph))) else "" + print( + f"{weapon[:26]:26} {sf:>10} {srate:>6} {ph:>10} {prate:>6} {gap:>+11}{mark}", file=out + ) + st, pt = ( + sum(v for (s, _), v in fired.items() if s == "sds"), + sum(v for (s, _), v in fired.items() if s == "princess"), + ) + sk, pk = ( + sum(v for (s, _), v in hits.items() if s == "sds"), + sum(v for (s, _), v in hits.items() if s == "princess"), + ) + print("-" * len(head), file=out) + print( + f"{'all':26} {st:>10} {100 * sk / max(st, 1):>5.1f}% {pt:>10} " + f"{100 * pk / max(pt, 1):>5.1f}% {st - pt:>+11}", + file=out, + ) + print(f"\nsds fired {100 * st / max(pt, 1):.0f}% as often as princess", file=out) + + +ROUND = re.compile(r"Initiative Phase for Round #(\d+)") +HEAT = re.compile( + r"(?P[^.]{1,40}?) \( *(?Psds|princess) [^)]*\) gains (?P\d+) heat, " + r"sinks (?P\d+) heat and is now at (?P\d+) heat" +) + + +def outcome(reports, out=sys.stdout) -> None: + """Final BV each side, per match, and the differential. + + Battle value rather than the win column because a capped match is scored + undecided however far ahead it was, and a short batch is mostly capped. + """ + rows = [] + for report in reports: + result = report.with_suffix("").with_suffix(".json") + if not result.is_file(): + continue + try: + match = json.loads(result.read_text()) + except (OSError, ValueError): + continue + if "players" not in match: + continue + us = next((p for p in match["players"] if p.get("kind") == "sds"), None) + them = next((p for p in match["players"] if p.get("kind") != "sds"), None) + if not us or not them: + continue + + def share(player): + start = player.get("bvStart") or 1 + return 100 * (player.get("bvRemaining") or 0) / start + + won = match.get("victoryTeam") == us.get("team") + decided = bool(match.get("victoryTeam")) + rows.append( + ( + result.name.split("-seed")[0][:26], + match.get("round", 0), + share(us), + share(them), + won, + decided, + ) + ) + if not rows: + print("no results beside these reports", file=out) + return + head = f"{'scenario':26} {'rnd':>4} {'sds BV':>7} {'pri BV':>7} {'diff':>7} outcome" + print(head, file=out) + print("-" * len(head), file=out) + for name, rnd, ours, theirs, won, decided in rows: + verdict = ("SDS" if won else "princess") if decided else "capped" + print( + f"{name:26} {rnd:>4} {ours:>6.0f}% {theirs:>6.0f}% {ours - theirs:>+6.0f} {verdict}", + file=out, + ) + ours = sum(r[2] for r in rows) / len(rows) + theirs = sum(r[3] for r in rows) / len(rows) + decided = [r for r in rows if r[5]] + print("-" * len(head), file=out) + print(f"{'mean':26} {'':>4} {ours:>6.1f}% {theirs:>6.1f}% {ours - theirs:>+6.1f}", file=out) + print( + f"ahead on BV in {sum(1 for r in rows if r[2] > r[3])}/{len(rows)}; " + f"won {sum(1 for r in decided if r[4])}/{len(decided)} decided; " + f"{len(rows) - len(decided)}/{len(rows)} hit the cap", + file=out, + ) + + +def heat(reports, out=sys.stdout) -> None: + """What each side ran at, round by round. + + Gained and sunk are per round; `at` is what the machine carries into the + next one. A side that never climbs is not spending its capacity. + """ + gained: dict = collections.defaultdict(list) + carried: dict = collections.defaultdict(list) + peak: dict = collections.defaultdict(int) + for report in reports: + text = re.sub(r"\s+", " ", flatten(report)) + marks = [(m.start(), int(m.group(1))) for m in ROUND.finditer(text)] + for entry in HEAT.finditer(text): + rnd = next((r for start, r in reversed(marks) if start < entry.start()), 0) + side = entry.group("side") + gained[(side, rnd)].append(int(entry.group("gained"))) + carried[(side, rnd)].append(int(entry.group("now"))) + peak[side] = max(peak[side], int(entry.group("now"))) + if not gained: + print("no heat lines found", file=out) + return + rounds = sorted({r for _, r in gained}) + head = f"{'round':>6} {'sds gained':>11} {'sds at':>8} {'pri gained':>11} {'pri at':>8}" + print(head, file=out) + print("-" * len(head), file=out) + for rnd in rounds: + cells = [] + for side in ("sds", "princess"): + g = gained.get((side, rnd), []) + c = carried.get((side, rnd), []) + cells += [sum(g) / len(g) if g else 0.0, sum(c) / len(c) if c else 0.0] + print( + f"{rnd:>6} {cells[0]:>11.1f} {cells[1]:>8.1f} {cells[2]:>11.1f} {cells[3]:>8.1f}", + file=out, + ) + allg = { + s: [v for (side, _), vs in gained.items() if side == s for v in vs] + for s in ("sds", "princess") + } + print("-" * len(head), file=out) + for side in ("sds", "princess"): + values = allg[side] + mean = sum(values) / len(values) if values else 0.0 + print(f"{side:9} mean gained {mean:5.2f} a unit-round, peak carried {peak[side]}", file=out) + + +def summarise(reports, out=sys.stdout) -> None: + """Everything, which is what a batch summary prints.""" + print("outcomes", file=out) + outcome(reports, out) + print("\nweapons, both sides", file=out) + compare(reports, out) + print("\nheat", file=out) + heat(reports, out) + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("target", help="a directory of runs, or one report") + ap.add_argument("--era", help="only matches whose name carries this era") + ap.add_argument( + "--side", + default=None, + choices=("sds", "princess"), + help="weapons view: one side only, instead of both side by side", + ) + ap.add_argument( + "--view", + default="all", + choices=("all", "outcome", "weapons", "heat"), + help="which summary to print; the default prints all three", + ) + args = ap.parse_args(argv) + + root = Path(args.target) + reports = sorted(root.rglob("*.report.html")) if root.is_dir() else [root] + if args.era: + reports = [r for r in reports if args.era in r.name] + if not reports: + print(f"no reports under {root}", file=sys.stderr) + return 1 + + if args.side is None: + {"all": summarise, "outcome": outcome, "weapons": compare, "heat": heat}[args.view](reports) + return 0 + + fired: dict[tuple[str, str], int] = collections.Counter() + hits: dict[tuple[str, str], int] = collections.Counter() + needs: dict[tuple[str, str], list[int]] = collections.defaultdict(list) + for report in reports: + for side, weapon, need, hit in walk(report): + if args.side != "both" and side != args.side: + continue + fired[(side, weapon)] += 1 + hits[(side, weapon)] += hit + needs[(side, weapon)].append(need) + + if not fired: + print("no weapon fire found; the report format may have changed", file=sys.stderr) + return 1 + + print(f"{len(reports)} reports, side={args.side}" + (f", era={args.era}" if args.era else "")) + head = f"{'weapon':28} {'fired':>7} {'hits':>6} {'hit%':>6} {'mean needs':>11}" + print(head) + print("-" * len(head)) + for key, count in sorted(fired.items(), key=lambda kv: -kv[1]): + side, weapon = key + mean = sum(needs[key]) / len(needs[key]) + print( + f"{weapon[:28]:28} {count:>7} {hits[key]:>6} " + f"{100 * hits[key] / count:>5.1f}% {mean:>11.1f}" + ) + total = sum(fired.values()) + landed = sum(hits.values()) + print("-" * len(head)) + print(f"{'all':28} {total:>7} {landed:>6} {100 * landed / total:>5.1f}%") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_board.py b/tests/test_board.py new file mode 100644 index 0000000..840dd5f --- /dev/null +++ b/tests/test_board.py @@ -0,0 +1,161 @@ +"""Reading MegaMek's board files, and finding the right one. + +The parts worth pinning are the ones that can be wrong silently. A coordinate +split in the wrong place puts every hex in the wrong column and the map still +draws. A board that cannot be found must not stop the viewer opening, because +the reason it is opened is that a match is being played right now. + +Fixtures are written here rather than copied from an install: this repository +ships none of MegaMek's data, and a test that needs a MegaMek release to run is +a test that does not run. +""" + +import tempfile +import unittest +from pathlib import Path + +from sds.board import ( + Board, + BoardError, + blank, + board_names, + find_board_file, + load_for, + parse_board, + scenario_for, +) + +BOARD = """# a comment, and the licence header a real one carries +size 4 3 +tag "Woods (Auto)" +hex 0101 0 "" "desert" +hex 0201 2 "rough:1" "desert" +hex 0301 0 "woods:1;foliage_elev:2" "desert" +hex 0401 -1 "water:2" "desert" +hex 0102 1 "" "desert" +hex 0202 0 "" "desert" +hex 0302 0 "building:3:12" "desert" +hex 0402 0 "" "desert" +hex 0103 0 "" "desert" +hex 0203 0 "" "desert" +hex 0303 0 "" "desert" +hex 0403 0 "" "desert" +""" + + +def write(tmp: Path, name: str, text: str) -> Path: + path = tmp / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + return path + + +class TestParsing(unittest.TestCase): + def board(self, tmp: Path) -> Board: + return parse_board(write(tmp, "b.board", BOARD)) + + def test_size_and_hex_count(self): + with tempfile.TemporaryDirectory() as tmp: + board = self.board(Path(tmp)) + self.assertEqual((board.width, board.height), (4, 3)) + self.assertEqual(len(board.hexes), 12) + + def test_coordinates_are_one_based_in_the_file_and_zero_based_here(self): + # `end: {x, y}` in a decision log is a zero-based MegaMek Coords, and + # the whole point of this module is that the two meet without an + # off-by-one that nothing would report. + with tempfile.TemporaryDirectory() as tmp: + board = self.board(Path(tmp)) + self.assertEqual(board.hexes[(0, 0)].elevation, 0) + self.assertEqual(board.hexes[(1, 0)].elevation, 2) + self.assertEqual(board.hexes[(0, 1)].elevation, 1) + + def test_a_wide_board_splits_its_coordinates_down_the_middle(self): + # Three digits a side once a board passes 99 hexes. A fixed `[:2]` + # reads 100120 as column 10, which is a plausible hex on the same + # board - so nothing raises and every glyph lands somewhere wrong. + with tempfile.TemporaryDirectory() as tmp: + path = write(Path(tmp), "wide.board", 'size 120 120\nhex 100120 3 "" "x"\n') + board = parse_board(path) + self.assertIn((99, 119), board.hexes) + + def test_terrain_keeps_its_level_and_drops_the_third_field(self): + with tempfile.TemporaryDirectory() as tmp: + board = self.board(Path(tmp)) + self.assertEqual(board.hexes[(2, 0)].terrain, {"woods": 1, "foliage_elev": 2}) + self.assertEqual(board.hexes[(3, 0)].terrain, {"water": 2}) + self.assertEqual(board.hexes[(2, 1)].terrain, {"building": 3}) + + def test_only_drawn_terrain_reaches_the_page(self): + # `foliage_elev` is bookkeeping. It is kept in the model and left out + # of the payload, because a glyph for it would mean nothing. + with tempfile.TemporaryDirectory() as tmp: + payload = self.board(Path(tmp)).as_json() + hexes = {(h["x"], h["y"]): h for h in payload["hexes"]} + self.assertEqual(hexes[(2, 0)]["t"], {"woods": 1}) + + def test_negative_elevation_survives(self): + with tempfile.TemporaryDirectory() as tmp: + self.assertEqual(self.board(Path(tmp)).hexes[(3, 0)].elevation, -1) + + def test_a_file_with_no_hexes_is_refused(self): + with tempfile.TemporaryDirectory() as tmp: + path = write(Path(tmp), "empty.board", "size 4 3\n") + with self.assertRaises(BoardError): + parse_board(path) + + +class TestFinding(unittest.TestCase): + def test_maps_key_is_read_and_random_entries_ignored(self): + with tempfile.TemporaryDirectory() as tmp: + path = write(Path(tmp), "s.mms", "Name=x\nMaps=Map Set 5/Deep Canyon 1,RANDOM\n") + self.assertEqual(board_names(path), ["Map Set 5/Deep Canyon 1"]) + + def test_a_board_is_found_by_basename_when_the_set_moved(self): + # Boards get reorganised between MegaMek releases. Failing on that + # would be failing on the common case, so the basename is the fallback. + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + write(home / "data" / "boards" / "Elsewhere", "Deep Canyon 1.board", BOARD) + found = find_board_file("Map Set 5/Deep Canyon 1", home) + self.assertIsNotNone(found) + self.assertEqual(found.name, "Deep Canyon 1.board") + + def test_the_scenario_comes_from_the_host_log_before_the_result_exists(self): + # The live case. The result document is written when the match ends; + # the host prints its banner before round 0. + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) / "repo" + write(repo / "scenarios", "s.mms", "Maps=x\n") + runs = repo / "runs" + write(runs, "tag.log", " seed : 1\n scenario : /work/scenarios/s.mms\n") + self.assertEqual(scenario_for("tag", runs, repo), repo / "scenarios" / "s.mms") + + def test_the_tag_names_the_scenario_when_nothing_else_does(self): + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) / "repo" + write(repo / "scenarios" / "suite", "2v2-clan-s1.mms", "Maps=x\n") + runs = repo / "runs" + runs.mkdir(parents=True) + found = scenario_for("2v2-clan-s1-seed30-e9fa54a0", runs, repo) + self.assertEqual(found, repo / "scenarios" / "suite" / "2v2-clan-s1.mms") + + def test_a_missing_board_gives_a_blank_one_rather_than_an_error(self): + # A viewer that refuses to open because an install moved is a viewer + # nobody opens during a match. + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) / "repo" + runs = repo / "runs" + runs.mkdir(parents=True) + board = load_for("tag", runs, repo, Path(tmp) / "nowhere") + self.assertEqual(board.name, "(no board found)") + self.assertTrue(board.source) + self.assertEqual(len(board.hexes), board.width * board.height) + + def test_blank_covers_every_hex(self): + board = blank(3, 2) + self.assertEqual(sorted(board.hexes), sorted((x, y) for y in range(2) for x in range(3))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_explain.py b/tests/test_explain.py new file mode 100644 index 0000000..8588102 --- /dev/null +++ b/tests/test_explain.py @@ -0,0 +1,165 @@ +"""What `sds explain` claims about a decision, tested without the table. + +The formatting is not the part that can be wrong without anybody noticing. The +parts that can are: whether the terms it prints add up to the score the bot +actually ranked with, whether it refuses to decompose a log with weights that +do not belong to it, and whether "held fire" and "had nothing to shoot at" stay +separate - conflating those two is what turned a positioning problem into a +scoring problem for a day. +""" + +import json +import tempfile +import unittest +from pathlib import Path + +from sds.explain import ( + ExplainError, + decision_logs, + read, + render, + tally, + terms, + weights_for, +) + +WEIGHTS = {"expected_damage": 4.0, "overkill": -3.0, "weapon_concentration": 1.5} + + +def candidate(label: str, damage: float, overkill: float, concentration: float) -> dict: + phi = { + "expected_damage": damage, + "overkill": overkill, + "weapon_concentration": concentration, + } + value = sum(phi[name] * WEIGHTS[name] for name in phi) + return {"label": label, "phi": phi, "value": value} + + +def firing(seq: int, candidates: list[dict], chosen: int) -> dict: + return { + "seat": "sds North", + "seq": seq, + "round": 2, + "phase": "FIRING", + "unit": 0, + "candidates": candidates, + "chosen": chosen, + } + + +def written(rows: list[dict], weights: dict | None = WEIGHTS) -> tuple[Path, Path]: + """A log and its sidecar in a temporary directory, as a run would leave them.""" + directory = Path(tempfile.mkdtemp()) + log = directory / "tag-sds_North.decisions.jsonl" + log.write_text("".join(json.dumps(row) + "\n" for row in rows)) + if weights is not None: + (directory / "tag-sds_North.weights.json").write_text(json.dumps({"weights": weights})) + return directory, log + + +class TermsAddUp(unittest.TestCase): + """The property the whole command rests on.""" + + def test_the_terms_sum_to_the_value_the_bot_recorded(self): + one = candidate("1 at Talon", 0.25, 0.05, 1.0) + rebuilt = sum(term.product for term in terms(one, WEIGHTS)) + self.assertAlmostEqual(rebuilt, one["value"], places=6) + + def test_a_feature_with_no_weight_contributes_nothing(self): + one = candidate("1 at Talon", 0.25, 0.05, 1.0) + one["phi"]["some_unweighted_feature"] = 9.0 + rebuilt = sum(term.product for term in terms(one, WEIGHTS)) + self.assertAlmostEqual(rebuilt, one["value"], places=6) + + def test_terms_are_ordered_by_how_much_they_moved_the_score(self): + one = candidate("1 at Talon", 0.25, 0.9, 1.0) + products = [abs(term.product) for term in terms(one, WEIGHTS)] + self.assertEqual(products, sorted(products, reverse=True)) + + +class WrongWeightsAreRefused(unittest.TestCase): + def test_a_missing_sidecar_says_so_rather_than_guessing(self): + _, log = written([firing(1, [candidate("hold fire", 0, 0, 0)], 0)], weights=None) + with self.assertRaises(ExplainError): + weights_for(log) + + def test_the_sidecar_is_found_beside_the_log(self): + _, log = written([firing(1, [candidate("hold fire", 0, 0, 0)], 0)]) + self.assertEqual(weights_for(log), WEIGHTS) + + def test_weights_that_do_not_rebuild_the_score_are_flagged(self): + row = firing( + 1, [candidate("hold fire", 0, 0, 0), candidate("1 at Talon", 0.5, 0.1, 1.0)], 1 + ) + wrong = dict(WEIGHTS, expected_damage=40.0) + self.assertIn("WARNING", render(row, wrong)) + self.assertNotIn("WARNING", render(row, WEIGHTS)) + + +class TheTallySeparatesHoldingFireFromHavingNoShot(unittest.TestCase): + """A menu of one is a unit with no legal shot, not a decision to hold fire.""" + + def test_a_menu_of_one_is_not_counted_as_taking_the_default(self): + rows = [ + firing(1, [candidate("hold fire", 0, 0, 0)], 0), + firing(3, [candidate("hold fire", 0, 0, 0), candidate("1 at Talon", 0.5, 0.1, 1.0)], 1), + ] + line = tally(rows) + self.assertIn("2 scored decisions", line) + self.assertIn("1 had no candidate but the default", line) + self.assertIn("0 took the default", line) + + def test_the_default_being_taken_over_a_real_menu_is_counted(self): + rows = [ + firing(3, [candidate("hold fire", 0, 0, 0), candidate("1 at Talon", -1.0, 0.9, 0.1)], 0) + ] + self.assertIn("1 took the default", tally(rows)) + + +class ReadingALog(unittest.TestCase): + def test_lines_without_a_menu_are_skipped(self): + directory = Path(tempfile.mkdtemp()) + log = directory / "tag-sds_North.decisions.jsonl" + log.write_text( + json.dumps({"seq": 1, "phase": "DEPLOYMENT"}) + + "\n" + + json.dumps(firing(2, [candidate("hold fire", 0, 0, 0)], 0)) + + "\n" + ) + self.assertEqual([row["seq"] for row in read(log)], [2]) + + def test_a_phase_filter_is_applied(self): + _, log = written([firing(2, [candidate("hold fire", 0, 0, 0)], 0)]) + self.assertEqual(read(log, "MOVEMENT"), []) + self.assertEqual(len(read(log, "FIRING")), 1) + + def test_a_directory_finds_its_logs_and_an_empty_one_says_so(self): + directory, log = written([firing(2, [candidate("hold fire", 0, 0, 0)], 0)]) + self.assertEqual(decision_logs(directory), [log]) + with self.assertRaises(ExplainError): + decision_logs(Path(tempfile.mkdtemp())) + + +class TheTableNamesWhatWasTaken(unittest.TestCase): + def test_the_chosen_column_is_marked_and_every_candidate_is_shown(self): + row = firing( + 9, + [ + candidate("hold fire", 0, 0, 0), + candidate("1 at Talon", 0.25, 0.05, 1.0), + candidate("2 at Talon", 0.50, 0.04, 0.8), + ], + 2, + ) + table = render(row, WEIGHTS, full=True) + self.assertIn("took [2] '2 at Talon'", table) + self.assertIn("hold fire", table) + self.assertIn("<= took", table) + # A feature every candidate scores at zero is not a row worth printing. + row["candidates"][0]["phi"]["ammo_spent"] = 0.0 + self.assertNotIn("ammo_spent", render(row, dict(WEIGHTS, ammo_spent=-0.5), full=True)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_play.py b/tests/test_play.py new file mode 100644 index 0000000..0914fb6 --- /dev/null +++ b/tests/test_play.py @@ -0,0 +1,117 @@ +"""The container `sds play` builds, and the watchdogs it has to turn off.""" + +from __future__ import annotations + +import contextlib +import io +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from sds import play as play_module +from sds.play import PlaySpec + + +def _invocation(spec: PlaySpec) -> tuple[list[str], str]: + """The `docker run` argv and the script it hands the container. + + Docker, the display probe and the settings directory are all faked: the + thing under test is the command line, and a real one wants an X server and + a MegaMek install. + """ + seen: list[list[str]] = [] + + def fake_run(command, *popenargs, **kwargs): + if command[:2] == ["docker", "run"]: + seen.append(command) + return subprocess.CompletedProcess(command, 0) + + with ( + tempfile.TemporaryDirectory(dir=play_module.REPO) as tmp, + mock.patch.object(play_module, "preflight", lambda _spec: None), + mock.patch.object( + play_module, "display_environment", lambda: (":7", "/run/user/1000/cookie") + ), + mock.patch.object(play_module, "_seed_config", lambda _home: Path("/tmp/mmconf")), + mock.patch.object(play_module.subprocess, "run", fake_run), + # `play` narrates itself at a person; a test suite is not the audience. + contextlib.redirect_stdout(io.StringIO()), + ): + play_module.play(spec, Path(tmp)) + command = seen[0] + return command, command[-1] + + +def _spec(**overrides) -> PlaySpec: + fields = { + "scenario": play_module.REPO / "scenarios" / "mirror-lance.mms", + "human": "North", + "sds": "South", + "bot": "/work/target/release/sds-bot", + } + fields.update(overrides) + return PlaySpec(**fields) + + +def _flag(script: str, flag: str) -> str: + """The value SdsHost is passed for one flag. + + Every host argument is single-quoted into the script, so the quotes come + off before either side is compared. + """ + parts = [word.strip("'") for word in script.split()] + return parts[parts.index(flag) + 1] + + +class CommandLine(unittest.TestCase): + def test_every_watchdog_is_off(self): + """A person takes minutes per turn; all three of these would end the game.""" + _, script = _invocation(_spec()) + self.assertEqual(_flag(script, "--stall-seconds"), "0") + self.assertEqual(_flag(script, "--wall-clock"), "0") + self.assertEqual(_flag(script, "--max-rounds"), "0") + + def test_the_seats_say_who_is_who(self): + _, script = _invocation(_spec(human="South", sds="North")) + self.assertIn("'South=human'", script) + self.assertIn("'North=sds'", script) + self.assertIn('-player_name "human South"', script) + + def test_the_bot_is_named_and_passed_through(self): + _, script = _invocation(_spec(bot="python3 /work/bots/random_bot.py")) + self.assertIn("'python3 /work/bots/random_bot.py'", script) + + def test_the_client_is_not_headless(self): + """The host stays headless; the seat a person sits in cannot be.""" + _, script = _invocation(_spec()) + host, client = script.split("megamek.MegaMek") + self.assertIn("-Djava.awt.headless=true", host) + self.assertNotIn("-Djava.awt.headless=true", client) + + def test_the_display_is_mounted_not_published(self): + command, _ = _invocation(_spec()) + self.assertIn("DISPLAY=:7", command) + self.assertIn("/tmp/.X11-unix:/tmp/.X11-unix", command) + self.assertIn("/run/user/1000/cookie:/tmp/.sds-xauth:ro", command) + # Nothing is published: the server and the client share the container's + # own loopback, which is why two of these cannot collide. + self.assertNotIn("-p", command) + self.assertNotIn("--publish", command) + + def test_the_container_is_not_named_sds(self): + """`sds clean` kills every `sds-*` container on the machine.""" + command, _ = _invocation(_spec()) + name = command[command.index("--name") + 1] + self.assertTrue(name.startswith("sdsplay-"), name) + self.assertFalse(name.startswith("sds-"), name) + + def test_the_server_outlives_the_result(self): + """So MegaMek's end-of-game screen is readable rather than a disconnect.""" + _, script = _invocation(_spec()) + self.assertGreater(int(_flag(script, "--linger-seconds")), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_stats.py b/tests/test_stats.py index 60a515e..1a97ce6 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -98,6 +98,44 @@ class TestSummary(unittest.TestCase): self.assertEqual(summary.sides["princess"].wins, 1) +class TestTurnOrder(unittest.TestCase): + """Turn order has to be countable, not only correct. + + "we sent the wrong unit" and "no other unit could have gone" must not look + the same, and the mean eligible-set size is the number that tells them + apart. A substituted unit is its own column because the bot did answer - + it is not a default, it is a refused choice. + """ + + @staticmethod + def _seat(**counters): + seat = player("N", 1, "sds") + seat["sds"] = { + "decisions": 10, + "answered": 10, + "defaulted": 0, + "illegal": 0, + "failed": 0, + **counters, + } + return seat + + def test_the_counters_are_summed_and_printed(self): + seat = self._seat(eligible=25, chose=10, differed=6, substituted=2) + summary = Summary([result("victory", 1, [seat, player("S", 2, "princess")])]) + side = summary.sides["N"] + self.assertEqual((side.eligible, side.chose, side.differed), (25, 10, 6)) + rendered = summary.render() + self.assertIn("turn order", rendered) + # 25 eligible over 10 decisions: the bot had a real choice. + self.assertIn("2.50", rendered) + self.assertIn("named a unit that could not act 2 time(s)", rendered) + + def test_a_log_without_the_counters_prints_no_turn_order_table(self): + summary = Summary([result("victory", 1, [self._seat(), player("S", 2, "princess")])]) + self.assertNotIn("turn order", summary.render()) + + class TestBreakdown(unittest.TestCase): """The per-scenario split is what says whether to move the round limit.""" diff --git a/weights/hand-authored.json b/weights/hand-authored.json index bf154a1..0363e12 100644 --- a/weights/hand-authored.json +++ b/weights/hand-authored.json @@ -11,7 +11,12 @@ "expected_damage": 4.0, "exposure": -3.0, "has_been_fired_upon": 0.5, - "heat_incurred": -2.0, + "has_explodable_ammo": 0.0, + "heat_ammo_explosion_risk": -8.0, + "heat_incurred": 6.0, + "heat_mp_penalty": -1.0, + "heat_shutdown_risk": -6.0, + "heat_to_hit_penalty": -2.0, "honour_broken": 1.0, "los_in": -1.5, "los_out": 1.5,