diff --git a/crates/sds-core/src/volley.rs b/crates/sds-core/src/volley.rs index f59aa97..2bc8034 100644 --- a/crates/sds-core/src/volley.rs +++ b/crates/sds-core/src/volley.rs @@ -66,7 +66,11 @@ pub const MAX_TO_HIT: i32 = 12; pub struct MountedWeapon { pub id: i32, /// Where it is mounted. Picks the arc, and decides whether a twist moves it. - pub location: MekLocation, + /// + /// `None` is a mount this build does not model, from + /// [`crate::wire::Weapon::mount`]. Such a weapon fires forward and never + /// takes the twist. + pub location: Option, /// Rear-mounted weapons fire into the rear arc whatever their location. pub rear_mounted: bool, pub short: i32, @@ -411,9 +415,23 @@ pub fn gather( continue; } // The twist moves a torso or arm weapon and leaves a leg weapon - // pointing where the unit's feet do. - let firing = arc::firing_facing(state.facing, twist, weapon.location); - let arc = arc::weapon_arc(weapon.location, weapon.rear_mounted, state.arms_flipped); + // pointing where the unit's feet do. A mount this build does not + // model is read as a leg: it declines the twist, which is true of + // the one location known to be missing - a tripod's centre leg - + // and is the safe direction for any other. + let firing = match weapon.location { + Some(location) => arc::firing_facing(state.facing, twist, location), + None => state.facing, + }; + // The arc is not a guess. Every location but the two arms is + // forward, and rear-mounted beats the location outright, so the + // centre torso's arc is right for an unmodelled mount unless it is + // an arm - and forward is the narrower of the two. + let arc = arc::weapon_arc( + weapon.location.unwrap_or(MekLocation::CenterTorso), + weapon.rear_mounted, + state.arms_flipped, + ); if !arc::is_in_arc(state.hex, firing, target.hex, arc) { continue; } diff --git a/crates/sds-core/src/wire.rs b/crates/sds-core/src/wire.rs index 96f8811..74ff947 100644 --- a/crates/sds-core/src/wire.rs +++ b/crates/sds-core/src/wire.rs @@ -519,22 +519,32 @@ pub struct Ammo { } impl Weapon { - /// Where this weapon is mounted. + /// Where this weapon is mounted, or `None` for a mount this build does not + /// model. /// - /// An observation that predates the field, or one naming a location no Mek - /// has, becomes [`MekLocation::CenterTorso`]. That is the safe default - /// because it is the plain case: a centre-torso weapon fires forward and - /// follows the twist, which is what every mount but the arms and the legs - /// does. Guessing an arm would claim an arc the weapon may not have, and - /// guessing a leg would refuse a twist the weapon may well take. - pub fn mount(&self) -> MekLocation { + /// The two ways this can fail are not the same failure and do not share an + /// answer. + /// + /// **Absent** is an observation older than the field, and + /// [`MekLocation::CenterTorso`] is the honest reading: a centre-torso + /// weapon fires forward and follows the twist, which is what every mount + /// but the arms and the legs does. Guessing an arm would claim an arc the + /// weapon may not have, and guessing a leg would refuse a twist the weapon + /// may well take. + /// + /// **Present but unrecognised** is a location MegaMek has and + /// [`MekLocation`] does not - `Mek.LOC_CENTER_LEG` is a tripod's, and it is + /// a leg. Reading that as a centre torso would hand it the torso twist, + /// and a leg must never follow the twist. There is no location to name, so + /// this says so and the caller declines the twist rather than inventing + /// one. + pub fn mount(&self) -> Option { let Some(name) = self.location.as_deref() else { - return MekLocation::CenterTorso; + return Some(MekLocation::CenterTorso); }; MekLocation::ALL .into_iter() .find(|location| location.abbreviation() == name) - .unwrap_or(MekLocation::CenterTorso) } /// Average damage this weapon lands at a range, by bracket. @@ -831,6 +841,34 @@ mod tests { assert_eq!(head.to_bare_structure(true), 9); } + /// A weapon off the wire, at the mount the JSON names. + fn mounted(location: &str) -> crate::volley::MountedWeapon { + let json = format!( + r#"{{"id": 1, "name": "Gun", "location": "{location}", + "short": 6, "medium": 12, "long": 18}}"# + ); + let weapon: Weapon = serde_json::from_str(&json).expect("parses"); + crate::volley::MountedWeapon::from_wire(&weapon) + } + + /// Does this weapon bear on (14,10) from (10,10), facing 0, twisted one + /// hexside right? See [`a_leg_weapon_off_the_wire_does_not_follow_the_twist`] + /// for why that hex. + fn bears(weapon: &crate::volley::MountedWeapon) -> bool { + let (from, to) = (Coord::new(10, 10), Coord::new(14, 10)); + let (facing, twisted) = (0, 1); + let firing = match weapon.location { + Some(location) => crate::arc::firing_facing(facing, twisted, location), + None => facing, + }; + let arc = crate::arc::weapon_arc( + weapon.location.unwrap_or(MekLocation::CenterTorso), + weapon.rear_mounted, + false, + ); + crate::arc::is_in_arc(from, firing, to, arc) + } + /// The mount is optional on the wire, and an observation recorded before it /// existed still parses. The doc comment on [`Weapon::mount`] claims the /// absent case is the centre torso; this is that claim. @@ -840,15 +878,7 @@ mod tests { let weapon = &unit.weapons[0]; assert_eq!(weapon.location, None); assert!(!weapon.rear_mounted); - assert_eq!(weapon.mount(), MekLocation::CenterTorso); - - // A mount no Mek has takes the same default rather than panicking. - let odd: Weapon = serde_json::from_str( - r#"{"id": 1, "name": "Gun", "location": "TU", - "short": 6, "medium": 12, "long": 18}"#, - ) - .expect("parses"); - assert_eq!(odd.mount(), MekLocation::CenterTorso); + assert_eq!(weapon.mount(), Some(MekLocation::CenterTorso)); } /// An arm, a torso and a leg each survive a trip through the wire as @@ -866,13 +896,13 @@ mod tests { location.abbreviation() ); let weapon: Weapon = serde_json::from_str(&json).expect("parses"); - assert_eq!(weapon.mount(), location); + assert_eq!(weapon.mount(), Some(location)); let again: Weapon = serde_json::from_str(&serde_json::to_string(&weapon).expect("serialises")) .expect("re-parses"); assert_eq!(again.location.as_deref(), Some(location.abbreviation())); - assert_eq!(again.mount(), location); + assert_eq!(again.mount(), Some(location)); } } @@ -887,7 +917,7 @@ mod tests { ) .expect("parses"); assert!(weapon.rear_mounted); - assert_eq!(weapon.mount(), MekLocation::LeftTorso); + assert_eq!(weapon.mount(), Some(MekLocation::LeftTorso)); let again: Weapon = serde_json::from_str(&serde_json::to_string(&weapon).expect("serialises")) @@ -905,34 +935,43 @@ mod tests { /// forward arc at facing 0 and inside it one hexside of twist to the right. #[test] fn a_leg_weapon_off_the_wire_does_not_follow_the_twist() { - let from = Coord::new(10, 10); - let to = Coord::new(14, 10); - let (facing, twisted) = (0, 1); - - let mounted = |abbreviation: &str| { - let json = format!( - r#"{{"id": 1, "name": "Gun", "location": "{abbreviation}", - "short": 6, "medium": 12, "long": 18}}"# - ); - let weapon: Weapon = serde_json::from_str(&json).expect("parses"); - crate::volley::MountedWeapon::from_wire(&weapon) - }; - - let bears = |weapon: &crate::volley::MountedWeapon| { - crate::arc::is_in_arc( - from, - crate::arc::firing_facing(facing, twisted, weapon.location), - to, - crate::arc::weapon_arc(weapon.location, weapon.rear_mounted, false), - ) - }; - let arm = mounted("RA"); - assert_eq!(arm.location, MekLocation::RightArm); + assert_eq!(arm.location, Some(MekLocation::RightArm)); assert!(bears(&arm), "an arm weapon takes the twist"); let leg = mounted("LL"); - assert_eq!(leg.location, MekLocation::LeftLeg); + assert_eq!(leg.location, Some(MekLocation::LeftLeg)); assert!(!bears(&leg), "a leg weapon stays on the unit's facing"); } + + /// An abbreviation this build does not model is not a centre torso weapon. + /// + /// `Mek.LOC_CENTER_LEG` is a tripod's ninth location and it is a leg, so + /// reading an unrecognised mount as a centre torso would hand a leg the + /// torso twist and overvalue twisting - a wrong number with nothing to + /// fail on. `sds/units.py` keeps tripods out of the generated suite, but + /// that guard is in the scenario generator and not in the wire. + #[test] + fn an_unmodelled_mount_never_gains_the_twist() { + // A tripod's centre leg, and a location that is not MegaMek's at all. + for abbreviation in ["CL", "TU"] { + let weapon: Weapon = serde_json::from_str(&format!( + r#"{{"id": 1, "name": "Gun", "location": "{abbreviation}", + "short": 6, "medium": 12, "long": 18}}"# + )) + .expect("parses"); + // Distinct from the absent case, which is Some(CenterTorso). + assert_eq!(weapon.mount(), None); + + let unmodelled = mounted(abbreviation); + assert_eq!(unmodelled.location, None); + assert!( + !bears(&unmodelled), + "{abbreviation} must not follow the twist" + ); + } + + // The thing this is not: a centre torso weapon, which does twist. + assert!(bears(&mounted("CT"))); + } } diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index dca0426..57b313c 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -102,9 +102,15 @@ bears on: `Mek.getWeaponArc` reads it, and `Mek.isSecondaryArcWeapon` is false for the legs, so a leg weapon fires along the unit's facing and a torso twist does not move it while an arm or torso weapon follows the twist. `rearMounted` is sent only when true and beats the location outright: the weapon fires into -the rear arc wherever it sits. A weapon with no `location` — an observation -recorded before the field existed — is read as a centre-torso weapon, which -fires forward and follows the twist like every mount but the arms and the legs. +the rear arc wherever it sits. + +Two readings on the bot's side, and they are not the same. A weapon with **no** +`location` — an observation recorded before the field existed — is read as a +centre-torso weapon, which fires forward and follows the twist like every mount +but the arms and the legs. A `location` the bot does **not** model is read as +firing forward and never following the twist: MegaMek has locations a biped +does not, and the one that exists today is a tripod's centre leg (`CL`), which +is a leg. Reading it as a centre torso would hand a leg the torso twist. `ammo` is what is in the tube, and is absent for an energy weapon and for an empty rack. A launcher's damage is not a property of the launcher: an SRM-6 diff --git a/plan/protocol.md b/plan/protocol.md index d4db8d6..718e5c2 100644 --- a/plan/protocol.md +++ b/plan/protocol.md @@ -33,7 +33,10 @@ Adding a field is deliberate, with a reason in the commit message. MegaMek's own abbreviations, with `rearMounted` beside it. The case that makes it load-bearing is the legs: `Mek.isSecondaryArcWeapon` is false for them, so a leg weapon does not follow the torso twist, and a candidate - generator that assumed a torso mount would overvalue twisting + generator that assumed a torso mount would overvalue twisting. An absent + location and one the bot does not model are read differently on purpose - + the first is a centre torso, the second declines the twist, because the + location known to be missing is a tripod's centre leg - [ ] **Per-unit levels in the round tick** - on the match result as `entities`, `{seat: {entity id: {bv, armor, internal, potential, force}}}`, beside the side totals it is the per-unit form of. A destroyed unit drops