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<KnownRelief> 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.
This commit is contained in:
2026-11-06 12:50:09 +03:00
parent 6f7b041c8b
commit a47812aadd
6 changed files with 162 additions and 41 deletions
+68 -33
View File
@@ -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<Vec<(u32, DVec3)>>,
/// 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<Arc<KnownRelief>>,
}
/// 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<f32>,
}
impl KnownRelief {
pub(super) fn new(grid: raid::Grid, heights: Vec<f32>) -> 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<Vec<f32>> {
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<RouteJob> = 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<Vec<f32>> {
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<Vec<f32>> {
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<DroneKind, Vec<f32>> = HashMap::new();
let mut routes: HashMap<(DroneKind, (i32, i32), (i32, i32)), Vec<DVec3>> = 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<usize>, Option<Vec<f32>>> = HashMap::new();
let mut prices: HashMap<(DroneKind, Option<usize>), Vec<f32>> = HashMap::new();
let mut routes: HashMap<(DroneKind, Option<usize>, (i32, i32), (i32, i32)), Vec<DVec3>> = 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<u32> {
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()
}
}
+4 -3
View File
@@ -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<ew::RouteJob>,
/// 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<f32>)>,
/// 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<Arc<ew::KnownRelief>>,
/// 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).
+1 -1
View File
@@ -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,
+83
View File
@@ -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<super::ew::KnownRelief> {
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<super::ew::KnownRelief> {
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<DVec3> {
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<Vec<f32>>| v.map(|x| x.into_iter().map(f32::to_bits).collect::<Vec<_>>());
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() {
+5 -3
View File
@@ -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 по-прежнему хранит
+1 -1
View File
@@ -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<KnownRelief>` в job), поэтому поздняя умная волна не переписывает ожидающий маршрут; цены и кэш маршрутов ключуются версией. Регрессии: 40+ ретаргетов, продвижение каждый бюджетный тик, отсутствие вечных пустых маршрутов, детерминизм повторного seed, отсутствие дублей и сохранение рельефа pending job после замены `known_relief` | Полный #127 (EnemyPicture, общая модель возможностей батареи/связи, честное разделение наблюдения и истины, структурированное намерение, объяснимость) не реализован. Рельеф повторного плана — выборка из сетки версии задания; вне её Unknown. Очередь — рантайм-состояние планировщика: drones/queue не входят в текущий формат save, поэтому старые saves не меняются; отдельная durable-очередь потребовала бы нового формата |
По #127 реализована только устойчивая очередь маршрутов, а не полный «EnemyPicture». Очередь не
сохраняется в autosave: текущий формат вообще не хранит дронов и очередь запуска, поэтому изменение