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 потерь трека" + } + ] +}