From 545d4777e80ceb204173eb726af05995079e8a1b Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 15:47:03 +0300 Subject: [PATCH] Kill outcomes: falling hull, breakup or air detonation by weapon and target, recorded for replays (#97) Co-Authored-By: Claude Opus 5.5 --- airdef-mod/src/film.rs | 11 +- airdef-mod/src/replay.rs | 30 ++- airdef-mod/src/sim/mod.rs | 112 +++++++++-- airdef-mod/src/sim/outcome.rs | 356 ++++++++++++++++++++++++++++++++++ airdef-mod/src/sim/record.rs | 81 +++++++- airdef-mod/src/sim/regress.rs | 2 +- 6 files changed, 555 insertions(+), 37 deletions(-) create mode 100644 airdef-mod/src/sim/outcome.rs diff --git a/airdef-mod/src/film.rs b/airdef-mod/src/film.rs index 04c7671..8666d1c 100644 --- a/airdef-mod/src/film.rs +++ b/airdef-mod/src/film.rs @@ -308,10 +308,15 @@ fn plan_event(rec: &Recording, i: usize, floor: f64) -> Vec { Some(Shot::new(a, b, &[(a, 1.0), (b, 1.4)], variants, head, "", i)) }; match e.kind { - EventKind::Kill { drone, kind, sam, sam_kind, sam_index, missile } => { + EventKind::Kill { drone, kind, sam, sam_kind, sam_index, missile, outcome } => { let sp = rec.sam(sam).map_or(pos - DVec3::new(150.0, 0.0, 0.0), |s| s.1); let name = format!("{} №{}", sam_kind.name(), sam_index + 1); - let sub = format!("Перехват «{}»", kind.spec().name); + let how = match outcome { + crate::sim::outcome::Outcome::DisabledFalling => "", + crate::sim::outcome::Outcome::Breakup => ": разрушение в воздухе", + crate::sim::outcome::Outcome::AirDetonation => ": детонация в воздухе", + }; + let sub = format!("Перехват «{}»{how}", kind.spec().name); let mt = missile.and_then(|m| rec.track(m).map(|t| (m, t.first as f64))); match mt { Some((m, tl)) => { @@ -638,7 +643,7 @@ mod tests { rec.events.push(Event { at: kill, pos, - kind: EventKind::Kill { drone: 1, kind: DroneKind::Geran3, sam: 5, sam_kind: SamKind::S400, sam_index: 0, missile: missile.then_some(2) }, + kind: EventKind::Kill { drone: 1, kind: DroneKind::Geran3, sam: 5, sam_kind: SamKind::S400, sam_index: 0, missile: missile.then_some(2), outcome: Default::default() }, }); rec } diff --git a/airdef-mod/src/replay.rs b/airdef-mod/src/replay.rs index d474a1c..9832484 100644 --- a/airdef-mod/src/replay.rs +++ b/airdef-mod/src/replay.rs @@ -125,13 +125,23 @@ fn clock(ticks: f64) -> String { format!("{:02}:{:04.1}", (s / 60.0) as u32, s % 60.0) } +/// How a kill ended, when it was not the plain fall. +fn outcome_note(outcome: crate::sim::outcome::Outcome) -> &'static str { + use crate::sim::outcome::Outcome; + match outcome { + Outcome::DisabledFalling => "", + Outcome::Breakup => " (разрушение)", + Outcome::AirDetonation => " (детонация в воздухе)", + } +} + /// What an event was, in a line. pub fn title(rec: &Recording, i: usize) -> String { let e = &rec.events[i]; match e.kind { - EventKind::Kill { kind, sam_kind, sam_index, missile, .. } => { + EventKind::Kill { kind, sam_kind, sam_index, missile, outcome, .. } => { let how = if missile.is_some() { "ракетой" } else { "огнём пушек" }; - format!("{} №{} сбивает «{}» {how}", sam_kind.name(), sam_index + 1, kind.spec().name) + format!("{} №{} сбивает «{}» {how}{}", sam_kind.name(), sam_index + 1, kind.spec().name, outcome_note(outcome)) } EventKind::Hit { kind, object: Some(o), .. } => format!("«{}» поражает объект №{}", kind.spec().name, o + 1), EventKind::Hit { kind, sam: Some((sk, si)), .. } => format!("«{}» бьёт по {} №{}", kind.spec().name, sk.name(), si + 1), @@ -146,8 +156,8 @@ pub fn title(rec: &Recording, i: usize) -> String { pub fn row(rec: &Recording, i: usize) -> String { let e = &rec.events[i]; let what = match e.kind { - EventKind::Kill { kind, sam_kind, sam_index, missile, .. } => { - format!("{}-{} > {}{}", sam_kind.name(), sam_index + 1, kind.spec().name, if missile.is_none() { " (пушки)" } else { "" }) + EventKind::Kill { kind, sam_kind, sam_index, missile, outcome, .. } => { + format!("{}-{} > {}{}{}", sam_kind.name(), sam_index + 1, kind.spec().name, if missile.is_none() { " (пушки)" } else { "" }, outcome_note(outcome)) } EventKind::Hit { kind, object: Some(o), .. } => format!("{} > объект {}", kind.spec().name, o + 1), EventKind::Hit { kind, sam: Some((sk, si)), .. } => format!("{} > {}-{}", kind.spec().name, sk.name(), si + 1), @@ -163,8 +173,9 @@ pub fn row(rec: &Recording, i: usize) -> String { pub fn interest(rec: &Recording, i: usize) -> f64 { let e = &rec.events[i]; match e.kind { - EventKind::Kill { kind, missile, .. } => { + EventKind::Kill { kind, missile, outcome, .. } => { let spec = kind.spec(); + let bonus = if outcome == crate::sim::outcome::Outcome::AirDetonation { 2.0 } else { 0.0 }; let class = match spec.class { Class::Ballistic => 10.0, Class::Supersonic => 7.0, @@ -179,7 +190,7 @@ pub fn interest(rec: &Recording, i: usize) -> f64 { }; // A kill close to what it was going for. let close = rec.objects.iter().chain(rec.sams.iter().map(|s| &s.2)).any(|o| o.distance(e.pos) < 40.0); - class + rare + if spec.decoy { -3.0 } else { 0.0 } + if missile.is_none() { 1.0 } else { 0.0 } + if close { 3.0 } else { 0.0 } + bonus + class + rare + if spec.decoy { -3.0 } else { 0.0 } + if missile.is_none() { 1.0 } else { 0.0 } + if close { 3.0 } else { 0.0 } } EventKind::Hit { power, object, sam, .. } => { power as f64 * 1.5 + if object.is_some() { 6.0 } else if sam.is_some() { 5.0 } else { 0.0 } @@ -439,13 +450,16 @@ impl Player { let heard = |p: DVec3| listener + (p - self.eye); for e in rec.events.iter().filter(|e| (self.heard..self.t).contains(&(e.at as f64))) { let event = match e.kind { - EventKind::Kill { .. } => "airdef:blast.air", + EventKind::Kill { kind, outcome, .. } => crate::sim::outcome::look(kind, outcome).3, EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][(power as usize).min(4)], EventKind::SamLost { .. } => "airdef:blast.big", EventKind::Split { .. } => "airdef:boom.sonic", }; - let gain = if let EventKind::Kill { kind, .. } = e.kind { crate::sim::record::kill_look(kind).2 } else { 1.0 }; + let gain = if let EventKind::Kill { kind, outcome, .. } = e.kind { crate::sim::outcome::look(kind, outcome).2 } else { 1.0 }; out.sound(event, heard(e.pos), gain, 1.0); + if let EventKind::Kill { outcome: crate::sim::outcome::Outcome::AirDetonation, .. } = e.kind { + out.sound("airdef:boom.sonic", heard(e.pos), 0.8, 1.2); + } } for tr in rec.tracks.iter().filter(|tr| (self.heard..self.t).contains(&(tr.first as f64))) { if let What::Missile(kind, _) = tr.what diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index cf171aa..b3f6c28 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -20,6 +20,7 @@ mod ew; pub mod lights; mod logistics; mod orders; +pub mod outcome; mod units; mod why; pub mod world; @@ -31,6 +32,7 @@ pub use ew::EW_RADIUS; pub use damage::{Comps, Part, PartState}; pub use sight::{Ident, Track}; use damage::dice; +use outcome::{Impact, Outcome}; pub use units::{Family, SamKind}; use units::Muzzle; pub use raid::{Approach, Aim, RaidOrder}; @@ -1375,6 +1377,8 @@ enum Fx { Fireball { pos: DVec3, size: f64, age: u32, life: u32, spin: DQuat }, /// A falling piece of a drone. Debris { pos: DVec3, vel: DVec3, rot: DQuat, spin: DQuat, age: u32 }, + /// A piece thrown off by a breakup or an air detonation: its own drag and life. + Piece { pos: DVec3, vel: DVec3, rot: DQuat, piece: outcome::Piece, age: u32 }, /// A glowing red tracer round. Tracer { pos: DVec3, vel: DVec3, life: u32 }, } @@ -2094,11 +2098,27 @@ impl Sim { self.fx.push((id, Fx::Fireball { pos, size, age: 0, life, spin })); } - /// A drone hit by air defence: a flash, pieces, and it falls burning. - fn shoot_down(&mut self, idx: usize, by: u32, missile: Option, out: &mut Out) { + /// A drone hit by the guns of system `by`. + fn shoot_down(&mut self, idx: usize, by: u32, missile: Option, out: &mut Out) -> Option { + let kind = self.sams.iter().find(|s| s.id == by).map_or(SamKind::Gepard, |s| s.kind); + let shell = kind.spec().gun.as_ref().map_or(5.0, |g| g.shell); + let at = self.drones[idx].pos; + self.shoot_down_by(idx, by, missile, Impact::gun(kind, at, shell), out) + } + + /// A drone brought down by `imp` from system `by`: how it ends is rolled once (combat randomness, so a + /// detonation really means no ground impact), recorded with the kill, and drawn from the visual randomness + /// only. The kill is counted here and nowhere else. + fn shoot_down_by(&mut self, idx: usize, by: u32, missile: Option, imp: Impact, out: &mut Out) -> Option { if !self.drones[idx].alive() { - return; + return None; } + let dkind = self.drones[idx].kind; + let roll = self.rng.f(); + let outcome = outcome::choose(dkind, &imp, roll); + let spec = dkind.spec(); + // A broken-up target's warhead section may stay whole: it falls on as a wreck (and may go off on landing). + let keeps_warhead = outcome == Outcome::Breakup && !spec.decoy && spec.warhead > 0 && self.rng.chance(outcome::WARHEAD_SURVIVES); let sector = self.drones[idx].sector as usize; self.learn(sector, true); let until = self.tick + 70; @@ -2106,33 +2126,72 @@ impl Sim { s.kill_view = Some((self.drones[idx].id, self.drones[idx].pos, until)); } if let Some((i, s)) = self.sams.iter().enumerate().find(|(_, s)| s.id == by) { - let (drone, kind, pos) = (self.drones[idx].id, self.drones[idx].kind, self.drones[idx].pos); - let kill = record::EventKind::Kill { drone, kind, sam: by, sam_kind: s.kind, sam_index: i, missile }; + let (drone, kind, pos) = (self.drones[idx].id, dkind, self.drones[idx].pos); + let kill = record::EventKind::Kill { drone, kind, sam: by, sam_kind: s.kind, sam_index: i, missile, outcome }; self.log(pos, kill); } let d = &mut self.drones[idx]; + let before = d.vel; d.flight = Flight::Falling; d.age = 0; - d.vel = d.vel * 0.6 + DVec3::new(0.0, 0.15, 0.0); + d.vel = match outcome { + Outcome::DisabledFalling => d.vel * 0.6 + DVec3::new(0.0, 0.15, 0.0), + _ => d.vel * 0.35 + DVec3::new(0.0, 0.1, 0.0), + }; let pos = d.pos; let vel = d.vel; self.kills += 1; if let Some(s) = self.sams.iter_mut().find(|s| s.id == by) { s.kills += 1; } - // The look of the kill goes by the warhead it blew up (replays use the same). - let (size, life, gain) = record::kill_look(self.drones[idx].kind); + // The look of the kill goes by the warhead it blew up and by how it ended (replays use the same). + let (size, life, gain, sound) = outcome::look(dkind, outcome); self.fireball(pos, size as f64, life / 9); - out.particles("minecraft:explosion", pos, 0.6, DVec3::ZERO, 0.0, 4); - out.particles("minecraft:large_smoke", pos, 1.0, DVec3::ZERO, 0.06, 25); - out.particles("minecraft:firework", pos, 0.3, DVec3::ZERO, 0.35, 30); - out.sound("airdef:blast.air", pos, gain, 1.0 + self.vis.f() as f32 * 0.2); - for _ in 0..5 { - let id = fx_ident(&mut self.fx_next); - let v = vel + DVec3::new(self.vis.s(), self.vis.f() * 0.8, self.vis.s()) * 0.35; - let spin = DQuat::from_euler(glam::EulerRot::XYZ, self.vis.s() * 0.4, self.vis.s() * 0.4, self.vis.s() * 0.4); - self.fx.push((id, Fx::Debris { pos, vel: v, rot: DQuat::IDENTITY, spin, age: 0 })); + let pitch = 1.0 + self.vis.f() as f32 * 0.2; + match outcome { + Outcome::DisabledFalling => { + out.particles("minecraft:explosion", pos, 0.6, DVec3::ZERO, 0.0, 4); + out.particles("minecraft:large_smoke", pos, 1.0, DVec3::ZERO, 0.06, 25); + out.particles("minecraft:firework", pos, 0.3, DVec3::ZERO, 0.35, 30); + for _ in 0..5 { + let id = fx_ident(&mut self.fx_next); + let v = vel + DVec3::new(self.vis.s(), self.vis.f() * 0.8, self.vis.s()) * 0.35; + let spin = DQuat::from_euler(glam::EulerRot::XYZ, self.vis.s() * 0.4, self.vis.s() * 0.4, self.vis.s() * 0.4); + self.fx.push((id, Fx::Debris { pos, vel: v, rot: DQuat::IDENTITY, spin, age: 0 })); + } + } + Outcome::Breakup => { + out.particles("minecraft:explosion", pos, 0.8, DVec3::ZERO, 0.0, 3); + out.particles("minecraft:large_smoke", pos, 1.4, DVec3::ZERO, 0.08, 30); + out.particles("minecraft:firework", pos, 0.4, DVec3::ZERO, 0.3, 14); + } + Outcome::AirDetonation => { + out.particles("minecraft:flash", pos, 0.0, DVec3::ZERO, 0.0, 1); + out.particles("minecraft:explosion_emitter", pos, 0.8, DVec3::ZERO, 0.0, 2); + out.particles("minecraft:large_smoke", pos, size * 0.3, DVec3::new(0.0, 0.05, 0.0), 0.07, if self.shader { 20 } else { 40 }); + out.particles("minecraft:firework", pos, 0.6, DVec3::ZERO, 0.5, 50); + // The shock arrives after the flash (`boom` sounds travel); the warhead's blast hurts what is near. + out.sound("airdef:boom.sonic", pos, 0.8, 1.2); + self.blast_damage(pos, 0, out); + } } + out.sound(sound, pos, gain, pitch); + let seed = self.vis.next(); + let mut alive = self.fx.iter().filter(|(_, f)| matches!(f, Fx::Piece { .. })).count(); + for p in outcome::pieces(seed, outcome, before) { + if alive + 1 > outcome::PIECES_MAX { + break; + } + let id = fx_ident(&mut self.fx_next); + let rot = DQuat::from_euler(glam::EulerRot::XYZ, self.vis.s(), self.vis.s(), self.vis.s()); + self.fx.push((id, Fx::Piece { pos, vel: p.vel, rot, piece: p, age: 0 })); + alive += 1; + } + if outcome == Outcome::AirDetonation || (outcome == Outcome::Breakup && !keeps_warhead) { + // The main mass is gone: nothing falls to the ground and nothing goes off there. + self.drones.remove(idx); + } + Some(outcome) } // ------------------------------------------------ air defence @@ -2486,7 +2545,7 @@ impl Sim { } for (t, by) in gun_hits { if let Some(i) = self.drones.iter().position(|d| d.id == t) { - self.shoot_down(i, by, None, out); + let _ = self.shoot_down(i, by, None, out); } } for i in 0..self.sams.len() { @@ -2679,7 +2738,8 @@ impl Sim { if let Some(t) = target.and_then(|t| self.drones.iter().position(|d| d.id == t)) { self.drones[t].engaged = self.drones[t].engaged.saturating_sub(1); if kill { - self.shoot_down(t, sam, Some(mid), out); + let (at, closing) = (self.drones[t].pos, (m.vel - self.drones[t].vel).length()); + let _ = self.shoot_down_by(t, sam, Some(mid), Impact::missile(m.kind, at, closing), out); } } // A missile that lost its target frees it for others. @@ -2730,6 +2790,20 @@ impl Sim { let landed = ground.at(*pos, now).is_some_and(|g| pos.y <= g); !landed && *age < 200 } + Fx::Piece { pos, vel, rot, piece, age } => { + *age += 1; + outcome::fly(pos, vel, piece.drag); + *rot = piece.spin * *rot; + out.model(id, "airdef:debris", *pos, *rot, piece.scale, false); + if piece.burns { + out.particles("minecraft:large_smoke", *pos, 0.1, DVec3::new(0.0, 0.03, 0.0), 0.01, 1); + if *age % 3 == 0 { + out.particles("minecraft:flame", *pos, 0.1, DVec3::ZERO, 0.01, 1); + } + } + let landed = ground.at(*pos, now).is_some_and(|g| pos.y <= g); + !landed && *age < piece.life + } Fx::Tracer { pos, vel, life } => { *pos += *vel; vel.y -= 0.02; diff --git a/airdef-mod/src/sim/outcome.rs b/airdef-mod/src/sim/outcome.rs new file mode 100644 index 0000000..9ff36cf --- /dev/null +++ b/airdef-mod/src/sim/outcome.rs @@ -0,0 +1,356 @@ +//! What a kill does to the air target (#97): it falls burning on, it breaks up, or its warhead goes +//! off in the air. The chances are game profiles, all here and nowhere else; they are not the real +//! weapons' figures. The roll is combat randomness (a detonation means no ground impact), one draw +//! per kill; everything drawn from it (pieces, sparks) is decoration and uses only the visual RNG. +//! The outcome is recorded with the kill, so a replay shows it and never rolls again. +use super::*; + +/// How an air target ended up when it was brought down. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum Outcome { + /// Control lost: the burning hull falls on with what momentum it had. A recording without an + /// outcome (an old one) is read as this, which is what every kill used to be. + #[default] + DisabledFalling, + /// The airframe tears into several pieces; the warhead section may survive and fall on. + Breakup, + /// The warhead goes off in the air: a flash and a rain of debris, nothing left to hit the ground. + AirDetonation, +} + +/// What brought the target down. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum KillCause { + /// Shells of this system's guns. + Gun(SamKind), + Missile(MissileKind), +} + +/// A hit, as far as the outcome is concerned. +#[derive(Clone, Copy, Debug)] +pub struct Impact { + pub cause: KillCause, + /// Game energy of the hit (see `energy_of`). + pub energy: f64, + pub at: DVec3, + /// Closing speed, blocks a tick. + pub rel_speed: f64, +} + +impl Impact { + pub fn gun(sam: SamKind, at: DVec3, rel_speed: f64) -> Self { + Impact { cause: KillCause::Gun(sam), energy: energy_of(KillCause::Gun(sam)), at, rel_speed } + } + pub fn missile(kind: MissileKind, at: DVec3, rel_speed: f64) -> Self { + Impact { cause: KillCause::Missile(kind), energy: energy_of(KillCause::Missile(kind)), at, rel_speed } + } +} + +// ---- the profiles: the one place to tune ------------------------------------------------------ + +/// Game energy of one hit: a shell is small, an interceptor's warhead is big. +pub fn energy_of(cause: KillCause) -> f64 { + match cause { + KillCause::Gun(SamKind::Gepard) => 0.15, + KillCause::Gun(_) => 0.13, + KillCause::Missile(m) => match m { + MissileKind::Pac3 => 1.0, + MissileKind::M57e6 => 0.5, + MissileKind::M48n6 => 1.2, + MissileKind::M77n6 => 1.6, + MissileKind::IrisT => 0.7, + MissileKind::Amraam => 0.6, + MissileKind::M9m317 => 0.8, + MissileKind::M9m338 => 0.7, + // Rams: a lot of speed, no warhead to speak of. + MissileKind::P1sun => 0.25, + }, + } +} + +/// How much a target takes before it comes apart: size, then what it carries. +pub fn toughness(kind: DroneKind) -> f64 { + let s = kind.spec(); + 0.2 + 0.2 * s.scale + 0.03 * s.warhead as f64 +} + +/// Chance of an air detonation: nothing without a warhead, rare for shells, likely when a strong +/// interceptor meets a small target. +const DET_BASE: f64 = 0.04; +const DET_SLOPE: f64 = 0.32; +const DET_FLOOR: f64 = 0.03; +const DET_CEIL: f64 = 0.75; +/// Of the rest, the share that breaks up instead of falling on. +const BREAK_BASE: f64 = 0.12; +const BREAK_SLOPE: f64 = 0.3; +const BREAK_CEIL: f64 = 0.8; +/// A broken-up target whose warhead section stays whole and falls on (and may cook off on landing). +pub const WARHEAD_SURVIVES: f64 = 0.3; + +/// The relative strength of the hit against the target. +pub fn ratio(kind: DroneKind, imp: &Impact) -> f64 { + let speed = (0.8 + 0.05 * imp.rel_speed).clamp(0.8, 1.2); + imp.energy * speed / toughness(kind) +} + +/// (chance of an air detonation, chance of a breakup) of a hit on `kind`; the rest falls on. +pub fn odds(kind: DroneKind, imp: &Impact) -> (f64, f64) { + let s = kind.spec(); + let r = ratio(kind, imp); + let det = if s.decoy || s.warhead == 0 { 0.0 } else { (DET_BASE + DET_SLOPE * (r - 0.4)).clamp(DET_FLOOR, DET_CEIL) }; + let share = (BREAK_BASE + BREAK_SLOPE * r).clamp(0.0, BREAK_CEIL); + (det, (1.0 - det) * share) +} + +/// The outcome for a roll of 0..1. +pub fn choose(kind: DroneKind, imp: &Impact, roll: f64) -> Outcome { + let (det, brk) = odds(kind, imp); + if roll < det { + Outcome::AirDetonation + } else if roll < det + brk { + Outcome::Breakup + } else { + Outcome::DisabledFalling + } +} + +/// The look of a kill by the target's warhead and the outcome: (fireball radius, ticks it burns, +/// loudness, sound). A bare decoy pops; a detonation lights the sky. The live raid and its replay +/// both take it from here. +pub fn look(kind: DroneKind, outcome: Outcome) -> (f32, u32, f32, &'static str) { + let w = kind.spec().warhead.min(8) as f32; + let size = 3.5 * (0.85 + 0.12 * w); + let life = (8 + (w as u32) / 2) * 9; + let gain = 1.0 + 0.06 * w; + match outcome { + Outcome::DisabledFalling => (size, life, gain, "airdef:blast.air"), + Outcome::Breakup => (size * 0.9, life, gain * 0.9, "airdef:blast.medium"), + Outcome::AirDetonation => (size * 1.9, life * 8 / 5, gain * 1.5, "airdef:blast.big"), + } +} + +// ---- pieces ----------------------------------------------------------------------------------- + +/// Most pieces alive at once; a mass battle thins them out. +pub const PIECES_MAX: usize = 320; +/// Longest a piece lives, ticks (a replay's pieces end here too). +pub const PIECE_LIFE: u32 = 110; + +/// One piece thrown off: its own velocity, spin, size, drag and life. +#[derive(Clone, Copy, Debug)] +pub struct Piece { + pub vel: DVec3, + pub spin: DQuat, + pub scale: f64, + pub drag: f64, + pub life: u32, + /// Trails smoke while it falls. + pub burns: bool, +} + +/// The pieces of an outcome from `seed` (decorative randomness only), moving on with `base` (the +/// target's velocity). None for a fall. +pub fn pieces(seed: u64, outcome: Outcome, base: DVec3) -> Vec { + let (n, speed, size, life) = match outcome { + Outcome::DisabledFalling => return Vec::new(), + Outcome::Breakup => (7, 0.35, 1.0, 70), + Outcome::AirDetonation => (12, 0.8, 0.8, 50), + }; + let mut rng = Rng::new(seed ^ 0x9e37_79b9_7f4a_7c15); + (0..n) + .map(|_| { + let dir = DVec3::new(rng.s(), rng.s() * 0.8 + 0.2, rng.s()).normalize_or(DVec3::Y); + let v = base * if outcome == Outcome::Breakup { 0.55 } else { 0.3 } + dir * speed * (0.4 + 0.8 * rng.f()); + let spin = DQuat::from_euler(glam::EulerRot::XYZ, rng.s() * 0.5, rng.s() * 0.5, rng.s() * 0.5); + Piece { + vel: v, + spin, + scale: size * (0.5 + 0.8 * rng.f()), + drag: 0.96 + 0.025 * rng.f(), + life: (life as f64 * (0.6 + 0.4 * rng.f())) as u32, + burns: rng.chance(0.6), + } + }) + .collect() +} + +/// One tick of a piece's flight: gravity, then drag (the replays and the live raid move the same). +pub fn fly(pos: &mut DVec3, vel: &mut DVec3, drag: f64) { + vel.y -= 0.05; + *vel *= drag; + *pos += *vel; +} + +/// Where a piece is `age` ticks after it was thrown from `at`, as a pure function (for replays). +pub fn piece_at(at: DVec3, p: &Piece, age: u32) -> DVec3 { + let (mut pos, mut vel) = (at, p.vel); + for _ in 0..age.min(p.life) { + fly(&mut pos, &mut vel, p.drag); + } + pos +} + +/// How a piece is turned `age` ticks after it was thrown (a replay's, from the start). +pub fn piece_rot(p: &Piece, age: u32) -> DQuat { + let mut rot = DQuat::IDENTITY; + for _ in 0..age.min(p.life) { + rot = p.spin * rot; + } + rot +} + +#[cfg(test)] +mod tests { + use super::*; + + fn shares(kind: DroneKind, imp: Impact, seeds: u64) -> [u32; 3] { + let mut n = [0u32; 3]; + let mut rng = Rng::new(97); + for _ in 0..seeds { + let roll = rng.f(); + n[match choose(kind, &imp, roll) { + Outcome::DisabledFalling => 0, + Outcome::Breakup => 1, + Outcome::AirDetonation => 2, + }] += 1; + } + n + } + + // #97: the profiles are tendencies, not laws: shells mostly leave a falling hull, a strong interceptor + // against a small target often detonates it, and everything still can happen. + #[test] + fn outcomes_follow_the_weapon_and_the_target() { + let at = DVec3::ZERO; + let gun = shares(DroneKind::Shahed, Impact::gun(SamKind::Gepard, at, 10.0), 4000); + assert!(gun[0] > 2400 && gun[2] < 400, "gun against a Shahed: {gun:?}"); + assert!(gun[1] > 0 && gun[2] > 0, "not a law: some break up, a few go off: {gun:?}"); + let strong = shares(DroneKind::Shahed, Impact::missile(MissileKind::M77n6, at, 6.0), 4000); + assert!(strong[2] > 2000, "a strong interceptor against a small target: {strong:?}"); + let weak = shares(DroneKind::Shahed, Impact::missile(MissileKind::M57e6, at, 5.0), 4000); + assert!(weak[2] < strong[2] && weak[2] > gun[2], "in between: {weak:?}"); + // A big missile is tougher than a small drone for the same interceptor. + let big = shares(DroneKind::Iskander, Impact::missile(MissileKind::Pac3, at, 6.0), 4000); + let small = shares(DroneKind::Shahed, Impact::missile(MissileKind::Pac3, at, 6.0), 4000); + assert!(big[2] < small[2], "{big:?} {small:?}"); + // Without a warhead nothing goes off: a decoy or a bus breaks up or falls. + for kind in [DroneKind::Gerbera, DroneKind::Oreshnik] { + assert_eq!(shares(kind, Impact::missile(MissileKind::M77n6, at, 6.0), 2000)[2], 0, "{kind:?}"); + } + } + + fn kill(seed: u64, shader: bool, imp: impl Fn(DVec3) -> Impact) -> (Outcome, usize, u64) { + let mut sim = Sim::new(seed); + sim.shader = shader; + let mut out = Out::default(); + let sam = sim.id(); + sim.sams.push(Sam::new(sam, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0)); + sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, 300.0), DVec3::X, DVec3::new(900.0, 70.0, 0.0)); + let at = sim.drones[0].pos; + let o = sim.shoot_down_by(0, sam, None, imp(at), &mut out).unwrap(); + (o, sim.drones.len(), sim.rng.state()) + } + + // #97: the roll is combat randomness: the same with graphics on and off, and the drawing never moves it. + #[test] + fn the_outcome_is_the_same_with_graphics_on_and_off() { + for seed in 1..40 { + let strong = |at| Impact::missile(MissileKind::M77n6, at, 6.0); + assert_eq!(kill(seed, true, strong), kill(seed, false, strong), "seed {seed}"); + let gun = |at| Impact::gun(SamKind::Gepard, at, 10.0); + assert_eq!(kill(seed, true, gun), kill(seed, false, gun), "seed {seed}"); + } + } + + // #97: a detonated warhead is spent: no wreck falls, so no second blast on landing; the kill counts once. + #[test] + fn a_detonated_target_does_no_ground_damage_and_counts_once() { + let mut found = 0; + for seed in 1..60 { + let mut sim = Sim::new(seed); + sim.damage = true; + let mut out = Out::default(); + let mut ground = Ground::default(); + ground.answer((-50..50).flat_map(|x| (-50..400).map(move |z| (x, z, 70))).collect(), 0); + let sam = sim.id(); + sim.sams.push(Sam::new(sam, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0)); + sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, 300.0), DVec3::X, DVec3::new(900.0, 70.0, 0.0)); + let at = sim.drones[0].pos; + let o = sim.shoot_down_by(0, sam, Some(5), Impact::missile(MissileKind::M77n6, at, 6.0), &mut out).unwrap(); + if o != Outcome::AirDetonation { + continue; + } + found += 1; + assert!(sim.drones.is_empty(), "the main mass is gone"); + assert_eq!((sim.kills, sim.hits, sim.crashes), (1, 0, 0)); + assert_eq!(sim.sams[0].kills, 1); + let mut cmds = out.commands.len(); + for _ in 0..400 { + sim.tick += 1; + let mut o = Out::default(); + sim.step_drones(&mut ground, None, &mut o); + sim.step_fx(&mut ground, &mut o); + cmds += o.commands.len(); + assert!(o.sounds.iter().all(|s| !s.0.starts_with("airdef:blast")), "a second blast"); + } + assert_eq!(cmds, 0, "no primed TNT for the land from a detonated warhead"); + assert_eq!(sim.kills, 1); + } + assert!(found >= 10, "{found} detonations in 59 seeds"); + } + + // #97: a breakup throws pieces that have a life: they expire, and the number alive is bounded in a mass battle. + #[test] + fn pieces_expire_and_a_mass_battle_keeps_them_bounded() { + let mut sim = Sim::new(3); + let mut out = Out::default(); + let mut ground = Ground::default(); + for i in 0..150 { + let sam = sim.id(); + sim.sams.push(Sam::new(sam, SamKind::Patriot, DVec3::new(f64::from(i) * 500.0, 70.0, 0.0), 0.0)); + sim.add_drone(DroneKind::Shahed, DVec3::new(f64::from(i) * 500.0, 400.0, 300.0), DVec3::X, DVec3::new(9e5, 70.0, 0.0)); + let at = sim.drones.last().unwrap().pos; + let idx = sim.drones.len() - 1; + let _ = sim.shoot_down_by(idx, sam, None, Impact::missile(MissileKind::M77n6, at, 6.0), &mut out); + } + let pieces = |s: &Sim| s.fx.iter().filter(|(_, f)| matches!(f, Fx::Piece { .. })).count(); + let n = pieces(&sim); + assert!(n > 0 && n <= PIECES_MAX, "{n} pieces"); + for _ in 0..PIECE_LIFE + 1 { + sim.tick += 1; + sim.step_fx(&mut ground, &mut Out::default()); + } + assert_eq!(pieces(&sim), 0, "pieces expire"); + } + + // #97: a breakup either keeps the warhead section (a wreck still falling, which may go off on landing by the + // existing roll) or leaves nothing to fall. + #[test] + fn a_breakup_keeps_the_warhead_section_only_sometimes() { + let (mut kept, mut gone) = (0, 0); + for seed in 1..200 { + let (o, drones, _) = kill(seed, true, |at| Impact::missile(MissileKind::M57e6, at, 5.0)); + if o == Outcome::Breakup { + if drones == 1 { + kept += 1; + } else { + gone += 1; + } + } + } + assert!(kept > 0 && gone > kept, "kept {kept} gone {gone}"); + } + + // #97: a flight of a piece is a pure function of its age (so a replay moves it as the raid did). + #[test] + fn a_pieces_flight_is_the_same_stepped_or_computed() { + let ps = pieces(5, Outcome::AirDetonation, DVec3::new(0.5, 0.0, 0.2)); + assert!(!ps.is_empty() && pieces(5, Outcome::DisabledFalling, DVec3::ZERO).is_empty()); + let (mut pos, mut vel) = (DVec3::new(1.0, 100.0, 2.0), ps[0].vel); + for _ in 0..20 { + fly(&mut pos, &mut vel, ps[0].drag); + } + assert!(pos.distance(piece_at(DVec3::new(1.0, 100.0, 2.0), &ps[0], 20)) < 1e-9); + assert!(ps.iter().all(|p| p.life <= PIECE_LIFE)); + } +} diff --git a/airdef-mod/src/sim/record.rs b/airdef-mod/src/sim/record.rs index 8e85a8e..52f1436 100644 --- a/airdef-mod/src/sim/record.rs +++ b/airdef-mod/src/sim/record.rs @@ -168,8 +168,9 @@ pub struct Track { #[derive(Clone, Copy, PartialEq, Debug)] pub enum EventKind { - /// A drone shot down: by which system (its index then), with a missile or the guns. - Kill { drone: u32, kind: DroneKind, sam: u32, sam_kind: SamKind, sam_index: usize, missile: Option }, + /// A drone shot down: by which system (its index then), with a missile or the guns, and how it ended + /// (rolled once, live; a replay reads it and never rolls). + Kill { drone: u32, kind: DroneKind, sam: u32, sam_kind: SamKind, sam_index: usize, missile: Option, outcome: Outcome }, /// A drone's warhead went off where it aimed: at an object, a system, or the land. Hit { drone: u32, kind: DroneKind, power: u32, object: Option, sam: Option<(SamKind, usize)> }, /// A system destroyed. @@ -331,8 +332,8 @@ pub struct ReplayFire { /// The look of an interception by the drone's warhead: (fireball radius, ticks it burns, loudness). /// A bare decoy pops; a big warhead lights the sky. The same in the live raid and its replay. pub fn kill_look(kind: DroneKind) -> (f32, u32, f32) { - let w = kind.spec().warhead.min(8) as f32; - (3.5 * (0.85 + 0.12 * w), (8 + (w as u32) / 2) * 9, 1.0 + 0.06 * w) + let (size, life, gain, _) = outcome::look(kind, Outcome::DisabledFalling); + (size, life, gain) } /// A blast's look for an event: (fireball radius, ticks it burns). The live blasts use the same. @@ -340,8 +341,8 @@ pub fn blast_spec(kind: EventKind) -> (f32, u32) { const SIZE: [f32; 5] = [2.5, 4.5, 7.0, 10.0, 13.0]; const LIFE: [u32; 5] = [11, 11, 18, 22, 26]; match kind { - EventKind::Kill { kind, .. } => { - let (size, life, _) = kill_look(kind); + EventKind::Kill { kind, outcome, .. } => { + let (size, life, _, _) = outcome::look(kind, outcome); (size, life) } EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => { @@ -581,6 +582,7 @@ impl Sim { /// Draws the recording's drones and interceptors as they were at `t`, as ghosts: ids from `base`. pub fn draw_ghosts(&self, rec: &Recording, t: f64, base: u32, out: &mut Out) { + let base_id = base; for (k, tr) in rec.tracks.iter().enumerate() { let Some(pos) = tr.at(rec.origin, t) else { continue }; let dir = tr.dir(rec.origin, t); @@ -660,6 +662,31 @@ impl Sim { out.particles("minecraft:large_smoke", f.at + DVec3::Y, 0.8, DVec3::new(0.0, 0.1, 0.0), 0.03, 1 + (smoke * 2.0) as u32); } } + // The pieces a breakup or an air detonation threw off, from the recorded outcome (never a new roll): the same + // pieces the live raid made, moved by the same flight, a pure function of the time. + for (k, e) in rec.events.iter().enumerate() { + let EventKind::Kill { drone, outcome, .. } = e.kind else { continue }; + let age = t - e.at as f64; + if outcome == Outcome::DisabledFalling || !(0.0..outcome::PIECE_LIFE as f64).contains(&age) { + continue; + } + let base = rec + .track(drone) + .and_then(|tr| Some((tr.at(rec.origin, e.at as f64)? - tr.at(rec.origin, e.at as f64 - STEP as f64)?) / STEP as f64)) + .unwrap_or(DVec3::ZERO); + let seed = (k as u64).wrapping_mul(0x2545_f491_4f6c_dd1d) ^ e.at as u64; + for (n, p) in outcome::pieces(seed, outcome, base).iter().enumerate() { + if age as u32 >= p.life { + continue; + } + let at = outcome::piece_at(e.pos, p, age as u32); + let id = base_id + 0x0060_0000 + (k as u32 * 16 + n as u32) * PARTS; + out.model(id, "airdef:debris", at, outcome::piece_rot(p, age as u32), p.scale, false); + if p.burns && (age as u32) % 3 == 0 { + out.particles("minecraft:large_smoke", at, 0.1, DVec3::new(0.0, 0.03, 0.0), 0.01, 1); + } + } + } // Blasts: with the shader effects on, the post pass and the HD lights draw them from // `replay_blasts` (the same as the live ones); the models are the fallback without them. if self.shader { @@ -793,4 +820,46 @@ mod tests { assert_eq!(r.world_tick(50.0), Some(1040 + 10)); assert_eq!(r.world_tick(70.0), Some(1100 + 10)); } + + // #97: the kill's outcome is a recorded fact: a replay draws the pieces of that outcome and rolls nothing; + // an event without one (an old recording) is the plain fall it always was. + #[test] + fn a_replay_shows_the_recorded_outcome() { + let mut sim = Sim::new(5); + sim.rec = Some(Recording::new(1, &sim)); + let sam = sim.id(); + sim.sams.push(Sam::new(sam, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0)); + sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, 300.0), DVec3::X, DVec3::new(900.0, 70.0, 0.0)); + let at = sim.drones[0].pos; + let live = sim.shoot_down_by(0, sam, None, Impact::missile(MissileKind::M77n6, at, 6.0), &mut Out::default()).unwrap(); + let rec = sim.rec.take().unwrap(); + let recorded = rec.events.iter().find_map(|e| if let EventKind::Kill { outcome, .. } = e.kind { Some(outcome) } else { None }); + assert_eq!(recorded, Some(live), "the live outcome is what was recorded"); + let pieces = |rec: &Recording, t: f64| { + let mut out = Out::default(); + sim.draw_ghosts(rec, t, 0x5000_0000, &mut out); + out.models.iter().filter(|m| m.name == "airdef:debris").count() + }; + let at_tick = rec.events[0].at as f64; + // The same frame twice is the same (a pure function of the time), and the pieces are the outcome's. + let mut forced = Recording { events: rec.events.clone(), ..Recording::blank(200) }; + for (outcome, want) in [(Outcome::AirDetonation, true), (Outcome::Breakup, true), (Outcome::DisabledFalling, false)] { + if let EventKind::Kill { outcome: o, .. } = &mut forced.events[0].kind { + *o = outcome; + } + assert_eq!(pieces(&forced, at_tick + 5.0), pieces(&forced, at_tick + 5.0)); + assert_eq!(pieces(&forced, at_tick + 5.0) > 0, want, "{outcome:?}"); + assert_eq!(pieces(&forced, at_tick + f64::from(outcome::PIECE_LIFE) + 1.0), 0, "the pieces are gone in time"); + } + // Its flash and sound are the outcome's too. + let spec = |o| { + let mut e = rec.events[0]; + if let EventKind::Kill { outcome, .. } = &mut e.kind { + *outcome = o; + } + blast_spec(e.kind) + }; + assert!(spec(Outcome::AirDetonation).0 > spec(Outcome::DisabledFalling).0); + assert_eq!(spec(Outcome::default()).0, kill_look(DroneKind::Shahed).0, "an old recording looks as it did"); + } } diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index 1bd362b..1e67235 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -473,7 +473,7 @@ fn pending_exact_answer_does_not_let_the_fuse_kill_through_a_wall() { ground.world.answer(answers, sim.tick); } } - (first, sim.kills, sim.drones[0].alive()) + (first, sim.kills, sim.drones.first().is_some_and(Drone::alive)) }; for seed in 1..=10 { // Just behind the wall, inside the fuse's reach of a missile held at its face.