feat: attenuate indirect blast damage behind known cover
build / mod (push) Failing after 18s

Use a bounded read-only pre-destruction occupancy sample; preserve conservative damage for unresolved geometry and do not defer or repeat damage on replies.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
2026-11-02 23:55:38 +03:00
parent a7d400ea7e
commit c96676f3e5
8 changed files with 196 additions and 19 deletions
+112 -4
View File
@@ -141,6 +141,16 @@ impl Comps {
/// 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 {
@@ -240,7 +250,7 @@ impl Sim {
}
/// 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, out: &mut Out) {
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() {
@@ -253,8 +263,9 @@ impl Sim {
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 -= hit * k * k * if d < 1.0 { 1.5 } else { 1.0 };
sam.health -= cover * hit * k * k * if d < 1.0 { 1.5 } else { 1.0 };
if sam.health <= 0.0 {
destroyed.push(i);
continue;
@@ -279,7 +290,7 @@ impl Sim {
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 = hit * kp * kp * (0.7 + roll) * 1.5 * if dp < 1.0 { 1.4 } else { 1.0 };
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);
@@ -300,7 +311,8 @@ impl Sim {
if d < r {
if let Some(h) = self.target_health.get_mut(i) {
let before = *h;
*h = (*h - OBJECT_HIT[power] * (1.0 - d / r)).max(0.0);
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));
}
@@ -523,3 +535,99 @@ pub(super) fn draw_wreck_of(sam: &Sam, out: &mut Out) {
}
}
}
#[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));
}
}
+7 -7
View File
@@ -2386,7 +2386,7 @@ impl Sim {
out.jolt((1.0 - d / reach) as f32 * strength, [1.0, 0.8, 1.6, 2.2, 3.0][power]);
}
}
self.blast_damage(pos, power, out);
self.blast_damage(pos, power, ground, out);
if self.damage && tnt > 0 {
for i in 0..tnt {
out.commands.push(format!(
@@ -2416,17 +2416,17 @@ impl Sim {
}
/// A drone hit by the guns of system `by`.
fn shoot_down(&mut self, idx: usize, by: u32, missile: Option<u32>, out: &mut Out) -> Option<Outcome> {
fn shoot_down(&mut self, idx: usize, by: u32, missile: Option<u32>, ground: &Ground, out: &mut Out) -> Option<Outcome> {
let kind = self.sams.iter().find(|s| s.id == by).map_or(SamKind::Gepard, |s| s.kind);
let shell = kind.spec().gun.as_ref().map_or(5.0, |g| g.shell);
let at = self.drones[idx].pos;
self.shoot_down_by(idx, by, missile, Impact::gun(kind, at, shell), out)
self.shoot_down_by(idx, by, missile, Impact::gun(kind, at, shell), ground, out)
}
/// A drone brought down by `imp` from system `by`: how it ends is rolled once (combat randomness, so a
/// detonation really means no ground impact), recorded with the kill, and drawn from the visual randomness
/// only. The kill is counted here and nowhere else.
fn shoot_down_by(&mut self, idx: usize, by: u32, missile: Option<u32>, imp: Impact, out: &mut Out) -> Option<Outcome> {
fn shoot_down_by(&mut self, idx: usize, by: u32, missile: Option<u32>, imp: Impact, ground: &Ground, out: &mut Out) -> Option<Outcome> {
if !self.drones[idx].alive() {
return None;
}
@@ -2489,7 +2489,7 @@ impl Sim {
out.particles("minecraft:firework", pos, 0.6, DVec3::ZERO, 0.5, 50);
// The shock arrives after the flash (`boom` sounds travel); the warhead's blast hurts what is near.
out.sound("airdef:boom.sonic", pos, 0.8, 1.2);
self.blast_damage(pos, 0, out);
self.blast_damage(pos, 0, ground, out);
}
}
out.sound(sound, pos, gain, pitch);
@@ -2927,7 +2927,7 @@ impl Sim {
}
for (t, by) in gun_hits {
if let Some(i) = self.drones.iter().position(|d| d.id == t) {
let _ = self.shoot_down(i, by, None, out);
let _ = self.shoot_down(i, by, None, ground, out);
}
}
for i in 0..self.sams.len() {
@@ -3133,7 +3133,7 @@ impl Sim {
self.drones[t].engaged = self.drones[t].engaged.saturating_sub(1);
if kill {
let (at, closing) = (self.drones[t].pos, (m.vel - self.drones[t].vel).length());
let _ = self.shoot_down_by(t, sam, Some(mid), Impact::missile(m.kind, at, closing), out);
let _ = self.shoot_down_by(t, sam, Some(mid), Impact::missile(m.kind, at, closing), ground, out);
}
}
// A missile that lost its target frees it for others.
+3 -3
View File
@@ -247,7 +247,7 @@ mod tests {
sim.sams.push(Sam::new(sam, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, 300.0), DVec3::X, DVec3::new(900.0, 70.0, 0.0));
let at = sim.drones[0].pos;
let o = sim.shoot_down_by(0, sam, None, imp(at), &mut out).unwrap();
let o = sim.shoot_down_by(0, sam, None, imp(at), &Ground::default(), &mut out).unwrap();
(o, sim.drones.len(), sim.rng.state())
}
@@ -276,7 +276,7 @@ mod tests {
sim.sams.push(Sam::new(sam, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, 300.0), DVec3::X, DVec3::new(900.0, 70.0, 0.0));
let at = sim.drones[0].pos;
let o = sim.shoot_down_by(0, sam, Some(5), Impact::missile(MissileKind::M77n6, at, 6.0), &mut out).unwrap();
let o = sim.shoot_down_by(0, sam, Some(5), Impact::missile(MissileKind::M77n6, at, 6.0), &Ground::default(), &mut out).unwrap();
if o != Outcome::AirDetonation {
continue;
}
@@ -311,7 +311,7 @@ mod tests {
sim.add_drone(DroneKind::Shahed, DVec3::new(f64::from(i) * 500.0, 400.0, 300.0), DVec3::X, DVec3::new(9e5, 70.0, 0.0));
let at = sim.drones.last().unwrap().pos;
let idx = sim.drones.len() - 1;
let _ = sim.shoot_down_by(idx, sam, None, Impact::missile(MissileKind::M77n6, at, 6.0), &mut out);
let _ = sim.shoot_down_by(idx, sam, None, Impact::missile(MissileKind::M77n6, at, 6.0), &Ground::default(), &mut out);
}
let pieces = |s: &Sim| s.fx.iter().filter(|(_, f)| matches!(f, Fx::Piece { .. })).count();
let n = pieces(&sim);
+1 -1
View File
@@ -1376,7 +1376,7 @@ mod tests {
sim.sams.push(Sam::new(sam, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, 300.0), DVec3::X, DVec3::new(900.0, 70.0, 0.0));
let at = sim.drones[0].pos;
let live = sim.shoot_down_by(0, sam, None, Impact::missile(MissileKind::M77n6, at, 6.0), &mut Out::default()).unwrap();
let live = sim.shoot_down_by(0, sam, None, Impact::missile(MissileKind::M77n6, at, 6.0), &Ground::default(), &mut Out::default()).unwrap();
let rec = sim.rec.take().unwrap();
let recorded = rec.events.iter().find_map(|e| if let EventKind::Kill { outcome, .. } = e.kind { Some(outcome) } else { None });
assert_eq!(recorded, Some(live), "the live outcome is what was recorded");
+2 -2
View File
@@ -64,7 +64,7 @@ fn network_launcher_must_contribute_to_route_risk() {
fn repair_must_cancel_pending_magazine_explosions() {
let mut sim=Sim::new(1);
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::Patriot,DVec3::new(0.,70.,0.),0.));
sim.blast_damage(DVec3::new(0.,70.,0.),4,&mut Out::default());
sim.blast_damage(DVec3::new(0.,70.,0.),4,&Ground::default(),&mut Out::default());
assert!(!sim.later.is_empty());sim.repair();
sim.tick=3; sim.step_later(&mut Ground::default(),None,&mut Out::default());
assert!(sim.sams[0].alive(),"a fully repaired launcher was destroyed again by its own pre-repair cook-off");
@@ -1479,7 +1479,7 @@ fn graphics_settings_do_not_change_combat_randomness_or_ids() {
sim.fireball(DVec3::new(f64::from(i), 80.0, 0.0), 4.0, 11);
}
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 80.0, 0.0), DVec3::X, DVec3::new(900.0, 80.0, 0.0));
sim.shoot_down(0, 0, None, &mut out);
sim.shoot_down(0, 0, None, &Ground::default(), &mut out);
let id = sim.id();
(sim.drones[0].id, id, sim.aim_error(DroneKind::Shahed))
};
+2 -2
View File
@@ -147,7 +147,7 @@ fn a_blast_from_behind_hurts_the_link_a_blast_from_the_front_the_drive() {
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Patriot, at, 0.0));
let mut out = Out::default();
sim.blast_damage(at + DVec3::new(0.0, 3.0, dz), 2, &mut out);
sim.blast_damage(at + DVec3::new(0.0, 3.0, dz), 2, &Ground::default(), &mut out);
assert!(sim.sams[0].alive());
let c = sim.sams[0].comps;
(1.0 - c.get(Part::Comms), 1.0 - c.get(Part::Mount), 1.0 - c.get(Part::Sensor))
@@ -168,7 +168,7 @@ fn a_close_blast_can_leave_a_firing_but_immobile_system() {
sim.sams.push(Sam::new(id, SamKind::Pantsir, DVec3::new(0.0, 70.0, 0.0), 0.0));
let mut out = Out::default();
sim.tick = seed;
sim.blast_damage(DVec3::new(0.0, 69.0, 6.0 + seed as f64 * 0.06), 3, &mut out);
sim.blast_damage(DVec3::new(0.0, 69.0, 6.0 + seed as f64 * 0.06), 3, &Ground::default(), &mut out);
let s = &sim.sams[0];
if s.alive() && s.part(Part::Tracks) <= 0.0 && s.part(Part::Launcher) > 0.0 && s.part(Part::Gun) > 0.0 {
assert!(!sim.order_move(0, DVec3::new(300.0, 70.0, 0.0)), "failed running gear must refuse a march");
+49
View File
@@ -799,6 +799,55 @@ pub fn section_of(source: &dyn WorldSource, s: Sec) -> Fill {
}
impl WorldCache {
/// Read-only occupancy at the instant of a blast. Some(true) is known cover,
/// Some(false) a fully known open segment, None unresolved geometry. No query,
/// chunk load or later reply can change damage already committed for this blast.
/// This deliberately treats partial collision blocks as occupied cubes.
pub fn blast_cover(&self, a: DVec3, b: DVec3, now: u64) -> Option<bool> {
let d = b - a;
let mut p = [a.x.floor() as i32, a.y.floor() as i32, a.z.floor() as i32];
let av = [a.x, a.y, a.z];
let dv = [d.x, d.y, d.z];
let step = dv.map(|v| if v > 0.0 { 1 } else { -1 });
let mut next = [f64::INFINITY; 3];
let mut delta = [f64::INFINITY; 3];
for i in 0..3 {
if dv[i] != 0.0 {
let edge = f64::from(p[i]) + if dv[i] > 0.0 { 1.0 } else { 0.0 };
next[i] = (edge - av[i]) / dv[i];
delta[i] = (1.0 / dv[i]).abs();
}
}
let mut unknown = false;
for n in 0..256 {
let fill = self.secs.get(&sec_of(p[0], p[1], p[2]))
.filter(|e| !e.stale && now.saturating_sub(e.since) < RECHECK)
.map(|e| &e.fill);
let solid = match fill {
Some(Fill::Empty) => Some(false),
Some(Fill::Full) => Some(true),
Some(Fill::Mixed(bits)) => {
let i = ((p[1] & 15) << 8 | (p[2] & 15) << 4 | (p[0] & 15)) as usize;
Some(bits[i >> 3] >> (i & 7) & 1 != 0)
}
_ => None,
};
match solid {
// Ignore the struck/origin voxel, not the next wall behind it.
Some(true) if n > 0 => return Some(true),
None => unknown = true,
_ => {}
}
let axis = if next[0] < next[1] {
if next[0] < next[2] { 0 } else { 2 }
} else if next[1] < next[2] { 1 } else { 2 };
if next[axis] > 1.0 { return if unknown { None } else { Some(false) }; }
next[axis] += delta[axis];
p[axis] += step[axis];
}
None
}
/// Answers the exact rays of the last tick from a synthetic world, as the game does one tick later.
pub fn serve_rays(&mut self, source: &dyn WorldSource, rays: Vec<(u32, DVec3, DVec3)>) {
let replies: Vec<RayReply> = rays
+20
View File
@@ -71,6 +71,26 @@ Metal-проверки не подтверждают Windows/Linux, HDR всех
## Важные ограничения для следующего агента
### #119: непрямой взрыв и укрытия
`WorldCache::blast_cover` читает текущую occupancy без запросов мира и без загрузки чанков.
Три луча к репрезентативным точкам корпуса дают общий множитель для корпуса и узлов;
для отмеченного объекта считается своя тройка. Известное укрытие оставляет 15% игрового
воздействия по перекрытому лучу, открытый — 100%; края/проёмы дают промежуточное значение.
Все лучи этого вызова читаются до отправки разрушения в движок. Исходный воксель пропускается,
чтобы контакт на поверхности стены не закрывал взрыв сразу во все стороны.
Unknown, stale и слишком старые секции считаются **неразрешёнными**. Ожидание равно нулю:
для них сохраняется прежний неослабленный урон как консервативная игровая политика,
а не утверждение, что там воздух. После ответа мира этот взрыв не пересчитывается.
Нет очереди отложенного урона и повторного применения на world reply. Частичные блоки
на этом этапе считаются занятым кубом; тонкая collision shape для blast отдельно не запрашивается.
Прямой вооружённый контакт не ослабляется этим множителем.
Проверки `sim::damage::cover_tests`: стена/проём для корпуса, узлов и объекта;
Unknown и поздний ответ без повторного урона/запросов; прямое попадание; устаревшая геометрия;
пропуск исходного блока. Все четыре прошли. Объединённый полный прогон будет указан ниже.
- Не закрывать issue даже после исправления; сообщать проверенный объём и остаток в комментарии.
- Не переписывать gitlink на плавающую ветку. Изменения движка сначала публикуются отдельно.
- Не путать наблюдённый трек с настоящим положением цели и `own_tick` с relay.