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

+ * + *

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.