diff --git a/airdef-mod/src/film.rs b/airdef-mod/src/film.rs index 88c8644..e97d7ff 100644 --- a/airdef-mod/src/film.rs +++ b/airdef-mod/src/film.rs @@ -7,8 +7,8 @@ //! raid, so the same film comes out every time. The land is asked for before the film starts (the //! planner needs the heights under every camera and along every line of sight); each shot has a //! few candidate placements, and the one the land hides least is kept; the camera is lifted over -//! the ground wherever it would dip into it. Only what the recording knows is shown: a launch is -//! the interceptor's first point, a kill the recorded event; no lock-on or detection is invented. +//! the ground wherever it would dip into it. Only what the recording knows is shown: launches use the recorded transition where available (older tracks use their first point); +//! no lock-on or detection is invented. use crate::replay::{interest, title}; use crate::sim::record::{EventKind, Recording, STEP}; use crate::sim::Ground; @@ -309,7 +309,8 @@ fn plan_event(rec: &Recording, i: usize, floor: f64) -> Vec { }; match e.kind { 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 launch_tick = missile.and_then(|m| rec.missile_launch(m).map(|e| e.at as f64)).unwrap_or(te); + let sp = rec.sam_at(sam, launch_tick).map_or(pos - DVec3::new(150.0, 0.0, 0.0), |s| s.1); let name = format!("{} №{}", sam_kind.name(), sam_index + 1); let how = match outcome { crate::sim::outcome::Outcome::DisabledFalling => "", @@ -317,7 +318,7 @@ fn plan_event(rec: &Recording, i: usize, floor: f64) -> Vec { 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))); + let mt = missile.and_then(|m| rec.track(m).map(|t| (m, rec.missile_launch(m).map_or(t.first as f64, |e| e.at as f64)))); match mt { Some((m, tl)) => { // The facts, in order: the launcher waits, the interceptor leaves it at `tl`, it diff --git a/airdef-mod/src/replay.rs b/airdef-mod/src/replay.rs index 9cebbd6..03a2fa4 100644 --- a/airdef-mod/src/replay.rs +++ b/airdef-mod/src/replay.rs @@ -497,7 +497,7 @@ impl Player { } } What::Missile(kind, _) => { - if self.t - tr.first as f64 > kind.lit_after() as f64 { + if rec.missile_is_lit(tr, self.t, kind.lit_after()) { let (event, gain, pitch) = kind.engine_voice(); out.engine(key, event, at, vel, gain, pitch, false); } @@ -534,9 +534,20 @@ impl Player { out.sound("airdef:boom.sonic", heard(e.pos), 0.8, 1.2); } } + // New recordings carry the exact launch and ignition sounds and their actual positions. + if let Some(scene) = &rec.scene { + for e in scene.events().iter().filter(|e| (self.heard..self.t).contains(&(e.at as f64))) { + match e.kind { + crate::sim::record::SceneKind::MissileLaunch { sound, pitch, .. } => out.sound(sound, heard(e.pos), 1.0, pitch), + crate::sim::record::SceneKind::MotorIgnition { sound: Some(sound), .. } => out.sound(sound, heard(e.pos), 1.0, 1.0), + _ => {} + } + } + } for tr in rec.tracks.iter().filter(|tr| (self.heard..self.t).contains(&(tr.first as f64))) { let event = match tr.what { - What::Missile(kind, _) => Some(kind.launch_sound()), + What::Missile(kind, _) if rec.missile_launch(tr.id).is_none() => Some(kind.launch_sound()), + What::Missile(..) => None, What::Strike => Some(DroneKind::Kalibr.launch_sound()), What::Drone(_) => None, }; @@ -1107,6 +1118,36 @@ mod engine_tests { let _ = rec; p } + #[test] + fn recorded_launch_and_ignition_sounds_use_exact_events_without_duplicates() { + use crate::sim::record::{SceneEvent, SceneJournal, SceneKind}; + let mut rec = Recording::blank(100); + rec.straight(2, What::Missile(MissileKind::M48n6, 5), 20, 80, DVec3::new(0.0, 66.0, 0.0), DVec3::new(0.0, 70.0, 300.0)); + let mut journal = SceneJournal::default(); + journal.fixture_push(SceneEvent { at: 19, pos: DVec3::new(2.0, 66.0, 0.0), kind: SceneKind::MissileLaunch { missile: 2, sam: 5, target: 1, kind: MissileKind::M48n6, sound: "airdef:launch.s400_eject", pitch: 1.0 } }); + journal.fixture_push(SceneEvent { at: 24, pos: DVec3::new(2.0, 75.0, 0.0), kind: SceneKind::MotorIgnition { missile: 2, kind: MissileKind::M48n6, sound: Some("airdef:launch.s400") } }); + rec.scene = Some(journal); + let mut p = player(&rec); + let mut out = Out::default(); + p.heard = 18.0; + p.t = 21.0; + p.sounds(&rec, DVec3::ZERO, &mut out); + assert_eq!(out.sounds.len(), 1, "the first track sample must not repeat the launch"); + assert_eq!(out.sounds[0].0, "airdef:launch.s400_eject"); + out.sounds.clear(); + p.t = 25.0; + p.sounds(&rec, DVec3::ZERO, &mut out); + assert_eq!(out.sounds.len(), 1); + assert_eq!(out.sounds[0].0, "airdef:launch.s400"); + assert!(!rec.missile_is_lit(&rec.tracks[0], 23.0, 5)); + assert!(rec.missile_is_lit(&rec.tracks[0], 24.0, 5)); + out.sounds.clear(); + p.heard = 18.0; + p.t = 25.0; + p.sounds(&rec, DVec3::ZERO, &mut out); + assert_eq!(out.sounds.iter().map(|s| s.0).collect::>(), vec!["airdef:launch.s400_eject", "airdef:launch.s400"]); + } + #[test] fn counter_strike_launch_is_heard_once_and_can_be_replayed_after_seek() { let mut rec = Recording::blank(100); diff --git a/airdef-mod/src/sim/damage.rs b/airdef-mod/src/sim/damage.rs index 705d4c9..8c7d3a7 100644 --- a/airdef-mod/src/sim/damage.rs +++ b/airdef-mod/src/sim/damage.rs @@ -186,6 +186,21 @@ impl DroneKind { } impl Sim { + /// Apply combat consequences first, independently of whether a recorder is active. + pub(super) fn emit_combat_event(&mut self, pos: DVec3, kind: record::EventKind) { + match kind { + record::EventKind::Crash { power, .. } => self.ignite(pos, power, None), + record::EventKind::Hit { power, sam, .. } => { + let host = sam.and_then(|(_, i)| self.sams.get(i)).map(|s| s.id); + self.ignite(pos, power.max(1), host) + } + record::EventKind::SamLost { sam, .. } => self.ignite(pos, 2, Some(sam)), + record::EventKind::Strike { result, .. } if record::strike_power(result) > 0 => self.ignite(pos, record::strike_power(result), None), + _ => {} + } + self.record_event(pos, kind); + } + /// The aim error of a new strike: drawn once from the seed, the drone keeps it for its whole flight. pub(super) fn aim_error(&mut self, kind: DroneKind) -> DVec3 { let (cep, gross) = kind.accuracy(); @@ -330,7 +345,7 @@ impl Sim { let tnt = if charge > 4.0 && self.rng.chance(0.4) { 2 } else { 1 }; self.later.push((when, at, tnt, true)); } - self.log(pos, record::EventKind::SamLost { sam: id, kind, index: i }); + self.emit_combat_event(pos, record::EventKind::SamLost { sam: id, kind, index: i }); // Shells pop in strings. for k in 0..(bursts / 12).min(14) { let when = now + 10 + (k as u64) * 6 + (self.rng.f() * 8.0) as u64; diff --git a/airdef-mod/src/sim/fires.rs b/airdef-mod/src/sim/fires.rs index 3fc2feb..3ccc65b 100644 --- a/airdef-mod/src/sim/fires.rs +++ b/airdef-mod/src/sim/fires.rs @@ -84,6 +84,9 @@ impl Fires { } impl Fire { + /// The observed surface for recording; None means it has not been resolved. + pub(super) fn surface_is_water(&self) -> Option { self.land.map(|land| !land) } + fn take_blocks(self) -> Vec { self.blocks .into_iter() diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index 10f616c..8bf79c6 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -1868,6 +1868,7 @@ impl Sim { self.adapt(); self.step_settling(ground); self.release_queue(ground, out); + self.begin_recording_if_active(); self.step_drones(ground, player, out); self.step_later(ground, player, out); self.step_moves(ground, out); @@ -2209,7 +2210,7 @@ impl Sim { } } for (drone, pos) in splits { - self.log(pos, record::EventKind::Split { drone }); + self.emit_combat_event(pos, record::EventKind::Split { drone }); } for (drone, kind, pos, tnt) in crashes { self.crashes += 1; @@ -2220,7 +2221,7 @@ impl Sim { 6 | 7 => 3, _ => 4, }; - self.log(pos, record::EventKind::Crash { drone, kind, power }); + self.emit_combat_event(pos, record::EventKind::Crash { drone, kind, power }); } for (drone, kind, pos, tnt) in impacts { if let Some(sector) = self.drones.iter().find(|x| x.id == drone).map(|x| x.sector as usize) { @@ -2235,7 +2236,7 @@ impl Sim { }; let object = self.targets.iter().position(|t| t.distance(pos) < 16.0); let sam = self.sams.iter().enumerate().find(|(_, s)| s.alive() && s.pos.distance(pos) < 12.0).map(|(i, s)| (s.kind, i)); - self.log(pos, record::EventKind::Hit { drone, kind, power, object, sam }); + self.emit_combat_event(pos, record::EventKind::Hit { drone, kind, power, object, sam }); let vel = self.drones.iter().find(|x| x.id == drone).map_or(DVec3::ZERO, |x| x.vel); self.direct_hit(pos, vel, tnt, out); } @@ -2406,7 +2407,7 @@ impl Sim { if let Some((i, s)) = self.sams.iter().enumerate().find(|(_, s)| s.id == by) { 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); + self.emit_combat_event(pos, kill); } let d = &mut self.drones[idx]; let before = d.vel; @@ -2857,6 +2858,7 @@ impl Sim { out.particles("minecraft:cloud", muzzle, 0.6, dir * 0.05, 0.05, 30); let event = if kind == MissileKind::M77n6 { "airdef:launch.s500_eject" } else { "airdef:launch.s400_eject" }; out.sound(event, muzzle, 1.0, 1.0); + self.scene_event(muzzle, record::SceneKind::MissileLaunch { missile: id, sam, target, kind, sound: event, pitch: 1.0 }); } else { out.particles("minecraft:flash", muzzle, 0.0, DVec3::ZERO, 0.0, 1); if spec.flame > 0.0 { @@ -2864,7 +2866,9 @@ impl Sim { out.particles("minecraft:campfire_cosy_smoke", muzzle - dir * 3.0, 1.2, DVec3::ZERO, 0.02, 20); } let event = if kind == MissileKind::M57e6 { "airdef:launch.pantsir" } else { "airdef:launch.patriot" }; - out.sound(event, muzzle, 1.0, 0.95 + self.vis.f() as f32 * 0.1); + let pitch = 0.95 + self.vis.f() as f32 * 0.1; + out.sound(event, muzzle, 1.0, pitch); + self.scene_event(muzzle, record::SceneKind::MissileLaunch { missile: id, sam, target, kind, sound: event, pitch }); } } for (id, pos, vel, life) in tracers { @@ -2887,6 +2891,7 @@ impl Sim { let mut done: Vec<(u32, Option, bool, u32)> = Vec::new(); // (missile, target, kill, sam) let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32); let (mut blocked, mut scrubbed) = (0u32, 0u32); + let mut ignitions = Vec::new(); #[cfg(test)] let mut scrub_log = Vec::new(); let sams_fit: HashMap = self.sams.iter().map(|s| (s.id, 1.0 - 0.3 * (1.0 - s.health.clamp(0.0, 1.0)))).collect(); @@ -3033,6 +3038,10 @@ impl Sim { done.push((m.id, None, false, m.sam)); continue; } + if lit && m.age == spec.cold + 1 { + let sound = (spec.cold > 0 && spec.flame > 0.0).then_some(if m.kind == MissileKind::M77n6 { "airdef:launch.s500" } else { "airdef:launch.s400" }); + ignitions.push((m.pos, m.id, m.kind, sound)); + } if lit { // The motor is one source that follows the missile from ignition on (#95). let (event, gain, pitch) = m.kind.engine_voice(); @@ -3051,6 +3060,9 @@ impl Sim { } } } + for (pos, missile, kind, sound) in ignitions { + self.scene_event(pos, record::SceneKind::MotorIgnition { missile, kind, sound }); + } #[cfg(test)] self.scrubs.extend(scrub_log); self.note(|t| { diff --git a/airdef-mod/src/sim/record.rs b/airdef-mod/src/sim/record.rs index 392d58e..f873800 100644 --- a/airdef-mod/src/sim/record.rs +++ b/airdef-mod/src/sim/record.rs @@ -20,6 +20,9 @@ pub struct SamKey { dish: f32, /// A searchlight's beam: how far it runs free (to 8 blocks), 0 when it is off. beam: f32, + /// Hull and component health at the sampled tick. + health: f32, + parts: [f32; 6], } impl SamKey { @@ -33,6 +36,8 @@ impl SamKey { pitch: sam.pitch as f32, dish: sam.dish as f32, beam: if sam.beam_on { ((sam.beam_free / 8.0).round() * 8.0).max(8.0) as f32 } else { 0.0 }, + health: sam.health as f32, + parts: sam.comps.0.map(|v| v as f32), } } /// The same pose (to within what could be seen). @@ -44,6 +49,8 @@ impl SamKey { && near(self.pitch, o.pitch, 0.002) && near(self.dish, o.dish, 0.002) && self.beam == o.beam + && self.health == o.health + && self.parts == o.parts } } @@ -84,6 +91,8 @@ impl SamTrack { pitch: l(a.pitch, b.pitch), dish: turn(a.dish, b.dish, f), beam: a.beam, + health: a.health, + parts: a.parts, }) } /// The system as it stood at `t`, as a ghost to draw with the ids from `id`. @@ -96,7 +105,8 @@ impl SamTrack { sam.dish = k.dish as f64; sam.beam_on = k.beam > 0.0; sam.beam_free = k.beam as f64; - sam.health = if self.lost.is_some_and(|l| t >= l as f64) { 0.0 } else { 1.0 }; + sam.health = if self.lost.is_some_and(|l| t >= l as f64) { 0.0 } else { k.health as f64 }; + sam.comps = Comps(k.parts.map(|v| v as f64)); Some(sam) } } @@ -112,6 +122,7 @@ impl Recording { tracks: Vec::new(), index: HashMap::new(), events: Vec::new(), + scene: None, tally: Tally::default(), sams: Vec::new(), objects: Vec::new(), @@ -208,6 +219,61 @@ pub struct Event { pub kind: EventKind, } +/// Measured scene transitions. These are facts, not inferred cinematic beats. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SceneKind { + MissileLaunch { missile: u32, sam: u32, target: u32, kind: MissileKind, sound: &'static str, pitch: f32 }, + MotorIgnition { missile: u32, kind: MissileKind, sound: Option<&'static str> }, + TrackAcquired { sam: u32, target: u32, ever_own: bool }, + TrackLost { sam: u32, target: u32 }, + TrackSource { sam: u32, target: u32, ever_own: bool }, + TrackLock { sam: u32, target: u32, locked: bool }, + FireStarted { fire: u32, age: u32, life: u32, size: f32, quiet: bool, shown: bool, water: Option }, + FireChanged { fire: u32, age: u32, life: u32, size: f32, quiet: bool, shown: bool, water: Option }, + FireEnded { fire: u32 }, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SceneEvent { + pub at: u32, + pub pos: DVec3, + pub kind: SceneKind, +} + +/// At most this many scene transitions per recording; overflow is explicitly marked. +const MAX_SCENE_EVENTS: usize = 65_536; + +#[derive(Default)] +pub struct SceneJournal { + events: Vec, + pub truncated: bool, + tracks: HashMap<(u32, u32), (bool, bool, DVec3)>, + fires: HashMap)>, + fire_events: Vec, + launches: HashMap, + ignitions: HashMap, +} + +impl SceneJournal { + /// Read-only transitions in chronological order. + pub fn events(&self) -> &[SceneEvent] { &self.events } + + #[cfg(feature = "test-fixtures")] + pub fn fixture_push(&mut self, event: SceneEvent) { self.push(event); } + + fn push(&mut self, event: SceneEvent) { + if self.events.len() >= MAX_SCENE_EVENTS { self.truncated = true; return; } + let index = self.events.len(); + match event.kind { + SceneKind::MissileLaunch { missile, .. } => { self.launches.insert(missile, index); } + SceneKind::MotorIgnition { missile, .. } => { self.ignitions.insert(missile, index); } + SceneKind::FireStarted { .. } | SceneKind::FireChanged { .. } | SceneKind::FireEnded { .. } => self.fire_events.push(index), + _ => {} + } + self.events.push(event); + } +} + /// Why interceptors and systems failed, as far as it was counted: only these are known. #[derive(Clone, Copy, Default, PartialEq, Debug)] pub struct Tally { @@ -267,6 +333,8 @@ pub struct Recording { pub tracks: Vec, index: HashMap, pub events: Vec, + /// None is the legacy event/track-only format. + pub scene: Option, /// The failures counted during the raid. pub tally: Tally, /// The systems and objects as they stood: (id, kind, where). @@ -345,6 +413,11 @@ pub struct ReplayFire { pub start: u32, pub life: u32, pub size: f32, + /// Age already accumulated before recording began. + pub age_offset: u32, + pub quiet: bool, + pub shown: bool, + pub water: Option, /// The event that lit it (the models' ids). pub key: usize, } @@ -409,6 +482,24 @@ pub fn replay_blasts(rec: &Recording, t: f64) -> Vec { /// feed it (the greater size, the longer life), as `Sim::ignite` does; those after it died light /// a new one. A pure function of `t`. pub fn replay_fires(rec: &Recording, t: f64) -> Vec { + if let Some(scene) = rec.scene.as_ref().filter(|s| !s.truncated) { + let mut active: HashMap = HashMap::new(); + for &index in &scene.fire_events { + let e = &scene.events[index]; + if e.at as f64 > t { break; } + match e.kind { + SceneKind::FireStarted { fire, age, life, size, quiet, shown, water } + | SceneKind::FireChanged { fire, age, life, size, quiet, shown, water } => { + active.insert(fire, ReplayFire { at: e.pos, start: e.at.saturating_sub(age), age_offset: age.saturating_sub(e.at), life, size, quiet, shown, water, key: fire as usize }); + } + SceneKind::FireEnded { fire } => { active.remove(&fire); } + _ => {} + } + } + let mut fires: Vec<_> = active.into_values().filter(|f| t - f.start as f64 + (f.age_offset as f64) < f.life as f64).collect(); + fires.sort_by_key(|f| f.key); + return fires; + } let mut fires: Vec = Vec::new(); for (k, e) in rec.events.iter().enumerate() { if e.at as f64 > t { @@ -427,7 +518,7 @@ pub fn replay_fires(rec: &Recording, t: f64) -> Vec { f.size = f.size.max(size); f.life = f.life.max(e.at - f.start + life); } else { - fires.push(ReplayFire { at: e.pos, start: e.at, life, size, key: k }); + fires.push(ReplayFire { at: e.pos, start: e.at, life, size, age_offset: 0, quiet: false, shown: true, water: None, key: k }); } } fires.retain(|f| t < (f.start + f.life) as f64); @@ -444,6 +535,7 @@ impl Recording { tracks: Vec::new(), index: HashMap::new(), events: Vec::new(), + scene: Some(SceneJournal::default()), tally: Tally::default(), sams: sim.sams.iter().map(|s| (s.id, s.kind, s.pos)).collect(), objects: sim.targets.clone(), @@ -476,6 +568,74 @@ impl Recording { } } } + /// Actual launch, if observed during this recording. Missing is not proof of no launch: + /// the recording may have started late or the bounded journal may have filled. + pub fn missile_launch(&self, missile: u32) -> Option<&SceneEvent> { + let scene = self.scene.as_ref()?; + scene.events.get(*scene.launches.get(&missile)?) + } + + /// Actual motor ignition, when it was recorded. + pub fn missile_ignition(&self, missile: u32) -> Option<&SceneEvent> { + let scene = self.scene.as_ref()?; + scene.events.get(*scene.ignitions.get(&missile)?) + } + + /// Recorded ignition takes precedence; older/incomplete tracks retain their original fallback. + pub fn missile_is_lit(&self, tr: &Track, t: f64, cold: u32) -> bool { + if let Some(event) = self.missile_ignition(tr.id) { return t >= event.at as f64; } + if self.missile_launch(tr.id).is_some() && self.scene.as_ref().is_some_and(|s| !s.truncated) { return false; } + t - tr.first as f64 > cold as f64 + } + + fn note_tracks(&mut self, sams: &[Sam]) { + let Some(journal) = &mut self.scene else { return }; + let mut current = HashMap::new(); + for sam in sams { + for track in &sam.tracks { + let key = (sam.id, track.id); + let state = (track.own_tick > 0, track.locked, track.pos); + let old = journal.tracks.get(&key).copied(); + if old.is_none() { + journal.push(SceneEvent { at: self.len, pos: track.pos, kind: SceneKind::TrackAcquired { sam: sam.id, target: track.id, ever_own: state.0 } }); + } else if old.is_some_and(|old| old.0 != state.0) { + journal.push(SceneEvent { at: self.len, pos: track.pos, kind: SceneKind::TrackSource { sam: sam.id, target: track.id, ever_own: state.0 } }); + } + if (old.is_none() && state.1) || old.is_some_and(|old| old.1 != state.1) { + journal.push(SceneEvent { at: self.len, pos: track.pos, kind: SceneKind::TrackLock { sam: sam.id, target: track.id, locked: state.1 } }); + } + current.insert(key, state); + } + } + let mut lost: Vec<_> = journal.tracks.iter().filter(|(key, _)| !current.contains_key(key)).map(|(key, state)| (*key, state.2)).collect(); + lost.sort_by_key(|(key, _)| *key); + for ((sam, target), pos) in lost { + journal.push(SceneEvent { at: self.len, pos, kind: SceneKind::TrackLost { sam, target } }); + } + journal.tracks = current; + } + + /// Sample actual fire state after the simulation update, including existing fires at start. + fn note_fires(&mut self, fires: &[fires::Fire], now: u64) { + let Some(journal) = &mut self.scene else { return }; + let mut current = HashMap::new(); + for fire in fires { + let state = (fire.at, fire.life, fire.size, fire.quiet, fire.shown, fire.surface_is_water()); + let age = now.saturating_sub(fire.start).min(u32::MAX as u64) as u32; + let kind = match journal.fires.get(&fire.id) { + None => Some(SceneKind::FireStarted { fire: fire.id, age, life: fire.life, size: fire.size, quiet: fire.quiet, shown: fire.shown, water: state.5 }), + Some(old) if *old != state => Some(SceneKind::FireChanged { fire: fire.id, age, life: fire.life, size: fire.size, quiet: fire.quiet, shown: fire.shown, water: state.5 }), + _ => None, + }; + if let Some(kind) = kind { journal.push(SceneEvent { at: self.len, pos: fire.at, kind }); } + current.insert(fire.id, state); + } + let mut ended: Vec<_> = journal.fires.iter().filter(|(id, _)| !current.contains_key(id)).map(|(id, state)| (*id, state.0)).collect(); + ended.sort_by_key(|(id, _)| *id); + for (fire, pos) in ended { journal.push(SceneEvent { at: self.len, pos, kind: SceneKind::FireEnded { fire } }); } + journal.fires = current; + } + /// The game's tick at the recording's tick `t` (fractional), if it was known: the samples run /// on at the pace of the ticks between them (a pause or a gap is a step). pub fn world_tick(&self, t: f64) -> Option { @@ -524,6 +684,15 @@ impl Recording { pub fn track(&self, id: u32) -> Option<&Track> { self.index.get(&id).map(|&i| &self.tracks[i]) } + /// Sampled pose at the replay time, falling back to the old static snapshot. + pub fn sam_at(&self, id: u32, t: f64) -> Option<(SamKind, DVec3)> { + if let Some(track) = self.sam_tracks.iter().find(|s| s.id == id) { + let pose = track.pose(t)?; + return Some((track.kind, self.origin + DVec3::new(pose.pos[0] as f64, pose.pos[1] as f64, pose.pos[2] as f64))); + } + self.sam(id) + } + /// The system with this id as it stood. pub fn sam(&self, id: u32) -> Option<(SamKind, DVec3)> { self.sams.iter().find(|s| s.0 == id).map(|s| (s.1, s.2)) @@ -546,18 +715,17 @@ impl Sim { } } - /// Something worth a replay happened. - pub(super) fn log(&mut self, pos: DVec3, kind: EventKind) { - match kind { - EventKind::Crash { power, .. } => self.ignite(pos, power, None), - EventKind::Hit { power, sam, .. } => { - let host = sam.and_then(|(_, i)| self.sams.get(i)).map(|s| s.id); - self.ignite(pos, power.max(1), host) + /// Record an actual transition without modifying gameplay or random state. + pub(super) fn scene_event(&mut self, pos: DVec3, kind: SceneKind) { + if let Some(r) = &mut self.rec { + if let Some(journal) = &mut r.scene { + journal.push(SceneEvent { at: r.len, pos, kind }); } - EventKind::SamLost { sam, .. } => self.ignite(pos, 2, Some(sam)), - EventKind::Strike { result, .. } if strike_power(result) > 0 => self.ignite(pos, strike_power(result), None), - _ => {} } + } + + /// Append an already-applied fact. This never changes the live world. + pub(super) fn record_event(&mut self, pos: DVec3, kind: EventKind) { if let Some(r) = &mut self.rec { let at = r.len; if let EventKind::Kill { drone, .. } = kind @@ -574,21 +742,24 @@ impl Sim { } } + /// Open before the combat update so transitions on the first active tick are not lost. + pub(super) fn begin_recording_if_active(&mut self) { + if self.rec.is_none() && (self.drones.iter().any(|d| d.alive()) || !self.strikes.flights.is_empty()) { + self.recordings += 1; + self.rec = Some(Recording::new(self.recordings, self)); + } + } + /// 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. 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 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; - self.rec = Some(Recording::new(self.recordings, self)); - } + self.begin_recording_if_active(); let Some(r) = &mut self.rec else { return }; + r.note_tracks(&self.sams); + r.note_fires(&self.fires.list, self.tick); if r.len % TIME_STEP == 0 { if let Some(dt) = self.day_time { r.times.push((r.len, dt)); @@ -612,8 +783,12 @@ impl Sim { r.quiet = if busy { 0 } else { r.quiet + 1 }; if r.quiet >= 100 || r.len >= LONGEST { let mut done = self.rec.take().unwrap(); + if let Some(scene) = &mut done.scene { + scene.tracks = HashMap::new(); + scene.fires = HashMap::new(); + } done.len = done.len.saturating_sub(done.quiet.saturating_sub(40)); - if !done.events.is_empty() { + if !done.events.is_empty() || done.scene.as_ref().is_some_and(|s| !s.events.is_empty()) { self.replays.push(done); if self.replays.len() > KEPT { self.replays.remove(0); @@ -668,7 +843,7 @@ impl Sim { let spec = kind.spec(); let rot = look(dir, 0.0) * DQuat::from_rotation_z(t * 0.3); out.model(id, spec.model, pos, rot, spec.scale, false); - if t - tr.first as f64 > spec.cold as f64 && spec.flame > 0.0 { + if rec.missile_is_lit(tr, t, spec.cold) && spec.flame > 0.0 { let back = (spec.rear - 8.0) / 16.0 * spec.scale + spec.flame; out.model(id + 1, "airdef:flame", pos + rot * DVec3::new(0.0, 0.0, back), rot, spec.flame, true); } @@ -703,8 +878,10 @@ impl Sim { // Fires: what the events left burning (the flames dim and the smoke thickens with time), // out when their life ends. for f in replay_fires(rec, t) { - let age = t - f.start as f64; - let (flame, smoke) = fires::replay_fire_of(f.size, f.life, age); + let age = t - f.start as f64 + f.age_offset as f64; + let (mut flame, mut smoke) = fires::replay_fire_of(f.size, f.life, age); + if f.water == Some(true) { flame = 0.0; smoke *= 0.5; } + if f.quiet || !f.shown { flame = 0.0; } let id = base + 0x00c0_0000 + f.key as u32 * PARTS; if flame > 0.05 { let wob = 1.0 + 0.15 * (age * 0.9 + f.key as f64).sin(); @@ -781,6 +958,7 @@ mod tests { tracks: Vec::new(), index: HashMap::new(), events: Vec::new(), + scene: None, tally: Tally::default(), sams: Vec::new(), objects: Vec::new(), @@ -811,6 +989,155 @@ mod tests { assert!((pose(100.0).pos[0] - 4.0).abs() < 1e-6); } + #[test] + fn recorder_is_passive_and_combat_effects_do_not_depend_on_it() { + let kind = EventKind::Crash { drone: 1, kind: DroneKind::Shahed, power: 2 }; + let mut recording = Sim::new(4); + recording.rec = Some(Recording::new(1, &recording)); + recording.record_event(DVec3::ZERO, kind); + assert!(recording.fires.list.is_empty(), "recording a fact must not ignite the live world"); + assert_eq!(recording.rec.as_ref().unwrap().events.len(), 1); + let mut without = Sim::new(4); + without.emit_combat_event(DVec3::ZERO, kind); + assert_eq!(without.fires.list.len(), 1, "the domain effect must run without a recorder"); + let mut with = Sim::new(4); + with.rec = Some(Recording::new(1, &with)); + with.emit_combat_event(DVec3::ZERO, kind); + assert_eq!(with.fires.list.len(), 1); + assert_eq!(with.rec.as_ref().unwrap().events.len(), 1); + assert_eq!(with.fires.list[0].size, without.fires.list[0].size); + } + + #[test] + fn recorded_sam_health_and_parts_are_stepwise_and_seekable() { + let mut r = recording(); + let mut sam = Sam::new(7, SamKind::Patriot, DVec3::ZERO, 0.0); + r.note_sams(std::slice::from_ref(&sam), 0); + sam.health = 0.4; + sam.comps.0[Part::Sensor as usize] = 0.25; + r.note_sams(std::slice::from_ref(&sam), 10); + let st = &r.sam_tracks[0]; + let before = st.ghost(r.origin, 9.0, 7).unwrap(); + let damaged = st.ghost(r.origin, 10.0, 7).unwrap(); + assert_eq!(before.health, 1.0); + assert!((damaged.health - 0.4).abs() < 1e-6); + assert_eq!(damaged.comps.get(Part::Sensor), 0.25); + assert_eq!(st.ghost(r.origin, 10.0, 7).unwrap().comps, damaged.comps); + } + + #[test] + fn sampled_track_and_fire_transitions_follow_real_changes() { + let mut sim = Sim::new(3); + let id = sim.id(); + sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::ZERO, 0.0)); + let mut r = Recording::new(1, &sim); + let tr = sight::Track { id: 77, kind: DroneKind::Shahed, pos: DVec3::Z * 100.0, vel: -DVec3::Z, tick: 1, own_tick: 1, ttl: 10, hits: 1, locked: false }; + sim.sams[0].tracks.push(tr); + r.note_tracks(&sim.sams); + r.len = 1; + sim.sams[0].tracks[0].locked = true; + r.note_tracks(&sim.sams); + r.len = 2; + sim.sams[0].tracks.clear(); + r.note_tracks(&sim.sams); + let events = &r.scene.as_ref().unwrap().events; + assert!(matches!(events[0].kind, SceneKind::TrackAcquired { sam, target: 77, ever_own: true } if sam == id)); + assert!(matches!(events[1].kind, SceneKind::TrackLock { sam, target: 77, locked: true } if sam == id)); + assert!(matches!(events[2].kind, SceneKind::TrackLost { sam, target: 77 } if sam == id)); + + sim.ignite(DVec3::ZERO, 1, None); + let fire = sim.fires.list[0].id; + r.note_fires(&sim.fires.list, sim.tick); + assert_eq!(replay_fires(&r, 2.0)[0].size, 1.0); + sim.ignite(DVec3::ZERO, 4, None); + r.len += 1; + r.note_fires(&sim.fires.list, sim.tick); + assert_eq!(replay_fires(&r, 3.0)[0].size, 3.0); + assert_eq!(replay_fires(&r, 2.0)[0].size, 1.0, "seeking back uses old fire state"); + sim.fires.extinguish(); + r.len += 1; + r.note_fires(&sim.fires.list, sim.tick); + assert!(replay_fires(&r, 4.0).is_empty()); + let events = &r.scene.as_ref().unwrap().events; + assert!(events.iter().any(|e| matches!(e.kind, SceneKind::FireStarted { fire: id, .. } if id == fire))); + assert!(events.iter().any(|e| matches!(e.kind, SceneKind::FireChanged { fire: id, size, .. } if id == fire && size == 3.0))); + assert!(events.iter().any(|e| matches!(e.kind, SceneKind::FireEnded { fire: id } if id == fire))); + } + + #[test] + fn replay_fire_uses_sampled_existing_age_water_and_end() { + let mut r = recording(); + let mut scene = SceneJournal::default(); + scene.push(SceneEvent { at: 0, pos: DVec3::X, kind: SceneKind::FireStarted { fire: 7, age: 50, life: 100, size: 1.0, quiet: false, shown: true, water: Some(true) } }); + scene.push(SceneEvent { at: 10, pos: DVec3::X, kind: SceneKind::FireEnded { fire: 7 } }); + r.scene = Some(scene); + let a = replay_fires(&r, 5.0); + assert_eq!(a.len(), 1); + assert_eq!((a[0].start, a[0].age_offset, a[0].water), (0, 50, Some(true))); + assert!(replay_fires(&r, 11.0).is_empty()); + let b = replay_fires(&r, 5.0); + assert_eq!((a[0].start, a[0].age_offset, a[0].water), (b[0].start, b[0].age_offset, b[0].water)); + } + + #[test] + fn scene_journal_is_bounded_and_incomplete_is_marked() { + let mut j = SceneJournal::default(); + for i in 0..=MAX_SCENE_EVENTS { + j.push(SceneEvent { at: i as u32, pos: DVec3::ZERO, kind: SceneKind::FireEnded { fire: i as u32 } }); + } + assert_eq!(j.events.len(), MAX_SCENE_EVENTS); + assert!(j.truncated); + } + + #[test] + fn ten_minute_transition_stream_is_bounded() { + let started = std::time::Instant::now(); + let mut sim = Sim::new(5); + for _ in 0..4 { + let id = sim.id(); + let mut sam = Sam::new(id, SamKind::Patriot, DVec3::ZERO, 0.0); + for target in 1..=4 { + sam.tracks.push(sight::Track { id: target, kind: DroneKind::Shahed, pos: DVec3::Z, vel: DVec3::ZERO, tick: 1, own_tick: 1, ttl: 1, hits: 1, locked: false }); + } + sim.sams.push(sam); + } + let mut r = Recording::new(1, &sim); + for t in 0..12_000 { + r.len = t; + for sam in &mut sim.sams { for tr in &mut sam.tracks { tr.locked = t % 2 == 0; } } + r.note_tracks(&sim.sams); + } + let scene = r.scene.as_ref().unwrap(); + assert_eq!(scene.events.len(), MAX_SCENE_EVENTS); + assert!(scene.truncated); + eprintln!("scene stress: 12000 ticks, {} events, event storage {:.2} MiB, elapsed {:?}", scene.events.len(), scene.events.capacity() * std::mem::size_of::() / (1024 * 1024), started.elapsed()); + } + + #[test] + fn real_launch_and_ignition_are_recorded_with_ids_and_sound() { + let mut sim = Sim::new(0x87); + let sam = sim.id(); + sim.sams.push(Sam::new(sam, SamKind::Patriot, DVec3::new(0.0, 64.0, 0.0), 0.0)); + let mut out = Out::default(); + for (i, kind) in [DroneKind::Shahed, DroneKind::Kh101, DroneKind::Iskander].into_iter().enumerate() { + sim.launch(kind, DVec3::new(i as f64 * 100.0, 110.0, 800.0), -DVec3::Z, DVec3::new(0.0, 64.0, 0.0), &mut out); + } + let mut ground = Ground::default(); + for _ in 0..600 { + let wanted = ground.take_wanted(); + ground.answer(wanted.into_iter().map(|(x, z)| (x, z, 64)).collect(), sim.tick); + out = Out::default(); + sim.step(&mut ground, None, &mut out); + if sim.rec.as_ref().and_then(|r| r.scene.as_ref()).is_some_and(|j| j.events.iter().any(|e| matches!(e.kind, SceneKind::MotorIgnition { .. }))) { break; } + } + let r = sim.rec.as_ref().expect("recording"); + let launch = r.scene.as_ref().unwrap().events.iter().find(|e| matches!(e.kind, SceneKind::MissileLaunch { .. })).expect("actual launch"); + let missile = match launch.kind { SceneKind::MissileLaunch { missile, sam: source, sound, .. } => { assert_eq!(source, sam); assert_eq!(sound, "airdef:launch.patriot"); missile }, _ => unreachable!() }; + let ignition = r.missile_ignition(missile).expect("actual ignition"); + assert!(ignition.at >= launch.at); + assert_eq!(r.missile_launch(missile).unwrap().pos, launch.pos); + } + // #76: a replay's searchlights point where the recording had them, from the recorded pose. #[test] fn a_replay_has_the_recorded_searchlight_beams() { diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index 3f11086..709331e 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -624,6 +624,8 @@ fn replay_fires_and_blasts_follow_the_recording() { let mut out = Out::default(); sim.step(&mut g, None, &mut out); let mut rec = sim.rec.take().unwrap(); + // Legacy recordings reconstruct fire from combat events; new journals record actual fire state. + rec.scene = None; let at = DVec3::new(5.0, 70.0, 5.0); let crash = |power| EventKind::Crash { drone: 1, kind: DroneKind::Geran3, power }; let life = fires::life_for(1); diff --git a/airdef-mod/src/sim/strike.rs b/airdef-mod/src/sim/strike.rs index 612b52b..a89e09d 100644 --- a/airdef-mod/src/sim/strike.rs +++ b/airdef-mod/src/sim/strike.rs @@ -756,7 +756,7 @@ impl Sim { self.strikes.shot_down += 1; 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 }); + self.emit_combat_event(high, record::EventKind::Strike { site: f.site, result: record::StrikeResult::Intercepted }); out.messages.push(format!("Удар по ПУ №{}: ракета сбита ПВО противника у площадки, цель цела", f.site)); return; } @@ -781,11 +781,11 @@ impl Sim { self.strikes.destroyed += 1; self.strikes.cancelled.push((f.site, n)); self.set_state(f.site, SiteState::Destroyed(now)); - self.log(at, record::EventKind::Strike { site: f.site, result }); + self.emit_combat_event(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 }); + self.emit_combat_event(at, record::EventKind::Strike { site: f.site, result: R::Missed }); out.messages.push(format!("Удар по ПУ №{}: за пределами загруженной карты; пуски идут, ракета потеряна", f.site)); } return; @@ -805,7 +805,7 @@ impl Sim { self.later.push((when, at + off, if loaded { 2 } else { 0 }, true)); } self.strikes.wrecks.push((at, now + TUNE.burn)); - self.log(at, record::EventKind::Strike { site: f.site, result }); + self.emit_combat_event(at, record::EventKind::Strike { site: f.site, result }); out.messages.push(format!( "Удар по ПУ №{}: площадка уничтожена, запас сгорел ({stock}); отменено запусков: {n}", f.site @@ -818,7 +818,7 @@ impl Sim { self.strikes.stunned += 1; self.set_state(f.site, SiteState::Stunned(now + TUNE.suppress_ticks)); self.blast_at(at, 4, false, false, ground, player, out); - self.log(at, record::EventKind::Strike { site: f.site, result }); + self.emit_combat_event(at, record::EventKind::Strike { site: f.site, result }); out.messages.push(format!( "Удар по ПУ №{}: накрыла рядом ({miss:.0} м), площадка подавлена на {} с, но цела", f.site, @@ -828,7 +828,7 @@ impl Sim { _ => { self.strikes.missed += 1; self.blast_at(at, 4, false, false, ground, player, out); - self.log(at, record::EventKind::Strike { site: f.site, result: R::Missed }); + self.emit_combat_event(at, record::EventKind::Strike { site: f.site, result: R::Missed }); out.messages.push(format!("Удар по ПУ №{}: промах на {miss:.0} м, площадка цела, пуски идут", f.site)); } } @@ -842,7 +842,7 @@ impl Sim { } 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 }); + self.emit_combat_event(at, record::EventKind::Strike { site: f.site, result: record::StrikeResult::Blocked }); out.messages.push(format!( "Удар по ПУ №{}: ракета разбилась о рельеф или строение в {:.0} м от цели, площадка цела", f.site, diff --git a/airdef-mod/src/smoke.rs b/airdef-mod/src/smoke.rs index caccd92..552195c 100644 --- a/airdef-mod/src/smoke.rs +++ b/airdef-mod/src/smoke.rs @@ -193,7 +193,11 @@ pub fn replay_smoke(rec: &crate::sim::record::Recording, t: f64) -> Vec = rec