From c96676f3e5836f7467101d8d7b6bf0caba3ea821 Mon Sep 17 00:00:00 2001 From: chexit Date: Mon, 2 Nov 2026 23:55:38 +0300 Subject: [PATCH] feat: attenuate indirect blast damage behind known cover 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 --- airdef-mod/src/sim/damage.rs | 116 ++++++++++++++++++++++++++++- airdef-mod/src/sim/mod.rs | 14 ++-- airdef-mod/src/sim/outcome.rs | 6 +- airdef-mod/src/sim/record.rs | 2 +- airdef-mod/src/sim/regress.rs | 4 +- airdef-mod/src/sim/sensor_tests.rs | 4 +- airdef-mod/src/sim/world.rs | 49 ++++++++++++ docs/CODEX-HANDOFF.md | 20 +++++ 8 files changed, 196 insertions(+), 19 deletions(-) diff --git a/airdef-mod/src/sim/damage.rs b/airdef-mod/src/sim/damage.rs index 277b0a1..5f4bc81 100644 --- a/airdef-mod/src/sim/damage.rs +++ b/airdef-mod/src/sim/damage.rs @@ -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::() / 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 { + 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)); + } +} diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index cfc344f..a7c8a9a 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -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, out: &mut Out) -> Option { + fn shoot_down(&mut self, idx: usize, by: u32, missile: Option, ground: &Ground, out: &mut Out) -> Option { let kind = self.sams.iter().find(|s| s.id == by).map_or(SamKind::Gepard, |s| s.kind); let shell = kind.spec().gun.as_ref().map_or(5.0, |g| g.shell); let at = self.drones[idx].pos; - self.shoot_down_by(idx, by, missile, Impact::gun(kind, at, shell), out) + 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, imp: Impact, out: &mut Out) -> Option { + fn shoot_down_by(&mut self, idx: usize, by: u32, missile: Option, imp: Impact, ground: &Ground, out: &mut Out) -> Option { 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. diff --git a/airdef-mod/src/sim/outcome.rs b/airdef-mod/src/sim/outcome.rs index 9ff36cf..7559658 100644 --- a/airdef-mod/src/sim/outcome.rs +++ b/airdef-mod/src/sim/outcome.rs @@ -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); diff --git a/airdef-mod/src/sim/record.rs b/airdef-mod/src/sim/record.rs index a938533..1face30 100644 --- a/airdef-mod/src/sim/record.rs +++ b/airdef-mod/src/sim/record.rs @@ -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"); diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index 709331e..276be28 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -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)) }; diff --git a/airdef-mod/src/sim/sensor_tests.rs b/airdef-mod/src/sim/sensor_tests.rs index 47f8496..1b41ead 100644 --- a/airdef-mod/src/sim/sensor_tests.rs +++ b/airdef-mod/src/sim/sensor_tests.rs @@ -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"); diff --git a/airdef-mod/src/sim/world.rs b/airdef-mod/src/sim/world.rs index 8589d1e..8a5d039 100644 --- a/airdef-mod/src/sim/world.rs +++ b/airdef-mod/src/sim/world.rs @@ -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 { + 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 = rays diff --git a/docs/CODEX-HANDOFF.md b/docs/CODEX-HANDOFF.md index 41e8bca..32469fc 100644 --- a/docs/CODEX-HANDOFF.md +++ b/docs/CODEX-HANDOFF.md @@ -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.