From 5ad6ea8ad9c1babe2680c6b8a408279f3e2c53d5 Mon Sep 17 00:00:00 2001 From: chexit Date: Mon, 2 Nov 2026 20:59:56 +0300 Subject: [PATCH] Share stable content IDs across raids, inventory and item dispatch (#91) Co-Authored-By: Codex --- airdef-mod/src/lib.rs | 40 +------- airdef-mod/src/sim/catalog.rs | 187 ++++++++++++++++++++++++++++++++++ airdef-mod/src/sim/mod.rs | 4 + airdef-mod/src/sim/units.rs | 18 +--- airdef-mod/src/wave.rs | 75 +++++++++++--- 5 files changed, 258 insertions(+), 66 deletions(-) create mode 100644 airdef-mod/src/sim/catalog.rs diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index 0f34081..d802d6a 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -28,37 +28,7 @@ mod scenario; mod wave; use sim::{Class, DroneKind, Ground, Out, Sam, SamKind, Sim}; -const ITEMS: [&str; 29] = [ - "airdef:designator", - "airdef:launch_shahed", - "airdef:launch_geran3", - "airdef:launch_lancet", - "airdef:launch_kh101", - "airdef:launch_kalibr", - "airdef:launch_kh22", - "airdef:launch_zircon", - "airdef:launch_iskander", - "airdef:launch_kinzhal", - "airdef:launch_oreshnik", - "airdef:deploy_patriot", - "airdef:deploy_gepard", - "airdef:deploy_pantsir", - "airdef:deploy_s400", - "airdef:deploy_s500", - "airdef:deploy_irist", - "airdef:deploy_nasams", - "airdef:deploy_buk", - "airdef:deploy_tor", - "airdef:deploy_p1sun", - "airdef:deploy_radar", - "airdef:deploy_depot", - "airdef:deploy_tzm", - "airdef:deploy_ew", - "airdef:deploy_light", - "airdef:mover", - "airdef:flare_gun", - "airdef:remover", -]; +const ITEMS: [&str; sim::catalog::INVENTORY_COUNT] = sim::catalog::INVENTORY; struct AirDef { smoke: smoke::Smoke, @@ -314,8 +284,8 @@ impl AirDef { } None => say("Нет блока под прицелом"), }, - _ if id.starts_with("airdef:launch_") => { - let Some(kind) = DroneKind::from_key(&id["airdef:launch_".len()..]) else { return }; + _ if sim::catalog::launch_item(id).is_some() => { + let Some(kind) = sim::catalog::launch_item(id) else { return }; // Where you look, else the nearest marked target. let target = aim().or_else(|| self.sim.nearest_target(eye(&p))); match target { @@ -326,8 +296,8 @@ impl AirDef { None => say("Наведитесь на блок или отметьте цель"), } } - _ if id.starts_with("airdef:deploy_") => { - let Some(kind) = SamKind::from_key(&id["airdef:deploy_".len()..]) else { return }; + _ if sim::catalog::deploy_item(id).is_some() => { + let Some(kind) = sim::catalog::deploy_item(id) else { return }; match aim() { Some(at) => { let d = view(&p); diff --git a/airdef-mod/src/sim/catalog.rs b/airdef-mod/src/sim/catalog.rs new file mode 100644 index 0000000..693088a --- /dev/null +++ b/airdef-mod/src/sim/catalog.rs @@ -0,0 +1,187 @@ +//! Stable identities and availability for the existing simulation content. +//! +//! Balance and rendering remain in `DroneSpec` and `SamSpec`: the catalog +//! refers to those typed definitions instead of copying their values. The +//! first eleven drone entries retain the historical positional wave-save order. +use super::{DroneKind, SamKind}; + +#[derive(Clone, Copy, Debug)] +pub struct Aircraft { + pub kind: DroneKind, + /// Inventory item, when this type has a player launch item. + pub launch_item: Option<&'static str>, + /// May appear as a selectable type in the raid menu. + pub raid: bool, +} + +impl Aircraft { + /// Existing save/command key; changing it requires an explicit migration. + pub fn id(self) -> &'static str { self.kind.spec().key } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GroundStrike { + /// The game has no confirmed compatible ground-attack loadout for this unit. + /// This is a content limitation, not a claim about the real equipment. + Unavailable { reason: &'static str }, +} + +#[derive(Clone, Copy, Debug)] +pub struct Unit { + pub kind: SamKind, + pub deploy_item: &'static str, +} + +impl Unit { + pub fn id(self) -> &'static str { self.kind.spec().key } + pub fn ground_strike(self) -> GroundStrike { + GroundStrike::Unavailable { reason: "Для этой техники не задан подтверждённый профиль удара по координатам" } + } +} + +use DroneKind::*; +/// The raid entries come first, in the order stored by existing scenario files. +pub const AIRCRAFT: &[Aircraft] = &[ + Aircraft { kind: Gerbera, launch_item: None, raid: true }, + Aircraft { kind: Shahed, launch_item: Some("airdef:launch_shahed"), raid: true }, + Aircraft { kind: Geran3, launch_item: Some("airdef:launch_geran3"), raid: true }, + Aircraft { kind: Lancet, launch_item: Some("airdef:launch_lancet"), raid: true }, + Aircraft { kind: Kh101, launch_item: Some("airdef:launch_kh101"), raid: true }, + Aircraft { kind: Kalibr, launch_item: Some("airdef:launch_kalibr"), raid: true }, + Aircraft { kind: Kh22, launch_item: Some("airdef:launch_kh22"), raid: true }, + Aircraft { kind: Zircon, launch_item: Some("airdef:launch_zircon"), raid: true }, + Aircraft { kind: Iskander, launch_item: Some("airdef:launch_iskander"), raid: true }, + Aircraft { kind: Kinzhal, launch_item: Some("airdef:launch_kinzhal"), raid: true }, + Aircraft { kind: Oreshnik, launch_item: Some("airdef:launch_oreshnik"), raid: true }, + Aircraft { kind: OreshnikBlock, launch_item: None, raid: false }, + Aircraft { kind: OreshnikSub, launch_item: None, raid: false }, +]; + +macro_rules! units { + ($( $kind:ident => $item:literal ),+ $(,)?) => { + pub const UNITS: &[Unit] = &[$(Unit { kind: SamKind::$kind, deploy_item: $item }),+]; + }; +} +units! { + Patriot => "airdef:deploy_patriot", Gepard => "airdef:deploy_gepard", + Pantsir => "airdef:deploy_pantsir", S400 => "airdef:deploy_s400", + S500 => "airdef:deploy_s500", IrisT => "airdef:deploy_irist", + Nasams => "airdef:deploy_nasams", Buk => "airdef:deploy_buk", + Tor => "airdef:deploy_tor", P1sun => "airdef:deploy_p1sun", + Radar => "airdef:deploy_radar", Depot => "airdef:deploy_depot", + Tzm => "airdef:deploy_tzm", Ew => "airdef:deploy_ew", Light => "airdef:deploy_light", +} + +/// Existing positional serialization makes the first entries' order part of the save format. +const fn raid_count() -> usize { + let mut i = 0; + let mut count = 0; + while i < AIRCRAFT.len() { + if AIRCRAFT[i].raid { count += 1; } + i += 1; + } + count +} +pub const RAID_COUNT: usize = raid_count(); +const fn raid_kinds() -> [DroneKind; RAID_COUNT] { + let mut result = [Gerbera; RAID_COUNT]; + let mut i = 0; + let mut out = 0; + while i < AIRCRAFT.len() { + if AIRCRAFT[i].raid { result[out] = AIRCRAFT[i].kind; out += 1; } + i += 1; + } + result +} +pub const RAID_KINDS: [DroneKind; RAID_COUNT] = raid_kinds(); + +pub const UNIT_COUNT: usize = UNITS.len(); +const fn unit_kinds() -> [SamKind; UNIT_COUNT] { + let mut result = [SamKind::Patriot; UNIT_COUNT]; + let mut i = 0; + while i < result.len() { result[i] = UNITS[i].kind; i += 1; } + result +} +pub const UNIT_KINDS: [SamKind; UNIT_COUNT] = unit_kinds(); + +pub fn unit(kind: SamKind) -> &'static Unit { + UNITS.iter().find(|entry| entry.kind == kind).expect("unit kind missing from catalog") +} + +/// Inventory ordering is stable for existing remotes, while availability comes from the same entries as raids. +const fn inventory_count() -> usize { + let mut n = UNIT_COUNT + 4; + let mut i = 0; + while i < AIRCRAFT.len() { + if AIRCRAFT[i].launch_item.is_some() { n += 1; } + i += 1; + } + n +} +pub const INVENTORY_COUNT: usize = inventory_count(); +const fn inventory_items() -> [&'static str; INVENTORY_COUNT] { + let mut out = [""; INVENTORY_COUNT]; + out[0] = "airdef:designator"; + let mut n = 1; + let mut i = 0; + while i < AIRCRAFT.len() { + if let Some(item) = AIRCRAFT[i].launch_item { out[n] = item; n += 1; } + i += 1; + } + i = 0; + while i < UNITS.len() { out[n] = UNITS[i].deploy_item; n += 1; i += 1; } + out[n] = "airdef:mover"; + out[n + 1] = "airdef:flare_gun"; + out[n + 2] = "airdef:remover"; + out +} +pub const INVENTORY: [&str; INVENTORY_COUNT] = inventory_items(); + +pub fn launch_item(id: &str) -> Option { + AIRCRAFT.iter().find(|entry| entry.launch_item == Some(id)).map(|entry| entry.kind) +} +pub fn deploy_item(id: &str) -> Option { + UNITS.iter().find(|entry| entry.deploy_item == id).map(|entry| entry.kind) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + use super::super::units::Muzzle; + + #[test] + fn identities_cover_the_simulation_and_raid_order_is_compatible() { + let mut ids = HashSet::new(); + let mut kinds = HashSet::new(); + let mut items = HashSet::new(); + for (i, e) in AIRCRAFT.iter().enumerate() { + assert!(ids.insert(e.id()), "duplicate aircraft ID {}", e.id()); + assert!(kinds.insert(e.kind), "duplicate aircraft kind {:?}", e.kind); + if i < 11 { assert!(e.raid); } + if let Some(item) = e.launch_item { + assert!(items.insert(item), "duplicate item {item}"); + assert_eq!(item, format!("airdef:launch_{}", e.id())); + } + } + assert_eq!(kinds, DroneKind::ALL.into_iter().collect()); + let mut kinds = HashSet::new(); + for e in UNITS { + assert!(ids.insert(e.id()), "duplicate unit ID {}", e.id()); + assert!(kinds.insert(e.kind), "duplicate unit kind {:?}", e.kind); + assert!(items.insert(e.deploy_item), "duplicate item {}", e.deploy_item); + assert_eq!(e.deploy_item, format!("airdef:deploy_{}", e.id())); + let spec = e.kind.spec(); + assert!(spec.sensor.is_finite() && spec.sensor >= 0.0); + match (&spec.missile, spec.muzzle) { + (Some(_), Muzzle::None) => panic!("{} has missiles but no muzzle", e.id()), + (_, Muzzle::Grid(cols, rows, size)) => assert!(cols > 0 && rows > 0 && size.is_finite() && size > 0.0), + (_, Muzzle::Rails(xs)) => assert!(!xs.is_empty() && xs.iter().all(|v| v.is_finite())), + _ => {} + } + + } + assert_eq!(kinds, SamKind::ALL.into_iter().collect()); + assert_eq!(&RAID_KINDS[..11], &[Gerbera, Shahed, Geran3, Lancet, Kh101, Kalibr, Kh22, Zircon, Iskander, Kinzhal, Oreshnik]); + } +} diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index b097725..10f616c 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -9,6 +9,7 @@ use glam::{DQuat, DVec3}; use std::collections::HashMap; +pub mod catalog; mod damage; pub mod doctrine; pub mod fires; @@ -640,6 +641,9 @@ pub struct DroneSpec { } impl DroneSpec { + /// The registered visual model for this profile. + pub fn model(&self) -> &'static str { self.model } + /// Its size, for how thick its smoke is. pub fn trail_scale(&self) -> f32 { self.scale as f32 diff --git a/airdef-mod/src/sim/units.rs b/airdef-mod/src/sim/units.rs index 0788a42..d88d59c 100644 --- a/airdef-mod/src/sim/units.rs +++ b/airdef-mod/src/sim/units.rs @@ -156,23 +156,7 @@ const BATTERY_SPINNER: Option<(&str, f64, f64, f64, f64)> = Some(("airdef:radar_ const TUBE_PIVOT: DVec3 = DVec3::new(0.0, -0.5, 1.5); impl SamKind { - pub const ALL: [SamKind; 15] = [ - SamKind::Patriot, - SamKind::Gepard, - SamKind::Pantsir, - SamKind::S400, - SamKind::S500, - SamKind::IrisT, - SamKind::Nasams, - SamKind::Buk, - SamKind::Tor, - SamKind::P1sun, - SamKind::Radar, - SamKind::Depot, - SamKind::Tzm, - SamKind::Ew, - SamKind::Light, - ]; + pub const ALL: [SamKind; super::catalog::UNIT_COUNT] = super::catalog::UNIT_KINDS; /// The ones that shoot (the roles menu lists these). pub const ARMED: [SamKind; 10] = [ SamKind::Patriot, diff --git a/airdef-mod/src/wave.rs b/airdef-mod/src/wave.rs index 7303061..0a8c99f 100644 --- a/airdef-mod/src/wave.rs +++ b/airdef-mod/src/wave.rs @@ -2,20 +2,8 @@ //! where, how far out and at what; and ready-made raids to start from. use crate::sim::{Aim, Approach, DroneKind, RaidOrder}; -/// The kinds a raid can have, in the menu's order. -pub const KINDS: [DroneKind; 11] = [ - DroneKind::Gerbera, - DroneKind::Shahed, - DroneKind::Geran3, - DroneKind::Lancet, - DroneKind::Kh101, - DroneKind::Kalibr, - DroneKind::Kh22, - DroneKind::Zircon, - DroneKind::Iskander, - DroneKind::Kinzhal, - DroneKind::Oreshnik, -]; +/// The historical raid order is owned by the shared content catalog. +pub use crate::sim::catalog::RAID_KINDS as KINDS; /// What a click on a kind's row steps its number through. const STEPS: [u32; 12] = [0, 1, 2, 3, 4, 6, 8, 10, 15, 20, 30, 40]; @@ -180,3 +168,62 @@ impl WaveConfig { RaidOrder::mix(n, dist) } } + +#[cfg(test)] +mod catalog_tests { + use super::*; + use crate::sim::catalog::{AIRCRAFT, UNITS}; + + fn assets() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/airdef") + } + fn item_exists(item: &str) { + let name = item.strip_prefix("airdef:").expect("item namespace"); + for path in [format!("items/{name}.json"), format!("models/item/{name}.json"), format!("textures/item/{name}.png")] { + assert!(assets().join(&path).is_file(), "{item}: missing {path}"); + } + let definition = std::fs::read_to_string(assets().join(format!("items/{name}.json"))).unwrap(); + assert!(definition.contains(&format!("airdef:item/{name}")), "{item}: wrong item model"); + let model = std::fs::read_to_string(assets().join(format!("models/item/{name}.json"))).unwrap(); + assert!(model.contains(&format!("airdef:item/{name}")), "{item}: wrong icon reference"); + let png = std::fs::read(assets().join(format!("textures/item/{name}.png"))).unwrap(); + assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"), "{item}: invalid PNG signature"); + } + fn model_exists(model: &str) { + let name = model.strip_prefix("airdef:").expect("model namespace"); + let path = assets().join(format!("models/block/{name}.json")); + assert!(path.is_file(), "missing model {}", path.display()); + } + + #[test] + fn catalog_resources_exist() { + for aircraft in AIRCRAFT { + model_exists(aircraft.kind.spec().model()); + if let Some(item) = aircraft.launch_item { item_exists(item); } + } + for unit in UNITS { + item_exists(unit.deploy_item); + let p = &unit.kind.spec().parts; + model_exists(p.body); + for model in [p.launcher, p.pack, p.dish.map(|v| v.0), p.spinner.map(|v| v.0)].into_iter().flatten() { model_exists(model); } + for (model, _, _) in p.fixed { model_exists(model); } + } + } + + #[test] + fn inventory_entries_match_catalog_and_scenario_order_round_trips() { + for item in AIRCRAFT.iter().filter_map(|a| a.launch_item).chain(UNITS.iter().map(|u| u.deploy_item)) { + assert_eq!(crate::ITEMS.iter().filter(|i| **i == item).count(), 1, "{item}"); + } + for item in crate::ITEMS.iter().filter(|i| i.starts_with("airdef:launch_") || i.starts_with("airdef:deploy_")) { + assert!(AIRCRAFT.iter().any(|a| a.launch_item == Some(*item)) || UNITS.iter().any(|u| u.deploy_item == *item), "uncatalogued item {item}"); + } + let mut wave = WaveConfig::default(); + for (i, count) in wave.counts.iter_mut().enumerate() { *count = i as u32; } + let text = wave.text(); + let mut restored = WaveConfig::default(); + restored.parse(&text.split_whitespace().collect::>()); + assert_eq!(restored.counts, wave.counts); + assert_eq!(restored.order().kinds, KINDS.iter().copied().zip(wave.counts).filter(|(_, n)| *n > 0).collect::>()); + } +}