From f68e86b65199afbb0f2b47d4f35341fb5cef1cdf Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 3 Sep 2026 23:50:20 -0400 Subject: [PATCH] feat(candidates): a route that ends aboard a carrier `LOAD` appended to a route the search already found, gated on `canCarry`. A carrier that can take this unit makes its hex terminal rather than blocked: stacking says who may share a hex and loading is a separate permission. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I1c8b29dab499b3cabc86769027cdd6485e3561a1 --- bridge/sds/SdsMountProbe.java | 48 +++++++++++++++++++ crates/sds-bot/src/candidates.rs | 76 ++++++++++++++++++++++++++++++- scenarios/tactics/mount-probe.mms | 26 +++++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 scenarios/tactics/mount-probe.mms diff --git a/bridge/sds/SdsMountProbe.java b/bridge/sds/SdsMountProbe.java index 1dafb5c..dc6dd9c 100644 --- a/bridge/sds/SdsMountProbe.java +++ b/bridge/sds/SdsMountProbe.java @@ -212,6 +212,7 @@ public final class SdsMountProbe { for (String[] pair : pairs) { predicate(out, argv[0], pair[0], pair[1]); } + emitted(out, argv[0]); } } @@ -366,6 +367,53 @@ public final class SdsMountProbe { cargo.getClass().getSimpleName(), cargo.getElevation()); } + /** + * What `Observation.unit` actually emits for a carrier and its cargo. + * + * A match log records a trimmed unit, so the absence of a field there says + * nothing about the wire. This asks the emitter. + */ + static void emitted(PrintStream out, String scenario) throws Exception { + MekSummaryCache cache = MekSummaryCache.getInstance(); + megamek.common.scenario.Scenario sc = + new megamek.common.scenario.ScenarioLoader(new java.io.File(scenario)).load(); + Game game = (Game) sc.createGame(); + game.setPhase(megamek.common.enums.GamePhase.MOVEMENT); + MekSummary cm = cache.getMek("Badger (C) Tracked Transport A"); + MekSummary gm = cache.getMek("Clan Heavy Foot Infantry Ebon Keshik Point"); + Entity carrier = new MekFileParser(cm.getSourceFile(), cm.getEntryName()).getEntity(); + Entity cargo = new MekFileParser(gm.getSourceFile(), gm.getEntryName()).getEntity(); + megamek.common.board.Coords at = new megamek.common.board.Coords(3, 3); + megamek.common.Player me = game.getPlayersList().get(0); + for (Entity e : new Entity[] {carrier, cargo}) { + e.setGame(game); + e.setOwner(me); + e.setDeployed(true); + e.setFacing(0); + e.setDone(false); + } + carrier.setId(9301); + cargo.setId(9302); + carrier.setPosition(at); + cargo.setPosition(at.translated(3)); + game.addEntity(carrier); + game.addEntity(cargo); + + Observation obs = new Observation(new com.fasterxml.jackson.databind.ObjectMapper()); + for (Entity e : new Entity[] {carrier, cargo}) { + com.fasterxml.jackson.databind.node.ObjectNode node = obs.unit(game, me, e); + out.printf("emitted %-32s canCarry=%s carrying=%s carriedBy=%s transports=%d%n", + e.getShortName().replace(' ', '_'), + node.has("canCarry") ? node.get("canCarry").toString() : "absent", + node.has("carrying") ? node.get("carrying").toString() : "absent", + node.has("carriedBy") ? node.get("carriedBy").toString() : "absent", + e.getTransports().size()); + out.printf(" stacking %-32s stackEnterable=%s%n", + e.getShortName().replace(' ', '_'), + node.has("stackEnterable") ? node.get("stackEnterable").toString() : "absent"); + } + } + private SdsMountProbe() { } } diff --git a/crates/sds-bot/src/candidates.rs b/crates/sds-bot/src/candidates.rs index eb2317e..d771e1d 100644 --- a/crates/sds-bot/src/candidates.rs +++ b/crates/sds-bot/src/candidates.rs @@ -499,6 +499,70 @@ pub fn generate( }; let charge_count = charges.len() as u64; out.extend(charges); + // **Mounting is a move, so it is a movement candidate.** + // + // MegaMek has no mount action: `LOAD` is a `MoveStepType`, and a route that + // walks into a carrier's hex and ends with one is how a unit gets aboard. + // So this is a route the search already found, with one step appended - + // not a new candidate source. + // + // **Gated on `canCarry`, which is `Entity.canLoad` asked per pair.** The + // bot does not decide whether a carrier has room, or whether a squad may + // use a Mek's handles; MegaMek answers both, per pair, on the wire. + if !me.is_carried() { + for carrier in &friends { + if !carrier.can_carry_unit(me.id) { + continue; + } + let hex = Coord::new(carrier.x, carrier.y); + // A route the search already produced, ending in the carrier's own + // hex. Nothing is invented: if no scored stand is there, the mover + // cannot reach it this turn and there is no candidate to make. + let Some((at, score)) = scored + .iter() + .enumerate() + .filter(|(_, score)| score.reach.stand.hex == hex) + .min_by_key(|(_, score)| score.reach.mp_spent) + else { + continue; + }; + let route_through = if score.reach.jumped { + match air.as_ref() { + Some(found) => found, + None => continue, + } + } else { + match ground.as_ref() { + Some(found) => found, + None => continue, + } + }; + let Some(mut steps) = steps_to(route_through, score.reach.stand) else { + continue; + }; + // A bare `LOAD` with no step before it is refused: the mover has to + // arrive. Measured - `LOAD` alone from an adjacent hex is illegal + // and `FORWARDS+LOAD` is legal at two points. + if steps.is_empty() { + continue; + } + steps.push("LOAD".to_string()); + let Some(mut proposal) = measured.get(at).cloned() else { + continue; + }; + proposal.label = format!("mount{MARK}{}", carrier.name); + proposal.action = Action::Move { + steps, + charge_target: None, + }; + proposal.notes.push(format!( + "{} mp, boards {} at ({}, {})", + score.reach.mp_spent, carrier.name, hex.x, hex.y + )); + out.push(proposal); + } + } + if out.is_empty() { return None; } @@ -639,7 +703,17 @@ fn occupancy(observation: &Observation, unit: &Unit) -> (Vec, Vec) if hex == mine { continue; } - if enterable.contains(&other.id) { + // **A carrier that can take this unit is somewhere it may end up.** + // + // Stacking says who may share a hex; loading is a different permission + // and MegaMek grants it separately - `FORWARDS+LOAD` into the carrier's + // own hex is legal, measured. Without this the hex is blocked, the + // search never reaches it, and a mount candidate has no route to build + // from: the platoon stood beside the transport for a whole match. + // + // Terminal rather than open: the unit ends its move aboard, and nothing + // walks *through* a carrier. + if enterable.contains(&other.id) || other.can_carry_unit(unit.id) { terminal.push(hex); } else { blocked.push(hex); diff --git a/scenarios/tactics/mount-probe.mms b/scenarios/tactics/mount-probe.mms new file mode 100644 index 0000000..bb054c7 --- /dev/null +++ b/scenarios/tactics/mount-probe.mms @@ -0,0 +1,26 @@ +# One carrier, one platoon, and nothing to shoot at. +# +# The acceptance test for mounting, and deliberately the smallest board state +# that can produce one: a Badger tracked transport with an infantry compartment, +# a foot platoon beside it, and a single distant enemy so the match has two +# sides and a reason to end. If the platoon does not end a turn aboard, the +# failure is in one of three places rather than confounded across a lance. +# +# The Badger is the carrier because it is one: an Atlas and a Manticore both +# have `MekArms`, `ClampMountMek`, `RoofRack` and `TankTrailerHitch` and carry +# no infantry at all. 286 designs have an `InfantryCompartment`; most do not. +# +# 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=1 +Name=mount-probe +Description=A transport and a platoon a hex apart: does the platoon get aboard. +BoardWidth=1 +BoardHeight=1 +Maps=GrassLands/16x17 Grasslands Open Terrain 2 +Factions=North,South +Location_North=N +Location_South=S +Unit_North_1=Badger (C) Tracked Transport A,North 1,4,5 +Unit_North_2=Clan Heavy Foot Infantry Ebon Keshik Point,North 2,4,5 +Unit_South_1=Locust LCT-1V,South 1,4,5 -- 2.51.2