From a47812aadd7aa7b87e5637ca2bcc901e2254785f Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 6 Nov 2026 12:50:09 +0300 Subject: [PATCH] fixup! Keep unfinished retarget routes in a bounded job queue (#127) The relief snapshot was still global: a smart plan finishing while a retarget job waited could replace Sim::known_relief, so the pending route was planned on the newer map. RouteJob now carries Arc for its knowledge version; Sim::known_relief keeps the same Arc only to seed new jobs. retarget_relief reads the job's snapshot, and relief sampling, price grids and route caches are keyed by that version. The new regression makes a job wait, swaps the global relief for another grid with a wall, and checks the pending route and sample stay those of the original version; outside its grid is Unknown. Core release, adapter and WASM contract pass. --- airdef-mod/src/sim/ew.rs | 101 +++++++++++++++++++++++----------- airdef-mod/src/sim/mod.rs | 7 ++- airdef-mod/src/sim/raid.rs | 2 +- airdef-mod/src/sim/regress.rs | 83 ++++++++++++++++++++++++++++ docs/CODEX-HANDOFF.md | 8 ++- docs/IMPLEMENTATION-STATUS.md | 2 +- 6 files changed, 162 insertions(+), 41 deletions(-) diff --git a/airdef-mod/src/sim/ew.rs b/airdef-mod/src/sim/ew.rs index 6ac7780..5033b7f 100644 --- a/airdef-mod/src/sim/ew.rs +++ b/airdef-mod/src/sim/ew.rs @@ -25,6 +25,47 @@ pub(super) struct RouteJob { version: u64, /// The enemy picture that tick knew, shared by every job of the tick. known: Arc>, + /// The land that tick's plan knew, shared by every job of the version. A later plan replacing the + /// global snapshot must not change a pending job's relief (#127). + relief: Option>, +} + +/// The known land of a smart plan (#127): the grid it was mapped on and its cell heights (NaN unknown). +/// Every retarget job of that knowledge version shares one. +pub(super) struct KnownRelief { + grid: raid::Grid, + heights: Vec, +} + +impl KnownRelief { + pub(super) fn new(grid: raid::Grid, heights: Vec) -> Self { + Self { grid, heights } + } + + /// This land sampled on `grid`: the height of the nearest cell, NaN where this plan knew nothing. + /// None when no sampled cell was known at all. + pub(super) fn sampled_on(&self, grid: &raid::Grid) -> Option> { + let src = &self.grid; + let n = grid.size; + let mut out = vec![f32::NAN; (n * n) as usize]; + let (x1, z1) = (src.min.x + src.size as f64 * src.cell, src.min.z + src.size as f64 * src.cell); + let mut any = false; + for i in 0..n { + for j in 0..n { + let p = grid.centre((i, j), 0.0); + if p.x < src.min.x || p.x >= x1 || p.z < src.min.z || p.z >= z1 { + continue; + } + let (si, sj) = src.index(p); + let h = self.heights[(si * src.size + sj) as usize]; + if h.is_finite() { + out[(i * n + j) as usize] = h; + any = true; + } + } + } + any.then_some(out) + } } /// How far a station jams. @@ -193,6 +234,9 @@ impl Sim { if known.is_empty() { return; } + // Every job of this adaptive tick plans over the same relief version, even if another smart + // plan finishes before the job gets its budget (#127). + let relief = self.known_relief.clone(); let pick = |i: usize, list: &[(u32, DVec3)]| list[i % list.len()]; // The drones sent at a new target: a route job each, to be worked out as the planner's budget allows. let mut jobs: Vec = Vec::new(); @@ -210,7 +254,7 @@ impl Sim { let (unit, at) = if !ew.is_empty() { pick(n, &ew) } else { pick(n, &known) }; d.target = at + d.err; if Self::route_plannable(d) { - jobs.push(RouteJob { drone: d.id, aim: d.target, unit, version: self.tick, known: Arc::clone(&known) }); + jobs.push(RouteJob { drone: d.id, aim: d.target, unit, version: self.tick, known: Arc::clone(&known), relief: relief.clone() }); } else { // Nothing will be replanned for it: its old route leads to the old aim, so it goes straight. d.route.clear(); @@ -229,7 +273,7 @@ impl Sim { if let Some(&(unit, at)) = next { d.target = at + d.err; if plannable { - jobs.push(RouteJob { drone: d.id, aim: d.target, unit, version: self.tick, known: Arc::clone(&known) }); + jobs.push(RouteJob { drone: d.id, aim: d.target, unit, version: self.tick, known: Arc::clone(&known), relief: relief.clone() }); } else { d.route.clear(); } @@ -295,29 +339,10 @@ impl Sim { job.unit == 0 || !self.sams.iter().any(|s| s.id == job.unit && !s.alive()) } - /// The known land of the original plan, sampled on `grid` (NaN where that plan knew nothing). A retarget - /// is planned over the same relief as the raid's own routes, never over ground mapped after it (#127). - fn retarget_relief(&self, grid: &raid::Grid) -> Option> { - let (src, heights) = self.known_relief.as_ref()?; - let n = grid.size; - let mut out = vec![f32::NAN; (n * n) as usize]; - let (x1, z1) = (src.min.x + src.size as f64 * src.cell, src.min.z + src.size as f64 * src.cell); - let mut any = false; - for i in 0..n { - for j in 0..n { - let p = grid.centre((i, j), 0.0); - if p.x < src.min.x || p.x >= x1 || p.z < src.min.z || p.z >= z1 { - continue; - } - let (si, sj) = src.index(p); - let h = heights[(si * src.size + sj) as usize]; - if h.is_finite() { - out[(i * n + j) as usize] = h; - any = true; - } - } - } - any.then_some(out) + /// The land this job's knowledge version knew, sampled on `grid` (NaN outside it). It reads the job's + /// own snapshot: a later smart plan replacing `Sim::known_relief` must not change a pending job (#127). + fn retarget_relief(job: &RouteJob, grid: &raid::Grid) -> Option> { + job.relief.as_deref()?.sampled_on(grid) } /// Works the retarget queue: one grid and one set of prices cover the jobs, and the work is the @@ -354,10 +379,11 @@ impl Sim { let cell = raid::CELL.max(2.0 * span / raid::CELLS as f64); let centre = (lo + hi) / 2.0; let grid = raid::Grid { min: centre - DVec3::new(span, 0.0, span), cell, size: (2.0 * span / cell) as i32 + 1 }; - let relief = self.retarget_relief(&grid); - let land = relief.as_deref(); - let mut prices: HashMap> = HashMap::new(); - let mut routes: HashMap<(DroneKind, (i32, i32), (i32, i32)), Vec> = HashMap::new(); + // Jobs may carry different relief versions; sample each only once and keep prices and routes per + // version, so a pending job is planned on the land its own knowledge version knew (#127). + let mut reliefs: HashMap, Option>> = HashMap::new(); + let mut prices: HashMap<(DroneKind, Option), Vec> = HashMap::new(); + let mut routes: HashMap<(DroneKind, Option, (i32, i32), (i32, i32)), Vec> = HashMap::new(); let mut work = 0usize; while let Some(job) = jobs.front().cloned() { if work >= raid::WORK_PER_TICK { @@ -372,21 +398,24 @@ impl Sim { continue; }; let (pos, kind) = (d.pos, d.kind); - let key = (kind, grid.index(pos), grid.index(job.aim)); + let version = job.relief.as_ref().map(|r| Arc::as_ptr(r) as usize); + let land = reliefs.entry(version).or_insert_with(|| Self::retarget_relief(&job, &grid)); + let land = land.as_deref(); + let key = (kind, version, grid.index(pos), grid.index(job.aim)); let route = match routes.get(&key) { Some(r) => r.clone(), None => { - if !prices.contains_key(&kind) { + if !prices.contains_key(&(kind, version)) { let (p, cost) = self.price_grid_known(&grid, kind, land, job.known.as_slice()); work += cost; - prices.insert(kind, p); + prices.insert((kind, version), p); } let relief = land.map(|heights| raid::Relief { heights, slope: (kind.climb_pitch().tan() * 0.5) as f32, floor: (job.aim.y + kind.spec().clearance) as f32, }); - let (r, cost) = raid::plan_route_on(&grid, &prices[&kind], relief.as_ref(), pos, job.aim); + let (r, cost) = raid::plan_route_on(&grid, &prices[&(kind, version)], relief.as_ref(), pos, job.aim); work += cost; routes.insert(key, r.clone()); r @@ -405,4 +434,10 @@ impl Sim { pub(super) fn retarget_ids(&self) -> Vec { self.retargets.iter().map(|j| j.drone).collect() } + + /// The relief a pending job carries (tests and diagnostics). + #[cfg(test)] + pub(super) fn pending_relief(&self, id: u32) -> Option<&KnownRelief> { + self.retargets.iter().find(|j| j.drone == id)?.relief.as_deref() + } } diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index 5dfa70b..191ec3f 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -8,6 +8,7 @@ //! parts are drawn (`Sim::draw_sam`). use glam::{DQuat, DVec3}; use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; pub mod battery; pub mod launch; @@ -1305,9 +1306,9 @@ pub struct Sim { /// Routes still to work out after a mass retarget (#127): the drones were given a new aim, but the /// planner's share of a tick ran out. They keep their old route until a new one is ready. retargets: VecDeque, - /// The known land of the last smart raid's plan: its grid and cell heights (NaN unknown). A retarget - /// is planned over the same knowledge, never over land mapped after it (#127). - known_relief: Option<(raid::Grid, Vec)>, + /// The known land of the last smart raid's plan: its grid and cell heights (NaN unknown). New retarget + /// jobs take this `Arc` as their version; a later plan replaces the pointer, not a pending job's map (#127). + known_relief: Option>, /// The vehicles' ground routes, and the jobs the trucks found out of reach (#94). routes: route::Routes, /// Each marked object's health, 0 to 100 (`targets`' order). diff --git a/airdef-mod/src/sim/raid.rs b/airdef-mod/src/sim/raid.rs index 7a9e2f1..35acd1b 100644 --- a/airdef-mod/src/sim/raid.rs +++ b/airdef-mod/src/sim/raid.rs @@ -483,7 +483,7 @@ impl Sim { // Keep the land this plan was worked out over (#127): a retarget later is planned over the same // known relief, not over ground mapped since. if plan.smart && !plan.heights.is_empty() { - self.known_relief = Some((plan.grid, std::mem::take(&mut plan.heights))); + self.known_relief = Some(std::sync::Arc::new(ew::KnownRelief::new(plan.grid, std::mem::take(&mut plan.heights)))); } self.wave = Some(WaveStats { kills: self.kills, diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index 7f86b6b..94592b5 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -263,6 +263,89 @@ fn mass_retarget_is_deterministic_and_never_duplicates_a_drone() { } } +#[cfg(test)] +fn relief_grid() -> Grid { + Grid { min: DVec3::new(-2560.0, 0.0, -2560.0), cell: 32.0, size: 160 } +} + +#[cfg(test)] +fn flat_relief() -> std::sync::Arc { + let g = relief_grid(); + std::sync::Arc::new(super::ew::KnownRelief::new(g, vec![70.0; (g.size * g.size) as usize])) +} + +#[cfg(test)] +fn wall_relief() -> std::sync::Arc { + let g = relief_grid(); + let mut h = vec![70.0; (g.size * g.size) as usize]; + for i in 0..g.size { + for j in 0..g.size { + let x = g.min.x + (f64::from(i) + 0.5) * g.cell; + let z = g.min.z + (f64::from(j) + 0.5) * g.cell; + if (x + 1000.0).abs() < 80.0 && z.abs() < 420.0 { + h[(i * g.size + j) as usize] = 1000.0; + } + } + } + std::sync::Arc::new(super::ew::KnownRelief::new(g, h)) +} + +#[cfg(test)] +fn finish_retargets(sim: &mut Sim) { + for _ in 0..200 { + if sim.retargets.is_empty() { + return; + } + sim.tick += 1; + sim.adapt(); + } + panic!("the retarget queue did not finish"); +} + +#[cfg(test)] +fn retarget_route(sim: &Sim, id: u32) -> Vec { + sim.queue.iter().find(|(_, d)| d.id == id).unwrap().1.route.clone() +} + +#[test] +fn pending_retarget_keeps_its_relief_version_after_a_new_plan() { + let flat = flat_relief(); + let wall = wall_relief(); + let outside = Grid { min: DVec3::new(3000.0, 0.0, 3000.0), cell: 32.0, size: 8 }; + assert!(flat.sampled_on(&outside).is_none(), "land outside the plan must be Unknown"); + let edge = Grid { min: DVec3::new(2500.0, 0.0, 2500.0), cell: 32.0, size: 8 }; + let edge_flat = flat.sampled_on(&edge).unwrap(); + assert!(edge_flat.iter().any(|h| h.is_nan()) && edge_flat.iter().any(|h| *h == 70.0)); + + let prepare = |relief| { + let (mut sim, _, _) = mass_retarget_sim(7); + sim.known_relief = Some(relief); + sim.tick = 40; + sim.adapt(); + sim + }; + let mut control = prepare(flat.clone()); + let mut test = prepare(flat.clone()); + let id = *test.retarget_ids().last().unwrap(); + assert!(test.pending_relief(id).is_some(), "the chosen job is not waiting"); + // Another smart plan finishes while the job waits: the global snapshot is the wall now. + test.known_relief = Some(wall.clone()); + let bits = |v: Option>| v.map(|x| x.into_iter().map(f32::to_bits).collect::>()); + assert_eq!( + bits(test.pending_relief(id).unwrap().sampled_on(&edge)), + bits(flat.sampled_on(&edge)), + "a pending job's relief changed with the global snapshot" + ); + + finish_retargets(&mut control); + finish_retargets(&mut test); + assert_eq!(retarget_route(&control, id), retarget_route(&test, id), "the pending route used the newer relief"); + + let mut walled = prepare(wall); + finish_retargets(&mut walled); + assert_ne!(retarget_route(&control, id), retarget_route(&walled, id), "the test does not distinguish the relief versions"); +} + // #31: a big cache of fresh columns is not scanned on every answer #[test] fn big_ground_cache_is_swept_rarely() { diff --git a/docs/CODEX-HANDOFF.md b/docs/CODEX-HANDOFF.md index 1d6d02f..4ef4ec7 100644 --- a/docs/CODEX-HANDOFF.md +++ b/docs/CODEX-HANDOFF.md @@ -381,9 +381,11 @@ ID цели-юнита, версией знаний и общим для тик Lancet, баллистика и decoy, как и раньше, летят прямо: для них задание не создаётся. В полёте следующая известная цель берётся только если до неё есть разворот; цель сзади внутри радиуса разворота пропускается, и дрон завершает текущий заход, а не получает невыполнимый маршрут. -Повторный план использует снимок разведки исходного выбора цели и известный рельеф последнего умного плана -(`Sim::known_relief`, выборка по сетке); за пределами известной сетки — Unknown, новая карта не -раскрывается. +Повторный план использует снимок разведки исходного выбора цели и известный рельеф **той же версии +задания**: `KnownRelief` (сетка и высоты) лежит в `Arc` внутри `RouteJob`, `Sim::known_relief` хранит +тот же `Arc` только для новых заданий. Завершение другой умной волны заменяет глобальный указатель, +но не снимок ожидающего job; цены и кэш маршрутов в `replan_retargets` ключуются версией рельефа. +За пределами известной сетки — Unknown, новая карта не раскрывается. Очередь — рантайм-состояние планировщика. Дроны и очередь запуска в текущий `state_text` не входят, поэтому формат save не менялся и старые saves читаются как прежде; запись replay по-прежнему хранит diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index a4d209f..376e485 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -50,7 +50,7 @@ | #120 | Конечное горение, powered/coast, сопротивление и снижение доступного манёвра, один остаточный pk по фактической скорости; swept-встреча; события burnout/energy loss; согласованное прекращение факела/дыма/звука | Игровой профиль, без аэродинамики и реальных параметров; сравнительная выборка 12 рейдов и расширенная сетка 48 рейдов (6 вариантов × 8 seed, батареи/дальности/классы) записаны в measurements; без подтверждённого дефекта параметры не менялись; прослушивание остаётся | | #121 | On/Off в команде и карте, save/replay; Off не создаёт own measurements и сохраняет сетевой приём; раскрытие работающего радара наблюдателем; SEAD использует last-known и возраст | Условный наблюдатель по расстоянию, без радиотехнической модели и ошибки пеленга; выбор на карте ещё не проверен в GUI | | #122 | Воспроизведены два класса: tick fuel exhaustion на двух максимальных составах, HD (полный обход height-cache на каждом взрыве заменён удалением нужных ключей; max135.3M/300M) и остановка в UI event на втором крупном приказе (переснимок known_units на каждом дроне заменён одним снимком на приказ и O(журнал+группировка); max command 32.9M/1e9, `/stats` до/после совпал); нативные тесты эквивалентности и второго приказа; WASM fixture с 4 принятыми приказами; callback diagnostics/profile | Требуется приёмка на Windows; без подтверждённого build identity нельзя утверждать ту же причину пользовательского сеанса; FPS/GPU не измерялись | -| #127 | Узкая часть: массовое перенацеливание не теряет необработанные маршруты. Задания (ID дрона, новая цель, версия знаний) живут в FIFO-очереди между тиками; бюджет `WORK_PER_TICK` тот же, задания снимаются/заменяются без роста очереди, старый безопасный маршрут остаётся до готовности нового, а при отмене задания используется прямой полёт как fallback. Новый маршрут считается по тому же снимку разведки и известному рельефу исходного плана. Регрессия: 40+ ретаргетов, продвижение каждый бюджетный тик, отсутствие вечных пустых маршрутов, детерминизм повторного seed и отсутствие дублей | Полный #127 (EnemyPicture, общая модель возможностей батареи/связи, честное разделение наблюдения и истины, структурированное намерение, объяснимость) не реализован. Рельеф повторного плана — выборка из сетки последнего умного плана; вне её Unknown. Очередь — рантайм-состояние планировщика: drones/queue не входят в текущий формат save, поэтому старые saves не меняются; отдельная durable-очередь потребовала бы нового формата | +| #127 | Узкая часть: массовое перенацеливание не теряет необработанные маршруты. Задания (ID дрона, новая цель, версия знаний) живут в FIFO-очереди между тиками; бюджет `WORK_PER_TICK` тот же, задания снимаются/заменяются без роста очереди, старый безопасный маршрут остаётся до готовности нового, а при отмене задания используется прямой полёт как fallback. Снимок рельефа принадлежит версии задания (`Arc` в job), поэтому поздняя умная волна не переписывает ожидающий маршрут; цены и кэш маршрутов ключуются версией. Регрессии: 40+ ретаргетов, продвижение каждый бюджетный тик, отсутствие вечных пустых маршрутов, детерминизм повторного seed, отсутствие дублей и сохранение рельефа pending job после замены `known_relief` | Полный #127 (EnemyPicture, общая модель возможностей батареи/связи, честное разделение наблюдения и истины, структурированное намерение, объяснимость) не реализован. Рельеф повторного плана — выборка из сетки версии задания; вне её Unknown. Очередь — рантайм-состояние планировщика: drones/queue не входят в текущий формат save, поэтому старые saves не меняются; отдельная durable-очередь потребовала бы нового формата | По #127 реализована только устойчивая очередь маршрутов, а не полный «EnemyPicture». Очередь не сохраняется в autosave: текущий формат вообще не хранит дронов и очередь запуска, поэтому изменение