Share stable content IDs across raids, inventory and item dispatch (#91)

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
2026-11-02 20:59:56 +03:00
parent 657d8f16c6
commit 5ad6ea8ad9
5 changed files with 258 additions and 66 deletions
+5 -35
View File
@@ -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);
+187
View File
@@ -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<DroneKind> {
AIRCRAFT.iter().find(|entry| entry.launch_item == Some(id)).map(|entry| entry.kind)
}
pub fn deploy_item(id: &str) -> Option<SamKind> {
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]);
}
}
+4
View File
@@ -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
+1 -17
View File
@@ -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,
+61 -14
View File
@@ -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::<Vec<_>>());
assert_eq!(restored.counts, wave.counts);
assert_eq!(restored.order().kinds, KINDS.iter().copied().zip(wave.counts).filter(|(_, n)| *n > 0).collect::<Vec<_>>());
}
}