Record measured scene transitions and isolate combat effects from recorder (#88)
Capture actual missile launch/ignition, track transitions, fire state, SAM health and components. Keep legacy fallback and bound scene transitions to 65536. Renderers still use incremental adapters; shared SceneTimeline remains follow-up work. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
@@ -7,8 +7,8 @@
|
|||||||
//! raid, so the same film comes out every time. The land is asked for before the film starts (the
|
//! 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
|
//! 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
|
//! 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 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);
|
||||||
//! the interceptor's first point, a kill the recorded event; no lock-on or detection is invented.
|
//! no lock-on or detection is invented.
|
||||||
use crate::replay::{interest, title};
|
use crate::replay::{interest, title};
|
||||||
use crate::sim::record::{EventKind, Recording, STEP};
|
use crate::sim::record::{EventKind, Recording, STEP};
|
||||||
use crate::sim::Ground;
|
use crate::sim::Ground;
|
||||||
@@ -309,7 +309,8 @@ fn plan_event(rec: &Recording, i: usize, floor: f64) -> Vec<Shot> {
|
|||||||
};
|
};
|
||||||
match e.kind {
|
match e.kind {
|
||||||
EventKind::Kill { drone, kind, sam, sam_kind, sam_index, missile, outcome } => {
|
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 name = format!("{} №{}", sam_kind.name(), sam_index + 1);
|
||||||
let how = match outcome {
|
let how = match outcome {
|
||||||
crate::sim::outcome::Outcome::DisabledFalling => "",
|
crate::sim::outcome::Outcome::DisabledFalling => "",
|
||||||
@@ -317,7 +318,7 @@ fn plan_event(rec: &Recording, i: usize, floor: f64) -> Vec<Shot> {
|
|||||||
crate::sim::outcome::Outcome::AirDetonation => ": детонация в воздухе",
|
crate::sim::outcome::Outcome::AirDetonation => ": детонация в воздухе",
|
||||||
};
|
};
|
||||||
let sub = format!("Перехват «{}»{how}", kind.spec().name);
|
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 {
|
match mt {
|
||||||
Some((m, tl)) => {
|
Some((m, tl)) => {
|
||||||
// The facts, in order: the launcher waits, the interceptor leaves it at `tl`, it
|
// The facts, in order: the launcher waits, the interceptor leaves it at `tl`, it
|
||||||
|
|||||||
@@ -497,7 +497,7 @@ impl Player {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
What::Missile(kind, _) => {
|
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();
|
let (event, gain, pitch) = kind.engine_voice();
|
||||||
out.engine(key, event, at, vel, gain, pitch, false);
|
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);
|
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))) {
|
for tr in rec.tracks.iter().filter(|tr| (self.heard..self.t).contains(&(tr.first as f64))) {
|
||||||
let event = match tr.what {
|
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::Strike => Some(DroneKind::Kalibr.launch_sound()),
|
||||||
What::Drone(_) => None,
|
What::Drone(_) => None,
|
||||||
};
|
};
|
||||||
@@ -1107,6 +1118,36 @@ mod engine_tests {
|
|||||||
let _ = rec;
|
let _ = rec;
|
||||||
p
|
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<_>>(), vec!["airdef:launch.s400_eject", "airdef:launch.s400"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn counter_strike_launch_is_heard_once_and_can_be_replayed_after_seek() {
|
fn counter_strike_launch_is_heard_once_and_can_be_replayed_after_seek() {
|
||||||
let mut rec = Recording::blank(100);
|
let mut rec = Recording::blank(100);
|
||||||
|
|||||||
@@ -186,6 +186,21 @@ impl DroneKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Sim {
|
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.
|
/// 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 {
|
pub(super) fn aim_error(&mut self, kind: DroneKind) -> DVec3 {
|
||||||
let (cep, gross) = kind.accuracy();
|
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 };
|
let tnt = if charge > 4.0 && self.rng.chance(0.4) { 2 } else { 1 };
|
||||||
self.later.push((when, at, tnt, true));
|
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.
|
// Shells pop in strings.
|
||||||
for k in 0..(bursts / 12).min(14) {
|
for k in 0..(bursts / 12).min(14) {
|
||||||
let when = now + 10 + (k as u64) * 6 + (self.rng.f() * 8.0) as u64;
|
let when = now + 10 + (k as u64) * 6 + (self.rng.f() * 8.0) as u64;
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ impl Fires {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Fire {
|
impl Fire {
|
||||||
|
/// The observed surface for recording; None means it has not been resolved.
|
||||||
|
pub(super) fn surface_is_water(&self) -> Option<bool> { self.land.map(|land| !land) }
|
||||||
|
|
||||||
fn take_blocks(self) -> Vec<String> {
|
fn take_blocks(self) -> Vec<String> {
|
||||||
self.blocks
|
self.blocks
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -1868,6 +1868,7 @@ impl Sim {
|
|||||||
self.adapt();
|
self.adapt();
|
||||||
self.step_settling(ground);
|
self.step_settling(ground);
|
||||||
self.release_queue(ground, out);
|
self.release_queue(ground, out);
|
||||||
|
self.begin_recording_if_active();
|
||||||
self.step_drones(ground, player, out);
|
self.step_drones(ground, player, out);
|
||||||
self.step_later(ground, player, out);
|
self.step_later(ground, player, out);
|
||||||
self.step_moves(ground, out);
|
self.step_moves(ground, out);
|
||||||
@@ -2209,7 +2210,7 @@ impl Sim {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (drone, pos) in splits {
|
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 {
|
for (drone, kind, pos, tnt) in crashes {
|
||||||
self.crashes += 1;
|
self.crashes += 1;
|
||||||
@@ -2220,7 +2221,7 @@ impl Sim {
|
|||||||
6 | 7 => 3,
|
6 | 7 => 3,
|
||||||
_ => 4,
|
_ => 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 {
|
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) {
|
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 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));
|
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);
|
let vel = self.drones.iter().find(|x| x.id == drone).map_or(DVec3::ZERO, |x| x.vel);
|
||||||
self.direct_hit(pos, vel, tnt, out);
|
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) {
|
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 (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 };
|
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 d = &mut self.drones[idx];
|
||||||
let before = d.vel;
|
let before = d.vel;
|
||||||
@@ -2857,6 +2858,7 @@ impl Sim {
|
|||||||
out.particles("minecraft:cloud", muzzle, 0.6, dir * 0.05, 0.05, 30);
|
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" };
|
let event = if kind == MissileKind::M77n6 { "airdef:launch.s500_eject" } else { "airdef:launch.s400_eject" };
|
||||||
out.sound(event, muzzle, 1.0, 1.0);
|
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 {
|
} else {
|
||||||
out.particles("minecraft:flash", muzzle, 0.0, DVec3::ZERO, 0.0, 1);
|
out.particles("minecraft:flash", muzzle, 0.0, DVec3::ZERO, 0.0, 1);
|
||||||
if spec.flame > 0.0 {
|
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);
|
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" };
|
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 {
|
for (id, pos, vel, life) in tracers {
|
||||||
@@ -2887,6 +2891,7 @@ impl Sim {
|
|||||||
let mut done: Vec<(u32, Option<u32>, bool, u32)> = Vec::new(); // (missile, target, kill, sam)
|
let mut done: Vec<(u32, Option<u32>, bool, u32)> = Vec::new(); // (missile, target, kill, sam)
|
||||||
let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32);
|
let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32);
|
||||||
let (mut blocked, mut scrubbed) = (0u32, 0u32);
|
let (mut blocked, mut scrubbed) = (0u32, 0u32);
|
||||||
|
let mut ignitions = Vec::new();
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
let mut scrub_log = Vec::new();
|
let mut scrub_log = Vec::new();
|
||||||
let sams_fit: HashMap<u32, f64> = self.sams.iter().map(|s| (s.id, 1.0 - 0.3 * (1.0 - s.health.clamp(0.0, 1.0)))).collect();
|
let sams_fit: HashMap<u32, f64> = 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));
|
done.push((m.id, None, false, m.sam));
|
||||||
continue;
|
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 {
|
if lit {
|
||||||
// The motor is one source that follows the missile from ignition on (#95).
|
// The motor is one source that follows the missile from ignition on (#95).
|
||||||
let (event, gain, pitch) = m.kind.engine_voice();
|
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)]
|
#[cfg(test)]
|
||||||
self.scrubs.extend(scrub_log);
|
self.scrubs.extend(scrub_log);
|
||||||
self.note(|t| {
|
self.note(|t| {
|
||||||
|
|||||||
+351
-24
@@ -20,6 +20,9 @@ pub struct SamKey {
|
|||||||
dish: f32,
|
dish: f32,
|
||||||
/// A searchlight's beam: how far it runs free (to 8 blocks), 0 when it is off.
|
/// A searchlight's beam: how far it runs free (to 8 blocks), 0 when it is off.
|
||||||
beam: f32,
|
beam: f32,
|
||||||
|
/// Hull and component health at the sampled tick.
|
||||||
|
health: f32,
|
||||||
|
parts: [f32; 6],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SamKey {
|
impl SamKey {
|
||||||
@@ -33,6 +36,8 @@ impl SamKey {
|
|||||||
pitch: sam.pitch as f32,
|
pitch: sam.pitch as f32,
|
||||||
dish: sam.dish 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 },
|
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).
|
/// The same pose (to within what could be seen).
|
||||||
@@ -44,6 +49,8 @@ impl SamKey {
|
|||||||
&& near(self.pitch, o.pitch, 0.002)
|
&& near(self.pitch, o.pitch, 0.002)
|
||||||
&& near(self.dish, o.dish, 0.002)
|
&& near(self.dish, o.dish, 0.002)
|
||||||
&& self.beam == o.beam
|
&& self.beam == o.beam
|
||||||
|
&& self.health == o.health
|
||||||
|
&& self.parts == o.parts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +91,8 @@ impl SamTrack {
|
|||||||
pitch: l(a.pitch, b.pitch),
|
pitch: l(a.pitch, b.pitch),
|
||||||
dish: turn(a.dish, b.dish, f),
|
dish: turn(a.dish, b.dish, f),
|
||||||
beam: a.beam,
|
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`.
|
/// 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.dish = k.dish as f64;
|
||||||
sam.beam_on = k.beam > 0.0;
|
sam.beam_on = k.beam > 0.0;
|
||||||
sam.beam_free = k.beam as f64;
|
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)
|
Some(sam)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,6 +122,7 @@ impl Recording {
|
|||||||
tracks: Vec::new(),
|
tracks: Vec::new(),
|
||||||
index: HashMap::new(),
|
index: HashMap::new(),
|
||||||
events: Vec::new(),
|
events: Vec::new(),
|
||||||
|
scene: None,
|
||||||
tally: Tally::default(),
|
tally: Tally::default(),
|
||||||
sams: Vec::new(),
|
sams: Vec::new(),
|
||||||
objects: Vec::new(),
|
objects: Vec::new(),
|
||||||
@@ -208,6 +219,61 @@ pub struct Event {
|
|||||||
pub kind: EventKind,
|
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<bool> },
|
||||||
|
FireChanged { fire: u32, age: u32, life: u32, size: f32, quiet: bool, shown: bool, water: Option<bool> },
|
||||||
|
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<SceneEvent>,
|
||||||
|
pub truncated: bool,
|
||||||
|
tracks: HashMap<(u32, u32), (bool, bool, DVec3)>,
|
||||||
|
fires: HashMap<u32, (DVec3, u32, f32, bool, bool, Option<bool>)>,
|
||||||
|
fire_events: Vec<usize>,
|
||||||
|
launches: HashMap<u32, usize>,
|
||||||
|
ignitions: HashMap<u32, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
/// Why interceptors and systems failed, as far as it was counted: only these are known.
|
||||||
#[derive(Clone, Copy, Default, PartialEq, Debug)]
|
#[derive(Clone, Copy, Default, PartialEq, Debug)]
|
||||||
pub struct Tally {
|
pub struct Tally {
|
||||||
@@ -267,6 +333,8 @@ pub struct Recording {
|
|||||||
pub tracks: Vec<Track>,
|
pub tracks: Vec<Track>,
|
||||||
index: HashMap<u32, usize>,
|
index: HashMap<u32, usize>,
|
||||||
pub events: Vec<Event>,
|
pub events: Vec<Event>,
|
||||||
|
/// None is the legacy event/track-only format.
|
||||||
|
pub scene: Option<SceneJournal>,
|
||||||
/// The failures counted during the raid.
|
/// The failures counted during the raid.
|
||||||
pub tally: Tally,
|
pub tally: Tally,
|
||||||
/// The systems and objects as they stood: (id, kind, where).
|
/// The systems and objects as they stood: (id, kind, where).
|
||||||
@@ -345,6 +413,11 @@ pub struct ReplayFire {
|
|||||||
pub start: u32,
|
pub start: u32,
|
||||||
pub life: u32,
|
pub life: u32,
|
||||||
pub size: f32,
|
pub size: f32,
|
||||||
|
/// Age already accumulated before recording began.
|
||||||
|
pub age_offset: u32,
|
||||||
|
pub quiet: bool,
|
||||||
|
pub shown: bool,
|
||||||
|
pub water: Option<bool>,
|
||||||
/// The event that lit it (the models' ids).
|
/// The event that lit it (the models' ids).
|
||||||
pub key: usize,
|
pub key: usize,
|
||||||
}
|
}
|
||||||
@@ -409,6 +482,24 @@ pub fn replay_blasts(rec: &Recording, t: f64) -> Vec<Blast> {
|
|||||||
/// feed it (the greater size, the longer life), as `Sim::ignite` does; those after it died light
|
/// 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`.
|
/// a new one. A pure function of `t`.
|
||||||
pub fn replay_fires(rec: &Recording, t: f64) -> Vec<ReplayFire> {
|
pub fn replay_fires(rec: &Recording, t: f64) -> Vec<ReplayFire> {
|
||||||
|
if let Some(scene) = rec.scene.as_ref().filter(|s| !s.truncated) {
|
||||||
|
let mut active: HashMap<u32, ReplayFire> = 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<ReplayFire> = Vec::new();
|
let mut fires: Vec<ReplayFire> = Vec::new();
|
||||||
for (k, e) in rec.events.iter().enumerate() {
|
for (k, e) in rec.events.iter().enumerate() {
|
||||||
if e.at as f64 > t {
|
if e.at as f64 > t {
|
||||||
@@ -427,7 +518,7 @@ pub fn replay_fires(rec: &Recording, t: f64) -> Vec<ReplayFire> {
|
|||||||
f.size = f.size.max(size);
|
f.size = f.size.max(size);
|
||||||
f.life = f.life.max(e.at - f.start + life);
|
f.life = f.life.max(e.at - f.start + life);
|
||||||
} else {
|
} 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);
|
fires.retain(|f| t < (f.start + f.life) as f64);
|
||||||
@@ -444,6 +535,7 @@ impl Recording {
|
|||||||
tracks: Vec::new(),
|
tracks: Vec::new(),
|
||||||
index: HashMap::new(),
|
index: HashMap::new(),
|
||||||
events: Vec::new(),
|
events: Vec::new(),
|
||||||
|
scene: Some(SceneJournal::default()),
|
||||||
tally: Tally::default(),
|
tally: Tally::default(),
|
||||||
sams: sim.sams.iter().map(|s| (s.id, s.kind, s.pos)).collect(),
|
sams: sim.sams.iter().map(|s| (s.id, s.kind, s.pos)).collect(),
|
||||||
objects: sim.targets.clone(),
|
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
|
/// 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).
|
/// 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<u64> {
|
pub fn world_tick(&self, t: f64) -> Option<u64> {
|
||||||
@@ -524,6 +684,15 @@ impl Recording {
|
|||||||
pub fn track(&self, id: u32) -> Option<&Track> {
|
pub fn track(&self, id: u32) -> Option<&Track> {
|
||||||
self.index.get(&id).map(|&i| &self.tracks[i])
|
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.
|
/// The system with this id as it stood.
|
||||||
pub fn sam(&self, id: u32) -> Option<(SamKind, DVec3)> {
|
pub fn sam(&self, id: u32) -> Option<(SamKind, DVec3)> {
|
||||||
self.sams.iter().find(|s| s.0 == id).map(|s| (s.1, s.2))
|
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.
|
/// Record an actual transition without modifying gameplay or random state.
|
||||||
pub(super) fn log(&mut self, pos: DVec3, kind: EventKind) {
|
pub(super) fn scene_event(&mut self, pos: DVec3, kind: SceneKind) {
|
||||||
match kind {
|
if let Some(r) = &mut self.rec {
|
||||||
EventKind::Crash { power, .. } => self.ignite(pos, power, None),
|
if let Some(journal) = &mut r.scene {
|
||||||
EventKind::Hit { power, sam, .. } => {
|
journal.push(SceneEvent { at: r.len, pos, kind });
|
||||||
let host = sam.and_then(|(_, i)| self.sams.get(i)).map(|s| s.id);
|
|
||||||
self.ignite(pos, power.max(1), host)
|
|
||||||
}
|
}
|
||||||
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 {
|
if let Some(r) = &mut self.rec {
|
||||||
let at = r.len;
|
let at = r.len;
|
||||||
if let EventKind::Kill { drone, .. } = kind
|
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,
|
/// 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.
|
/// ends it when the sky has been clear 5 s.
|
||||||
pub(super) fn record(&mut self) {
|
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
|
// 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.
|
// 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();
|
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() {
|
self.begin_recording_if_active();
|
||||||
// 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));
|
|
||||||
}
|
|
||||||
let Some(r) = &mut self.rec else { return };
|
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 r.len % TIME_STEP == 0 {
|
||||||
if let Some(dt) = self.day_time {
|
if let Some(dt) = self.day_time {
|
||||||
r.times.push((r.len, dt));
|
r.times.push((r.len, dt));
|
||||||
@@ -612,8 +783,12 @@ impl Sim {
|
|||||||
r.quiet = if busy { 0 } else { r.quiet + 1 };
|
r.quiet = if busy { 0 } else { r.quiet + 1 };
|
||||||
if r.quiet >= 100 || r.len >= LONGEST {
|
if r.quiet >= 100 || r.len >= LONGEST {
|
||||||
let mut done = self.rec.take().unwrap();
|
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));
|
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);
|
self.replays.push(done);
|
||||||
if self.replays.len() > KEPT {
|
if self.replays.len() > KEPT {
|
||||||
self.replays.remove(0);
|
self.replays.remove(0);
|
||||||
@@ -668,7 +843,7 @@ impl Sim {
|
|||||||
let spec = kind.spec();
|
let spec = kind.spec();
|
||||||
let rot = look(dir, 0.0) * DQuat::from_rotation_z(t * 0.3);
|
let rot = look(dir, 0.0) * DQuat::from_rotation_z(t * 0.3);
|
||||||
out.model(id, spec.model, pos, rot, spec.scale, false);
|
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;
|
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);
|
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),
|
// Fires: what the events left burning (the flames dim and the smoke thickens with time),
|
||||||
// out when their life ends.
|
// out when their life ends.
|
||||||
for f in replay_fires(rec, t) {
|
for f in replay_fires(rec, t) {
|
||||||
let age = t - f.start as f64;
|
let age = t - f.start as f64 + f.age_offset as f64;
|
||||||
let (flame, smoke) = fires::replay_fire_of(f.size, f.life, age);
|
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;
|
let id = base + 0x00c0_0000 + f.key as u32 * PARTS;
|
||||||
if flame > 0.05 {
|
if flame > 0.05 {
|
||||||
let wob = 1.0 + 0.15 * (age * 0.9 + f.key as f64).sin();
|
let wob = 1.0 + 0.15 * (age * 0.9 + f.key as f64).sin();
|
||||||
@@ -781,6 +958,7 @@ mod tests {
|
|||||||
tracks: Vec::new(),
|
tracks: Vec::new(),
|
||||||
index: HashMap::new(),
|
index: HashMap::new(),
|
||||||
events: Vec::new(),
|
events: Vec::new(),
|
||||||
|
scene: None,
|
||||||
tally: Tally::default(),
|
tally: Tally::default(),
|
||||||
sams: Vec::new(),
|
sams: Vec::new(),
|
||||||
objects: Vec::new(),
|
objects: Vec::new(),
|
||||||
@@ -811,6 +989,155 @@ mod tests {
|
|||||||
assert!((pose(100.0).pos[0] - 4.0).abs() < 1e-6);
|
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::<SceneEvent>() / (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.
|
// #76: a replay's searchlights point where the recording had them, from the recorded pose.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_replay_has_the_recorded_searchlight_beams() {
|
fn a_replay_has_the_recorded_searchlight_beams() {
|
||||||
|
|||||||
@@ -624,6 +624,8 @@ fn replay_fires_and_blasts_follow_the_recording() {
|
|||||||
let mut out = Out::default();
|
let mut out = Out::default();
|
||||||
sim.step(&mut g, None, &mut out);
|
sim.step(&mut g, None, &mut out);
|
||||||
let mut rec = sim.rec.take().unwrap();
|
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 at = DVec3::new(5.0, 70.0, 5.0);
|
||||||
let crash = |power| EventKind::Crash { drone: 1, kind: DroneKind::Geran3, power };
|
let crash = |power| EventKind::Crash { drone: 1, kind: DroneKind::Geran3, power };
|
||||||
let life = fires::life_for(1);
|
let life = fires::life_for(1);
|
||||||
|
|||||||
@@ -756,7 +756,7 @@ impl Sim {
|
|||||||
self.strikes.shot_down += 1;
|
self.strikes.shot_down += 1;
|
||||||
let high = at + DVec3::Y * 40.0;
|
let high = at + DVec3::Y * 40.0;
|
||||||
self.blast_at(high, 0, true, false, ground, player, out);
|
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));
|
out.messages.push(format!("Удар по ПУ №{}: ракета сбита ПВО противника у площадки, цель цела", f.site));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -781,11 +781,11 @@ impl Sim {
|
|||||||
self.strikes.destroyed += 1;
|
self.strikes.destroyed += 1;
|
||||||
self.strikes.cancelled.push((f.site, n));
|
self.strikes.cancelled.push((f.site, n));
|
||||||
self.set_state(f.site, SiteState::Destroyed(now));
|
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));
|
out.messages.push(format!("Удар по ПУ №{}: за пределами загруженной карты, результат не виден; пуски оттуда прекратились (отменено {n})", f.site));
|
||||||
} else {
|
} else {
|
||||||
self.strikes.missed += 1;
|
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));
|
out.messages.push(format!("Удар по ПУ №{}: за пределами загруженной карты; пуски идут, ракета потеряна", f.site));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -805,7 +805,7 @@ impl Sim {
|
|||||||
self.later.push((when, at + off, if loaded { 2 } else { 0 }, true));
|
self.later.push((when, at + off, if loaded { 2 } else { 0 }, true));
|
||||||
}
|
}
|
||||||
self.strikes.wrecks.push((at, now + TUNE.burn));
|
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!(
|
out.messages.push(format!(
|
||||||
"Удар по ПУ №{}: площадка уничтожена, запас сгорел ({stock}); отменено запусков: {n}",
|
"Удар по ПУ №{}: площадка уничтожена, запас сгорел ({stock}); отменено запусков: {n}",
|
||||||
f.site
|
f.site
|
||||||
@@ -818,7 +818,7 @@ impl Sim {
|
|||||||
self.strikes.stunned += 1;
|
self.strikes.stunned += 1;
|
||||||
self.set_state(f.site, SiteState::Stunned(now + TUNE.suppress_ticks));
|
self.set_state(f.site, SiteState::Stunned(now + TUNE.suppress_ticks));
|
||||||
self.blast_at(at, 4, false, false, ground, player, out);
|
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!(
|
out.messages.push(format!(
|
||||||
"Удар по ПУ №{}: накрыла рядом ({miss:.0} м), площадка подавлена на {} с, но цела",
|
"Удар по ПУ №{}: накрыла рядом ({miss:.0} м), площадка подавлена на {} с, но цела",
|
||||||
f.site,
|
f.site,
|
||||||
@@ -828,7 +828,7 @@ impl Sim {
|
|||||||
_ => {
|
_ => {
|
||||||
self.strikes.missed += 1;
|
self.strikes.missed += 1;
|
||||||
self.blast_at(at, 4, false, false, ground, player, out);
|
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));
|
out.messages.push(format!("Удар по ПУ №{}: промах на {miss:.0} м, площадка цела, пуски идут", f.site));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -842,7 +842,7 @@ impl Sim {
|
|||||||
}
|
}
|
||||||
self.strikes.missed += 1;
|
self.strikes.missed += 1;
|
||||||
self.blast_at(at, 4, false, false, ground, player, out);
|
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!(
|
out.messages.push(format!(
|
||||||
"Удар по ПУ №{}: ракета разбилась о рельеф или строение в {:.0} м от цели, площадка цела",
|
"Удар по ПУ №{}: ракета разбилась о рельеф или строение в {:.0} м от цели, площадка цела",
|
||||||
f.site,
|
f.site,
|
||||||
|
|||||||
@@ -193,7 +193,11 @@ pub fn replay_smoke(rec: &crate::sim::record::Recording, t: f64) -> Vec<SmokeSeg
|
|||||||
fires.sort_by(|a, b| b.size.total_cmp(&a.size).then(a.start.cmp(&b.start)));
|
fires.sort_by(|a, b| b.size.total_cmp(&a.size).then(a.start.cmp(&b.start)));
|
||||||
for (rank, f) in fires.iter().enumerate() {
|
for (rank, f) in fires.iter().enumerate() {
|
||||||
let life = f.life as f64;
|
let life = f.life as f64;
|
||||||
fire_column(&mut out, 0x1000_0000 | f.key as u32, f.at, f.start as u64, f.size, rank < crate::sim::fires::MAX, tick, &|k| crate::sim::fires::level(k.saturating_sub(f.start as u64) as f64, life));
|
fire_column(&mut out, 0x1000_0000 | f.key as u32, f.at, f.start as u64, f.size, f.shown && rank < crate::sim::fires::MAX, tick, &|k| {
|
||||||
|
let (mut flame, mut smoke) = crate::sim::fires::level(k.saturating_sub(f.start as u64) as f64 + f.age_offset as f64, life);
|
||||||
|
if f.water == Some(true) { flame = 0.0; smoke *= 0.5; }
|
||||||
|
(flame, smoke)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Blasts keep their billows long after the fireball is out.
|
// Blasts keep their billows long after the fireball is out.
|
||||||
let blasts: Vec<crate::sim::Blast> = rec
|
let blasts: Vec<crate::sim::Blast> = rec
|
||||||
|
|||||||
Reference in New Issue
Block a user