cb73cb7ccc
Co-Authored-By: Codex <noreply@openai.com>
441 lines
16 KiB
Rust
441 lines
16 KiB
Rust
//! Headless driver for the pure simulation (`airdef::sim`): builds a scenario, steps the
|
|
//! simulation like the mod's tick does (answering ground queries from a height function),
|
|
//! and measures. Shared by the `sim_run` example and `tests/scenarios.rs`.
|
|
//!
|
|
//! Native timings say nothing about the WASM build or the GPU; use them to compare runs of the
|
|
//! same machine.
|
|
#![allow(dead_code)]
|
|
#[path = "../../src/world_view.rs"]
|
|
mod world_view;
|
|
use airdef::sim::{Aim, Approach, DroneKind, Ground, Out, RaidOrder, Sam, SamKind, Sim};
|
|
use glam::DVec3;
|
|
use std::collections::HashMap;
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Height of the land under a column (what `render.ground` would answer).
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub enum Terrain {
|
|
Flat(i32),
|
|
/// Smooth hills: `base + amp * (sin(x/period) + cos(z/period)) / 2`.
|
|
Hills { base: i32, amp: f64, period: f64 },
|
|
}
|
|
|
|
/// The land as blocks: solid at and below the column's height (what the game would answer `render.sections` with).
|
|
impl airdef::sim::world::WorldSource for Terrain {
|
|
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
|
|
Some(y < self.height(x, z))
|
|
}
|
|
}
|
|
|
|
impl Terrain {
|
|
pub fn height(self, x: i32, z: i32) -> i32 {
|
|
match self {
|
|
Terrain::Flat(h) => h,
|
|
Terrain::Hills { base, amp, period } => {
|
|
base + (amp * ((f64::from(x) / period).sin() + (f64::from(z) / period).cos()) / 2.0).round() as i32
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct RaidSpec {
|
|
pub kinds: Vec<(DroneKind, u32)>,
|
|
pub approach: Approach,
|
|
pub aim: Aim,
|
|
pub dist: f64,
|
|
/// The tick the raid is ordered at (a raid waits for the previous one to be sent).
|
|
pub at: u64,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Unit {
|
|
pub kind: SamKind,
|
|
pub pos: DVec3,
|
|
pub heading: f64,
|
|
/// Starts with an empty magazine, waiting for a reload (logistics).
|
|
pub empty: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Scenario {
|
|
pub name: String,
|
|
pub seed: u64,
|
|
pub units: Vec<Unit>,
|
|
pub objects: Vec<DVec3>,
|
|
pub raids: Vec<RaidSpec>,
|
|
pub terrain: Terrain,
|
|
/// Hard limit of ticks (20 a second).
|
|
pub ticks: u64,
|
|
/// Stop as soon as every raid has ended.
|
|
pub stop_at_end: bool,
|
|
pub ew: bool,
|
|
/// Ticks a truck on a job may stand still before it counts as stuck.
|
|
pub stuck_after: u64,
|
|
}
|
|
|
|
impl Scenario {
|
|
pub fn new(name: &str) -> Self {
|
|
Scenario {
|
|
name: name.into(),
|
|
seed: 1,
|
|
units: Vec::new(),
|
|
objects: vec![DVec3::new(0.0, 70.0, 0.0)],
|
|
raids: Vec::new(),
|
|
terrain: Terrain::Flat(70),
|
|
ticks: 20 * 60 * 10,
|
|
stop_at_end: true,
|
|
ew: false,
|
|
stuck_after: 600,
|
|
}
|
|
}
|
|
pub fn unit(&mut self, kind: SamKind, x: f64, z: f64) -> &mut Unit {
|
|
self.units.push(Unit { kind, pos: DVec3::new(x, 70.0, z), heading: 0.0, empty: false });
|
|
self.units.last_mut().unwrap()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Report {
|
|
pub name: String,
|
|
pub seed: u64,
|
|
pub ticks_run: u64,
|
|
pub units: usize,
|
|
pub launched: u32,
|
|
pub kills: u32,
|
|
pub hits: u32,
|
|
/// Missiles fired by air defence (sum of the systems' `fired`).
|
|
pub fired: u32,
|
|
pub ground_queries: u64,
|
|
/// The block cache's counters (issue #43) and the drones that struck the land (issue #42).
|
|
pub world: String,
|
|
pub crashes: u32,
|
|
/// What went wrong for the air defence, as the raid report words it.
|
|
pub failures: String,
|
|
pub ground_batches: u64,
|
|
pub tick_p50: Duration,
|
|
pub tick_p95: Duration,
|
|
pub tick_max: Duration,
|
|
pub tick_total: Duration,
|
|
/// Time spent in ticks that began with a raid being planned.
|
|
pub planning: Duration,
|
|
pub planning_ticks: u64,
|
|
pub nan_positions: u64,
|
|
/// Ids of trucks that stood on a job without progress for too long.
|
|
pub stuck_trucks: Vec<u32>,
|
|
/// Truck-ticks spent on a job or driving (shows the logistics actually ran).
|
|
pub truck_busy_ticks: u64,
|
|
pub raids_ordered: usize,
|
|
pub raids_ended: usize,
|
|
pub all_ended: bool,
|
|
}
|
|
|
|
fn finite(v: DVec3) -> bool {
|
|
v.x.is_finite() && v.y.is_finite() && v.z.is_finite()
|
|
}
|
|
|
|
pub fn run(sc: &Scenario) -> Report {
|
|
let mut sim = Sim::new(sc.seed);
|
|
sim.damage = false; // explosions would change the land the height function describes
|
|
for u in &sc.units {
|
|
let id = sim.id();
|
|
// Systems stand on the land, whatever the layout says.
|
|
let pos = DVec3::new(u.pos.x, f64::from(sc.terrain.height(u.pos.x.floor() as i32, u.pos.z.floor() as i32)), u.pos.z);
|
|
let mut s = Sam::new(id, u.kind, pos, u.heading);
|
|
if u.empty {
|
|
s.empty = true;
|
|
s.missiles = 0;
|
|
}
|
|
sim.sams.push(s);
|
|
}
|
|
sim.targets = sc.objects.iter().map(|o| DVec3::new(o.x, f64::from(sc.terrain.height(o.x.floor() as i32, o.z.floor() as i32)), o.z)).collect();
|
|
sim.target_health = vec![100.0; sc.objects.len()];
|
|
if sc.ew {
|
|
sim.set_ew(true);
|
|
}
|
|
let mut ground = Ground::default();
|
|
let mut pending: Vec<(i32, i32, i32)> = Vec::new();
|
|
let mut r = Report { name: sc.name.clone(), seed: sc.seed, units: sc.units.len(), ..Default::default() };
|
|
let mut times: Vec<Duration> = Vec::with_capacity(sc.ticks as usize);
|
|
let mut ordered = vec![false; sc.raids.len()];
|
|
let mut launched_before = vec![0u32; sc.raids.len()];
|
|
let mut ended = vec![false; sc.raids.len()];
|
|
// truck id -> (last position, ticks standing while busy)
|
|
let mut standing: HashMap<u32, (DVec3, u64)> = HashMap::new();
|
|
let mut out = Out::default();
|
|
for now in 1..=sc.ticks {
|
|
// Raids come in order: one is ordered once the previous one has been sent.
|
|
for (i, spec) in sc.raids.iter().enumerate() {
|
|
if !ordered[i] && now >= spec.at && !sim.planning() && (i == 0 || ended[i - 1]) {
|
|
let order = RaidOrder { kinds: spec.kinds.clone(), approach: spec.approach, dist: spec.dist, aim: spec.aim };
|
|
launched_before[i] = sim.launched;
|
|
let objects = sim.targets.clone();
|
|
sim.order_raid(&order, &objects, &mut out);
|
|
ordered[i] = true;
|
|
}
|
|
}
|
|
ground.answer(std::mem::take(&mut pending), now);
|
|
let planning = sim.planning();
|
|
out = Out::default();
|
|
let t = Instant::now();
|
|
sim.step(&mut ground, None, &mut out);
|
|
let wanted = ground.take_wanted();
|
|
let sections = ground.world.take_wanted(now);
|
|
ground.world.serve(&sc.terrain, sections, now);
|
|
let dt = t.elapsed();
|
|
times.push(dt);
|
|
if planning {
|
|
r.planning += dt;
|
|
r.planning_ticks += 1;
|
|
}
|
|
if !wanted.is_empty() {
|
|
r.ground_batches += 1;
|
|
r.ground_queries += wanted.len() as u64;
|
|
pending = wanted.into_iter().map(|(x, z)| (x, z, sc.terrain.height(x, z))).collect();
|
|
}
|
|
r.ticks_run = now;
|
|
// NaN check on everything with a position.
|
|
let bad = sim.drones.iter().filter(|d| !finite(d.pos) || !finite(d.vel)).count()
|
|
+ sim.missiles.iter().filter(|m| !finite(m.pos)).count()
|
|
+ sim.sams.iter().filter(|s| !finite(s.pos)).count()
|
|
+ sim.blasts.iter().filter(|b| !finite(b.at)).count()
|
|
+ out.models.iter().filter(|m| !finite(m.at)).count();
|
|
r.nan_positions += bad as u64;
|
|
// Trucks with a job or on the move, standing still.
|
|
for s in sim.sams.iter().filter(|s| s.kind == SamKind::Tzm && s.alive()) {
|
|
let busy = s.moving() || s.job().is_some();
|
|
r.truck_busy_ticks += u64::from(busy);
|
|
let e = standing.entry(s.id).or_insert((s.pos, 0));
|
|
if !busy || e.0.distance(s.pos) > 0.05 {
|
|
*e = (s.pos, 0);
|
|
} else {
|
|
e.1 += 1;
|
|
if e.1 == sc.stuck_after && !r.stuck_trucks.contains(&s.id) {
|
|
r.stuck_trucks.push(s.id);
|
|
}
|
|
}
|
|
}
|
|
// A raid has ended when it was ordered, planned, flown and nothing is left in the air.
|
|
let quiet = !sim.planning() && sim.queue.is_empty() && !sim.drones.iter().any(|d| d.alive()) && sim.missiles.is_empty();
|
|
for i in 0..sc.raids.len() {
|
|
if ordered[i] && !ended[i] && quiet && sim.launched > launched_before[i] {
|
|
ended[i] = true;
|
|
}
|
|
}
|
|
if sc.stop_at_end && ended.iter().all(|e| *e) && !sc.raids.is_empty() {
|
|
break;
|
|
}
|
|
}
|
|
r.launched = sim.launched;
|
|
r.kills = sim.kills;
|
|
r.hits = sim.hits;
|
|
r.fired = sim.sams.iter().map(|s| s.fired).sum();
|
|
r.raids_ordered = ordered.iter().filter(|o| **o).count();
|
|
r.world = world_view::text(&ground.world);
|
|
r.crashes = sim.crashes;
|
|
r.failures = sim.tally.text();
|
|
r.raids_ended = ended.iter().filter(|o| **o).count();
|
|
r.all_ended = r.raids_ended == sc.raids.len();
|
|
r.tick_total = times.iter().sum();
|
|
times.sort();
|
|
if !times.is_empty() {
|
|
let at = |q: f64| times[((times.len() - 1) as f64 * q).round() as usize];
|
|
r.tick_p50 = at(0.5);
|
|
r.tick_p95 = at(0.95);
|
|
r.tick_max = *times.last().unwrap();
|
|
}
|
|
r
|
|
}
|
|
|
|
fn ms(d: Duration) -> f64 {
|
|
d.as_secs_f64() * 1000.0
|
|
}
|
|
|
|
impl Report {
|
|
/// Problems that fail a scenario: empty when it is healthy.
|
|
pub fn problems(&self) -> Vec<String> {
|
|
let mut p = Vec::new();
|
|
if self.nan_positions > 0 {
|
|
p.push(format!("{} non-finite positions", self.nan_positions));
|
|
}
|
|
if !self.stuck_trucks.is_empty() {
|
|
p.push(format!("stuck trucks: {:?}", self.stuck_trucks));
|
|
}
|
|
if !self.all_ended {
|
|
p.push(format!("{} of {} raids ended in {} ticks", self.raids_ended, self.raids_ended.max(self.raids_ordered), self.ticks_run));
|
|
}
|
|
p
|
|
}
|
|
pub fn text(&self) -> String {
|
|
format!(
|
|
"scenario {name} (seed {seed}): {units} units, {ticks} ticks ({secs:.0} s of game time)\n\
|
|
raids: {ro} ordered, {re} ended\n\
|
|
launched {l}, shot down {k}, got through {h}, air-defence missiles fired {f}\n\
|
|
tick time ms: p50 {p50:.3} p95 {p95:.3} max {max:.3} total {tot:.0}\n\
|
|
planning: {pl:.1} ms over {pt} ticks\n\
|
|
ground queries: {gq} in {gb} batches\n\
|
|
non-finite positions: {nan}\n\
|
|
stuck trucks: {stuck:?} (busy truck-ticks {busy})\n\
|
|
crashes into the land: {crashes}; {world}\n\
|
|
air-defence failures: {failures}\n",
|
|
name = self.name,
|
|
seed = self.seed,
|
|
units = self.units,
|
|
ticks = self.ticks_run,
|
|
secs = self.ticks_run as f64 / 20.0,
|
|
ro = self.raids_ordered,
|
|
re = self.raids_ended,
|
|
l = self.launched,
|
|
k = self.kills,
|
|
h = self.hits,
|
|
f = self.fired,
|
|
p50 = ms(self.tick_p50),
|
|
p95 = ms(self.tick_p95),
|
|
max = ms(self.tick_max),
|
|
tot = ms(self.tick_total),
|
|
pl = ms(self.planning),
|
|
pt = self.planning_ticks,
|
|
gq = self.ground_queries,
|
|
gb = self.ground_batches,
|
|
nan = self.nan_positions,
|
|
stuck = self.stuck_trucks,
|
|
busy = self.truck_busy_ticks,
|
|
crashes = self.crashes,
|
|
world = self.world,
|
|
failures = if self.failures.is_empty() { "none" } else { &self.failures },
|
|
)
|
|
}
|
|
pub fn json(&self) -> String {
|
|
format!(
|
|
"{{\"scenario\":{:?},\"seed\":{},\"units\":{},\"ticks\":{},\"raids_ordered\":{},\"raids_ended\":{},\
|
|
\"launched\":{},\"kills\":{},\"hits\":{},\"fired\":{},\
|
|
\"tick_ms\":{{\"p50\":{:.4},\"p95\":{:.4},\"max\":{:.4},\"total\":{:.2}}},\
|
|
\"planning_ms\":{:.2},\"planning_ticks\":{},\"ground_queries\":{},\"ground_batches\":{},\
|
|
\"nan_positions\":{},\"stuck_trucks\":{:?},\"truck_busy_ticks\":{}}}",
|
|
self.name,
|
|
self.seed,
|
|
self.units,
|
|
self.ticks_run,
|
|
self.raids_ordered,
|
|
self.raids_ended,
|
|
self.launched,
|
|
self.kills,
|
|
self.hits,
|
|
self.fired,
|
|
ms(self.tick_p50),
|
|
ms(self.tick_p95),
|
|
ms(self.tick_max),
|
|
ms(self.tick_total),
|
|
ms(self.planning),
|
|
self.planning_ticks,
|
|
self.ground_queries,
|
|
self.ground_batches,
|
|
self.nan_positions,
|
|
self.stuck_trucks,
|
|
self.truck_busy_ticks,
|
|
)
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------ ready-made scenarios
|
|
|
|
fn ring(sc: &mut Scenario, kinds: &[SamKind], count: usize, r0: f64, r1: f64) {
|
|
for i in 0..count {
|
|
let a = i as f64 * 2.399_963; // golden angle: an even spread without randomness
|
|
let r = r0 + (r1 - r0) * (i as f64 / count.max(1) as f64);
|
|
let kind = kinds[i % kinds.len()];
|
|
sc.unit(kind, a.sin() * r, a.cos() * r).heading = a;
|
|
}
|
|
}
|
|
|
|
/// A big smart raid on 61 systems.
|
|
pub fn big_smart() -> Scenario {
|
|
let mut sc = Scenario::new("big-smart");
|
|
sc.terrain = Terrain::Hills { base: 70, amp: 14.0, period: 90.0 };
|
|
for (x, z) in [(0.0, 0.0), (120.0, 80.0), (-150.0, -60.0)] {
|
|
sc.objects.push(DVec3::new(x, 70.0, z));
|
|
}
|
|
ring(&mut sc, &[SamKind::Radar], 6, 150.0, 500.0);
|
|
ring(&mut sc, &[SamKind::Ew], 2, 100.0, 300.0);
|
|
ring(&mut sc, &SamKind::ARMED, 53, 60.0, 450.0);
|
|
assert_eq!(sc.units.len(), 61);
|
|
sc.raids.push(RaidSpec {
|
|
kinds: vec![
|
|
(DroneKind::Gerbera, 8),
|
|
(DroneKind::Shahed, 12),
|
|
(DroneKind::Geran3, 4),
|
|
(DroneKind::Lancet, 4),
|
|
(DroneKind::Kh101, 4),
|
|
(DroneKind::Kalibr, 4),
|
|
(DroneKind::Iskander, 2),
|
|
],
|
|
approach: Approach::AllSides,
|
|
aim: Aim::Smart,
|
|
dist: 500.0,
|
|
at: 1,
|
|
});
|
|
sc.ticks = 20 * 60 * 8;
|
|
sc
|
|
}
|
|
|
|
/// Trucks and a depot supplying systems that start with empty magazines, under a raid.
|
|
pub fn logistics() -> Scenario {
|
|
let mut sc = Scenario::new("logistics");
|
|
sc.unit(SamKind::Radar, 0.0, -40.0);
|
|
sc.unit(SamKind::Depot, 300.0, 300.0);
|
|
for (x, z) in [(310.0, 320.0), (290.0, 330.0)] {
|
|
sc.unit(SamKind::Tzm, x, z);
|
|
}
|
|
sc.unit(SamKind::Tzm, 280.0, 280.0);
|
|
for (i, kind) in [SamKind::Patriot, SamKind::Tor, SamKind::Buk, SamKind::Nasams, SamKind::Pantsir, SamKind::S400].into_iter().enumerate() {
|
|
let a = i as f64 * 1.05;
|
|
let u = sc.unit(kind, a.sin() * 120.0, a.cos() * 120.0);
|
|
// Every other system starts out of missiles: the trucks have work from the first second.
|
|
u.empty = i % 2 == 0;
|
|
}
|
|
sc.raids.push(RaidSpec {
|
|
kinds: vec![(DroneKind::Shahed, 16), (DroneKind::Gerbera, 8), (DroneKind::Kh101, 3)],
|
|
approach: Approach::TwoSectors,
|
|
aim: Aim::Objects,
|
|
dist: 400.0,
|
|
at: 1,
|
|
});
|
|
sc.ticks = 20 * 60 * 6;
|
|
sc
|
|
}
|
|
|
|
/// EW stations on and a smart enemy that adapts over three raids.
|
|
pub fn ew_adaptive() -> Scenario {
|
|
let mut sc = Scenario::new("ew-adaptive");
|
|
sc.ew = true;
|
|
sc.unit(SamKind::Radar, 0.0, 0.0);
|
|
sc.unit(SamKind::Radar, 200.0, -200.0);
|
|
sc.unit(SamKind::Ew, 80.0, 0.0);
|
|
sc.unit(SamKind::Ew, -80.0, 60.0);
|
|
for (i, kind) in [SamKind::Pantsir, SamKind::Gepard, SamKind::Tor, SamKind::Patriot, SamKind::Buk, SamKind::Gepard].into_iter().enumerate() {
|
|
let a = i as f64 * 1.05;
|
|
sc.unit(kind, a.sin() * 150.0, a.cos() * 150.0);
|
|
}
|
|
for at in [1, 1, 1] {
|
|
sc.raids.push(RaidSpec {
|
|
kinds: vec![(DroneKind::Gerbera, 6), (DroneKind::Shahed, 10), (DroneKind::Kh101, 4), (DroneKind::Kalibr, 3)],
|
|
approach: Approach::AllSides,
|
|
aim: Aim::Smart,
|
|
dist: 450.0,
|
|
at,
|
|
});
|
|
}
|
|
sc.ticks = 20 * 60 * 12;
|
|
sc
|
|
}
|
|
|
|
pub fn preset(name: &str) -> Option<Scenario> {
|
|
match name {
|
|
"big-smart" => Some(big_smart()),
|
|
"logistics" => Some(logistics()),
|
|
"ew-adaptive" => Some(ew_adaptive()),
|
|
_ => None,
|
|
}
|
|
}
|
|
pub const PRESETS: [&str; 3] = ["big-smart", "logistics", "ew-adaptive"];
|