diff --git a/bridge/sds/SdsSelfDamage.java b/bridge/sds/SdsSelfDamage.java
new file mode 100644
index 0000000..f9d9c6e
--- /dev/null
+++ b/bridge/sds/SdsSelfDamage.java
@@ -0,0 +1,269 @@
+package sds;
+
+import java.io.File;
+import java.io.PrintStream;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+import megamek.SuiteConstants;
+import megamek.common.actions.ChargeAttackAction;
+import megamek.common.actions.DfaAttackAction;
+import megamek.common.actions.KickAttackAction;
+import megamek.common.game.Game;
+import megamek.common.game.IGame;
+import megamek.common.loaders.MekSummaryCache;
+import megamek.common.scenario.Scenario;
+import megamek.common.scenario.ScenarioLoader;
+import megamek.common.units.Entity;
+
+/**
+ * Ask MegaMek what an attack costs the machine that makes it.
+ *
+ *
Every offensive column on the feature vector prices what an action does to
+ * somebody else. Two attacks in this release also damage their own attacker,
+ * by a fixed amount and not as a side effect of falling, and nothing on the
+ * vector can express that. Before a column is written for it, three things are
+ * asked rather than assumed:
+ *
+ *
+ * - Whose weight sets a charge's self-damage?
+ * {@code ChargeAttackAction.getDamageTakenBy(Entity, Entity)} takes both
+ * machines, so either could be in it. The answer decides whether the
+ * column can be measured from the attacker alone - which would make it a
+ * property of the unit, cacheable and quotable once - or has to be
+ * carried per attacker/target pair the way the damage table already is.
+ *
- Whose weight sets a death-from-above's?
+ * {@code DfaAttackAction.getDamageTakenBy(Entity)} takes only the
+ * attacker, which is a different shape, and printing the two tables side
+ * by side is what says so rather than a signature read once.
+ *
- What else in this release damages its own attacker? The package
+ * is enumerated out of the jar and reflected, so the answer is what this
+ * build contains rather than what four imports happen to name.
+ *
+ *
+ * The denominator is printed beside every figure: armour plus structure
+ * remaining, which is what {@code incoming_damage} already divides by. A
+ * self-damage column that means anything has to be a share of the same thing
+ * standing in front of a gun is a share of.
+ *
+ *
No match, no server, no bot. Nothing here encodes a rule.
+ */
+public final class SdsSelfDamage {
+
+ private SdsSelfDamage() {}
+
+ public static void main(String[] args) throws Exception {
+ String scenario = null;
+ for (int i = 0; i < args.length - 1; i++) {
+ if ("--scenario".equals(args[i])) {
+ scenario = args[i + 1];
+ }
+ }
+ if (scenario == null) {
+ System.err.println("usage: sds.SdsSelfDamage --scenario ");
+ System.exit(2);
+ }
+
+ MekSummaryCache cache = MekSummaryCache.getInstance();
+ while (!cache.isInitialized()) {
+ Thread.sleep(250L);
+ }
+
+ PrintStream out = System.out;
+ out.printf("# megamek %s, scenario %s%n", SuiteConstants.VERSION, scenario);
+
+ Game game = load(scenario);
+ List attackers = team(game, 1);
+ List targets = team(game, 2);
+ if (attackers.isEmpty() || targets.isEmpty()) {
+ System.err.println("the scenario needs a team 1 and a team 2");
+ System.exit(3);
+ }
+
+ everythingThatHurtsItsOwnAttacker(out);
+ chargeSelfDamage(out, game, attackers, targets);
+ dfaSelfDamage(out, attackers);
+ }
+
+ /**
+ * Which actions in this build have a self-damage answer at all.
+ *
+ * Reflected out of the jar rather than listed, so the table is this
+ * release's own inventory. An action with no {@code getDamageTakenBy} does
+ * not damage its attacker by the rules - a kick that misses costs a
+ * piloting roll, which is a fall and is priced by the column that already
+ * exists for one.
+ */
+ private static void everythingThatHurtsItsOwnAttacker(PrintStream out) throws Exception {
+ out.printf("%n## every action in this build with a self-damage answer%n%n");
+ out.printf("# %-28s %s%n", "action", "signature");
+ int found = 0;
+ int scanned = 0;
+ for (String name : actionClasses()) {
+ Class> action;
+ try {
+ action = Class.forName(name);
+ } catch (Throwable problem) {
+ continue;
+ }
+ scanned++;
+ for (Method method : action.getDeclaredMethods()) {
+ if (!"getDamageTakenBy".equals(method.getName())
+ || !Modifier.isPublic(method.getModifiers())) {
+ continue;
+ }
+ StringBuilder shape = new StringBuilder("(");
+ for (Class> parameter : method.getParameterTypes()) {
+ if (shape.length() > 1) {
+ shape.append(", ");
+ }
+ shape.append(parameter.getSimpleName());
+ }
+ shape.append(")");
+ out.printf(" %-28s %s%n", action.getSimpleName(), shape);
+ found++;
+ }
+ }
+ out.printf("%n%d signature(s) across %d action class(es)%n", found, scanned);
+ }
+
+ /**
+ * A charge, per attacker and target.
+ *
+ *
{@code tacops_charge_damage} is printed both ways, and the game is
+ * asked which one it is set to. {@code resolveChargeDamage} reads the
+ * option out of the game rather than taking it from the caller, so a bot
+ * quoting one figure has to know which one the host will use - and the two
+ * branches are not the same shape: one reads only the target, the other
+ * reads both machines and the distance run.
+ *
+ *
The TacOps column is quoted at a run's worth of hexes rather than at
+ * nought. The convenience overloads pass {@code Entity.delta_distance},
+ * which on an undeployed entity is zero, and that branch multiplies by it -
+ * so asking the short way prints zeroes that are an artefact of the
+ * question rather than an answer.
+ */
+ private static void chargeSelfDamage(PrintStream out, Game game, List attackers,
+ List targets) {
+ boolean tacopsOn = game.getOptions().booleanOption("tacops_charge_damage");
+ out.printf("%n## charge: ChargeAttackAction.getDamageTakenBy(attacker, target)%n%n");
+ out.printf("# tacops_charge_damage in this game: %s%n", tacopsOn);
+ out.printf("# each cell is plain/tacops, the TacOps figure at a full run of hexes%n%n");
+ out.printf("# %-22s %5s %8s %8s", "attacker", "tons", "health", "kick");
+ for (Entity target : targets) {
+ out.printf(" %14s", shortName(target));
+ }
+ out.println();
+ for (Entity attacker : attackers) {
+ int kick = KickAttackAction.getDamageFor(attacker, KickAttackAction.LEFT, false);
+ out.printf(" %-22s %5.0f %8d %8d", shortName(attacker), attacker.getWeight(),
+ health(attacker), kick);
+ for (Entity target : targets) {
+ int plain = ChargeAttackAction.getDamageTakenBy(attacker, target);
+ int tacops = ChargeAttackAction.getDamageTakenBy(
+ attacker, target, true, Math.max(attacker.getRunMP(), 1));
+ out.printf(" %14s", plain + "/" + tacops);
+ }
+ out.println();
+ }
+ out.printf("%n# the same pairs as a share of the attacker's remaining armour plus"
+ + " structure,%n# which is the denominator `incoming_damage` already uses%n%n");
+ out.printf("# %-22s %8s", "attacker", "health");
+ for (Entity target : targets) {
+ out.printf(" %14s", shortName(target));
+ }
+ out.println();
+ for (Entity attacker : attackers) {
+ out.printf(" %-22s %8d", shortName(attacker), health(attacker));
+ for (Entity target : targets) {
+ double share = health(attacker) <= 0 ? 0.0
+ : (double) ChargeAttackAction.getDamageTakenBy(attacker, target)
+ / health(attacker);
+ out.printf(" %14.3f", share);
+ }
+ out.println();
+ }
+ }
+
+ /**
+ * A death from above, per attacker.
+ *
+ * The damage dealt is printed beside it so the trade is visible in one
+ * row: what the target takes against what the attacker takes for it.
+ */
+ private static void dfaSelfDamage(PrintStream out, List attackers) {
+ out.printf("%n## death from above: DfaAttackAction.getDamageTakenBy(attacker)%n%n");
+ out.printf("# %-22s %5s %8s %8s %8s %8s%n",
+ "attacker", "tons", "health", "dealt", "taken", "share");
+ for (Entity attacker : attackers) {
+ int taken = DfaAttackAction.getDamageTakenBy(attacker);
+ int dealt = DfaAttackAction.getDamageFor(attacker, false);
+ double share = health(attacker) <= 0 ? 0.0 : (double) taken / health(attacker);
+ out.printf(" %-22s %5.0f %8d %8d %8d %8.3f%n", shortName(attacker),
+ attacker.getWeight(), health(attacker), dealt, taken, share);
+ }
+ }
+
+ /** Armour plus structure remaining: what a share of self-damage is a share of. */
+ private static int health(Entity entity) {
+ return Math.max(entity.getTotalArmor(), 0) + Math.max(entity.getTotalInternal(), 0);
+ }
+
+ private static String shortName(Entity entity) {
+ return entity.getShortName();
+ }
+
+ private static List team(Game game, int team) {
+ List out = new ArrayList<>();
+ for (Entity entity : game.getEntitiesVector()) {
+ if (entity.getOwner().getTeam() == team) {
+ out.add(entity);
+ }
+ }
+ return out;
+ }
+
+ /**
+ * Every class in {@code megamek.common.actions}, read out of the jar the
+ * package was loaded from.
+ */
+ private static List actionClasses() throws Exception {
+ List names = new ArrayList<>();
+ URL marker = SdsSelfDamage.class.getClassLoader()
+ .getResource("megamek/common/actions/ChargeAttackAction.class");
+ if (marker == null || !"jar".equals(marker.getProtocol())) {
+ return names;
+ }
+ String path = marker.getPath();
+ String jar = path.substring(0, path.indexOf('!'));
+ jar = new File(new java.net.URI(jar)).getPath();
+ try (JarFile file = new JarFile(jar)) {
+ Enumeration entries = file.entries();
+ while (entries.hasMoreElements()) {
+ String entry = entries.nextElement().getName();
+ if (!entry.startsWith("megamek/common/actions/") || !entry.endsWith(".class")
+ || entry.contains("$")) {
+ continue;
+ }
+ names.add(entry.substring(0, entry.length() - ".class".length())
+ .replace('/', '.'));
+ }
+ }
+ return names;
+ }
+
+ private static Game load(String path) throws Exception {
+ Scenario scenario = new ScenarioLoader(new File(path)).load();
+ IGame game = scenario.createGame();
+ if (!(game instanceof Game tw)) {
+ throw new IllegalStateException(path + " is not a Total Warfare scenario");
+ }
+ return tw;
+ }
+}
diff --git a/scenarios/self-damage/observed.txt b/scenarios/self-damage/observed.txt
new file mode 100644
index 0000000..c0d44ed
--- /dev/null
+++ b/scenarios/self-damage/observed.txt
@@ -0,0 +1,44 @@
+# megamek 0.51.00, scenario /work/scenarios/self-damage/self-damage-probe.mms
+
+## every action in this build with a self-damage answer
+
+# action signature
+ AirMekRamAttackAction (Entity, Targetable)
+ AirMekRamAttackAction (Entity, Targetable, int)
+ ChargeAttackAction (Entity, IBuilding, Coords)
+ ChargeAttackAction (Entity, Entity, boolean, int)
+ ChargeAttackAction (Entity, Entity)
+ ChargeAttackAction (Entity, Entity, boolean)
+ DfaAttackAction (Entity)
+ RamAttackAction (IAero, Entity)
+ RamAttackAction (IAero, Entity, Coords, int, int)
+
+9 signature(s) across 69 action class(es)
+
+## charge: ChargeAttackAction.getDamageTakenBy(attacker, target)
+
+# tacops_charge_damage in this game: false
+# each cell is plain/tacops, the TacOps figure at a full run of hexes
+
+# attacker tons health kick Locust LCT-1V Griffin GRF-1N Charger CGR-1A1 Atlas AS7-D
+ Locust LCT-1V 20 97 4 2/12 6/17 8/19 10/20
+ Griffin GRF-1N 55 243 11 2/11 6/22 8/26 10/28
+ Charger CGR-1A1 80 282 16 2/12 6/26 8/32 10/35
+ Atlas AS7-D 100 456 20 2/8 6/17 8/22 10/25
+
+# the same pairs as a share of the attacker's remaining armour plus structure,
+# which is the denominator `incoming_damage` already uses
+
+# attacker health Locust LCT-1V Griffin GRF-1N Charger CGR-1A1 Atlas AS7-D
+ Locust LCT-1V 97 0.021 0.062 0.082 0.103
+ Griffin GRF-1N 243 0.008 0.025 0.033 0.041
+ Charger CGR-1A1 282 0.007 0.021 0.028 0.035
+ Atlas AS7-D 456 0.004 0.013 0.018 0.022
+
+## death from above: DfaAttackAction.getDamageTakenBy(attacker)
+
+# attacker tons health dealt taken share
+ Locust LCT-1V 20 97 6 4 0.041
+ Griffin GRF-1N 55 243 17 11 0.045
+ Charger CGR-1A1 80 282 24 16 0.057
+ Atlas AS7-D 100 456 30 20 0.044
diff --git a/scenarios/self-damage/self-damage-probe.mms b/scenarios/self-damage/self-damage-probe.mms
new file mode 100644
index 0000000..7948284
--- /dev/null
+++ b/scenarios/self-damage/self-damage-probe.mms
@@ -0,0 +1,79 @@
+# The self-damage probe: four attackers and four targets across the tonnage
+# range, so the two weights come apart.
+#
+# `sds.SdsSelfDamage` loads this and asks MegaMek what an attack costs the
+# machine making it. Two questions that read plausible either way and are
+# therefore not worth reasoning about:
+#
+# - **Whose weight sets a charge's self-damage?** The attacker's, the
+# target's, or both. It decides whether the column can be measured from
+# the attacker alone or has to be quoted per pair.
+# - **Whose weight sets a death-from-above's?** The signature already differs
+# - `DfaAttackAction.getDamageTakenBy` takes only the attacker - so the
+# pair of tables side by side says whether the two attacks are the same
+# shape.
+#
+# The set is a cross product on purpose. Reading across a row holds the
+# attacker and varies the target; reading down a column holds the target and
+# varies the attacker. Locust 20, Griffin 55, Charger 80 and Atlas 100 span
+# the range widely enough that a linear term in either weight is visible
+# without arithmetic.
+#
+# The units are adjacent because `ChargeAttackAction.toHit` needs a source hex
+# next to the target to answer anything but IMPOSSIBLE.
+#
+# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0.
+# https://creativecommons.org/licenses/by-nc-sa/4.0/
+MMSVersion: 2
+name: self-damage-probe
+description: |
+ Four attackers at 20, 55, 80 and 100 tons facing four targets at the same
+ weights. Not playable: it exists to be loaded by sds.SdsSelfDamage.
+
+map:
+ boards:
+ - file: Map Set 5/16x17 Open Terrain 1.board
+
+factions:
+
+- name: attackers
+ team: 1
+ deploy: N
+ units:
+ - fullname: Locust LCT-1V
+ id: 101
+ at: [ 3, 3 ]
+ facing: 3
+ - fullname: Griffin GRF-1N
+ id: 102
+ at: [ 5, 3 ]
+ facing: 3
+ - fullname: Charger CGR-1A1
+ id: 103
+ at: [ 7, 3 ]
+ facing: 3
+ - fullname: Atlas AS7-D
+ id: 104
+ at: [ 9, 3 ]
+ facing: 3
+
+- name: targets
+ team: 2
+ deploy: S
+ units:
+ - fullname: Locust LCT-1V
+ id: 201
+ at: [ 3, 4 ]
+ facing: 0
+ - fullname: Griffin GRF-1N
+ id: 202
+ at: [ 5, 4 ]
+ facing: 0
+ - fullname: Charger CGR-1A1
+ id: 203
+ at: [ 7, 4 ]
+ facing: 0
+ - fullname: Atlas AS7-D
+ id: 204
+ at: [ 9, 4 ]
+ facing: 0
diff --git a/sds/cli.py b/sds/cli.py
index 2e93761..d076a37 100644
--- a/sds/cli.py
+++ b/sds/cli.py
@@ -1080,6 +1080,17 @@ def cmd_club_probe(args: argparse.Namespace) -> int:
return code
+def cmd_self_damage_probe(args: argparse.Namespace) -> int:
+ """Record what MegaMek says an attack costs the machine that makes it."""
+ from .match import self_damage_probe
+
+ out = Path(args.out).resolve()
+ code = self_damage_probe(Path(args.scenario), out)
+ if code == 0:
+ print(f"wrote {out}")
+ return code
+
+
def cmd_battle_armor_probe(args: argparse.Namespace) -> int:
"""Record what MegaMek permits a battle armor squad and its carrier."""
from .match import battle_armor_probe
@@ -2291,6 +2302,25 @@ def main(argv: list[str] | None = None) -> int:
)
club_probe.set_defaults(func=cmd_club_probe)
+ self_damage = sub.add_parser(
+ "self-damage-probe",
+ help="record what MegaMek says an attack costs its own attacker",
+ description="Loads a scenario of four attackers and four targets across "
+ "the tonnage range and prints what MegaMek answers for each: a charge's "
+ "self-damage per pair, a death-from-above's per attacker, both as a share "
+ "of the attacker's remaining armour and structure, and every action in "
+ "this build that has a self-damage answer at all. No match and no server.",
+ )
+ self_damage.add_argument(
+ "--scenario",
+ default=str(REPO / "scenarios" / "self-damage" / "self-damage-probe.mms"),
+ )
+ self_damage.add_argument(
+ "--out",
+ default=str(REPO / "scenarios" / "self-damage" / "observed.txt"),
+ )
+ self_damage.set_defaults(func=cmd_self_damage_probe)
+
stand_probe = sub.add_parser(
"stand-probe",
help="record what MegaMek permits a prone, damaged Mek to do",
diff --git a/sds/match.py b/sds/match.py
index 51c1bf5..adc1ab3 100644
--- a/sds/match.py
+++ b/sds/match.py
@@ -1030,6 +1030,22 @@ def club_probe(scenario: Path, out: Path) -> int:
return _bridge("clubprobe", "sds.SdsClubs", arguments, stdout=out)
+def self_damage_probe(scenario: Path, out: Path) -> int:
+ """Ask MegaMek what an attack costs the machine that makes it.
+
+ One JVM, no server, no match. Every offensive column on the vector prices
+ what an action does to somebody else; two attacks in this release also
+ damage their own attacker by a fixed amount rather than as a side effect of
+ falling. Whose weight sets each figure reads plausible either way, and the
+ answer decides whether the column can be measured from the attacker alone
+ or has to be carried per pair. `sds.SdsSelfDamage` also enumerates the
+ action package out of the jar, so "what else does this" is this build's own
+ inventory rather than four imports.
+ """
+ arguments = ["--scenario", f"/work/{scenario.resolve().relative_to(REPO)}"]
+ return _bridge("selfdamage", "sds.SdsSelfDamage", arguments, stdout=out)
+
+
def battle_armor_probe(scenario: Path, out: Path) -> int:
"""Ask MegaMek what a battle armor squad and a carrier may do together.