From 8dbd276cf534ef1dca690a5ccb9baaaf0a360ba1 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 4 Sep 2026 17:27:37 -0400 Subject: [PATCH] feat(melee)!: the death from above, and the fall a miss costs whether or not the attack does The declaration is the charge's - a `DFA` step, gated on `canDFA`, built into the action by `MovePathHandler` - and the routes are the sweep's own jump routes, because the attack requires a jump and `DfaAttackAction` names no backwards refusal. A miss is a fall with no roll to pass, which costs half again what landing does, so `self_damage` becomes an expectation over both branches and `attacker_self_damage` reads the certainty. Change-Id: I6c3d2fde6cf9a70bcf7a63fab2a33d4daea8441e --- bridge/sds/Observation.java | 119 ++++++++++++ bridge/sds/SdsClient.java | 26 +++ crates/sds-bot/src/candidates.rs | 200 ++++++++++++++++++- crates/sds-bot/src/force.rs | 2 + crates/sds-bot/src/imitate.rs | 1 + crates/sds-bot/src/main.rs | 4 + crates/sds-bot/src/unit.rs | 8 +- crates/sds-core/examples/vignettes/dfa.rs | 78 ++++++++ crates/sds-core/examples/vignettes/mod.rs | 7 +- crates/sds-core/src/features/dfa.rs | 224 ++++++++++++++++++++++ crates/sds-core/src/features/incoming.rs | 1 + crates/sds-core/src/features/latch.rs | 1 + crates/sds-core/src/features/mod.rs | 11 ++ crates/sds-core/src/plan.rs | 2 + crates/sds-core/src/reconcile.rs | 1 + crates/sds-core/src/stands.rs | 1 + crates/sds-core/src/wire.rs | 89 +++++++++ docs/FEATURES.md | 12 +- docs/TACTICS.md | 26 +-- docs/features/dfa_damage.svg | 98 ++++++++++ docs/tactics/engage.svg | 147 +++++++------- weights/hand-authored.json | 1 + 22 files changed, 970 insertions(+), 89 deletions(-) create mode 100644 crates/sds-core/examples/vignettes/dfa.rs create mode 100644 crates/sds-core/src/features/dfa.rs create mode 100644 docs/features/dfa_damage.svg diff --git a/bridge/sds/Observation.java b/bridge/sds/Observation.java index cf9fbfd..e1f3e85 100644 --- a/bridge/sds/Observation.java +++ b/bridge/sds/Observation.java @@ -25,6 +25,7 @@ import megamek.common.compute.Compute; import megamek.common.units.EntityMovementType; import megamek.common.battleArmor.BattleArmor; import megamek.common.actions.ChargeAttackAction; +import megamek.common.actions.DfaAttackAction; import megamek.common.actions.ClubAttackAction; import megamek.common.actions.KickAttackAction; import megamek.common.actions.PunchAttackAction; @@ -194,8 +195,10 @@ public final class Observation { List movers = (eligible == null || eligible.isEmpty()) ? (actor == null ? List.of() : List.of(actor)) : eligible; + ArrayNode dfas = root.putArray("dfas"); for (Entity mover : movers) { chargesFor(game, me, mover, charges); + dfasFor(game, mover, dfas); } return root; } @@ -310,6 +313,122 @@ public final class Observation { EntityMovementType.MOVE_WALK, EntityMovementType.MOVE_RUN, }; + /** + * Every death from above this unit could declare, quoted from the hex it + * would jump off. + + *

The declaration is the charge's, exactly. Probed rather than + * assumed: {@code MovePathHandler} watches the submitted path for a + * {@link MoveStepType#DFA} step, gates it on {@code Entity.canDFA()}, + * constructs the {@code DfaAttackAction} itself and hands it to + * {@code Game.addCharge}. Nothing is declared in the physical phase, and + * {@code Entity.isEligibleForFiring} returns false on {@code isMakingDfa} + * the same way it does on {@code isCharging} - so the trade is the same + * one: the whole firing turn, for the machine landing on somebody. + * + *

There is no gait dimension here and that is not an omission. + * {@code DfaAttackAction.toHit(Game, int, Targetable, MovePath)} names its + * refusals one at a time and one of them is "D.F.A. must involve + * jumping": a death from above is only ever made at a jump, so the one + * thing the quote is indexed by is the hex it is launched from. The + * charge's mirror-image refusal - "No backwards movement allowed while + * charging" - has no counterpart in that list, which is what says the + * reverse-route problem #405 had is not this attack's problem. + * + *

The three damage figures are separate because they are three different + * events, and two of them are what the miss costs: + * + *

    + *
  • {@code damage} is {@code DfaAttackAction.getDamageFor} - the + * target's, on a hit. + *
  • {@code selfDamage} is {@code DfaAttackAction.getDamageTakenBy} - + * ours, to the legs, on a hit. + *
  • {@code fallDamage} is what a miss costs, and it is the + * larger of the two. {@code TWGameManager.resolveDfaAttack} answers a + * missed death from above with {@code doEntityFall(attacker, hex, 2, + * 3, getBasePilotingRoll(), false, false)} - no roll to pass, unlike + * a missed kick - and {@code doEntityFall} takes + * {@code round(weight / 10) * (height + 1)} points off, at the height + * of 2 that call passes. That arithmetic is read out of the jar + * rather than answered by it: {@code doEntityFall} is + * package-private in {@code megamek.server.totalWarfare} and needs a + * running game manager, so there is nothing to ask. {@code sds + * dfa-probe} prints the figure beside the two MegaMek does answer. + *
+ */ + private void dfasFor(Game game, Entity mover, ArrayNode dfas) { + if (mover.getPosition() == null || !mover.canDFA() || mover.getJumpMP() <= 0) { + return; + } + // A target no jump of ours reaches is a target with no death from above + // to quote. The bound is the mover's jump allowance plus the step into + // the target's hex, and is deliberately generous for the same reason + // the charge's is: a dropped option is one the bot cannot see, and a + // kept one costs six `toHit` calls. + int furthest = Math.max(mover.getJumpMP(), 1) + 1; + for (Entity target : game.getEntitiesVector()) { + if (target.getPosition() == null || target.getId() == mover.getId()) { + continue; + } + if (mover.getPosition().distance(target.getPosition()) > furthest) { + continue; + } + ObjectNode quote = null; + for (Coords from : target.getPosition().allAdjacent()) { + if (!game.getBoard().contains(from)) { + continue; + } + ToHitData toHit; + try { + toHit = DfaAttackAction.toHit(game, mover.getId(), target, from); + } catch (RuntimeException problem) { + continue; + } + if (toHit == null || toHit.getValue() == TargetRoll.IMPOSSIBLE + || toHit.getValue() == TargetRoll.AUTOMATIC_FAIL) { + continue; + } + if (quote == null) { + quote = dfas.addObject(); + quote.put("shooter", mover.getId()); + quote.put("target", target.getId()); + // Set by the *attacker's* weight and by nothing of the + // target's, and flat in the distance jumped - which is the + // whole difference from a charge, where the target's weight + // sets the cost and the hexes set the damage. + quote.put("damage", DfaAttackAction.getDamageFor( + mover, target.isConventionalInfantry())); + quote.put("selfDamage", DfaAttackAction.getDamageTakenBy(mover)); + quote.put("fallDamage", missedDfaFallDamage(mover)); + quote.putArray("from"); + } + ObjectNode at = ((ArrayNode) quote.get("from")).addObject(); + at.put("x", from.getX()); + at.put("y", from.getY()); + at.put("toHit", toHit.getValue()); + } + } + } + + /** + * What a missed death from above takes off its own attacker. + * + *

{@code TWGameManager.doEntityFall} computes + * {@code round(getWeight() / 10) * (height + 1)} and {@code resolveDfaAttack} + * passes a height of 2 on a miss. Both are read out of the jar; neither is + * callable from here, because {@code doEntityFall} is package-private and + * takes a running game manager. + * + *

The damage is only part of what a miss costs. The same call sets + * {@code setProne(true)} and {@code setFallen(true)}, and + * {@code checkPilotAvoidFallDamage} adds a "height of fall" modifier + * of {@code height - 1} to a piloting roll the pilot takes to avoid a hit. + * Neither of those is points and neither is in this figure. + */ + private static int missedDfaFallDamage(Entity mover) { + return (int) Math.round(mover.getWeight() / 10.0) * 3; + } + /** * A firing decision, with every shot the rules allow already worked out. * diff --git a/bridge/sds/SdsClient.java b/bridge/sds/SdsClient.java index d56a599..071a8f3 100644 --- a/bridge/sds/SdsClient.java +++ b/bridge/sds/SdsClient.java @@ -1256,6 +1256,32 @@ public final class SdsClient extends BotClient { } path.addStep(MoveStepType.CHARGE, into); } + // And the same for a death from above, which differs from a charge in + // the step type and in nothing else here: `MovePathHandler` watches for + // a `DFA` step, gates it on `Entity.canDFA()`, builds the + // `DfaAttackAction` and hands it to the same `Game.addCharge`. + // + // The two are mutually exclusive - a path is a jump or it is not, and + // `toHit` refuses a charge that jumps and a death from above that does + // not - so a reply naming both is a bug on our side and is refused here + // rather than sent. + JsonNode dfa = reply.path("dfaTarget"); + if (dfa.isInt()) { + if (charge.isInt()) { + System.err.println("[sds] " + getName() + + " named both a charge and a death from above on one path"); + refusedAt = -1; + return null; + } + Entity onto = getGame().getEntity(dfa.asInt()); + if (onto == null) { + System.err.println("[sds] " + getName() + + " jumped on a unit that is not there: " + dfa.asInt()); + refusedAt = -1; + return null; + } + path.addStep(MoveStepType.DFA, onto); + } if (!path.isMoveLegal()) { // **Why, not just that.** A rejected path is the one failure mode // the candidate generator cannot see from the inside: it proposed diff --git a/crates/sds-bot/src/candidates.rs b/crates/sds-bot/src/candidates.rs index 8d63f7b..6295043 100644 --- a/crates/sds-bot/src/candidates.rs +++ b/crates/sds-bot/src/candidates.rs @@ -61,11 +61,12 @@ use sds_core::stands::{self, Combatant, Foe, Mover, Params, Presence}; use crate::carry::{self, Memos, Tally}; use sds_core::features::charge::{Charge, ChargeDamage}; +use sds_core::features::dfa::{Dfa, DfaCost, DfaDamage}; use sds_core::features::firing; use sds_core::features::score::Score; use sds_core::surface::{self, facts_of}; use sds_core::volley::{self, MountedWeapon}; -use sds_core::wire::{hit_chance, Action, ChargeQuote, Coord, Observation, Unit}; +use sds_core::wire::{hit_chance, Action, ChargeQuote, Coord, DfaQuote, Observation, Unit}; /// The marker every generated label carries. /// @@ -155,6 +156,10 @@ pub struct Cost { /// were built from. A charge is a stand plus one step, so the two together /// say how much of the reachable set had a target in front of it. pub charges: u64, + /// Death-from-above candidates this sweep added. Counted apart from the + /// charges because they come from the *jump* half of the reachable set and + /// a board can offer plenty of one and none of the other. + pub dfas: u64, } /// Everything one sweep produced: the menu, what it cost, and what it was @@ -471,6 +476,7 @@ pub fn generate( proposal.action = Action::Move { steps: steps.into_iter().map(sds_core::wire::Step::plain).collect(), charge_target: None, + dfa_target: None, }; // The gait is named, not implied. It is the `A` of GATOR for our own // shooting and half of the target movement modifier against us, and a @@ -562,6 +568,7 @@ pub fn generate( proposal.action = Action::Move { steps, charge_target: None, + dfa_target: None, }; proposal.notes.push(format!( "{} mp, boards {} at ({}, {})", @@ -672,6 +679,7 @@ pub fn generate( proposal.action = Action::Move { steps: vec![sds_core::wire::Step::dropping("UNLOAD", rider.id, at)], charge_target: None, + dfa_target: None, }; proposal .notes @@ -680,11 +688,24 @@ pub fn generate( } } + // The death-from-above candidates, which need no second search at all: a + // jump is the only gait one can be made at, and the jump half of the + // reachable set is already scored with its routes in `air`. That is the + // charge's reverse-route problem not arising rather than being solved - + // `DfaAttackAction.toHit(Game, MovePath)` names its refusals one at a time + // and "no backwards movement" is not among them. + let dfas = match air.as_ref() { + Some(air) => dfa_proposals(observation, me, air, &scored, &out), + None => Vec::new(), + }; + let dfa_count = dfas.len() as u64; + out.extend(dfas); if out.is_empty() { return None; } let cost = Cost { charges: charge_count, + dfas: dfa_count, reach_considered: considered, reach_dropped: dropped, volley_hits: volley_counters.hits - counters_at_start.hits, @@ -1055,6 +1076,7 @@ fn charge_proposals( proposal.action = Action::Move { steps: steps.into_iter().map(sds_core::wire::Step::plain).collect(), charge_target: Some(quote.target), + dfa_target: None, }; proposal.damage_dealt = charge.expected(); proposal.damage_by_target = std::iter::once((quote.target, charge.expected())).collect(); @@ -1067,6 +1089,182 @@ fn charge_proposals( out } +/// The deaths from above this unit could declare, built from the jump stands +/// it already reached. +/// +/// # No second search, and that is the finding rather than an omission +/// +/// #405 needed one: the sweep minimises MP, turning costs MP, so the cheapest +/// route to a hex facing an enemy is very often a reverse - and +/// `ChargeAttackAction.toHit(Game, MovePath)` refuses a path containing +/// `BACKWARDS` with "No backwards movement allowed while charging". The same +/// question asked of `DfaAttackAction.toHit(Game, int, Targetable, MovePath)`, +/// which names its refusals one at a time, has no such entry. What it does have +/// is "D.F.A. must involve jumping" - so the routes this needs are the *jump* +/// routes, they are already in the sweep's own `air` search, and turning is +/// free while jumping so no facing is unreachable at the price of another. +/// +/// # What it costs, and where each half is priced +/// +/// `Entity.isEligibleForFiring` returns false on `isMakingDfa` exactly as it +/// does on `isCharging`, so every volley column is zeroed here the way it is +/// for a charge and [`DfaDamage`] carries the whole of the offence against +/// [`firing::ceiling`]. +/// +/// The costs are two, and the second is the one nothing on the vector could +/// previously say: +/// +/// - **A hit** takes `DfaAttackAction.getDamageTakenBy` off our legs, and hands +/// us a piloting roll at four over base - `PilotingRollData(id, 4, "executed +/// death from above")`. +/// - **A miss is a fall, certain, with no roll to pass.** +/// `TWGameManager.resolveDfaAttack` answers one with `doEntityFall(..., 2, 3, +/// getBasePilotingRoll(), false, false)`, and that takes `round(weight / 10) +/// * 3` off - about half again what a hit costs. +/// +/// Both are points off armour and structure, so both go into +/// [`firing::SelfDamage`] as one expectation over the two branches; and both +/// can put the machine on the floor, so both go into +/// [`firing::AttackerSelfDamage`], which already means exactly that. Neither +/// column needed widening and no third scale was invented: see +/// [`sds_core::features::dfa::DfaCost`]. +/// +/// # What is approximate here, stated rather than corrected +/// +/// - **The hex.** On a hit the attacker ends in the target's hex. Every +/// positional column is the *launching* hex's, because that is the hex the +/// sweep scored - the same choice #405 made and for the same reason. +/// - **The elevation.** The quote's `toHit` was asked with the attacker's +/// elevation as it stands now, not as it would be at the launch hex. A death +/// from above over a level change is quoted at the wrong roll rather than +/// refused; `toHit(Game, MovePath)` recomputes it server-side. +/// - **`heat_incurred`** is left as the stand measured it, which includes a +/// volley's heat this candidate never pays. That over-charges it, which is +/// the safe direction. +fn dfa_proposals( + observation: &Observation, + me: &Unit, + air: &Search, + scored: &[stands::StandScore], + stands_out: &[Proposal], +) -> Vec { + let quotes: Vec<&DfaQuote> = observation + .dfas + .iter() + .filter(|quote| quote.shooter == me.id) + .collect(); + if quotes.is_empty() { + return Vec::new(); + } + let ceiling = firing::ceiling(me); + let allowance = me.jump_allowance(); + let mut out: Vec = Vec::new(); + for (at, score) in scored.iter().enumerate() { + // Only the jump half of the reachable set. A ground stand has no death + // from above to make from it, whatever is standing in front of it. + if !score.reach.jumped { + continue; + } + let stand = score.reach.stand; + // One more jump hex has to be left to land on them with. + if score.reach.mp_spent >= allowance.max(0) { + continue; + } + // The hex the machine is looking at. A jumping Mek lands on any facing + // for nothing, so requiring the stand to face its target costs no + // option: the same hex is reached at the same price on all six. + let ahead = stand.ahead(); + let Some(quote) = quotes.iter().copied().find(|quote| { + observation + .units + .iter() + .any(|unit| unit.id == quote.target && unit.x == ahead.x && unit.y == ahead.y) + }) else { + continue; + }; + let Some(to_hit) = quote.to_hit_from(stand.hex.x, stand.hex.y) else { + continue; + }; + let Some(base) = stands_out.get(at) else { + continue; + }; + let Some(steps) = steps_to(air, stand) else { + continue; + }; + let mut proposal = base.clone(); + let landed = hit_chance(to_hit); + let dfa = Dfa { + hit_chance: landed, + damage: quote.damage.max(0) as f32, + ceiling, + }; + let cost = DfaCost { + hit_chance: landed, + on_hit: quote.self_damage.max(0) as f32, + on_miss: quote.fall_damage.max(0) as f32, + }; + // Every column that reads a volley, zeroed. `isEligibleForFiring` is + // false for a unit making one of these, so leaving them would credit + // the machine with a turn it is simultaneously not taking. + let features = &mut proposal.features; + features.record::(Score::fraction(0.0)); + features.record::(Score::probability(0.0)); + features.record::(Score::probability(0.0)); + features.record::(Score::fraction(0.0)); + features.record::(Score::probability(0.0)); + features.record::(Score::fraction(0.0)); + features.record::(Score::fraction(0.0)); + features.record::(Score::fraction(0.0)); + features.record::(Score::fraction(0.0)); + features.measure::(&dfa); + // The roll the *target* is handed: `PilotingRollData(targetId, 2, "hit + // by death from above")`, gated the way every other entry in this + // column is - a machine that cannot fall has no roll to force. + let target_falls = observation + .units + .iter() + .find(|unit| unit.id == quote.target) + .is_some_and(|unit| unit.can_fall); + if target_falls { + features.record::(Score::probability(landed)); + } + // The points, over both branches. This is the one place on the vector + // where a worse roll costs *more*: the miss is the expensive outcome. + features.measure::(&firing::SelfHarm { + take: cost.expected_points(), + health: (me.armor + me.internal).max(0) as f32, + }); + // And the floor. A miss puts us on it every time; a hit hands us a + // piloting roll at four over base for having landed. + features.record::(Score::probability( + cost.fall_chance(me.psr_base), + )); + proposal.label = format!( + "dfa {}{MARK}({}, {}) facing {}", + quote.target, stand.hex.x, stand.hex.y, stand.facing + ); + proposal.action = Action::Move { + steps: steps.into_iter().map(sds_core::wire::Step::plain).collect(), + charge_target: None, + dfa_target: Some(quote.target), + }; + proposal.damage_dealt = dfa.expected(); + proposal.damage_by_target = std::iter::once((quote.target, dfa.expected())).collect(); + proposal.notes.push(format!( + "death from above onto {} on a {}, {} damage, {} taken landing and {} falling, \ + {} hexes jumped, no firing turn", + quote.target, + to_hit, + quote.damage, + quote.self_damage, + quote.fall_damage, + score.reach.hexes_moved + )); + out.push(proposal); + } + out +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/sds-bot/src/force.rs b/crates/sds-bot/src/force.rs index c2c8626..3e21c3e 100644 --- a/crates/sds-bot/src/force.rs +++ b/crates/sds-bot/src/force.rs @@ -717,6 +717,7 @@ mod tests { action: Action::Move { steps: vec![], charge_target: None, + dfa_target: None, }, end: Some(Coord::new(at as i32, id)), features: FeatureVector::new(), @@ -1092,6 +1093,7 @@ mod tests { action: Action::Move { steps: vec![], charge_target: None, + dfa_target: None, }, end: Some(at), features, diff --git a/crates/sds-bot/src/imitate.rs b/crates/sds-bot/src/imitate.rs index 01ff194..5f3d930 100644 --- a/crates/sds-bot/src/imitate.rs +++ b/crates/sds-bot/src/imitate.rs @@ -613,6 +613,7 @@ mod tests { fn observation(round: i32, phase: &str, units: Vec) -> Observation { Observation { charges: Vec::new(), + dfas: Vec::new(), rejected: Vec::new(), seq: 1, round, diff --git a/crates/sds-bot/src/main.rs b/crates/sds-bot/src/main.rs index d751824..7f00004 100644 --- a/crates/sds-bot/src/main.rs +++ b/crates/sds-bot/src/main.rs @@ -1478,6 +1478,7 @@ impl Bot { Action::Move { steps: Vec::new(), charge_target: None, + dfa_target: None, } }; self.count_turn(&eligible, &choice); @@ -3171,6 +3172,7 @@ temperature {temperature}; do not benchmark this run", action: Action::Move { steps: Vec::new(), charge_target: None, + dfa_target: None, }, unit: None, choice: None, @@ -3353,6 +3355,7 @@ mod tests { action: Action::Move { steps: vec![], charge_target: None, + dfa_target: None, }, end: None, features, @@ -3523,6 +3526,7 @@ mod tests { action: Action::Move { steps: Vec::new(), charge_target: None, + dfa_target: None, }, generated: true, value, diff --git a/crates/sds-bot/src/unit.rs b/crates/sds-bot/src/unit.rs index d52914f..cdfb8b5 100644 --- a/crates/sds-bot/src/unit.rs +++ b/crates/sds-bot/src/unit.rs @@ -520,6 +520,7 @@ pub fn propose_including( action: sds_core::wire::Action::Move { steps: Vec::new(), charge_target: None, + dfa_target: None, }, end: here, hexes_moved: 0, @@ -544,6 +545,7 @@ pub fn propose_including( Vec::new() }, charge_target: None, + dfa_target: None, }, end: here, hexes_moved: 0, @@ -620,6 +622,7 @@ pub fn propose_including( action: sds_core::wire::Action::Move { steps: steps.into_iter().map(sds_core::wire::Step::plain).collect(), charge_target: None, + dfa_target: None, }, end, hexes_moved: moved_hexes, @@ -661,6 +664,7 @@ pub fn propose_including( action: sds_core::wire::Action::Move { steps: Vec::new(), charge_target: None, + dfa_target: None, }, end, hexes_moved: distance(here, end), @@ -708,7 +712,7 @@ pub fn propose_including( "[sds-bot] unit {}: {} states ({} ground, {} jump), \ {} enemy positions ({} jumped, {} unmoved), \ {} exchanges, {} surfaced, {} range brackets, \ - {} weapons not modelled, {} charges, {} los asked ({}% hit), \ + {} weapons not modelled, {} charges, {} dfas, {} los asked ({}% hit), \ {} of {} enemies out of reach", me.id, cost.states, @@ -722,6 +726,7 @@ pub fn propose_including( cost.brackets, cost.unmodelled, cost.charges, + cost.dfas, asked, (hits * 100).checked_div(asked).unwrap_or(0), cost.reach_dropped, @@ -1539,6 +1544,7 @@ mod tests { let board = board(); let observation = Observation { charges: Vec::new(), + dfas: Vec::new(), seq: 1, round: 1, phase: "MOVEMENT".into(), diff --git a/crates/sds-core/examples/vignettes/dfa.rs b/crates/sds-core/examples/vignettes/dfa.rs new file mode 100644 index 0000000..55c0f0a --- /dev/null +++ b/crates/sds-core/examples/vignettes/dfa.rs @@ -0,0 +1,78 @@ +//! The worked example for the other attack declared by moving, and the only +//! one whose cost rises as its chances fall. +//! +//! A charge that misses is a displacement. A death from above that misses is a +//! fall - `TWGameManager.resolveDfaAttack` answers one with `doEntityFall(..., +//! 2, 3, getBasePilotingRoll(), false, false)`, with no roll to pass - and +//! `doEntityFall` takes `round(weight / 10) * (height + 1)` off at that height +//! of 2. So the expensive branch is the branch where nothing landed. + +use sds_core::features::dfa::{Dfa, DfaCost, DfaDamage}; +use sds_core::features::Feature; +use sds_core::wire::hit_chance; + +use super::{Documented, Example, Faction, Figure, RowFigure, Subject}; + +/// A hundred-ton machine whose guns would land twenty points from where it +/// stands, jumping onto targets that need progressively worse rolls. +/// +/// The three figures are MegaMek's own, at a hundred tons: +/// `getDamageFor` reads 30, `getDamageTakenBy` reads 20, and `doEntityFall`'s +/// arithmetic at a height of 2 reads 30. Nothing here recomputes them from a +/// rule. +const CEILING: f32 = 20.0; +const DEALT: f32 = 30.0; +const TAKEN_LANDING: f32 = 20.0; +const TAKEN_FALLING: f32 = 30.0; + +fn jumping(to_hit: i32) -> Subject { + let landed = hit_chance(to_hit); + let dfa = Dfa { + hit_chance: landed, + damage: DEALT, + ceiling: CEILING, + }; + let cost = DfaCost { + hit_chance: landed, + on_hit: TAKEN_LANDING, + on_miss: TAKEN_FALLING, + }; + Subject { + label: format!("needs {to_hit}"), + side: Faction::Ours, + value: DfaDamage::measure(&dfa).get(), + lines: vec![ + format!("{:.0}% to land", landed * 100.0), + format!("{:.1} expected onto them", dfa.expected()), + format!("{:.1} expected back at us", cost.expected_points()), + ], + } +} + +impl Documented for DfaDamage { + fn example() -> Example { + Example { + shows: "one hundred-ton machine jumping onto targets that need a 4, a 7, a 9 \ + and an 11, against a turn of shooting that would land twenty points \ + from where it stands. MegaMek's own figures at that weight are 30 \ + dealt, 20 taken to the legs on a hit, and 30 taken on a miss.", + reading: "A share of the same denominator `expected_damage` divides by - the \ + machine's own best turn - so a death from above, a charge and a \ + volley are three readings of one scale and the force picks between \ + them by comparing numbers rather than kinds. \ + \ + The third line under each bar is the one that has no counterpart \ + anywhere else in the basis. Every other attack costs less as it \ + gets less likely to connect; this one costs *more*, because a miss \ + is a fall with no roll to pass and a fall takes half again what \ + landing does. At an 11 the machine expects to put about six points \ + on somebody and take about twenty-eight back. Those points sit in \ + `self_damage` and the floor they leave us on sits in \ + `attacker_self_damage`, so what this column says is only what the \ + jump buys.", + figure: Figure::Row(RowFigure { + subjects: vec![jumping(4), jumping(7), jumping(9), jumping(11)], + }), + } + } +} diff --git a/crates/sds-core/examples/vignettes/mod.rs b/crates/sds-core/examples/vignettes/mod.rs index e7c239a..a73c014 100644 --- a/crates/sds-core/examples/vignettes/mod.rs +++ b/crates/sds-core/examples/vignettes/mod.rs @@ -60,6 +60,7 @@ pub mod scene; mod charge; mod deployment; +mod dfa; mod firing; mod force; mod heat; @@ -111,7 +112,7 @@ pub struct Vignette { /// what it is aimed at, then the force. `page.rs` walks this; anything whose /// module is not here would be dropped, which is what /// `every_family_is_known` in `tests/vignettes.rs` refuses. -pub const FAMILIES: [(&str, &str); 8] = [ +pub const FAMILIES: [(&str, &str); 9] = [ ( "deployment", "Deployment: where to put a machine down, before anything has moved", @@ -129,6 +130,10 @@ pub const FAMILIES: [(&str, &str); 8] = [ "charge", "Charge: the one attack declared by moving, and the firing turn it costs", ), + ( + "dfa", + "Death from above: the other one, and the only attack that costs more when it is less likely to land", + ), ]; impl Vignette { diff --git a/crates/sds-core/src/features/dfa.rs b/crates/sds-core/src/features/dfa.rs new file mode 100644 index 0000000..bd79534 --- /dev/null +++ b/crates/sds-core/src/features/dfa.rs @@ -0,0 +1,224 @@ +//! What a death from above is worth, and what it costs whether it lands or not. +//! +//! **The declaration is the charge's.** Probed rather than assumed: +//! `MovePathHandler` watches a submitted move path for a [`MoveStepType::DFA`] +//! step, gates it on `Entity.canDFA()`, constructs the `DfaAttackAction` itself +//! and hands it to the same `Game.addCharge`. And the cost is the charge's too: +//! `Entity.isEligibleForFiring` returns false on `isMakingDfa` exactly as it +//! does on `isCharging`, so a machine that jumps on somebody gets no firing +//! turn at all. That is why this is a column beside +//! [`crate::features::charge::ChargeDamage`] against the same denominator - +//! [`crate::features::firing::ceiling`], the machine's own best turn - rather +//! than damage added to a volley the unit is simultaneously not taking. +//! +//! [`MoveStepType::DFA`]: https://megamek.org +//! +//! # Why it is not `charge_damage` +//! +//! The two attacks trade the same thing away and their arithmetic has nothing +//! in common. A charge's damage is the *target's* weight against the hexes run; +//! a death from above's is `ceil(tons / 10 * 3)` of the *attacker's*, flat in +//! the distance jumped. A force that learns one has learned nothing about the +//! other, so they are two columns and the fit is asked to separate them. +//! +//! # What this column does not carry, and where each half went +//! +//! Neither of the two costs is here, for the same reason the charge's are not +//! in its column: they are damage arriving at the attacker, and +//! [`crate::features::firing::ceiling`] is what the attacker can put *out*. +//! +//! - **On a hit** the attacker takes `ceil(tons / 5)` to the legs. +//! - **On a miss it falls, with no roll to pass.** +//! `TWGameManager.resolveDfaAttack` answers a miss with `doEntityFall(..., +//! 2, 3, getBasePilotingRoll(), false, false)`, and `doEntityFall` takes +//! `round(weight / 10) * (height + 1)` off at that height of 2 - about half +//! again what a hit costs. **This is the branch nothing on the vector could +//! previously price**: [`crate::features::firing::AttackerSelfDamage`] holds +//! a fall *chance* and a missed charge is merely a displacement, so a missed +//! kick's piloting roll was the only fall the basis knew about. +//! +//! Both go into [`crate::features::firing::SelfDamage`], as one expectation +//! over the two branches, because they are the same quantity - points off +//! armour and structure - arriving on different rolls. The *event* of ending up +//! on the floor goes into [`crate::features::firing::AttackerSelfDamage`], +//! which already means "the chance this action puts us on the floor, by our own +//! doing" and needs no widening to say that a miss does it every time. + +use crate::features::score::{Bounded, Score}; +use crate::features::Feature; + +/// One death from above, priced. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Dfa { + /// The chance the roll connects. + pub hit_chance: f32, + /// Damage MegaMek says it deals - `DfaAttackAction.getDamageFor`. + pub damage: f32, + /// The attacker's own best turn, in damage - the same denominator + /// [`crate::features::firing::ExpectedDamage`] divides by. + pub ceiling: f32, +} + +impl Dfa { + /// Damage this expects to land. + pub fn expected(&self) -> f32 { + self.hit_chance.clamp(0.0, 1.0) * self.damage.max(0.0) + } +} + +/// The share of its own best turn that a unit lands by jumping onto somebody. +pub struct DfaDamage; + +impl Feature for DfaDamage { + type Norm = Bounded; + type Input<'a> = Dfa; + const NAME: &'static str = "dfa_damage"; + const DESCRIPTION: &'static str = + "The share of everything we could put out that this death from above lands instead."; + + fn measure(dfa: &Dfa) -> Score { + if dfa.ceiling <= 0.0 { + return Score::fraction(0.0); + } + Score::fraction(dfa.expected() / dfa.ceiling) + } +} + +/// What one death from above costs its own attacker, over both branches. +/// +/// **The miss costs more than the hit, and it is certain.** MegaMek's own two +/// figures for a 55-ton Griffin are 11 points on a hit and 18 on a miss; for an +/// Atlas, 20 and 30. So the expectation is not "the cost, if it works" the way +/// a charge's is - it is a weighted choice between two costs, and a worse roll +/// moves the weight onto the larger one. That is the whole reason a death from +/// above needs anything new said about it at all. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DfaCost { + /// The chance the roll connects. + pub hit_chance: f32, + /// `DfaAttackAction.getDamageTakenBy` - what a landed one takes off us. + pub on_hit: f32, + /// `doEntityFall`'s `round(weight / 10) * 3` - what a missed one takes off + /// us, with no roll in between. + pub on_miss: f32, +} + +impl DfaCost { + /// Points we expect to pay for making it, over both branches. + pub fn expected_points(&self) -> f32 { + let landed = self.hit_chance.clamp(0.0, 1.0); + landed * self.on_hit.max(0.0) + (1.0 - landed) * self.on_miss.max(0.0) + } + + /// The chance this puts us on the floor. + /// + /// A miss does it every time - `doEntityFall` with no roll to pass. A hit + /// hands us one: `doEntityDisplacement` into the target's hex carries a + /// `PilotingRollData(id, 4, "executed death from above")`, so a landed + /// death from above is a piloting roll at four over base. + /// + /// `psr_base` of nought or less is a machine the column has no number for - + /// see `Unit::psr_base` - and reads as the miss branch alone rather than as + /// a machine that never falls. + pub fn fall_chance(&self, psr_base: i32) -> f32 { + let landed = self.hit_chance.clamp(0.0, 1.0); + let on_hit = if psr_base > 0 { + (1.0 - crate::wire::hit_chance(psr_base + DFA_LANDING_MODIFIER)).clamp(0.0, 1.0) + } else { + 0.0 + }; + ((1.0 - landed) + landed * on_hit).clamp(0.0, 1.0) + } +} + +/// What a landed death from above adds to the attacker's piloting roll. +/// +/// `TWGameManager.resolveDfaAttack` builds `new PilotingRollData(attackerId, 4, +/// "executed death from above")` and hands it to `doEntityDisplacement`. +pub const DFA_LANDING_MODIFIER: i32 = 4; + +#[cfg(test)] +mod tests { + use super::*; + use crate::features::FeatureVector; + + /// A death from above reads as a share of the same turn a volley is a + /// share of, and saturates rather than exceeding it. + #[test] + fn a_dfa_is_a_share_of_the_same_turn() { + let mut vector = FeatureVector::new(); + let landed = vector.measure::(&Dfa { + hit_chance: 0.5, + damage: 30.0, + ceiling: 30.0, + }); + assert!((landed - 0.5).abs() < 1e-6, "read {landed}"); + + let none = DfaDamage::measure(&Dfa { + hit_chance: 1.0, + damage: 10.0, + ceiling: 0.0, + }); + assert_eq!(none.get(), 0.0); + + let big = DfaDamage::measure(&Dfa { + hit_chance: 1.0, + damage: 60.0, + ceiling: 20.0, + }); + assert_eq!(big.get(), 1.0); + } + + /// The cost rises as the roll gets worse, because the branch that costs + /// more is the branch where nothing landed. + /// + /// This is the property that separates a death from above from every other + /// attack the vector prices. A charge, a kick and a volley all cost less + /// when they are less likely to connect; this one costs more. + #[test] + fn a_worse_roll_costs_more_rather_than_less() { + // An Atlas: 20 points to the legs on a hit, 30 on the floor on a miss. + let sure = DfaCost { + hit_chance: 1.0, + on_hit: 20.0, + on_miss: 30.0, + }; + let doubtful = DfaCost { + hit_chance: 0.25, + ..sure + }; + assert_eq!(sure.expected_points(), 20.0); + assert!( + doubtful.expected_points() > sure.expected_points(), + "read {} against {}", + doubtful.expected_points(), + sure.expected_points() + ); + } + + /// A miss is a fall every time, and a hit is a piloting roll at four over + /// base. + #[test] + fn a_miss_is_a_certain_fall() { + let cost = DfaCost { + hit_chance: 0.0, + on_hit: 20.0, + on_miss: 30.0, + }; + assert_eq!(cost.fall_chance(4), 1.0); + + // Landing every time still leaves the roll for having landed. A crew + // piloting 4 rolls an 8 after the modifier, which is not a certainty in + // either direction. + let sure = DfaCost { + hit_chance: 1.0, + ..cost + }; + let risk = sure.fall_chance(4); + assert!(risk > 0.0 && risk < 1.0, "read {risk}"); + + // A machine with no piloting roll to read is charged for the miss + // branch and nothing else, rather than for a fall nobody quoted. + assert_eq!(sure.fall_chance(0), 0.0); + } +} diff --git a/crates/sds-core/src/features/incoming.rs b/crates/sds-core/src/features/incoming.rs index 3e0220e..8b2e34e 100644 --- a/crates/sds-core/src/features/incoming.rs +++ b/crates/sds-core/src/features/incoming.rs @@ -269,6 +269,7 @@ mod tests { action: Action::Move { steps: vec![], charge_target: None, + dfa_target: None, }, end: Some(Coord::new(at as i32, 0)), features: FeatureVector::new(), diff --git a/crates/sds-core/src/features/latch.rs b/crates/sds-core/src/features/latch.rs index 3bc13b6..fbf98ac 100644 --- a/crates/sds-core/src/features/latch.rs +++ b/crates/sds-core/src/features/latch.rs @@ -174,6 +174,7 @@ mod tests { let before = latches.clone(); latches.observe(&Observation { charges: Vec::new(), + dfas: Vec::new(), seq: 1, rejected: Vec::new(), round: 4, diff --git a/crates/sds-core/src/features/mod.rs b/crates/sds-core/src/features/mod.rs index e45811f..9718e6f 100644 --- a/crates/sds-core/src/features/mod.rs +++ b/crates/sds-core/src/features/mod.rs @@ -25,6 +25,7 @@ pub mod charge; pub mod decision; pub mod deployment; +pub mod dfa; pub mod firing; #[cfg(test)] pub(crate) mod fixture; @@ -292,6 +293,15 @@ impl Weights { // a charge is the only attack on the list that hurts us when it // works. .with::(4.0) + // And the other movement attack, at the same weight for the same + // reason: a death from above's damage is a share of the same + // denominator, so a jump that lands a third of the machine's best + // turn is worth what a volley landing a third of it is worth. It is + // a separate column from the charge because the two arithmetics + // share nothing - a charge reads the target's weight and the hexes + // run, a death from above reads only ours - so a fit that learns + // one has learned nothing about the other. + .with::(4.0) // A fallen Mek loses its next turn and is easier to hit. Worth // real points, well below a kill. .with::(1.5) @@ -879,6 +889,7 @@ macro_rules! for_each_feature { $apply!($crate::features::firing::AttackerSelfDamage); $apply!($crate::features::firing::SelfDamage); $apply!($crate::features::charge::ChargeDamage); + $apply!($crate::features::dfa::DfaDamage); $apply!($crate::features::firing::HeatIncurred); $apply!($crate::features::heat::HeatMpPenalty); $apply!($crate::features::heat::HeatToHitPenalty); diff --git a/crates/sds-core/src/plan.rs b/crates/sds-core/src/plan.rs index c73a567..ed0d599 100644 --- a/crates/sds-core/src/plan.rs +++ b/crates/sds-core/src/plan.rs @@ -406,6 +406,7 @@ mod tests { action: Action::Move { steps: vec![], charge_target: None, + dfa_target: None, }, end: None, features: measured(0.4, 0.1), @@ -534,6 +535,7 @@ mod tests { action: Action::Move { steps: vec![], charge_target: None, + dfa_target: None, }, end: None, features: measured(0.5, 0.25), diff --git a/crates/sds-core/src/reconcile.rs b/crates/sds-core/src/reconcile.rs index ebbe4a9..8c06cdd 100644 --- a/crates/sds-core/src/reconcile.rs +++ b/crates/sds-core/src/reconcile.rs @@ -726,6 +726,7 @@ mod tests { action: Action::Move { steps: vec![], charge_target: None, + dfa_target: None, }, end, features, diff --git a/crates/sds-core/src/stands.rs b/crates/sds-core/src/stands.rs index 8b681be..ea2a7de 100644 --- a/crates/sds-core/src/stands.rs +++ b/crates/sds-core/src/stands.rs @@ -2417,6 +2417,7 @@ pub fn propose( action: Action::Move { steps: Vec::new(), charge_target: None, + dfa_target: None, }, end: Some(end), features, diff --git a/crates/sds-core/src/wire.rs b/crates/sds-core/src/wire.rs index 602c09b..47ce2b1 100644 --- a/crates/sds-core/src/wire.rs +++ b/crates/sds-core/src/wire.rs @@ -334,6 +334,13 @@ pub struct Observation { /// proposed. #[serde(default)] pub charges: Vec, + /// Present in the movement phase: every death from above the rules allow, + /// quoted from the hexes it could be launched from. + /// + /// Empty from a host older than the field, the same way [`Self::charges`] + /// is. + #[serde(default)] + pub dfas: Vec, } /// What one unit charging one target would cost and achieve. @@ -395,6 +402,78 @@ impl ChargeQuote { } } +/// What one unit jumping onto one target would cost and achieve. +/// +/// **A death from above is declared exactly the way a charge is.** The bot +/// sends a move path whose last step enters the target's hex; +/// `MovePathHandler` gates it on `Entity.canDFA()`, builds the +/// `DfaAttackAction` and hands it to the same `Game.addCharge`. What differs is +/// the arithmetic, and all three figures here are the attacker's own weight +/// rather than the target's. +/// +/// # The miss is the interesting half +/// +/// A missed charge is a displacement. A missed death from above is a **fall, +/// with no roll to pass**: `TWGameManager.resolveDfaAttack` answers one with +/// `doEntityFall(attacker, hex, 2, 3, getBasePilotingRoll(), false, false)`. +/// So this quote carries two costs rather than one, and the branch that costs +/// more is the branch where nothing landed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DfaQuote { + /// The unit that would jump. + pub shooter: i32, + /// The unit it would land on. + pub target: i32, + /// Damage the target takes on a hit. + /// + /// `DfaAttackAction.getDamageFor` - `ceil(tons / 10 * 3)`, half again with + /// talons, and cut to `max(1, damage / 10)` against conventional infantry. + /// Flat in the distance jumped, which is the whole difference from a + /// charge's table. + pub damage: i32, + /// Damage the attacker takes on a hit, to the legs. + /// + /// `DfaAttackAction.getDamageTakenBy` - `ceil(tons / 5)`, the attacker's + /// own weight with nothing of the target's in it. + #[serde(rename = "selfDamage")] + pub self_damage: i32, + /// Damage the attacker takes on a **miss**, which is certain rather than + /// risked. + /// + /// `doEntityFall` takes `round(weight / 10) * (height + 1)` and + /// `resolveDfaAttack` passes a height of 2. It runs about half again the + /// figure on a hit, so the cheaper outcome of a death from above is the one + /// where it works. + #[serde(rename = "fallDamage", default)] + pub fall_damage: i32, + /// Every hex the jump could be launched from, with the roll it needs. + #[serde(default)] + pub from: Vec, +} + +impl DfaQuote { + /// The roll this needs from `hex`, if the rules allow it from there at all. + pub fn to_hit_from(&self, x: i32, y: i32) -> Option { + self.from + .iter() + .find(|at| at.x == x && at.y == y) + .map(|at| at.to_hit) + } +} + +/// One hex a death from above could be launched from. +/// +/// There is no gait beside it and that is not an omission: `toHit(Game, int, +/// Targetable, MovePath)` refuses a path that does not jump, with "D.F.A. must +/// involve jumping". A death from above has exactly one gait. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DfaFrom { + pub x: i32, + pub y: i32, + #[serde(rename = "toHit")] + pub to_hit: i32, +} + /// One hex a charge could be launched from, at one gait. /// /// Both halves matter and neither is derivable from the other: the source hex @@ -2159,6 +2238,14 @@ pub enum Action { skip_serializing_if = "Option::is_none" )] charge_target: Option, + /// The unit this move lands on, when it is a death from above. + /// + /// The same mechanism as `chargeTarget` above, with a `DFA` + /// step in place of the `CHARGE` one. The two are mutually exclusive - + /// a charge may not jump and a death from above must - and the host + /// refuses a move naming both rather than sending it. + #[serde(default, rename = "dfaTarget", skip_serializing_if = "Option::is_none")] + dfa_target: Option, }, Fire { attacks: Vec, @@ -2491,6 +2578,7 @@ mod tests { let charging = super::Action::Move { steps: vec!["FORWARDS".into()], charge_target: Some(7), + dfa_target: None, }; let json = serde_json::to_value(&charging).expect("serialises"); assert_eq!(json["chargeTarget"], 7); @@ -2498,6 +2586,7 @@ mod tests { let ordinary = super::Action::Move { steps: vec!["FORWARDS".into()], charge_target: None, + dfa_target: None, }; let json = serde_json::to_value(&ordinary).expect("serialises"); assert!(json.get("chargeTarget").is_none(), "read {json}"); diff --git a/docs/FEATURES.md b/docs/FEATURES.md index c943c7d..d9d46d3 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -10,7 +10,7 @@ Every figure is measured by the feature's own `measure`, through the same code the bot runs. Nothing on this page is a transcription, and `crates/sds-core/tests/vignettes.rs` fails if any of it has gone stale. -All 58 of them, because a feature cannot exist without one - see `crates/sds-core/examples/vignettes/mod.rs`. +All 59 of them, because a feature cannot exist without one - see `crates/sds-core/examples/vignettes/mod.rs`. ## ammo_spent @@ -182,6 +182,16 @@ A share of the whole board rather than of an open field, which is what makes it The ridge reads 1.00 and the ground either side of it reads well under, which is the whole of the feature: at deployment there is nobody to have a line to, so the only thing worth asking about a hex is how much of the map it commands. Compare it with `deploy_ground` on the same board - the two disagree, and a hex that both like is a hex worth standing in. +## dfa_damage + +`bounded`. The share of everything we could put out that this death from above lands instead. + +**The figure shows** one hundred-ton machine jumping onto targets that need a 4, a 7, a 9 and an 11, against a turn of shooting that would land twenty points from where it stands. MegaMek's own figures at that weight are 30 dealt, 20 taken to the legs on a hit, and 30 taken on a miss. + +![dfa_damage](features/dfa_damage.svg) + +A share of the same denominator `expected_damage` divides by - the machine's own best turn - so a death from above, a charge and a volley are three readings of one scale and the force picks between them by comparing numbers rather than kinds. The third line under each bar is the one that has no counterpart anywhere else in the basis. Every other attack costs less as it gets less likely to connect; this one costs *more*, because a miss is a fall with no roll to pass and a fall takes half again what landing does. At an 11 the machine expects to put about six points on somebody and take about twenty-eight back. Those points sit in `self_damage` and the floor they leave us on sits in `attacker_self_damage`, so what this column says is only what the jump buys. + ## distance_to_objective `bounded`. How far this hex is from the hex the order named, as a share of the board's longest distance. diff --git a/docs/TACTICS.md b/docs/TACTICS.md index 6b00ddb..e197ece 100644 --- a/docs/TACTICS.md +++ b/docs/TACTICS.md @@ -86,7 +86,7 @@ Close all the way in, to where the fists reach. The same board and the same eight machines under every implemented tactic, so a difference between two figures is a difference between two weightings and nothing else. Each arrow is an argmax over that unit's real reachable set, scored through the builder the bot's own menu calls - not a drawing of where a tactic ought to go. Their lance does not move, and is scored where it stands rather than over everywhere it could reach, so the picture and the arithmetic agree about what ours was deciding against. Two arrows ending in one hex are not a bug either, and are worth looking at: each unit is scored alone, so nothing here stops four machines wanting the same ground. Reconciling that is the force layer's job, and this is what it has to work with. -Moves 5 of the 59 features in the basis: `enemy_centroid`, `enemy_nearest`, `expected_damage`, `incoming_damage`, `range_band_fit`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 5 of the 60 features in the basis: `enemy_centroid`, `enemy_nearest`, `expected_damage`, `incoming_damage`, `range_band_fit`. Everything not drawn is priced exactly as `Engage` prices it. Agrees most with `engage`, 0.50 of the time on the mirrored corpus and 0.45 on the asymmetric one - partly new. @@ -104,7 +104,7 @@ The bars below are that stand-in, drawn so the gap is legible rather than hidden The same board and the same eight machines under every implemented tactic, so a difference between two figures is a difference between two weightings and nothing else. Each arrow is an argmax over that unit's real reachable set, scored through the builder the bot's own menu calls - not a drawing of where a tactic ought to go. Their lance does not move, and is scored where it stands rather than over everywhere it could reach, so the picture and the arithmetic agree about what ours was deciding against. Two arrows ending in one hex are not a bug either, and are worth looking at: each unit is scored alone, so nothing here stops four machines wanting the same ground. Reconciling that is the force layer's job, and this is what it has to work with. -Moves 10 of the 59 features in the basis: `cover_quality`, `elevation_gain`, `enemy_nearest`, `incoming_damage`, `indirect_share`, `level_terrain`, `level_tmm`, `los_in`, `los_out`, `percent_options`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 10 of the 60 features in the basis: `cover_quality`, `elevation_gain`, `enemy_nearest`, `incoming_damage`, `indirect_share`, `level_terrain`, `level_tmm`, `los_in`, `los_out`, `percent_options`. Everything not drawn is priced exactly as `Engage` prices it. ## break @@ -116,7 +116,7 @@ Get out of their line entirely, and give up this turn's shot for it. The same board and the same eight machines under every implemented tactic, so a difference between two figures is a difference between two weightings and nothing else. Each arrow is an argmax over that unit's real reachable set, scored through the builder the bot's own menu calls - not a drawing of where a tactic ought to go. Their lance does not move, and is scored where it stands rather than over everywhere it could reach, so the picture and the arithmetic agree about what ours was deciding against. Two arrows ending in one hex are not a bug either, and are worth looking at: each unit is scored alone, so nothing here stops four machines wanting the same ground. Reconciling that is the force layer's job, and this is what it has to work with. -Moves 7 of the 59 features in the basis: `cover_distance`, `cover_quality`, `expected_damage`, `los_in`, `los_out`, `p_kill`, `range_band_fit`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 7 of the 60 features in the basis: `cover_distance`, `cover_quality`, `expected_damage`, `los_in`, `los_out`, `p_kill`, `range_band_fit`. Everything not drawn is priced exactly as `Engage` prices it. Agrees most with `harass`, 0.21 of the time on the mirrored corpus and 0.16 on the asymmetric one - a new direction. @@ -132,7 +132,7 @@ The bars below are that stand-in, drawn so the gap is legible rather than hidden ![control](tactics/control.svg) -Moves 9 of the 59 features in the basis: `cohesion`, `cover_quality`, `elevation_gain`, `incoming_damage`, `level_terrain`, `level_tmm`, `los_in`, `los_out`, `percent_options`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 9 of the 60 features in the basis: `cohesion`, `cover_quality`, `elevation_gain`, `incoming_damage`, `level_terrain`, `level_tmm`, `los_in`, `los_out`, `percent_options`. Everything not drawn is priced exactly as `Engage` prices it. ## engage @@ -144,7 +144,7 @@ Fight where our guns work, and take the exchange we are offered. The same board and the same eight machines under every implemented tactic, so a difference between two figures is a difference between two weightings and nothing else. Each arrow is an argmax over that unit's real reachable set, scored through the builder the bot's own menu calls - not a drawing of where a tactic ought to go. Their lance does not move, and is scored where it stands rather than over everywhere it could reach, so the picture and the arithmetic agree about what ours was deciding against. Two arrows ending in one hex are not a bug either, and are worth looking at: each unit is scored alone, so nothing here stops four machines wanting the same ground. Reconciling that is the force layer's job, and this is what it has to work with. -The baseline itself, so the other figures have something to be differences from. It gives a non-zero weight to 39 of the 59 features in the basis. The rest are not oversights: a firing decision measures none of the movement features and a movement decision none of the firing ones, so a weight for a feature that decision never took would contribute nothing anyway. `heat_incurred` is the one deliberate zero among the features it does name - see the comment on `Weights::hand_authored` for why the hand set declines to guess it. +The baseline itself, so the other figures have something to be differences from. It gives a non-zero weight to 40 of the 60 features in the basis. The rest are not oversights: a firing decision measures none of the movement features and a movement decision none of the firing ones, so a weight for a feature that decision never took would contribute nothing anyway. `heat_incurred` is the one deliberate zero among the features it does name - see the comment on `Weights::hand_authored` for why the hand set declines to guess it. Agrees most with `flank`, 0.66 of the time on the mirrored corpus and 0.58 on the asymmetric one - partly new. @@ -158,7 +158,7 @@ Take ground that is hard to shoot us on, and hold it. The same board and the same eight machines under every implemented tactic, so a difference between two figures is a difference between two weightings and nothing else. Each arrow is an argmax over that unit's real reachable set, scored through the builder the bot's own menu calls - not a drawing of where a tactic ought to go. Their lance does not move, and is scored where it stands rather than over everywhere it could reach, so the picture and the arithmetic agree about what ours was deciding against. Two arrows ending in one hex are not a bug either, and are worth looking at: each unit is scored alone, so nothing here stops four machines wanting the same ground. Reconciling that is the force layer's job, and this is what it has to work with. -Moves 8 of the 59 features in the basis: `cover_quality`, `elevation_gain`, `incoming_damage`, `level_terrain`, `level_tmm`, `los_in`, `los_out`, `percent_options`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 8 of the 60 features in the basis: `cover_quality`, `elevation_gain`, `incoming_damage`, `level_terrain`, `level_tmm`, `los_in`, `los_out`, `percent_options`. Everything not drawn is priced exactly as `Engage` prices it. Agrees most with `engage`, 0.38 of the time on the mirrored corpus and 0.40 on the asymmetric one - a new direction. @@ -172,7 +172,7 @@ Take the arc rather than the range: get behind them. The same board and the same eight machines under every implemented tactic, so a difference between two figures is a difference between two weightings and nothing else. Each arrow is an argmax over that unit's real reachable set, scored through the builder the bot's own menu calls - not a drawing of where a tactic ought to go. Their lance does not move, and is scored where it stands rather than over everywhere it could reach, so the picture and the arithmetic agree about what ours was deciding against. Two arrows ending in one hex are not a bug either, and are worth looking at: each unit is scored alone, so nothing here stops four machines wanting the same ground. Reconciling that is the force layer's job, and this is what it has to work with. -Moves 5 of the 59 features in the basis: `cohesion`, `enemy_centroid`, `enemy_nearest`, `range_band_fit`, `rear_arc_gain`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 5 of the 60 features in the basis: `cohesion`, `enemy_centroid`, `enemy_nearest`, `range_band_fit`, `rear_arc_gain`. Everything not drawn is priced exactly as `Engage` prices it. Agrees most with `engage`, 0.66 of the time on the mirrored corpus and 0.58 on the asymmetric one - partly new. @@ -186,7 +186,7 @@ Shoot from where we can still leave, and never be where they want us. The same board and the same eight machines under every implemented tactic, so a difference between two figures is a difference between two weightings and nothing else. Each arrow is an argmax over that unit's real reachable set, scored through the builder the bot's own menu calls - not a drawing of where a tactic ought to go. Their lance does not move, and is scored where it stands rather than over everywhere it could reach, so the picture and the arithmetic agree about what ours was deciding against. Two arrows ending in one hex are not a bug either, and are worth looking at: each unit is scored alone, so nothing here stops four machines wanting the same ground. Reconciling that is the force layer's job, and this is what it has to work with. -Moves 6 of the 59 features in the basis: `cover_distance`, `enemy_nearest`, `expected_damage`, `incoming_damage`, `los_in`, `percent_options`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 6 of the 60 features in the basis: `cover_distance`, `enemy_nearest`, `expected_damage`, `incoming_damage`, `los_in`, `percent_options`. Everything not drawn is priced exactly as `Engage` prices it. Agrees most with `engage`, 0.45 of the time on the mirrored corpus and 0.51 on the asymmetric one - partly new. @@ -200,7 +200,7 @@ Get back together before doing anything else. The same board and the same eight machines under every implemented tactic, so a difference between two figures is a difference between two weightings and nothing else. Each arrow is an argmax over that unit's real reachable set, scored through the builder the bot's own menu calls - not a drawing of where a tactic ought to go. Their lance does not move, and is scored where it stands rather than over everywhere it could reach, so the picture and the arithmetic agree about what ours was deciding against. Two arrows ending in one hex are not a bug either, and are worth looking at: each unit is scored alone, so nothing here stops four machines wanting the same ground. Reconciling that is the force layer's job, and this is what it has to work with. -Moves 5 of the 59 features in the basis: `cohesion`, `enemy_nearest`, `expected_damage`, `friend_support`, `incoming_damage`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 5 of the 60 features in the basis: `cohesion`, `enemy_nearest`, `expected_damage`, `friend_support`, `incoming_damage`. Everything not drawn is priced exactly as `Engage` prices it. Agrees most with `engage`, 0.44 of the time on the mirrored corpus and 0.50 on the asymmetric one - partly new. @@ -216,7 +216,7 @@ The bars below are that stand-in, drawn so the gap is legible rather than hidden ![reposition](tactics/reposition.svg) -Moves 5 of the 59 features in the basis: `expected_damage`, `incoming_damage`, `p_kill`, `percent_options`, `range_band_fit`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 5 of the 60 features in the basis: `expected_damage`, `incoming_damage`, `p_kill`, `percent_options`, `range_band_fit`. Everything not drawn is priced exactly as `Engage` prices it. ## screen @@ -230,7 +230,7 @@ The bars below are that stand-in, drawn so the gap is legible rather than hidden ![screen](tactics/screen.svg) -Moves 4 of the 59 features in the basis: `cohesion`, `friend_support`, `incoming_damage`, `los_in`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 4 of the 60 features in the basis: `cohesion`, `friend_support`, `incoming_damage`, `los_in`. Everything not drawn is priced exactly as `Engage` prices it. ## spot @@ -238,7 +238,7 @@ See for somebody else, and accept the worse shot. ![spot](tactics/spot.svg) -Moves 12 of the 59 features in the basis: `cover_quality`, `elevation_gain`, `expected_damage`, `incoming_damage`, `level_terrain`, `level_tmm`, `los_in`, `los_out`, `overlook`, `p_kill`, `percent_options`, `range_band_fit`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 12 of the 60 features in the basis: `cover_quality`, `elevation_gain`, `expected_damage`, `incoming_damage`, `level_terrain`, `level_tmm`, `los_in`, `los_out`, `overlook`, `p_kill`, `percent_options`, `range_band_fit`. Everything not drawn is priced exactly as `Engage` prices it. ## withdraw @@ -250,6 +250,6 @@ Get off the board alive. The same board and the same eight machines under every implemented tactic, so a difference between two figures is a difference between two weightings and nothing else. Each arrow is an argmax over that unit's real reachable set, scored through the builder the bot's own menu calls - not a drawing of where a tactic ought to go. Their lance does not move, and is scored where it stands rather than over everywhere it could reach, so the picture and the arithmetic agree about what ours was deciding against. Two arrows ending in one hex are not a bug either, and are worth looking at: each unit is scored alone, so nothing here stops four machines wanting the same ground. Reconciling that is the force layer's job, and this is what it has to work with. -Moves 9 of the 59 features in the basis: `cohesion`, `edge_distance`, `enemy_nearest`, `expected_damage`, `incoming_damage`, `level_tmm`, `los_in`, `p_kill`, `range_band_fit`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 9 of the 60 features in the basis: `cohesion`, `edge_distance`, `enemy_nearest`, `expected_damage`, `incoming_damage`, `level_tmm`, `los_in`, `p_kill`, `range_band_fit`. Everything not drawn is priced exactly as `Engage` prices it. Agrees most with `break`, 0.16 of the time on the mirrored corpus and 0.09 on the asymmetric one - a new direction. diff --git a/docs/features/dfa_damage.svg b/docs/features/dfa_damage.svg new file mode 100644 index 0000000..968a955 --- /dev/null +++ b/docs/features/dfa_damage.svg @@ -0,0 +1,98 @@ +needs 41.0092% to land27.5 expected onto them20.8 expected back at usneeds 70.8858% to land17.5 expected onto them24.2 expected back at usneeds 90.4228% to land8.3 expected onto them27.2 expected back at usneeds 110.128% to land2.5 expected onto them29.2 expected back at usdfa_damage diff --git a/docs/tactics/engage.svg b/docs/tactics/engage.svg index 0a88c04..4a9d081 100644 --- a/docs/tactics/engage.svg +++ b/docs/tactics/engage.svg @@ -1,4 +1,4 @@ - + - + engage its own weight per feature - + ammo_spent -0.50 @@ -97,77 +97,80 @@ deploy_outlook +1.50 -elevation_gain - -+1.00 -expected_damage - -+4.00 -heat_ammo_explosion_risk - --8.00 -heat_mp_penalty - --1.00 -heat_shutdown_risk - --6.00 -heat_to_hit_penalty - --2.00 -incoming_damage - --4.00 -level_terrain - -+1.50 -level_tmm +dfa_damage + ++4.00 +elevation_gain + ++1.00 +expected_damage + ++4.00 +heat_ammo_explosion_risk + +-8.00 +heat_mp_penalty + +-1.00 +heat_shutdown_risk + +-6.00 +heat_to_hit_penalty + +-2.00 +incoming_damage + +-4.00 +level_terrain +1.50 -los_in - --1.50 -los_out - -+1.50 -overkill - --3.00 -p_kill - -+8.00 -p_psr_threshold - -+1.50 -percent_options - -+2.00 -range_band_fit - -+3.00 -rear_arc_exposure - --2.00 -rear_arc_gain - -+2.00 -self_damage - --4.00 -submerged +level_tmm + ++1.50 +los_in + +-1.50 +los_out + ++1.50 +overkill + +-3.00 +p_kill + ++8.00 +p_psr_threshold + ++1.50 +percent_options + ++2.00 +range_band_fit + ++3.00 +rear_arc_exposure + +-2.00 +rear_arc_gain + ++2.00 +self_damage -4.00 -target_breach - -+2.50 -target_gunnery - -+1.00 -target_health - --2.00 -target_piloting - -+0.50 -39 feature(s), widest move 8.00 +submerged + +-4.00 +target_breach + ++2.50 +target_gunnery + ++1.00 +target_health + +-2.00 +target_piloting + ++0.50 +40 feature(s), widest move 8.00 diff --git a/weights/hand-authored.json b/weights/hand-authored.json index e5dd4aa..b579c93 100644 --- a/weights/hand-authored.json +++ b/weights/hand-authored.json @@ -37,6 +37,7 @@ "rear_arc_gain": 2.0, "attacker_self_damage": -4.0, "charge_damage": 4.0, + "dfa_damage": 4.0, "self_damage": -4.0, "submerged": -4.0, "target_breach": 2.5, -- 2.51.2