Files
airdef/airdef-mod/src/sim/damage.rs
T
2026-11-03 00:18:44 +03:00

641 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Air defence can be hit too: a blast near a system damages it (it turns
//! slower, sees less, hits worse, smokes, then burns), a destroyed one leaves
//! a burning wreck and its magazine cooks off — the more missiles or shells
//! were left in it, the bigger the blast and the longer the secondary ones.
//! Marked objects take the same blasts.
use super::*;
/// By blast power (as `explode` grades it): how far it reaches a vehicle, and how hard it hits one at the centre.
const REACH: [f64; 5] = [4.0, 7.0, 12.0, 18.0, 26.0];
const HIT: [f64; 5] = [0.2, 0.45, 0.85, 1.25, 1.7];
/// A unit's working parts, each 1 (whole) to 0 (failed); the hull is `Sam::health` (0 destroys it).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Part {
/// The radar (or optics): its reach and its scan.
Sensor,
/// The aiming drive of the launcher or turret.
Mount,
/// The launcher: cells, reload.
Launcher,
/// The gun: it jams.
Gun,
/// The running gear: it can't march when it fails.
Tracks,
/// The data link: no network when it fails.
Comms,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PartState {
Ok,
Damaged,
Failed,
}
impl Part {
pub const ALL: [Part; 6] = [Part::Sensor, Part::Mount, Part::Launcher, Part::Gun, Part::Tracks, Part::Comms];
pub fn name(self) -> &'static str {
match self {
Part::Sensor => "РЛС",
Part::Mount => "привод наведения",
Part::Launcher => "пусковая",
Part::Gun => "орудие",
Part::Tracks => "ходовая",
Part::Comms => "связь",
}
}
pub fn short(self) -> &'static str {
match self {
Part::Sensor => "РЛС",
Part::Mount => "Прив",
Part::Launcher => "ПУ",
Part::Gun => "Ор",
Part::Tracks => "Ход",
Part::Comms => "Св",
}
}
fn index(self) -> usize {
self as usize
}
/// Whether this kind of unit has the part at all.
pub fn on(self, kind: SamKind) -> bool {
let spec = kind.spec();
match self {
Part::Sensor => spec.sensor > 0.0,
Part::Mount => kind.armed() && kind != SamKind::Light && (spec.missile.is_some() || spec.gun.is_some()),
Part::Launcher => spec.missile.is_some(),
Part::Gun => spec.gun.is_some(),
Part::Tracks => kind.mobile(),
Part::Comms => true,
}
}
/// Where the part is on the vehicle: right, up, back, blocks at scale 1 (the nose points to -back).
fn place(self, spec: &super::units::SamSpec) -> (f64, f64, f64) {
match self {
Part::Sensor => (0.0, spec.mast / spec.scale, 1.0),
Part::Mount => (0.0, spec.mount.0, -spec.mount.1),
Part::Launcher => (0.0, spec.mount.0, -spec.mount.1 + 0.4),
Part::Gun => (0.0, spec.mount.0, -spec.mount.1 - 1.0),
Part::Tracks => (0.0, 0.3, 0.0),
Part::Comms => (0.0, 1.6, 1.8),
}
}
}
/// Below this a part is damaged (works worse), at 0 it has failed.
pub const WORN: f64 = 0.6;
/// The state of every part: `Part::ALL`'s order.
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct Comps(pub [f64; 6]);
impl Default for Comps {
fn default() -> Self {
Self([1.0; 6])
}
}
impl Comps {
pub fn get(&self, p: Part) -> f64 {
self.0[p.index()]
}
pub fn state(&self, p: Part) -> PartState {
let v = self.get(p);
if v <= 0.0 {
PartState::Failed
} else if v < WORN {
PartState::Damaged
} else {
PartState::Ok
}
}
pub fn whole(&self) -> bool {
self.0.iter().all(|&v| v >= 1.0)
}
/// Parts of this kind that are not whole, as "РЛС 40%, ходовая отказ".
pub fn describe(&self, kind: SamKind) -> String {
Part::ALL
.iter()
.filter(|p| p.on(kind) && self.get(**p) < 1.0)
.map(|p| match self.state(*p) {
PartState::Failed => format!("{} отказ", p.short()),
_ => format!("{} {:.0}%", p.short(), self.get(*p) * 100.0),
})
.collect::<Vec<_>>()
.join(", ")
}
/// The weakest part alone, for a one-line status (the full list is on the monitor).
pub fn worst(&self, kind: SamKind) -> String {
Part::ALL
.iter()
.filter(|p| p.on(kind) && self.get(**p) < 1.0)
.min_by(|a, b| self.get(**a).total_cmp(&self.get(**b)))
.map(|p| match self.state(*p) {
PartState::Failed => format!("{} отказ", p.short()),
_ => format!("{} {:.0}%", p.short(), self.get(*p) * 100.0),
})
.unwrap_or_default()
}
}
/// Health points an object loses at the centre of a blast.
const OBJECT_HIT: [f64; 5] = [2.0, 6.0, 14.0, 26.0, 40.0];
/// Three cheap game samples across a hull. Known cover leaves residual splash;
/// unknown geometry keeps the previous unattenuated damage (not a claim of air).
/// Zero async wait freezes the pre-destruction cache and avoids late double damage.
fn blast_exposure(ground: &Ground, from: DVec3, centre: DVec3, size: f64, now: u64) -> f64 {
let across = horizontal(centre - from).cross(DVec3::Y).normalize_or(DVec3::X) * size;
[centre, centre - across, centre + across].into_iter().map(|to| {
if ground.world.blast_cover(from, to, now) == Some(true) { 0.15 } else { 1.0 }
}).sum::<f64>() / 3.0
}
impl MissileKind {
/// The explosive a missile left in a burning launcher adds (TNT).
fn charge(self) -> f64 {
match self {
MissileKind::Pac3 => 0.8,
MissileKind::M57e6 => 0.45,
MissileKind::M48n6 => 1.8,
MissileKind::M77n6 => 2.2,
MissileKind::IrisT => 0.6,
MissileKind::Amraam => 0.5,
MissileKind::M9m317 => 1.2,
MissileKind::M9m338 => 0.4,
MissileKind::P1sun => 0.1,
}
}
}
/// A strike that ends farther than this from its aim point misses (the lethal radius of a point target).
pub(super) const MISS_RADIUS: f64 = 3.0;
/// A system's hull, as a cylinder: its radius (× scale) and how far above the ground it reaches (× scale).
const HULL_RADIUS: f64 = 2.2;
const HULL_TOP: f64 = 3.0;
impl DroneKind {
/// Strike accuracy: the CEP (blocks; half the strikes land within it, the scatter is Rayleigh)
/// and the share of strikes with a rare gross navigation fault, which lands beyond `MISS_RADIUS`.
pub(super) fn accuracy(self) -> (f64, f64) {
match self {
DroneKind::Shahed => (1.2, 0.03),
DroneKind::Geran3 => (1.0, 0.03),
DroneKind::Lancet => (0.8, 0.02),
DroneKind::Gerbera => (1.5, 0.04),
DroneKind::Kh101 => (1.3, 0.05),
DroneKind::Kalibr => (1.3, 0.05),
DroneKind::Kh22 => (1.6, 0.06),
DroneKind::Zircon => (1.5, 0.06),
DroneKind::Iskander => (1.4, 0.06),
DroneKind::Kinzhal => (1.5, 0.06),
// The bus and its blocks: the blocks are aimed by the split, so only the bus's own error applies.
DroneKind::Oreshnik | DroneKind::OreshnikBlock | DroneKind::OreshnikSub => (1.6, 0.08),
}
}
}
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();
let angle = self.rng.f() * std::f64::consts::TAU;
let r = if self.rng.chance(gross) {
MISS_RADIUS + cep * (0.5 + 2.5 * self.rng.f())
} else {
// Rayleigh with its median at the CEP.
let u = 1.0 - self.rng.f();
cep * (-2.0 * u.ln() / (2.0 * std::f64::consts::LN_2)).sqrt()
};
DVec3::new(angle.cos() * r, 0.0, angle.sin() * r)
}
/// An armed warhead reached `pos` (its last step was `vel`): a system whose hull it swept through is
/// destroyed at once, whatever the blast would have done to it. A near miss is only splash.
pub(super) fn direct_hit(&mut self, pos: DVec3, vel: DVec3, tnt: u32, out: &mut Out) {
if !self.damage || tnt == 0 {
return;
}
let from = pos - vel;
for i in 0..self.sams.len() {
let sam = &self.sams[i];
if !sam.alive() {
continue;
}
let s = sam.kind.spec().scale;
let hit = (0..=8).any(|k| {
let p = from.lerp(pos, k as f64 / 8.0);
let across = DVec3::new(p.x - sam.pos.x, 0.0, p.z - sam.pos.z).length();
across <= HULL_RADIUS * s && p.y >= sam.pos.y - 1.0 && p.y <= sam.pos.y + HULL_TOP * s
});
if hit {
self.destroy(i, out);
}
}
}
/// A blast of `power` (0 to 4) at `pos`: the systems and objects round it take damage.
pub(super) fn blast_damage(&mut self, pos: DVec3, power: usize, ground: &Ground, out: &mut Out) {
let (reach, hit) = (REACH[power], HIT[power]);
let mut destroyed = Vec::new();
for (i, sam) in self.sams.iter_mut().enumerate() {
if !sam.alive() {
continue;
}
// The vehicle is a few blocks long: measured from its hull.
let d = (sam.pos.distance(pos) - 2.5).max(0.0);
if d >= reach {
continue;
}
let k = 1.0 - d / reach;
let cover = blast_exposure(ground, pos, sam.pos + DVec3::Y * (1.5 * sam.kind.spec().scale), sam.kind.spec().scale, self.tick);
let before = sam.health;
sam.health -= cover * hit * k * k * if d < 1.0 { 1.5 } else { 1.0 };
if sam.health <= 0.0 {
destroyed.push(i);
continue;
}
if before >= 0.5 && sam.health < 0.5 {
out.messages.push(format!("{} №{} повреждён", sam.kind.name(), i + 1));
}
// The parts take the blast by their own distance from it: the side it came from decides which.
let spec = sam.kind.spec();
let rot = DQuat::from_rotation_y(sam.heading);
for part in Part::ALL {
if !part.on(sam.kind) {
continue;
}
let (x, y, back) = part.place(spec);
let at = sam.pos + rot * DVec3::new(x, y, back) * spec.scale;
let size = if part == Part::Tracks { 2.5 } else { 1.0 };
let dp = (at.distance(pos) - size).max(0.0);
if dp >= reach {
continue;
}
let kp = 1.0 - dp / reach;
let roll = dice(self.seed, self.tick, sam.id as u64, part as u64);
// The parts are softer than the hull: a blast the hull survives can still put one out.
let dmg = cover * hit * kp * kp * (0.7 + roll) * 1.5 * if dp < 1.0 { 1.4 } else { 1.0 };
let was = sam.comps.state(part);
let v = &mut sam.comps.0[part as usize];
*v = (*v - dmg).max(0.0);
let now = sam.comps.state(part);
if now != was && now != PartState::Ok {
let word = if now == PartState::Failed { "отказал(а)" } else { "повреждён(а)" };
out.messages.push(format!("{} №{}: {} {word}", sam.kind.name(), i + 1, part.name()));
}
}
sam.fit_cells();
}
for i in destroyed {
self.destroy(i, out);
}
for (i, t) in self.targets.iter().enumerate() {
let d = t.distance(pos);
let r = reach * 1.6;
if d < r {
if let Some(h) = self.target_health.get_mut(i) {
let before = *h;
let cover = blast_exposure(ground, pos, *t + DVec3::Y * 0.8, 0.75, self.tick);
*h = (*h - cover * OBJECT_HIT[power] * (1.0 - d / r)).max(0.0);
if before > 0.0 && *h <= 0.0 {
out.messages.push(format!("ОБЪЕКТ №{} УНИЧТОЖЕН", i + 1));
}
}
}
}
}
/// System `i` destroyed: its magazine goes up, the wreck burns.
fn destroy(&mut self, i: usize, out: &mut Out) {
let now = self.tick;
let sam = &mut self.sams[i];
sam.health = 0.0;
sam.burn = 900;
sam.state = SamState::Destroyed;
sam.target = None;
sam.gun_target = None;
sam.reloading = 0;
sam.tracks.clear();
sam.seen.clear();
sam.own.clear();
let spec = sam.kind.spec();
let missiles = sam.missiles;
let bursts = sam.bursts;
sam.missiles = 0;
sam.bursts = 0;
let pos = sam.pos + DVec3::Y;
out.messages.push(format!("{} №{} УНИЧТОЖЕН", sam.kind.name(), i + 1));
let (id, kind) = (sam.id, sam.kind);
// A depot's stock (or a truck's load) goes up with it.
let stock = if matches!(self.sams[i].kind, SamKind::Depot | SamKind::Tzm) { self.sams[i].cargo } else { 0 };
self.sams[i].cargo = 0;
let typed = self.sams[i].ground_stock + self.sams[i].air_48n6_stock;
self.sams[i].ground_stock = 0;
self.sams[i].air_48n6_stock = 0;
self.sams[i].ground_rounds = 0;
self.sams[i].ground_request = false;
self.sams[i].ground_return = false;
self.sams[i].return_cargo = false;
let missiles = missiles + stock * 2 + typed;
let charge = spec.missile.as_ref().map_or(0.0, |m| m.kind.charge() * missiles as f64) + bursts as f64 / 60.0
+ stock as f64 * 1.5;
// The vehicle's own fuel and the first of its load at once, then the rest cooking off.
let first = (1.0 + charge * 0.6).round().clamp(1.0, 8.0) as u32;
self.later.push((now + 2, pos, first, false));
for k in 0..missiles.min(40) {
let when = now + 15 + (k as u64) * 12 + (self.rng.f() * 25.0) as u64;
let at = pos + DVec3::new(self.rng.s() * 3.0, self.rng.f() * 2.0, self.rng.s() * 3.0);
let tnt = if charge > 4.0 && self.rng.chance(0.4) { 2 } else { 1 };
self.later.push((when, at, tnt, true));
}
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;
let at = pos + DVec3::new(self.rng.s() * 2.0, self.rng.f() * 1.5, self.rng.s() * 2.0);
self.later.push((when, at, 0, true));
}
}
/// The blasts whose time has come.
pub(super) fn step_later(&mut self, ground: &mut Ground, player: Option<DVec3>, out: &mut Out) {
let now = self.tick;
let due: Vec<_> = self.later.iter().filter(|l| l.0 <= now).copied().collect();
self.later.retain(|l| l.0 > now);
for (_, pos, tnt, small) in due {
self.explode(pos, tnt, small, ground, player, out);
if small {
// Something flies out of a burning launcher.
out.particles("minecraft:lava", pos, 0.8, DVec3::new(0.0, 0.3, 0.0), 0.3, 12);
out.particles("minecraft:firework", pos, 0.5, DVec3::new(0.0, 0.2, 0.0), 0.25, 10);
}
}
}
/// Damaged systems smoke, wrecks burn.
pub(super) fn step_wrecks(&mut self, out: &mut Out) {
let tick = self.tick;
for sam in &mut self.sams {
let top = sam.pos + DVec3::Y * (1.5 * sam.kind.spec().scale);
if !sam.alive() {
if sam.burn > 0 {
sam.burn -= 1;
out.particles("minecraft:flame", top, 1.0, DVec3::new(0.0, 0.05, 0.0), 0.03, 3);
out.particles("minecraft:large_smoke", top, 0.8, DVec3::new(0.0, 0.12, 0.0), 0.03, 2);
if tick % 8 == 0 {
out.particles("minecraft:campfire_cosy_smoke", top, 1.0, DVec3::new(0.0, 0.07, 0.0), 0.01, 1);
}
if tick % 40 == 0 {
out.sound("minecraft:block.fire.ambient", top, 3.0, 0.7);
}
} else if tick % 12 == 0 {
out.particles("minecraft:campfire_signal_smoke", top, 0.6, DVec3::new(0.0, 0.05, 0.0), 0.01, 1);
}
} else if sam.health < 0.7 {
let every = if sam.health < 0.35 { 2 } else { 5 };
if tick % every == 0 {
out.particles("minecraft:large_smoke", top, 0.4, DVec3::new(0.0, 0.08, 0.0), 0.02, 1);
}
if sam.health < 0.35 && tick % 3 == 0 {
out.particles("minecraft:flame", top, 0.3, DVec3::ZERO, 0.01, 1);
}
}
}
}
}
/// A roll from 0 to 1 fixed by the seed and three numbers: the same events roll the same in every run,
/// and the simulation's own random sequence is left alone.
pub(super) fn dice(seed: u64, a: u64, b: u64, c: u64) -> f64 {
let mut x = seed ^ a.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ b.wrapping_mul(0xBF58_476D_1CE4_E5B9) ^ c.wrapping_mul(0x94D0_49BB_1331_11EB);
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
x ^= x >> 31;
(x >> 11) as f64 / (1u64 << 53) as f64
}
impl Sam {
/// Its wreck still burns.
pub fn burning(&self) -> bool {
self.burn > 0
}
/// A part's state, `Ok` for one this unit does not have.
pub fn part(&self, p: Part) -> f64 {
if p.on(self.kind) { self.comps.get(p) } else { 1.0 }
}
pub fn part_state(&self, p: Part) -> PartState {
if p.on(self.kind) { self.comps.state(p) } else { PartState::Ok }
}
pub fn parts_text(&self) -> String {
self.comps.describe(self.kind)
}
/// The aiming drive's share of its speed: a failed one doesn't turn at all.
pub(super) fn drive_fit(&self) -> f64 {
let h = self.part(Part::Mount);
if h <= 0.0 { 0.0 } else { 0.3 + 0.7 * h }
}
/// The radar's share of its reach; the scan is slower by the same.
pub(super) fn radar_fit(&self) -> f64 {
let h = self.part(Part::Sensor);
if h <= 0.0 { 0.0 } else { 0.4 + 0.6 * h }
}
/// The gun's share of its hit chance.
pub(super) fn gun_fit(&self) -> f64 {
0.5 + 0.5 * self.part(Part::Gun)
}
/// Its launcher's cells still good: a damaged one holds fewer missiles.
pub(super) fn cells(&self) -> u32 {
let Some(load) = &self.kind.spec().missile else { return 0 };
let h = self.part(Part::Launcher);
if h <= 0.0 { 0 } else { ((load.magazine as f64 * (0.4 + 0.6 * h)).ceil() as u32).clamp(1, load.magazine) }
}
/// Missiles above what the cells hold are lost with them.
pub fn fit_cells(&mut self) {
if self.kind.spec().missile.is_some() {
self.ground_rounds = self.ground_rounds.min(self.cells());
self.missiles = self.missiles.min(self.cells().saturating_sub(self.ground_rounds));
}
}
/// Ticks a reload takes: longer with a damaged launcher.
pub(super) fn reload_time(&self) -> u32 {
let r = self.kind.spec().reload.max(1);
(r as f64 * (1.0 + 1.5 * (1.0 - self.part(Part::Launcher)))).round() as u32
}
/// The gun fires (not jammed for good, not jammed now).
pub(super) fn gun_up(&self) -> bool {
self.part(Part::Gun) > 0.0 && self.jam == 0
}
/// How far it can march: the running gear's share of the speed (0 can't).
pub(super) fn drive_speed(&self) -> f64 {
let h = self.part(Part::Tracks);
if h <= 0.0 { 0.0 } else { 0.35 + 0.65 * h }
}
/// The data link's reach, 0 when the link has failed.
pub(super) fn link_range(&self) -> f64 {
let h = self.part(Part::Comms);
if h <= 0.0 { 0.0 } else { super::sight::LINK * (0.3 + 0.7 * h) }
}
}
impl Sim {
/// Repairs every system (wrecks rebuilt, magazines full) and object.
pub fn repair(&mut self) -> u32 {
let mut n = 0;
for sam in &mut self.sams {
if sam.health < 1.0 || !sam.comps.whole() {
n += 1;
}
sam.comps = Comps::default();
sam.jam = 0;
let spec = sam.kind.spec();
sam.health = 1.0;
sam.burn = 0;
sam.state = SamState::Search;
sam.reloading = 0;
sam.empty = false;
// Rebuilt where it stands: a march under way is called off.
sam.dest = None;
sam.march = 0;
sam.missiles = spec.missile.as_ref().map_or(0, |m| m.magazine.saturating_sub(sam.ground_rounds));
sam.bursts = spec.gun.as_ref().map_or(0, |g| g.bursts);
}
for h in &mut self.target_health {
*h = 100.0;
}
// The wrecks' ammunition still to cook off goes with them.
self.later.clear();
self.fires.extinguish();
n
}
}
/// A wreck: the hull sunk and turned, the launcher or turret thrown down beside it (a live system's, or a replay's ghost).
pub(super) fn draw_wreck_of(sam: &Sam, out: &mut Out) {
{
let s = sam.kind.spec().scale;
let tilt = DQuat::from_rotation_y(sam.heading) * DQuat::from_rotation_z(0.12) * DQuat::from_rotation_x(-0.06);
let hull = sam.pos + DVec3::Y * (0.5 * s - 0.35);
let side = DQuat::from_rotation_y(sam.heading) * DVec3::new(1.8 * s, 0.0, 0.6 * s);
let thrown = DQuat::from_rotation_y(sam.heading + 1.1) * DQuat::from_rotation_z(1.2);
let parts = &sam.kind.spec().parts;
let (body, part) = (parts.body, parts.launcher.or(parts.pack));
out.model(sam.id, super::units::wreck(body), hull, tilt, s, false);
if let Some(part) = part {
out.model(sam.id + 1, super::units::wreck(part), sam.pos + side + DVec3::Y * 0.4 * s, thrown, s, false);
}
}
}
#[cfg(test)]
mod cover_tests {
use super::*;
struct CoverWorld { wall: bool, opening: bool }
impl world::WorldSource for CoverWorld {
fn solid(&self, x: i32, _y: i32, z: i32) -> Option<bool> {
Some(self.wall && x == 8 && !(self.opening && z == 8))
}
}
fn cache(src: &CoverWorld) -> Ground {
let mut ground = Ground::default();
let mut answers = Vec::new();
for x in -1..=1 { for z in -1..=1 {
let sec = (x, 4, z);
answers.push(world::SectionAnswer { sec, fill: world::section_of(src, sec) });
}}
ground.world.answer(answers, 10);
ground
}
fn victim() -> Sim {
let mut sim = Sim::new(7);
sim.tick = 10;
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::S400, DVec3::new(10.5, 70.0, 8.5), 0.0));
sim.targets.push(DVec3::new(10.5, 71.0, 8.5));
sim.target_health.push(100.0);
sim
}
#[test]
fn known_wall_and_opening_attenuate_hull_parts_and_objects() {
let from = DVec3::new(0.5, 71.5, 8.5);
let mut results = Vec::new();
for src in [CoverWorld { wall: false, opening: false }, CoverWorld { wall: true, opening: false }, CoverWorld { wall: true, opening: true }] {
let mut sim = victim();
sim.blast_damage(from, 2, &cache(&src), &mut Out::default());
results.push((1.0 - sim.sams[0].health, 1.0 - sim.sams[0].comps.get(Part::Tracks), 100.0 - sim.target_health[0]));
}
for i in 0..3 {
let open = [results[0].0, results[0].1, results[0].2][i];
let wall = [results[1].0, results[1].1, results[1].2][i];
let gap = [results[2].0, results[2].1, results[2].2][i];
assert!(open > 0.0);
assert!((wall / open - 0.15).abs() < 1e-9);
assert!(wall < gap && gap < open, "sample {i}: wall {wall}, gap {gap}, open {open}");
}
}
#[test]
fn unresolved_cover_has_bounded_zero_wait_and_no_late_damage() {
let from = DVec3::new(0.5, 71.5, 8.5);
let mut unknown = Ground::default();
assert_eq!(unknown.world.blast_cover(from, DVec3::new(10.5, 71.5, 8.5), 10), None);
let mut sim = victim();
let mut open = victim();
sim.blast_damage(from, 2, &unknown, &mut Out::default());
open.blast_damage(from, 2, &cache(&CoverWorld { wall: false, opening: false }), &mut Out::default());
assert_eq!(sim.sams[0].health, open.sams[0].health);
let health = sim.sams[0].health;
let hit_points = sim.target_health[0];
assert!(unknown.world.take_wanted(10).is_empty());
assert!(unknown.world.take_rays(10).is_empty());
// A reply, even one proving cover, is not a second combat event.
unknown.world.answer(vec![world::SectionAnswer { sec: (0, 4, 0), fill: world::Fill::Full }], 11);
assert_eq!(sim.sams[0].health, health);
assert_eq!(sim.target_health[0], hit_points);
}
#[test]
fn direct_contact_is_not_weakened_by_cover_and_blast_does_not_mutate_world() {
let mut sim = victim();
let ground = cache(&CoverWorld { wall: true, opening: false });
let from = DVec3::new(0.5, 71.5, 8.5);
let end = DVec3::new(10.5, 71.5, 8.5);
assert_eq!(ground.world.blast_cover(from, end, 10), Some(true));
sim.blast_damage(from, 2, &ground, &mut Out::default());
assert_eq!(ground.world.blast_cover(from, end, 10), Some(true));
let at = sim.sams[0].pos + DVec3::Y;
sim.direct_hit(at, DVec3::X * 2.0, 1, &mut Out::default());
assert!(!sim.sams[0].alive());
}
#[test]
fn stale_cover_is_unresolved_and_source_voxel_does_not_shield_everything() {
let ground = cache(&CoverWorld { wall: true, opening: false });
let from = DVec3::new(0.5, 71.5, 8.5);
let end = DVec3::new(10.5, 71.5, 8.5);
assert_eq!(ground.world.blast_cover(from, end, 1210), None);
let source = DVec3::new(8.5, 71.5, 8.5);
assert_eq!(ground.world.blast_cover(source, end, 10), Some(false));
}
}