Keep unfinished retarget routes in a bounded job queue (#127)

A mass retarget used to clear every selected drone's route before the planner's per-tick work ran out: the rest flew straight for ever. Route jobs now carry the drone id, new aim, target unit, knowledge version and the intel snapshot, stay in a FIFO across ticks and are drained under the same WORK_PER_TICK. An old safe route is kept until its replacement is ready; a cancelled job falls back to a straight run. Jobs are replaced in place per drone, cancelled when the drone or unit is gone, and retarget routes reuse the original plan's known relief. An in-flight drone no longer turns to a unit behind its turning circle. Core release tests, adapter tests and the real WASM contract pass.
This commit is contained in:
2026-11-06 12:05:36 +03:00
parent dc36d67eb7
commit 6f7b041c8b
7 changed files with 369 additions and 57 deletions
+3 -1
View File
@@ -133,7 +133,9 @@ sandbox-сценария, `/airdef ground-load <С-400>` загружает од
**Умный противник учится.** Он запоминает, откуда его налёты сбивали, а откуда они прорывались, и следующий
налёт ведёт с лучшего направления; помнит комплексы, выдавшие себя пуском, и станции РЭБ, и во время налёта
перенацеливает ещё не взлетевшие Ланцеты и Шахеды на них; если цель-комплекс уничтожен, дроны идут к
следующему известному. Дроны под обстрелом прижимаются к земле.
следующему известному. Маршруты к новым целям достраиваются по бюджету планировщика в следующих тиках:
необработанное задание не теряется, старый безопасный участок остаётся до готовности нового, а дрон без
маршрута летит прямо только до ближайшего бюджета. Дроны под обстрелом прижимаются к земле.
**Ночь.** Исправные пушки с собственным свежим радарным сопровождением не теряют точность из-за темноты.
Цели освещают **прожекторы** (луч ведёт ближайшую
+190 -47
View File
@@ -8,7 +8,24 @@
//! stations jammed (its next drones go for them), and from which bearings its
//! raids were shot down or got through (its next raid comes from the best).
use super::*;
use std::collections::HashSet;
use std::collections::{HashSet, VecDeque};
use std::sync::Arc;
/// A route still to be worked out after a retarget (#127). The drone already has its new aim; the job
/// gives it a route as the planner's budget allows, and its old route stays until then.
#[derive(Clone)]
pub(super) struct RouteJob {
/// The drone, by id: queue indices move as launches happen.
drone: u32,
/// The new aim, the drone's navigation error included.
aim: DVec3,
/// The unit it was sent at (0 when the aim is not a unit).
unit: u32,
/// The adaptive tick whose picture chose the aim: the knowledge version.
version: u64,
/// The enemy picture that tick knew, shared by every job of the tick.
known: Arc<Vec<(u32, DVec3)>>,
}
/// How far a station jams.
pub const EW_RADIUS: f64 = 350.0;
@@ -157,23 +174,30 @@ impl Sim {
}
/// During a smart raid: its drones not yet launched go for what just gave itself away (a jamming station
/// first, then systems that fired), and drones whose system target is gone pick another.
/// first, then systems that fired), and drones whose system target is gone pick another. Routes for the
/// new aims are worked out over the following ticks, as the planner's share allows (#127).
pub(super) fn adapt(&mut self) {
if self.tick % 40 != 0 {
return;
if self.tick % 40 == 0 {
self.adapt_targets();
}
self.replan_retargets();
}
/// The adaptive tick: chooses the new aims and leaves a route job for each drone that needs one. The
/// old route stays until a new one is ready: a drone is never left with no way round the defences.
fn adapt_targets(&mut self) {
self.intel.revealed.retain(|r| self.tick - r.2 < 6000);
let known = self.known_units();
let known = Arc::new(self.known_units());
let ew: Vec<(u32, DVec3)> =
known.iter().copied().filter(|(id, _)| self.sams.iter().any(|s| s.id == *id && s.kind == SamKind::Ew)).collect();
if known.is_empty() {
return;
}
let pick = |i: usize, list: &[(u32, DVec3)]| list[i % list.len()].1;
// The drones sent at a new target: (waiting in the queue, index), to get a new route.
let mut retargeted: Vec<(bool, usize)> = Vec::new();
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();
let mut n = 0usize;
for (qi, (_, d)) in self.queue.iter_mut().enumerate().filter(|(_, (_, d))| d.smart) {
for (_, d) in self.queue.iter_mut().filter(|(_, d)| d.smart) {
let hunter = matches!(d.kind, DroneKind::Lancet | DroneKind::Kh22 | DroneKind::Zircon);
if !hunter && !(d.kind == DroneKind::Shahed && n % 4 == 0) {
n += 1;
@@ -183,50 +207,145 @@ impl Sim {
if known.iter().any(|(_, pos)| pos.distance(d.target - d.err) < 6.0) {
continue;
}
let to = if !ew.is_empty() { pick(n, &ew) } else { pick(n, &known) };
d.target = to + d.err;
d.route.clear();
retargeted.push((true, qi));
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) });
} else {
// Nothing will be replanned for it: its old route leads to the old aim, so it goes straight.
d.route.clear();
}
n += 1;
}
// In flight: a target system destroyed — the next known one, or keep going.
for (di, d) in self.drones.iter_mut().enumerate().filter(|(_, d)| d.alive() && d.smart) {
for d in self.drones.iter_mut().filter(|d| d.alive() && d.smart) {
let aimed_at_wreck = self.sams.iter().any(|s| !s.alive() && s.pos.distance(d.target - d.err) < 6.0);
if aimed_at_wreck {
let next = known.iter().min_by(|a, b| a.1.distance(d.pos).total_cmp(&b.1.distance(d.pos)));
if let Some((_, at)) = next {
d.target = *at + d.err;
d.route.clear();
retargeted.push((false, di));
let plannable = Self::route_plannable(d);
let next = known
.iter()
.filter(|k| !plannable || Self::can_turn_to(d, k.1))
.min_by(|a, b| a.1.distance(d.pos).total_cmp(&b.1.distance(d.pos)));
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) });
} else {
d.route.clear();
}
}
}
}
self.replan_routes(&retargeted);
for job in jobs {
self.queue_route(job);
}
}
/// Routes round the known defences for drones that were just sent at a new target, from where they are
/// (or wait). One grid and one set of prices cover them all, and the work is the planner's share for a
/// tick: those left over fly straight, as they did before there was a replan. A Lancet's strike on a
/// unit and the ballistic missiles go straight on purpose, as in the raid's own plan.
fn replan_routes(&mut self, jobs: &[(bool, usize)]) {
let drone = |s: &Sim, (queued, i): (bool, usize)| if queued { s.queue[i].1.clone() } else { s.drones[i].clone() };
let jobs: Vec<(bool, usize)> = jobs
.iter()
.copied()
.filter(|&j| {
let d = drone(self, j);
let spec = d.kind.spec();
d.smart && spec.ballistic.is_none() && !spec.decoy && d.kind != DroneKind::Lancet
})
.collect();
/// A kind whose route the replan works out: a Lancet's strike on a unit and the ballistic missiles go
/// straight on purpose, as in the raid's own plan.
fn route_plannable(d: &Drone) -> bool {
let spec = d.kind.spec();
d.smart && spec.ballistic.is_none() && !spec.decoy && d.kind != DroneKind::Lancet
}
/// Whether a drone can turn onto `at` from where it is. A slow missile with a close target behind
/// would only circle it: it finishes the approach it is on instead of turning to another unit (#127).
fn can_turn_to(d: &Drone, at: DVec3) -> bool {
let spec = d.kind.spec();
let radius = (spec.speed / spec.turn.max(0.01)).max(24.0);
let to = horizontal(at - d.pos);
if to.length() >= radius * 2.0 {
return true;
}
let heading = horizontal(d.vel).normalize_or_zero();
heading == DVec3::ZERO || heading.dot(to.normalize_or_zero()) > 0.0
}
/// Adds a job, replacing an older one for the same drone in place: a drone retargeted again before its
/// route was ready must not grow the queue or be planned for an aim it no longer has (#127).
fn queue_route(&mut self, job: RouteJob) {
if let Some(i) = self.retargets.iter().position(|j| j.drone == job.drone) {
if self.retargets[i].version <= job.version {
self.retargets[i] = job;
}
} else {
self.retargets.push_back(job);
}
}
/// The drone a job is for: waiting in the queue or already flying.
fn drone_of(&self, id: u32) -> Option<&Drone> {
self.queue.iter().find(|(_, d)| d.id == id).map(|(_, d)| d).or_else(|| self.drones.iter().find(|d| d.id == id))
}
fn drone_of_mut(&mut self, id: u32) -> Option<&mut Drone> {
if let Some(i) = self.queue.iter().position(|(_, d)| d.id == id) {
return Some(&mut self.queue[i].1);
}
self.drones.iter_mut().find(|d| d.id == id)
}
/// Whether a job still says what its drone is doing. A dead drone, an aim changed again, or a unit
/// destroyed since cancels the job: the next adaptive tick will make a new one if it is still wanted.
fn job_live(&self, job: &RouteJob) -> bool {
let Some(d) = self.drone_of(job.drone) else { return false };
if !d.alive() || d.target != job.aim {
return false;
}
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)
}
/// Works the retarget queue: one grid and one set of prices cover the jobs, and the work is the
/// planner's share for a tick. A job the budget cannot reach stays queued for the next tick, and its
/// drone keeps the old route (or, if it had none, flies straight for a while — never for ever).
fn replan_retargets(&mut self) {
if self.retargets.is_empty() {
return;
}
let mut jobs = std::mem::take(&mut self.retargets);
let mut live = VecDeque::with_capacity(jobs.len());
while let Some(job) = jobs.pop_front() {
if self.job_live(&job) {
live.push_back(job);
} else if let Some(d) = self.drone_of_mut(job.drone) && d.alive() {
// No new route will come from this job: the old route is a fallback, not a plan.
d.route.clear();
}
}
jobs = live;
if jobs.is_empty() {
return;
}
// One grid over all their starts and goals, with room round them to go by.
let (mut lo, mut hi) = (DVec3::splat(f64::INFINITY), DVec3::splat(f64::NEG_INFINITY));
for &j in &jobs {
let d = drone(self, j);
for p in [d.pos, d.target] {
for job in &jobs {
let Some(d) = self.drone_of(job.drone) else { continue };
for p in [d.pos, job.aim] {
lo = lo.min(p);
hi = hi.max(p);
}
@@ -235,31 +354,55 @@ 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();
let mut work = 0usize;
for j in jobs {
let d = drone(self, j);
while let Some(job) = jobs.front().cloned() {
if work >= raid::WORK_PER_TICK {
break;
}
let key = (d.kind, grid.index(d.pos), grid.index(d.target));
if !self.job_live(&job) {
jobs.pop_front();
continue;
}
let Some(d) = self.drone_of(job.drone) else {
jobs.pop_front();
continue;
};
let (pos, kind) = (d.pos, d.kind);
let key = (kind, grid.index(pos), grid.index(job.aim));
let route = match routes.get(&key) {
Some(r) => r.clone(),
None => {
if !prices.contains_key(&d.kind) {
let (p, cost) = self.price_grid(&grid, d.kind);
if !prices.contains_key(&kind) {
let (p, cost) = self.price_grid_known(&grid, kind, land, job.known.as_slice());
work += cost;
prices.insert(d.kind, p);
prices.insert(kind, p);
}
let (r, cost) = raid::plan_route(&grid, &prices[&d.kind], d.pos, d.target);
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);
work += cost;
routes.insert(key, r.clone());
r
}
};
let d = if j.0 { &mut self.queue[j.1].1 } else { &mut self.drones[j.1] };
d.route = route;
if let Some(d) = self.drone_of_mut(job.drone) {
d.route = route;
}
jobs.pop_front();
}
self.retargets = jobs;
}
/// The drones with a route job still waiting (tests and diagnostics).
#[cfg(test)]
pub(super) fn retarget_ids(&self) -> Vec<u32> {
self.retargets.iter().map(|j| j.drone).collect()
}
}
+11 -1
View File
@@ -7,7 +7,7 @@
//! (`DroneKind::spec`, `MissileKind::spec`, `SamKind::spec`) plus how its
//! parts are drawn (`Sim::draw_sam`).
use glam::{DQuat, DVec3};
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};
pub mod battery;
pub mod launch;
@@ -1302,6 +1302,12 @@ pub struct Sim {
pub strikes: strike::Strikes,
/// A raid being planned.
plan: Option<raid::Plan>,
/// 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 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).
@@ -1381,6 +1387,8 @@ impl Sim {
sites: sites::Sites::default(),
strikes: strike::Strikes::new(),
plan: None,
retargets: VecDeque::new(),
known_relief: None,
routes: Default::default(),
target_health: Vec::new(),
wave: None,
@@ -1490,6 +1498,8 @@ impl Sim {
self.queue.clear();
self.sites.clear();
self.plan = None;
self.retargets.clear();
self.known_relief = None;
self.wave = None;
// Ammunition still to cook off, flares in the sky.
self.later.clear();
+15 -7
View File
@@ -146,6 +146,7 @@ const LONGEST_WAIT: f64 = 12_000.0;
pub(super) const WORK_PER_TICK: usize = 40_000;
/// The planner's grid over a raid: its corner, its cells' size and how many a side.
#[derive(Clone, Copy)]
pub(super) struct Grid {
pub(super) min: DVec3,
pub(super) cell: f64,
@@ -157,16 +158,11 @@ impl Grid {
let n = self.size;
((((p.x - self.min.x) / self.cell) as i32).clamp(0, n - 1), (((p.z - self.min.z) / self.cell) as i32).clamp(0, n - 1))
}
fn centre(&self, (i, j): (i32, i32), y: f64) -> DVec3 {
pub(super) fn centre(&self, (i, j): (i32, i32), y: f64) -> DVec3 {
DVec3::new(self.min.x + (i as f64 + 0.5) * self.cell, y, self.min.z + (j as f64 + 0.5) * self.cell)
}
}
/// The cheapest way over `grid` from `from` to `to` by its prices (A*): points along it, `to` last.
pub(super) fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> (Vec<DVec3>, usize) {
plan_route_on(grid, prices, None, from, to)
}
/// 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.
pub(super) struct Relief<'a> {
@@ -484,6 +480,11 @@ impl Sim {
}
self.queue.push((now + wait.clamp(0.0, LONGEST_WAIT) as u64, d));
}
// 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.wave = Some(WaveStats {
kills: self.kills,
hits: self.hits,
@@ -619,6 +620,7 @@ impl Sim {
/// The price of each cell of `grid` for this kind flying low: 1, and much more where a
/// system sees it there (its radar's reach for it, the land) and can shoot it.
#[cfg(test)]
pub(super) fn price_grid(&self, grid: &Grid, kind: DroneKind) -> (Vec<f32>, usize) {
self.price_grid_on(grid, kind, None)
}
@@ -626,10 +628,16 @@ impl Sim {
/// The same over known land: a cell's drone flies `height` over the land there, not over the system's feet,
/// so what a radar sees and what a hill hides follow the relief.
pub(super) fn price_grid_on(&self, grid: &Grid, kind: DroneKind, land: Option<&[f32]>) -> (Vec<f32>, usize) {
let known_now = self.known_units();
self.price_grid_known(grid, kind, land, &known_now)
}
/// The same over a picture of the enemy: a retarget is priced on the picture its target was chosen
/// from, so the jobs of one adaptive tick do not each see a different world (#127).
pub(super) fn price_grid_known(&self, grid: &Grid, kind: DroneKind, land: Option<&[f32]>, known_now: &[(u32, DVec3)]) -> (Vec<f32>, usize) {
let mut cost = 0;
let spec = kind.spec();
let height = spec.clearance.max(6.0);
let known_now = self.known_units();
let n = grid.size;
let mut prices = vec![1.0f32; (n * n) as usize];
// Known jamming stations: drones that navigate by satellite keep away.
+116
View File
@@ -147,6 +147,122 @@ fn retargeted_smart_drone_gets_a_route_round_the_defences() {
assert!(d.route.len() > 1, "no route after the new target: it flies straight through the defences");
}
// #127: a mass retarget works its route jobs out over the following ticks. No drone is left without a
// route by the budget, old safe routes are kept until their replacements are ready, and the queue never
// grows or repeats a drone.
#[cfg(test)]
fn mass_retarget_sim(seed: u64) -> (Sim, HashMap<u32, Vec<DVec3>>, u32) {
let mut sim = Sim::new(seed);
for i in 0..8 {
for j in 0..5 {
let at = DVec3::new(-560.0 + i as f64 * 160.0, 70.0, -320.0 + j as f64 * 160.0);
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::S400, at, 0.0));
sim.intel.revealed.push((id, at, 0));
}
}
let mut old = HashMap::new();
let mut empty = 0;
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)];
// The last drone starts with no route at all (a drone whose route was lost): its job must wait its
// budget, not be dropped, and must give it a route in the end.
if i == 39 {
d.route.clear();
empty = d.id;
}
old.insert(d.id, d.route.clone());
sim.queue.push((0, d));
}
(sim, old, empty)
}
#[cfg(test)]
fn flight_state(sim: &Sim) -> Vec<(u32, u64, u64, u64, Vec<(u64, u64, u64)>)> {
sim.queue
.iter()
.map(|(_, d)| {
let route = d.route.iter().map(|p| (p.x.to_bits(), p.y.to_bits(), p.z.to_bits())).collect();
(d.id, d.target.x.to_bits(), d.target.y.to_bits(), d.target.z.to_bits(), route)
})
.collect()
}
#[test]
fn mass_retarget_finishes_its_route_jobs_over_the_budget() {
let (mut sim, old, empty) = mass_retarget_sim(7);
sim.tick = 40;
sim.adapt();
let pending = sim.retarget_ids();
assert!(!pending.is_empty(), "the budget planned all 40 routes: the test no longer covers the queue");
assert_eq!(pending.len(), pending.iter().collect::<std::collections::HashSet<_>>().len(), "duplicate jobs");
assert!(pending.contains(&empty), "a drone left with no route by the budget was dropped from the queue");
for id in &pending {
let d = &sim.queue.iter().find(|(_, d)| d.id == *id).unwrap().1;
assert_eq!(&d.route, old.get(id).unwrap(), "an old safe route was replaced before the new one was ready");
}
assert!(
sim.queue.iter().all(|(_, d)| !d.route.is_empty() || d.id == empty),
"a route was wiped by the budget"
);
let mut left = pending.len();
for _ in 0..80 {
sim.tick += 1;
sim.adapt();
assert!(sim.retargets.len() < left, "a budget tick made no progress: {left} jobs left");
left = sim.retargets.len();
let still = sim.retarget_ids();
assert!(
sim.queue.iter().all(|(_, d)| !d.route.is_empty() || still.contains(&d.id)),
"a route is still empty with no job left to fill it"
);
if left == 0 {
break;
}
}
assert_eq!(left, 0, "route jobs were never finished");
for (_, d) in &sim.queue {
assert!(d.route.last().is_some_and(|p| p.distance(d.target) < 1e-6), "the new route does not end at the new aim");
}
}
#[test]
fn mass_retarget_is_deterministic_and_never_duplicates_a_drone() {
let (mut a, _, _) = mass_retarget_sim(11);
let (mut b, _, _) = mass_retarget_sim(11);
a.tick = 40;
b.tick = 40;
a.adapt();
b.adapt();
assert_eq!(a.retarget_ids(), b.retarget_ids(), "the same seed left a different queue");
assert_eq!(flight_state(&a), flight_state(&b), "the same seed planned different routes");
for _ in 0..80 {
a.adapt();
b.adapt();
let ids = a.retarget_ids();
assert_eq!(ids.len(), ids.iter().collect::<std::collections::HashSet<_>>().len(), "a drone has two jobs");
assert_eq!(a.retarget_ids(), b.retarget_ids(), "the queues drifted apart");
assert_eq!(flight_state(&a), flight_state(&b), "the routes drifted apart");
if ids.is_empty() {
break;
}
a.tick += 1;
b.tick += 1;
// Another adaptive tick while the queue is still being worked: a known aim must not enqueue again.
if a.tick % 40 == 0 {
a.adapt();
b.adapt();
let ids = a.retarget_ids();
assert_eq!(ids.len(), ids.iter().collect::<std::collections::HashSet<_>>().len(), "a repeat adapt duplicated a job");
}
}
}
// #31: a big cache of fresh columns is not scanned on every answer
#[test]
fn big_ground_cache_is_swept_rarely() {
+27
View File
@@ -366,6 +366,33 @@ Unknown, stale и слишком старые секции считаются **
Unknown и поздний ответ без повторного урона/запросов; прямое попадание; устаревшая геометрия;
пропуск исходного блока. Все четыре прошли в объединённом наборе.
### #127: устойчивая очередь маршрутов после массового перенацеливания
Реализована только узкая часть issue: `sim::ew::RouteJob` — задание с ID дрона, новой целью,
ID цели-юнита, версией знаний и общим для тика снимком `known_units` (`Arc`). `adapt` по-прежнему
выбирает новые цели раз в 40 тиков, но маршруты теперь достраивает `replan_retargets` **каждый
тик**: одно задание или несколько, пока не исчерпан `WORK_PER_TICK`. Незавершённое задание
остаётся в `Sim::retargets` (FIFO) и не теряется; повторный выбор того же дрона заменяет задание
на месте, поэтому очередь не растёт.
Старый маршрут не очищается при перенацеливании: дрон следует готовому безопасному участку,
пока новое задание не получит маршрут. Если задание отменяется (дрон погиб, цель изменена снова,
юнит уничтожен), маршрут очищается как fallback — дрон летит прямо до следующего адаптивного тика.
Lancet, баллистика и decoy, как и раньше, летят прямо: для них задание не создаётся. В полёте
следующая известная цель берётся только если до неё есть разворот; цель сзади внутри радиуса
разворота пропускается, и дрон завершает текущий заход, а не получает невыполнимый маршрут.
Повторный план использует снимок разведки исходного выбора цели и известный рельеф последнего умного плана
(`Sim::known_relief`, выборка по сетке); за пределами известной сетки — Unknown, новая карта не
раскрывается.
Очередь — рантайм-состояние планировщика. Дроны и очередь запуска в текущий `state_text` не входят,
поэтому формат save не менялся и старые saves читаются как прежде; запись replay по-прежнему хранит
траектории, а не задания. Регрессии: `mass_retarget_finishes_its_route_jobs_over_the_budget`
(40+ ретаргетов, прогресс каждый бюджетный тик, старый маршрут до нового, нет пустых маршрутов)
и `mass_retarget_is_deterministic_and_never_duplicates_a_drone` (тот же seed — те же цели и
маршруты; повторный `adapt` не создаёт дублей). Полный EnemyPicture, общая модель возможностей,
честное разделение наблюдения/истины и структурированное намерение остаются за #127.
## Что следующему агенту проверить в первую очередь
1. На Windows: обновление сабмодуля через `all`, сохранение/перезаход, рабочий цикл игры.
+7 -1
View File
@@ -1,6 +1,6 @@
# Состояние исходных 16 задач
Ветка `kimi-work`, 4 ноября 2026. Issues остаются открытыми по просьбе владельца.
Ветка `kimi-work`, 6 ноября 2026. Issues остаются открытыми по просьбе владельца.
«Реализовано» ниже означает проверенный объём кода, а не закрытие issue и не подтверждение всех платформ.
Новый проверенный объём: [продолжение пяти задач 4 ноября](CONTINUATION-2026-11-04.md).
@@ -50,6 +50,12 @@
| #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 реализована только устойчивая очередь маршрутов, а не полный «EnemyPicture». Очередь не
сохраняется в autosave: текущий формат вообще не хранит дронов и очередь запуска, поэтому изменение
не ломает старые saves и не требует миграции. Replay пишет траектории, а не внутренние задания
планировщика; формат записи не менялся.
Точные коммиты, формат сохранения, тесты и порядок продолжения:
[CODEX-HANDOFF.md](CODEX-HANDOFF.md). Формальный open-статус не меняется после реализации.