diff --git a/crates/sds-bot/src/imitate.rs b/crates/sds-bot/src/imitate.rs index 6631c30..6b5cfe0 100644 --- a/crates/sds-bot/src/imitate.rs +++ b/crates/sds-bot/src/imitate.rs @@ -526,6 +526,8 @@ mod tests { weapons: vec![Weapon { id: 1, name: "PPC".into(), + location: Some("RT".into()), + rear_mounted: false, heat: 10, short: 6, medium: 12, diff --git a/crates/sds-core/src/features/fixture.rs b/crates/sds-core/src/features/fixture.rs index 9f6f595..7d6aed3 100644 --- a/crates/sds-core/src/features/fixture.rs +++ b/crates/sds-core/src/features/fixture.rs @@ -4,7 +4,8 @@ //! test fixture cannot drift from what actually arrives off the wire: a renamed //! field breaks these the same way it would break a match. -use crate::wire::{Shot, Unit}; +use crate::arc::MekLocation; +use crate::wire::{Shot, Unit, Weapon}; /// A middleweight Mek, whole, with every location a biped has and no weapons. /// @@ -54,8 +55,18 @@ pub fn mek(id: i32) -> Unit { .expect("fixture parses") } +/// The same weapon, bolted on somewhere. +/// +/// The mount rides the wire, so a test that cares about arcs states it here +/// rather than passing it beside the weapon. +pub fn at(mut weapon: Weapon, location: MekLocation, rear_mounted: bool) -> Weapon { + weapon.location = Some(location.abbreviation().to_string()); + weapon.rear_mounted = rear_mounted; + weapon +} + /// A weapon that lands all its damage in one place. -pub fn gun(id: i32, damage: f32, heat: i32) -> crate::wire::Weapon { +pub fn gun(id: i32, damage: f32, heat: i32) -> Weapon { serde_json::from_str(&format!( r#"{{ "id": {id}, "name": "Gun {id}", "heat": {heat}, @@ -82,7 +93,7 @@ pub fn shot(weapon: i32, target: i32, to_hit: i32, damage: f32, heat: i32) -> Sh } /// A missile rack with rounds in it. -pub fn rack(id: i32, rack_size: i32, heat: i32, shots_left: i32) -> crate::wire::Weapon { +pub fn rack(id: i32, rack_size: i32, heat: i32, shots_left: i32) -> Weapon { serde_json::from_str(&format!( r#"{{ "id": {id}, "name": "Rack {id}", "heat": {heat}, diff --git a/crates/sds-core/src/volley.rs b/crates/sds-core/src/volley.rs index bd04bf8..f59aa97 100644 --- a/crates/sds-core/src/volley.rs +++ b/crates/sds-core/src/volley.rs @@ -59,10 +59,9 @@ pub const MAX_TO_HIT: i32 = 12; /// A weapon as the arc test needs it: the wire's weapon, plus where it is bolted /// on. /// -/// The wire does not carry a mount location - [`crate::wire::Weapon`] has ranges -/// and damage and no location - so the caller states it. A weapon whose mount is -/// unknown is honestly a torso weapon: [`MekLocation::CenterTorso`] fires -/// forward and follows the twist, which is what all but the arms and legs do. +/// The mount comes off the wire - [`crate::wire::Weapon::location`] carries it, +/// and [`crate::wire::Weapon::mount`] says what an observation that predates the +/// field becomes. #[derive(Debug, Clone, PartialEq)] pub struct MountedWeapon { pub id: i32, @@ -82,12 +81,12 @@ pub struct MountedWeapon { } impl MountedWeapon { - /// A wire weapon at a stated mount. - pub fn from_wire(weapon: &Weapon, location: MekLocation, rear_mounted: bool) -> Self { + /// A wire weapon at the mount the wire states. + pub fn from_wire(weapon: &Weapon) -> Self { Self { id: weapon.id, - location, - rear_mounted, + location: weapon.mount(), + rear_mounted: weapon.rear_mounted, short: weapon.short, medium: weapon.medium, long: weapon.long_range, @@ -616,7 +615,7 @@ mod tests { /// A single-packet gun at a stated mount. fn gun_at(id: i32, damage: f32, location: MekLocation) -> MountedWeapon { - MountedWeapon::from_wire(&fixture::gun(id, damage, 3), location, false) + MountedWeapon::from_wire(&fixture::at(fixture::gun(id, damage, 3), location, false)) } /// The bearing this whole file's geometry rests on: from (10,10), the hex @@ -865,8 +864,16 @@ mod tests { gun_at(1, 10.0, MekLocation::RightArm), gun_at(2, 5.0, MekLocation::LeftTorso), gun_at(3, 8.0, MekLocation::RightLeg), - MountedWeapon::from_wire(&fixture::rack(4, 10, 4, 12), MekLocation::LeftArm, false), - MountedWeapon::from_wire(&fixture::gun(5, 12.0, 6), MekLocation::CenterTorso, true), + MountedWeapon::from_wire(&fixture::at( + fixture::rack(4, 10, 4, 12), + MekLocation::LeftArm, + false, + )), + MountedWeapon::from_wire(&fixture::at( + fixture::gun(5, 12.0, 6), + MekLocation::CenterTorso, + true, + )), ] } diff --git a/crates/sds-core/src/wire.rs b/crates/sds-core/src/wire.rs index 3139d2e..96f8811 100644 --- a/crates/sds-core/src/wire.rs +++ b/crates/sds-core/src/wire.rs @@ -9,6 +9,8 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; +use crate::arc::MekLocation; + /// A hex, in MegaMek's zero-based offset coordinates. /// /// Ordered by column then row, which is board order and is not a claim that one @@ -446,6 +448,20 @@ impl Location { pub struct Weapon { pub id: i32, pub name: String, + /// Where it is bolted on, by MegaMek abbreviation - `RA`, `CT`, `LL`. + /// + /// The mount picks the firing arc and decides whether a torso twist moves + /// the weapon, so an arm gun and a leg gun do not bear on the same hexes. + /// `None` when the observation predates the field, and for a mount no Mek + /// has. [`Weapon::mount`] reads it and says what the absent case becomes. + #[serde(default)] + pub location: Option, + /// The weapon fires into the rear arc whatever its location. + /// + /// Absent means front-facing, which is what all but a handful of mounts + /// are: the bridge sends the key only when it is true. + #[serde(default)] + pub rear_mounted: bool, #[serde(default)] pub heat: i32, pub short: i32, @@ -503,6 +519,24 @@ pub struct Ammo { } impl Weapon { + /// Where this weapon is mounted. + /// + /// 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 { + let Some(name) = self.location.as_deref() else { + return 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. pub fn damage_at(&self, range: i32) -> f32 { if range <= self.short { @@ -676,7 +710,7 @@ mod tests { "internal": 0, "internalMax": 9, "destroyed": true} ], "weapons": [{ - "id": 3, "name": "LRM 10", "heat": 4, + "id": 3, "name": "LRM 10", "location": "RT", "heat": 4, "short": 7, "medium": 14, "long": 21, "avgDamageShort": 6.0, "avgDamageMedium": 6.0, "avgDamageLong": 6.0, "rackSize": 10, @@ -796,4 +830,109 @@ mod tests { let head = unit.location("HD").unwrap(); assert_eq!(head.to_bare_structure(true), 9); } + + /// 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. + #[test] + fn a_weapon_with_no_mount_is_a_centre_torso_weapon() { + let unit = &parse(LEGACY).units[0]; + 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); + } + + /// An arm, a torso and a leg each survive a trip through the wire as + /// themselves. Nothing downstream can tell them apart if this collapses. + #[test] + fn a_mount_round_trips() { + for location in [ + MekLocation::RightArm, + MekLocation::LeftTorso, + MekLocation::LeftLeg, + ] { + let json = format!( + r#"{{"id": 1, "name": "Gun", "location": "{}", + "short": 6, "medium": 12, "long": 18}}"#, + location.abbreviation() + ); + let weapon: Weapon = serde_json::from_str(&json).expect("parses"); + assert_eq!(weapon.mount(), 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); + } + } + + /// Rear-mounted is its own flag, not a location. It has to survive the trip + /// too: a rear-mounted torso gun fires into the rear arc, and reading it as + /// a forward gun would claim hexes it cannot reach. + #[test] + fn a_rear_mounted_weapon_round_trips_as_rear_mounted() { + let weapon: Weapon = serde_json::from_str( + r#"{"id": 1, "name": "Gun", "location": "LT", "rearMounted": true, + "short": 6, "medium": 12, "long": 18}"#, + ) + .expect("parses"); + assert!(weapon.rear_mounted); + assert_eq!(weapon.mount(), MekLocation::LeftTorso); + + let again: Weapon = + serde_json::from_str(&serde_json::to_string(&weapon).expect("serialises")) + .expect("re-parses"); + assert!(again.rear_mounted); + } + + /// The reason the field exists. `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 weapon follows the twist. The volley + /// crate already asserts this from hand-built mounts; what is checked here + /// is that a weapon arriving off the wire lands in the same place. + /// + /// The geometry: from (10,10), (14,10) is 90 degrees off, outside a + /// 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!(bears(&arm), "an arm weapon takes the twist"); + + let leg = mounted("LL"); + assert_eq!(leg.location, MekLocation::LeftLeg); + assert!(!bears(&leg), "a leg weapon stays on the unit's facing"); + } }