--- id: mounting title: A platoon aboard a transport is worth something the basis cannot say status: open dependsOn: [candidates, features] exitCriterion: > A carried unit's worth is scored, and a platoon offered a ride takes it when the ride is worth more than the ground. --- # mounting The route exists and nothing chooses it. `canCarry` is on the wire, a candidate that walks into a carrier's hex and ends with `LOAD` is generated, and in the acceptance scenario it was offered once and declined: | round | the platoon chose | mount offered | |---|---|---| | 1 | stand @ (6,1) | 0 | | 2 | stand @ (5,0) | **1** | | 3-7 | stand / flank | 0 | It is scored as an ordinary stand in the carrier's hex - no line of sight, nothing adjacent - so it loses to every alternative, and by round 3 the platoon had walked away and it was never offered again. ## What a carried unit is worth Two things are measured and **no current column expresses either**: - it **moves at the carrier's speed**. A foot platoon runs 1 and a Badger runs 9, so the ride is worth **nine times its own pace**; a Maxim hover transport is 12. A battle armour squad runs 4, so the same Badger is worth 2.3 - the multiplier is a fact about the pair, not about mounting. - it **cannot be shot at separately**: `Entity.isTargetable` answers false once the transport id is set. **Whether it can fire is unverified.** `isEligibleFor(FIRING)` answers *true* for a carried unit, which is not the same question as having a legal shot, and the difference has not been settled. Do not price a silence that has not been measured. So the trade is at least mobility and protection, bought with something not yet established. The question this epic answers is what that is worth. ## What mounting is **A `MoveStepType`, not an action.** `LOAD`, `MOUNT` and `UNLOAD` are steps, so mounting belongs to the movement decision, and `SdsClient.buildPath` already carries them - `MoveStepType.valueOf` has no whitelist. Measured: `FORWARDS+LOAD` is legal at 2 MP, `MOUNT` at 0, `LOAD` alone from an adjacent hex refused. **Stacking and loading are separate permissions.** A carrier's hex is barred by stacking and open for loading, and MegaMek grants the two independently, so a carrier that can take a unit makes its hex *terminal*: it may end there, and nothing walks through. Without that the search never reaches the carrier and there is no route to append `LOAD` to. **Three predicates, and they are not the same question:** - `Transporter.canLoad` - capacity and type alone, blind to where the machines are. It accepts a foot platoon into a `FixedWingSupport` flying at elevation 999. - `Entity.canLoad` - that plus a position check. The boolean toggles it: `canLoad(e, false)` matches the transporter, `canLoad(e)` applies it. **This is the one to ask.** - `MovePath.isMoveLegal` - neither. It is true for an Atlas that has no handles for the squad, because it validates the path; the load is resolved by the server afterwards. ## Open - [ ] **What a carried unit is worth**, per the trade above. - [ ] **A route cannot name what to load, so the load never happens.** Answered, and it is a protocol gap rather than a generation one. A route is a list of step names, so `SdsClient.buildPath` calls `addStep(MoveStepType)` and never the `(MoveStepType, Targetable)` overload that names the carrier. Measured: a bare `LOAD` step reports `target=null` and the same step given the carrier reports it by name - **and both are `isMoveLegal` true**, which is why nothing caught it. In a match the platoon's menu narrowed to the single mount candidate, it chose it, it walked into the carrier's hex, and `carriedBy` stayed empty. Fixing it means a step that can carry a target id. - [ ] **What a carried unit may do**, once one can be carried. ## Getting off is the carrier's decision **A carried unit gets no turn**, so nothing asks it where it wants to be put down: in a match, from the round after it mounts, only the carrier is ever offered a movement decision. `UNLOAD` is measured to match - it is the *carrier's* step, and every form of it on the passenger's own path is refused: | path | form | legal | mp | |---|---|---|---| | carrier | `UNLOAD` bare | no | - | | carrier | `UNLOAD` naming the passenger | **yes** | **0** | | carrier | `FORWARDS` then `UNLOAD` naming the passenger | **yes** | 1 | | passenger | any form | no | - | **Three steps, three different rules**, which is why each was measured rather than reasoned from the last: `LOAD` is refused from an adjacent hex and `MOVE_ILLEGAL` from inside the carrier's own; `MOUNT` is the passenger's, legal from beside the carrier, and free; `UNLOAD` is the carrier's, needs the passenger named, is free, and is legal both standing still and after driving. So a drop costs the carrier nothing but the ground it covers, and the carrier can move and then put its passenger down. ### What a drop does, measured **The named hex is honoured.** The carrier sent `{"name":"UNLOAD","target":1,"hex":{"x":3,"y":1}}` from (3,2) and the passenger was on (3,1) in the next frame, twice in one match. So a drop location is chosen rather than decided by the rules, and `SafeDropoff` has something to choose between. **A dropped unit gets a movement turn the following round.** Dropped in round 2's movement phase, it was asked for a movement decision in round 3. **A unit dropped in the movement phase gets neither a firing turn nor a movement turn that round.** Measured on `scenarios/tactics/mount-close.mms`, where the same platoon is asked for firing decisions with a menu of seven in five other rounds - so the absence in the drop round is the drop and not a lack of targets: | round | passenger's movement turn | passenger's firing turn | |---|---|---| | 1-3 | yes | yes, 7 candidates | | 4 | yes (it mounts) | - | | **5** | **none - the carrier unloads it** | **none** | | 6 | yes | yes, 7 candidates | Two instruments failed before this one and both failed on the target rather than the mechanism: a Locust that stayed out of the platoon's range all match, and a gun emplacement that was destroyed in round one and stalled the match. An UrbanMech walks 2 and survives. **The jar agrees, and names the mechanism.** `MovePathHandler.processSteps` sends an `UNLOAD` step to `TWGameManager.unloadUnit`, which reaches `Entity.setTransportId(-1)`; that one setter sets both `unloadedThisTurn` and `done`. `isSelectableThisTurn` is false while either holds, and `determineTurnOrder` allocates turns only to entities that pass it, so no firing turn is generated. `isEligibleForMovement` tests `unloadedThisTurn` directly. `resetEntityPhase` clears `done` between phases but not `unloadedThisTurn`, which is cleared only in `Entity.newRound` - which is why the flag survives movement into firing. The one exception is a grounded Small Craft unloading something that is not infantry: that branch clears both flags and grants a movement turn at half MP. It grants movement, not shooting. **This is what MegaMek does. Whether it matches the rules is a question for jmm.** In particular, a dropped unit shooting the round it is put down is a thing the code refuses in every branch, and it is worth confirming that is the rule rather than an implementation choice. So a drop is positional: what the passenger gets is a place to be at the start of its next turn, and `SafeDropoff` is about where it wants to *stand*, not where it wants to shoot from this phase. **This is what MegaMek does. Whether it matches the rules is not established here.** ### Four duties and two profiles, one mechanism `TaskOverride` and `TacticOverride` now carry `Spot`, `Board`, `Carry`, `Deliver`, and the `Embark` and `SafeDropoff` profiles. **None of them needed new machinery.** `Spot` was expressible in existing columns, `Embark` found the basis gap and recorded it, and the drop-off column is the one addition jmm has sanctioned. Four consumers of one design is a better answer to "can tactics be built from what exists" than any single tactic working. ### The algorithm, as jmm gave it - Calculate eligible moves for the carrier with normal feature scoring - From that set, calculate all eligible drop-off points for the passenger (typically a subset of moved locations + possibly some adjacency superset depending on type of dismount) - For the set of all possible drop-off points, score the passenger's evaluation of where it would LIKE to go - For all scored drop-off points, they give their score to the hex the carrier would have to walk to, in order to use that drop-off point - This is now a per-hex feature for the carrier, which is 0 in hexes that aren't drop-off eligible, and some normalized-to-1 as "best dropoff I could ask for" - The carrier has a TacticsOverride to have it even consider that feature which would normally be weighted 0 **It is a back-projection.** The passenger's valuation of a drop hex flows *backwards* through the reachability relation onto the carrier's move hex that would enable it. The carrier is not evaluating the passenger; the passenger's own scoring is being projected. **And it is a new column**, which is worth stating rather than slipping past: `Spot` needed none, `Embark` found a gap and recorded it, and this one jmm has explicitly sanctioned. ### The clamp is the hard part > The biggest issue with this is that we have to ensure the feature stays 0 if > none of the drop-off points are any good for the carried unit at this time. > That's how you don't have 3-range infantry mount, and then immediately > dismount next turn not being any closer to the enemy. If none of their hexes > would lead to good shots they should score the hex 0 as a clamp. **A relative normalisation defeats this outright.** "Normalised to 1 as the best drop-off I could ask for", read as *best within this set*, makes the least-bad drop of a uniformly bad set score 1 - and the platoon dismounts pointlessly, which is the anti-pattern the clamp exists to stop. The reference has to be absolute: what a good position is *for that unit*, not the best of what is on offer. **`range_band_fit` is the measure, and two halves of it had to be fixed before it could read.** Both were found by using it, and both were the same defect in different places: a conventional platoon's numbers are not on its weapons. - **The damage.** `projected_damage` summed `weapon.damage`, and `WeaponType.getDamage()` answers a sentinel for an infantry weapon - `-15.0` for a point that delivers 3.00. `ConvInfantry.getDamagePerTrooper()` times the trooper count is the real figure, it is on the wire as `infantryDamage`, and `projected_damage` reads it. - **The reach.** Fixing the damage moved the column not at all, because `projected_damage` still asked `brackets::projected` for the shot - and that refuses a weapon whose bracket damage is nought *before* it reports the range. Every bin on a conventional infantry weapon is nought, so the rifle that does the shooting was thrown away at the damage gate. Measured off the wire, the 25-trooper Motorized Infantry platoon's whole armament: | weapon | usable | short | medium | long | damage bins | |---|---|---|---|---|---| | Rifle (Bolt-Action) | yes | 0 | 0 | **3** | 0.00 / 0.00 / 0.00 | | Swarm Mek | yes | 0 | 0 | 0 | 0.00 / 0.00 / 0.00 | | Stop Swarm Attack | yes | 0 | 0 | 0 | 0.00 / 0.00 / 0.00 | | Leg Attack | yes | 0 | 0 | 0 | 0.00 / 0.00 / 0.00 | The rifle's long bracket of 3 is the only range in the list, and it is the three-range platoon of jmm's requirement. `brackets::reaches` answers the half that is populated - which bracket, and what the range and the weapon's own modifier charge - and the platoon supplies the damage. With both, `best_damage` for that platoon reads 0.24 where it read 0.00, and `range_band_fit` reads 1.00 inside three hexes and 0.00 beyond. ### The clamp, demonstrated `scenarios/tactics/mount-approach.mms` is the instrument, and it exists because neither earlier scenario could ask the question. `mount-probe.mms` mounts reliably and its enemy is never in reach, so every drop reads nought and a working clamp is indistinguishable from a broken one; `mount-close.mms` starts everybody on one edge, where the platoon has a shot from the ground and never boards. This one puts them in sequence: the pair deploy apart, the platoon boards while there is nothing to shoot at, and the carrier then drives the length of the board towards an UrbanMech slow enough to still be there. So the same question is asked in both directions inside one match. Seed 1: | round | carrier | enemy | drop hexes offered | best worth | past the clamp | carrier chose | |---|---|---|---|---|---|---| | 1 | (3,2) | (12,14) | - | - | - | the platoon mounts | | 2 | (3,2) | (12,11) | 6 | 0.00 | **0** | drives to (3,8) | | 3 | (3,8) | (10,10) | 4 | 0.00 | **0** | drives to (8,9) | | 4 | (8,9) | (9,9) | 4 | 1.00 | **4** | **drop at (7,8)** | | 5 | (8,9) | (9,9) | - | - | - | the platoon is on the ground at (7,8) | Both outcomes, and the reason differs the way the requirement says it should: in rounds 2 and 3 the enemy is seven and eight hexes off, outside the rifle's three, so a platoon put down there would be no closer to anything. In round 4 it is adjacent. **It clamps within a round as well as between them.** On seed 2 and later in seed 1 the carrier offers six hexes and keeps two, so this is a per-hex bar rather than a per-round switch: the hexes beside the carrier are not all worth the same. **For this passenger the column is a step, not a slope**, and that is a fact about the design rather than about the feature. A bolt-action rifle has a single bracket - `0 / 0 / 3` - so its to-hit is the same at one hex as at three, and `range_band_fit` reads 1.00 everywhere inside the reach and 0.00 outside it. That is all a clamp needs, and it is not enough to *rank* one drop hex above another by range. A passenger with two brackets would separate them; this one cannot. **Suppressing them all is how the carrier drives on**, and it needs no separate branch. `Duty::Deliver` narrows the carrier's menu to its `UNLOAD` routes; with none left the narrowing is empty, `harmonise::constrain` restores the whole menu and counts the duty unsatisfiable. Rounds 2 and 3 above are that count. See `plan/ev.md` for the category the damage figure belongs to - four weapon classes now where `getDamage()` returns a code. **The pattern is still right.** It is `Bounded`, and its denominator is `best_damage(posture.unit)` - the unit's own best firepower - so it scores against that unit's own standard rather than against the board, the force, or the other candidates. Its zero case is the clamp, already written: *"A machine with nothing left to fire has no band to be in. Zero rather than a half: there is no range that would help it."* ### Two jobs, and the vocabulary already separates them "A feature like *this is my best option*" and "stay 0 when none of them are any good" pull against each other: the least-bad drop of a uniformly bad set **is** that unit's best option. They are two jobs and the basis already distinguishes them. - **`Local`** is min-max across this decision's own candidates - literally "my best option". It exists: `damage_lead`. And **`Weights` will not accept it**, enforced by a `compile_fail` doctest, because a local column "means nothing across two decisions". It ranks a menu and cannot be weighed against anything outside it. - **`Bounded`** means *a rule gives the domain*, and the rule may be about the unit: `range_band_fit` divides by `best_damage(posture.unit)` and returns zero when the unit has nothing that would help. **So the carrier's column must be `Bounded` with a per-unit reference, and the clamp is its zero.** The carrier is not choosing among drops - it is weighing *dropping* against *driving on*, which is a comparison across candidates of different kinds, and a `Local` column cannot be weighted into that at all. A self-relative ranking is welcome *inside* the drop set, where argmax is the only thing asked of it. That resolves both of jmm's constraints without a fourth normalisation: rank within the set if it helps, but what reaches the carrier is measured against the passenger's own standard, and is nought when nothing clears it. **This is a missing feature, not a missing capability.** `Bounded` demonstrably takes a per-unit reference today, so the drop-off column is a feature to write rather than a normalisation to invent. - [x] **Where to drop, scored as the passenger.** `Posture` already carries the candidate hex beside the unit and every positional feature is written against that pair, so scoring a unit at a hex it does not occupy is what `pathfind` and `stands` do every turn. What is missing is only that generation is driven from the actor - `generate(.., me, ..)` builds one unit's menu - so this needs the generator callable for a non-actor unit. **It is plumbing, not a new evaluation model**, and it is not `TacticOverride`: that gives a unit different weights for its *own* candidates, where this is one unit's turn producing another's. Both are only observable in a match. `TWGameManager.loadUnit` sends a packet and throws without a live `Server`, so unlike motive damage this cannot be driven headlessly - and `Entity.load` is only half the operation, leaving the cargo's own `transportId` at -1 and the unit still reading as targetable. **The acceptance scenario is the instrument**, not a convenience. - [ ] **Which battle armour may ride a Mek.** A Black Hawk (Nova) A carries an `Elemental Battle Armor [Laser](Sqd5)` and refuses a `Fenrir Battle Armor [ERML](Sqd5)` - same carrier, same `BattleArmorHandles` - so the gate is a property of the squad. Generation is unaffected: it asks `canLoad` and gets the right answer either way. A carrier is a specific population: an Atlas and a Manticore have `MekArms`, `ClampMountMek`, `RoofRack` and `TankTrailerHitch` and carry no infantry at all. 286 designs have an `InfantryCompartment`, and the 1047 with `BattleArmorHandles` are all OmniMeks.