Share stable content IDs across raids, inventory and item dispatch (#91)
Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
+5
-35
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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<_>>());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user