diff --git a/.gitignore b/.gitignore index 147de40..b7f0a77 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,9 @@ __pycache__/ # Validation reports, regenerated by `sds validate`. results/ + +# The weapon arc corpus. Deterministic and regenerable with +# `./sds.sh arc-dump`, and 9375 lines of it broke Tangled's pull rendering. +# `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 diff --git a/README.md b/README.md index 9fe855e..c7524b9 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ or is dice. ./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 + ./sds.sh arc-dump # the arc corpus, which is NOT committed A 4v4 on one map sheet takes about 90 seconds. diff --git a/bridge/sds/SdsArcs.java b/bridge/sds/SdsArcs.java new file mode 100644 index 0000000..87d734d --- /dev/null +++ b/bridge/sds/SdsArcs.java @@ -0,0 +1,234 @@ +package sds; + +import java.io.PrintStream; +import java.util.ArrayList; +import java.util.List; + +import megamek.common.board.Coords; +import megamek.common.compute.ComputeArc; +import megamek.common.enums.FacingArc; +import megamek.common.equipment.EquipmentType; +import megamek.common.equipment.Mounted; +import megamek.common.game.Game; +import megamek.common.units.BipedMek; +import megamek.common.units.Mek; + +/** + * Dump weapon arc membership, read out of MegaMek rather than a rulebook. + * + *

Which weapons bear from a position is the first prune candidate + * evaluation runs, and it is run once per {@code (my state, enemy hex)} pair - + * so the bot computes it itself rather than asking the server. A + * reimplementation that is one degree off does not throw: it silently offers + * shots that do not exist and declines shots that do, and that arrives as a + * worse win rate a hundred games later. This is the file that turns that + * silence into a test failure. + * + *

The arc test is not a function of the six hexsides. MegaMek takes + * the continuous bearing from attacker to target - + * {@link Coords#degree(Coords)}, off the hexes' ideal centres - subtracts the + * firing facing's angle, and asks whether the remainder falls in the arc's + * {@code [startAngle, endAngle]} window. A target eight hexes away can sit at a + * bearing no hexside has. So the dump walks relative offsets, not directions. + * + *

Four tables, all exhaustive over what they cover: + * + *

+ * arc    <NAME> <code> <startAngle> <endAngle>
+ * degree <parity> <dx> <dy> <degrees>
+ * bears  <parity> <dx> <dy> <facing> <NAME,NAME,...>
+ * twist  <facing> <secondary> <valid> <clipped>
+ * weapon <LOC> <rear> <flipped> <NAME> <usesSecondaryFacing>
+ * 
+ * + *

{@code parity} is the low bit of the attacker's x. It is load-bearing: + * a hex centre's y is {@code 2*y + (x&1)}, so an odd {@code dx} shifts the + * bearing one half-row one way or the other depending on which column you + * start in. That is exactly the place a hand-written port goes wrong, so both + * parities are dumped for every offset. + * + *

{@code bears} is the join of the first two: the arcs that contain the + * target, listed by name, or {@code -} for none. It is redundant with + * {@code arc} and {@code degree} on purpose - a port that reproduces the + * bearings but bounds the windows wrongly fails on it, and a port that gets + * the windows right off a wrong bearing fails on {@code degree}. + * + *

Bound. Offsets are every hex within 10 of the attacker, plus the + * whole ring at 17. Ten covers every short and medium range band a ground unit + * fires in on a 16x17 sheet; the ring at 17 is the shallow-angle case, where + * hexes one row apart differ by three degrees and an arc edge falls between + * them. Facings are all six. Arcs are the nine a ground unit can mount - + * the aerospace, capital, VGL and building arcs are in MegaMek's enum and are + * not here, because nothing in this repository fields a unit that has one. + * + *

Fed to {@code crates/sds-core/tests/corpus/arcs.txt} and read by + * {@code crates/sds-core/tests/arc_corpus.rs}, which fails if a row drifts. + * Nothing dumps during a match. + */ +public final class SdsArcs { + + /** Every hex this far from the attacker is dumped. */ + private static final int NEAR_RADIUS = 10; + + /** Plus the whole ring at this range: the shallow-angle case. */ + private static final int FAR_RING = 17; + + /** + * A hex far enough from the origin that no offset leaves the first + * quadrant. Nothing here reads a board, but negative coordinates are worth + * avoiding: {@code x & 1} on a negative x is not the parity anyone means. + */ + private static final int ORIGIN = 40; + + /** The arcs a ground unit can mount, in MegaMek's own declaration order. */ + private static final FacingArc[] ARCS = { + FacingArc.ARC_360, + FacingArc.ARC_FORWARD, + FacingArc.ARC_LEFT_ARM, + FacingArc.ARC_RIGHT_ARM, + FacingArc.ARC_REAR, + FacingArc.ARC_LEFT_SIDE, + FacingArc.ARC_RIGHT_SIDE, + FacingArc.ARC_MAIN_GUN, + FacingArc.ARC_TURRET, + }; + + public static void main(String[] argv) throws Exception { + PrintStream out = System.out; + // Before any Mek is constructed: the constructor reaches for an armour + // type, and nothing else here would build that table. + EquipmentType.initializeTypes(); + out.println("# generated by sds.SdsArcs - do not edit"); + + for (FacingArc arc : ARCS) { + out.printf("arc %s %d %d %d%n", + arc.name(), arc.getArcCode(), arc.getStartAngle(), arc.getEndAngle()); + } + + // Every whole degree against every arc. The window's two ends are + // not both inclusive and it is not the same end each time, so this is + // read out rather than inferred from the bounds above. + for (FacingArc arc : ARCS) { + for (int degrees = 0; degrees <= 360; degrees++) { + out.printf("window %s %d %d%n", arc.name(), degrees, arc.isInsideArc(degrees) ? 1 : 0); + } + } + + for (int parity = 0; parity <= 1; parity++) { + Coords from = new Coords(ORIGIN + parity, ORIGIN); + for (Coords to : offsets(from)) { + int dx = to.getX() - from.getX(); + int dy = to.getY() - from.getY(); + out.printf("degree %d %d %d %d%n", parity, dx, dy, from.degree(to)); + for (int facing = 0; facing < 6; facing++) { + out.printf("bears %d %d %d %d %s%n", + parity, dx, dy, facing, bearing(from, facing, to)); + } + } + } + + twists(out); + weapons(out); + } + + /** Which of the ground arcs contain {@code to}, or {@code -} for none. */ + private static String bearing(Coords from, int facing, Coords to) { + StringBuilder names = new StringBuilder(); + for (FacingArc arc : ARCS) { + if (ComputeArc.isInArc(from, facing, to, arc.getArcCode())) { + if (names.length() > 0) { + names.append(','); + } + names.append(arc.name()); + } + } + return names.length() == 0 ? "-" : names.toString(); + } + + /** + * The legal torso twists, off a standing Mek with no quirks. + * + *

{@code isValidSecondaryFacing} and {@code clipSecondaryFacing} are + * what bound the twist, and both read the unit's state - a prone, bracing + * or already-twisted Mek cannot twist at all, and the {@code ext_twist} + * quirk widens it to two hexsides. The bot's states are standing and + * untwisted, so that is what is dumped; the other cases are decided by + * flags the observation already carries. + */ + private static void twists(PrintStream out) { + Game game = new Game(); + Mek mek = new BipedMek(); + mek.setGame(game); + mek.setId(1); + for (int facing = 0; facing < 6; facing++) { + mek.setFacing(facing); + mek.setSecondaryFacing(facing); + for (int secondary = 0; secondary < 6; secondary++) { + out.printf("twist %d %d %d %d%n", + facing, + secondary, + mek.isValidSecondaryFacing(secondary) ? 1 : 0, + mek.clipSecondaryFacing(secondary)); + } + } + } + + /** + * Location to arc, and whether the twist applies to it. + * + *

A real weapon in a real location, because {@code getWeaponArc} reads + * the mount rather than taking a location number: rear-mounted is one + * answer, flipped arms are another, and legs are the case that catches a + * port - a leg weapon fires forward like a torso weapon but does + * not follow the torso twist. + */ + private static void weapons(PrintStream out) throws Exception { + EquipmentType laser = EquipmentType.get("Medium Laser"); + if (laser == null) { + throw new IllegalStateException("no Medium Laser in the equipment tables"); + } + String[] abbreviations = new BipedMek().getLocationAbbreviations(); + + for (int location = 0; location < 8; location++) { + for (int rear = 0; rear <= 1; rear++) { + for (int flipped = 0; flipped <= 1; flipped++) { + Game game = new Game(); + Mek mek = new BipedMek(); + mek.setGame(game); + mek.setId(1); + mek.setArmsFlipped(flipped == 1); + Mounted mounted = mek.addEquipment(laser, location, rear == 1); + int number = mek.getEquipmentNum(mounted); + out.printf("weapon %s %d %d %s %d%n", + abbreviations[location], + rear, + flipped, + FacingArc.valueOf(mek.getWeaponArc(number)).name(), + mek.isSecondaryArcWeapon(number) ? 1 : 0); + } + } + } + } + + /** Every hex within {@link #NEAR_RADIUS}, plus the ring at {@link #FAR_RING}. */ + private static List offsets(Coords from) { + List out = new ArrayList<>(); + int reach = FAR_RING; + for (int x = from.getX() - reach; x <= from.getX() + reach; x++) { + for (int y = from.getY() - reach; y <= from.getY() + reach; y++) { + Coords to = new Coords(x, y); + int distance = from.distance(to); + if (distance == 0) { + continue; + } + if (distance <= NEAR_RADIUS || distance == FAR_RING) { + out.add(to); + } + } + } + return out; + } + + private SdsArcs() { + } +} diff --git a/crates/sds-core/src/arc.rs b/crates/sds-core/src/arc.rs new file mode 100644 index 0000000..3bbd6c9 --- /dev/null +++ b/crates/sds-core/src/arc.rs @@ -0,0 +1,638 @@ +//! Weapon arcs, ported from MegaMek's `ComputeArc` and `FacingArc`. +//! +//! The bot has to answer "which of my weapons would bear on that hex if I +//! stood here facing that way", for every candidate state against every enemy +//! position, before it spends anything on statistics. The server will not +//! answer that question about a hex the unit is not standing in, so the bot +//! computes it, and a port that is one degree off silently offers shots that +//! do not exist. +//! +//! **This is not a function of the six hexsides.** MegaMek takes the +//! continuous bearing between the two hexes' ideal centres, subtracts the +//! firing facing's angle, and asks whether the remainder falls inside the +//! arc's window. A target ten hexes away sits at a bearing no hexside has, and +//! rounding it to a hexside moves it across an arc edge. +//! +//! # Integers, not `atan` +//! +//! Every arc boundary is a multiple of 30 degrees. From `IdealHex` the centres +//! are `cx = X_CONST * (3x + 2)` and `cy = 2y + (x & 1) + 1` with `X_CONST = +//! tan(PI/6) = 1/sqrt(3)`, so the bearing satisfies +//! +//! ```text +//! tan(theta) = sqrt(3) * u / q u = dx, q = -(2*dy + delta) +//! ``` +//! +//! where `delta` is the difference of the two columns' parities. `tan(30) = +//! 1/sqrt(3)` and `tan(60) = sqrt(3)`, so the `sqrt(3)` cancels on both sides +//! of every boundary comparison and what is left is integer arithmetic on +//! `(u, q)`. [`bearing`] returns which 30-degree sector the target is in and +//! whether it sits exactly on the edge; no arc boundary falls inside a sector, +//! so that is all an arc test needs. +//! +//! MegaMek rounds its bearing to a whole degree before testing the interval, +//! which puts a bearing a fraction outside an arc onto its edge. This port +//! does not, and the two agree on every target within **49 columns and rows** +//! of the attacker - more than a two-sheet board is wide. Past that they can +//! differ by one arc, so check before using this on a map that big. +//! +//! Checked against `bridge/sds/SdsArcs.java`'s dump by +//! `tests/arc_corpus.rs`, exhaustively. The dump is not committed; that file +//! says how to make one. + +use std::cmp::Ordering; + +use crate::wire::Coord; + +/// The firing arcs a ground unit can mount. +/// +/// MegaMek's `FacingArc` has fifty-odd; the aerospace, capital, VGL and +/// building arcs are not here, because nothing in this repository fields a +/// unit that has one. The discriminants are MegaMek's own arc codes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Arc { + All = 0, + Forward = 1, + LeftArm = 2, + RightArm = 3, + Rear = 4, + LeftSide = 5, + RightSide = 6, + MainGun = 7, + Turret = 25, +} + +impl Arc { + /// The window this arc covers, relative to the firing facing. + /// + /// `(start, end, start inclusive, end inclusive)` in degrees, wrapping + /// through zero when `start > end`. **The ends are not uniformly + /// inclusive**, and not in a way any rule states: MegaMek gives each arc + /// its own predicate, and the four arcs that partition the circle - the + /// forward, the two sides and the rear - are closed and open exactly + /// where they must be to tile it without overlapping. Read out of + /// `FacingArc.isInsideArc` a degree at a time, not inferred from the + /// bounds. + /// + /// Every bound is a multiple of 30, which is what makes the integer + /// bearing test above possible. + const fn window(self) -> (i32, i32, bool, bool) { + match self { + Arc::All => (0, 360, true, true), + Arc::Forward => (300, 60, true, true), + Arc::LeftArm => (240, 60, true, true), + Arc::RightArm => (300, 120, true, true), + Arc::Rear => (120, 240, false, false), + Arc::LeftSide => (240, 300, true, false), + Arc::RightSide => (60, 120, false, true), + Arc::MainGun => (240, 120, true, true), + Arc::Turret => (330, 30, true, true), + } + } + + /// The arc's bounds as MegaMek declares them, ends aside. + /// + /// `FacingArc.getStartAngle` and `getEndAngle`. + pub const fn bounds(self) -> (i32, i32) { + let (start, end, _, _) = self.window(); + (start, end) + } + + /// MegaMek's arc code, the integer `Compute.ARC_*` names. + pub const fn code(self) -> i32 { + self as i32 + } + + /// Every arc a ground unit can mount, in MegaMek's declaration order. + pub const ALL: [Arc; 9] = [ + Arc::All, + Arc::Forward, + Arc::LeftArm, + Arc::RightArm, + Arc::Rear, + Arc::LeftSide, + Arc::RightSide, + Arc::MainGun, + Arc::Turret, + ]; + + /// The same window, counted in 30-degree sectors instead of degrees. + /// + /// The hot path works in sectors from end to end, so the division by 30 + /// happens here, at compile time, rather than once per call. + const fn sector_window(self) -> (i32, i32, bool, bool) { + let (start, end, start_closed, end_closed) = self.window(); + (start / 30, end / 30, start_closed, end_closed) + } + + /// Does this arc hold a bearing already relative to the firing facing? + /// + /// [`Bearing`] carries no angle, only which 30-degree sector and whether + /// the target is exactly on its edge, which is everything an arc whose + /// bounds are multiples of 30 can ask. + pub fn contains(self, bearing: Bearing) -> bool { + self.holds(Sector::from(bearing)) + } + + /// The hot path: no degrees, no enum, no division. + fn holds(self, sector: Sector) -> bool { + let (start, end, start_closed, end_closed) = self.sector_window(); + let after = match sector.against(start) { + Ordering::Less => false, + Ordering::Equal => start_closed, + Ordering::Greater => true, + }; + let before = match sector.against(end) { + Ordering::Greater => false, + Ordering::Equal => end_closed, + Ordering::Less => true, + }; + if start > end { + after || before + } else { + after && before + } + } + + /// Does this arc hold a whole-degree bearing? + /// + /// `FacingArc.isInsideArc(int)`, for the rounded degrees MegaMek works + /// in. Not used to decide anything - [`contains`](Arc::contains) is - but + /// it is what the dumped window table is checked against, and it reads the + /// same `window` so the two cannot drift apart. + pub fn contains_relative(self, degrees: i32) -> bool { + let (start, end, start_closed, end_closed) = self.window(); + let after = if start_closed { + degrees >= start + } else { + degrees > start + }; + let before = if end_closed { + degrees <= end + } else { + degrees < end + }; + if start > end { + after || before + } else { + after && before + } + } +} + +/// Where a target sits, to the precision an arc test needs. +/// +/// Either exactly on a multiple of 30 degrees, or strictly inside one of the +/// twelve 30-degree sectors. No arc boundary falls inside a sector, so nothing +/// finer can change an answer - and everything coarser loses the boundary +/// cases, which are most of what an arc test is. +/// +/// Clockwise from straight ahead, as MegaMek's bearings are. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Bearing { + /// Exactly this many degrees: `0`, `30`, ... `330`. + Exactly(i32), + /// Strictly between these two, which are 30 apart. + Between(i32, i32), +} + +impl From for Bearing { + fn from(sector: Sector) -> Self { + let low = sector.index * 30; + if sector.exact { + Bearing::Exactly(low) + } else { + Bearing::Between(low, low + 30) + } + } +} + +/// The bearing as the arithmetic actually carries it. +/// +/// Which of the twelve 30-degree sectors, and whether the target sits exactly +/// on that sector's lower edge. [`Bearing`] is this in degrees, and is what +/// callers and tests read; the deciding path never leaves this form, so a +/// facing's rotation is an integer subtraction and an arc's bound is a +/// compile-time constant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Sector { + /// `0..12`, clockwise from straight ahead. + index: i32, + /// On the edge at `30 * index`, rather than inside the sector above it. + exact: bool, +} + +impl Sector { + fn exactly(index: i32) -> Self { + Sector { + index: index.rem_euclid(12), + exact: true, + } + } + + fn between(index: i32) -> Self { + Sector { + index: index.rem_euclid(12), + exact: false, + } + } + + /// Turn `steps` sectors of 30 degrees anticlockwise. + fn turned(self, steps: i32) -> Self { + Sector { + index: (self.index + steps).rem_euclid(12), + exact: self.exact, + } + } + + /// Where this sits against a boundary counted in sectors. + fn against(self, bound: i32) -> Ordering { + if self.exact { + self.index.cmp(&bound) + } else if self.index < bound { + // The open sector `(index, index + 1)` is below a boundary only + // when the boundary is at or past its top. + Ordering::Less + } else { + Ordering::Greater + } + } +} + +impl From for Sector { + fn from(bearing: Bearing) -> Self { + match bearing { + Bearing::Exactly(degrees) => Sector::exactly(degrees / 30), + Bearing::Between(low, _) => Sector::between(low / 30), + } + } +} + +/// `(u, q)`: the target's offset in the form the bearing is a function of. +/// +/// `u` is the column offset and `q` is twice the row offset the other way up, +/// corrected for the two columns' parities - MegaMek's ideal-hex centres put +/// odd columns half a row down, which is why the same offset gives a different +/// bearing from an even column and an odd one, and why an arc port that +/// forgets parity is right about half the board. +fn offset(from: Coord, to: Coord) -> (i64, i64) { + let parity = i64::from(to.x.rem_euclid(2) - from.x.rem_euclid(2)); + let u = i64::from(to.x - from.x); + let q = -(2 * i64::from(to.y - from.y) + parity); + (u, q) +} + +/// The bearing from `from` to `to`, clockwise from north. +/// +/// `Coords.degree`, to the precision an arc test needs and without its +/// floating point. +/// +/// The twelve boundary directions are `(sin b, cos b)` for `b` a multiple of +/// 30, and the target's direction is `(sqrt(3) * u, q)`. Every cross product +/// between them is a positive multiple of an integer in `u` and `q`, so the +/// sign of that integer says which side of the boundary the target is on: +/// +/// ```text +/// 0: u 30: 3u - q 60: u - q 90: -q +/// 120: -(u + q) 150: -(3u + q) +/// ``` +/// +/// and the other six are these negated. Sign positive means the bearing is +/// past that boundary, zero means exactly on it. Half the circle is the other +/// half with `(u, q)` negated, so only the first six are needed and the +/// bearing falls out in at most four comparisons. +/// +/// The same hex answers 270 degrees, which is what MegaMek's equal-centres +/// branch returns; callers do not ask, but a silent zero there would be a +/// different wrong answer. +pub fn bearing(from: Coord, to: Coord) -> Bearing { + Bearing::from(sector(from, to)) +} + +/// [`bearing`], in the form the deciding path uses. +fn sector(from: Coord, to: Coord) -> Sector { + let (u, q) = offset(from, to); + if u == 0 { + return match q.cmp(&0) { + Ordering::Greater => Sector::exactly(0), + Ordering::Less => Sector::exactly(6), + Ordering::Equal => Sector::exactly(9), + }; + } + // The southern half is the northern half turned about, so turn it and + // put the half turn back at the end. + let (half, u, q) = if u < 0 { (6, -u, -q) } else { (0, u, q) }; + + match q.cmp(&0) { + // Due east of us, exactly. + Ordering::Equal => Sector::exactly(half + 3), + // Ahead and to the right: somewhere in the first quarter. + Ordering::Greater => match (3 * u - q).cmp(&0) { + Ordering::Less => Sector::between(half), + Ordering::Equal => Sector::exactly(half + 1), + Ordering::Greater => match (u - q).cmp(&0) { + Ordering::Less => Sector::between(half + 1), + Ordering::Equal => Sector::exactly(half + 2), + Ordering::Greater => Sector::between(half + 2), + }, + }, + // Behind and to the right: the second quarter. + Ordering::Less => match (-(u + q)).cmp(&0) { + Ordering::Less => Sector::between(half + 3), + Ordering::Equal => Sector::exactly(half + 4), + Ordering::Greater => match (-(3 * u + q)).cmp(&0) { + Ordering::Less => Sector::between(half + 4), + Ordering::Equal => Sector::exactly(half + 5), + Ordering::Greater => Sector::between(half + 5), + }, + }, + } +} + +/// The bearing to `to`, relative to a unit at `from` facing `facing`. +/// +/// `UnitPosition.relativeDotProduct`: the bearing less the facing's angle. A +/// facing is two sectors, so the correction is a rotation of the sector index +/// and touches nothing else. +pub fn relative_bearing(from: Coord, facing: i32, to: Coord) -> Bearing { + Bearing::from(relative_sector(from, facing, to)) +} + +/// [`relative_bearing`], in the form the deciding path uses. +fn relative_sector(from: Coord, facing: i32, to: Coord) -> Sector { + sector(from, to).turned(-2 * facing) +} + +/// Does a weapon in `arc`, on a unit at `from` whose firing facing is +/// `facing`, bear on `to`? +/// +/// `ComputeArc.isInArc(Coords, int, Coords, int)`. `facing` is the *firing* +/// facing: for a weapon the torso twist applies to that is the secondary +/// facing, and for one it does not - a leg weapon - it is the unit's facing. +/// [`firing_facing`] picks between them. +pub fn is_in_arc(from: Coord, facing: i32, to: Coord, arc: Arc) -> bool { + arc.holds(relative_sector(from, facing, to)) +} + +/// A Mek location, in MegaMek's own numbering. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum MekLocation { + Head = 0, + CenterTorso = 1, + RightTorso = 2, + LeftTorso = 3, + RightArm = 4, + LeftArm = 5, + RightLeg = 6, + LeftLeg = 7, +} + +impl MekLocation { + pub const ALL: [MekLocation; 8] = [ + MekLocation::Head, + MekLocation::CenterTorso, + MekLocation::RightTorso, + MekLocation::LeftTorso, + MekLocation::RightArm, + MekLocation::LeftArm, + MekLocation::RightLeg, + MekLocation::LeftLeg, + ]; + + /// MegaMek's location abbreviation, as the dump prints it. + pub const fn abbreviation(self) -> &'static str { + match self { + MekLocation::Head => "HD", + MekLocation::CenterTorso => "CT", + MekLocation::RightTorso => "RT", + MekLocation::LeftTorso => "LT", + MekLocation::RightArm => "RA", + MekLocation::LeftArm => "LA", + MekLocation::RightLeg => "RL", + MekLocation::LeftLeg => "LL", + } + } +} + +/// Which arc a Mek weapon fires in. +/// +/// `Mek.getWeaponArc`. Rear-mounted beats everything; flipped arms turn both +/// arm arcs to the rear; everything else is forward. +pub fn weapon_arc(location: MekLocation, rear_mounted: bool, arms_flipped: bool) -> Arc { + if rear_mounted { + return Arc::Rear; + } + match location { + MekLocation::RightArm if arms_flipped => Arc::Rear, + MekLocation::RightArm => Arc::RightArm, + MekLocation::LeftArm if arms_flipped => Arc::Rear, + MekLocation::LeftArm => Arc::LeftArm, + _ => Arc::Forward, + } +} + +/// Does the torso twist move this weapon? +/// +/// `Mek.isSecondaryArcWeapon`: everything but the legs. A leg weapon fires +/// forward like a torso weapon and stays put when the torso turns, which is +/// the case a port written from the arc alone gets wrong. +pub const fn uses_secondary_facing(location: MekLocation) -> bool { + !matches!(location, MekLocation::RightLeg | MekLocation::LeftLeg) +} + +/// The facing a weapon in `location` actually fires along. +/// +/// `ComputeArc.getFacing`, for the Mek case. +pub fn firing_facing(facing: i32, secondary_facing: i32, location: MekLocation) -> i32 { + if uses_secondary_facing(location) { + secondary_facing + } else { + facing + } +} + +/// Is `secondary` a torso twist a standing, unquirked Mek may take? +/// +/// `Mek.isValidSecondaryFacing`. One hexside either way, or none. +/// `extended` is the `ext_twist` quirk, which widens it to two - dumped +/// because the flag exists, not because anything fields it today. +/// +/// A Mek that is prone, bracing, already twisted, or carries `no_twist` +/// cannot twist at all; those are unit state the observation carries, and +/// [`can_twist`] is where they are applied. +pub fn is_valid_secondary_facing(facing: i32, secondary: i32, extended: bool) -> bool { + let delta = (secondary - facing).rem_euclid(6); + if extended { + matches!(delta, 0 | 1 | 2 | 4 | 5) + } else { + matches!(delta, 0 | 1 | 5) + } +} + +/// The nearest legal twist to `requested`. +/// +/// `Mek.clipSecondaryFacing`. `can_twist` false pins it to the unit's facing; +/// otherwise a request three or more hexsides right clips left and anything +/// else clips right, and `ext_twist` takes the far side two hexsides instead +/// of one. +pub fn clip_secondary_facing(facing: i32, requested: i32, can_twist: bool, extended: bool) -> i32 { + if can_twist && is_valid_secondary_facing(facing, requested, extended) { + return requested.rem_euclid(6); + } + if !can_twist { + return facing.rem_euclid(6); + } + let delta = (requested + 6 - facing).rem_euclid(6); + if delta == 3 && extended { + return (facing + 2).rem_euclid(6); + } + if delta >= 3 { + (facing + 5).rem_euclid(6) + } else { + (facing + 1).rem_euclid(6) + } +} + +/// May this unit twist at all? +/// +/// `Mek.canChangeSecondaryFacing`. Kept separate from the geometry so the +/// caller states the unit's condition rather than passing a bare flag. +pub const fn can_twist(prone: bool, bracing: bool, already_twisted: bool, no_twist: bool) -> bool { + !no_twist && !prone && !bracing && !already_twisted +} + +/// The twists available from `facing`, in ascending order. +pub fn twists(facing: i32, can_twist: bool, extended: bool) -> Vec { + if !can_twist { + return vec![facing.rem_euclid(6)]; + } + let reach = if extended { 2 } else { 1 }; + let mut out: Vec = (-reach..=reach) + .map(|delta| (facing + delta).rem_euclid(6)) + .collect(); + out.sort_unstable(); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The adjacent hexes sit exactly on the six even boundaries, from both + /// column parities. If parity were dropped this is the first thing to go. + #[test] + fn a_neighbour_is_exactly_on_a_hexside() { + for x in [8, 9] { + let from = Coord::new(x, 8); + for direction in 0..6 { + let to = crate::hex::translated(from, direction, 1); + assert_eq!( + bearing(from, to), + Bearing::Exactly(direction * 60), + "{from:?} direction {direction}" + ); + } + } + } + + /// A unit with only forward weapons, facing directly away, bears nothing. + #[test] + fn facing_away_bears_nothing() { + let from = Coord::new(8, 8); + // Directly north of the attacker, which faces south. + let to = Coord::new(8, 3); + assert_eq!(bearing(from, to), Bearing::Exactly(0)); + assert!(!is_in_arc(from, 3, to, Arc::Forward)); + assert!(is_in_arc(from, 0, to, Arc::Forward)); + } + + /// One hexside of turn moves every bearing by exactly one hexside. + #[test] + fn a_turn_moves_every_bearing_by_a_hexside() { + let from = Coord::new(8, 8); + for facing in 0..6 { + for right in [-1i32, 1] { + let turned = facing + right; + for x in 0..17 { + for y in 0..17 { + let to = Coord::new(x, y); + if to == from { + continue; + } + let straight = Sector::from(relative_bearing(from, facing, to)); + let after = relative_bearing(from, turned, to); + assert_eq!(after, Bearing::from(straight.turned(-2 * right))); + } + } + } + } + } + + /// Six turns return to the start, and the arc set with them. + #[test] + fn six_turns_return() { + let from = Coord::new(9, 9); + let to = Coord::new(12, 4); + for arc in Arc::ALL { + let straight = is_in_arc(from, 0, to, arc); + assert_eq!(is_in_arc(from, 6, to, arc), straight); + } + } + + /// The two entry points into a window cannot drift apart. + /// + /// [`Arc::contains`] decides and [`Arc::contains_relative`] is what the + /// dumped table is checked against; on a whole degree that is a multiple + /// of 30, and on the degree either side of one, they must agree. + #[test] + fn the_two_window_tests_agree() { + for arc in Arc::ALL { + for sector in 0..12 { + let degrees = sector * 30; + assert_eq!( + arc.contains(Bearing::Exactly(degrees)), + arc.contains_relative(degrees), + "{arc:?} at {degrees}" + ); + assert_eq!( + arc.contains(Bearing::Between(degrees, degrees + 30)), + arc.contains_relative(degrees + 15), + "{arc:?} inside {degrees}" + ); + } + } + } + + /// `ARC_360` holds everything, and the four arcs that tile the circle do + /// so exactly once - which is what the ends' mixed inclusivity is for. + #[test] + fn windows_wrap() { + assert!(Arc::All.contains(Bearing::Exactly(0))); + assert!(Arc::All.contains(Bearing::Between(330, 360))); + assert!(Arc::Forward.contains(Bearing::Exactly(0))); + assert!(Arc::Forward.contains(Bearing::Exactly(60))); + assert!(Arc::Forward.contains(Bearing::Exactly(300))); + assert!(!Arc::Forward.contains(Bearing::Between(60, 90))); + assert!(!Arc::Forward.contains(Bearing::Between(270, 300))); + + for sector in 0..12 { + for position in [ + Bearing::Exactly(sector * 30), + Bearing::Between(sector * 30, sector * 30 + 30), + ] { + let covering = [Arc::Forward, Arc::RightSide, Arc::Rear, Arc::LeftSide] + .into_iter() + .filter(|arc| arc.contains(position)) + .count(); + assert_eq!(covering, 1, "{position:?} is covered {covering} times"); + } + } + } + + #[test] + fn legs_do_not_twist() { + assert!(!uses_secondary_facing(MekLocation::LeftLeg)); + assert_eq!(firing_facing(0, 1, MekLocation::LeftLeg), 0); + assert_eq!(firing_facing(0, 1, MekLocation::LeftTorso), 1); + } +} diff --git a/crates/sds-core/src/lib.rs b/crates/sds-core/src/lib.rs index 31cb87a..1789770 100644 --- a/crates/sds-core/src/lib.rs +++ b/crates/sds-core/src/lib.rs @@ -9,6 +9,7 @@ //! Where the I/O lives instead: `sds-node` for talking to other thinkers, //! `sds-bot` for talking to the match host. +pub mod arc; pub mod ev; pub mod explore; pub mod features; diff --git a/crates/sds-core/tests/arc_corpus.rs b/crates/sds-core/tests/arc_corpus.rs new file mode 100644 index 0000000..82109d9 --- /dev/null +++ b/crates/sds-core/tests/arc_corpus.rs @@ -0,0 +1,393 @@ +//! The Rust weapon arcs against MegaMek's, row for row. +//! +//! `tests/corpus/arcs.txt` is written by `bridge/sds/SdsArcs.java`, which +//! walks MegaMek's own `ComputeArc.isInArc` over every relative offset within +//! 10 hexes of the attacker plus the whole ring at 17, both column parities, +//! all six facings, and all nine arcs a ground unit can mount. +//! +//! **The corpus is not in the repository.** It is 9375 lines, and committed it +//! pushed the rendered pull request past the 8 MB a Tangled client will read. +//! It is deterministic, so it is regenerated instead: +//! +//! ```text +//! ./sds.sh arc-dump --out crates/sds-core/tests/corpus/arcs.txt +//! ``` +//! +//! **What that costs, plainly: the arc port is no longer checked against +//! MegaMek on a fresh clone, or anywhere without MegaMek and Docker.** Every +//! test below reads the corpus, and they fail rather than skip when it is +//! absent - loudly, and naming the command that makes it - so a green run +//! still means the port was checked. But the check now depends on someone +//! having run the dump, and there is no CI here that would. +//! +//! Nothing samples here and nothing is hand-written. The corpus is the whole +//! finite space it covers, so a port that is right on the hexsides and wrong +//! three degrees either side of one fails on the row where that happens +//! rather than in a win rate two hundred games later. + +use std::collections::BTreeSet; +use std::path::Path; +use std::sync::OnceLock; + +use sds_core::arc::{ + self, can_twist, clip_secondary_facing, is_valid_secondary_facing, weapon_arc, Arc, Bearing, + MekLocation, +}; +use sds_core::wire::Coord; + +/// The corpus, read at run time rather than compiled in. +/// +/// `include_str!` would fail the build on a machine that has not run the dump, +/// with a message about a missing file and nothing about how to make one. This +/// fails the tests instead, and says what to run. +fn corpus() -> &'static str { + static CORPUS: OnceLock = OnceLock::new(); + CORPUS + .get_or_init(|| { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/arcs.txt"); + std::fs::read_to_string(&path).unwrap_or_else(|error| { + panic!( + "\n\n\ + the weapon arc corpus is missing, so the arc port is UNVERIFIED.\n\ + {}: {error}\n\n\ + It is deliberately not committed - 9375 lines of it broke Tangled's\n\ + pull rendering - and it is deterministic, so regenerate it:\n\n \ + ./sds.sh arc-dump --out crates/sds-core/tests/corpus/arcs.txt\n\n\ + That needs Docker and an extracted MegaMek under MM_HOME\n\ + (default ~/.cache/mul-build/megamek), and `./scripts/build.sh`\n\ + to have built bridge/build/sds.jar. It takes a few seconds.\n", + path.display() + ) + }) + }) + .as_str() +} + +/// The attacker hex the dump used, per column parity. +/// +/// `SdsArcs.ORIGIN`. Any hex would do for the offsets, but the parity of `x` +/// would not: an odd column shifts the hex centre half a row, so the same +/// offset gives a different bearing from an even column and an odd one. +fn origin(parity: i32) -> Coord { + Coord::new(40 + parity, 40) +} + +fn arc_by_name(name: &str) -> Arc { + match name { + "ARC_360" => Arc::All, + "ARC_FORWARD" => Arc::Forward, + "ARC_LEFT_ARM" => Arc::LeftArm, + "ARC_RIGHT_ARM" => Arc::RightArm, + "ARC_REAR" => Arc::Rear, + "ARC_LEFT_SIDE" => Arc::LeftSide, + "ARC_RIGHT_SIDE" => Arc::RightSide, + "ARC_MAIN_GUN" => Arc::MainGun, + "ARC_TURRET" => Arc::Turret, + other => panic!("{other}: not an arc this port carries"), + } +} + +fn location_by_abbreviation(abbreviation: &str) -> MekLocation { + MekLocation::ALL + .into_iter() + .find(|location| location.abbreviation() == abbreviation) + .unwrap_or_else(|| panic!("{abbreviation}: not a Mek location")) +} + +fn rows(kind: &str) -> impl Iterator> { + let kind = kind.to_string(); + corpus() + .lines() + .filter(move |line| line.split(' ').next() == Some(kind.as_str())) + .map(|line| line.split(' ').skip(1).collect()) +} + +/// The corpus is what it claims to be, before anything is compared to it. +/// +/// A dump that silently wrote nothing would otherwise pass every test below. +#[test] +fn corpus_is_exhaustive() { + let arcs = rows("arc").count(); + let degrees = rows("degree").count(); + let bears = rows("bears").count(); + let twists = rows("twist").count(); + let weapons = rows("weapon").count(); + + assert_eq!(arcs, Arc::ALL.len(), "one row per arc"); + assert_eq!(bears, degrees * 6, "six facings per offset"); + assert_eq!(twists, 36, "six facings by six secondary facings"); + assert_eq!( + weapons, + 8 * 2 * 2, + "location by rear-mounted by arms-flipped" + ); + // Every hex within ten (331), plus the ring at seventeen (102), per + // parity. Exact rather than "enough": a corpus that shrank would + // otherwise still pass everything below it. + assert_eq!(degrees, (331 - 1 + 102) * 2, "the swept offsets"); + assert_eq!( + rows("window").count(), + Arc::ALL.len() * 361, + "every degree of every arc" + ); + + // Both parities, in equal numbers: the odd-column shift is the thing a + // port most often drops, and a corpus with only one parity would not say. + let even = rows("degree").filter(|row| row[0] == "0").count(); + assert_eq!(even * 2, degrees, "both column parities, equally"); +} + +/// Every arc's membership, one whole degree at a time. +/// +/// The reason this table exists: MegaMek's arcs are not uniformly closed at +/// their ends, so a port that reads the bounds and picks one convention is +/// wrong on four boundary degrees and right everywhere else. +#[test] +fn window_membership_matches() { + for row in rows("window") { + let arc = arc_by_name(row[0]); + let degrees: i32 = row[1].parse().unwrap(); + assert_eq!( + arc.contains_relative(degrees), + row[2] == "1", + "{} at {degrees} degrees", + row[0] + ); + } +} + +/// Every arc's bounds, as MegaMek declares them. +#[test] +fn windows_match() { + for row in rows("arc") { + let arc = arc_by_name(row[0]); + assert_eq!(arc.code(), row[1].parse::().unwrap(), "{}", row[0]); + assert_eq!( + arc.bounds(), + (row[2].parse().unwrap(), row[3].parse().unwrap()), + "{}", + row[0] + ); + } +} + +/// The integer bearing brackets MegaMek's dumped degree, every row. +/// +/// `Exactly(d)` has to be the degree itself, and an open sector has to hold +/// it. This checks the sector against the dump directly rather than through +/// the arcs, so a sector that is one out fails here and says so plainly. +#[test] +fn the_integer_bearing_brackets_the_corpus_degree() { + let mut exact = 0; + let mut between = 0; + for row in rows("degree") { + let parity: i32 = row[0].parse().unwrap(); + let from = origin(parity); + let to = Coord::new( + from.x + row[1].parse::().unwrap(), + from.y + row[2].parse::().unwrap(), + ); + let degrees: i32 = row[3].parse().unwrap(); + let label = format!("parity {parity} offset {},{}", row[1], row[2]); + match arc::bearing(from, to) { + Bearing::Exactly(ours) => { + assert_eq!(ours, degrees % 360, "{label}: exact bearing"); + exact += 1; + } + Bearing::Between(low, high) => { + assert!( + low <= degrees && degrees <= high, + "{label}: {degrees} not in ({low},{high})" + ); + between += 1; + } + } + } + // Both kinds are in the corpus. A sweep that landed only on hexsides + // would test half the code and look like it had tested all of it. + assert!(exact > 0 && between > 0, "{exact} exact, {between} between"); +} + +/// Every arc's membership, for every offset and facing. +#[test] +fn membership_matches() { + let mut checked = 0; + for row in rows("bears") { + let parity: i32 = row[0].parse().unwrap(); + let from = origin(parity); + let to = Coord::new( + from.x + row[1].parse::().unwrap(), + from.y + row[2].parse::().unwrap(), + ); + let facing: i32 = row[3].parse().unwrap(); + + let expected: BTreeSet = if row[4] == "-" { + BTreeSet::new() + } else { + row[4].split(',').map(arc_by_name).collect() + }; + let ours: BTreeSet = Arc::ALL + .into_iter() + .filter(|arc| arc::is_in_arc(from, facing, to, *arc)) + .collect(); + assert_eq!( + ours, expected, + "parity {parity} offset {},{} facing {facing}", + row[1], row[2] + ); + checked += 1; + } + assert!(checked > 0); +} + +/// The torso twist's bounds, and where an illegal request clips to. +#[test] +fn twists_match() { + for row in rows("twist") { + let facing: i32 = row[0].parse().unwrap(); + let secondary: i32 = row[1].parse().unwrap(); + assert_eq!( + is_valid_secondary_facing(facing, secondary, false), + row[2] == "1", + "valid {facing} -> {secondary}" + ); + assert_eq!( + clip_secondary_facing(facing, secondary, true, false), + row[3].parse::().unwrap(), + "clip {facing} -> {secondary}" + ); + // And the table the dump cannot show, because the dump's Mek is + // standing: a Mek that cannot twist has one firing facing, its own. + assert!(!can_twist(true, false, false, false)); + assert!(!can_twist(false, false, false, true)); + assert_eq!( + clip_secondary_facing(facing, secondary, false, false), + facing + ); + } +} + +/// Location to arc, and whether the twist carries it. +#[test] +fn weapon_arcs_match() { + for row in rows("weapon") { + let location = location_by_abbreviation(row[0]); + let rear = row[1] == "1"; + let flipped = row[2] == "1"; + assert_eq!( + weapon_arc(location, rear, flipped), + arc_by_name(row[3]), + "{} rear {rear} flipped {flipped}", + row[0] + ); + assert_eq!( + arc::uses_secondary_facing(location), + row[4] == "1", + "{} secondary facing", + row[0] + ); + } +} + +/// A unit with only forward weapons, facing directly away, bears nothing. +/// +/// Read off the corpus rather than argued: every row MegaMek puts in the rear +/// arc, which is the 120 degrees squarely astern, must be out of the forward +/// arc - in the dump and in the port. +#[test] +fn facing_away_bears_nothing() { + let mut behind = 0; + for row in rows("bears") { + let arcs: BTreeSet<&str> = row[4].split(',').collect(); + if !arcs.contains("ARC_REAR") { + continue; + } + behind += 1; + assert!( + !arcs.contains("ARC_FORWARD"), + "the dump has a rear-arc target in the forward arc" + ); + + let parity: i32 = row[0].parse().unwrap(); + let from = origin(parity); + let to = Coord::new( + from.x + row[1].parse::().unwrap(), + from.y + row[2].parse::().unwrap(), + ); + let facing: i32 = row[3].parse().unwrap(); + assert!( + !arc::is_in_arc(from, facing, to, Arc::Forward), + "our forward arc holds a target squarely astern" + ); + } + assert!(behind > 0, "no case in the corpus is behind the attacker"); +} + +/// One hexside of twist recovers exactly one hexside of arc, and no more. +/// +/// The set of hexes a forward weapon bears on after twisting right by one is +/// exactly the set it would bear on facing one hexside right - not a hex +/// more, not a hex less. This is what "the twist buys you one hexside" means +/// in a form that fails if the twist is applied as anything else. +#[test] +fn one_twist_is_one_hexside() { + for parity in 0..2 { + let from = origin(parity); + let offsets: Vec = rows("degree") + .filter(|row| row[0].parse::().unwrap() == parity) + .map(|row| { + Coord::new( + from.x + row[1].parse::().unwrap(), + from.y + row[2].parse::().unwrap(), + ) + }) + .collect(); + assert!(!offsets.is_empty()); + + for facing in 0..6 { + for twist in [-1i32, 1] { + let secondary = (facing + twist).rem_euclid(6); + assert!(is_valid_secondary_facing(facing, secondary, false)); + + let twisted: BTreeSet<(i32, i32)> = offsets + .iter() + .filter(|to| { + arc::is_in_arc( + from, + arc::firing_facing(facing, secondary, MekLocation::CenterTorso), + **to, + Arc::Forward, + ) + }) + .map(|to| (to.x, to.y)) + .collect(); + let turned: BTreeSet<(i32, i32)> = offsets + .iter() + .filter(|to| arc::is_in_arc(from, secondary, **to, Arc::Forward)) + .map(|to| (to.x, to.y)) + .collect(); + assert_eq!(twisted, turned, "facing {facing} twist {twist}"); + + // And a leg weapon does not move with it. + let legs: BTreeSet<(i32, i32)> = offsets + .iter() + .filter(|to| { + arc::is_in_arc( + from, + arc::firing_facing(facing, secondary, MekLocation::LeftLeg), + **to, + Arc::Forward, + ) + }) + .map(|to| (to.x, to.y)) + .collect(); + let straight: BTreeSet<(i32, i32)> = offsets + .iter() + .filter(|to| arc::is_in_arc(from, facing, **to, Arc::Forward)) + .map(|to| (to.x, to.y)) + .collect(); + assert_eq!(legs, straight, "leg weapon followed the torso twist"); + } + } + } +} diff --git a/sds/cli.py b/sds/cli.py index 7ae226c..951c900 100644 --- a/sds/cli.py +++ b/sds/cli.py @@ -327,6 +327,17 @@ def cmd_los_dump(args: argparse.Namespace) -> int: return code +def cmd_arc_dump(args: argparse.Namespace) -> int: + """Write the corpus the Rust weapon arcs are tested against.""" + from .match import arc_dump + + out = Path(args.out).resolve() + code = arc_dump(out) + if code == 0: + print(f"wrote {out}") + return code + + def cmd_catalogue(args: argparse.Namespace) -> int: """Everything the bot reasons with, read out of the code that defines it.""" from .catalogue import CatalogueError, from_bot, goals, labels, render @@ -700,6 +711,20 @@ def main(argv: list[str] | None = None) -> int: los_dump.add_argument("--cases", type=int, default=600, help="cases per board") los_dump.set_defaults(func=cmd_los_dump) + arc_dump = sub.add_parser( + "arc-dump", + help="dump MegaMek's weapon arc tables, as a Rust test corpus", + description="Reads ComputeArc and FacingArc out exhaustively: every " + "relative offset within ten hexes plus the ring at seventeen, both " + "column parities, all six facings, all nine ground arcs. No board and " + "no match. An offline fixture, committed once.", + ) + arc_dump.add_argument( + "--out", + default=str(REPO / "crates" / "sds-core" / "tests" / "corpus" / "arcs.txt"), + ) + arc_dump.set_defaults(func=cmd_arc_dump) + train = sub.add_parser( "train", help="fit M from a run's decision logs", diff --git a/sds/match.py b/sds/match.py index 617e8f1..946e997 100644 --- a/sds/match.py +++ b/sds/match.py @@ -253,14 +253,14 @@ def run(spec: MatchSpec, out_dir: Path) -> dict: return result -def los_dump(scenarios: list[Path], out: Path, cases: int = 600) -> int: - """Dump `(attacker, target) -> LosEffects` per board, as a test corpus. - - One short JVM, no server and no match: it loads the scenario for its board - and two entities, then walks a fixed set of hex pairs through MegaMek's own - `LosEffects.calculateLOS`. The Rust line of sight is checked against the - file this writes, because a reimplementation that disagrees does not fail - - it plays badly, which reads as a bad bot rather than a bug. +def _bridge(kind: str, main_class: str, arguments: list[str], stdout: Path | None = None) -> int: + """Run one bridge tool in the runner image: one JVM, no server, no match. + + The three corpus dumps and the scenario validator all want the same + container - the repository at /work, MegaMek at /mm, log4j quiet, and the + reflection opens MegaMek's units cache needs - and differ only in the class + and its arguments. `stdout` captures the tool's output to a file for the + ones that print their table rather than writing it. """ if shutil.which("docker") is None: raise MatchError("docker is not on PATH") @@ -270,8 +270,7 @@ def los_dump(scenarios: list[Path], out: Path, cases: int = 600) -> int: if not (REPO / "bridge" / "build" / "sds.jar").is_file(): raise MatchError("bridge/build/sds.jar is missing; run ./scripts/build.sh") - out.parent.mkdir(parents=True, exist_ok=True) - container = f"sds-losdump-{uuid.uuid4().hex[:8]}" + container = f"sds-{kind}-{uuid.uuid4().hex[:8]}" command = [ "docker", "run", @@ -294,17 +293,18 @@ def los_dump(scenarios: list[Path], out: Path, cases: int = 600) -> int: *ADD_OPENS, "-cp", "MegaMek.jar:lib/*:/work/bridge/build/sds.jar", - "sds.SdsLosDump", - "--out", - f"/work/{out.resolve().relative_to(REPO)}", - "--cases", - str(cases), + main_class, + *arguments, ] - command += [f"/work/{path.resolve().relative_to(REPO)}" for path in scenarios] _track(container) try: - completed = subprocess.run(command, check=False) + if stdout is None: + completed = subprocess.run(command, check=False) + else: + stdout.parent.mkdir(parents=True, exist_ok=True) + with stdout.open("wb") as handle: + completed = subprocess.run(command, stdout=handle, check=False) finally: subprocess.run( ["docker", "kill", container], @@ -316,6 +316,32 @@ def los_dump(scenarios: list[Path], out: Path, cases: int = 600) -> int: return completed.returncode +def los_dump(scenarios: list[Path], out: Path, cases: int = 600) -> int: + """Dump `(attacker, target) -> LosEffects` per board, as a test corpus. + + One short JVM, no server and no match: it loads the scenario for its board + and two entities, then walks a fixed set of hex pairs through MegaMek's own + `LosEffects.calculateLOS`. The Rust line of sight is checked against the + file this writes, because a reimplementation that disagrees does not fail - + it plays badly, which reads as a bad bot rather than a bug. + """ + out.parent.mkdir(parents=True, exist_ok=True) + arguments = ["--out", f"/work/{out.resolve().relative_to(REPO)}", "--cases", str(cases)] + arguments += [f"/work/{path.resolve().relative_to(REPO)}" for path in scenarios] + return _bridge("losdump", "sds.SdsLosDump", arguments) + + +def arc_dump(out: Path) -> int: + """Dump MegaMek's weapon arc tables, as a test corpus. + + No board and no scenario: arcs are a function of two hex coordinates and a + facing, so this is pure geometry read out of `ComputeArc` and `FacingArc`. + Exhaustive over what it covers, and it prints its table, so the output is + captured rather than written by the tool. + """ + return _bridge("arcdump", "sds.SdsArcs", [], stdout=out) + + def validate( scenarios: list[Path], report: Path | None = None, @@ -332,58 +358,13 @@ def validate( at 100% BV and is recorded as undecided, which costs a whole game and reads as a harness fault. """ - if shutil.which("docker") is None: - raise MatchError("docker is not on PATH") - mm_home = DEFAULT_MM_HOME - if not (mm_home / "MegaMek.jar").is_file(): - raise MatchError(f"no MegaMek.jar under {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") - report = report or (REPO / "results" / "validate.json") report.parent.mkdir(parents=True, exist_ok=True) - container = f"sds-validate-{uuid.uuid4().hex[:8]}" - command = [ - "docker", - "run", - "--rm", - "--name", - container, - "--user", - f"{os.getuid()}:{os.getgid()}", - "-v", - f"{REPO}:/work", - "-v", - f"{mm_home}:/mm", - "-w", - "/mm", - DEFAULT_IMAGE, - "java", - "-Dlog4j2.configurationFile=/work/bridge/log4j2-quiet.xml", - "-Dsentry.dsn=", - "-Djava.awt.headless=true", - *ADD_OPENS, - "-cp", - "MegaMek.jar:lib/*:/work/bridge/build/sds.jar", - "sds.SdsValidate", - "--out", - f"/work/{report.relative_to(REPO)}", - ] + arguments = ["--out", f"/work/{report.relative_to(REPO)}"] if allow_uncrossable: - command.append("--allow-uncrossable") - command += [f"/work/{path.resolve().relative_to(REPO)}" for path in scenarios] - - _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) + arguments.append("--allow-uncrossable") + arguments += [f"/work/{path.resolve().relative_to(REPO)}" for path in scenarios] + code = _bridge("validate", "sds.SdsValidate", arguments) parsed = json.loads(report.read_text()) if report.is_file() else [] - return completed.returncode, parsed + return code, parsed