From 31d967b0364aefd2a49721b9b70a3da9fd697451 Mon Sep 17 00:00:00 2001 From: chexit Date: Sun, 1 Nov 2026 02:34:39 +0300 Subject: [PATCH] Counter-strikes collide, persist and record their flights (#106 #108 #109 #113) Co-Authored-By: Kimi --- README.md | 3 +- airdef-mod/src/lib.rs | 65 ++++-- airdef-mod/src/map.rs | 8 +- airdef-mod/src/radar.rs | 4 +- airdef-mod/src/replay.rs | 8 + airdef-mod/src/sim/raid.rs | 3 +- airdef-mod/src/sim/record.rs | 34 ++- airdef-mod/src/sim/sites.rs | 147 +++++++++++- airdef-mod/src/sim/strike.rs | 440 ++++++++++++++++++++++++++++++++--- airdef-mod/src/smoke.rs | 4 +- 10 files changed, 646 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 17aafab..69f450d 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,8 @@ API мода `render.set-lights` (до 512, плавно интерполиру - **Удар.** Ударных ракет мало (3 в запасе, максимум 4, новая за 5 минут), дальность до 4500 блоков; стартуют оттуда, где вы стоите, летят 4 блока в тик (2 км за 25 с) в точку *оценки*. На подлёте ракету может сбить ПВО противника: у ПУ её нет (40%), лёгкая (35%, сбивает в 30%) или тяжёлая (25%, 65%), у баллистических батарей тяжёлая чаще; узнаёте только по итогу. Разброс ракеты 14 блоков; решает истинное расстояние до ПУ: до 35 блоков — **уничтожена** (запас сгорел и рвётся, отложенные запуски отменены, огонь и дымящийся остов, взрыв общим кодом), до 90 — **подавлена** на 30 с (пуски отложены, не отменены), дальше — **промах**, воронка рядом, ПУ цела: очередь за попадание в соседний грунт не списывается. Плохо известную ПУ (круг в сотни блоков) бить почти бесполезно. - **Вне карты.** ПУ без земли (нет загруженной карты) тоже находится: азимут и дальность по трекам, но не рисуется. Удар, который попал бы, прекращает её пуски («результат не виден»); промах теряется. - **Цена выбора.** Ракета запаса не вернётся, пока летит, налёт идёт: близкие площадки не остановить, далёкие (крылатые и баллистические за 2–4 км) успеваете, но для этого нужны РЛС с обзором в сторону налёта и риск промаха или ПВО противника. Отчёт налёта называет сорванные пуски: «Налёт сорван ударами по ПУ (№…): не взлетело N из заявленных M». - - Запись: удар попадает в журнал повтора (строка и взрыв/огонь на карте), сами ПУ в повторе не рисуются. Запас ракет входит в сохранение и сценарий (`strikes <запас> <тиков до следующей>`). Все числа механики в одной таблице `TUNE` в `src/sim/strike.rs`. + - Столкновение с рельефом или строением по пути взрывает ракету в точке контакта, не поражая ПУ; неизвестная геометрия задерживает полёт, а не считается воздухом. Если новая волна сворачивает старую ПУ, уже выпущенная ракета всё равно завершает полёт и даёт обычный исход. + - Запись: полёт ударной ракеты, её двигатель и исход попадают в журнал повтора (трек, строка и взрыв/огонь на карте), самостоятельный удар удерживает запись до завершения; сами ПУ в повторе не рисуются. Запас ракет входит в сохранение и сценарий (`strikes <запас> <тиков до следующей>`), прогресс пополнения автосохраняется контрольными точками с точностью до 5 секунд. Все числа механики в одной таблице `TUNE` в `src/sim/strike.rs`. - `/airdef roles` — показать, по каким классам целей работает каждый комплекс; `/airdef roles <классы|default>` — задать (классы через запятую: `drone`, `jet`, `cruise`, `supersonic`, `ballistic` или `all`), например `/airdef roles s400 cruise,ballistic`; - `/airdef doctrine` — доктрины комплексов (#47; в меню N: «Доктрины…»): `/airdef doctrine <номер|all> reserve ` — держать K ракет для срочных целей (баллистика или до удара меньше 12 с); `salvo eco|normal|heavy` — сколько перехватчиков на цель (только базовые / как обычно / на один больше); `sector <центр°> <полуширина°>` или `sector off` — сектор ответственности (0° — север); `guard <объект№>[,<№>]` или `guard off` — стрелять только по тому, что идёт на охраняемые объекты (отметка: `/airdef target here`). По умолчанию ограничений нет; последний шанс сильнее доктрины; `/airdef why <номер>` называет, какая настройка остановила или разрешила пуск; сохраняется с миром и сценариями. Карта охраны и пробелов в ней — вместе с картой (#48). - `/airdef deploy ` — поставить комплекс в 8 блоках перед собой; diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index 833d99c..8a5fdc0 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -101,7 +101,7 @@ struct AirDef { hd: bool, /// The world whose state is loaded (`game.world-id`), once one is. world: Option>, - /// The systems' and objects' health as last saved, to save again when it changes. + /// The durable systems, objects and strike stock as last saved, to save again when they change. health_mark: u64, /// The menu's page while it is open. menu: Option, @@ -1060,6 +1060,29 @@ fn world_identity() -> Option { }) } +/// What an autosave must not miss (#7, #113): the systems' places, ammunition and damage, the objects' health, and +/// the strike missiles' stock and refill. A change here marks the durable state dirty; the write itself is still +/// paced by `next_save`, and a failed write is retried. +fn durable_mark(sim: &Sim) -> u64 { + let st = &sim.strikes; + // The stock exactly; the refill at checkpoints, so that a tick-by-tick progress doesn't ask for a save (and a + // full state dump) every tick. A restart is at most `progress_save` ticks (5 s) behind on the next missile. + let strikes = u64::from(st.stock) | u64::from(st.progress / sim::strike::TUNE.progress_save.max(1)) << 8; + sim.sams + .iter() + .map(|s| { + let at = s.dest.unwrap_or(s.pos); + let place = (at.x * 10.0) as i64 as u64 ^ ((at.z * 10.0) as i64 as u64).rotate_left(21) ^ (if s.dest.is_some() { 0 } else { (s.heading * 100.0) as i64 as u64 }).rotate_left(42); + let ammo = u64::from(s.missiles) | u64::from(s.bursts) << 16 | u64::from(s.cargo) << 32 | u64::from(s.empty) << 48 | u64::from(s.reloading > 0) << 49; + let parts = s.comps.0.iter().fold(0u64, |a, v| a.wrapping_mul(131).wrapping_add((v * 100.0) as u64)); + ((s.health * 1000.0) as u64).wrapping_mul(1_000_003) ^ place ^ ammo.rotate_left(7) ^ parts.rotate_left(13) + }) + .chain(sim.target_health.iter().map(|h| (h * 10.0) as u64)) + .fold(sim.sams.len() as u64, |a, h| a.wrapping_mul(31).wrapping_add(h)) + .wrapping_mul(31) + .wrapping_add(strikes) +} + impl AirDef { /// The EW stations: on if none is, else off. fn toggle_ew(&mut self) { @@ -1889,19 +1912,7 @@ impl Mod for AirDef { } // Damage is saved too: a destroyed system stays destroyed. So are where the units stand // (or drive to: not every step of the way), their ammunition and the depots' stock. - let mark = self - .sim - .sams - .iter() - .map(|s| { - let at = s.dest.unwrap_or(s.pos); - let place = (at.x * 10.0) as i64 as u64 ^ ((at.z * 10.0) as i64 as u64).rotate_left(21) ^ (if s.dest.is_some() { 0 } else { (s.heading * 100.0) as i64 as u64 }).rotate_left(42); - let ammo = u64::from(s.missiles) | u64::from(s.bursts) << 16 | u64::from(s.cargo) << 32 | u64::from(s.empty) << 48 | u64::from(s.reloading > 0) << 49; - let parts = s.comps.0.iter().fold(0u64, |a, v| a.wrapping_mul(131).wrapping_add((v * 100.0) as u64)); - ((s.health * 1000.0) as u64).wrapping_mul(1_000_003) ^ place ^ ammo.rotate_left(7) ^ parts.rotate_left(13) - }) - .chain(self.sim.target_health.iter().map(|h| (h * 10.0) as u64)) - .fold(self.sim.sams.len() as u64, |a, h| a.wrapping_mul(31).wrapping_add(h)); + let mark = durable_mark(&self.sim); if mark != self.health_mark { self.health_mark = mark; self.dirty = true; @@ -2208,7 +2219,10 @@ impl Mod for AirDef { // A strike missile at known site n, from where the player stands. let p = player().ok_or("Нет игрока")?; let id = n.parse::().map_err(|_| "/airdef strike <номер ПУ из /airdef sites>")?; - Some(self.sim.order_strike(id, DVec3::new(p.x, p.y, p.z), &mut out)?) + let said = self.sim.order_strike(id, DVec3::new(p.x, p.y, p.z), &mut out)?; + // The stock went down: the durable state must not forget it (#113). + self.dirty = true; + Some(said) } ["move", n] => { // Unit n (as the summary numbers them) drives to where the player stands. @@ -2355,4 +2369,25 @@ mod tests { assert!(switch_allowed(true).is_err()); assert!(switch_allowed(false).is_ok()); } + + #[test] + fn strike_stock_and_refill_change_the_durable_mark() { + let mut sim = Sim::new(7); + let full = durable_mark(&sim); + sim.strikes.stock -= 1; + assert_ne!(durable_mark(&sim), full, "spending a missile is not saved"); + + sim.strikes.progress = sim::strike::TUNE.resupply - 1; + let before = durable_mark(&sim); + let (mut ground, mut out) = (sim::Ground::default(), sim::Out::default()); + sim.step(&mut ground, None, &mut out); + assert_eq!(sim.strikes.stock, 3); + assert_ne!(durable_mark(&sim), before, "refilling a missile is not saved"); + + let checkpoint = durable_mark(&sim); + sim.strikes.progress = 1; + assert_eq!(durable_mark(&sim), checkpoint, "progress inside one checkpoint saves every tick"); + sim.strikes.progress = sim::strike::TUNE.progress_save; + assert_ne!(durable_mark(&sim), checkpoint, "a refill checkpoint is not saved"); + } } diff --git a/airdef-mod/src/map.rs b/airdef-mod/src/map.rs index bef71b6..405bfa5 100644 --- a/airdef-mod/src/map.rs +++ b/airdef-mod/src/map.rs @@ -1047,6 +1047,8 @@ impl AirDef { let mut out = sim::Out::default(); match self.sim.order_strike(site, DVec3::new(p.x, p.y, p.z), &mut out) { Ok(m) => { + // The stock went down: the durable state must not forget it (#113). + self.dirty = true; say(&m); ui.note(tick, "Ударная ракета пошла"); } @@ -1418,13 +1420,13 @@ impl AirDef { }; self.map_sector(ui, &mut s, sam.pos, &d, sam.kind.range().max(200.0)); } - // Flights of the strike missiles. + // Flights of the strike missiles: the line to the target, the marker where the missile really is (#106). for f in &self.sim.strikes.flights { let a = ui.to_screen(f.from.x, f.from.z); let b = ui.to_screen(f.aim.x, f.aim.y); s.push(line(a, b, 1.0, alpha(0xFFA040, 0.5))); - let k = ((now.saturating_sub(f.start)) as f64 / (f.arrive.saturating_sub(f.start)).max(1) as f64).clamp(0.0, 1.0) as f32; - s.push(diamond(a.0 + (b.0 - a.0) * k, a.1 + (b.1 - a.1) * k, 3.0, ORANGE)); + let c = ui.to_screen(f.pos.x, f.pos.z); + s.push(diamond(c.0, c.1, 3.0, ORANGE)); } // Objects. if ui.filters[F_OBJECTS] { diff --git a/airdef-mod/src/radar.rs b/airdef-mod/src/radar.rs index d47d8ff..8767595 100644 --- a/airdef-mod/src/radar.rs +++ b/airdef-mod/src/radar.rs @@ -139,9 +139,7 @@ pub fn draw(sim: &Sim, player: DVec3, yaw: f32) -> Vec { } // Strike missiles in flight, on the same footing: a small bright dot where the scope reaches, else nothing. for f in &sim.strikes.flights { - let t = (sim.tick.saturating_sub(f.start)) as f64 / (f.arrive - f.start).max(1) as f64; - let p = f.from.lerp(DVec3::new(f.aim.x, f.from.y, f.aim.y), t.min(1.0)); - if let Some((x, y, _)) = scope(p) { + if let Some((x, y, _)) = scope(f.pos) { s.push(square(x, y, 1.5, 0xFFFFFFA0)); } } diff --git a/airdef-mod/src/replay.rs b/airdef-mod/src/replay.rs index 716f688..5737f5b 100644 --- a/airdef-mod/src/replay.rs +++ b/airdef-mod/src/replay.rs @@ -145,6 +145,7 @@ fn strike_word(result: StrikeResult) -> &'static str { StrikeResult::Missed => "промах", StrikeResult::Intercepted => "ракета сбита ПВО противника", StrikeResult::Halted => "пуски прекращены (вне карты)", + StrikeResult::Blocked => "разбилась о рельеф", } } @@ -501,6 +502,12 @@ impl Player { out.engine(key, event, at, vel, gain, pitch, false); } } + What::Strike => { + // A counter-strike's motor from its launch, as the live flight has it (#109). + if let Some((event, pitch)) = DroneKind::Kalibr.engine_voice() { + out.engine(key, event, at, vel, 0.8, pitch, false); + } + } } } } @@ -930,6 +937,7 @@ impl Player { What::Drone(kind) if kind.spec().decoy => (0x70A0A0A0, 0xFFC0C0C0), What::Drone(_) => (0x90FF4040, 0xFFFF6060), What::Missile(..) => (0x90FFD040, 0xFFFFE070), + What::Strike => (0x90FFA040, 0xFFFFC060), }; let upto = ((self.t - tr.first as f64) / STEP as f64).floor(); if upto < 0.0 { diff --git a/airdef-mod/src/sim/raid.rs b/airdef-mod/src/sim/raid.rs index 8ded552..118ebfd 100644 --- a/airdef-mod/src/sim/raid.rs +++ b/airdef-mod/src/sim/raid.rs @@ -756,7 +756,8 @@ const AIR_START_WAIT: u32 = 120; // A side that found nothing is not given up (no raid is cancelled for it): its site is off the map. // The pads, and the sites themselves; the old raid's sites go unless they still have jobs to fly. let queued: Vec = self.queue.iter().map(|q| q.1.id).collect(); - self.sites.retire(&queued); + let refs = self.site_refs(); + self.sites.retire(&queued, &refs); for w in plan.wants.iter_mut() { w.jobs = 0; } diff --git a/airdef-mod/src/sim/record.rs b/airdef-mod/src/sim/record.rs index 289ff9f..6b692a8 100644 --- a/airdef-mod/src/sim/record.rs +++ b/airdef-mod/src/sim/record.rs @@ -153,6 +153,8 @@ pub enum What { Drone(DroneKind), /// An interceptor and the system that fired it. Missile(MissileKind, u32), + /// A counter-strike missile on its way to a launch site (#109). + Strike, } pub struct Track { @@ -195,6 +197,8 @@ pub enum StrikeResult { Intercepted, /// Off the map: its launches ended, nothing to see. Halted, + /// It met the land or a building short of its aim (#106): it went off there. + Blocked, } #[derive(Clone, Copy, Debug)] @@ -357,7 +361,7 @@ pub fn strike_power(result: StrikeResult) -> u32 { match result { StrikeResult::Destroyed => 3, StrikeResult::Stunned => 2, - StrikeResult::Missed => 1, + StrikeResult::Missed | StrikeResult::Blocked => 1, StrikeResult::Intercepted | StrikeResult::Halted => 0, } } @@ -379,7 +383,7 @@ pub fn blast_spec(kind: EventKind) -> (f32, u32) { EventKind::Split { .. } => (2.0, 7 * 9), EventKind::Strike { result, .. } => match result { StrikeResult::Destroyed => (SIZE[4], LIFE[4] * 9), - StrikeResult::Stunned | StrikeResult::Missed => (SIZE[3], LIFE[3] * 9), + StrikeResult::Stunned | StrikeResult::Missed | StrikeResult::Blocked => (SIZE[3], LIFE[3] * 9), StrikeResult::Intercepted => (SIZE[0], LIFE[0] * 9), StrikeResult::Halted => (0.0, 0), }, @@ -570,14 +574,15 @@ impl Sim { } } - /// Once a tick: starts a recording with the first drone, adds the tracks' points, ends it - /// when the sky has been clear 5 s. + /// Once a tick: starts a recording with the first drone or counter-strike in the air, adds the tracks' points, + /// ends it when the sky has been clear 5 s. pub(super) fn record(&mut self) { - // Wrecks still falling (far off they fall a long while) don't keep it going. - let busy = self.drones.iter().any(|d| d.alive()) || !self.missiles.is_empty() || !self.queue.is_empty(); + // Wrecks still falling (far off they fall a long while) don't keep it going. A counter-strike is a recorded + // object in its own right (#109): it holds the recording open until it lands, whatever the sky is doing. + let busy = self.drones.iter().any(|d| d.alive()) || !self.missiles.is_empty() || !self.queue.is_empty() || !self.strikes.flights.is_empty(); if self.rec.is_none() { - // It starts with the first drone in the sky (not with a raid's drones still waiting). - if !self.drones.iter().any(|d| d.alive()) { + // It starts with the first drone or strike missile in the air (not with a raid's drones still waiting). + if !self.drones.iter().any(|d| d.alive()) && self.strikes.flights.is_empty() { return; } self.recordings += 1; @@ -599,6 +604,9 @@ impl Sim { for m in &self.missiles { r.point(m.id, What::Missile(m.kind, m.sam), m.pos); } + for f in &self.strikes.flights { + r.point(f.track(), What::Strike, f.pos); + } } r.len += 1; r.quiet = if busy { 0 } else { r.quiet + 1 }; @@ -665,6 +673,16 @@ impl Sim { out.model(id + 1, "airdef:flame", pos + rot * DVec3::new(0.0, 0.0, back), rot, spec.flame, true); } } + What::Strike => { + // The same model, flame and turn the live flight drew (#106, #109). + let spec = DroneKind::Kalibr.spec(); + let rot = look(dir, 0.0); + out.model(id, spec.model, pos, rot, spec.scale, false); + if let Engine::Jet(up, back, size) = spec.engine { + let flicker = 0.7 + 0.3 * (t * 1.7).sin().abs(); + out.model(id + 1, "airdef:geran3_flame", pos + rot * DVec3::new(0.0, up * spec.scale, back * spec.scale), rot, spec.scale * size * flicker, true); + } + } } } // The air-defence systems as they stood: where, which way, the launcher or turret where it diff --git a/airdef-mod/src/sim/sites.rs b/airdef-mod/src/sim/sites.rs index 8d1de66..b7af4d8 100644 --- a/airdef-mod/src/sim/sites.rs +++ b/airdef-mod/src/sim/sites.rs @@ -164,6 +164,10 @@ pub struct Sites { pub list: Vec, next: u32, jobs: Vec<(u32, u32)>, + /// Sites a new wave kept for something still in the air (#108): their kit stays until that is gone. + holding: Vec, + /// Drones that left a site and are still in the air: (drone, site), so their site is not taken away under them. + flying: Vec<(u32, u32)>, } /// What a site says about a drone due to leave. @@ -194,6 +198,8 @@ impl Sites { pub fn clear(&mut self) { self.list.clear(); self.jobs.clear(); + self.holding.clear(); + self.flying.clear(); } pub(super) fn add(&mut self, mut site: LaunchSite) -> u32 { self.next += 1; @@ -205,11 +211,34 @@ impl Sites { pub(super) fn job(&mut self, drone: u32, site: u32) { self.jobs.push((drone, site)); } - /// The old raid's sites go, but not those with jobs still to fly. - pub(super) fn retire(&mut self, queued: &[u32]) { + /// The old raid's sites go, but not those with jobs still to fly, nor those something in the air still came + /// from or is flying to (#108: a missile on its way is not left shooting at a vanished base). + pub(super) fn retire(&mut self, queued: &[u32], refs: &[u32]) { self.jobs.retain(|j| queued.contains(&j.0)); let jobs = &self.jobs; - self.list.retain(|s| jobs.iter().any(|j| j.1 == s.id)); + let mut list = std::mem::take(&mut self.list); + list.retain(|s| jobs.iter().any(|j| j.1 == s.id) || refs.contains(&s.id)); + self.list = list; + // What only the references hold: let go once they are gone, so no old bases pile up. + self.holding = self.list.iter().filter(|s| !jobs.iter().any(|j| j.1 == s.id)).map(|s| s.id).collect(); + } + /// Drops the sites `retire` held only for a flight, a drone or a scout link that is now gone. + pub(super) fn prune(&mut self, refs: &[u32]) { + if self.holding.is_empty() { + return; + } + let live = |id: &u32| refs.contains(id) || self.jobs.iter().any(|j| j.1 == *id); + let gone: Vec = self.holding.iter().copied().filter(|id| !live(id)).collect(); + if gone.is_empty() { + return; + } + self.holding.retain(|id| live(id)); + self.list.retain(|s| !gone.contains(&s.id)); + } + /// Sites drones still in the air left: (drone, site) for those alive in the list, forgetting the others. + pub(super) fn flying_sites(&mut self, alive: &[u32]) -> Vec { + self.flying.retain(|(d, _)| alive.contains(d)); + self.flying.iter().map(|f| f.1).collect() } fn take(&mut self, drone: u32, now: u64) -> Slot { let Some(j) = self.jobs.iter().position(|j| j.0 == drone) else { return Slot::Free }; @@ -229,6 +258,8 @@ impl Sites { s.next_free = now + s.class.cadence(); let air = s.offmap; self.jobs.swap_remove(j); + // A drone in the air still belongs to its site: a new wave must not take the pad from under it (#108). + self.flying.push((drone, site)); if air { Slot::Air } else { Slot::Go } } } @@ -1052,4 +1083,114 @@ mod tests { }; assert_eq!(run(false), run(true)); } + + // ---------------------------------------------------------------- #108: what holds a site beyond its wave + + /// A site with the radars' fix on it, and no launches left: a strike can be sent at it without any drone flying. + fn fixed_site(sim: &mut Sim, pos: DVec3) -> u32 { + let id = sim.sites.add(LaunchSite { + id: 0, + side: 0, + sector: 0, + bearing: 0.0, + class: SiteClass::Ramp, + pos, + range: pos.z, + water: false, + stock: 0, + planned: 0, + alive: true, + next_free: 0, + offmap: false, + }); + sim.strikes.known.push(super::super::strike::Known { + site: id, + class: SiteClass::Ramp, + obs: Vec::new(), + est: Some(super::super::strike::Estimate { + pos: glam::DVec2::new(pos.x, pos.z), + radius: 5.0, + fix: true, + bearing: 0.0, + bearing_err: 0.01, + origin: glam::DVec2::ZERO, + }), + launches: 1, + last: 0, + state: super::super::strike::SiteState::Active, + cover: None, + aimed: false, + }); + id + } + #[test] + fn a_wave_keeps_the_site_a_strike_is_still_flying_to() { + let mut sim = Sim::new(11); + sim.strikes.cover_override = Some(0); + let id = fixed_site(&mut sim, DVec3::new(0.0, 64.0, 300.0)); + let mut ground = Ground::default(); + let mut out = Out::default(); + sim.order_strike(id, DVec3::new(0.0, 64.0, 0.0), &mut out).unwrap(); + // The next wave is planned while the missile is in the air: its housekeeping must not take the target away. + let refs = sim.site_refs(); + assert!(refs.contains(&id), "the flight is no reference to its site"); + sim.sites.retire(&[], &refs); + assert!(sim.sites.get(id).is_some(), "a new wave took the site a missile was flying to"); + // The missile lands: the site is hit, and nothing keeps it afterwards. + for _ in 0..300 { + sim.step(&mut ground, None, &mut out); + if sim.strikes.flights.is_empty() { + break; + } + } + assert!(sim.strikes.flights.is_empty()); + assert_eq!(sim.strikes.fired, 1); + assert_eq!(sim.strikes.destroyed + sim.strikes.stunned + sim.strikes.missed, 1, "{:?}", out.messages); + assert!(sim.sites.get(id).is_none(), "the old base stays on the map for ever"); + assert!(sim.strikes.flights.is_empty()); + } + + #[test] + fn a_wave_keeps_the_site_a_drone_left_from_until_that_is_gone() { + let mut sim = Sim::new(11); + let id = sim.sites.add(LaunchSite { + id: 0, + side: 0, + sector: 0, + bearing: 0.0, + class: SiteClass::Ramp, + pos: DVec3::new(0.0, 64.0, 300.0), + range: 300.0, + water: false, + stock: 1, + planned: 1, + alive: true, + next_free: 0, + offmap: false, + }); + let site = sim.sites.get(id).unwrap().clone(); + let d = sim.make_drone(DroneKind::Shahed, site.muzzle(0), -DVec3::Z, DVec3::new(0.0, 70.0, 0.0)); + sim.sites.job(d.id, id); + sim.queue.push((1, d)); + let (mut ground, mut out) = (Ground::default(), Out::default()); + // Let the drone leave the ramp. + let mut left = false; + for _ in 0..200 { + sim.step(&mut ground, None, &mut out); + left |= sim.drones.iter().any(|d| d.alive()); + if left { + break; + } + } + assert!(left, "the drone never left"); + let refs = sim.site_refs(); + assert!(refs.contains(&id), "a drone in the air is no reference to the site it left"); + sim.sites.retire(&[], &refs); + assert!(sim.sites.get(id).is_some(), "the site of a flying drone was taken away"); + // Once the drone is gone, the site is let go: no old bases pile up. + sim.drones.clear(); + let refs = sim.site_refs(); + sim.sites.prune(&refs); + assert!(sim.sites.get(id).is_none(), "an old base stays after its last drone"); + } } diff --git a/airdef-mod/src/sim/strike.rs b/airdef-mod/src/sim/strike.rs index 54ec70a..dbf9b95 100644 --- a/airdef-mod/src/sim/strike.rs +++ b/airdef-mod/src/sim/strike.rs @@ -50,6 +50,9 @@ pub struct Tune { pub stock: u32, pub stock_max: u32, pub resupply: u32, + /// Ticks between the checkpoints at which the refilling progress is written to a save: a restart is at most + /// this much of the wait to the next missile behind (#113). + pub progress_save: u32, /// Blocks a tick, the farthest target (blocks), and the cruise height over the launch (blocks). pub speed: f64, pub range: f64, @@ -83,6 +86,7 @@ pub const TUNE: Tune = Tune { stock: 3, stock_max: 4, resupply: 6000, + progress_save: 100, speed: 8.0, range: 4500.0, height: 40.0, @@ -261,16 +265,48 @@ struct Flown { at: u64, } -/// A strike missile in flight. +/// The target of a strike as it was when the missile left: what the outcome is judged against, even if the site +/// has been taken off the map since (#108). +#[derive(Clone, Copy, Debug)] +pub struct Target { + /// Where the site really stands (x, z). + pub pos: DVec2, + pub stock: u32, + pub offmap: bool, + pub alive: bool, + /// What its cover was (0 none, 1 light, 2 heavy), fixed at the launch. + pub cover: u8, +} + +/// A strike missile in flight: the shot it was fired as, where it is, and where it is going. #[derive(Clone, Copy, Debug)] pub struct Flight { pub id: u32, pub site: u32, + /// Where it was fired from, and where it is now (the swept step, not the plan). pub from: DVec3, - /// The aim (x, z). + pub pos: DVec3, + /// The aim (x, z) the player was told of, and the place it really lands: the aim plus its own scatter, + /// fixed at the launch so the flight and its outcome name the same point. pub aim: DVec2, + pub land: DVec2, pub start: u64, pub arrive: u64, + /// The site as it was at the launch. + pub snap: Target, + /// Ticks it was held at an unread stretch of the world (#61). + hold: u16, +} + +impl Flight { + /// The id of its track in a recording (a range of its own, apart from the drones'). + pub fn track(&self) -> u32 { + 0x7100_0000 | self.id + } + /// The key of its exact-shape questions, apart from those of the drones' and interceptors'. + fn key(&self) -> u32 { + 0x9000_0000 | self.id + } } #[derive(Default)] @@ -314,6 +350,10 @@ impl Strikes { pub fn seconds_to_next(&self) -> u32 { (TUNE.resupply - self.progress.min(TUNE.resupply)) / 20 } + /// Sites a living launched drone not yet picked up on a track still came from (#108). + pub(super) fn scouted<'a>(&'a self, alive: &'a [u32]) -> impl Iterator + 'a { + self.flown.iter().filter(move |f| alive.contains(&f.drone)).map(|f| f.site) + } } /// A standard normal from the seed and three numbers (the combat's random sequence is left alone). @@ -534,21 +574,65 @@ impl Sim { } } - /// Strike missiles on their way: drawn, heard, and landed when their time is up. + /// Strike missiles on their way: moved a swept step at a time, drawn and heard where they really are, and + /// landed or broken on what the world says is in the way (#106). fn fly_strikes(&mut self, ground: &mut Ground, player: Option, out: &mut Out) { let now = self.tick; let spec = DroneKind::Kalibr.spec(); let flights = std::mem::take(&mut self.strikes.flights); let mut rest = Vec::with_capacity(flights.len()); - for f in flights { - if now >= f.arrive { - self.land_strike(f, ground, player, out); + for mut f in flights { + // The terminal point: where it lands, at the land there as known now (a ridge that changed since the + // launch is flown over or into as it stands). With no land told of, the height it left at. + let below = ground.at(DVec3::new(f.land.x, 0.0, f.land.y), now); + let end = DVec3::new(f.land.x, below.unwrap_or(f.from.y), f.land.y); + let t = ((now - f.start) as f64 / (f.arrive - f.start).max(1) as f64).clamp(0.0, 1.0); + let want = f.from.lerp(end, t) + DVec3::Y * (TUNE.height * (std::f64::consts::PI * t).sin().max(0.0).sqrt()); + let from = f.pos; + if now >= f.arrive && from.distance(end) <= TUNE.speed * 1.5 + 2.0 { + self.land_strike(f, from, ground, player, out); continue; } - let t = (now - f.start) as f64 / (f.arrive - f.start).max(1) as f64; - let end = DVec3::new(f.aim.x, f.from.y, f.aim.y); - let pos = f.from.lerp(end, t) + DVec3::Y * (TUNE.height * (std::f64::consts::PI * t).sin().max(0.0).sqrt()); - let dir = (end - f.from).normalize_or(DVec3::NEG_Z) + DVec3::Y * 0.001; + // A missile that never reaches its point (the world never tells of the way) ends somewhere: it does + // not stay in the air for ever. + if now >= f.arrive + 600 { + self.crash_strike(f, from, ground, player, out); + continue; + } + let vel = want - from; + if vel.length() > 1e-6 { + ground.ahead(from, vel, 20.0, now); + } + let sweep = ground.trace(f.key(), from, want, false, now); + let mut pos = want; + let contact = match sweep { + world::Sweep::Hit { point, .. } => Some(point), + world::Sweep::Wait { t } => { + pos = from.lerp(want, (t - 1e-6).max(0.0)); + None + } + world::Sweep::Unknown { .. } => { + // Unread is not air (#61): the flight holds at the edge of what is read, and only a chunk + // the game doesn't have (not one still loading) is passed, high over the land. + let low = ground.at(from, now).is_some_and(|g| from.y < g + 12.0); + if let Some(t) = ground.unread(&sweep, &mut f.hold, low) { + pos = from.lerp(want, t); + } + None + } + world::Sweep::Clear => None, + }; + if let Some(point) = contact { + self.crash_strike(f, point, ground, player, out); + continue; + } + // The land's height says it met the ground where the blocks were not read: the same contact. + let here = ground.at(pos, now); + if ground.ground_hit(here, pos, 0.2, &sweep, now) { + self.crash_strike(f, pos, ground, player, out); + continue; + } + let dir = (pos - from).normalize_or(end - f.from).normalize_or(DVec3::NEG_Z); let rot = look(dir, 0.0); let id = 0x7500_0000 + f.id * PARTS; out.model(id, spec.model, pos, rot, spec.scale, false); @@ -557,12 +641,28 @@ impl Sim { out.model(id + 1, "airdef:geran3_flame", pos + rot * DVec3::new(0.0, up * spec.scale, back * spec.scale), rot, spec.scale * size * flicker, true); } if let Some((event, pitch)) = DroneKind::Kalibr.engine_voice() { - out.engine(id, event, pos, dir.normalize() * TUNE.speed, 0.8, pitch, false); + out.engine(id, event, pos, dir * TUNE.speed, 0.8, pitch, false); } + f.pos = pos; rest.push(f); } rest.append(&mut self.strikes.flights); self.strikes.flights = rest; + // A site held only for something still in the air goes when that is gone (#108): no old bases pile up. + let refs = self.site_refs(); + self.sites.prune(&refs); + } + + /// The sites something still in the air came from or is flying to (#108): a site retired by a new wave is kept + /// while these exist, and let go after. + pub(super) fn site_refs(&mut self) -> Vec { + let mut v: Vec = self.strikes.flights.iter().map(|f| f.site).collect(); + let alive: Vec = self.drones.iter().filter(|d| d.alive()).map(|d| d.id).collect(); + v.extend(self.strikes.scouted(&alive)); + v.extend(self.sites.flying_sites(&alive)); + v.sort_unstable(); + v.dedup(); + v } /// Fires a strike missile at known site `id` from `from`. Returns the line for the player. @@ -584,14 +684,32 @@ impl Sim { if dist > TUNE.range { return Err(format!("ПУ №{id} дальше дальности удара ({})", crate::panel::distance(TUNE.range))); } + // The target as it is now: the missile is judged against this whatever becomes of the site meanwhile (#108). + let site = self.sites.get(id).ok_or_else(|| format!("ПУ №{id} уже нет на карте"))?; + let snap = Target { pos: DVec2::new(site.pos.x, site.pos.z), stock: site.stock, offmap: site.offmap, alive: site.alive, cover: self.cover_of(site) }; self.strikes.stock -= 1; self.strikes.fired += 1; self.strikes.next += 1; let ticks = (dist / TUNE.speed).ceil().max(20.0) as u64; let start = from + DVec3::Y * 1.5; - self.strikes.flights.push(Flight { id: self.strikes.next, site: id, from: start, aim: est.pos, start: self.tick, arrive: self.tick + ticks }); + // Its own scatter at the aim, rolled once here: the flight and its outcome name one point. + let (gx, gz) = (gauss(self.seed, self.strikes.next as u64, id as u64, 7), gauss(self.seed, self.strikes.next as u64, id as u64, 8)); + let land = est.pos + DVec2::new(gx, gz) * TUNE.cep; + self.strikes.flights.push(Flight { + id: self.strikes.next, + site: id, + from: start, + pos: start, + aim: est.pos, + land, + start: self.tick, + arrive: self.tick + ticks, + snap, + hold: 0, + }); if let Some(k) = self.strikes.known.iter_mut().find(|k| k.site == id) { k.aimed = true; + k.cover = Some(snap.cover); } out.sound(DroneKind::Kalibr.launch_sound(), start, 1.0, 1.0); out.particles("minecraft:large_smoke", start, 0.6, DVec3::ZERO, 0.05, 30); @@ -605,37 +723,34 @@ impl Sim { )) } - /// A strike arrives. - fn land_strike(&mut self, f: Flight, ground: &mut Ground, player: Option, out: &mut Out) { + /// A strike arrives at `at` (the contact point: the ground under its landing point, or the face it met). + fn land_strike(&mut self, f: Flight, at: DVec3, ground: &mut Ground, player: Option, out: &mut Out) { let now = self.tick; if let Some(k) = self.strikes.known.iter_mut().find(|k| k.site == f.site) { k.aimed = false; + k.cover = Some(f.snap.cover); } - let Some(site) = self.sites.get(f.site).cloned() else { - out.messages.push(format!("Удар по ПУ №{}: площадка уже свёрнута, ракета ушла в пустоту", f.site)); - return; + // The site as it stands now; if it has been taken off the map since the launch (a new wave's housekeeping, + // a clear), the shot was still fired at that place: it has its outcome by its coordinates (#108). + let (truth, offmap, alive, stock) = match self.sites.get(f.site) { + Some(s) => (DVec2::new(s.pos.x, s.pos.z), s.offmap, s.alive, s.stock), + None => (f.snap.pos, f.snap.offmap, f.snap.alive, f.snap.stock), }; - let cover = self.cover_of(&site); - if let Some(k) = self.strikes.known.iter_mut().find(|k| k.site == f.site) { - k.cover = Some(cover); - } - let spot = |x: f64, z: f64, ground: &mut Ground, fallback: f64| DVec3::new(x, ground.at(DVec3::new(x, 0.0, z), now).unwrap_or(fallback), z); + let cover = f.snap.cover; // The enemy's cover. if dice(self.seed, f.id as u64, f.site as u64, 201) < TUNE.cover_p[cover as usize] { self.strikes.shot_down += 1; - let at = spot(f.aim.x, f.aim.y, ground, site.pos.y) + DVec3::Y * 40.0; - self.blast_at(at, 0, true, false, ground, player, out); - self.log(at, record::EventKind::Strike { site: f.site, result: record::StrikeResult::Intercepted }); + let high = at + DVec3::Y * 40.0; + self.blast_at(high, 0, true, false, ground, player, out); + self.log(high, record::EventKind::Strike { site: f.site, result: record::StrikeResult::Intercepted }); out.messages.push(format!("Удар по ПУ №{}: ракета сбита ПВО противника у площадки, цель цела", f.site)); return; } - // Where it lands: the aim and the missile's scatter; how far from the site decides. - let (gx, gz) = (gauss(self.seed, f.id as u64, f.site as u64, 7), gauss(self.seed, f.id as u64, f.site as u64, 8)); - let land = DVec2::new(f.aim.x + gx * TUNE.cep, f.aim.y + gz * TUNE.cep); - let miss = (land - DVec2::new(site.pos.x, site.pos.z)).length(); - let result = if site.offmap && miss <= TUNE.hit_r { + // Where it lands was fixed at the launch; how far from the site decides. + let miss = (f.land - truth).length(); + let result = if offmap && miss <= TUNE.hit_r { record::StrikeResult::Halted - } else if !site.alive { + } else if !alive { record::StrikeResult::Missed } else if miss <= TUNE.hit_r { record::StrikeResult::Destroyed @@ -645,25 +760,24 @@ impl Sim { record::StrikeResult::Missed }; use record::StrikeResult as R; - if site.offmap { + if offmap { // Nothing stands there, nothing is seen: the launches stop or they do not. if result == R::Halted { let n = self.halt_site(f.site); self.strikes.destroyed += 1; self.strikes.cancelled.push((f.site, n)); self.set_state(f.site, SiteState::Destroyed(now)); - self.log(site.pos, record::EventKind::Strike { site: f.site, result }); + self.log(at, record::EventKind::Strike { site: f.site, result }); out.messages.push(format!("Удар по ПУ №{}: за пределами загруженной карты, результат не виден; пуски оттуда прекратились (отменено {n})", f.site)); } else { self.strikes.missed += 1; + self.log(at, record::EventKind::Strike { site: f.site, result: R::Missed }); out.messages.push(format!("Удар по ПУ №{}: за пределами загруженной карты; пуски идут, ракета потеряна", f.site)); } return; } - let at = spot(land.x, land.y, ground, site.pos.y); match result { R::Destroyed => { - let stock = site.stock; let n = self.destroy_site(f.site); self.strikes.destroyed += 1; self.strikes.cancelled.push((f.site, n)); @@ -706,6 +820,22 @@ impl Sim { } } + /// A strike missile met the land or a building short of its aim: it goes off there, and the site is untouched + /// because nothing was aimed at. One result for the statistics and the record, before any cover (#106). + fn crash_strike(&mut self, f: Flight, at: DVec3, ground: &mut Ground, player: Option, out: &mut Out) { + if let Some(k) = self.strikes.known.iter_mut().find(|k| k.site == f.site) { + k.aimed = false; + } + self.strikes.missed += 1; + self.blast_at(at, 4, false, false, ground, player, out); + self.log(at, record::EventKind::Strike { site: f.site, result: record::StrikeResult::Blocked }); + out.messages.push(format!( + "Удар по ПУ №{}: ракета разбилась о рельеф или строение в {:.0} м от цели, площадка цела", + f.site, + (at.x - f.snap.pos.x).hypot(at.z - f.snap.pos.y) + )); + } + fn set_state(&mut self, site: u32, state: SiteState) { if let Some(k) = self.strikes.known.iter_mut().find(|k| k.site == site) { k.state = state; @@ -1126,4 +1256,244 @@ mod tests { assert!(sim.queue.is_empty() && sim.sites.pending(site.id) == 0); assert!(left_at_strike > 3); } + + // ---------------------------------------------------------------- #106: the strike flies through the world + + /// A wall across `at` (z), solid in the sections from the ground up, with `hole` (a band of block heights) left + /// open by its exact shapes: the narrow phase is what decides. + struct Wall { + at: i32, + hole: Option<(i32, i32)>, + } + impl Wall { + fn in_wall(&self, y: i32, z: i32) -> bool { + z == self.at && (64..200).contains(&y) + } + } + impl world::WorldSource for Wall { + fn solid(&self, _x: i32, y: i32, z: i32) -> Option { + Some(y < 64 || self.in_wall(y, z)) + } + fn boxes(&self, _x: i32, y: i32, z: i32) -> Vec<[f64; 6]> { + let open = self.hole.is_some_and(|(lo, hi)| (lo..hi).contains(&y)); + if self.in_wall(y, z) && !open { vec![[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]] } else { Vec::new() } + } + } + + /// The game's answers of the tick, as `step` would get them: the land heights and the sections. + fn serve(ground: &mut Ground, src: &dyn world::WorldSource, now: u64) { + let cols = ground.take_wanted().into_iter().map(|(x, z)| (x, z, 64)).collect(); + ground.answer(cols, now); + let secs = ground.world.take_wanted(now); + ground.world.serve(src, secs, now); + } + + /// Reads the sections along `a`..`b` ahead of the flight, as a real game has them loaded before the shot. + fn warm(ground: &mut Ground, src: &dyn world::WorldSource, a: DVec3, b: DVec3) { + for _ in 0..4 { + ground.world.sweep(a, b, false, 0); + let w = ground.world.take_wanted(0); + ground.world.serve(src, w, 0); + } + } + + /// A site at `pos` with `stock` launches still ahead of it and the radars' fix on it: no drone has to fly first. + fn fixed_site(sim: &mut Sim, pos: DVec3, stock: u32) -> u32 { + let id = sim.sites.add(LaunchSite { + id: 0, + side: 0, + sector: 0, + bearing: 0.0, + class: SiteClass::Ramp, + pos, + range: pos.z, + water: false, + stock, + planned: stock, + alive: true, + next_free: 0, + offmap: false, + }); + sim.strikes.known.push(Known { + site: id, + class: SiteClass::Ramp, + obs: Vec::new(), + est: Some(Estimate { pos: DVec2::new(pos.x, pos.z), radius: 5.0, fix: true, bearing: 0.0, bearing_err: 0.01, origin: DVec2::ZERO }), + launches: 1, + last: 0, + state: SiteState::Active, + cover: None, + aimed: false, + }); + for i in 0..stock { + let site = sim.sites.get(id).unwrap().clone(); + let d = sim.make_drone(DroneKind::Shahed, site.muzzle(i), -DVec3::Z, DVec3::new(0.0, 70.0, 0.0)); + sim.sites.job(d.id, id); + // Well after the strike: the site keeps its stock and its launches. + sim.queue.push((20_000 + u64::from(i), d)); + } + id + } + + fn strike_events(sim: &Sim) -> Vec { + sim.rec.iter().chain(sim.replays.iter()).flat_map(|r| r.events.iter().map(|e| e.kind)).filter(|k| matches!(k, record::EventKind::Strike { .. })).collect() + } + + // #106: a wall between the shot and the site catches the missile: it goes off at the contact, the site is untouched + // and keeps its launches, the stock is spent once and the result is counted once, before any cover roll. + #[test] + fn a_counter_strike_breaks_on_a_wall_between_it_and_its_site() { + let mut sim = Sim::new(11); + // Heavy cover would shoot a missile down half the time: a broken one is never intercepted. + sim.strikes.cover_override = Some(2); + let id = fixed_site(&mut sim, DVec3::new(0.0, 64.0, 300.0), 4); + let (mut ground, mut out) = (Ground::default(), Out::default()); + let wall = Wall { at: 140, hole: None }; + warm(&mut ground, &wall, DVec3::new(0.0, 65.5, 0.0), DVec3::new(0.0, 105.0, 300.0)); + sim.order_strike(id, DVec3::new(0.0, 64.0, 0.0), &mut out).unwrap(); + out.messages.clear(); + let mut crossed = false; + for _ in 0..300 { + serve(&mut ground, &wall, sim.tick); + sim.step(&mut ground, None, &mut out); + crossed |= sim.strikes.flights.iter().any(|f| f.pos.z > 144.0); + if sim.strikes.flights.is_empty() { + break; + } + } + assert!(!crossed, "the missile flew through the wall"); + assert!(sim.strikes.flights.is_empty(), "still flying"); + let site = sim.sites.get(id).expect("the site was taken away"); + assert!(site.alive && site.stock == 4, "the site was hit through the wall: {site:?}"); + assert_eq!(sim.sites.pending(id), 4, "its launches still stand"); + assert_eq!((sim.strikes.fired, sim.strikes.missed, sim.strikes.destroyed, sim.strikes.stunned, sim.strikes.shot_down), (1, 1, 0, 0, 0)); + // The blast is at the wall's face, not at the site 160 blocks on. + let at = sim.blasts.first().expect("no blast").at; + assert!((at.z - 140.0).abs() < 1.0 && at.x.abs() < 1.0, "burst at {at:?}, not at the wall"); + assert!(out.messages.iter().any(|m| m.contains("разбилась")), "{:?}", out.messages); + // One result, recorded, with the flight it belongs to as a track of its own (#109). + let events = strike_events(&sim); + assert_eq!(events.len(), 1, "{events:?}"); + assert!(matches!(events[0], record::EventKind::Strike { result: record::StrikeResult::Blocked, .. }), "{:?}", events[0]); + let rec = sim.rec.iter().chain(sim.replays.iter()).next().expect("the shot made no recording"); + let tr = rec.tracks.iter().find(|t| t.what == record::What::Strike).expect("no recorded flight"); + let end = tr.at(rec.origin, tr.last() as f64).expect("no last point"); + assert!(end.z < 145.0 && end.z > 100.0, "the recorded flight ends at {end:?}, not at the wall"); + assert!((end.z - at.z).abs() < 3.0, "the recorded flight and the blast disagree: {end:?} / {at:?}"); + } + + // #106: the sections say "solid" over the whole wall, but its exact shape leaves an opening: the flight goes + // through it by the narrow phase (and a slab in the way is flown over, not assumed clear). + #[test] + fn a_counter_strike_passes_an_opening_by_the_blocks_shape() { + let mut sim = Sim::new(11); + sim.strikes.cover_override = Some(0); + // No launches: only the strike can make a recording here. + let id = fixed_site(&mut sim, DVec3::new(0.0, 64.0, 300.0), 0); + let (mut ground, mut out) = (Ground::default(), Out::default()); + // A slit between 100 and 110 blocks high, where the missile flies at about 104. + let wall = Wall { at: 140, hole: Some((100, 110)) }; + warm(&mut ground, &wall, DVec3::new(0.0, 65.5, 0.0), DVec3::new(0.0, 105.0, 300.0)); + sim.order_strike(id, DVec3::new(0.0, 64.0, 0.0), &mut out).unwrap(); + let mut crossed = false; + for _ in 0..400 { + serve(&mut ground, &wall, sim.tick); + sim.step(&mut ground, None, &mut out); + crossed |= sim.strikes.flights.iter().any(|f| f.pos.z > 144.0); + if sim.strikes.flights.is_empty() { + break; + } + } + assert!(crossed, "the missile was stopped by a wall with a slit: {:?}", out.messages); + assert_eq!(sim.strikes.missed, 0, "it broke on the slit: {:?}", out.messages); + // It went on to its point: the site is hit (or narrowly missed), and never "blocked". + let events = strike_events(&sim); + assert_eq!(events.len(), 1, "{events:?}"); + assert!(!matches!(events[0], record::EventKind::Strike { result: record::StrikeResult::Blocked, .. }), "{:?}", events[0]); + assert_eq!(sim.strikes.fired, 1); + } + + // #106: an open way works as before: the missile reaches its point, the site dies, one result, one stock spent. + #[test] + fn a_counter_strike_on_an_open_way_still_reaches_its_site() { + let mut sim = Sim::new(11); + sim.strikes.cover_override = Some(0); + let id = fixed_site(&mut sim, DVec3::new(0.0, 64.0, 300.0), 2); + let (mut ground, mut out) = (Ground::default(), Out::default()); + let open = Wall { at: 10_000, hole: None }; + sim.order_strike(id, DVec3::new(0.0, 64.0, 0.0), &mut out).unwrap(); + for _ in 0..400 { + serve(&mut ground, &open, sim.tick); + sim.step(&mut ground, None, &mut out); + if sim.strikes.flights.is_empty() { + break; + } + } + assert!(sim.strikes.flights.is_empty()); + assert_eq!((sim.strikes.fired, sim.strikes.missed), (1, 0), "{:?}", out.messages); + assert_eq!(sim.strikes.destroyed, 1, "{:?}", out.messages); + assert!(!sim.sites.get(id).unwrap().alive); + } + + // #108: a missile already in the air is not lost when its site is taken off the map: it keeps its outcome by + // the coordinates it was fired at, and the stock and the result are accounted once. + #[test] + fn a_strike_whose_site_is_taken_off_the_map_still_has_its_result() { + let mut sim = Sim::new(11); + sim.strikes.cover_override = Some(0); + let id = fixed_site(&mut sim, DVec3::new(0.0, 64.0, 300.0), 0); + let (mut ground, mut out) = (Ground::default(), Out::default()); + sim.order_strike(id, DVec3::new(0.0, 64.0, 0.0), &mut out).unwrap(); + // The world unloads (or the sky is cleared): no site to find on arrival. + sim.clear(); + assert!(sim.sites.get(id).is_none()); + out.messages.clear(); + for _ in 0..400 { + sim.step(&mut ground, None, &mut out); + if sim.strikes.flights.is_empty() { + break; + } + } + assert!(sim.strikes.flights.is_empty(), "the missile vanished with the site"); + assert!(!out.messages.iter().any(|m| m.contains("свёрнута") || m.contains("пустоту")), "{:?}", out.messages); + assert_eq!(sim.strikes.fired, 1); + assert_eq!(sim.strikes.destroyed + sim.strikes.stunned + sim.strikes.missed, 1, "{:?}", out.messages); + assert_eq!(sim.strikes.shot_down, 0); + assert_eq!(strike_events(&sim).len(), 1, "the result was not recorded"); + } + + // #109: after the last enemy is gone, a strike still flying five seconds later keeps its recording open; + // its track and outcome survive, and the recording closes only after the usual quiet tail. + #[test] + fn a_late_counter_strike_keeps_the_recording_until_it_lands() { + let mut sim = Sim::new(11); + sim.strikes.cover_override = Some(0); + let id = fixed_site(&mut sim, DVec3::new(0.0, 64.0, 1000.0), 0); + let (mut ground, mut out) = (Ground::default(), Out::default()); + let open = Wall { at: 10_000, hole: None }; + sim.order_strike(id, DVec3::new(0.0, 64.0, 0.0), &mut out).unwrap(); + let drone = sim.make_drone(DroneKind::Shahed, DVec3::new(0.0, 65.0, 0.0), -DVec3::Z, DVec3::new(0.0, 64.0, 1000.0)); + sim.drones.push(drone); + serve(&mut ground, &open, sim.tick); + sim.step(&mut ground, None, &mut out); + assert!(sim.rec.is_some()); + sim.drones.clear(); + for _ in 0..100 { + serve(&mut ground, &open, sim.tick); + sim.step(&mut ground, None, &mut out); + } + assert!(!sim.strikes.flights.is_empty(), "the strike landed too early for this scenario"); + assert!(sim.rec.is_some(), "the recording closed five seconds after the last drone"); + for _ in 0..400 { + serve(&mut ground, &open, sim.tick); + sim.step(&mut ground, None, &mut out); + if sim.rec.is_none() { + break; + } + } + assert!(sim.rec.is_none(), "the recording never closed after the strike and its quiet tail"); + let rec = sim.replays.last().expect("the late strike was not kept"); + assert!(rec.tracks.iter().any(|t| t.what == record::What::Strike)); + assert!(rec.events.iter().any(|e| matches!(e.kind, record::EventKind::Strike { .. }))); + } } diff --git a/airdef-mod/src/smoke.rs b/airdef-mod/src/smoke.rs index 8f1db63..6591a39 100644 --- a/airdef-mod/src/smoke.rs +++ b/airdef-mod/src/smoke.rs @@ -3,7 +3,7 @@ //! derives everything from a segment's age, so the columns and blasts are a pure function of the //! tick (nothing grows twice when a frame is shown again); trails are the only state, and they are //! cut back when the tick goes backwards. -use crate::sim::{Class, Sim}; +use crate::sim::{Class, DroneKind, Sim}; use glam::DVec3; use horizon_mod_sdk::render::SmokeSegment; use std::collections::HashMap; @@ -162,6 +162,8 @@ pub fn replay_smoke(rec: &crate::sim::record::Recording, t: f64) -> Vec missile_look(kind), + // A counter-strike leaves the trail of the cruise missile it is (#109). + What::Strike => Some(drone_look(Class::Cruise, DroneKind::Kalibr.spec().trail_scale())), }; let Some(look) = look else { continue }; let life = look.life as f64;