Counter-strikes collide, persist and record their flights (#106 #108 #109 #113)

Co-Authored-By: Kimi <noreply@moonshot.ai>
This commit is contained in:
2026-11-01 02:34:39 +03:00
parent 1a693763fd
commit 31d967b036
10 changed files with 646 additions and 70 deletions
+2 -1
View File
@@ -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 <patriot|gepard|pantsir|s400|s500|all> <классы|default>` — задать (классы через запятую: `drone`, `jet`, `cruise`, `supersonic`, `ballistic` или `all`), например `/airdef roles s400 cruise,ballistic`;
- `/airdef doctrine` — доктрины комплексов (#47; в меню N: «Доктрины…»): `/airdef doctrine <номер|all> reserve <K>` — держать K ракет для срочных целей (баллистика или до удара меньше 12 с); `salvo eco|normal|heavy` — сколько перехватчиков на цель (только базовые / как обычно / на один больше); `sector <центр°> <полуширина°>` или `sector off` — сектор ответственности (0° — север); `guard <объект№>[,<№>]` или `guard off` — стрелять только по тому, что идёт на охраняемые объекты (отметка: `/airdef target here`). По умолчанию ограничений нет; последний шанс сильнее доктрины; `/airdef why <номер>` называет, какая настройка остановила или разрешила пуск; сохраняется с миром и сценариями. Карта охраны и пробелов в ней — вместе с картой (#48).
- `/airdef deploy <patriot|gepard|pantsir|s400|s500>` — поставить комплекс в 8 блоках перед собой;
+50 -15
View File
@@ -101,7 +101,7 @@ struct AirDef {
hd: bool,
/// The world whose state is loaded (`game.world-id`), once one is.
world: Option<Option<String>>,
/// 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<Page>,
@@ -1060,6 +1060,29 @@ fn world_identity() -> Option<String> {
})
}
/// 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::<u32>().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");
}
}
+5 -3
View File
@@ -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] {
+1 -3
View File
@@ -139,9 +139,7 @@ pub fn draw(sim: &Sim, player: DVec3, yaw: f32) -> Vec<Shape> {
}
// 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));
}
}
+8
View File
@@ -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 {
+2 -1
View File
@@ -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<u32> = 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;
}
+26 -8
View File
@@ -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
+144 -3
View File
@@ -164,6 +164,10 @@ pub struct Sites {
pub list: Vec<LaunchSite>,
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<u32>,
/// 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<u32> = 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<u32> {
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");
}
}
+405 -35
View File
@@ -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<Item = u32> + '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<DVec3>, 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<u32> {
let mut v: Vec<u32> = self.strikes.flights.iter().map(|f| f.site).collect();
let alive: Vec<u32> = 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<DVec3>, 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<DVec3>, 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<DVec3>, 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<bool> {
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<record::EventKind> {
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 { .. })));
}
}
+3 -1
View File
@@ -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<SmokeSeg
Some(drone_look(spec.class, spec.trail_scale()))
}
What::Missile(kind, _) => 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;