diff --git a/README.md b/README.md index dcc862d..cfedee3 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,9 @@ sandbox-сценария, `/airdef ground-load <С-400>` загружает од **Умный противник учится.** Он запоминает, откуда его налёты сбивали, а откуда они прорывались, и следующий налёт ведёт с лучшего направления; помнит комплексы, выдавшие себя пуском, и станции РЭБ, и во время налёта перенацеливает ещё не взлетевшие Ланцеты и Шахеды на них; если цель-комплекс уничтожен, дроны идут к -следующему известному. Дроны под обстрелом прижимаются к земле. +следующему известному. Маршруты к новым целям достраиваются по бюджету планировщика в следующих тиках: +необработанное задание не теряется, старый безопасный участок остаётся до готовности нового, а дрон без +маршрута летит прямо только до ближайшего бюджета. Дроны под обстрелом прижимаются к земле. **Ночь.** Исправные пушки с собственным свежим радарным сопровождением не теряют точность из-за темноты. Цели освещают **прожекторы** (луч ведёт ближайшую diff --git a/airdef-mod/src/sim/ew.rs b/airdef-mod/src/sim/ew.rs index eefd90a..6ac7780 100644 --- a/airdef-mod/src/sim/ew.rs +++ b/airdef-mod/src/sim/ew.rs @@ -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>, +} /// 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 = 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> { + 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> = HashMap::new(); let mut routes: HashMap<(DroneKind, (i32, i32), (i32, i32)), Vec> = 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 { + self.retargets.iter().map(|j| j.drone).collect() } } diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index 4fc1621..5dfa70b 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -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, + /// 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 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(); diff --git a/airdef-mod/src/sim/raid.rs b/airdef-mod/src/sim/raid.rs index f6901af..7a9e2f1 100644 --- a/airdef-mod/src/sim/raid.rs +++ b/airdef-mod/src/sim/raid.rs @@ -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, 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, 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, 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, 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. diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index 5d47e75..7f86b6b 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -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) { + 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::>().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::>().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::>().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() { diff --git a/docs/CODEX-HANDOFF.md b/docs/CODEX-HANDOFF.md index 3416e26..1d6d02f 100644 --- a/docs/CODEX-HANDOFF.md +++ b/docs/CODEX-HANDOFF.md @@ -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`, сохранение/перезаход, рабочий цикл игры. diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index 0956dbb..a4d209f 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -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-статус не меняется после реализации.