From 6c76859dcfee44a3d2db2b29d77b68cb2642e0e9 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 4 Sep 2026 16:07:10 -0400 Subject: [PATCH] feat(melee)!: the charge, priced against the firing turn it costs A charge is declared by moving: `MovePathHandler` watches the submitted path for a `CHARGE` step, builds the `ChargeAttackAction` itself and hands it to `Game.addCharge`. `Entity.isEligibleForFiring` returns false for a charging unit, so a charge candidate is scored with every volley column zeroed and a new `charge_damage` column carrying its whole offence, against the same denominator `expected_damage` divides by. Adding a column changes the basis fingerprint, so every fitted weight set is stale against it. Change-Id: I6f22cf9396afb62e685e0e7f00e540a36b784993 --- bridge/sds/Observation.java | 108 ++++++++++ bridge/sds/SdsClient.java | 24 +++ crates/sds-bot/src/candidates.rs | 206 +++++++++++++++++- crates/sds-bot/src/force.rs | 10 +- crates/sds-bot/src/imitate.rs | 1 + crates/sds-bot/src/main.rs | 20 +- crates/sds-bot/src/unit.rs | 33 ++- crates/sds-core/examples/vignettes/charge.rs | 80 +++++++ crates/sds-core/examples/vignettes/mod.rs | 7 +- crates/sds-core/src/features/charge.rs | 129 ++++++++++++ crates/sds-core/src/features/incoming.rs | 5 +- crates/sds-core/src/features/latch.rs | 1 + crates/sds-core/src/features/mod.rs | 11 + crates/sds-core/src/pathfind.rs | 14 ++ crates/sds-core/src/plan.rs | 10 +- crates/sds-core/src/reconcile.rs | 5 +- crates/sds-core/src/stands.rs | 5 +- crates/sds-core/src/wire.rs | 166 +++++++++++++++ docs/FEATURES.md | 12 +- docs/TACTICS.md | 24 +-- docs/features/charge_damage.svg | 98 +++++++++ docs/tactics/engage.svg | 211 ++++++++++--------- plan/melee.md | 96 ++++++++- scenarios/charge-duel.mms | 27 +++ weights/charge-damage-off.json | 46 ++++ weights/hand-authored.json | 1 + 26 files changed, 1201 insertions(+), 149 deletions(-) create mode 100644 crates/sds-core/examples/vignettes/charge.rs create mode 100644 crates/sds-core/src/features/charge.rs create mode 100644 docs/features/charge_damage.svg create mode 100644 scenarios/charge-duel.mms create mode 100644 weights/charge-damage-off.json diff --git a/bridge/sds/Observation.java b/bridge/sds/Observation.java index 2501a3f..90d9e4e 100644 --- a/bridge/sds/Observation.java +++ b/bridge/sds/Observation.java @@ -24,6 +24,7 @@ import megamek.common.board.Coords; import megamek.common.compute.Compute; import megamek.common.units.EntityMovementType; import megamek.common.battleArmor.BattleArmor; +import megamek.common.actions.ChargeAttackAction; import megamek.common.actions.ClubAttackAction; import megamek.common.actions.KickAttackAction; import megamek.common.actions.PunchAttackAction; @@ -188,9 +189,116 @@ public final class Observation { if (rejected != null && !rejected.isEmpty()) { root.set("rejected", rejected.deepCopy()); } + ArrayNode charges = root.putArray("charges"); + List movers = (eligible == null || eligible.isEmpty()) + ? (actor == null ? List.of() : List.of(actor)) + : eligible; + for (Entity mover : movers) { + chargesFor(game, me, mover, charges); + } return root; } + /** + * Every charge this unit could declare, quoted from the hex it would come + * from. + * + *

A charge is a movement decision that the server turns into an + * attack. Probed rather than assumed: {@code MovePathHandler} watches + * the submitted path for a {@link MoveStepType#CHARGE} step, gates it on + * {@code Entity.canCharge}, builds the {@code ChargeAttackAction} itself + * and hands it to {@code Game.addCharge}. So the bot declares one by + * sending a move, not by naming an attack, and nothing in the physical + * phase has to know about it. + * + *

What it costs is the whole firing phase. + * {@code Entity.isEligibleForFiring} returns false outright for a unit + * whose {@code isCharging} is set, so a charging machine gets no firing + * turn at all. That is the trade this puts on the menu, and it is why the + * candidate that carries a charge scores no volley. + * + *

The quote is per source hex because that is what + * {@code ChargeAttackAction.toHit(Game, Targetable, Coords, int, + * EntityMovementType, boolean, boolean)} takes, and per gait because the + * attacker's movement type is one of its modifiers. Distance is not in it: + * the to-hit is the same however far the machine ran to arrive, and only + * the damage reads the count. So a fixed six neighbours times two gaits + * per target is the whole table, whatever the board is. + * + *

{@code damage} is indexed by hexes moved rather than computed here. + * {@code ChargeAttackAction.getDamageFor} is not a formula worth copying - + * it reads the attacker's weight, whether its centre torso is under water, + * and the {@code tacops_charge_damage} option - so MegaMek is asked once + * per hex count the machine could reach and the answers travel. + */ + private void chargesFor(Game game, Player me, Entity mover, ArrayNode charges) { + if (mover.getPosition() == null || !mover.canCharge()) { + return; + } + for (Entity target : game.getEntitiesVector()) { + if (target.getPosition() == null || target.getId() == mover.getId()) { + continue; + } + ChargeAttackAction action = new ChargeAttackAction(mover, target); + ObjectNode quote = null; + for (Coords from : target.getPosition().allAdjacent()) { + if (!game.getBoard().contains(from)) { + continue; + } + for (EntityMovementType gait : CHARGE_GAITS) { + ToHitData toHit; + try { + toHit = action.toHit(game, target, from, mover.getElevation(), + gait, false, false); + } catch (RuntimeException problem) { + continue; + } + if (toHit == null || toHit.getValue() == TargetRoll.IMPOSSIBLE + || toHit.getValue() == TargetRoll.AUTOMATIC_FAIL) { + continue; + } + if (quote == null) { + quote = charges.addObject(); + quote.put("shooter", mover.getId()); + quote.put("target", target.getId()); + // Set by the *target*, not by the attacker and not by + // how far it ran: `getDamageTakenBy` reads the target's + // weight and nothing of ours but whether we are wading. + quote.put("selfDamage", + ChargeAttackAction.getDamageTakenBy(mover, target)); + ArrayNode byHexes = quote.putArray("damageByHexes"); + // Index is hexes moved. Nought and one are both in it so + // a reader indexes rather than offsets, and MegaMek + // answers both rather than this deciding what they mean. + int furthest = Math.max(mover.getRunMP(), 1); + for (int hexes = 0; hexes <= furthest; hexes++) { + byHexes.add(ChargeAttackAction.getDamageFor( + mover, target, false, 0, hexes)); + } + quote.putArray("from"); + } + ObjectNode at = ((ArrayNode) quote.get("from")).addObject(); + at.put("x", from.getX()); + at.put("y", from.getY()); + at.put("gait", gait == EntityMovementType.MOVE_RUN ? "run" : "walk"); + at.put("toHit", toHit.getValue()); + } + } + } + } + + /** + * The two gaits a charge can be made at. + * + *

A jump is not among them: {@code ChargeAttackAction.toHit(Game, + * MovePath)} refuses a path containing {@code START_JUMP} with "No jumping + * allowed while charging". Jumping onto something is a death from above, + * which is a different attack. + */ + private static final EntityMovementType[] CHARGE_GAITS = { + EntityMovementType.MOVE_WALK, EntityMovementType.MOVE_RUN, + }; + /** * 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 07e49a6..c94f804 100644 --- a/bridge/sds/SdsClient.java +++ b/bridge/sds/SdsClient.java @@ -1088,6 +1088,30 @@ public final class SdsClient extends BotClient { } path.addStep(type); } + // **A charge is declared as a step, not as an attack.** Probed: + // `MovePathHandler` watches the submitted path for a `CHARGE` step, + // gates it on `Entity.canCharge`, builds the `ChargeAttackAction` + // itself and hands it to `Game.addCharge`. So the only thing the bot + // has to say is which target the last step goes into, and the target is + // what `MovePath.addStep(MoveStepType, Targetable)` carries - a bare + // `addStep(CHARGE)` names nobody and the server drops it. + // + // It rides beside `steps` rather than inside it because `steps` is a + // list of names all the way down, and the refusal machinery in + // `wire.rs` compares those names as prefixes. A charge is always the + // last step, so a field is enough and the prefix comparison is left + // alone. + JsonNode charge = reply.path("chargeTarget"); + if (charge.isInt()) { + Entity into = getGame().getEntity(charge.asInt()); + if (into == null) { + System.err.println("[sds] " + getName() + " charged a unit that is not there: " + + charge.asInt()); + refusedAt = -1; + return null; + } + path.addStep(MoveStepType.CHARGE, into); + } 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 9e36d26..ea82995 100644 --- a/crates/sds-bot/src/candidates.rs +++ b/crates/sds-bot/src/candidates.rs @@ -52,16 +52,20 @@ use sds_core::facts::Gait; use sds_core::hex::distance as hex_distance; use sds_core::los::LosCache; use sds_core::pathfind::{ - reachable_within, search_within, steps_to, MoveBoard, OptionsCache, Reach, Walker, + reachable_within, search_forwards, search_within, steps_to, MoveBoard, OptionsCache, Reach, + Search, Walker, }; use sds_core::plan::{Proposal, Threat}; use sds_core::stance::Stance; 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::firing; +use sds_core::features::score::Score; use sds_core::surface::{self, facts_of}; use sds_core::volley::{self, MountedWeapon}; -use sds_core::wire::{Action, Coord, Observation, Unit}; +use sds_core::wire::{hit_chance, Action, ChargeQuote, Coord, Observation, Unit}; /// The marker every generated label carries. /// @@ -147,6 +151,10 @@ pub struct Cost { /// in. A memo that never hits and one that always does look identical from /// the outside without these. pub carried: Tally, + /// Charge candidates this sweep added to the menu, and the stands they + /// 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, } /// Everything one sweep produced: the menu, what it cost, and what it was @@ -460,7 +468,10 @@ pub fn generate( "{}{MARK}({}, {}) facing {}", pick_label, stand.hex.x, stand.hex.y, stand.facing ); - proposal.action = Action::Move { steps }; + proposal.action = Action::Move { + steps, + charge_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 // run scored as a walk is a lie in both directions - so it is written @@ -473,10 +484,26 @@ pub fn generate( )); out.push(proposal); } + // The charge routes, and only when there is a charge to route. A second + // ground Dijkstra costs about what the first one did - a fraction of a + // millisecond against a sweep measured in tens - and it is skipped + // entirely on a board where nothing is quoted. + let forwards = observation + .charges + .iter() + .any(|quote| quote.shooter == me.id) + .then(|| search_forwards(move_board, &Walker::running(me), &blocked)); + let charges = match forwards.as_ref() { + Some(forwards) => charge_proposals(observation, me, forwards, &scored, &out), + None => Vec::new(), + }; + let charge_count = charges.len() as u64; + out.extend(charges); if out.is_empty() { return None; } let cost = Cost { + charges: charge_count, reach_considered: considered, reach_dropped: dropped, volley_hits: volley_counters.hits - counters_at_start.hits, @@ -670,6 +697,179 @@ pub(crate) fn envelope(observation: &Observation, board: &MoveBoard, unit: &Unit .collect() } +/// The charges this unit could declare, built from the stands it can reach. +/// +/// # A charge is a stand plus one step +/// +/// **The server builds the attack, not the bot.** Probed rather than assumed: +/// `MovePathHandler` watches a submitted move path for a `CHARGE` step, gates +/// it on `Entity.canCharge`, constructs the `ChargeAttackAction` itself and +/// hands it to `Game.addCharge`. So a charge candidate is a stand the sweep +/// already found, adjacent to a target and facing it, with one more step on +/// the end - which is why this is a pass over `scored` rather than a second +/// search. +/// +/// # What it costs, and why the volley columns read nought +/// +/// `Entity.isEligibleForFiring` returns false outright when `isCharging` is +/// set. A charging machine is given no firing turn at all, so a charge +/// candidate is priced with every column that describes a volley zeroed and +/// [`ChargeDamage`] carrying the whole of its offence. That is the trade the +/// force is being asked to make, and stating it as "this damage instead of +/// that damage" is what puts the two on one scale: +/// [`firing::ceiling`] is the denominator of both. +/// +/// # What is approximate here, stated rather than corrected +/// +/// - **The hex.** On a hit the attacker takes the target's hex and the target +/// is displaced. Every positional column is therefore the *launching* hex's, +/// because that is the hex the sweep scored and the landing hex exists only +/// if the roll succeeds. +/// - **The gait.** The charge step's own MP cost is taken as one, so a charge +/// into rough ground may be quoted at the walking to-hit when MegaMek would +/// call it a run. The quote carries both gaits and the wrong one is a +/// one-point error in the roll, not an illegal move: `toHit(Game, MovePath)` +/// recomputes it server-side from the path that actually arrives. +/// - **`heat_incurred` and the heat chart** are left as the stand measured +/// them, which includes the volley's heat that a charging unit never pays. +/// That over-charges a charge, which is the safe direction for a term whose +/// job is to make the bot cautious. +/// - **The self-damage points are not priced.** +/// `ChargeAttackAction.getDamageTakenBy` is certain damage set by the +/// target's weight, and `attacker_self_damage` holds a *fall chance* rather +/// than a health share - so there is no column it fits today. This +/// under-prices a charge. See `plan/melee.md`. +fn charge_proposals( + observation: &Observation, + me: &Unit, + forwards: &Search, + scored: &[stands::StandScore], + stands_out: &[Proposal], +) -> Vec { + let quotes: Vec<&ChargeQuote> = observation + .charges + .iter() + .filter(|quote| quote.shooter == me.id) + .collect(); + if quotes.is_empty() { + return Vec::new(); + } + let ceiling = firing::ceiling(me); + let mut out: Vec = Vec::new(); + for (at, score) in scored.iter().enumerate() { + // A jump is not a charge. `ChargeAttackAction.toHit(Game, MovePath)` + // refuses a path containing `START_JUMP` with "No jumping allowed while + // charging"; jumping onto something is a death from above, which is a + // different attack and not this one. + if score.reach.jumped { + continue; + } + let stand = score.reach.stand; + // The forward-only route to this same stand, and its own numbers. The + // sweep's route is the cheapest one and is very often a reverse, which + // a charge may not be; and the forward route is a different length, so + // the hexes the damage reads and the gait the to-hit reads have to come + // from it rather than from the stand the sweep scored. + let Some(route) = forwards + .reached + .iter() + .find(|reach| reach.stand == stand) + .copied() + else { + continue; + }; + // One step of movement has to be left to make it with. + if route.mp_spent >= me.run_mp.max(0) { + continue; + } + // The hex the machine is looking at. A charge enters the hex in front, + // so the stand's own facing is what says which target - if any - this + // stand could charge, and no turn is added to reach a second one. + 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; + }; + // Running if the charge step takes it past its walking allowance. The + // step is costed at one; see the note above. + let running = route.mp_spent + 1 > me.walk_mp.max(0); + let Some(to_hit) = quote.to_hit_from(stand.hex.x, stand.hex.y, running) else { + continue; + }; + let Some(base) = stands_out.get(at) else { + continue; + }; + let Some(mut proposal) = Some(base.clone()) else { + continue; + }; + let Some(steps) = steps_to(forwards, stand) else { + continue; + }; + let landed = hit_chance(to_hit); + let charge = Charge { + hit_chance: landed, + // One more hex than the stand covered: the charge step itself. + damage: quote.damage_at(route.hexes_moved + 1), + ceiling, + }; + // Every column that reads a volley, zeroed. A charging unit is given no + // firing turn, so leaving these would have credited it with a volley 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::(&charge); + // The roll the *target* is handed. `resolveChargeDamage` calls + // `getBasePilotingRoll` with "was charged" as its reason, gated the way + // every other entry in this column is gated: 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)); + } + // And the roll *we* are handed for making it - "charging", the same + // call, on the same hit. This column is a chance of ending up on the + // floor by our own doing, which is exactly what that is. + if me.psr_base > 0 { + let falls = (1.0 - hit_chance(me.psr_base)).clamp(0.0, 1.0); + features.record::(Score::probability( + (landed * falls).clamp(0.0, 1.0), + )); + } + proposal.label = format!( + "charge {}{MARK}({}, {}) facing {}", + quote.target, stand.hex.x, stand.hex.y, stand.facing + ); + proposal.action = Action::Move { + steps, + charge_target: Some(quote.target), + }; + proposal.damage_dealt = charge.expected(); + proposal.damage_by_target = std::iter::once((quote.target, charge.expected())).collect(); + proposal.notes.push(format!( + "charge into {} on a {}, {} damage, {} taken, {} hexes forwards, no firing turn", + quote.target, to_hit, charge.damage, quote.self_damage, route.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 16818d2..bf257d6 100644 --- a/crates/sds-bot/src/force.rs +++ b/crates/sds-bot/src/force.rs @@ -587,7 +587,10 @@ mod tests { .map(|(at, take)| Proposal { unit: id, label: format!("stand {at}"), - action: Action::Move { steps: vec![] }, + action: Action::Move { + steps: vec![], + charge_target: None, + }, end: Some(Coord::new(at as i32, id)), features: FeatureVector::new(), damage_dealt: 0.0, @@ -872,7 +875,10 @@ mod tests { Proposal { unit: 1, label: label.into(), - action: Action::Move { steps: vec![] }, + action: Action::Move { + steps: vec![], + charge_target: None, + }, end: Some(at), features, damage_dealt: 0.0, diff --git a/crates/sds-bot/src/imitate.rs b/crates/sds-bot/src/imitate.rs index 16f87a6..7a027f4 100644 --- a/crates/sds-bot/src/imitate.rs +++ b/crates/sds-bot/src/imitate.rs @@ -606,6 +606,7 @@ mod tests { fn observation(round: i32, phase: &str, units: Vec) -> Observation { Observation { + charges: 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 4654804..7a99447 100644 --- a/crates/sds-bot/src/main.rs +++ b/crates/sds-bot/src/main.rs @@ -1431,7 +1431,10 @@ impl Bot { Action::Pass } else { // Ours, and no order for it. The inert default, not Princess. - Action::Move { steps: Vec::new() } + Action::Move { + steps: Vec::new(), + charge_target: None, + } }; self.count_turn(&eligible, &choice); return Ok(Turn { @@ -2877,7 +2880,10 @@ temperature {temperature}; do not benchmark this run", // stands still. eprintln!("[sds-bot] planning failed: {error}"); Turn { - action: Action::Move { steps: Vec::new() }, + action: Action::Move { + steps: Vec::new(), + charge_target: None, + }, unit: None, choice: None, } @@ -3056,7 +3062,10 @@ mod tests { Proposal { unit, label: label.into(), - action: Action::Move { steps: vec![] }, + action: Action::Move { + steps: vec![], + charge_target: None, + }, end: None, features, damage_dealt: 0.0, @@ -3208,7 +3217,10 @@ mod tests { fn order(label: &str, value: f32) -> Order { Order { - action: Action::Move { steps: Vec::new() }, + action: Action::Move { + steps: Vec::new(), + charge_target: None, + }, generated: true, value, label: label.to_string(), diff --git a/crates/sds-bot/src/unit.rs b/crates/sds-bot/src/unit.rs index f0fb223..3c89a1f 100644 --- a/crates/sds-bot/src/unit.rs +++ b/crates/sds-bot/src/unit.rs @@ -513,6 +513,7 @@ pub fn propose_including( } else { Vec::new() }, + charge_target: None, }, end: here, hexes_moved: 0, @@ -586,7 +587,10 @@ pub fn propose_including( let moved_hexes = hexes_moved(&steps); offers.push(Offer { label: label.into(), - action: sds_core::wire::Action::Move { steps }, + action: sds_core::wire::Action::Move { + steps, + charge_target: None, + }, end, hexes_moved: moved_hexes, mp_spent: distance(here, end), @@ -624,7 +628,10 @@ pub fn propose_including( if !observed_pushed { offers.push(Offer { label: OBSERVED_LABEL.into(), - action: sds_core::wire::Action::Move { steps: Vec::new() }, + action: sds_core::wire::Action::Move { + steps: Vec::new(), + charge_target: None, + }, end, hexes_moved: distance(here, end), mp_spent: distance(here, end), @@ -671,7 +678,7 @@ pub fn propose_including( "[sds-bot] unit {}: {} states ({} ground, {} jump), \ {} enemy positions ({} jumped, {} unmoved), \ {} exchanges, {} surfaced, {} range brackets, \ - {} weapons not modelled, {} los asked ({}% hit), \ + {} weapons not modelled, {} charges, {} los asked ({}% hit), \ {} of {} enemies out of reach", me.id, cost.states, @@ -684,6 +691,7 @@ pub fn propose_including( cost.surfaced, cost.brackets, cost.unmodelled, + cost.charges, asked, (hits * 100).checked_div(asked).unwrap_or(0), cost.reach_dropped, @@ -798,7 +806,7 @@ pub fn propose_including( // refusals fail on their first step, and the prefix rule pre-empts 62% // of later refusals against 33% for whole-path exclusion. proposals.retain(|proposal| match &proposal.action { - sds_core::wire::Action::Move { steps } => { + sds_core::wire::Action::Move { steps, .. } => { !refused.iter().any(|rejected| rejected.refuses(steps)) } _ => true, @@ -918,7 +926,7 @@ fn measure( // `START_JUMP`, and so do we. let jumped = matches!( &offer.action, - sds_core::wire::Action::Move { steps } + sds_core::wire::Action::Move { steps, .. } if steps.first().is_some_and(|s| s == "START_JUMP") ); // The positional group first, and unconditionally: a hex the enemy can @@ -1500,6 +1508,7 @@ mod tests { fn scene() -> (Observation, Board) { let board = board(); let observation = Observation { + charges: Vec::new(), seq: 1, round: 1, phase: "MOVEMENT".into(), @@ -1539,7 +1548,9 @@ mod tests { .proposals .iter() .filter_map(|proposal| match &proposal.action { - sds_core::wire::Action::Move { steps } if !steps.is_empty() => Some(steps.clone()), + sds_core::wire::Action::Move { steps, .. } if !steps.is_empty() => { + Some(steps.clone()) + } _ => None, }) .collect() @@ -1705,7 +1716,9 @@ mod tests { .proposals .iter() .find_map(|proposal| match &proposal.action { - sds_core::wire::Action::Move { steps } if !steps.is_empty() => Some(steps.clone()), + sds_core::wire::Action::Move { steps, .. } if !steps.is_empty() => { + Some(steps.clone()) + } _ => None, }) .expect("the sweep offered at least one move"); @@ -1777,7 +1790,7 @@ mod tests { .iter() .filter(|p| candidates::is_generated(&p.label)) { - let sds_core::wire::Action::Move { steps } = &proposal.action else { + let sds_core::wire::Action::Move { steps, .. } = &proposal.action else { panic!("a movement candidate that is not a move"); }; assert!( @@ -1993,7 +2006,7 @@ mod tests { vec!["stay prone".to_string()], "a pinned unit was offered more than the one answer it has" ); - let sds_core::wire::Action::Move { steps } = &pinned.proposals[0].action else { + let sds_core::wire::Action::Move { steps, .. } = &pinned.proposals[0].action else { panic!("`stay prone` is not a move"); }; assert!(steps.is_empty(), "`stay prone` carries steps: {steps:?}"); @@ -2023,7 +2036,7 @@ mod tests { assert!( free.proposals.iter().any(|p| matches!( &p.action, - sds_core::wire::Action::Move { steps } if steps.iter().any(|s| s == "GET_UP") + sds_core::wire::Action::Move { steps, .. } if steps.iter().any(|s| s == "GET_UP") )), "no proposal stands the unit up: {:?}", labels(&free) diff --git a/crates/sds-core/examples/vignettes/charge.rs b/crates/sds-core/examples/vignettes/charge.rs new file mode 100644 index 0000000..fae464a --- /dev/null +++ b/crates/sds-core/examples/vignettes/charge.rs @@ -0,0 +1,80 @@ +//! The worked example for the one attack that is declared by moving. +//! +//! Every other offensive column in the basis reads a volley. This one cannot: +//! `Entity.isEligibleForFiring` returns false outright when `isCharging` is +//! set, so a charging machine is given no firing turn at all and there is no +//! volley to read. The figure is therefore about the *trade* rather than about +//! a hex - what a machine gets for spending the phase it would otherwise have +//! shot in. + +use sds_core::features::charge::{Charge, ChargeDamage}; +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, charging the same target from further and further back. +/// +/// The damage figures are the ones the bridge quotes: +/// `ChargeAttackAction.getDamageFor`, asked once per hex count, which for a +/// hundred tons and no water reads ten points a hex after the first. Nothing +/// here recomputes them - a formula copied into a figure is a figure that can +/// disagree with the bot. +const CEILING: f32 = 20.0; + +fn charging(hexes: i32, to_hit: i32) -> Subject { + let damage = (10 * (hexes - 1).max(0)) as f32; + let charge = Charge { + hit_chance: hit_chance(to_hit), + damage, + ceiling: CEILING, + }; + Subject { + label: format!("{hexes} hexes, needs {to_hit}"), + side: Faction::Ours, + value: ChargeDamage::measure(&charge).get(), + lines: vec![ + format!("{damage:.0} damage if it lands"), + format!("{:.0}% to land", hit_chance(to_hit) * 100.0), + format!("{:.1} expected", charge.expected()), + ], + } +} + +impl Documented for ChargeDamage { + fn example() -> Example { + Example { + shows: "one hundred-ton machine charging the same target from one, two, four \ + and seven hexes back, against a turn of shooting that would land twenty \ + points from where it stands. The roll gets worse as the run gets \ + longer, because a charge is made at a run and the attacker's own \ + movement is one of its modifiers.", + reading: "A share of the same denominator `expected_damage` divides by - the \ + machine's own best turn - so the two columns are directly \ + comparable, and that comparison is the decision. From one hex a \ + charge is worth nothing at all: MegaMek's own damage figure is ten \ + points a hex *after the first*, so a machine that has not run reads \ + 0.00 and can only lose the firing turn it would have spent. From \ + four it is worth about as much as shooting, and from seven it is \ + worth more than the machine could shoot even though it lands less \ + than half the time. \ + \ + What this column deliberately does not carry is on the other side \ + of the vector: the piloting roll the target is handed sits in \ + `p_psr_threshold`, and the one *we* are handed for making it sits \ + in `attacker_self_damage`. The certain damage a charge costs its \ + own attacker - set by the target's weight, not by how far we ran - \ + has no column at all yet, so every number in this figure is an \ + over-estimate of what a charge is worth.", + figure: Figure::Row(RowFigure { + subjects: vec![ + charging(1, 5), + charging(2, 5), + charging(4, 6), + charging(7, 8), + ], + }), + } + } +} diff --git a/crates/sds-core/examples/vignettes/mod.rs b/crates/sds-core/examples/vignettes/mod.rs index fbeca3e..e7c239a 100644 --- a/crates/sds-core/examples/vignettes/mod.rs +++ b/crates/sds-core/examples/vignettes/mod.rs @@ -58,6 +58,7 @@ pub mod page; pub mod render; pub mod scene; +mod charge; mod deployment; mod firing; mod force; @@ -110,7 +111,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); 7] = [ +pub const FAMILIES: [(&str, &str); 8] = [ ( "deployment", "Deployment: where to put a machine down, before anything has moved", @@ -124,6 +125,10 @@ pub const FAMILIES: [(&str, &str); 7] = [ ("target", "Target: which of them to shoot"), ("force", "Force: what the whole side's shooting adds up to"), ("incoming", "Incoming: what is about to be done to us"), + ( + "charge", + "Charge: the one attack declared by moving, and the firing turn it costs", + ), ]; impl Vignette { diff --git a/crates/sds-core/src/features/charge.rs b/crates/sds-core/src/features/charge.rs new file mode 100644 index 0000000..247e176 --- /dev/null +++ b/crates/sds-core/src/features/charge.rs @@ -0,0 +1,129 @@ +//! What a charge is worth, and what it costs to have made one. +//! +//! **A charge is the one attack that is declared by moving.** Probed rather +//! than assumed: `MovePathHandler` watches a submitted move path for a +//! [`MoveStepType::CHARGE`][step] step, gates it on `Entity.canCharge`, builds +//! the `ChargeAttackAction` itself and hands it to `Game.addCharge`. The bot +//! never names an attack; it names a hex to end in and a unit to end in it. +//! +//! [step]: https://megamek.org +//! +//! # Why it needs a column of its own +//! +//! Every other offensive column on the vector reads a volley, and a charging +//! machine has no volley to read. `Entity.isEligibleForFiring` returns false +//! outright when `isCharging` is set, so a unit that charges is given no firing +//! turn at all - the whole phase is spent. Routing the charge's damage through +//! [`ExpectedDamage`][crate::features::firing::ExpectedDamage] would therefore +//! have said the opposite of what happens: it would have read as a volley the +//! unit is simultaneously not taking. +//! +//! The denominator is the same one a volley uses - +//! [`crate::features::firing::ceiling`], the machine's own best turn - so the +//! two columns are on one scale and a force choosing between charging and +//! shooting is comparing like with like. +//! +//! # What this column does not carry +//! +//! - **The self-damage.** It belongs in +//! [`AttackerSelfDamage`][crate::features::firing::AttackerSelfDamage], +//! where the epic already put it: "the question 'what does this cost me' is +//! one question". `ChargeAttackAction.getDamageTakenBy` is set by the target +//! and by nothing of ours - every attacker takes the same for charging the +//! same machine, however far it ran and whatever it weighs. +//! - **The piloting roll it forces.** `resolveChargeDamage` calls +//! `getBasePilotingRoll` with "was charged" as its reason, which is the +//! `p_psr_threshold` column's own question. +//! - **Where the machine ends up.** On a hit the attacker takes the target's +//! hex and the target is displaced, so a charge candidate's positional terms +//! describe the hex it charges *from* rather than the hex it lands in. That +//! is stated rather than corrected: the launching hex is the one the sweep +//! scored, and pricing the landing hex means scoring a position that exists +//! only if the roll succeeds. + +use crate::features::score::{Bounded, Score}; +use crate::features::Feature; + +/// One charge, priced. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Charge { + /// The chance the roll connects. + pub hit_chance: f32, + /// Damage MegaMek says it deals at the distance this candidate covers. + pub damage: f32, + /// The attacker's own best turn, in damage - the same denominator + /// [`crate::features::firing::ExpectedDamage`] divides by. + pub ceiling: f32, +} + +impl Charge { + /// 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 charging. +pub struct ChargeDamage; + +impl Feature for ChargeDamage { + type Norm = Bounded; + type Input<'a> = Charge; + const NAME: &'static str = "charge_damage"; + const DESCRIPTION: &'static str = + "The share of everything we could put out that this charge lands instead."; + + fn measure(charge: &Charge) -> Score { + if charge.ceiling <= 0.0 { + return Score::fraction(0.0); + } + Score::fraction(charge.expected() / charge.ceiling) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::features::FeatureVector; + + /// A charge reads as a share of the same turn a volley is a share of. + /// + /// The two columns divide by [`crate::features::firing::ceiling`], so an + /// Atlas that would land 20 points of guns and 30 points of charge reads + /// the charge higher - which is the comparison the force has to be able to + /// make, and the one it could not make while the charge had no column. + #[test] + fn a_charge_is_a_share_of_the_same_turn() { + let mut vector = FeatureVector::new(); + let landed = vector.measure::(&Charge { + hit_chance: 0.5, + damage: 30.0, + ceiling: 30.0, + }); + assert!((landed - 0.5).abs() < 1e-6, "read {landed}"); + + // A machine with nothing to put out has no share to take. Zero rather + // than a division, and zero rather than a saturated one: a ceiling of + // nought is a unit that cannot fight, not a unit whose every option is + // perfect. + let none = ChargeDamage::measure(&Charge { + hit_chance: 1.0, + damage: 10.0, + ceiling: 0.0, + }); + assert_eq!(none.get(), 0.0); + } + + /// It saturates rather than exceeding one, the way every bounded column + /// does: a charge worth more than the machine's whole turn is worth all of + /// it and no more. + #[test] + fn a_charge_bigger_than_the_turn_saturates() { + let big = ChargeDamage::measure(&Charge { + hit_chance: 1.0, + damage: 60.0, + ceiling: 20.0, + }); + assert_eq!(big.get(), 1.0); + } +} diff --git a/crates/sds-core/src/features/incoming.rs b/crates/sds-core/src/features/incoming.rs index 18b2218..3e0220e 100644 --- a/crates/sds-core/src/features/incoming.rs +++ b/crates/sds-core/src/features/incoming.rs @@ -266,7 +266,10 @@ mod tests { .map(|(at, (enemy, take))| Proposal { unit, label: format!("stand {at}"), - action: Action::Move { steps: vec![] }, + action: Action::Move { + steps: vec![], + charge_target: None, + }, end: Some(Coord::new(at as i32, 0)), features: FeatureVector::new(), damage_dealt: 0.0, diff --git a/crates/sds-core/src/features/latch.rs b/crates/sds-core/src/features/latch.rs index 94f8a85..3bc13b6 100644 --- a/crates/sds-core/src/features/latch.rs +++ b/crates/sds-core/src/features/latch.rs @@ -173,6 +173,7 @@ mod tests { for _ in 0..3 { let before = latches.clone(); latches.observe(&Observation { + charges: 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 8af527d..f363dbe 100644 --- a/crates/sds-core/src/features/mod.rs +++ b/crates/sds-core/src/features/mod.rs @@ -22,6 +22,7 @@ //! both a [`FeatureVector`] against [`Weights`], and [`decision::Decision`] is //! the record of one such choice - every candidate, not just the winner. +pub mod charge; pub mod decision; pub mod deployment; pub mod firing; @@ -282,6 +283,15 @@ impl Weights { // Damage as a share of what the target has left, so stripping the // last of a cripple beats scratching a fresh one. .with::(4.0) + // The same, for the one attack that is declared by moving. Equal to + // `expected_damage` on purpose and not fitted: both are a share of + // the same denominator - the machine's own best turn - so a charge + // and a volley that land the same fraction of it are worth the + // same, and the choice between them is then made by the columns + // that differ. `attacker_self_damage` is the one that mostly does: + // a charge is the only attack on the list that hurts us when it + // works. + .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) @@ -838,6 +848,7 @@ macro_rules! for_each_feature { $apply!($crate::features::firing::PMissionKill); $apply!($crate::features::firing::PPsrThreshold); $apply!($crate::features::firing::AttackerSelfDamage); + $apply!($crate::features::charge::ChargeDamage); $apply!($crate::features::firing::HeatIncurred); $apply!($crate::features::heat::HeatMpPenalty); $apply!($crate::features::heat::HeatToHitPenalty); diff --git a/crates/sds-core/src/pathfind.rs b/crates/sds-core/src/pathfind.rs index 50b2a1e..a7e2f59 100644 --- a/crates/sds-core/src/pathfind.rs +++ b/crates/sds-core/src/pathfind.rs @@ -1056,6 +1056,20 @@ pub fn search_within( /// proposing an occupied hex still backs up, and a unit that may not back up /// still needs somewhere to stop - so they are separate parameters rather than /// one mode. +/// The same search with backing up forbidden. +/// +/// **A charge may not reverse into position.** Probed: +/// `ChargeAttackAction.toHit(Game, MovePath)` refuses a path containing +/// `BACKWARDS`, `LATERAL_LEFT_BACKWARDS` or `LATERAL_RIGHT_BACKWARDS` with "No +/// backwards movement allowed while charging". The ordinary sweep minimises MP +/// and turning costs MP, so the cheapest route to a hex facing an enemy is very +/// often a reverse - which makes the route the sweep already has unusable for a +/// charge even where the hex is exactly right. This is the second route to the +/// same stand, and the only caller that needs one. +pub fn search_forwards(board: &MoveBoard, walker: &Walker<'_>, blocked: &[Coord]) -> Search { + search_with(board, walker, blocked, false, &[]) +} + fn search_with( board: &MoveBoard, walker: &Walker, diff --git a/crates/sds-core/src/plan.rs b/crates/sds-core/src/plan.rs index bbeae9c..4969177 100644 --- a/crates/sds-core/src/plan.rs +++ b/crates/sds-core/src/plan.rs @@ -389,7 +389,10 @@ mod tests { let proposal = Proposal { unit: 12, label: "hold the ridge".into(), - action: Action::Move { steps: vec![] }, + action: Action::Move { + steps: vec![], + charge_target: None, + }, end: None, features: measured(0.4, 0.1), damage_dealt: 12.0, @@ -513,7 +516,10 @@ mod tests { let mut charge = Proposal { unit: 1, label: "charge".into(), - action: Action::Move { steps: vec![] }, + action: Action::Move { + steps: vec![], + charge_target: None, + }, end: None, features: measured(0.5, 0.25), damage_dealt: 20.0, diff --git a/crates/sds-core/src/reconcile.rs b/crates/sds-core/src/reconcile.rs index ab860a4..51470a0 100644 --- a/crates/sds-core/src/reconcile.rs +++ b/crates/sds-core/src/reconcile.rs @@ -659,7 +659,10 @@ mod tests { Proposal { unit, label: label.into(), - action: Action::Move { steps: vec![] }, + action: Action::Move { + steps: vec![], + charge_target: None, + }, end, features, damage_dealt: by_target.values().copied().fold(0.0, f32::max), diff --git a/crates/sds-core/src/stands.rs b/crates/sds-core/src/stands.rs index b4f269a..8b681be 100644 --- a/crates/sds-core/src/stands.rs +++ b/crates/sds-core/src/stands.rs @@ -2414,7 +2414,10 @@ pub fn propose( // The step list is the pathfinder's, and `Reach` records where a // path ended rather than how it got there. A caller that has to // send this to a host fills it in; nothing here pretends to. - action: Action::Move { steps: Vec::new() }, + action: Action::Move { + steps: Vec::new(), + charge_target: None, + }, end: Some(end), features, damage_dealt: score.offence.expected_damage, diff --git a/crates/sds-core/src/wire.rs b/crates/sds-core/src/wire.rs index 60e79e2..78ee0f0 100644 --- a/crates/sds-core/src/wire.rs +++ b/crates/sds-core/src/wire.rs @@ -317,6 +317,90 @@ pub struct Observation { /// not re-ask. See `plan/protocol.md`. #[serde(default)] pub rejected: Vec, + /// Present in the movement phase: every charge the rules allow, quoted + /// from the hexes it could be made from. + /// + /// Empty from a host older than the field, which is what makes this + /// additive: a bot that ignores it proposes exactly the moves it always + /// proposed. + #[serde(default)] + pub charges: Vec, +} + +/// What one unit charging one target would cost and achieve. +/// +/// **A charge is declared in the movement phase and resolved as a physical.** +/// The bot sends a move path whose last step enters the target's hex; +/// `MovePathHandler` builds the `ChargeAttackAction` from it and hands it to +/// `Game.addCharge`. Nothing is declared in the physical phase, which is why +/// this sits on the movement observation rather than among the shots. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ChargeQuote { + /// The unit that would charge. + pub shooter: i32, + /// The unit it would charge into. + pub target: i32, + /// Damage the *attacker* takes for making it, whatever it rolls. + /// + /// Set by the target and by nothing of ours: + /// `ChargeAttackAction.getDamageTakenBy` reads the target's weight, so + /// every attacker takes the same for charging the same machine however far + /// it ran and whatever it weighs. + #[serde(rename = "selfDamage")] + pub self_damage: i32, + /// Damage the target takes, indexed by hexes moved. + /// + /// MegaMek's own answer per hex count rather than a formula copied here: + /// `ChargeAttackAction.getDamageFor` reads the attacker's weight, whether + /// its centre torso is under water and the `tacops_charge_damage` option. + /// Index 0 and 1 are both present, so a reader indexes rather than offsets. + #[serde(rename = "damageByHexes", default)] + pub damage_by_hexes: Vec, + /// Every hex the charge could be launched from, with the roll it needs. + #[serde(default)] + pub from: Vec, +} + +impl ChargeQuote { + /// What this charge deals having moved `hexes`, clamped to the table. + /// + /// A path longer than the quote's own run allowance reads the longest entry + /// rather than nothing: the table is built from `getRunMP` at the time the + /// observation was written, and a reader asking past it wants the last + /// answer rather than a zero it would take for "no damage". + pub fn damage_at(&self, hexes: i32) -> f32 { + if self.damage_by_hexes.is_empty() { + return 0.0; + } + let at = (hexes.max(0) as usize).min(self.damage_by_hexes.len() - 1); + self.damage_by_hexes[at] as f32 + } + + /// The roll this charge needs from `hex` at `gait`, if it is legal at all. + pub fn to_hit_from(&self, x: i32, y: i32, running: bool) -> Option { + let want = if running { "run" } else { "walk" }; + self.from + .iter() + .find(|at| at.x == x && at.y == y && at.gait == want) + .map(|at| at.to_hit) + } +} + +/// One hex a charge could be launched from, at one gait. +/// +/// Both halves matter and neither is derivable from the other: the source hex +/// is what `ChargeAttackAction.toHit` reads for the attack's direction and +/// elevation, and the attacker's movement type is one of its modifiers. A jump +/// is deliberately not among the gaits - `toHit(Game, MovePath)` refuses a path +/// containing `START_JUMP` with "No jumping allowed while charging". +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChargeFrom { + pub x: i32, + pub y: i32, + /// `"walk"` or `"run"`. + pub gait: String, + #[serde(rename = "toHit")] + pub to_hit: i32, } /// One move the host refused, in the form it was sent. @@ -1781,6 +1865,23 @@ impl Shot { pub enum Action { Move { steps: Vec, + /// The unit this move charges into, when it charges. + /// + /// The host appends a `CHARGE` step naming this target after every + /// step in `steps`, because `MovePath.addStep(MoveStepType, + /// Targetable)` is the only form that carries one and a bare `CHARGE` + /// names nobody. It rides beside `steps` rather than inside it so that + /// list stays names all the way down: [`Rejected::refuses`] compares + /// those names as prefixes, and a charge is always the last step. + /// + /// `None` is every move that predates this field, and every move that + /// is not a charge. + #[serde( + default, + rename = "chargeTarget", + skip_serializing_if = "Option::is_none" + )] + charge_target: Option, }, Fire { attacks: Vec, @@ -1998,6 +2099,71 @@ mod rejection_tests { #[cfg(test)] mod tests { + /// A charge quote is indexed by hexes and asked by hex and gait. + /// + /// Both halves are MegaMek's own answers travelling rather than a formula + /// copied here: `getDamageFor` is asked once per hex count the machine + /// could cover, and `toHit` once per source hex and gait. The clamp is what + /// keeps a reader that asks past the table from taking a zero for "no + /// damage" - the table is built from `getRunMP` as it stood when the + /// observation was written. + #[test] + fn a_charge_quote_is_read_by_hexes_and_by_hex() { + let quote = super::ChargeQuote { + shooter: 1, + target: 2, + self_damage: 6, + // Eighty tons: nought from a standing start, then eight a hex. + damage_by_hexes: vec![0, 0, 8, 16, 24], + from: vec![ + super::ChargeFrom { + x: 4, + y: 5, + gait: "walk".into(), + to_hit: 6, + }, + super::ChargeFrom { + x: 4, + y: 5, + gait: "run".into(), + to_hit: 8, + }, + ], + }; + assert_eq!(quote.damage_at(0), 0.0); + assert_eq!(quote.damage_at(3), 16.0); + // Past the table, and below it: the nearest answer MegaMek gave rather + // than a zero the caller would read as "this charge does nothing". + assert_eq!(quote.damage_at(9), 24.0); + assert_eq!(quote.damage_at(-1), 0.0); + + // The gait is a modifier on the roll, so the same hex answers twice. + assert_eq!(quote.to_hit_from(4, 5, false), Some(6)); + assert_eq!(quote.to_hit_from(4, 5, true), Some(8)); + // A hex the quote does not name is a charge the rules do not allow from + // there, and `None` rather than a number is what stops one being built. + assert_eq!(quote.to_hit_from(9, 9, true), None); + } + + /// A move says whether it charges, and one that predates the field does + /// not - which is every move the bot has ever sent until now. + #[test] + fn a_move_names_the_unit_it_charges() { + let charging = super::Action::Move { + steps: vec!["FORWARDS".into()], + charge_target: Some(7), + }; + let json = serde_json::to_value(&charging).expect("serialises"); + assert_eq!(json["chargeTarget"], 7); + + let ordinary = super::Action::Move { + steps: vec!["FORWARDS".into()], + charge_target: None, + }; + let json = serde_json::to_value(&ordinary).expect("serialises"); + assert!(json.get("chargeTarget").is_none(), "read {json}"); + } + /// A flooded hex's floor is below the level it reads. /// /// Measured through the bridge: entering depth-1 water costs a Mek 3 MP diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 2b733a8..d928cc3 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 56 of them, because a feature cannot exist without one - see `crates/sds-core/examples/vignettes/mod.rs`. +All 57 of them, because a feature cannot exist without one - see `crates/sds-core/examples/vignettes/mod.rs`. ## ammo_spent @@ -42,6 +42,16 @@ How many distinct sides of each target we are shooting from. Attacking from seve Nought everywhere except the hex the machine can actually swing at, which is the whole point: this column prices an *action*, not a position, and at seven hexes there is no action to price. Where it does bite it is the product of two things the bot could not previously see together - that the kick misses, and that a miss forces a piloting roll this machine might fail. It is a probability rather than a share of damage, and deliberately: a fall's cost is mostly the turn and the prone position rather than the points, and the points version of this column read 0.009 at its largest and could not move a decision at all. At a base of 6 a missed kick puts this machine down about one time in four; a fresh crew at 3 reads an eighth of that, which is the distinction the feature exists to make. +## charge_damage + +`bounded`. The share of everything we could put out that this charge lands instead. + +**The figure shows** one hundred-ton machine charging the same target from one, two, four and seven hexes back, against a turn of shooting that would land twenty points from where it stands. The roll gets worse as the run gets longer, because a charge is made at a run and the attacker's own movement is one of its modifiers. + +![charge_damage](features/charge_damage.svg) + +A share of the same denominator `expected_damage` divides by - the machine's own best turn - so the two columns are directly comparable, and that comparison is the decision. From one hex a charge is worth nothing at all: MegaMek's own damage figure is ten points a hex *after the first*, so a machine that has not run reads 0.00 and can only lose the firing turn it would have spent. From four it is worth about as much as shooting, and from seven it is worth more than the machine could shoot even though it lands less than half the time. What this column deliberately does not carry is on the other side of the vector: the piloting roll the target is handed sits in `p_psr_threshold`, and the one *we* are handed for making it sits in `attacker_self_damage`. The certain damage a charge costs its own attacker - set by the target's weight, not by how far we ran - has no column at all yet, so every number in this figure is an over-estimate of what a charge is worth. + ## cohesion `bounded`. How close this hex keeps us to the rest of our force, as a share of the board's longest distance. diff --git a/docs/TACTICS.md b/docs/TACTICS.md index 1b7b41d..fdc953c 100644 --- a/docs/TACTICS.md +++ b/docs/TACTICS.md @@ -85,7 +85,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 56 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 57 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. @@ -101,7 +101,7 @@ The bars below are that stand-in, drawn so the gap is legible rather than hidden ![bombard](tactics/bombard.svg) -Moves 11 of the 56 features in the basis: `cover_quality`, `elevation_gain`, `enemy_nearest`, `expected_damage`, `incoming_damage`, `level_terrain`, `level_tmm`, `los_in`, `los_out`, `p_kill`, `percent_options`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 11 of the 57 features in the basis: `cover_quality`, `elevation_gain`, `enemy_nearest`, `expected_damage`, `incoming_damage`, `level_terrain`, `level_tmm`, `los_in`, `los_out`, `p_kill`, `percent_options`. Everything not drawn is priced exactly as `Engage` prices it. ## break @@ -113,7 +113,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 56 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 57 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. @@ -129,7 +129,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 56 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 57 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 @@ -141,7 +141,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 37 of the 56 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 38 of the 57 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. @@ -155,7 +155,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 56 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 57 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. @@ -169,7 +169,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 56 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 57 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. @@ -183,7 +183,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 56 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 57 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. @@ -197,7 +197,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 56 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 57 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. @@ -213,7 +213,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 56 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 57 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 @@ -227,7 +227,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 56 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 57 features in the basis: `cohesion`, `friend_support`, `incoming_damage`, `los_in`. Everything not drawn is priced exactly as `Engage` prices it. ## withdraw @@ -239,6 +239,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 56 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 57 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/charge_damage.svg b/docs/features/charge_damage.svg new file mode 100644 index 0000000..8da796f --- /dev/null +++ b/docs/features/charge_damage.svg @@ -0,0 +1,98 @@ +1 hexes, needs 50.000 damage if it lands83% to land0.0 expected2 hexes, needs 50.4210 damage if it lands83% to land8.3 expected4 hexes, needs 61.0030 damage if it lands72% to land21.7 expected7 hexes, needs 81.0060 damage if it lands42% to land25.0 expectedcharge_damage diff --git a/docs/tactics/engage.svg b/docs/tactics/engage.svg index 8c5bb33..03b2419 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 @@ -61,107 +61,110 @@ attacker_self_damage -4.00 -cohesion - -+1.00 -concentration - -+3.00 -cover_quality - -+2.00 -defense_1plus - -+0.22 -defense_2plus - -+0.30 -defense_3plus - -+0.37 -defense_4plus - -+0.44 -defense_5plus - -+0.37 -defense_6plus - -+0.30 -deploy_ground - -+3.00 -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 +charge_damage + ++4.00 +cohesion + ++1.00 +concentration + ++3.00 +cover_quality + ++2.00 +defense_1plus + ++0.22 +defense_2plus + ++0.30 +defense_3plus + ++0.37 +defense_4plus + ++0.44 +defense_5plus + ++0.37 +defense_6plus + ++0.30 +deploy_ground + ++3.00 +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 -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 -submerged - --4.00 -target_breach - -+2.50 -target_gunnery - -+1.00 -target_health - --2.00 -target_piloting - -+0.50 -37 feature(s), widest move 8.00 +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 +submerged + +-4.00 +target_breach + ++2.50 +target_gunnery + ++1.00 +target_health + +-2.00 +target_piloting + ++0.50 +38 feature(s), widest move 8.00 diff --git a/plan/melee.md b/plan/melee.md index 062d862..043da5a 100644 --- a/plan/melee.md +++ b/plan/melee.md @@ -131,12 +131,19 @@ because both read plausible either way: | Berserker BRZ-A3 | 100 | 20 | 20 | 10 | 4 | | Atlas AS7-D | 100 | *(none, empty list)* | 20 | 10 | - | -**A club's damage scales with the attacker's tonnage** - exactly tonnage over -five, which is the kick's own figure - so it belongs with the other physicals +**A club's damage scales with the attacker's tonnage** - so it belongs with the other physicals in `helm-core::expected`'s exclusion rather than among the guns: damage that scales with the machine gives every design carrying one a spike at one hex. And **`getClubs()` returns an empty list, not null**, for a design carrying -none, so the enumeration needs no null guard. The probe set is chosen so the +none, so the enumeration needs no null guard. + +**Tonnage over five is the hatchet's figure and not every club's.** The table +above is three hatchets and an Atlas, so it isolates tonnage and the club but +not the *kind* of club: `ClubAttackAction.getDamageFor` reads the mount, and a +sword is `ceil(w/10)+1`, a mace `ceil(w/4)`, a pile driver a flat 10 and a +flail a flat 9. Nothing here reimplements that - MegaMek is asked per mount - +but the sentence above generalised from one kind and is corrected rather than +left to be read as a rule. The probe set is chosen so the two variables come apart - three hatchets across the tonnage range isolates tonnage, and the Atlas at the Berserker's own 100 tons isolates the club. @@ -185,8 +192,8 @@ every club-carrying design. `PhysicalCapability` already carries `club` as a kind and the Rust side reads it. -- **Charge and death-from-above** are movement attacks and remain out of scope - here; see the question below. +- **Death from above** is a movement attack and remains out of scope here; see + the question below. The charge is done - see the section under it. ## Candidates, ranked by what they let the bot do @@ -243,9 +250,84 @@ takes a critical, not when the weapon fires. They belong with phase. `F_TSEMP` and `F_HYPER` identify them; probe MegaMek's critical and explosion machinery for what one does before writing a number down. +## The charge: declared by moving, resolved as a physical + +**The bot charges now.** In `charge-duel.mms` at seed 5 a Charger CGR-1A1 +declared one, MegaMek resolved it - `Charging Charger CGR-1A1 (princess South); +needs 7, rolls 2: misses` and `is displaced into hex 0512` - and the machine +took no weapon-attack turn that round, which is the whole of what a charge +costs. + +What was probed rather than assumed, and the call that answered each: + +- **The server builds the attack; the bot only moves.** + `MovePathHandler` watches the submitted path for a `MoveStepType.CHARGE` + step, gates it on `Entity.canCharge`, constructs the `ChargeAttackAction` + itself and hands it to `Game.addCharge`. Nothing is declared in the physical + phase, so the whole feature is a movement candidate plus one step - and + `MovePath.addStep(MoveStepType, Targetable)` is the only form that carries + the target a bare `CHARGE` names nobody with. +- **A charge costs the entire firing phase.** + `Entity.isEligibleForFiring` returns false outright when `isCharging()` is + set. This is the trade, it is why `charge_damage` is a column of its own + rather than a number added to `expected_damage`, and it is why a charge + candidate is scored with every volley column zeroed. +- **What each side takes.** `ChargeAttackAction.getDamageFor` is read off the + attacker's weight, whether its centre torso is under water, and the + `tacops_charge_damage` option - and by default it is nought from a standing + start and rises with hexes moved, so a machine that has not run has nothing + to score. `getDamageTakenBy` is set by the *target's* weight and by nothing + of ours: every attacker takes the same for charging the same machine. + Neither formula is copied - the bridge asks MegaMek once per hex count and + the answers travel on the wire. +- **Both sides roll.** `resolveChargeDamage` calls `getBasePilotingRoll` with + "charging" for us and "was charged" for them. +- **A charge may not reverse, jump, evade or end prone.** + `toHit(Game, MovePath)` refuses each in turn by name, and refuses a second + last step that violates stacking. + +**The route is the part that was not obvious.** `pathfind` minimises MP and +turning costs MP, so the cheapest route to a hex facing an enemy is very often +a *reverse* - and a charge may not reverse. Built on the sweep's own routes the +feature declared moves the server took and attacks it then threw away; built on +`pathfind::search_forwards`, a second ground search with backing forbidden, it +declares charges MegaMek resolves. That second search is the one cost this +feature adds to a movement decision, and it is skipped entirely where nothing +is quoted. + +**It is rarely the right answer, and that is the result.** Re-scoring 79 +recorded movement menus over nine matches with the column at its hand-authored +4.0 and again at nought: 19 menus carried a charge, the column changed the pick +on exactly one of them, and with it zeroed no charge is ever chosen. A Charger +- 80 tons, five Small Lasers, the design the option exists for - takes a charge +only when the run-up is long enough, because the damage is nought at one hex +and the roll gets worse as the run gets longer. + +- [ ] **The self-damage points are not priced.** `getDamageTakenBy` is certain + damage, and `attacker_self_damage` holds a *fall chance* rather than a + share of health, so there is no column it fits. The charge's own piloting + roll goes there, which is the right quantity; the points do not. This + under-prices a charge, which is the unsafe direction. +- [ ] **A charge cannot kill.** `p_kill`, `p_mission_kill` and `overkill` read + nought on a charge candidate, because the damage model behind them is a + volley's. Twenty-four points into one location is exactly the shape that + kills, and the bot cannot see it. +- [ ] **The positional columns describe the launching hex.** On a hit the + attacker takes the target's hex and the target is displaced. The landing + hex exists only if the roll succeeds, so the hex the sweep scored is the + one that is priced. +- [ ] **Death from above.** `DfaAttackAction` is the same shape - a `DFA` step, + `Entity.canDFA`, `MovePathHandler` building the action - and is a jump + rather than a run, so it needs the air search rather than a new one. + Left deliberately: charge landed properly beats both sketched. + ## Open questions for jmm -- Charge and death-from-above are movement attacks, not physical-phase ones. - In scope here, or their own epic? +- `ChargeAttackAction.toHit` against a foot infantry platoon returns IMPOSSIBLE + with `Target is not a 'Mek`, and no trample rule was found in this release. + MegaMek does that; whether it matches the tabletop rules is not something to + settle by reading further. +- Death from above is a movement attack, not a physical-phase one. In scope + here, or its own epic? - Is a punch ever the right answer for a healthy Mek, or only for one that has lost its guns? That decides whether this is a niche or a real part of play. diff --git a/scenarios/charge-duel.mms b/scenarios/charge-duel.mms new file mode 100644 index 0000000..7470095 --- /dev/null +++ b/scenarios/charge-duel.mms @@ -0,0 +1,27 @@ +# The acceptance case for a charge: two Chargers on open ground. +# +# The `CGR-1A1` is 80 tons, moves 5/8, and its whole armament is five Small +# Lasers. Its guns land almost nothing and its body lands ten points a hex, so +# it is the design on which "spend the firing phase to hit them with the +# machine" is not a close call - which is what makes it the case that says +# whether the option is on the menu at all, rather than the case that says how +# finely it is priced. +# +# Deployed at opposite edges rather than in contact. A charge's damage is +# MegaMek's `getDamageFor` read at the hexes moved, and it is nought from a +# standing start: the run has to happen before there is anything to score, so +# a scenario that begins in contact cannot show the feature working. +# +# 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=charge-duel +Description=A Charger a side on open ground: five Small Lasers against eighty tons at a run. +BoardWidth=1 +BoardHeight=1 +Maps=GrassLands/16x17 Grasslands Open Terrain 2 +Factions=North,South +Location_North=N +Location_South=S +Unit_North_1=Charger CGR-1A1,North 1,4,5 +Unit_South_1=Charger CGR-1A1,South 1,4,5 diff --git a/weights/charge-damage-off.json b/weights/charge-damage-off.json new file mode 100644 index 0000000..98e5d99 --- /dev/null +++ b/weights/charge-damage-off.json @@ -0,0 +1,46 @@ +{ + "basis": "unknown", + "note": "The baseline M, written by hand. This is what the bot plays with when no --weights is given, and the set a fitted one has to beat: see Weights::hand_authored in crates/sds-core/src/features/mod.rs for the sentence behind each number, and plan/training.md for why the baseline is permanent. Every feature is normalised to 0.0..=1.0, so a weight is the points of value that feature is worth at full strength. Basis unknown: fitted before `sds train` recorded the feature basis, and the fingerprint cannot be reconstructed after the fact.", + "version": 1, + "weights": { + "ammo_spent": -0.5, + "arc_spread": 1.0, + "cohesion": 1.0, + "concentration": 3.0, + "cover_quality": 2.0, + "defense_1plus": 0.22, + "defense_2plus": 0.3, + "defense_3plus": 0.37, + "defense_4plus": 0.44, + "defense_5plus": 0.37, + "defense_6plus": 0.3, + "deploy_ground": 3.0, + "deploy_outlook": 1.5, + "elevation_gain": 1.0, + "expected_damage": 4.0, + "heat_ammo_explosion_risk": -8.0, + "heat_incurred": 0.0, + "heat_mp_penalty": -1.0, + "heat_shutdown_risk": -6.0, + "heat_to_hit_penalty": -2.0, + "incoming_damage": -4.0, + "level_terrain": 1.5, + "level_tmm": 1.5, + "los_in": -1.5, + "los_out": 1.5, + "overkill": -3.0, + "p_kill": 8.0, + "p_psr_threshold": 1.5, + "percent_options": 2.0, + "range_band_fit": 3.0, + "rear_arc_exposure": -2.0, + "rear_arc_gain": 2.0, + "attacker_self_damage": -4.0, + "charge_damage": 0.0, + "submerged": -4.0, + "target_breach": 2.5, + "target_gunnery": 1.0, + "target_health": -2.0, + "target_piloting": 0.5 + } +} diff --git a/weights/hand-authored.json b/weights/hand-authored.json index 377265b..c142f9d 100644 --- a/weights/hand-authored.json +++ b/weights/hand-authored.json @@ -36,6 +36,7 @@ "rear_arc_exposure": -2.0, "rear_arc_gain": 2.0, "attacker_self_damage": -4.0, + "charge_damage": 4.0, "submerged": -4.0, "target_breach": 2.5, "target_gunnery": 1.0, -- 2.51.2