From e7934a1ebb4f8b11b9255f8ee85511bb6512786f Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 6 Nov 2026 18:35:09 +0300 Subject: [PATCH] Give the enemy a structured intent and a durable route queue (#127) Stage 2 of #127. A smart drone now carries why it is aimed where it is (Intent: plan, sead, react, replace) apart from the aim bookkeeping (unit, since, switches, confidence). Assignments are throttled per adaptive tick and per contact, a fresh disclosure replaces an aim only after a hold, within a switch cap and when the change is worth a new route, and a killed aim is replaced as a separate, lower-priority decision. The queue is bounded and taken by priority (plan > sead > react > replace); unknown land is no longer free in a replanned route. The autosave now carries the air state: the drones in flight, the launch queue, every pending job with its id, aim, unit, version, priority, intent and relief version, the contacts the jobs were chosen from and the relief snapshots they plan over. The format is a versioned `air 1` section; older saves read as before, scenarios do not carry the sky, stale, ghost and duplicate jobs are cancelled on load, and the section is bounded (512 drones, 512 route points, 4096 contacts, 8 reliefs, 900 KB). A periodic save waits while a raid is only being planned. enemy_picture() now explains targets and pending jobs (aims, aimed_by, pending_jobs, reason, work_per_tick) in English, and intel_bench records the queue and intent evidence next to completion, losses and failures. --- README.md | 3 + airdef-mod/examples/intel_bench.rs | 6 +- airdef-mod/examples/sim_run/harness.rs | 32 + airdef-mod/src/lib.rs | 93 +- airdef-mod/src/scenario.rs | 5 +- airdef-mod/src/sim/durable.rs | 941 ++++++++++++++++++ airdef-mod/src/sim/ew.rs | 836 ++++++++++++++-- airdef-mod/src/sim/mod.rs | 27 +- airdef-mod/src/sim/raid.rs | 33 +- airdef-mod/src/sim/regress.rs | 37 +- docs/CODEX-HANDOFF.md | 130 ++- docs/IMPLEMENTATION-STATUS.md | 13 +- .../issue-127-intent-queue-baseline.json | 154 +++ .../issue-127-intent-queue-result.json | 190 ++++ 14 files changed, 2411 insertions(+), 89 deletions(-) create mode 100644 airdef-mod/src/sim/durable.rs create mode 100644 docs/measurements/issue-127-intent-queue-baseline.json create mode 100644 docs/measurements/issue-127-intent-queue-result.json diff --git a/README.md b/README.md index f94aef3..81da661 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,9 @@ CI: `.gitea/workflows/build.yml` на каждый пуш собирает то **Сохранение.** Комплексы, объекты, их повреждения и настройки сохраняются отдельно для каждого мира (и для каждого сервера); уничтоженный комплекс остаётся остовом и после перезахода, пока его не отремонтируют. +Налёт в воздухе тоже сохраняется: дроны, очередь их запуска и незавершённые задания на маршрут +восстанавливаются, и перезаход не обрывает перенацеливание. Ракеты-перехватчики в воздухе и площадки +запуска не сохраняются; сценарий — стартовое состояние и налёт не несёт. **Сеть ПВО.** Единицы обмениваются измеренными треками в пределах игровой связи; повреждение связи сокращает её дальность. Можно поставить несколько С-400/С-500 без собственной РЛС и общие радары diff --git a/airdef-mod/examples/intel_bench.rs b/airdef-mod/examples/intel_bench.rs index a9b5998..b4d2025 100644 --- a/airdef-mod/examples/intel_bench.rs +++ b/airdef-mod/examples/intel_bench.rs @@ -21,7 +21,7 @@ fn esc(s: &str) -> String { fn run(sc: &Scenario) -> String { let r = harness::run(sc); format!( - "{{\"scenario\":\"{}\",\"seed\":{},\"ticks_run\":{},\"raids_ordered\":{},\"raids_ended\":{},\"all_ended\":{},\"launched\":{},\"kills\":{},\"hits\":{},\"crashes\":{},\"sams_lost\":{},\"fired\":{},\"failures\":\"{}\"}}", + "{{\"scenario\":\"{}\",\"seed\":{},\"ticks_run\":{},\"raids_ordered\":{},\"raids_ended\":{},\"all_ended\":{},\"launched\":{},\"kills\":{},\"hits\":{},\"crashes\":{},\"sams_lost\":{},\"fired\":{},\"retargets_max\":{},\"air_bytes\":{},\"switches_max\":{},\"intents\":\"{}\",\"failures\":\"{}\"}}", esc(&r.name), r.seed, r.ticks_run, @@ -34,6 +34,10 @@ fn run(sc: &Scenario) -> String { r.crashes, r.sams_lost, r.fired, + r.retargets_max, + r.air_bytes, + r.switches_max, + esc(&r.intents), esc(&r.failures), ) } diff --git a/airdef-mod/examples/sim_run/harness.rs b/airdef-mod/examples/sim_run/harness.rs index e90730b..ef5d4f5 100644 --- a/airdef-mod/examples/sim_run/harness.rs +++ b/airdef-mod/examples/sim_run/harness.rs @@ -130,6 +130,23 @@ pub struct Report { pub all_ended: bool, /// Systems the defence lost (destroyed units at the end). pub sams_lost: u32, + /// The most retarget route jobs waiting at once (#127). + pub retargets_max: usize, + /// The largest air section a save would have taken, in bytes (#127). + pub air_bytes: usize, + /// The aims every drone ended with, by intent: "plan:1,sead:2,react:3,replace:0". + pub intents: String, + /// The most aim switches one drone went through. + pub switches_max: u16, +} + +/// The last intent every smart drone of the run was seen carrying, as `name:count`. +fn intent_counts(seen: &HashMap) -> String { + let mut counts = std::collections::BTreeMap::new(); + for name in seen.values() { + *counts.entry(*name).or_insert(0usize) += 1; + } + ["plan", "sead", "react", "replace"].iter().map(|k| format!("{k}:{}", counts.get(*k).copied().unwrap_or(0))).collect::>().join(",") } fn finite(v: DVec3) -> bool { @@ -164,6 +181,8 @@ pub fn run(sc: &Scenario) -> Report { let mut ended = vec![false; sc.raids.len()]; // truck id -> (last position, ticks standing while busy) let mut standing: HashMap = HashMap::new(); + // drone id -> the last structured intent it carried (#127). + let mut intents_seen: HashMap = 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. @@ -196,6 +215,18 @@ pub fn run(sc: &Scenario) -> Report { pending = wanted.into_iter().map(|(x, z)| (x, z, sc.terrain.height(x, z))).collect(); } r.ticks_run = now; + r.retargets_max = r.retargets_max.max(sim.retargets_len()); + r.switches_max = sim.drones.iter().chain(sim.queue.iter().map(|(_, d)| d)).map(|d| d.aim_switches()).max().unwrap_or(0).max(r.switches_max); + for d in sim.drones.iter().chain(sim.queue.iter().map(|(_, d)| d)) { + if d.alive() && d.smart() { + intents_seen.insert(d.id, d.intent().name()); + } + } + // The air section a save would take, sampled when the sky is fullest (writing it every tick + // would distort the timings the bench measures). + if sim.drones.len() > r.air_bytes { + r.air_bytes = sim.air_text().map_or(0, |t| t.len()); + } // 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() @@ -239,6 +270,7 @@ pub fn run(sc: &Scenario) -> Report { r.failures = sim.tally.text(); r.raids_ended = ended.iter().filter(|o| **o).count(); r.all_ended = r.raids_ended == sc.raids.len(); + r.intents = intent_counts(&intents_seen); r.tick_total = times.iter().sum(); times.sort(); if !times.is_empty() { diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index 6b71b38..7067329 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -886,7 +886,13 @@ impl AirDef { /// The state as text, lines of words: the autosave's format, and the body of a scenario. /// `fresh`: every system and object whole, magazines full (a scenario to replay from the start). + /// The air state (the raid's drones, launch queue, retarget jobs and the picture behind them, + /// #127) belongs to the autosave only: a scenario is a start state, not a raid frozen mid-flight. fn state_text(&self, fresh: bool, view: bool) -> String { + self.state_text_with(fresh, view, !fresh) + } + + fn state_text_with(&self, fresh: bool, view: bool, air: bool) -> String { let mut text = String::new(); for s in &self.sim.sams { // Then its ammunition (missiles, bursts, out and waiting for a truck, reload ticks @@ -939,11 +945,27 @@ impl AirDef { for (i, s) in self.sim.sams.iter().enumerate().filter(|(_, s)| !s.doctrine.is_default()) { text += &format!("doctrine {i} {}\n", s.doctrine.save_text()); } + // The enemy's air state, last: it names its own format version, so an older build (or an + // older save) reads the layout and ignores it (#127). + if air && let Some(a) = self.sim.air_text() { + text += &a; + } text } fn save(&mut self) { - let text = self.state_text(false, true); + self.save_with(true); + } + + /// Writes the state. `air`: include the enemy's air state (#127). A raid still being *planned* has + /// no durable form — its items, sites and routes are half made — so a periodic save waits for it + /// to be launched (at most `sites::SEARCH_TICKS`), while a world change takes the layout alone. + fn save_with(&mut self, air: bool) { + if air && self.sim.planning() { + self.next_save = self.sim.tick + 20; + return; + } + let text = if air { self.state_text(false, true) } else { self.state_text_with(false, true, false) }; // Clean only once written: a failed write is tried again after a pause. match storage::set(&self.state_key(), text.as_bytes()) { Ok(()) => { @@ -993,7 +1015,15 @@ impl AirDef { /// naming the line and field, and the scene stays as it was (#86). fn apply_state(&mut self, text: &str) -> Result<(), String> { let layout = layout::Layout::parse(text)?; + // The air section is checked before anything is touched, like the layout (#127). + let air = sim::Sim::parse_air(text)?; self.commit_layout(layout); + if let Some(air) = air { + let cancelled = self.sim.restore_air(air); + if cancelled > 0 { + say(&format!("Воздушная обстановка восстановлена: отменено устаревших заданий {cancelled}")); + } + } Ok(()) } @@ -1134,6 +1164,10 @@ fn durable_mark(sim: &Sim) -> u64 { .fold(sim.sams.len() as u64, |a, h| a.wrapping_mul(31).wrapping_add(h)) .wrapping_mul(31) .wrapping_add(strikes) + // The air state's shape (#127): a launch, a loss, a new or finished route job makes the + // autosave take the raid as it stands, so a reload resumes it rather than an empty sky. + .wrapping_mul(31) + .wrapping_add(sim.air_mark()) } impl AirDef { @@ -1158,7 +1192,9 @@ impl AirDef { /// Another world opened: the last one's state saved, a clean simulation with this one's loaded. fn open_world(&mut self, world: Option) { if self.world.is_some() && self.dirty { - self.save(); + // Leaving the world: the layout is kept, a raid still being planned is not (it would have + // no drones to resume and the world it belonged to is going). + self.save_with(false); } self.end_session(presentation::ExitCause::WorldChange); let shader = self.sim.shader; @@ -2951,4 +2987,57 @@ mod tests { sim.strikes.progress = sim::strike::TUNE.progress_save; assert_ne!(durable_mark(&sim), checkpoint, "a refill checkpoint is not saved"); } + + /// #127: the air section of a save is written and read by the adapter, and a save without it (an + /// older one) still loads as before. The section itself is checked in the core's own tests; here + /// the contract is that the text the game writes is the text the next load restores, and that a + /// state with a sky replaces a state without one rather than merging into it. + #[test] + fn the_air_section_of_a_save_is_written_and_read_by_the_adapter() { + let zeros = "0 ".repeat(24); + let air = format!( + "air 1 120 2 0 1 1 1 1\n\ + airintel {zeros}\n\ + airglobal -\n\ + airrelief 0 0 0 16 8 u64\n\ + aircontact 32 s400 0 70 0 100 emission - - 0\n\ + airqueue 200 48 shahed 0 90 -600 0 1 0 0 70 0 cruise react 32 120 0 confirmed 0 0 0 0 0 0 false false true 0 - 0 - -\n\ + airjob 1 48 0 70 0 32 120 1 react 0\n" + ); + let saved = format!("sam {} 0 70 300 0\n{air}", SamKind::S400.key()); + let mut game = test_mod(); + game.apply_state(&saved).expect("a saved raid must load"); + assert_eq!(game.sim.tick, 120); + assert_eq!(game.sim.queue.len(), 1, "the launch queue is not restored"); + assert_eq!(game.sim.queue[0].0, 200); + assert_eq!(game.sim.drones.len(), 0); + let report = game.sim.enemy_picture(); + assert_eq!(report.pending.len(), 1, "the pending route job is not restored"); + assert_eq!(report.pending[0].intent, "react"); + assert_eq!(report.pending[0].unit, 32); + assert_eq!(report.contacts.len(), 1); + + // The game writes the same section back: what it read is what it saves. + let back = game.state_text(false, false); + assert!(back.contains("\nair 1 120 2 0 1 1 1 1\n"), "{back}"); + assert!(back.contains("\nairjob 1 48 0.00 70.00 0.00 32 120 1 react 0\n"), "{back}"); + assert!(back.contains("\nairqueue 200 48 "), "{back}"); + + // The air state is replaced, not merged: a save of a quiet world clears the sky. + let quiet = format!("sam {} 0 70 300 0\n", SamKind::S400.key()); + let quiet = format!("{quiet}air 1 300 2 0 0 0 0 0\nairintel {zeros}\nairglobal -\n"); + game.apply_state(&quiet).unwrap(); + assert!(game.sim.queue.is_empty() && game.sim.enemy_picture().pending.is_empty(), "an empty sky merged with the loaded one"); + + // An older save has no section at all: it loads its layout and leaves the sky as it was. + let mut old = test_mod(); + old.apply_state(&format!("sam {} 0 70 0 0\n", SamKind::S400.key())).unwrap(); + assert_eq!(old.sim.sams.len(), 1); + assert!(sim::Sim::parse_air("sam s400 0 70 0 0\n").unwrap().is_none()); + + // A scenario's body is a start state: it does not carry the sky. + let scenario = game.scenario_text("t", false); + assert!(!scenario.contains("\nair 1 "), "a scenario carries the air state"); + assert!(sim::Sim::parse_air(&scenario).unwrap().is_none()); + } } diff --git a/airdef-mod/src/scenario.rs b/airdef-mod/src/scenario.rs index 85fa4f6..4049134 100644 --- a/airdef-mod/src/scenario.rs +++ b/airdef-mod/src/scenario.rs @@ -36,9 +36,10 @@ fn names() -> Vec { impl AirDef { /// The scenario as text. - fn scenario_text(&self, name: &str, fresh: bool) -> String { + pub(crate) fn scenario_text(&self, name: &str, fresh: bool) -> String { let mut text = format!("scenario {VERSION}\nname {name}\nworld {}\n", self.state_key()); - text += &self.state_text(fresh, false); + // A scenario is a start state: its body is the layout, not the raid a save happened to catch. + text += &self.state_text_with(fresh, false, false); text += &format!("seed {}\nwave {}\n", self.sim.rng.state(), self.wave.text()); text } diff --git a/airdef-mod/src/sim/durable.rs b/airdef-mod/src/sim/durable.rs new file mode 100644 index 0000000..8df7d57 --- /dev/null +++ b/airdef-mod/src/sim/durable.rs @@ -0,0 +1,941 @@ +//! The enemy's air state in the autosave (#127): the raid's drones, the launch queue, the retarget +//! jobs still waiting, the picture they were chosen from and the land they were planned over. +//! +//! A route job alone is not durable. Its drone may still be on the ground or already flying, and its +//! aim is only meaningful with the picture the adaptive tick chose it from and the relief snapshot of +//! that knowledge version. So the save writes the whole air state that a pending job depends on, and +//! the load rebuilds it: drones, launch queue, jobs (with their id, aim, unit, version, priority and +//! intent), the contacts, and each relief version a pending job still uses. Nothing of the live +//! picture is mixed into a replay: a recording keeps trajectories, and this section is written and +//! read for the live simulation only. +//! +//! The format is its own versioned section of `state_text` (`air 1 ...`). A save without the section +//! (every save made before #127) loads exactly as before: the layout, and an empty sky. Every value is +//! checked before anything is restored, like [`layout`](crate::layout): a bad line refuses the whole +//! save and names the line and the field. Everything is bounded — drones, queued launches, jobs, +//! contacts, route points and relief cells — so a saved air state can never grow the file without limit. +use super::*; +use std::collections::{BTreeMap, HashMap}; + +/// The section's format version. A different one is refused rather than half-read. +const VERSION: u32 = 1; +/// Drones in the air a save may hold. A raid is normally tens of drones; this bound keeps the +/// section far inside the host's per-write storage budget even if a scenario makes thousands. +const MAX_DRONES: usize = 512; +/// Bytes the section may take before further drones are left out (the host allows 1 MiB a write; +/// the layout and the headroom of the rest of the state stay clear of it). +const MAX_BYTES: usize = 900_000; +/// Route points one drone may have in a save. +const MAX_ROUTE: usize = 512; +/// Contacts (the picture, and a job's picture rebuilt from it) a save may hold. +const MAX_CONTACTS: usize = 4096; +/// Relief versions a save may hold, and the cells of each. +const MAX_RELIEFS: usize = 8; +const MAX_CELLS: usize = 40_000; + +/// What the air section of a save holds, checked as a whole before the scene is touched. +pub struct AirSave { + tick: u64, + job_next: u32, + kills: [u32; ew::SECTORS], + hits: [u32; ew::SECTORS], + contacts: Vec, + drones: Vec, + queue: Vec<(u64, Drone)>, + jobs: Vec, + /// The land new retarget jobs take as their version. + known_relief: Option>, +} + +pub(crate) struct JobRec { + id: u32, + drone: u32, + aim: DVec3, + unit: u32, + version: u64, + intent: ew::Intent, + relief: Option>, +} + +/// A saved line's words, with the line number for the refusal. +struct Line<'a> { + n: usize, + word: &'a str, + w: Vec<&'a str>, +} + +impl Line<'_> { + fn bad(&self, field: &str, why: &str) -> String { + format!("строка {}: «{}», поле {field}: {why}", self.n, self.word) + } + fn word(&self, i: usize, field: &str) -> Result<&str, String> { + self.w.get(i).copied().ok_or_else(|| self.bad(field, "строка оборвана")) + } + fn count(&self, i: usize, field: &str, max: usize) -> Result { + let v = self.word(i, field)?; + let n: usize = v.parse().map_err(|_| self.bad(field, &format!("«{v}» не целое число")))?; + if n > max { + return Err(self.bad(field, &format!("{n} больше {max}"))); + } + Ok(n) + } + fn int(&self, i: usize, field: &str, lo: i64, hi: i64) -> Result { + let v = self.word(i, field)?; + let n: i64 = v.parse().map_err(|_| self.bad(field, &format!("«{v}» не целое число")))?; + if n < lo || n > hi { + return Err(self.bad(field, &format!("{n} вне допустимого {lo}..{hi}"))); + } + Ok(n) + } + fn flag(&self, i: usize, field: &str) -> Result { + match self.word(i, field)? { + "true" => Ok(true), + "false" => Ok(false), + v => Err(self.bad(field, &format!("«{v}»: нужно true или false"))), + } + } + fn num(&self, i: usize, field: &str) -> Result { + let v = self.word(i, field)?; + let x: f64 = v.parse().map_err(|_| self.bad(field, &format!("«{v}» не число")))?; + if !x.is_finite() || x.abs() > 3.0e7 { + return Err(self.bad(field, &format!("«{v}» не конечное допустимое число"))); + } + Ok(x) + } + fn opt_tick(&self, i: usize, field: &str) -> Result, String> { + if self.word(i, field)? == "-" { + return Ok(None); + } + Ok(Some(self.int(i, field, 0, 1 << 40)? as u64)) + } +} + +/// `x` as the save writes coordinates: centimetres are plenty for a flight, and keep the file small. +fn coord(x: f64) -> String { + format!("{x:.2}") +} + +/// One run of the relief as the save writes it: `u` for unknown land, `x` for known. +fn run_token(height: Option, run: usize) -> String { + height.map_or_else(|| format!("u{run}"), |h| format!("{h}x{run}")) +} + +fn tick(x: Option) -> String { + x.map_or_else(|| "-".into(), |v| v.to_string()) +} + +fn intent_of(key: &str) -> Option { + ew::Intent::from_key(key) +} + +fn flight_key(f: Flight) -> &'static str { + match f { + Flight::Cruise => "cruise", + Flight::Dive => "dive", + Flight::Ballistic => "ballistic", + Flight::Falling => "falling", + } +} + +fn flight_of(key: &str) -> Option { + [Flight::Cruise, Flight::Dive, Flight::Ballistic, Flight::Falling].into_iter().find(|f| flight_key(*f) == key) +} + +impl Drone { + /// The drone as one line of the save: its flight, its aim and intent, and its route. + fn air_line(&self, word: &str, lead: &str) -> String { + let mut route = String::from("-"); + if !self.route.is_empty() { + route = self.route.len().to_string(); + for p in &self.route { + route += &format!(" {} {} {}", coord(p.x), coord(p.y), coord(p.z)); + } + } + format!( + "{word} {lead}{id} {kind} {} {} {} {} {} {} {} {} {} {flight} {intent} {unit} {since} {switches} {conf} {} {} {} {age} {engaged} {sector} {low} {jammed} {smart} {hold} {} {} {} {route}", + coord(self.pos.x), + coord(self.pos.y), + coord(self.pos.z), + coord(self.vel.x), + coord(self.vel.y), + coord(self.vel.z), + coord(self.target.x), + coord(self.target.y), + coord(self.target.z), + coord(self.err.x), + coord(self.err.y), + coord(self.err.z), + if self.pace.best.is_finite() { coord(self.pace.best) } else { "-".into() }, + self.pace.at, + if self.pace.route == usize::MAX { "-".to_string() } else { self.pace.route.to_string() }, + kind = self.kind.spec().key, + flight = flight_key(self.flight), + intent = self.intent.key(), + unit = self.aim.unit, + since = self.aim.since, + switches = self.aim.switches, + conf = self.aim.confidence.map_or("-", |c| c.name()), + age = self.age, + engaged = self.engaged, + sector = self.sector, + low = self.low, + jammed = self.jammed, + smart = self.smart, + hold = self.hold, + id = self.id, + ) + } +} + +impl Sim { + /// A cheap shape of the air state for the autosave's dirty check: a launch, a loss, a new or + /// finished job changes it, a drone's own progress through the sky does not. A save takes the + /// air state at those moments, not on every tick of a flight (#127). + pub fn air_mark(&self) -> u64 { + let drones = self.drones.iter().filter(|d| d.alive()).count() as u64; + drones + ^ (self.queue.len() as u64) << 12 + ^ (self.retargets.len() as u64) << 24 + ^ (self.intel.contacts().len() as u64) << 36 + ^ (self.job_next as u64) << 48 + } + + /// The air section of the state text: `None` when there is nothing to keep (an empty sky and no + /// picture), so a save of a quiet world does not grow. Every pending job's drone is written with + /// it, together with the picture and the relief versions the job still plans over (#127). + pub fn air_text(&self) -> Option { + // What the parser accepts is what is written: a save this build writes always reads back. + let contacts: Vec = self.intel.contacts().values().copied().take(MAX_CONTACTS).collect(); + let drones: Vec<&Drone> = self.drones.iter().filter(|d| d.alive()).take(MAX_DRONES).collect(); + let queued: Vec<&(u64, Drone)> = self.queue.iter().take(MAX_DRONES).collect(); + // The relief versions still referenced: the global one and every pending job's. They are + // resolved before the drone lines so the head is fixed while the body's size is measured. + let mut reliefs: Vec> = Vec::new(); + let token_of = |r: &std::sync::Arc, reliefs: &mut Vec>| -> Option { + let at = std::sync::Arc::as_ptr(r) as usize; + if let Some(i) = reliefs.iter().position(|x| std::sync::Arc::as_ptr(x) as usize == at) { + return Some(i); + } + if reliefs.len() >= MAX_RELIEFS { + return None; + } + reliefs.push(std::sync::Arc::clone(r)); + Some(reliefs.len() - 1) + }; + let global = self.known_relief.as_ref().and_then(|r| token_of(r, &mut reliefs)); + let job_relief: Vec> = self.retargets.iter().map(|j| j.relief.as_ref().and_then(|r| token_of(r, &mut reliefs))).collect(); + let (kills, hits) = self.intel.sectors(); + // The head, with the counts filled in later; its length is what the body is measured against. + let mut head = format!("air {VERSION} {} {} 0 0 0 {} {}\n", self.tick, self.job_next, contacts.len(), reliefs.len()); + head += "airintel"; + for v in kills.iter().chain(hits.iter()) { + head += &format!(" {v}"); + } + head += "\n"; + head += &format!("airglobal {}\n", global.map_or_else(|| "-".into(), |t| t.to_string())); + for (i, r) in reliefs.iter().enumerate() { + let g = r.grid(); + let mut line = format!("airrelief {i} {} {} {} {} ", coord(g.min.x), coord(g.min.z), coord(g.cell), g.size); + let mut run: Option<(Option, usize)> = None; + for h in r.heights() { + let key = h.is_finite().then(|| h.round() as i32); + match run { + Some((k, n)) if k == key => run = Some((k, n + 1)), + Some((k, n)) => { + line += &format!("{} ", run_token(k, n)); + run = Some((key, 1)); + } + None => run = Some((key, 1)), + } + } + if let Some((k, n)) = run { + line += &run_token(k, n); + } + head += &line; + head += "\n"; + } + for c in &contacts { + head += &format!( + "aircontact {} {} {} {} {} {} {} {} {} {}\n", + c.id, + c.kind.spec().key, + coord(c.at.x), + coord(c.at.y), + coord(c.at.z), + c.tick, + c.seen.name(), + tick(c.emission), + tick(c.destroyed), + c.launches + ); + } + // The bodies go in while the section stays inside its byte bound; a drone left out takes its + // job with it (a job whose drone is not saved could never be restored). + let mut body = String::new(); + let mut saved: std::collections::HashSet = std::collections::HashSet::new(); + let fits = |head: &str, body: &str, line: &str| head.len() + 32 + body.len() + line.len() <= MAX_BYTES; + let mut drone_count = 0usize; + for d in &drones { + let line = d.air_line("airdrone", ""); + if !fits(&head, &body, &line) { + break; + } + body += &line; + body += "\n"; + saved.insert(d.id); + drone_count += 1; + } + let mut queued_count = 0usize; + for (at, d) in &queued { + let line = d.air_line("airqueue", &format!("{at} ")); + if !fits(&head, &body, &line) { + break; + } + body += &line; + body += "\n"; + saved.insert(d.id); + queued_count += 1; + } + let units: std::collections::HashSet = contacts.iter().map(|c| c.id).collect(); + let jobs: Vec<(&ew::RouteJob, Option)> = self + .retargets + .iter() + .zip(job_relief) + .filter(|(j, _)| saved.contains(&j.drone) && (j.unit == 0 || units.contains(&j.unit))) + .collect(); + if drone_count == 0 && queued_count == 0 && jobs.is_empty() && contacts.is_empty() { + return None; + } + let head = head.replacen( + &format!("air {VERSION} {} {} 0 0 0 ", self.tick, self.job_next), + &format!("air {VERSION} {} {} {drone_count} {queued_count} {} ", self.tick, self.job_next, jobs.len()), + 1, + ); + let mut text = head; + text += &body; + for (job, relief) in jobs { + text += &format!( + "airjob {} {} {} {} {} {} {} {} {} {}\n", + job.id, + job.drone, + coord(job.aim.x), + coord(job.aim.y), + coord(job.aim.z), + job.unit, + job.version, + job.priority(), + job.intent.key(), + relief.map_or_else(|| "-".into(), |t| t.to_string()) + ); + } + Some(text) + } + + /// Reads the air section of `text` (the lines `air_text` writes). `None` when the text has none: + /// an older save, restored with an empty sky as before. Everything is checked before it is used. + pub fn parse_air(text: &str) -> Result, String> { + let lines: Vec<(usize, Vec<&str>)> = text + .lines() + .enumerate() + .map(|(n, raw)| (n + 1, raw.split_whitespace().collect::>())) + .collect(); + let heads: Vec<&(usize, Vec<&str>)> = lines.iter().filter(|(_, w)| w.first() == Some(&"air")).collect(); + let Some((head_n, head)) = heads.first().copied() else { + // Lines of a section whose header is gone are a corrupt file, not an old save. + if let Some((n, w)) = lines.iter().find(|(_, w)| w.first().is_some_and(|w| w.starts_with("air"))) { + return Err(format!("строка {n}: «{}», воздушная часть без заголовка air", w[0])); + } + return Ok(None); + }; + if heads.len() > 1 { + return Err(format!("строка {}: воздушная часть записана дважды", heads[1].0)); + } + let l = Line { n: *head_n, word: "air", w: head.clone() }; + if head.len() != 9 { + return Err(l.bad("заголовок", "нужны версия, такт, счётчики и число версий рельефа")); + } + let version = l.int(1, "версия воздушной части", 0, 1 << 30)? as u32; + if version != VERSION { + return Err(l.bad("версия воздушной части", &format!("эта сборка читает только {VERSION}"))); + } + let tick = l.int(2, "такт", 0, 1 << 40)?.max(0) as u64; + let job_next = l.int(3, "номер задания", 0, 1 << 30)? as u32; + let n_drones = l.count(4, "дроны", MAX_DRONES)?; + let n_queue = l.count(5, "очередь запуска", MAX_DRONES)?; + let n_jobs = l.count(6, "задания", ew::JOBS_MAX)?; + let n_contacts = l.count(7, "контакты", MAX_CONTACTS)?; + let n_reliefs = l.count(8, "версии рельефа", MAX_RELIEFS)?; + // Every line of the section, and nothing else: a truncated or foreign line is refused. + let mut intel: Option<[u32; ew::SECTORS * 2]> = None; + let mut global: Option = None; + let mut reliefs: Vec>> = vec![None; n_reliefs]; + let mut contacts: BTreeMap = BTreeMap::new(); + let mut drones: BTreeMap = BTreeMap::new(); + let mut queue: Vec<(u64, Drone)> = Vec::new(); + let mut jobs: Vec = Vec::new(); + for (n, w) in &lines { + if w.first() == Some(&"air") { + continue; + } + let Some(&word) = w.first() else { continue }; + if !word.starts_with("air") { + continue; + } + let l = Line { n: *n, word, w: w.clone() }; + match word { + "airintel" => { + if w.len() != 1 + ew::SECTORS * 2 { + return Err(l.bad("счётчики секторов", &format!("нужны {} чисел", ew::SECTORS * 2))); + } + let mut v = [0u32; ew::SECTORS * 2]; + for (i, slot) in v.iter_mut().enumerate() { + *slot = l.int(i + 1, "счётчики секторов", 0, 1 << 30)? as u32; + } + intel = Some(v); + } + "airglobal" => { + if w.len() != 2 { + return Err(l.bad("рельеф", "нужна одна ссылка на версию рельефа")); + } + let t = l.word(1, "рельеф")?; + if t != "-" { + let at = l.count(1, "рельеф", MAX_RELIEFS)?; + if at >= n_reliefs { + return Err(l.bad("рельеф", "ссылка на неписаную версию")); + } + global = Some(at); + } + } + "airrelief" => { + let at = l.count(1, "номер рельефа", MAX_RELIEFS)?; + if at >= n_reliefs { + return Err(l.bad("номер рельефа", "больше объявленных")); + } + let grid = raid::Grid { + min: DVec3::new(l.num(2, "рельеф X")?, 0.0, l.num(3, "рельеф Z")?), + cell: l.num(4, "размер клетки")?, + size: l.int(5, "сторона сетки", 1, 400)? as i32, + }; + if grid.cell <= 0.0 { + return Err(l.bad("размер клетки", "должен быть больше нуля")); + } + let cells = (grid.size * grid.size) as usize; + if cells > MAX_CELLS { + return Err(l.bad("рельеф", &format!("{cells} клеток больше {MAX_CELLS}"))); + } + let mut heights = Vec::with_capacity(cells); + for t in &w[6..] { + // `u` is unknown land, `x` a known height repeated. + let (h, run) = if let Some(n) = t.strip_prefix('u') { + (f32::NAN, n) + } else { + let Some((head, n)) = t.split_once('x') else { + return Err(l.bad("рельеф", &format!("«{t}» не повтор высоты"))); + }; + let v: i32 = head.parse().map_err(|_| l.bad("рельеф", &format!("«{head}» не высота")))?; + (v as f32, n) + }; + let run: usize = run.parse().map_err(|_| l.bad("рельеф", "длина повтора не число"))?; + if run == 0 || run > cells { + return Err(l.bad("рельеф", &format!("повтор {run} вне 1..{cells}"))); + } + if heights.len() + run > cells { + return Err(l.bad("рельеф", "повторы длиннее сетки")); + } + heights.extend(std::iter::repeat_n(h, run)); + } + if heights.len() != cells { + return Err(l.bad("рельеф", "повторы не покрывают сетку")); + } + if reliefs[at].is_some() { + return Err(l.bad("номер рельефа", "версия записана дважды")); + } + reliefs[at] = Some(std::sync::Arc::new(ew::KnownRelief::new(grid, heights))); + } + "aircontact" => { + if w.len() != 11 { + return Err(l.bad("контакт", "нужны 10 полей наблюдения")); + } + let id = l.int(1, "номер юнита", 0, 1 << 30)? as u32; + let kind = SamKind::from_key(l.word(2, "тип")?).ok_or_else(|| l.bad("тип", "неизвестный тип"))?; + let at = DVec3::new(l.num(3, "контакт X")?, l.num(4, "контакт Y")?, l.num(5, "контакт Z")?); + let seen = ew::Seen::from_key(l.word(7, "канал")?).ok_or_else(|| l.bad("канал", "launch, emission или jamming"))?; + let c = ew::Contact { + id, + kind, + at, + tick: l.int(6, "такт наблюдения", 0, tick as i64)? as u64, + seen, + emission: l.opt_tick(8, "фиксация эмиссии")?, + destroyed: l.opt_tick(9, "наблюдённое уничтожение")?, + launches: l.int(10, "наблюдённые пуски", 0, u16::MAX as i64)? as u16, + }; + if contacts.insert(id, c).is_some() { + return Err(l.bad("номер юнита", "контакт записан дважды")); + } + } + "airdrone" | "airqueue" => { + let at = if word == "airqueue" { Some(l.int(1, "такт запуска", 0, 1 << 40)? as u64) } else { None }; + let (d, end) = parse_drone(&l, if at.is_some() { 2 } else { 1 })?; + if w.len() != end { + return Err(l.bad("дрон", "лишние поля")); + } + if at.is_some() { + queue.push((at.unwrap(), d)); + } else if drones.insert(d.id, d).is_some() { + return Err(l.bad("номер дрона", "дрон записан дважды")); + } + } + "airjob" => { + if w.len() != 11 { + return Err(l.bad("задание", "нужны 9 полей задания")); + } + let id = l.int(1, "номер задания", 0, 1 << 30)? as u32; + let drone = l.int(2, "дрон задания", 0, 1 << 30)? as u32; + let aim = DVec3::new(l.num(3, "цель X")?, l.num(4, "цель Y")?, l.num(5, "цель Z")?); + let unit = l.int(6, "юнит", 0, 1 << 30)? as u32; + let version = l.int(7, "версия знаний", 0, tick as i64)? as u64; + let priority = l.int(8, "приоритет", 0, 9)? as u8; + let intent = intent_of(l.word(9, "намерение")?).ok_or_else(|| l.bad("намерение", "plan, sead, react или replace"))?; + if priority != intent.priority() { + return Err(l.bad("приоритет", "не совпадает с намерением")); + } + let relief = match l.word(10, "рельеф")? { + "-" => None, + t => { + let at = t.parse::().map_err(|_| l.bad("рельеф", "не номер версии"))?; + if at >= n_reliefs { + return Err(l.bad("рельеф", "ссылка на неписаную версию")); + } + reliefs[at].clone() + } + }; + jobs.push(JobRec { id, drone, aim, unit, version, intent, relief }); + } + other => return Err(l.bad("строка", &format!("неизвестная строка воздушной части «{other}»"))), + } + } + // The counts are the file's own promise: a section cut short is refused, not half-restored. + if contacts.len() != n_contacts { + return Err(format!("воздушная часть: контактов {} вместо {n_contacts}", contacts.len())); + } + if drones.len() != n_drones { + return Err(format!("воздушная часть: дронов {} вместо {n_drones}", drones.len())); + } + if queue.len() != n_queue { + return Err(format!("воздушная часть: очередь {} вместо {n_queue}", queue.len())); + } + if jobs.len() != n_jobs { + return Err(format!("воздушная часть: заданий {} вместо {n_jobs}", jobs.len())); + } + if reliefs.iter().any(Option::is_none) { + return Err("воздушная часть: объявленная версия рельефа не записана".into()); + } + let mut seen_drones: std::collections::HashSet = drones.keys().copied().collect(); + for (_, d) in &queue { + if !seen_drones.insert(d.id) { + return Err("воздушная часть: дрон записан и в небе, и в очереди".into()); + } + } + if let Some(g) = global && reliefs[g].is_none() { + return Err("воздушная часть: глобальный рельеф не записан".into()); + } + let intel = intel.ok_or("воздушная часть: нет счётчиков секторов")?; + let mut kills = [0u32; ew::SECTORS]; + let mut hits = [0u32; ew::SECTORS]; + kills.copy_from_slice(&intel[..ew::SECTORS]); + hits.copy_from_slice(&intel[ew::SECTORS..]); + Ok(Some(AirSave { + tick, + job_next, + kills, + hits, + contacts: contacts.into_values().collect(), + drones: drones.into_values().collect(), + queue, + jobs, + known_relief: global.and_then(|g| reliefs[g].clone()), + })) + } + + /// Puts a checked air section back: the drones, the launch queue, the jobs (with the picture and + /// relief of each version) and the contacts. Stale jobs are cancelled, the queue is bounded, and a + /// job whose drone, aim or unit no longer stands is dropped — a restored job is never a ghost. + /// The live air state is replaced, never merged: what the sky held before the load goes. + pub fn restore_air(&mut self, save: AirSave) -> u32 { + self.tick = save.tick; + // New jobs must not reuse an id a restored job already holds. + let highest = save.jobs.iter().map(|j| j.id).max().unwrap_or(0); + self.job_next = save.job_next.max(1).max(highest.saturating_add(1)); + self.intel = ew::Intel::restore(save.contacts.clone(), save.kills, save.hits); + self.known_relief = save.known_relief; + self.drones = save.drones; + self.queue = save.queue; + self.retargets.clear(); + // A job's picture is its version's: the contacts observed no later than it, plus its own + // target (whose last saved observation stands in for one made after the version). + let mut by_version: HashMap>> = HashMap::new(); + for rec in save.jobs { + let known = by_version.entry(rec.version).or_insert_with(|| { + let picture: Vec = save + .contacts + .iter() + .filter(|c| c.tick <= rec.version || c.id == rec.unit) + .copied() + .collect(); + std::sync::Arc::new(picture) + }); + let job = ew::RouteJob::restored(rec.id, rec.drone, rec.aim, rec.unit, rec.version, rec.intent, std::sync::Arc::clone(known), rec.relief); + self.retargets.push_back(job); + } + // Jobs the saved picture no longer stands behind, and anything past the bound, go now. + self.prune_air() + } +} + +/// One drone's line (after its word and, for the queue, its launch tick). Returns the drone and the +/// number of words the body took. +fn parse_drone(l: &Line, first: usize) -> Result<(Drone, usize), String> { + let id = l.int(first, "номер дрона", 1, 1 << 30)? as u32; + let kind = DroneKind::from_key(l.word(first + 1, "тип дрона")?).ok_or_else(|| l.bad("тип дрона", "неизвестный тип"))?; + let pos = DVec3::new(l.num(first + 2, "дрон X")?, l.num(first + 3, "дрон Y")?, l.num(first + 4, "дрон Z")?); + let vel = DVec3::new(l.num(first + 5, "скорость X")?, l.num(first + 6, "скорость Y")?, l.num(first + 7, "скорость Z")?); + let target = DVec3::new(l.num(first + 8, "цель X")?, l.num(first + 9, "цель Y")?, l.num(first + 10, "цель Z")?); + let flight = flight_of(l.word(first + 11, "полёт")?).ok_or_else(|| l.bad("полёт", "cruise, dive, ballistic или falling"))?; + let intent = intent_of(l.word(first + 12, "намерение")?).ok_or_else(|| l.bad("намерение", "plan, sead, react или replace"))?; + let unit = l.int(first + 13, "юнит намерения", 0, 1 << 30)? as u32; + let since = l.int(first + 14, "такт выбора цели", 0, 1 << 40)? as u64; + let switches = l.int(first + 15, "переключения", 0, u16::MAX as i64)? as u16; + let conf = match l.word(first + 16, "уверенность")? { + "-" => None, + key => Some(ew::Confidence::from_key(key).ok_or_else(|| l.bad("уверенность", "doubtful, probable или confirmed"))?), + }; + let err = DVec3::new(l.num(first + 17, "ошибка X")?, l.num(first + 18, "ошибка Y")?, l.num(first + 19, "ошибка Z")?); + let age = l.int(first + 20, "возраст", 0, 1 << 30)? as u32; + let engaged = l.int(first + 21, "обстрелян", 0, 1 << 20)? as u32; + let sector = l.int(first + 22, "сектор", 0, ew::SECTORS as i64)? as u8; + let low = l.flag(first + 23, "низко")?; + let jammed = l.flag(first + 24, "подавлен")?; + let smart = l.flag(first + 25, "умный")?; + let hold = l.int(first + 26, "задержка", 0, u16::MAX as i64)? as u16; + let best = match l.word(first + 27, "лучшее приближение")? { + "-" => f64::INFINITY, + _ => l.num(first + 27, "лучшее приближение")?, + }; + let pat = l.int(first + 28, "такт приближения", 0, 1 << 30)? as u32; + let proute = match l.word(first + 29, "точек приближения")? { + "-" => usize::MAX, + _ => l.count(first + 29, "точек приближения", MAX_ROUTE)?, + }; + let mut at = first + 30; + let mut route = Vec::new(); + if l.word(at, "маршрут")? != "-" { + let n = l.count(at, "точек маршрута", MAX_ROUTE)?; + at += 1; + for _ in 0..n { + route.push(DVec3::new(l.num(at, "маршрут X")?, l.num(at + 1, "маршрут Y")?, l.num(at + 2, "маршрут Z")?)); + at += 3; + } + } else { + at += 1; + } + let drone = Drone { + id, + kind, + pos, + vel, + target, + flight, + roll: 0.0, + spin: 0.0, + age, + engaged, + route, + low, + jammed, + sector, + smart, + nav: Nav::default(), + err, + intent, + aim: ew::Aim { unit, since, switches, confidence: conf }, + hold, + pace: Pace { best, at: pat, route: proute }, + }; + Ok((drone, at)) +} + +/// The jobs' priorities and intents as the report words them (tests and diagnostics). +#[cfg(test)] +impl Sim { + pub(super) fn pending_intents(&self) -> Vec<(u32, ew::Intent)> { + self.retargets.iter().map(|j| (j.id, j.intent)).collect() + } +} + +#[cfg(test)] +mod durable_tests { + use super::*; + + fn contact(id: u32, kind: SamKind, at: DVec3, tick: u64, seen: ew::Seen, launches: u16) -> ew::Contact { + ew::Contact { id, kind, at, tick, seen, emission: Some(tick), destroyed: None, launches } + } + + /// A small force, a few known contacts and a relief version, as a smart raid leaves them. + fn raid_state(seed: u64) -> Sim { + let mut sim = Sim::new(seed); + let grid = raid::Grid { min: DVec3::new(-1024.0, 0.0, -1024.0), cell: 32.0, size: 64 }; + let mut heights = vec![71.0f32; (grid.size * grid.size) as usize]; + for i in 0..8 { + heights[(i * grid.size + 3) as usize] = f32::NAN; + } + sim.known_relief = Some(std::sync::Arc::new(ew::KnownRelief::new(grid, heights))); + let mut ids = Vec::new(); + for (k, kind) in [SamKind::S400, SamKind::Radar, SamKind::Ew].into_iter().enumerate() { + let id = sim.id(); + let at = DVec3::new(-400.0 + k as f64 * 400.0, 70.0, 0.0); + let c = contact(id, kind, at, 10, if kind == SamKind::Ew { ew::Seen::Jamming } else { ew::Seen::Emission }, k as u16); + sim.intel = ew::Intel::restore([c].to_vec(), [0; ew::SECTORS], [0; ew::SECTORS]); + ids.push(id); + } + sim.intel = ew::Intel::restore( + ids.iter().enumerate().map(|(k, id)| contact(*id, [SamKind::S400, SamKind::Radar, SamKind::Ew][k], DVec3::new(-400.0 + k as f64 * 400.0, 70.0, 0.0), 10, ew::Seen::Emission, k as u16)).collect(), + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + [12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1], + ); + sim.tick = 120; + sim.job_next = 7; + // One drone in the air with a route and an aim, one still on the ground. + let mut flying = sim.make_drone(DroneKind::Kh22, DVec3::new(-900.0, 130.0, -300.0), DVec3::X, DVec3::new(-400.0, 70.0, 0.0)); + flying.smart = true; + flying.intent = ew::Intent::React; + flying.aim = ew::Aim::unit(ids[0], 100, ew::Confidence::Confirmed); + flying.target = DVec3::new(-400.0, 70.0, 0.0) + flying.err; + flying.route = vec![DVec3::new(-900.0, 70.0, -300.0), DVec3::new(-600.0, 70.0, -100.0), DVec3::new(-400.0, 70.0, 0.0)]; + flying.low = true; + flying.jammed = true; + flying.sector = 5; + flying.age = 77; + sim.drones.push(flying); + let mut queued = sim.make_drone(DroneKind::Geran3, DVec3::new(-1000.0, 90.0, 200.0), DVec3::X, DVec3::new(400.0, 70.0, 0.0)); + queued.smart = true; + queued.intent = ew::Intent::Sead; + queued.aim = ew::Aim::unit(ids[1], 110, ew::Confidence::Probable); + queued.route = vec![DVec3::new(-1000.0, 70.0, 200.0), DVec3::new(400.0, 70.0, 0.0)]; + let queued_id = queued.id; + sim.queue.push((300, queued)); + // Two pending jobs: one of the plan, one a replacement, both with their own version. + let job = |id, drone, aim, unit, version, intent| { + ew::RouteJob::restored(id, drone, aim, unit, version, intent, std::sync::Arc::new(sim.intel.contacts().values().copied().collect()), sim.known_relief.clone()) + }; + sim.retargets.push_back(job(5, sim.drones[0].id, sim.drones[0].target, ids[0], 100, ew::Intent::React)); + let queued_target = sim.queue[0].1.target; + sim.retargets.push_back(job(6, queued_id, queued_target, ids[1], 120, ew::Intent::Replace)); + sim + } + + #[test] + fn the_air_state_round_trips_every_job_field() { + let sim = raid_state(71); + let text = sim.air_text().expect("a raid state has something to keep"); + let save = Sim::parse_air(&text).expect("the written section must read back").expect("the section is there"); + let mut back = Sim::new(1); + back.restore_air(save); + assert_eq!(back.tick, sim.tick); + assert_eq!(back.job_next, sim.job_next); + assert_eq!(back.intel.sectors(), sim.intel.sectors()); + assert_eq!(back.intel.contacts().len(), sim.intel.contacts().len()); + for (id, c) in sim.intel.contacts() { + let b = back.intel.contacts().get(id).expect("a contact was lost"); + assert_eq!(b.kind, c.kind); + assert!((b.at.distance(c.at)).abs() < 0.02 && b.tick == c.tick && b.seen == c.seen && b.launches == c.launches); + } + assert_eq!(back.drones.len(), 1); + let (a, b) = (&sim.drones[0], &back.drones[0]); + assert_eq!((a.id, a.kind, a.smart, a.intent, a.sector, a.age, a.low, a.jammed), (b.id, b.kind, b.smart, b.intent, b.sector, b.age, b.low, b.jammed)); + assert_eq!((a.aim.unit, a.aim.since, a.aim.switches, a.aim.confidence), (b.aim.unit, b.aim.since, b.aim.switches, b.aim.confidence)); + assert!(a.pos.distance(b.pos) < 0.02 && a.target.distance(b.target) < 0.02 && a.vel.distance(b.vel) < 0.02); + assert_eq!(a.route.len(), b.route.len()); + assert!(a.route.iter().zip(&b.route).all(|(x, y)| x.distance(*y) < 0.02)); + assert_eq!(back.queue.len(), 1); + assert_eq!(back.queue[0].0, 300); + let (qa, qb) = (&sim.queue[0].1, &back.queue[0].1); + assert_eq!((qa.id, qa.intent, qa.aim.unit), (qb.id, qb.intent, qb.aim.unit)); + assert!(qa.route.iter().zip(&qb.route).all(|(x, y)| x.distance(*y) < 0.02)); + // The jobs keep their ids, aims, units, versions, priorities and intents; so do their pictures + // (the same version shares one) and the land they plan over. + assert_eq!(back.retargets.len(), 2); + for (j, b) in sim.retargets.iter().zip(&back.retargets) { + assert_eq!((j.id, j.drone, j.unit, j.version, j.priority(), j.intent), (b.id, b.drone, b.unit, b.version, b.priority(), b.intent)); + assert!(j.aim.distance(b.aim) < 0.02); + assert_eq!(j.relief.is_some(), b.relief.is_some()); + } + let (ra, rb) = (sim.known_relief.as_ref().unwrap(), back.known_relief.as_ref().unwrap()); + assert_eq!(ra.heights().len(), rb.heights().len()); + assert!(ra.heights().iter().zip(rb.heights()).all(|(x, y)| (x.is_nan() && y.is_nan()) || x == y)); + // Nothing is written for an empty sky. + assert!(Sim::new(3).air_text().is_none()); + } + + /// 40 smart drones, one adaptive tick, a bounded number of aims and jobs: the state a save is + /// taken in while routes are still being worked out. + fn pending_raid(seed: u64) -> Sim { + let mut sim = Sim::new(seed); + let mut contacts = Vec::new(); + for k in 0..8 { + let id = sim.id(); + contacts.push(contact(id, SamKind::S400, DVec3::new(-560.0 + k as f64 * 160.0, 70.0, -320.0), 0, ew::Seen::Emission, 0)); + } + sim.intel = ew::Intel::restore(contacts, [0; ew::SECTORS], [0; ew::SECTORS]); + for i in 0..40 { + let from = DVec3::new(-1800.0, 100.0, -800.0 + i as f64 * 40.0); + let mut d = sim.make_drone(DroneKind::Kh22, from, DVec3::X, DVec3::new(1800.0, 70.0, 1800.0)); + d.smart = true; + d.route = vec![from, DVec3::new(1800.0, 70.0, 1800.0)]; + sim.queue.push((0, d)); + } + sim.tick = 40; + sim.adapt(); + sim + } + + #[test] + fn a_pending_route_continues_after_a_save_and_a_load() { + let mut original = pending_raid(73); + assert!(!original.retargets.is_empty(), "the test no longer saves mid-queue"); + let text = original.air_text().expect("a queued raid must be durable"); + let mut restored = Sim::new(73); + restored.restore_air(Sim::parse_air(&text).unwrap().unwrap()); + assert_eq!(restored.retargets.len(), original.retargets.len(), "the pending jobs did not survive the save"); + assert_eq!(restored.retarget_route_state(), original.retarget_route_state(), "the restored jobs differ from the saved ones"); + // Both go on: the same adaptive ticks, the same jobs finished, the same routes. + for _ in 0..400 { + original.tick += 1; + restored.tick += 1; + original.adapt(); + restored.adapt(); + assert_eq!(restored.retarget_route_state(), original.retarget_route_state(), "the restored queue drifted"); + if original.retargets.is_empty() && restored.retargets.is_empty() && original.queue.iter().all(|(_, d)| d.aim.unit != 0) { + break; + } + } + assert!(original.retargets.is_empty() && restored.retargets.is_empty(), "the restored queue never finished"); + // Every drone of the restored raid has a real route, and every aim it was given is one it planned. + assert!(restored.queue.iter().all(|(_, d)| !d.route.is_empty() && d.aim.unit != 0)); + assert!(restored.queue.iter().all(|(_, d)| d.route.last().is_some_and(|p| p.distance(d.target) < 1e-6))); + } + + // #127: the section has a byte bound of its own. A raid with more (and longer-routed) drones than a + // save can hold keeps as many as fit, and every job it writes belongs to a drone that was written. + #[test] + fn the_section_stays_inside_its_byte_bound() { + let mut sim = Sim::new(101); + for i in 0..600 { + let from = DVec3::new(-2000.0 + i as f64, 120.0, 0.0); + let mut d = sim.make_drone(DroneKind::Kh22, from, DVec3::X, DVec3::new(2000.0, 70.0, 0.0)); + d.smart = true; + d.route = (0..200).map(|k| DVec3::new(-2000.0 + k as f64 * 20.0, 70.0, 0.0)).collect(); + sim.drones.push(d); + } + let text = sim.air_text().expect("a sky full of drones is durable"); + assert!(text.len() <= MAX_BYTES, "the section grew to {} bytes", text.len()); + let save = Sim::parse_air(&text).expect("a bounded section must read back").unwrap(); + assert!(save.drones.len() <= MAX_DRONES); + assert!(save.drones.len() > 100, "the bound left almost nothing"); + } + + #[test] + fn stale_ghost_and_duplicate_jobs_are_cancelled() { + let mut sim = pending_raid(79); + let kept = sim.retargets.len(); + let some = sim.retargets[0].clone(); + // A job for a drone that is not here. + sim.retargets.push_back(ew::RouteJob::restored(900, 999_999, DVec3::ZERO, 0, sim.tick, ew::Intent::Plan, std::sync::Arc::new(Vec::new()), None)); + // A job whose aim is no longer its drone's. + let mut stale = some.clone(); + stale.id = 901; + stale.aim += DVec3::X * 40.0; + sim.retargets.push_back(stale); + // An older duplicate for a drone that already has a newer job. + let mut older = some.clone(); + older.id = 902; + older.version = sim.tick.saturating_sub(1); + sim.retargets.push_back(older); + // A job older than the TTL. + let mut old = sim.retargets[0].clone(); + old.id = 903; + old.version = sim.tick.saturating_sub(ew::JOB_TTL + 1); + sim.retargets.push_back(old); + let cancelled = sim.prune_air(); + assert_eq!(cancelled, 4, "the stale, ghost, duplicate and old jobs were not all cancelled"); + assert_eq!(sim.retargets.len(), kept); + assert!(sim.retargets.iter().all(|j| j.id < 900)); + } + + #[test] + fn a_job_for_a_unit_the_picture_lost_is_cancelled_on_load() { + let sim = pending_raid(83); + let unit = sim.retargets[0].unit; + let text = sim.air_text().unwrap(); + assert!(sim.intel.contacts().contains_key(&unit)); + // Cut the job's unit out of the saved picture (and its count): the job cannot stand behind it. + let mut lines: Vec = text.lines().map(str::to_string).collect(); + let head = lines.iter().position(|l| l.starts_with("air ")).unwrap(); + let mut w: Vec = lines[head].split_whitespace().map(str::to_string).collect(); + w[7] = (w[7].parse::().unwrap() - 1).to_string(); + lines[head] = w.join(" "); + lines.retain(|l| !l.starts_with(&format!("aircontact {unit} "))); + let cut = lines.join("\n") + "\n"; + let mut restored = Sim::new(83); + let cancelled = restored.restore_air(Sim::parse_air(&cut).expect("the cut section is still well formed").unwrap()); + assert!(cancelled > 0, "a job for a unit the picture lost was kept"); + assert!(restored.retargets.iter().all(|j| j.unit != unit), "the ghost job survived the load"); + } + + #[test] + fn a_damaged_air_section_is_refused_with_its_line_and_field() { + let sim = raid_state(89); + let text = sim.air_text().unwrap(); + assert!(Sim::parse_air("sam s400 0 70 0 0\n").unwrap().is_none(), "an older save must read as an empty sky"); + let e = Sim::parse_air(&text.replace("air 1 ", "air 2 ")).err().expect("a future version was accepted"); + assert!(e.contains("версия"), "{e}"); + let e = Sim::parse_air(&format!("{text}airbogus 1\n")).err().expect("a foreign line in the section was accepted"); + assert!(e.contains("неизвестная строка"), "{e}"); + // A count that does not match the lines is a cut-off file, refused rather than half restored. + let cut: String = text.lines().filter(|l| !l.starts_with("airjob ")).map(|l| format!("{l}\n")).collect(); + let e = Sim::parse_air(&cut).err().expect("a cut-off section was accepted"); + assert!(e.contains("заданий"), "{e}"); + // A coordinate that is not a number, and a broken relief run, name their line. + let mut lines: Vec = text.lines().map(str::to_string).collect(); + let drone = lines.iter().position(|l| l.starts_with("airdrone ")).unwrap(); + let mut w: Vec = lines[drone].split_whitespace().map(str::to_string).collect(); + w[3] = "NaN".into(); + lines[drone] = w.join(" "); + let e = Sim::parse_air(&lines.join("\n")).err().expect("a non-finite coordinate was accepted"); + assert!(e.contains(&format!("строка {}", drone + 1)) && e.contains("дрон X"), "{e}"); + let e = Sim::parse_air(&text.replace("airrelief 0 ", "airrelief 0 x ")).err().expect("a broken relief line was accepted"); + assert!(e.contains("рельеф"), "{e}"); + } +} + +#[cfg(test)] +impl Sim { + /// The pending jobs and the routes of their drones, for the save/restore comparison. + pub(super) fn retarget_route_state(&self) -> Vec<(u32, u32, u32, u64, u8, i64, Vec<(i64, i64, i64)>)> { + self.retargets + .iter() + .map(|j| { + let route = self + .queue + .iter() + .map(|(_, d)| d) + .chain(self.drones.iter()) + .find(|d| d.id == j.drone) + .map(|d| d.route.iter().map(|p| ((p.x * 100.0).round() as i64, (p.y * 100.0).round() as i64, (p.z * 100.0).round() as i64)).collect()) + .unwrap_or_default(); + let q = |v: f64| (v * 100.0).round() as i64; + (j.id, j.drone, j.unit, j.version, j.priority(), q(j.aim.x), route) + }) + .collect() + } +} diff --git a/airdef-mod/src/sim/ew.rs b/airdef-mod/src/sim/ew.rs index 5824b83..3d37fa9 100644 --- a/airdef-mod/src/sim/ew.rs +++ b/airdef-mod/src/sim/ew.rs @@ -23,23 +23,112 @@ use super::*; use std::collections::{BTreeMap, VecDeque}; use std::sync::Arc; +/// Why a smart drone is aimed where it is (#127). The reason is fixed when the aim is set and +/// kept apart from the aim itself: the plan's own objective, a SEAD job the plan chose, a +/// reaction to a fresh disclosure and a replacement after the picture killed the aim are +/// different decisions, with different priorities and different room to switch. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Intent { + /// The raid's own objective: the marked object, or the unit the plan sent it at when it was ordered. + Plan, + /// Planned SEAD: a radar the plan picked when the raid was ordered. + Sead, + /// A reaction to a disclosure: a station showed itself (jamming, an emission fix, a launch) + /// after the raid was in the air, and a drone was turned onto it. + React, + /// A replacement: the picture watched the previous aim die or forgot it, and this contact + /// is the nearest live one. + Replace, +} + +impl Intent { + pub fn name(self) -> &'static str { + match self { + Self::Plan => "plan", + Self::Sead => "sead", + Self::React => "react", + Self::Replace => "replace", + } + } + /// The key a save writes and reads. + pub(super) fn key(self) -> &'static str { + self.name() + } + pub(super) fn from_key(key: &str) -> Option { + [Self::Plan, Self::Sead, Self::React, Self::Replace].into_iter().find(|i| i.key() == key) + } + /// The planner's share of a tick this aim is worth: a planned SEAD job and a reaction to a + /// disclosure are finished before a replacement, and the plan's own objective before both (#127). + pub(super) fn priority(self) -> u8 { + match self { + Self::Plan => 3, + Self::Sead => 2, + Self::React => 1, + Self::Replace => 0, + } + } +} + +/// What a smart drone aims at and how firmly (#127): the unit (0 for a marked object), when the +/// aim was chosen, how many times it has been changed since, and the confidence of the contact it +/// rests on. This is the bookkeeping the switch throttle reads; [`Intent`] is why it aims there. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Aim { + pub(super) unit: u32, + pub(super) since: u64, + pub(super) switches: u16, + pub(super) confidence: Option, +} + +impl Aim { + /// An aim at a marked object: no unit, and no confidence to speak of. + pub(super) fn point(since: u64) -> Self { + Self { unit: 0, since, switches: 0, confidence: None } + } + pub(super) fn unit(id: u32, since: u64, confidence: Confidence) -> Self { + Self { unit: id, since, switches: 0, confidence: Some(confidence) } + } + /// The same aim, chosen again: the switch count is kept, the clock and the belief are new. + pub(super) fn replaced(&self, unit: u32, since: u64, confidence: Confidence) -> Self { + Self { unit, since, switches: self.switches.saturating_add(1), confidence: Some(confidence) } + } +} + /// A route still to be worked out after a retarget (#127). The drone already has its new aim; the job /// gives it a route as the planner's budget allows, and its old route stays until then. #[derive(Clone)] pub(super) struct RouteJob { + /// The job's own id, for the save and the report: jobs may be replaced in place. + pub(super) id: u32, /// The drone, by id: queue indices move as launches happen. - drone: u32, + pub(super) drone: u32, /// The new aim, the drone's navigation error included. - aim: DVec3, + pub(super) aim: DVec3, /// The unit it was sent at (0 when the aim is not a unit). - unit: u32, + pub(super) unit: u32, /// The adaptive tick whose picture chose the aim: the knowledge version. - version: u64, + pub(super) version: u64, /// The enemy picture that tick knew, shared by every job of the tick. - known: Arc>, + pub(super) known: Arc>, /// The land that tick's plan knew, shared by every job of the version. A later plan replacing the /// global snapshot must not change a pending job's relief (#127). - relief: Option>, + pub(super) relief: Option>, + /// Why the aim was chosen, and the planner's share of a tick it is worth (#127). + pub(super) intent: Intent, +} + +impl RouteJob { + /// A job of this tick's picture; the id and the priority follow from the intent. + fn new(id: u32, drone: u32, aim: DVec3, unit: u32, version: u64, intent: Intent, known: Arc>, relief: Option>) -> Self { + Self { id, drone, aim, unit, version, known, relief, intent } + } + pub(super) fn priority(&self) -> u8 { + self.intent.priority() + } + /// A job read back from a save: its id, aim, unit, version, picture and relief are the file's. + pub(super) fn restored(id: u32, drone: u32, aim: DVec3, unit: u32, version: u64, intent: Intent, known: Arc>, relief: Option>) -> Self { + Self { id, drone, aim, unit, version, known, relief, intent } + } } /// The known land of a smart plan (#127): the grid it was mapped on and its cell heights (NaN unknown). @@ -54,6 +143,14 @@ impl KnownRelief { Self { grid, heights } } + /// The grid the land was mapped on, and its heights (NaN unknown): for the save. + pub(super) fn grid(&self) -> raid::Grid { + self.grid + } + pub(super) fn heights(&self) -> &[f32] { + &self.heights + } + /// This land sampled on `grid`: the height of the nearest cell, NaN where this plan knew nothing. /// None when no sampled cell was known at all. pub(super) fn sampled_on(&self, grid: &raid::Grid) -> Option> { @@ -90,7 +187,14 @@ impl RouteJob { known: Arc>, relief: Option>, ) -> Self { - Self { drone, aim, unit, version, known, relief } + Self { id: drone, drone, aim, unit, version, known, relief, intent: Intent::Plan } + } + pub(super) fn with_intent(mut self, intent: Intent) -> Self { + self.intent = intent; + self + } + pub(super) fn id(&self) -> u32 { + self.id } } @@ -112,6 +216,29 @@ const COOL: f64 = 1.0 / 900.0; const READY: f64 = 0.3; /// Approach sectors the enemy keeps score of. pub const SECTORS: usize = 12; +/// Ticks an aim is kept before a fresh disclosure may replace it: a drone is not turned on +/// every adaptive tick, only when something new has actually shown itself (#127). +pub(super) const SWITCH_HOLD: u64 = 160; +/// How many times one drone's aim may be changed after its plan chose it. Past this it keeps +/// the aim it has: a long raid must not churn its routes on every new fix (#127). +pub(super) const SWITCH_CAP: u16 = 3; +/// New aims one adaptive tick may hand out, and ones it may give to the same contact: a mass +/// retarget is spread over the following ticks instead of replacing every route at once (#127). +pub(super) const ASSIGN_PER_TICK: usize = 16; +pub(super) const ASSIGN_PER_CONTACT: usize = 6; +/// How much nearer (blocks) a candidate must be than the current aim to be worth a switch when +/// its confidence is no better. A switch throws the current route away and costs the planner a +/// new one, so a marginal one is not made (#127). +pub(super) const SWITCH_GAIN: f64 = 120.0; +/// Route jobs the queue may hold at once, in memory and in a save (#127). Past this the lowest +/// priority (then the oldest) go: a retarget is best-effort, never an unbounded log. +pub(super) const JOBS_MAX: usize = 256; +/// A restored job older than this (ticks) is stale: the picture has moved on and the job is +/// cancelled rather than planned for an aim nobody chose any more (#127). +pub(super) const JOB_TTL: u64 = 1200; +/// What a cell of land a job's knowledge version does not know costs on top of the open ground +/// (the base cell is 1): unknown land is not free, so a replanned route keeps to what was mapped (#127). +pub(super) const UNKNOWN_LAND: f32 = 6.0; /// How a contact's trust decays with age alone (#127). The bands are the only source of /// confidence: nothing is invented about a unit between observations. @@ -133,6 +260,9 @@ impl Confidence { Self::Confirmed => "confirmed", } } + pub(super) fn from_key(key: &str) -> Option { + [Self::Doubtful, Self::Probable, Self::Confirmed].into_iter().find(|c| c.name() == key) + } } /// What a disclosure channel showed the moment it disclosed a unit (#127). The channel @@ -156,6 +286,9 @@ impl Seen { Self::Jamming => "jamming", } } + pub(super) fn from_key(key: &str) -> Option { + [Self::Launch, Self::Emission, Self::Jamming].into_iter().find(|s| s.name() == key) + } } /// The enemy's last observation of one unit (#127): where it was, what it was seen doing, @@ -179,6 +312,9 @@ pub(super) struct Contact { /// When the enemy's own munition watched it die (battle damage assessment). Truth /// read at that moment only; a unit destroyed unobserved keeps living in the picture. pub destroyed: Option, + /// Launches seen of this unit, as observations. The channel counts what it watched go up; + /// it says nothing about the magazine that is left, which is never in the picture (#127). + pub launches: u16, } /// A contact this old (ticks) is forgotten entirely (five minutes of silence). @@ -247,13 +383,24 @@ pub(super) struct Intel { } impl Intel { + /// The picture as a save writes it, and back: the contacts (one per unit) and the sector scores. + pub(super) fn contacts(&self) -> &BTreeMap { + &self.contacts + } + pub(super) fn sectors(&self) -> ([u32; SECTORS], [u32; SECTORS]) { + (self.kills, self.hits) + } + pub(super) fn restore(contacts: Vec, kills: [u32; SECTORS], hits: [u32; SECTORS]) -> Self { + Self { kills, hits, contacts: contacts.into_iter().map(|c| (c.id, c)).collect() } + } + /// The one writer of contacts: what was seen of `s`, at its place, this moment. pub(super) fn observe(&mut self, s: &Sam, now: u64, seen: Seen) { self.note(s.id, s.kind, s.pos, s.alive(), now, seen); } pub(super) fn note(&mut self, id: u32, kind: SamKind, at: DVec3, alive: bool, now: u64, seen: Seen) { - let c = self.contacts.entry(id).or_insert(Contact { id, kind, at, tick: now, seen, emission: None, destroyed: None }); + let c = self.contacts.entry(id).or_insert(Contact { id, kind, at, tick: now, seen, emission: None, destroyed: None, launches: 0 }); // The newest observation wins; a fresh look at a live unit clears an old wreck call. c.kind = kind; c.at = at; @@ -262,6 +409,9 @@ impl Intel { if seen == Seen::Emission { c.emission = Some(now); } + if seen == Seen::Launch { + c.launches = c.launches.saturating_add(1); + } if alive { c.destroyed = None; } @@ -421,43 +571,81 @@ impl Sim { /// The adaptive tick: chooses the new aims and leaves a route job for each drone that needs one. The /// old route stays until a new one is ready: a drone is never left with no way round the defences. + /// + /// Every aim carries its [`Intent`] and the [`Aim`] bookkeeping that throttles changes: assignments + /// are spread over adaptive ticks and contacts, a fresh disclosure may replace an aim only after + /// `SWITCH_HOLD` and `SWITCH_CAP`, and only when it is worth a new route. A replacement of an aim + /// the picture killed is a distinct, lower-priority decision. fn adapt_targets(&mut self) { self.intel.expire(self.tick); let picture = Arc::new(self.picture_of_defences()); - let ew: Vec = picture.iter().copied().filter(|c| c.kind == SamKind::Ew).collect(); if picture.is_empty() { return; } // Every job of this adaptive tick plans over the same relief version, even if another smart // plan finishes before the job gets its budget (#127). let relief = self.known_relief.clone(); - let pick = |i: usize, list: &[Contact]| list[i % list.len()]; + let now = self.tick; + // Job ids are taken from the simulation's counter: a job keeps its id when it is replaced in + // place, and the save writes it back (#127). + let mut job_next = self.job_next; // The drones sent at a new target: a route job each, to be worked out as the planner's budget allows. let mut jobs: Vec = Vec::new(); let mut n = 0usize; + let mut assigned = 0usize; + let mut per_contact: HashMap = HashMap::new(); for (_, d) in self.queue.iter_mut().filter(|(_, d)| d.smart) { let hunter = matches!(d.kind, DroneKind::Lancet | DroneKind::Kh22 | DroneKind::Zircon); - if !hunter && !(d.kind == DroneKind::Shahed && n % 4 == 0) { - n += 1; + let eligible = hunter || (d.kind == DroneKind::Shahed && n % 4 == 0); + n += 1; + if !eligible { continue; } - // Only those not yet sent at a unit. - if picture.iter().any(|c| c.at.distance(d.target - d.err) < 6.0) { + // What it is aiming at in the picture now, if anything (a unit merely moved is still + // aimed where it was seen: a contact is matched by its observed place). + if let Some(current) = Self::aimed_contact(&picture, d.target - d.err) { + // Already aiming at a unit: a fresh disclosure may be worth a switch, under the throttle. + let Some(c) = Self::switch_candidate(&picture, current, d, now) else { continue }; + if per_contact.get(&c.id).copied().unwrap_or(0) >= ASSIGN_PER_CONTACT { + continue; + } + *per_contact.entry(c.id).or_insert(0) += 1; + assigned += 1; + d.target = c.at + d.err; + d.intent = Intent::React; + d.aim = d.aim.replaced(c.id, now, c.confidence(now)); + if Self::route_plannable(d) { + jobs.push(RouteJob::new(job_next, d.id, d.target, d.aim.unit, now, Intent::React, Arc::clone(&picture), relief.clone())); + job_next += 1; + } else { + d.route.clear(); + } continue; } - let c = if !ew.is_empty() { pick(n, &ew) } else { pick(n, &picture) }; + // Not sent at a unit yet: it may be given one, a bounded number a tick and per + // contact, so a mass retarget does not replace every route in one tick (#127). + if assigned >= ASSIGN_PER_TICK { + continue; + } + let Some(c) = Self::fresh_target(&picture, &per_contact, d.kind, n) else { continue }; + *per_contact.entry(c.id).or_insert(0) += 1; + assigned += 1; d.target = c.at + d.err; + d.intent = Intent::React; + // A first assignment is not a switch: the aim's clock starts here, the count is kept. + d.aim = Aim { unit: c.id, since: now, switches: d.aim.switches, confidence: Some(c.confidence(now)) }; if Self::route_plannable(d) { - jobs.push(RouteJob { drone: d.id, aim: d.target, unit: c.id, version: self.tick, known: Arc::clone(&picture), relief: relief.clone() }); + jobs.push(RouteJob::new(job_next, d.id, d.target, d.aim.unit, now, Intent::React, Arc::clone(&picture), relief.clone())); + job_next += 1; } else { // Nothing will be replanned for it: its old route leads to the old aim, so it goes straight. d.route.clear(); } - n += 1; } // In flight: the aim is kept only while the picture says it is live. A unit watched dying, - // or forgotten after its contact aged out mid-flight, leaves the drone its course; the next - // observation of anything is a new decision. A unit merely moved is still aimed where seen. + // or forgotten after its contact aged out mid-flight, is replaced by the nearest live contact + // it can still turn onto — or, past the switch cap, the drone keeps its course. A unit merely + // moved is still aimed where seen. for d in self.drones.iter_mut().filter(|d| d.alive() && d.smart) { let aim = d.target - d.err; let aim_gone = self @@ -465,27 +653,92 @@ impl Sim { .contacts .values() .any(|c| c.at.distance(aim) < 6.0 && !c.live(self.tick)); - if aim_gone { - let plannable = Self::route_plannable(d); - let next = picture - .iter() - .filter(|c| !plannable || Self::can_turn_to(d, c.at)) - .min_by(|a, b| a.at.distance(d.pos).total_cmp(&b.at.distance(d.pos))); - if let Some(c) = next { - d.target = c.at + d.err; - if plannable { - jobs.push(RouteJob { drone: d.id, aim: d.target, unit: c.id, version: self.tick, known: Arc::clone(&picture), relief: relief.clone() }); - } else { - d.route.clear(); - } + if !aim_gone { + continue; + } + let plannable = Self::route_plannable(d); + // A replacement is not a disclosure reaction: the aim is dead, so the hold does not apply, + // but the cap and the tick's assignment budget do — a drone whose aim keeps dying stops + // chasing the picture, and a tick that killed many aims does not replace them all at once. + if d.aim.switches >= SWITCH_CAP || assigned >= ASSIGN_PER_TICK { + continue; + } + let next = picture + .iter() + .filter(|c| !plannable || Self::can_turn_to(d, c.at)) + .filter(|c| per_contact.get(&c.id).copied().unwrap_or(0) < ASSIGN_PER_CONTACT) + .min_by(|a, b| a.at.distance(d.pos).total_cmp(&b.at.distance(d.pos))); + if let Some(c) = next { + *per_contact.entry(c.id).or_insert(0) += 1; + assigned += 1; + d.target = c.at + d.err; + d.intent = Intent::Replace; + d.aim = d.aim.replaced(c.id, now, c.confidence(now)); + if plannable { + jobs.push(RouteJob::new(job_next, d.id, d.target, d.aim.unit, now, Intent::Replace, Arc::clone(&picture), relief.clone())); + job_next += 1; + } else { + d.route.clear(); } } } + self.job_next = job_next; for job in jobs { self.queue_route(job); } } + /// The contact the drone is aimed at now: the live one whose observed place is its aim. + fn aimed_contact(picture: &[Contact], aim: DVec3) -> Option { + picture.iter().copied().find(|c| c.at.distance(aim) < 6.0) + } + + /// The contact a drone not yet sent at a unit should be given: a station that gave itself away + /// (a jammer, a radar that emitted, a system that fired) before anything else, then the rest of + /// the picture, round-robin so a mass retarget spreads over what is known (#127). + fn fresh_target(picture: &[Contact], per_contact: &HashMap, kind: DroneKind, n: usize) -> Option { + let free = |c: &&Contact| per_contact.get(&c.id).copied().unwrap_or(0) < ASSIGN_PER_CONTACT; + // A station transmitting is the loudest disclosure: the raid turns on it first, as it always did. + let jammers: Vec<&Contact> = picture.iter().filter(|c| c.kind == SamKind::Ew).filter(free).collect(); + if let Some(c) = jammers.get(n % jammers.len().max(1)) { + return Some(**c); + } + // The hunters also take anything else that showed itself; the rest fall back to the picture. + let disclosed = |c: &&Contact| matches!(c.seen, Seen::Jamming | Seen::Emission | Seen::Launch); + let hunters = matches!(kind, DroneKind::Lancet | DroneKind::Kh22 | DroneKind::Zircon); + if hunters { + let shown: Vec<&Contact> = picture.iter().filter(disclosed).filter(free).collect(); + if let Some(c) = shown.get(n % shown.len().max(1)) { + return Some(**c); + } + } + let all: Vec<&Contact> = picture.iter().filter(free).collect(); + all.get(n % all.len().max(1)).map(|c| **c) + } + + /// Whether a fresh disclosure is worth replacing this drone's aim: a contact observed after the + /// aim was chosen, trusted (not `Doubtful`), clearly better placed or better believed, and the + /// throttle (hold time, switch cap) allows it (#127). A route change costs the planner a new + /// route, so a marginal candidate is not taken. + fn switch_candidate(picture: &[Contact], current: Contact, d: &Drone, now: u64) -> Option { + if d.aim.switches >= SWITCH_CAP || now.saturating_sub(d.aim.since) < SWITCH_HOLD { + return None; + } + let held = d.aim.confidence; + picture + .iter() + .filter(|c| c.id != current.id) + // Only something that has shown itself since this aim was chosen is a reason to switch. + .filter(|c| c.tick > d.aim.since) + .filter(|c| c.confidence(now) >= Confidence::Probable) + .filter(|c| match held { + Some(h) => c.confidence(now) > h || c.at.distance(d.pos) + SWITCH_GAIN < current.at.distance(d.pos), + None => true, + }) + .min_by(|a, b| a.at.distance(d.pos).total_cmp(&b.at.distance(d.pos))) + .copied() + } + /// A kind whose route the replan works out: a Lancet's strike on a unit and the ballistic missiles go /// straight on purpose, as in the raid's own plan. fn route_plannable(d: &Drone) -> bool { @@ -507,15 +760,60 @@ impl Sim { } /// Adds a job, replacing an older one for the same drone in place: a drone retargeted again before its - /// route was ready must not grow the queue or be planned for an aim it no longer has (#127). + /// route was ready must not grow the queue or be planned for an aim it no longer has (#127). The queue + /// is bounded: past `JOBS_MAX` the lowest priority (then the oldest) job makes room, and a job that is + /// not worth more than what is queued is refused outright — the drone keeps its old route. fn queue_route(&mut self, job: RouteJob) { if let Some(i) = self.retargets.iter().position(|j| j.drone == job.drone) { if self.retargets[i].version <= job.version { self.retargets[i] = job; } - } else { - self.retargets.push_back(job); + return; } + if self.retargets.len() >= JOBS_MAX { + let worst = self + .retargets + .iter() + .enumerate() + .min_by_key(|(i, j)| (j.priority(), *i)) + .map(|(i, _)| i); + match worst { + Some(i) if self.retargets[i].priority() < job.priority() => { + self.retargets.remove(i); + } + _ => return, + } + } + self.retargets.push_back(job); + } + + /// Drops the jobs a restored save can no longer stand behind (#127): a drone gone or dead, an aim + /// that has since changed, a unit the picture watched die or forgot, a duplicate for the same drone, + /// or a version older than `JOB_TTL` — the picture has moved on. Returns how many were cancelled. + pub(super) fn prune_air(&mut self) -> u32 { + let now = self.tick; + let mut seen: HashMap = HashMap::new(); + let mut cancelled = 0u32; + let mut kept: VecDeque = VecDeque::with_capacity(self.retargets.len()); + while let Some(job) = self.retargets.pop_front() { + let fresh = now.saturating_sub(job.version) <= JOB_TTL; + let newer = seen.get(&job.drone).is_none_or(|v| job.version >= *v); + let live = self.job_live(&job); + if fresh && newer && live { + seen.insert(job.drone, job.version); + kept.push_back(job); + } else { + cancelled += 1; + // A job dropped for staleness or as a duplicate leaves a route that still leads to the + // drone's own aim, so it is kept; one whose aim has changed leaves a route to nowhere, + // and that goes. + if let Some(d) = self.drone_of_mut(job.drone) && d.alive() && d.target != job.aim { + d.route.clear(); + } + } + } + self.retargets = kept; + cancelled } /// The drone a job is for: waiting in the queue or already flying. @@ -549,26 +847,31 @@ impl Sim { } /// Works the retarget queue: one grid and one set of prices cover the jobs, and the work is the - /// planner's share for a tick. A job the budget cannot reach stays queued for the next tick, and its - /// drone keeps the old route (or, if it had none, flies straight for a while — never for ever). + /// planner's share for a tick. The queue is a bounded priority queue — the plan's own objectives first, + /// then SEAD, then reactions to disclosures, then replacements; FIFO within a priority. A job the budget + /// cannot reach stays queued for the next tick, and its drone keeps the old route (or, if it had none, + /// flies straight for a while — never for ever). fn replan_retargets(&mut self) { if self.retargets.is_empty() { return; } - let mut jobs = std::mem::take(&mut self.retargets); - let mut live = VecDeque::with_capacity(jobs.len()); - while let Some(job) = jobs.pop_front() { - if self.job_live(&job) { - live.push_back(job); - } else if let Some(d) = self.drone_of_mut(job.drone) && d.alive() { + let mut jobs: Vec = std::mem::take(&mut self.retargets).into_iter().filter(|job| { + if self.job_live(job) { + return true; + } + if let Some(d) = self.drone_of_mut(job.drone) && d.alive() { // No new route will come from this job: the old route is a fallback, not a plan. d.route.clear(); } - } - jobs = live; + false + }).collect(); if jobs.is_empty() { return; } + // Stable: jobs of one priority keep the order they were queued in. + jobs.sort_by_key(|j| std::cmp::Reverse(j.priority())); + self.retargets = jobs.into_iter().collect(); + let jobs: Vec = std::mem::take(&mut self.retargets).into(); // One grid over all their starts and goals, with room round them to go by. let (mut lo, mut hi) = (DVec3::splat(f64::INFINITY), DVec3::splat(f64::NEG_INFINITY)); for job in &jobs { @@ -589,16 +892,20 @@ impl Sim { let mut prices: HashMap<(DroneKind, KnowledgeVersion), Vec> = HashMap::new(); let mut routes: HashMap<(DroneKind, KnowledgeVersion, (i32, i32), (i32, i32)), Vec> = HashMap::new(); let mut work = 0usize; - while let Some(job) = jobs.front().cloned() { + let mut kept: Vec = Vec::with_capacity(jobs.len()); + let mut i = 0usize; + while i < jobs.len() { if work >= raid::WORK_PER_TICK { + kept.extend(jobs[i..].iter().cloned()); break; } + let job = jobs[i].clone(); if !self.job_live(&job) { - jobs.pop_front(); + i += 1; continue; } let Some(d) = self.drone_of(job.drone) else { - jobs.pop_front(); + i += 1; continue; }; let (pos, kind) = (d.pos, d.kind); @@ -618,12 +925,23 @@ impl Sim { work += cost; prices.insert((kind, version), p); } - let relief = land.map(|heights| raid::Relief { + // Land this job's version did not know is not free: every unknown cell costs more + // than the open ground, so a route keeps to what the plan mapped out (#127). + let unknown_land; + let heights = match land { + Some(h) => h, + None => { + unknown_land = vec![f32::NAN; (grid.size * grid.size) as usize]; + &unknown_land + } + }; + let relief = raid::Relief { heights, slope: (kind.climb_pitch().tan() * 0.5) as f32, floor: (job.aim.y + kind.spec().clearance) as f32, - }); - let (r, cost) = raid::plan_route_on(&grid, &prices[&(kind, version)], relief.as_ref(), pos, job.aim); + unknown: UNKNOWN_LAND, + }; + let (r, cost) = raid::plan_route_on(&grid, &prices[&(kind, version)], Some(&relief), pos, job.aim); work += cost; routes.insert(key, r.clone()); r @@ -632,9 +950,14 @@ impl Sim { if let Some(d) = self.drone_of_mut(job.drone) { d.route = route; } - jobs.pop_front(); + i += 1; } - self.retargets = jobs; + self.retargets = kept.into_iter().collect(); + } + + /// The route jobs still waiting (#127; diagnostics). + pub fn retargets_len(&self) -> usize { + self.retargets.len() } /// The drones with a route job still waiting (tests and diagnostics). @@ -668,12 +991,53 @@ pub struct ContactReport { pub live: bool, /// When its destruction was observed, if it was. pub observed_destroyed: Option, + /// Launches of this unit the picture watched. The magazine behind them is never observed. + pub launches: u16, /// The belief the threat model prices by: that its own radar works. pub own_sensor: bool, /// Whether a live radar contact stands within data-link range of its observed place. pub donor_in_link: bool, /// What the planner makes of it, in words. pub threat: &'static str, + /// Drones aimed at this contact now (whatever the reason). + pub aimed_by: u32, + /// Route jobs still being worked out for this contact. + pub pending_jobs: u32, +} + +/// Why one drone is aimed where it is (#127): the reason, the unit, and the throttle's state. +#[derive(Clone, Debug, PartialEq)] +pub struct AimReport { + pub drone: u32, + /// The unit aimed at (0: a marked object). + pub unit: u32, + /// "plan", "sead", "react" or "replace". + pub intent: &'static str, + /// The tick the aim was chosen at. + pub since: u64, + /// How many times the aim has been changed since the plan chose it. + pub switches: u16, + /// The confidence of the contact the aim rests on, when it is a unit. + pub confidence: Option, +} + +/// A route job the planner has not finished (#127): what it is for, why it is still waiting, +/// and the knowledge version it will be planned over. +#[derive(Clone, Debug, PartialEq)] +pub struct PendingReport { + pub job: u32, + pub drone: u32, + pub unit: u32, + pub at: DVec3, + /// The adaptive tick whose picture chose the aim. + pub version: u64, + /// Higher goes first (see `Intent`). + pub priority: u8, + pub intent: &'static str, + /// Whether the land of this job's version is known at all. + pub relief_known: bool, + /// Why it is still queued, in words. + pub reason: &'static str, } /// The enemy's whole picture at one tick: what it knows and what it assumes, structured for @@ -682,13 +1046,29 @@ pub struct ContactReport { pub struct EnemyPictureReport { pub now: u64, pub contacts: Vec, + /// Every smart drone's aim and why, for the plan a player's raid faces. + pub aims: Vec, + /// The route jobs still waiting. + pub pending: Vec, + /// The planner's work a tick, for the pending queue's budget. + pub work_per_tick: usize, } impl Sim { - /// The structured picture: observation, confidence and priced threat per contact. + /// The structured picture: observation, confidence and priced threat per contact, the aims + /// every smart drone carries and why, and the pending route jobs with their reason (#127). pub fn enemy_picture(&self) -> EnemyPictureReport { let now = self.tick; let live: Vec = self.picture_of_defences(); + let aimed_at = |unit: u32| -> u32 { + self.queue + .iter() + .map(|(_, d)| d) + .chain(self.drones.iter()) + .filter(|d| d.alive() && d.smart && d.aim.unit == unit && unit != 0) + .count() as u32 + }; + let pending_at = |unit: u32| -> u32 { self.retargets.iter().filter(|j| j.unit == unit && unit != 0).count() as u32 }; let contacts = self .intel .contacts @@ -704,13 +1084,52 @@ impl Sim { confidence: c.confidence(now), live: c.live(now), observed_destroyed: c.destroyed, + launches: c.launches, own_sensor: c.believes_own_sensor(now), donor_in_link: donor, threat: Self::threat_text(c, now, donor), + aimed_by: aimed_at(c.id), + pending_jobs: pending_at(c.id), } }) .collect(); - EnemyPictureReport { now, contacts } + let aims = self + .queue + .iter() + .map(|(_, d)| d) + .chain(self.drones.iter()) + .filter(|d| d.alive() && d.smart) + .map(|d| AimReport { + drone: d.id, + unit: d.aim.unit, + intent: d.intent.name(), + since: d.aim.since, + switches: d.aim.switches, + confidence: d.aim.confidence, + }) + .collect(); + let pending = self + .retargets + .iter() + .map(|j| PendingReport { + job: j.id, + drone: j.drone, + unit: j.unit, + at: j.aim, + version: j.version, + priority: j.priority(), + intent: j.intent.name(), + relief_known: j.relief.is_some(), + reason: if now.saturating_sub(j.version) > JOB_TTL { + "stale: the picture that chose the aim has moved on" + } else if j.relief.is_none() { + "waiting for the planner's share; the land is unknown and costs accordingly" + } else { + "waiting for the planner's share of this tick" + }, + }) + .collect(); + EnemyPictureReport { now, contacts, aims, pending, work_per_tick: raid::WORK_PER_TICK } } /// What the planner makes of a contact, in words for the diagnostic (#127). @@ -784,6 +1203,10 @@ mod picture_tests { changed.sams[0].missiles = 0; changed.sams[0].health = 0.3; changed.sams[1].health = 0.0; + // ...and an EW station's heat, transmitter lock and switch are its own energy, not a disclosure. + changed.sams[2].heat = 1.0; + changed.sams[2].ew_lock = true; + changed.sams[2].ew_on = false; let after = snapshot(&changed); assert_eq!(before, after, "a hidden change moved the enemy's picture"); // The adaptive tick decides the same aims on both. @@ -798,6 +1221,40 @@ mod picture_tests { let aims = |sim: &Sim| sim.queue.iter().map(|(_, d)| d.target).collect::>(); assert_eq!(aims(&base), aims(&changed), "the same picture chose different aims"); assert_eq!(base.retarget_ids(), changed.retarget_ids()); + // The structured intent is part of the decision: the same picture chooses the same reason, + // the same unit and the same throttle state (#127). + let intent = |sim: &Sim| sim.queue.iter().map(|(_, d)| (d.intent, d.aim.unit, d.aim.since, d.aim.switches, d.aim.confidence)).collect::>(); + assert_eq!(intent(&base), intent(&changed), "a hidden change moved an intent"); + let jobs = |sim: &Sim| sim.retargets.iter().map(|j| (j.id, j.drone, j.unit, j.version, j.priority(), j.intent)).collect::>(); + assert_eq!(jobs(&base), jobs(&changed), "a hidden change moved a route job"); + } + + // #127: the report explains each contact's part in the plan — how many drones aim at it, how many + // routes are still being worked out for it, and why a pending job waits — in plain English, since + // the core carries no player-facing text. + #[test] + fn the_report_explains_aims_and_pending_jobs_in_english() { + let mut sim = Sim::new(97); + watched_force(&mut sim); + for i in 0..6 { + let mut d = sim.make_drone(DroneKind::Kh22, DVec3::new(-900.0, 120.0, -400.0 + i as f64 * 80.0), DVec3::X, DVec3::new(900.0, 70.0, 0.0)); + d.smart = true; + sim.queue.push((0, d)); + } + sim.tick = 40; + sim.adapt(); + let report = sim.enemy_picture(); + assert!(!report.aims.is_empty(), "the report has no aims"); + assert!(report.aims.iter().all(|a| matches!(a.intent, "react" | "plan" | "sead" | "replace"))); + let aimed = report.contacts.iter().find(|c| c.aimed_by > 0).expect("no contact is aimed at"); + assert!(aimed.pending_jobs > 0, "the aim of a pending route is not reported"); + assert_eq!(report.pending.len(), report.contacts.iter().map(|c| c.pending_jobs).sum::() as usize); + let p = &report.pending[0]; + assert!(!p.reason.is_empty() && p.priority <= 3); + assert_eq!(report.work_per_tick, super::super::raid::WORK_PER_TICK); + // No player-facing Russian in the core's diagnostics: every reported word is ASCII. + assert!(aimed.threat.is_ascii() && p.reason.is_ascii() && p.intent.is_ascii()); + assert!(report.contacts.iter().all(|c| c.seen.is_ascii() && c.threat.is_ascii())); } // #127: a new disclosure is what updates the picture. The moved patriot is re-fixed at its @@ -1007,4 +1464,271 @@ mod outcome_tests { assert!(other.intel.contacts.get(&id2).unwrap().destroyed.is_none(), "an unwatched fate changed the picture"); assert!(other.known_live(id2)); } -} \ No newline at end of file +} +#[cfg(test)] +mod intent_tests { + use super::*; + use super::super::raid::Grid; + use std::collections::HashMap; + + /// A unit the picture knows of, placed and observed once. + fn watch(sim: &mut Sim, kind: SamKind, at: DVec3, tick: u64, seen: Seen) -> u32 { + let id = sim.id(); + let sam = Sam::new(id, kind, at, 0.0); + sim.intel.observe(&sam, tick, seen); + sim.sams.push(sam); + id + } + + fn smart_drone(sim: &mut Sim, kind: DroneKind, from: DVec3, aim: DVec3) -> u32 { + let mut d = sim.make_drone(kind, from, DVec3::X, aim); + d.smart = true; + let id = d.id; + sim.queue.push((0, d)); + id + } + + fn goal_unit(sim: &Sim, id: u32) -> u32 { + sim.drones.iter().chain(sim.queue.iter().map(|(_, d)| d)).find(|d| d.id == id).unwrap().aim.unit + } + + // #127: the plan records *why* a drone is aimed where it is. A radar strike is planned SEAD; + // everything else the plan chose is its own objective, never a random re-pick. + #[test] + fn the_plan_records_sead_separately_from_its_objective() { + let mut sim = Sim::new(41); + let radar = watch(&mut sim, SamKind::Radar, DVec3::new(0.0, 70.0, 0.0), 0, Seen::Emission); + let order = raid::RaidOrder { + kinds: vec![(DroneKind::Kh22, 8), (DroneKind::Shahed, 4)], + approach: raid::Approach::Sector, + dist: 1200.0, + aim: raid::Aim::Smart, + }; + let objects = [DVec3::new(0.0, 70.0, 300.0)]; + let mut out = Out::default(); + sim.order_raid(&order, &objects, &mut out); + let intents = sim.planned_intents(); + assert_eq!(intents.len(), 12, "the plan did not hold every drone"); + let sead = intents.iter().filter(|i| i.1 == Intent::Sead).count(); + assert!(sead > 0, "no SEAD job was recorded for the observed radar"); + assert!(intents.iter().filter(|i| i.1 == Intent::Plan).count() > 0, "the plan's own objective was not recorded"); + assert!(intents.iter().all(|i| matches!(i.1, Intent::Sead | Intent::Plan))); + // Every SEAD job goes for the observed radar; the planner's own picks may also aim at it, + // but they are recorded as its objective, not as SEAD. + assert!(sim.planned_aims().iter().filter(|(_, i)| *i == Intent::Sead).all(|(unit, _)| *unit == radar)); + } + + // #127: a mass retarget is spread over adaptive ticks and contacts: one tick assigns a bounded + // number of new aims, and a single contact does not draw the whole force. + #[test] + fn assignments_are_throttled_per_tick_and_per_contact() { + let mut sim = Sim::new(43); + for k in 0..6 { + watch(&mut sim, SamKind::S400, DVec3::new(-600.0 + k as f64 * 240.0, 70.0, -200.0), 0, Seen::Emission); + } + for i in 0..40 { + let from = DVec3::new(-1800.0, 120.0, -900.0 + i as f64 * 40.0); + smart_drone(&mut sim, DroneKind::Kh22, from, DVec3::new(1800.0, 70.0, 1800.0)); + } + sim.tick = 40; + sim.adapt(); + let assigned: Vec<(u32, Intent, u64, u16)> = sim + .queue + .iter() + .map(|(_, d)| d) + .filter(|d| d.aim.unit != 0) + .map(|d| (d.aim.unit, d.intent, d.aim.since, d.aim.switches)) + .collect(); + assert_eq!(assigned.len(), ASSIGN_PER_TICK, "the first adaptive tick did not assign its bounded share"); + let mut per: HashMap = HashMap::new(); + for (unit, intent, since, switches) in &assigned { + *per.entry(*unit).or_insert(0) += 1; + assert_eq!(*intent, Intent::React, "a reaction to a disclosure was recorded as something else"); + assert!(*switches == 0 && *since == 40); + } + assert!(per.values().all(|n| *n <= ASSIGN_PER_CONTACT), "one contact drew more than {ASSIGN_PER_CONTACT} drones: {per:?}"); + assert!(sim.retarget_ids().len() <= ASSIGN_PER_TICK); + + sim.tick = 80; + sim.adapt(); + let now = sim.queue.iter().filter(|(_, d)| d.aim.unit != 0).count(); + assert!(now > assigned.len(), "the next adaptive tick assigned nobody"); + assert_eq!(now, 32, "the second adaptive tick did not assign its bounded share"); + // No drone's aim repeats a unit twice in the queue: an assignment is per drone. + let mut drones: Vec = sim.queue.iter().map(|(_, d)| d.id).collect(); + drones.sort_unstable(); + drones.dedup(); + assert_eq!(drones.len(), 40); + } + + // #127: a fresh disclosure replaces an aim only after the hold and within the cap, and only when + // it is clearly better placed — a switch throws the route away and costs a new one. + #[test] + fn a_fresh_disclosure_switches_an_aim_only_after_the_hold() { + let mut sim = Sim::new(47); + let far = watch(&mut sim, SamKind::Patriot, DVec3::new(400.0, 70.0, 400.0), 0, Seen::Emission); + let near = watch(&mut sim, SamKind::Ew, DVec3::new(-520.0, 70.0, -520.0), 0, Seen::Jamming); + let mut d = sim.make_drone(DroneKind::Kh22, DVec3::new(-600.0, 120.0, -600.0), DVec3::X, DVec3::new(400.0, 70.0, 400.0)); + d.smart = true; + let at = sim.intel.contacts()[&far].at; + d.target = at + d.err; + d.intent = Intent::Plan; + d.aim = Aim::unit(far, 0, Confidence::Confirmed); + let id = d.id; + sim.queue.push((0, d)); + // The jammer shows itself again later: a fresh disclosure. + sim.intel.note(near, SamKind::Ew, sim.intel.contacts()[&near].at, true, 100, Seen::Jamming); + + sim.tick = 120; + sim.adapt_targets(); + assert_eq!(goal_unit(&sim, id), far, "the aim was switched before the hold elapsed"); + sim.tick = 160; + sim.adapt_targets(); + assert_eq!(goal_unit(&sim, id), near, "a clearly better placed fresh disclosure did not switch the aim"); + let d = sim.queue.iter().map(|(_, d)| d).find(|x| x.id == id).unwrap(); + assert_eq!((d.intent, d.aim.switches), (Intent::React, 1), "the switch was not recorded as a reaction"); + + // The cap holds: a drone that has switched its fill keeps the aim it has. + let d = sim.queue.iter_mut().map(|(_, d)| d).find(|x| x.id == id).unwrap(); + d.aim.switches = SWITCH_CAP; + let before = d.target; + sim.intel.note(far, SamKind::S400, DVec3::new(-640.0, 70.0, -640.0), true, 300, Seen::Launch); + sim.tick = 400; + sim.adapt_targets(); + assert_eq!(sim.queue.iter().map(|(_, d)| d).find(|x| x.id == id).unwrap().target, before, "a drone past the switch cap was turned again"); + } + + // #127: an aim the picture watched die is replaced by the nearest live contact — a distinct, + // lower-priority decision than a disclosure reaction. + #[test] + fn a_watched_kill_replaces_the_aim_and_queues_a_lower_priority_route() { + let mut sim = Sim::new(53); + let dead = watch(&mut sim, SamKind::S400, DVec3::new(0.0, 70.0, 0.0), 0, Seen::Emission); + let alive = watch(&mut sim, SamKind::S400, DVec3::new(300.0, 70.0, 300.0), 0, Seen::Emission); + let mut d = sim.make_drone(DroneKind::Kh22, DVec3::new(-600.0, 120.0, -600.0), DVec3::X, DVec3::new(0.0, 70.0, 0.0)); + d.smart = true; + d.target = sim.intel.contacts()[&dead].at + d.err; + d.aim = Aim::unit(dead, 0, Confidence::Confirmed); + let id = d.id; + sim.drones.push(d); + // The enemy's own munition watched it die: the picture stops believing in it. + sim.intel.outcome(dead, false, DVec3::new(0.0, 70.0, 0.0), 40); + sim.tick = 40; + sim.adapt_targets(); + let d = sim.drones.iter().find(|x| x.id == id).unwrap(); + assert_eq!(d.aim.unit, alive, "the dead aim was not replaced by the nearest live contact"); + assert_eq!(d.intent, Intent::Replace); + assert_eq!(d.aim.switches, 1); + let pending = sim.pending_intents(); + assert!(pending.iter().any(|(_, i)| *i == Intent::Replace), "the replacement has no route job"); + } + + // #127: a unit that fires is observed to fire, and the picture counts the launches it watched. The + // magazine behind them is never in the picture: a hidden empty one changes nothing. + #[test] + fn launched_rounds_are_observed_but_the_magazine_is_not() { + let mut sim = Sim::new(59); + let id = watch(&mut sim, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 10, Seen::Launch); + sim.intel.note(id, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), true, 20, Seen::Launch); + sim.tick = 20; + assert_eq!(sim.intel.contacts()[&id].launches, 2, "observed launches are not counted"); + let report = sim.enemy_picture(); + assert_eq!(report.contacts.iter().find(|c| c.id == id).unwrap().launches, 2); + let grid = Grid { min: DVec3::new(-480.0, 0.0, -480.0), cell: 16.0, size: 60 }; + let before = sim.price_grid(&grid, DroneKind::Kh101); + // The magazine is empty and the launcher split from its radar: hidden changes. + let sam = sim.sams.iter_mut().find(|s| s.id == id).unwrap(); + sam.missiles = 0; + sam.bursts = 0; + sam.set_mode(super::super::battery::SamMode::LauncherOnly); + sim.sams.iter_mut().find(|s| s.id == id).unwrap().radar_on = false; + assert_eq!(before, sim.price_grid(&grid, DroneKind::Kh101), "a hidden magazine changed the prices"); + assert_eq!(sim.enemy_picture().contacts.iter().find(|c| c.id == id).unwrap().launches, 2); + } + + // #127: land the job's knowledge version did not map is not free. Given the same grid, the route + // with unknown cells priced keeps to the mapped land where the free-unknown one cuts across. + #[test] + fn unknown_land_is_not_free_in_a_replanned_route() { + let size = 80; + let grid = Grid { min: DVec3::new(-640.0, 0.0, -640.0), cell: 16.0, size }; + // The plan mapped the left half; under it the land is high, so the mapped way costs altitude. + let mut heights = vec![f32::NAN; (size * size) as usize]; + for i in 0..size / 2 { + for j in 0..size { + heights[(i * size + j) as usize] = 400.0; + } + } + let prices = vec![1.0f32; (size * size) as usize]; + let from = DVec3::new(-520.0, 120.0, -520.0); + let to = DVec3::new(-40.0, 120.0, 520.0); + let plan = |unknown: f32| { + let relief = raid::Relief { heights: &heights, slope: 1.0, floor: 70.0, unknown }; + let (route, _) = raid::plan_route_on(&grid, &prices, Some(&relief), from, to); + route + }; + let unknown_cells = |route: &[DVec3]| { + route.iter().filter(|p| { let (i, j) = grid.index(**p); heights[(i * size + j) as usize].is_nan() }).count() + }; + let free = plan(0.0); + let priced = plan(UNKNOWN_LAND); + assert!(unknown_cells(&free) > 0, "the test's free-unknown route never left the mapped land"); + assert!(unknown_cells(&priced) < unknown_cells(&free), "priced unknown land did not keep the route to what was mapped"); + assert_eq!(priced.last().unwrap(), &to); + } + + // #127: the queue is bounded, and a job that is not worth more than what is queued does not + // displace anything. + #[test] + fn the_route_queue_is_bounded_and_refuses_a_worthless_job() { + let mut sim = Sim::new(61); + for i in 0..JOBS_MAX { + let mut d = sim.make_drone(DroneKind::Kh22, DVec3::new(i as f64, 100.0, 0.0), DVec3::X, DVec3::new(1000.0, 70.0, 0.0)); + d.smart = true; + let id = d.id; + sim.drones.push(d); + sim.queue_route(RouteJob::for_test(id, DVec3::new(1000.0, 70.0, 0.0), 0, 1, Arc::new(Vec::new()), None)); + } + assert_eq!(sim.retargets.len(), JOBS_MAX); + // A replacement is worth less than a plan's objective: the queue refuses it. + let mut d = sim.make_drone(DroneKind::Kh22, DVec3::new(0.0, 100.0, 50.0), DVec3::X, DVec3::new(1000.0, 70.0, 50.0)); + d.smart = true; + let id = d.id; + sim.drones.push(d); + sim.queue_route(RouteJob::for_test(id, DVec3::new(1000.0, 70.0, 50.0), 0, 2, Arc::new(Vec::new()), None).with_intent(Intent::Replace)); + assert_eq!(sim.retargets.len(), JOBS_MAX, "a worthless job grew the queue"); + // Nothing queued is a replacement: a replacement is the lowest priority and does displace one. + sim.retargets.iter_mut().for_each(|j| j.intent = Intent::Replace); + let mut d = sim.make_drone(DroneKind::Kh22, DVec3::new(0.0, 100.0, 60.0), DVec3::X, DVec3::new(1000.0, 70.0, 60.0)); + d.smart = true; + let id = d.id; + sim.drones.push(d); + sim.queue_route(RouteJob::for_test(id, DVec3::new(1000.0, 70.0, 60.0), 0, 2, Arc::new(Vec::new()), None).with_intent(Intent::Plan)); + assert_eq!(sim.retargets.len(), JOBS_MAX, "the bound was broken"); + assert!(sim.retargets.back().is_some_and(|j| j.drone == id && j.intent == Intent::Plan), "the plan's job did not make room"); + } + + // #127: the same seed makes the same intents, aims and jobs; the throttle and the priority order + // never make it seed-dependent in a way that differs between two runs of one seed. + #[test] + fn the_same_seed_chose_the_same_intents_and_jobs() { + let build = |seed: u64| { + let mut sim = Sim::new(seed); + for k in 0..5 { + watch(&mut sim, SamKind::S400, DVec3::new(-500.0 + k as f64 * 250.0, 70.0, 0.0), 0, Seen::Emission); + } + for i in 0..24 { + smart_drone(&mut sim, DroneKind::Kh22, DVec3::new(-1600.0, 110.0, -500.0 + i as f64 * 40.0), DVec3::new(1600.0, 70.0, 1600.0)); + } + sim.tick = 40; + sim.adapt(); + let aims: Vec<_> = sim.queue.iter().map(|(_, d)| (d.id, d.intent, d.aim.unit, d.aim.since, d.aim.switches)).collect(); + let jobs: Vec<_> = sim.retargets.iter().map(|j| (j.id, j.drone, j.unit, j.version, j.priority())).collect(); + (aims, jobs) + }; + let a = build(67); + let b = build(67); + assert_eq!(a, b, "the same seed produced different intents or jobs"); + assert!(!a.1.is_empty(), "the test no longer leaves jobs pending"); + } +} diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index adc87e4..258f9d2 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -25,7 +25,8 @@ pub mod record; mod sight; mod terrain; mod ew; -pub use ew::{Confidence, ContactReport, EnemyPictureReport}; +mod durable; +pub use ew::{AimReport, Confidence, ContactReport, EnemyPictureReport, Intent, PendingReport}; pub mod lights; mod logistics; mod orders; @@ -732,6 +733,13 @@ pub struct Drone { /// Its navigation error (#65): `target` is the nominal aim plus this, kept apart so a new aim (a retarget) /// keeps the error this airframe was born with instead of becoming exact. err: DVec3, + /// Why it is aimed where it is (#127): the structured intent a smart drone carries — the plan's + /// objective, a SEAD reaction to a disclosure, or a replacement after the picture killed the aim. + /// Kept apart from any random re-pick, reported by the diagnostics, and saved with the drone. + intent: ew::Intent, + /// What it aims at and how firmly (#127): the unit, when the aim was chosen and how often it has + /// changed. The switch throttle and the priority of its route job read this. + aim: ew::Aim, /// Ticks held at the edge of blocks the game has not told of (#61); reset by a clear step. hold: u16, /// Its progress towards the aim: a flight that stops getting nearer runs out of fuel (see `Pace`). @@ -792,6 +800,17 @@ impl Drone { pub fn alive(&self) -> bool { self.flight != Flight::Falling } + /// Why it is aimed where it is (#127), and how often that aim has been changed. + pub fn intent(&self) -> ew::Intent { + self.intent + } + pub fn aim_switches(&self) -> u16 { + self.aim.switches + } + /// Sent by the smart enemy: it may be turned on what gives itself away. + pub fn smart(&self) -> bool { + self.smart + } /// How much a system wants to shoot it: fast and heavy first, decoys look real. fn threat(&self, from: DVec3) -> f64 { self.threat_at(from, self.pos) @@ -1340,6 +1359,9 @@ pub struct Sim { /// The known land of the last smart raid's plan: its grid and cell heights (NaN unknown). New retarget /// jobs take this `Arc` as their version; a later plan replaces the pointer, not a pending job's map (#127). known_relief: Option>, + /// The next route job's id (#127). A job keeps its id when it is replaced in place; the save writes + /// the counter so restored jobs keep theirs. + job_next: u32, /// The vehicles' ground routes, and the jobs the trucks found out of reach (#94). routes: route::Routes, /// Each marked object's health, 0 to 100 (`targets`' order). @@ -1421,6 +1443,7 @@ impl Sim { plan: None, retargets: VecDeque::new(), known_relief: None, + job_next: 1, routes: Default::default(), target_health: Vec::new(), wave: None, @@ -1978,6 +2001,8 @@ impl Sim { smart: false, nav: Nav::default(), err: DVec3::ZERO, + intent: ew::Intent::Plan, + aim: ew::Aim::point(0), hold: 0, pace: Pace::default(), }); diff --git a/airdef-mod/src/sim/raid.rs b/airdef-mod/src/sim/raid.rs index 313a01b..5df1120 100644 --- a/airdef-mod/src/sim/raid.rs +++ b/airdef-mod/src/sim/raid.rs @@ -164,11 +164,14 @@ impl Grid { } /// The land a route crosses: its height under each cell (NaN unknown), the climb a kind holds for long -/// (rise a block), and the height above which the land forces a drone up from its low altitude. +/// (rise a block), and the height above which the land forces a drone up from its low altitude. Unknown +/// land costs `unknown` per cell on top of the open ground: zero for a plan routed over land it is still +/// mapping, more than free for a replanned retarget (#127). pub(super) struct Relief<'a> { pub heights: &'a [f32], pub slope: f32, pub floor: f32, + pub unknown: f32, } /// [`plan_route`] over relief: a climb steeper than the kind holds is dear (a cliff or a ridge it would have to @@ -215,6 +218,9 @@ pub(super) fn plan_route_on(grid: &Grid, prices: &[f32], relief: Option<&Relief> price += (slope - r.slope) * 20.0 * len; } } + } else if r.unknown > 0.0 { + // Unmapped land is not free: the route prefers what the plan actually knew. + price += len * r.unknown; } } let ng = g + price; @@ -314,6 +320,16 @@ impl Sim { // Where it starts is provisional: the site's pad, once the site is placed. let from = centre + DVec3::new(a.sin() * dist, height + self.rng.s() * 6.0, a.cos() * dist); let mut d = self.make_drone(kind, from, horizontal(aim - from), aim); + // The plan's own decision, recorded as such (#127): a radar job is SEAD, a unit + // chosen for the raid is the plan's objective, anything else is a marked object. + if let Some(id) = at_sam { + let confidence = picture.iter().find(|c| c.id == id).map(|c| c.confidence(self.tick)); + d.intent = if job.is_some() { ew::Intent::Sead } else { ew::Intent::Plan }; + d.aim = confidence.map_or_else(|| ew::Aim::point(self.tick), |c| ew::Aim::unit(id, self.tick, c)); + } else { + d.intent = ew::Intent::Plan; + d.aim = ew::Aim::point(self.tick); + } let range = spec.ballistic.map_or(order.dist, |far| far.max(order.dist)); let want = sites::want_index(&mut wants, centre, side, a, sites::SiteClass::of(kind), range, order.dist.max(120.0)); let pad = wants[want].jobs; @@ -445,6 +461,7 @@ impl Sim { heights: &plan.heights, slope: (kind.climb_pitch().tan() * 0.5) as f32, floor: (aim.y + spec.clearance) as f32, + unknown: 0.0, }); let (r, cost) = plan_route_on(&plan.grid, &plan.prices[&kind], relief.as_ref(), from, aim); work += cost; @@ -505,6 +522,18 @@ impl Sim { self.plan.is_some() } + /// The plan's drones and the structured intent each was given (#127; tests and diagnostics). + #[cfg(test)] + pub(super) fn planned_intents(&self) -> Vec<(u32, ew::Intent)> { + self.plan.as_ref().map(|p| p.items.iter().map(|i| (i.d.id, i.d.intent)).collect()).unwrap_or_default() + } + + /// The plan's unit aims and their intent: the same list, keyed by what the drone goes for. + #[cfg(test)] + pub(super) fn planned_aims(&self) -> Vec<(u32, ew::Intent)> { + self.plan.as_ref().map(|p| p.items.iter().map(|i| (i.d.aim.unit, i.d.intent)).collect()).unwrap_or_default() + } + /// A drone ready to fly from `from`, not yet in the sky. pub(super) fn make_drone(&mut self, kind: DroneKind, from: DVec3, dir: DVec3, target: DVec3) -> Drone { let id = self.id(); @@ -539,6 +568,8 @@ impl Sim { smart: false, nav: Nav::default(), err, + intent: ew::Intent::Plan, + aim: ew::Aim::point(0), hold: 0, pace: Pace::default(), } diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index 49ce58e..b1894ad 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -205,35 +205,45 @@ fn mass_retarget_finishes_its_route_jobs_over_the_budget() { let pending = sim.retarget_ids(); assert!(!pending.is_empty(), "the budget planned all 40 routes: the test no longer covers the queue"); assert_eq!(pending.len(), pending.iter().collect::>().len(), "duplicate jobs"); - assert!(pending.contains(&empty), "a drone left with no route by the budget was dropped from the queue"); + // Assignments are throttled too: one adaptive tick hands out a bounded number of new aims, + // and the rest of the force is sent on the following adaptive ticks (#127). + assert!(pending.len() <= super::ew::ASSIGN_PER_TICK, "one adaptive tick assigned {} drones", pending.len()); for id in &pending { let d = &sim.queue.iter().find(|(_, d)| d.id == *id).unwrap().1; assert_eq!(&d.route, old.get(id).unwrap(), "an old safe route was replaced before the new one was ready"); } - assert!( - sim.queue.iter().all(|(_, d)| !d.route.is_empty() || d.id == empty), - "a route was wiped by the budget" - ); let mut left = pending.len(); - for _ in 0..80 { + for _ in 0..400 { sim.tick += 1; sim.adapt(); - assert!(sim.retargets.len() < left, "a budget tick made no progress: {left} jobs left"); - left = sim.retargets.len(); + if sim.tick % 40 == 0 { + // A new adaptive tick: more drones may be sent, still within the throttle. + let now = sim.retarget_ids().len(); + assert!(now <= left + super::ew::ASSIGN_PER_TICK, "an adaptive tick assigned more than {} drones", super::ew::ASSIGN_PER_TICK); + left = now; + } else if left > 0 { + assert!(sim.retargets.len() < left, "a budget tick made no progress: {left} jobs left"); + left = sim.retargets.len(); + } let still = sim.retarget_ids(); assert!( - sim.queue.iter().all(|(_, d)| !d.route.is_empty() || still.contains(&d.id)), + // A drone the throttle has not sent yet is still on its own plan's route or has none to lose; + // one already sent at a unit whose route is empty must have a job that will fill it. + sim.queue.iter().all(|(_, d)| !d.route.is_empty() || still.contains(&d.id) || d.aim.unit == 0), "a route is still empty with no job left to fill it" ); - if left == 0 { - break; - } } - assert_eq!(left, 0, "route jobs were never finished"); + assert_eq!(sim.retargets.len(), 0, "route jobs were never finished"); + // No drone is left with an empty route for ever, and every new route ends at its aim. The drone + // that started with no route at all got one too: its job waited for the budget, not for ever. for (_, d) in &sim.queue { + assert!(!d.route.is_empty(), "a drone was left with no route"); assert!(d.route.last().is_some_and(|p| p.distance(d.target) < 1e-6), "the new route does not end at the new aim"); + assert!(d.aim.unit != 0, "a drone was never sent at a unit"); } + let empty = sim.queue.iter().find(|(_, d)| d.id == empty).unwrap().1.clone(); + assert!(!empty.route.is_empty(), "the drone that started with no route never got one"); } #[test] @@ -370,6 +380,7 @@ fn same_relief_different_known_snapshots_do_not_share_prices_or_routes() { seen: super::ew::Seen::Emission, emission: Some(0), destroyed: None, + launches: 0, }; let known_all = std::sync::Arc::new(vec![contact(threat, threat_at), contact(target, aim)]); let known_target = std::sync::Arc::new(vec![contact(target, aim)]); diff --git a/docs/CODEX-HANDOFF.md b/docs/CODEX-HANDOFF.md index 24ff39a..376e331 100644 --- a/docs/CODEX-HANDOFF.md +++ b/docs/CODEX-HANDOFF.md @@ -449,9 +449,9 @@ Lancet, баллистика и decoy, как и раньше, летят пря рельефом: два job с одинаковым рельефом, но разными known units, не делят price/route grid. За пределами известной сетки — Unknown, новая карта не раскрывается. -Очередь — рантайм-состояние планировщика. Дроны и очередь запуска в текущий `state_text` не входят, -поэтому формат save не менялся и старые saves читаются как прежде; запись replay по-прежнему хранит -траектории, а не задания. Регрессии: `mass_retarget_finishes_its_route_jobs_over_the_budget` +Очередь этапа 1 была рантайм-состоянием планировщика: дроны и очередь запуска в `state_text` не +входили, поэтому durable-очередью она не была. Этап 2 (ниже) сохраняет её вместе с дронами, очередью +запуска, картиной и версиями рельефа. Регрессии этапа 1 остаются: `mass_retarget_finishes_its_route_jobs_over_the_budget` (40+ ретаргетов, прогресс каждый бюджетный тик, старый маршрут до нового, нет пустых маршрутов) и `mass_retarget_is_deterministic_and_never_duplicates_a_drone` (тот же seed — те же цели и маршруты; повторный `adapt` не создаёт дублей). @@ -492,8 +492,8 @@ launcher-only: без донорского контакта такая ПУ в статистикой секторов (`best_bearing` по kills/hits): новых всеведущих данных и усиления характеристик нет. -Форматы не изменились: intel — рантайм (save хранит Layout, replay — траектории), durable-очередь -по-прежнему не делается. Измерения до/после на одних seed: `docs/measurements/issue-127-enemy-picture-baseline.json` +Форматы этапа 1 не менялись: intel оставался рантаймом (save хранил Layout, replay — траектории); +durable-очередь появилась на этапе 2, ниже. Измерения до/после на одних seed: `docs/measurements/issue-127-enemy-picture-baseline.json` (0b645d77e) и `issue-127-enemy-picture-result.json` (эта ветка), bench — `cargo run --release --example intel_bench`. big-smart и logistics побайтово совпали с базой; ew-adaptive изменился (kills 62→59/61→59/61→56, hits 3→7/5→7/7→8, потери ЗРК 1→3/4→3/4→4) — фиксируется как факт перехода на честную картину, @@ -502,8 +502,124 @@ hits 3→7/5→7/7→8, потери ЗРК 1→3/4→3/4→4) — фиксир (донор любой роли; разрыв связи наблюдённый/нет), `destruction_moves_the_picture_only_when_watched`, `confidence_is_a_function_of_age_alone`, `enemy_picture_report_describes_observation_and_belief`, `an_impact_watches_the_kill_and_updates_only_the_picture` (BSA end-to-end), обновлённые SEAD/#4/#21/#122 -и обе регрессии очереди. Остаток #127: durable save queue (новый формат save), полный structured intent, -GUI поверх `enemy_picture()`, оценка баланса ew-adaptive. +и обе регрессии очереди. Остаток #127 после этапа 1: durable save queue, structured intent, GUI. + +### #127, этап 2: structured intent, durable queue и объяснимость + +**Намерение отделено от цели.** У дрона теперь два поля: `ew::Intent` — почему он летит туда — и +`ew::Aim` — юнит, когда цель выбрана, сколько раз менялась и уверенность контакта. `Intent`: +`Plan` (собственная цель плана: объект или юнит, выбранный при заказе), `Sead` (плановый удар по РЛС +из `radar_strikes`), `React` (реакция на раскрытие: станция показала себя уже в воздухе) и `Replace` +(картинка увидела гибель/забвение прежней цели). Случайный выбор цели (`choose_target`) больше не +смешивается с реакцией: при заказе налёта intent фиксируется, а `adapt` помечает свои решения явно. +Приоритет очереди — из намерения: Plan 3, Sead 2, React 1, Replace 0. + +**Throttle.** `SWITCH_HOLD = 160` тиков (8 с): свежее раскрытие может сменить цель только после +выдержки; `SWITCH_CAP = 3` смены на дрон за жизнь; `ASSIGN_PER_TICK = 16` новых целей за адаптивный +тик и `ASSIGN_PER_CONTACT = 6` на один контакт за тик — массовый ретаргет растягивается на следующие +адаптивные тики, а не переписывает все маршруты сразу. Смена возможна, только если контакт показался +позже текущей цели (`tick > aim.since`), он не Doubtful, и он либо достовернее текущего, либо ближе +на `SWITCH_GAIN = 120` блоков: смена выбрасывает готовый маршрут и стоит планировщику нового, поэтому +маргинальная не делается. Замена убитой цели выдержку не ждёт (цели уже нет), но упирается в тот же +cap. В полёте, как и раньше, цель меняется только когда картинка её убила или забыла. + +**Явные игровые реакции без скрытых данных.** Реакции читают только поля контакта, записанные каналом +наблюдения: выключенный радар остаётся в картине таким, каким его видели (эмиссия не фиксировалась — +собственный сенсор не верится), разделённая батарея читается как пуск без эмиссии (launcher-only, +без донора «слепа»), конечный БК наблюдается только как пуски (`Contact.launches`: счётчик увиденных +пусков; остаток магазина в картину не входит никогда), энергия/нагрев РЭБ — своё состояние станции, +а не раскрытие. Метаморфный тест `hidden_changes_do_not_move_the_picture_or_its_decisions` расширен: +скрытые перемещение, off, роли, режим, БК, урон, гибель и нагрев/замок/выключение РЭБ не двигают ни +картинку, ни цены, ни намерения/aims/очередь заданий. Повторное наблюдение двигает всё — как и должно. + +**Неизвестная земля не бесплатна.** `raid::Relief` получил поле `unknown`; у плана умной волны оно +0 (поведение этапа 1 сохранено), у повторного планирования задания — `UNKNOWN_LAND = 6` сверх базовой +цены клетки 1. Задание, чья версия рельефа ничего не знает, планируется по полностью неизвестной +земле с той же ценой, а не «как по свободной»: маршрут держится размеченной части, если она есть. +Тест `unknown_land_is_not_free_in_a_replanned_route` сравнивает маршрут с ценой 0 и с ценой 6 на одной +сетке: бесплатный срезает через неразмеченное, платный идёт по размеченному. + +**Durable queue: формат save.** `state_text` (автосейв) дописывает версионированную секцию +(`sim::durable`, формат `air 1`): + +``` +air 1 <такт> <номер следующего задания> <дронов> <в очереди> <заданий> <контактов> <версий рельефа> +airintel <12 kills> <12 hits> +airglobal <версия рельефа|-> +airrelief <номер> <клетка> <сторона> | <высота>x ...> +aircontact <тип> <такт наблюдения> <канал> <эмиссия|-> <гибель|-> <пусков> +airdrone <тип> ... <полёт> <намерение> <юнит> <смены> <уверенность> ... <маршрут: n x y z ...|-> +airqueue <такт запуска> <то же тело дрона> +airjob <дрон> <юнит> <версия> <приоритет> <намерение> <рельеф|-> +``` + +Секция пишется только автосейвом: сценарий — это стартовое состояние, его тело её не несёт +(`scenario_text` → `state_text_with(..., air = false)`). Старые saves без секции читаются как прежде +(пустое небо); старые сборки новые saves читают как Layout, игнорируя `air*` (Layout пропускает +неизвестные строки). Всё проверяется до применения: битая строка отказывает save целиком с номером +строки и полем, счётчики в заголовке сверяются с числом строк (обрезанный файл отвергается), чужая +строка внутри секции — тоже. Границы: 512 дронов, 512 точек маршрута, 4096 контактов, 8 версий +рельефа, 40 000 клеток; координаты пишутся до 0.01 блока (полёт этого не замечает), рельеф — RLE +целых высот. Секция дополнительно ограничена 900 КБ (хост разрешает 1 МиБ на одну запись): дрон, +который не влез, не пишется, и его задание тоже (восстановить его было бы нельзя). Измеренный размер секции в bench: 4.0–8.8 КБ для logistics/ew-adaptive и 61–62 КБ для +big-smart (38 дронов в воздухе). + +**Что именно восстанавливается.** Дроны в воздухе (живые; падающие обломки — эффект, их нет), +очередь запуска с тактами, задания (`id`, дрон, цель, юнит, версия, приоритет, намерение, версия +рельефа), контакты картины (счётчики секторов, пуски), глобальный снимок рельефа и `job_next`. +Картина задания восстанавливается «на свою версию»: контакты, наблюдённые не позже версии, плюс +собственная цель задания (её последнее сохранённое наблюдение заменяет более позднее — иначе цель +исчезла бы из своей же картины). Устаревшие задания отменяются при загрузке (`prune_air`): дрон +исчез/погиб, цель уже другая, юнит наблюдённо уничтожен или забыт, дубль по дрону, версия старше +`JOB_TTL = 1200` тиков (60 с). Маршрут отменённого «по устареванию/дублю» задания сохраняется (он +ведёт к той же цели), отменённого из-за смены цели — очищается. + +`apply_state` сначала проверяет Layout и секцию, затем применяет обе; секция заменяет воздушное +состояние, а не сливается с ним. `durable_mark` включает `air_mark` (число дронов/очереди/заданий/ +контактов и `job_next`): автосейв берётся на запуске, потере, новом/завершённом задании и новом/забытом +контакте, а не каждый тик полёта. Пока налёт *планируется*, периодический save откладывается (план не +имеет durable-формы; поиск площадок ограничен `sites::SEARCH_TICKS = 400`); при смене мира пишется +только Layout. Replay по-прежнему хранит траектории: секция пишется и читается только для живого +`Sim`, записи не смешиваются с ней. + +Остаток и ограничения: ракеты-перехватчики в воздухе, пусковые площадки (`sites`), промежуточный +план, статистика волны и состояние RNG не сохраняются — после загрузки перехваты начнутся заново, +очередь стартует без площадки (свободный слот), отчёт волны не придёт, а случайные броски идут от +нового генератора мира; сама воздушная обстановка (дроны, цели, задания, картина) восстанавливается +точно. GUI поверх отчёта не делался. + +**Объяснимость.** `EnemyPictureReport` расширен без русского текста в core: у контакта — `launches`, +`aimed_by` (сколько дронов на него нацелено) и `pending_jobs`; добавлены `aims` (дрон, юнит, intent, +since, switches, confidence) и `pending` (id задания, дрон, юнит, точка, версия, приоритет, intent, +`relief_known`, `reason` словами: ждёт бюджета / ждёт бюджета, земля неизвестна и стоит дороже / +устарело), плюс `work_per_tick`. Тест `the_report_explains_aims_and_pending_jobs_in_english` +проверяет и ASCII-only диагностику. + +**Измерения (baseline 8fc10c07a → эта ветка, `cargo run --release --example intel_bench`, seeds 1/7/42):** +`docs/measurements/issue-127-intent-queue-baseline.json` и `issue-127-intent-queue-result.json`. +big-smart: завершение у всех, launched 38, kills 38, hits 0, потери 0 — как в базе; ticks 1716→1676 +(seed 1) и 1542/1433 без изменений, fired 49→60, 48→68, 49→71 (больше пусков ПВО). logistics: все +поля совпали (27 запусков, 11/12/12 сбито, 8/7/8 прорвалось, 18 ракет, 0 потерь). ew-adaptive: +kills 59→58, 59→58, 56→63; hits 7→7, 7→9, 8→3; потери ЗРК 3→3, 3→4, 4→2; ticks 8084→7146, 8193→7650, +7916→7243; fired 69→76, 81→98, 69→82. Это следствие throttle назначений и приоритетной очереди, а не +подкрутки баланса: параметры ПВО/дронов не менялись, числа фиксируются как факт. В result-файле есть +новые поля (`retargets_max`, `air_bytes`, `switches_max`, `intents`), у baseline их нет. + +**Регрессии этапа 2.** Core: `the_plan_records_sead_separately_from_its_objective`, +`assignments_are_throttled_per_tick_and_per_contact`, `a_fresh_disclosure_switches_an_aim_only_after_the_hold`, +`a_watched_kill_replaces_the_aim_and_queues_a_lower_priority_route`, +`launched_rounds_are_observed_but_the_magazine_is_not`, `unknown_land_is_not_free_in_a_replanned_route`, +`the_route_queue_is_bounded_and_refuses_a_worthless_job`, `the_same_seed_chose_the_same_intents_and_jobs`, +`the_report_explains_aims_and_pending_jobs_in_english`, `the_air_state_round_trips_every_job_field`, +`a_pending_route_continues_after_a_save_and_a_load` (save/load посреди незавершённой очереди: та же +очередь, те же маршруты, обе кампании доводят задания, пустых маршрутов нет), +`stale_ghost_and_duplicate_jobs_are_cancelled`, `a_job_for_a_unit_the_picture_lost_is_cancelled_on_load`, +`a_damaged_air_section_is_refused_with_its_line_and_field`, `the_section_stays_inside_its_byte_bound` +(600 дронов с длинными маршрутами: секция ≤ 900 КБ, записанные задания принадлежат записанным дронам), +обновлённые массовые ретаргеты (40+ дронов: +прогресс на бюджетном тике, throttle назначений, у дрона, стартовавшего без маршрута, маршрут +появляется). Adapter: `the_air_section_of_a_save_is_written_and_read_by_the_adapter` (запись/чтение +секции, замена не слияние, старый save без секции, сценарий без воздуха). ## Что следующему агенту проверить в первую очередь diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index a821697..4e4e55b 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -51,13 +51,14 @@ | #120 | Конечное горение, powered/coast, сопротивление и снижение доступного манёвра, один остаточный pk по фактической скорости; swept-встреча; события burnout/energy loss; согласованное прекращение факела/дыма/звука | Игровой профиль, без аэродинамики и реальных параметров; сравнительная выборка 12 рейдов и расширенная сетка 48 рейдов (6 вариантов × 8 seed, батареи/дальности/классы) записаны в measurements; без подтверждённого дефекта параметры не менялись; прослушивание остаётся | | #121 | On/Off в команде и карте, save/replay; Off не создаёт own measurements и сохраняет сетевой приём; раскрытие работающего радара наблюдателем; SEAD использует last-known и возраст | Условный наблюдатель по расстоянию, без радиотехнической модели и ошибки пеленга; выбор на карте ещё не проверен в GUI | | #122 | Воспроизведены два класса: tick fuel exhaustion на двух максимальных составах, HD (полный обход height-cache на каждом взрыве заменён удалением нужных ключей; max135.3M/300M) и остановка в UI event на втором крупном приказе (переснимок known_units на каждом дроне заменён одним снимком на приказ и O(журнал+группировка); max command 32.9M/1e9, `/stats` до/после совпал); нативные тесты эквивалентности и второго приказа; WASM fixture с 4 принятыми приказами; callback diagnostics/profile | Требуется приёмка на Windows; без подтверждённого build identity нельзя утверждать ту же причину пользовательского сеанса; FPS/GPU не измерялись | -| #127 | Этап 1: честная EnemyPicture и актуальная модель возможностей. Журнал раскрытий заменён картой контактов: `Intel.contacts` хранит последнее наблюдение юнита (тип, место, канал «пуск/излучение/глушение», время, метка эмиссии, наблюдённое уничтожение) и никогда не обновляется тем, что юнит делает незамеченным. Уверенность — функция одного возраста (Confirmed/Probable/Doubtful; TTL контакта 6000 тиков). Угроза в `price_grid` читает только контакты: published-роли и published-дальности вида (без урона/ролей/мастов истины), собственный сенсор — по факту фиксации эмиссии, пуск без эмиссии у С-400/С-500 читается как боевая работа по сети (без донора такой контакт «слеп»; донор — любой живой контакт-РЛС в пределах link range от наблюдённого места), горизонт РЛС игрока из чужой ценовой сетки убран. Уничтожение учится только из BSA: последние мгновения собственной боеголовки у наблюдённого юнита (радиус 16). SEAD бьёт по контактам Probable и свежее; цели и ретаргет — по картинке, задание отменяется при наблюдённой гибели/забвении цели. Структурированная диагностика `Sim::enemy_picture()` (англ., без GUI): наблюдение/уверенность/допущение угрозы словами. Очередь #127 и WORK_PER_TICK без изменений; сейвы/replay не затронуты (intel — рантайм). Метаморфные регрессии: скрытые перемещение/off/роли/режим/БК/урон/гибель не двигают картинку, цены и выбор adapt; launcher-only без донора/с донором/разрыв связи наблюдённый и нет; повторное наблюдение обновляет; BSA end-to-end. База/результат по seed 1/7/42: `docs/measurements/issue-127-enemy-picture-{baseline,result}.json` (big-smart/logistics идентичны, ew-adaptive: kills 62→59/61→59/61→56, hits 3→7/5→7/7→8, потери 1→3/4→3/4→4 — фиксируется как факт, параметры не менялись) | Остаток #127: durable save queue (новый формат save), полный structured intent, GUI для диагностики картинки, оценка баланса изменений ew-adaptive (без подтверждённого дефекта параметры не трогали) | +| #127 | Этапы 1–2. **Картина:** журнал раскрытий заменён картой контактов (`Intel.contacts`: последнее «пуск/излучение/глушение», место, время, метка эмиссии, наблюдённая гибель, счётчик увиденных пусков), уверенность — функция возраста (Confirmed/Probable/Doubtful, TTL 6000), угроза и цены читают только published-параметры и факты наблюдений (launcher-only без донора «слеп»), уничтожение учится только из BSA. **Structured intent:** `Intent` (Plan/Sead/React/Replace) и `Aim` (юнит, since, switches, confidence) отделены от random-выбора; throttle `SWITCH_HOLD=160`, `SWITCH_CAP=3`, `ASSIGN_PER_TICK=16`, `ASSIGN_PER_CONTACT=6`, цена смены `SWITCH_GAIN=120`; в полёте цель меняется только когда картинка её убила/забыла. **Durable queue:** автосейв пишет версионированную секцию `air 1` — дроны, очередь запуска, задания (id, дрон, цель, юнит, версия, приоритет, намерение, версия рельефа), контакты, RLE-рельеф; очередь ограничена 256, устаревшие/фантомные/дублирующие задания отменяются при загрузке (`JOB_TTL=1200`), картина задания восстанавливается на свою версию, старые saves читаются (пустое небо), сценарии воздух не несут, Live/Replay не смешиваются. **Неизвестная земля не бесплатна** (`Relief.unknown=6` у повторного плана). **Объяснимость:** `enemy_picture()` дополнен aims/pending reason/aimed_by/pending_jobs без русского текста в core. Метаморфные тесты скрытых изменений (включая нагрев/замок РЭБ), save/restore посреди незавершённой очереди, детерминизм seed, 40+ массовый ретаргет. Замеры: `docs/measurements/issue-127-intent-queue-{baseline,result}.json` | GUI поверх `enemy_picture()`, игровая приёмка сохранения налёта на Windows/Linux, оценка баланса ew-adaptive (kills 59→58/59→58/56→63, потери 3→3/3→4/4→2 — следствие throttle, параметры не менялись) | -По #127 реализованы устойчивая очередь маршрутов и этап 1 честной EnemyPicture (карта контактов, -уверенность по возрасту, угроза по наблюдённым параметрам, BSA-канал уничтожения, диагностика -`enemy_picture()`). Очередь и картина не сохраняются в autosave: текущий формат вообще не хранит -дронов, очередь запуска и intel, поэтому старые saves и replay читаются как прежде и миграция не -нужна. +По #127 реализованы карта контактов, структурированное намерение с throttle и durable-очередь: +autosave хранит дронов, очередь запуска, задания с картиной и рельефом своей версии, поэтому +незавершённый ретаргет продолжается после перезахода. Старые saves без секции `air` читаются как +прежде (пустое небо), сценарии воздушное состояние не несут, replay по-прежнему хранит траектории. +Ракеты-перехватчики, площадки запуска, промежуточный план, статистика волны и RNG не сохраняются — +это ограничение записано в handoff. Точные коммиты, формат сохранения, тесты и порядок продолжения: [CODEX-HANDOFF.md](CODEX-HANDOFF.md). Формальный open-статус не меняется после реализации. diff --git a/docs/measurements/issue-127-intent-queue-baseline.json b/docs/measurements/issue-127-intent-queue-baseline.json new file mode 100644 index 0000000..5a1c345 --- /dev/null +++ b/docs/measurements/issue-127-intent-queue-baseline.json @@ -0,0 +1,154 @@ +{ + "schema": 1, + "world": "IntentQueue127", + "branch": "8fc10c07a", + "code_commit": "8fc10c07a (base of the branch; the run was made from a clean worktree at that commit)", + "bench": "cargo run --release --example intel_bench (airdef-mod, SDK-free harness)", + "scenarios": [ + "big-smart", + "ew-adaptive", + "logistics" + ], + "seeds": [ + 1, + 7, + 42 + ], + "runs": [ + { + "scenario": "big-smart", + "seed": 1, + "ticks_run": 1716, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 38, + "kills": 38, + "hits": 0, + "crashes": 0, + "sams_lost": 0, + "fired": 49, + "failures": "3 промахов по взрывателю, 27 ракет остались без цели, 3 потерь трека" + }, + { + "scenario": "big-smart", + "seed": 7, + "ticks_run": 1542, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 38, + "kills": 38, + "hits": 0, + "crashes": 0, + "sams_lost": 0, + "fired": 48, + "failures": "2 промахов по взрывателю, 22 ракет остались без цели, 5 потерь трека" + }, + { + "scenario": "big-smart", + "seed": 42, + "ticks_run": 1433, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 38, + "kills": 38, + "hits": 0, + "crashes": 0, + "sams_lost": 0, + "fired": 49, + "failures": "2 промахов по взрывателю, 27 ракет остались без цели, 7 потерь трека" + }, + { + "scenario": "logistics", + "seed": 1, + "ticks_run": 1009, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 27, + "kills": 11, + "hits": 8, + "crashes": 0, + "sams_lost": 0, + "fired": 18, + "failures": "1 промахов по взрывателю, 6 ракет остались без цели, 1 потерь трека, 3 ожиданий БК" + }, + { + "scenario": "logistics", + "seed": 7, + "ticks_run": 970, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 27, + "kills": 12, + "hits": 7, + "crashes": 0, + "sams_lost": 0, + "fired": 18, + "failures": "6 ракет остались без цели, 2 потерь трека, 3 ожиданий БК" + }, + { + "scenario": "logistics", + "seed": 42, + "ticks_run": 1003, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 27, + "kills": 12, + "hits": 8, + "crashes": 0, + "sams_lost": 0, + "fired": 18, + "failures": "6 ракет остались без цели, 3 потерь трека, 3 ожиданий БК" + }, + { + "scenario": "ew-adaptive", + "seed": 1, + "ticks_run": 8084, + "raids_ordered": 3, + "raids_ended": 3, + "all_ended": true, + "launched": 69, + "kills": 59, + "hits": 7, + "crashes": 3, + "sams_lost": 3, + "fired": 69, + "failures": "7 промахов по взрывателю, 20 ракет остались без цели, 11 потерь трека" + }, + { + "scenario": "ew-adaptive", + "seed": 7, + "ticks_run": 8193, + "raids_ordered": 3, + "raids_ended": 3, + "all_ended": true, + "launched": 69, + "kills": 59, + "hits": 7, + "crashes": 3, + "sams_lost": 3, + "fired": 81, + "failures": "4 промахов по взрывателю, 3 ракет выработали ресурс, 26 ракет остались без цели, 8 потерь трека" + }, + { + "scenario": "ew-adaptive", + "seed": 42, + "ticks_run": 7916, + "raids_ordered": 3, + "raids_ended": 3, + "all_ended": true, + "launched": 69, + "kills": 56, + "hits": 8, + "crashes": 5, + "sams_lost": 4, + "fired": 69, + "failures": "7 промахов по взрывателю, 20 ракет остались без цели, 12 потерь трека" + } + ] +} diff --git a/docs/measurements/issue-127-intent-queue-result.json b/docs/measurements/issue-127-intent-queue-result.json new file mode 100644 index 0000000..7c755f8 --- /dev/null +++ b/docs/measurements/issue-127-intent-queue-result.json @@ -0,0 +1,190 @@ +{ + "schema": 1, + "world": "IntentQueue127", + "branch": "agent/issue-127-intent-durable", + "code_commit": "agent/issue-127-intent-durable (this branch; see git log for the exact SHA)", + "bench": "cargo run --release --example intel_bench (airdef-mod, SDK-free harness)", + "scenarios": [ + "big-smart", + "ew-adaptive", + "logistics" + ], + "seeds": [ + 1, + 7, + 42 + ], + "runs": [ + { + "scenario": "big-smart", + "seed": 1, + "ticks_run": 1676, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 38, + "kills": 38, + "hits": 0, + "crashes": 0, + "sams_lost": 0, + "fired": 60, + "retargets_max": 1, + "air_bytes": 61895, + "switches_max": 1, + "intents": "plan:25,sead:0,react:13,replace:0", + "failures": "3 промахов по взрывателю, 41 ракет остались без цели, 9 потерь трека" + }, + { + "scenario": "big-smart", + "seed": 7, + "ticks_run": 1542, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 38, + "kills": 38, + "hits": 0, + "crashes": 0, + "sams_lost": 0, + "fired": 68, + "retargets_max": 2, + "air_bytes": 62311, + "switches_max": 1, + "intents": "plan:24,sead:0,react:14,replace:0", + "failures": "6 промахов по взрывателю, 37 ракет остались без цели, 9 потерь трека" + }, + { + "scenario": "big-smart", + "seed": 42, + "ticks_run": 1433, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 38, + "kills": 38, + "hits": 0, + "crashes": 0, + "sams_lost": 0, + "fired": 71, + "retargets_max": 2, + "air_bytes": 61205, + "switches_max": 1, + "intents": "plan:22,sead:0,react:16,replace:0", + "failures": "5 промахов по взрывателю, 49 ракет остались без цели, 21 потерь трека" + }, + { + "scenario": "logistics", + "seed": 1, + "ticks_run": 1009, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 27, + "kills": 11, + "hits": 8, + "crashes": 0, + "sams_lost": 0, + "fired": 18, + "retargets_max": 0, + "air_bytes": 4041, + "switches_max": 0, + "intents": "plan:0,sead:0,react:0,replace:0", + "failures": "1 промахов по взрывателю, 6 ракет остались без цели, 1 потерь трека, 3 ожиданий БК" + }, + { + "scenario": "logistics", + "seed": 7, + "ticks_run": 970, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 27, + "kills": 12, + "hits": 7, + "crashes": 0, + "sams_lost": 0, + "fired": 18, + "retargets_max": 0, + "air_bytes": 4031, + "switches_max": 0, + "intents": "plan:0,sead:0,react:0,replace:0", + "failures": "6 ракет остались без цели, 2 потерь трека, 3 ожиданий БК" + }, + { + "scenario": "logistics", + "seed": 42, + "ticks_run": 1003, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 27, + "kills": 12, + "hits": 8, + "crashes": 0, + "sams_lost": 0, + "fired": 18, + "retargets_max": 0, + "air_bytes": 4018, + "switches_max": 0, + "intents": "plan:0,sead:0,react:0,replace:0", + "failures": "6 ракет остались без цели, 3 потерь трека, 3 ожиданий БК" + }, + { + "scenario": "ew-adaptive", + "seed": 1, + "ticks_run": 7146, + "raids_ordered": 3, + "raids_ended": 3, + "all_ended": true, + "launched": 69, + "kills": 58, + "hits": 7, + "crashes": 4, + "sams_lost": 3, + "fired": 76, + "retargets_max": 0, + "air_bytes": 7125, + "switches_max": 1, + "intents": "plan:47,sead:8,react:14,replace:0", + "failures": "10 промахов по взрывателю, 17 ракет остались без цели, 11 потерь трека" + }, + { + "scenario": "ew-adaptive", + "seed": 7, + "ticks_run": 7650, + "raids_ordered": 3, + "raids_ended": 3, + "all_ended": true, + "launched": 69, + "kills": 58, + "hits": 9, + "crashes": 2, + "sams_lost": 4, + "fired": 98, + "retargets_max": 0, + "air_bytes": 8808, + "switches_max": 1, + "intents": "plan:19,sead:2,react:13,replace:35", + "failures": "9 промахов по взрывателю, 45 ракет остались без цели, 13 потерь трека" + }, + { + "scenario": "ew-adaptive", + "seed": 42, + "ticks_run": 7243, + "raids_ordered": 3, + "raids_ended": 3, + "all_ended": true, + "launched": 69, + "kills": 63, + "hits": 3, + "crashes": 3, + "sams_lost": 2, + "fired": 82, + "retargets_max": 0, + "air_bytes": 7997, + "switches_max": 1, + "intents": "plan:49,sead:8,react:12,replace:0", + "failures": "12 промахов по взрывателю, 21 ракет остались без цели, 18 потерь трека" + } + ] +}