Give the enemy a structured intent and a durable route queue (#127)
build / mod (push) Failing after 22s
release / windows (push) Failing after 2s

Stage 2 of #127. A smart drone now carries why it is aimed where it is
(Intent: plan, sead, react, replace) apart from the aim bookkeeping
(unit, since, switches, confidence). Assignments are throttled per
adaptive tick and per contact, a fresh disclosure replaces an aim only
after a hold, within a switch cap and when the change is worth a new
route, and a killed aim is replaced as a separate, lower-priority
decision. The queue is bounded and taken by priority (plan > sead >
react > replace); unknown land is no longer free in a replanned route.

The autosave now carries the air state: the drones in flight, the launch
queue, every pending job with its id, aim, unit, version, priority,
intent and relief version, the contacts the jobs were chosen from and
the relief snapshots they plan over. The format is a versioned `air 1`
section; older saves read as before, scenarios do not carry the sky,
stale, ghost and duplicate jobs are cancelled on load, and the section is
bounded (512 drones, 512 route points, 4096 contacts, 8 reliefs, 900 KB).
A periodic save waits while a raid is only being planned.

enemy_picture() now explains targets and pending jobs (aims, aimed_by,
pending_jobs, reason, work_per_tick) in English, and intel_bench records
the queue and intent evidence next to completion, losses and failures.
This commit is contained in:
2026-11-06 18:35:09 +03:00
parent 8fc10c07a1
commit e7934a1ebb
14 changed files with 2411 additions and 89 deletions
+3
View File
@@ -90,6 +90,9 @@ CI: `.gitea/workflows/build.yml` на каждый пуш собирает то
**Сохранение.** Комплексы, объекты, их повреждения и настройки сохраняются отдельно для каждого мира (и для каждого **Сохранение.** Комплексы, объекты, их повреждения и настройки сохраняются отдельно для каждого мира (и для каждого
сервера); уничтоженный комплекс остаётся остовом и после перезахода, пока его не отремонтируют. сервера); уничтоженный комплекс остаётся остовом и после перезахода, пока его не отремонтируют.
Налёт в воздухе тоже сохраняется: дроны, очередь их запуска и незавершённые задания на маршрут
восстанавливаются, и перезаход не обрывает перенацеливание. Ракеты-перехватчики в воздухе и площадки
запуска не сохраняются; сценарий — стартовое состояние и налёт не несёт.
**Сеть ПВО.** Единицы обмениваются измеренными треками в пределах игровой связи; повреждение связи **Сеть ПВО.** Единицы обмениваются измеренными треками в пределах игровой связи; повреждение связи
сокращает её дальность. Можно поставить несколько С-400/С-500 без собственной РЛС и общие радары сокращает её дальность. Можно поставить несколько С-400/С-500 без собственной РЛС и общие радары
+5 -1
View File
@@ -21,7 +21,7 @@ fn esc(s: &str) -> String {
fn run(sc: &Scenario) -> String { fn run(sc: &Scenario) -> String {
let r = harness::run(sc); let r = harness::run(sc);
format!( 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), esc(&r.name),
r.seed, r.seed,
r.ticks_run, r.ticks_run,
@@ -34,6 +34,10 @@ fn run(sc: &Scenario) -> String {
r.crashes, r.crashes,
r.sams_lost, r.sams_lost,
r.fired, r.fired,
r.retargets_max,
r.air_bytes,
r.switches_max,
esc(&r.intents),
esc(&r.failures), esc(&r.failures),
) )
} }
+32
View File
@@ -130,6 +130,23 @@ pub struct Report {
pub all_ended: bool, pub all_ended: bool,
/// Systems the defence lost (destroyed units at the end). /// Systems the defence lost (destroyed units at the end).
pub sams_lost: u32, 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<u32, &'static str>) -> 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::<Vec<_>>().join(",")
} }
fn finite(v: DVec3) -> bool { fn finite(v: DVec3) -> bool {
@@ -164,6 +181,8 @@ pub fn run(sc: &Scenario) -> Report {
let mut ended = vec![false; sc.raids.len()]; let mut ended = vec![false; sc.raids.len()];
// truck id -> (last position, ticks standing while busy) // truck id -> (last position, ticks standing while busy)
let mut standing: HashMap<u32, (DVec3, u64)> = HashMap::new(); let mut standing: HashMap<u32, (DVec3, u64)> = HashMap::new();
// drone id -> the last structured intent it carried (#127).
let mut intents_seen: HashMap<u32, &'static str> = HashMap::new();
let mut out = Out::default(); let mut out = Out::default();
for now in 1..=sc.ticks { for now in 1..=sc.ticks {
// Raids come in order: one is ordered once the previous one has been sent. // 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(); pending = wanted.into_iter().map(|(x, z)| (x, z, sc.terrain.height(x, z))).collect();
} }
r.ticks_run = now; 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. // NaN check on everything with a position.
let bad = sim.drones.iter().filter(|d| !finite(d.pos) || !finite(d.vel)).count() let bad = sim.drones.iter().filter(|d| !finite(d.pos) || !finite(d.vel)).count()
+ sim.missiles.iter().filter(|m| !finite(m.pos)).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.failures = sim.tally.text();
r.raids_ended = ended.iter().filter(|o| **o).count(); r.raids_ended = ended.iter().filter(|o| **o).count();
r.all_ended = r.raids_ended == sc.raids.len(); r.all_ended = r.raids_ended == sc.raids.len();
r.intents = intent_counts(&intents_seen);
r.tick_total = times.iter().sum(); r.tick_total = times.iter().sum();
times.sort(); times.sort();
if !times.is_empty() { if !times.is_empty() {
+91 -2
View File
@@ -886,7 +886,13 @@ impl AirDef {
/// The state as text, lines of words: the autosave's format, and the body of a scenario. /// 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). /// `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 { 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(); let mut text = String::new();
for s in &self.sim.sams { for s in &self.sim.sams {
// Then its ammunition (missiles, bursts, out and waiting for a truck, reload ticks // 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()) { for (i, s) in self.sim.sams.iter().enumerate().filter(|(_, s)| !s.doctrine.is_default()) {
text += &format!("doctrine {i} {}\n", s.doctrine.save_text()); 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 text
} }
fn save(&mut self) { 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. // Clean only once written: a failed write is tried again after a pause.
match storage::set(&self.state_key(), text.as_bytes()) { match storage::set(&self.state_key(), text.as_bytes()) {
Ok(()) => { Ok(()) => {
@@ -993,7 +1015,15 @@ impl AirDef {
/// naming the line and field, and the scene stays as it was (#86). /// naming the line and field, and the scene stays as it was (#86).
fn apply_state(&mut self, text: &str) -> Result<(), String> { fn apply_state(&mut self, text: &str) -> Result<(), String> {
let layout = layout::Layout::parse(text)?; 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); self.commit_layout(layout);
if let Some(air) = air {
let cancelled = self.sim.restore_air(air);
if cancelled > 0 {
say(&format!("Воздушная обстановка восстановлена: отменено устаревших заданий {cancelled}"));
}
}
Ok(()) 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)) .fold(sim.sams.len() as u64, |a, h| a.wrapping_mul(31).wrapping_add(h))
.wrapping_mul(31) .wrapping_mul(31)
.wrapping_add(strikes) .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 { 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. /// Another world opened: the last one's state saved, a clean simulation with this one's loaded.
fn open_world(&mut self, world: Option<String>) { fn open_world(&mut self, world: Option<String>) {
if self.world.is_some() && self.dirty { 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); self.end_session(presentation::ExitCause::WorldChange);
let shader = self.sim.shader; let shader = self.sim.shader;
@@ -2951,4 +2987,57 @@ mod tests {
sim.strikes.progress = sim::strike::TUNE.progress_save; sim.strikes.progress = sim::strike::TUNE.progress_save;
assert_ne!(durable_mark(&sim), checkpoint, "a refill checkpoint is not saved"); 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());
}
} }
+3 -2
View File
@@ -36,9 +36,10 @@ fn names() -> Vec<String> {
impl AirDef { impl AirDef {
/// The scenario as text. /// 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()); 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 += &format!("seed {}\nwave {}\n", self.sim.rng.state(), self.wave.text());
text text
} }
+941
View File
@@ -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<ew::Contact>,
drones: Vec<Drone>,
queue: Vec<(u64, Drone)>,
jobs: Vec<JobRec>,
/// The land new retarget jobs take as their version.
known_relief: Option<std::sync::Arc<ew::KnownRelief>>,
}
pub(crate) struct JobRec {
id: u32,
drone: u32,
aim: DVec3,
unit: u32,
version: u64,
intent: ew::Intent,
relief: Option<std::sync::Arc<ew::KnownRelief>>,
}
/// 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<usize, String> {
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<i64, String> {
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<bool, String> {
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<f64, String> {
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<Option<u64>, 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<run>` for unknown land, `<height>x<run>` for known.
fn run_token(height: Option<i32>, run: usize) -> String {
height.map_or_else(|| format!("u{run}"), |h| format!("{h}x{run}"))
}
fn tick(x: Option<u64>) -> String {
x.map_or_else(|| "-".into(), |v| v.to_string())
}
fn intent_of(key: &str) -> Option<ew::Intent> {
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> {
[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<String> {
// What the parser accepts is what is written: a save this build writes always reads back.
let contacts: Vec<ew::Contact> = 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<std::sync::Arc<ew::KnownRelief>> = Vec::new();
let token_of = |r: &std::sync::Arc<ew::KnownRelief>, reliefs: &mut Vec<std::sync::Arc<ew::KnownRelief>>| -> Option<usize> {
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<Option<usize>> = 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<i32>, 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<u32> = 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<u32> = contacts.iter().map(|c| c.id).collect();
let jobs: Vec<(&ew::RouteJob, Option<usize>)> = 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<Option<AirSave>, String> {
let lines: Vec<(usize, Vec<&str>)> = text
.lines()
.enumerate()
.map(|(n, raw)| (n + 1, raw.split_whitespace().collect::<Vec<_>>()))
.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<usize> = None;
let mut reliefs: Vec<Option<std::sync::Arc<ew::KnownRelief>>> = vec![None; n_reliefs];
let mut contacts: BTreeMap<u32, ew::Contact> = BTreeMap::new();
let mut drones: BTreeMap<u32, Drone> = BTreeMap::new();
let mut queue: Vec<(u64, Drone)> = Vec::new();
let mut jobs: Vec<JobRec> = 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<run>` is unknown land, `<height>x<run>` 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::<usize>().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<u32> = 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<u64, std::sync::Arc<Vec<ew::Contact>>> = HashMap::new();
for rec in save.jobs {
let known = by_version.entry(rec.version).or_insert_with(|| {
let picture: Vec<ew::Contact> = 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<String> = text.lines().map(str::to_string).collect();
let head = lines.iter().position(|l| l.starts_with("air ")).unwrap();
let mut w: Vec<String> = lines[head].split_whitespace().map(str::to_string).collect();
w[7] = (w[7].parse::<usize>().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<String> = text.lines().map(str::to_string).collect();
let drone = lines.iter().position(|l| l.starts_with("airdrone ")).unwrap();
let mut w: Vec<String> = 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()
}
}
+780 -56
View File
File diff suppressed because it is too large Load Diff
+26 -1
View File
@@ -25,7 +25,8 @@ pub mod record;
mod sight; mod sight;
mod terrain; mod terrain;
mod ew; mod ew;
pub use ew::{Confidence, ContactReport, EnemyPictureReport}; mod durable;
pub use ew::{AimReport, Confidence, ContactReport, EnemyPictureReport, Intent, PendingReport};
pub mod lights; pub mod lights;
mod logistics; mod logistics;
mod orders; 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) /// 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. /// keeps the error this airframe was born with instead of becoming exact.
err: DVec3, 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. /// Ticks held at the edge of blocks the game has not told of (#61); reset by a clear step.
hold: u16, hold: u16,
/// Its progress towards the aim: a flight that stops getting nearer runs out of fuel (see `Pace`). /// 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 { pub fn alive(&self) -> bool {
self.flight != Flight::Falling 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. /// How much a system wants to shoot it: fast and heavy first, decoys look real.
fn threat(&self, from: DVec3) -> f64 { fn threat(&self, from: DVec3) -> f64 {
self.threat_at(from, self.pos) 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 /// 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). /// jobs take this `Arc` as their version; a later plan replaces the pointer, not a pending job's map (#127).
known_relief: Option<Arc<ew::KnownRelief>>, known_relief: Option<Arc<ew::KnownRelief>>,
/// 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). /// The vehicles' ground routes, and the jobs the trucks found out of reach (#94).
routes: route::Routes, routes: route::Routes,
/// Each marked object's health, 0 to 100 (`targets`' order). /// Each marked object's health, 0 to 100 (`targets`' order).
@@ -1421,6 +1443,7 @@ impl Sim {
plan: None, plan: None,
retargets: VecDeque::new(), retargets: VecDeque::new(),
known_relief: None, known_relief: None,
job_next: 1,
routes: Default::default(), routes: Default::default(),
target_health: Vec::new(), target_health: Vec::new(),
wave: None, wave: None,
@@ -1978,6 +2001,8 @@ impl Sim {
smart: false, smart: false,
nav: Nav::default(), nav: Nav::default(),
err: DVec3::ZERO, err: DVec3::ZERO,
intent: ew::Intent::Plan,
aim: ew::Aim::point(0),
hold: 0, hold: 0,
pace: Pace::default(), pace: Pace::default(),
}); });
+32 -1
View File
@@ -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 /// 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(super) struct Relief<'a> {
pub heights: &'a [f32], pub heights: &'a [f32],
pub slope: f32, pub slope: f32,
pub floor: 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 /// [`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; 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; let ng = g + price;
@@ -314,6 +320,16 @@ impl Sim {
// Where it starts is provisional: the site's pad, once the site is placed. // 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 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); 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 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 want = sites::want_index(&mut wants, centre, side, a, sites::SiteClass::of(kind), range, order.dist.max(120.0));
let pad = wants[want].jobs; let pad = wants[want].jobs;
@@ -445,6 +461,7 @@ impl Sim {
heights: &plan.heights, heights: &plan.heights,
slope: (kind.climb_pitch().tan() * 0.5) as f32, slope: (kind.climb_pitch().tan() * 0.5) as f32,
floor: (aim.y + spec.clearance) 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); let (r, cost) = plan_route_on(&plan.grid, &plan.prices[&kind], relief.as_ref(), from, aim);
work += cost; work += cost;
@@ -505,6 +522,18 @@ impl Sim {
self.plan.is_some() 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. /// 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 { pub(super) fn make_drone(&mut self, kind: DroneKind, from: DVec3, dir: DVec3, target: DVec3) -> Drone {
let id = self.id(); let id = self.id();
@@ -539,6 +568,8 @@ impl Sim {
smart: false, smart: false,
nav: Nav::default(), nav: Nav::default(),
err, err,
intent: ew::Intent::Plan,
aim: ew::Aim::point(0),
hold: 0, hold: 0,
pace: Pace::default(), pace: Pace::default(),
} }
+24 -13
View File
@@ -205,35 +205,45 @@ fn mass_retarget_finishes_its_route_jobs_over_the_budget() {
let pending = sim.retarget_ids(); let pending = sim.retarget_ids();
assert!(!pending.is_empty(), "the budget planned all 40 routes: the test no longer covers the queue"); assert!(!pending.is_empty(), "the budget planned all 40 routes: the test no longer covers the queue");
assert_eq!(pending.len(), pending.iter().collect::<std::collections::HashSet<_>>().len(), "duplicate jobs"); assert_eq!(pending.len(), pending.iter().collect::<std::collections::HashSet<_>>().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 { for id in &pending {
let d = &sim.queue.iter().find(|(_, d)| d.id == *id).unwrap().1; 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_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(); let mut left = pending.len();
for _ in 0..80 { for _ in 0..400 {
sim.tick += 1; sim.tick += 1;
sim.adapt(); sim.adapt();
assert!(sim.retargets.len() < left, "a budget tick made no progress: {left} jobs left"); if sim.tick % 40 == 0 {
left = sim.retargets.len(); // 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(); let still = sim.retarget_ids();
assert!( 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" "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 { 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.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] #[test]
@@ -370,6 +380,7 @@ fn same_relief_different_known_snapshots_do_not_share_prices_or_routes() {
seen: super::ew::Seen::Emission, seen: super::ew::Seen::Emission,
emission: Some(0), emission: Some(0),
destroyed: None, destroyed: None,
launches: 0,
}; };
let known_all = std::sync::Arc::new(vec![contact(threat, threat_at), contact(target, aim)]); 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)]); let known_target = std::sync::Arc::new(vec![contact(target, aim)]);
+123 -7
View File
@@ -449,9 +449,9 @@ Lancet, баллистика и decoy, как и раньше, летят пря
рельефом: два job с одинаковым рельефом, но разными known units, не делят price/route grid. За рельефом: два job с одинаковым рельефом, но разными known units, не делят price/route grid. За
пределами известной сетки — Unknown, новая карта не раскрывается. пределами известной сетки — Unknown, новая карта не раскрывается.
Очередь — рантайм-состояние планировщика. Дроны и очередь запуска в текущий `state_text` не входят, Очередь этапа 1 была рантайм-состоянием планировщика: дроны и очередь запуска в `state_text` не
поэтому формат save не менялся и старые saves читаются как прежде; запись replay по-прежнему хранит входили, поэтому durable-очередью она не была. Этап 2 (ниже) сохраняет её вместе с дронами, очередью
траектории, а не задания. Регрессии: `mass_retarget_finishes_its_route_jobs_over_the_budget` запуска, картиной и версиями рельефа. Регрессии этапа 1 остаются: `mass_retarget_finishes_its_route_jobs_over_the_budget`
(40+ ретаргетов, прогресс каждый бюджетный тик, старый маршрут до нового, нет пустых маршрутов) (40+ ретаргетов, прогресс каждый бюджетный тик, старый маршрут до нового, нет пустых маршрутов)
и `mass_retarget_is_deterministic_and_never_duplicates_a_drone` (тот же seed — те же цели и и `mass_retarget_is_deterministic_and_never_duplicates_a_drone` (тот же seed — те же цели и
маршруты; повторный `adapt` не создаёт дублей). маршруты; повторный `adapt` не создаёт дублей).
@@ -492,8 +492,8 @@ launcher-only: без донорского контакта такая ПУ в
статистикой секторов (`best_bearing` по kills/hits): новых всеведущих данных и усиления статистикой секторов (`best_bearing` по kills/hits): новых всеведущих данных и усиления
характеристик нет. характеристик нет.
Форматы не изменились: intel — рантайм (save хранит Layout, replay — траектории), durable-очередь Форматы этапа 1 не менялись: intel оставался рантаймом (save хранил Layout, replay — траектории);
по-прежнему не делается. Измерения до/после на одних seed: `docs/measurements/issue-127-enemy-picture-baseline.json` 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`. (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, 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) — фиксируется как факт перехода на честную картину, 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`, (донор любой роли; разрыв связи наблюдённый/нет), `destruction_moves_the_picture_only_when_watched`,
`confidence_is_a_function_of_age_alone`, `enemy_picture_report_describes_observation_and_belief`, `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 `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, и обе регрессии очереди. Остаток #127 после этапа 1: durable save queue, structured intent, GUI.
GUI поверх `enemy_picture()`, оценка баланса ew-adaptive.
### #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 <номер> <minX> <minZ> <клетка> <сторона> <RLE: u<n> | <высота>x<n> ...>
aircontact <id> <тип> <x> <y> <z> <такт наблюдения> <канал> <эмиссия|-> <гибель|-> <пусков>
airdrone <id> <тип> ... <полёт> <намерение> <юнит> <since> <смены> <уверенность> ... <маршрут: n x y z ...|->
airqueue <такт запуска> <то же тело дрона>
airjob <id> <дрон> <x> <y> <z> <юнит> <версия> <приоритет> <намерение> <рельеф|->
```
Секция пишется только автосейвом: сценарий — это стартовое состояние, его тело её не несёт
(`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 без секции, сценарий без воздуха).
## Что следующему агенту проверить в первую очередь ## Что следующему агенту проверить в первую очередь
+7 -6
View File
@@ -51,13 +51,14 @@
| #120 | Конечное горение, powered/coast, сопротивление и снижение доступного манёвра, один остаточный pk по фактической скорости; swept-встреча; события burnout/energy loss; согласованное прекращение факела/дыма/звука | Игровой профиль, без аэродинамики и реальных параметров; сравнительная выборка 12 рейдов и расширенная сетка 48 рейдов (6 вариантов × 8 seed, батареи/дальности/классы) записаны в measurements; без подтверждённого дефекта параметры не менялись; прослушивание остаётся | | #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 | | #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 не измерялись | | #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 (карта контактов, По #127 реализованы карта контактов, структурированное намерение с throttle и durable-очередь:
уверенность по возрасту, угроза по наблюдённым параметрам, BSA-канал уничтожения, диагностика autosave хранит дронов, очередь запуска, задания с картиной и рельефом своей версии, поэтому
`enemy_picture()`). Очередь и картина не сохраняются в autosave: текущий формат вообще не хранит незавершённый ретаргет продолжается после перезахода. Старые saves без секции `air` читаются как
дронов, очередь запуска и intel, поэтому старые saves и replay читаются как прежде и миграция не прежде (пустое небо), сценарии воздушное состояние не несут, replay по-прежнему хранит траектории.
нужна. Ракеты-перехватчики, площадки запуска, промежуточный план, статистика волны и RNG не сохраняются —
это ограничение записано в handoff.
Точные коммиты, формат сохранения, тесты и порядок продолжения: Точные коммиты, формат сохранения, тесты и порядок продолжения:
[CODEX-HANDOFF.md](CODEX-HANDOFF.md). Формальный open-статус не меняется после реализации. [CODEX-HANDOFF.md](CODEX-HANDOFF.md). Формальный open-статус не меняется после реализации.
@@ -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 потерь трека"
}
]
}
@@ -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 потерь трека"
}
]
}