Represent damage diagnostics as typed reasons

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
2026-11-04 15:57:46 +03:00
parent eea1b3692e
commit 0c870c10d5
8 changed files with 127 additions and 31 deletions
+2 -1
View File
@@ -31,6 +31,7 @@ pub use airdef_core::sim;
mod scenario;
mod wave;
mod world_view;
mod why_view;
use sim::{Class, DroneKind, Ground, Out, Sam, SamKind, Sim};
const ITEMS: [&str; sim::catalog::INVENTORY_COUNT] = sim::catalog::INVENTORY;
@@ -2371,7 +2372,7 @@ impl Mod for AirDef {
return Err("Нет такой техники".into());
}
for c in self.sim.why(i) {
say(&format!("{} {}", if c.ok { "+" } else { "-" }, c.text));
say(&format!("{} {}", if c.ok { "+" } else { "-" }, why_view::reason(&c.reason)));
}
None
}
+1 -1
View File
@@ -273,7 +273,7 @@ pub fn draw(sim: &Sim, index: usize) -> Vec<Shape> {
s.push(fill(4.0, 156.0, 300.0, 4.0 + 10.0 * checks.len() as f32, 0xA0080C0A));
for (k, c) in checks.iter().enumerate() {
let color = if c.ok { 0xFF80FF90 } else { 0xFFFF9060 };
s.push(text(fit(format!("{} {}", if c.ok { "+" } else { "-" }, c.text), 294.0), 7.0, 158.0 + 10.0 * k as f32, color));
s.push(text(fit(format!("{} {}", if c.ok { "+" } else { "-" }, crate::why_view::reason(&c.reason)), 294.0), 7.0, 158.0 + 10.0 * k as f32, color));
}
s
}
+1 -1
View File
@@ -219,7 +219,7 @@ mod tests {
}
// The explanation while the target is in the sky: all the lines it gave then.
if sim.tick % 10 == 0 {
lines.extend(sim.why(0).into_iter().map(|c| c.text));
lines.extend(sim.why(0).into_iter().map(|c| c.legacy_text().to_owned()));
}
}
(sim.sams[0].fired, first, lines)
+1 -1
View File
@@ -30,7 +30,7 @@ mod route;
mod voices;
pub mod outcome;
mod units;
mod why;
pub mod why;
pub mod world;
#[cfg(test)]
mod balance;
+2 -2
View File
@@ -867,7 +867,7 @@ fn a_system_does_not_fire_at_a_target_masked_by_a_hill() {
seen |= sim.sams[0].seen.len() == 1;
masked += sim.sams[0].terrain.values().filter(|(_, b)| *b == terrain::MASKED).count() as u32;
// `why` names the reason while the verdict stands.
told |= sim.why(0).iter().any(|c| !c.ok && c.text.contains("за рельефом"));
told |= sim.why(0).iter().any(|c| !c.ok && c.legacy_text().contains("за рельефом"));
}
(sim, seen, masked, told)
};
@@ -895,7 +895,7 @@ fn a_long_range_system_lofts_over_a_ridge_or_waits() {
let sim = behind_ridge(&tall, 150, SamKind::IrisT, 230.0, ticks);
assert_eq!(sim.sams[0].fired, 0, "no missile can climb a 400-block mountain at this range");
assert_eq!(sim.tally.blocked + sim.tally.scrubbed, 0);
told |= sim.why(0).iter().any(|c| !c.ok && c.text.contains("рельеф"));
told |= sim.why(0).iter().any(|c| !c.ok && c.legacy_text().contains("рельеф"));
}
assert!(told, "`why` never named the land as the reason");
}
+1 -1
View File
@@ -173,7 +173,7 @@ fn a_close_blast_can_leave_a_firing_but_immobile_system() {
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");
assert!(why::missile_blocks(&sim.sams[0]) & why::LAUNCHER_DOWN == 0);
assert!(sim.why(0).iter().any(|c| c.text.contains("ходовая отказала")), "{:?}", sim.why(0).iter().map(|c| &c.text).collect::<Vec<_>>());
assert!(sim.why(0).iter().any(|c| matches!(c.reason, why::CheckReason::PartEffect(why::PartEffect::TracksFailed))), "{:?}", sim.why(0));
found = true;
break;
}
+53 -24
View File
@@ -250,15 +250,42 @@ pub(super) fn gun_blocks(s: &Sam, aim_id: u32, gt: u32, aim_error: f64, dist: f6
// ---------------------------------------------------------------- the diagnostic
/// One condition and whether it holds.
/// A diagnostic condition and its structured or legacy reason.
#[derive(Debug)]
pub struct Check {
pub ok: bool,
pub text: String,
pub reason: CheckReason,
}
#[derive(Debug)]
pub enum CheckReason {
/// Diagnostics not yet migrated to typed reasons.
Text(String),
Parts(Vec<(Part, f64)>),
PartEffect(PartEffect),
}
#[derive(Debug, PartialEq)]
pub enum PartEffect {
SensorFailed, SensorDamaged { fit: f64 },
MountFailed { yaw_degrees: f64 }, MountDamaged { fit: f64 },
LauncherFailed, LauncherDamaged { cells: u32, capacity: u32, reload_seconds: f64 },
GunFailed, GunDamaged { fit: f64 },
TracksFailed, TracksDamaged { speed: f64 },
CommsFailed, CommsDamaged { range: f64 },
}
impl Check {
#[cfg(test)]
pub(super) fn legacy_text(&self) -> &str {
match &self.reason { CheckReason::Text(text) => text, _ => "" }
}
}
fn check(ok: bool, text: impl Into<String>) -> Check {
Check { ok, text: text.into() }
Check { ok, reason: CheckReason::Text(text.into()) }
}
fn part_check(reason: CheckReason) -> Check { Check { ok: false, reason } }
impl Sim {
/// Why system `index` fires or doesn't, at its current target or at the most threatening
@@ -293,7 +320,9 @@ impl Sim {
v.push(check(alive > 0, format!("в небе {alive}, на картине {seen} (своя РЛС {own}, остальные по сети)")));
// The parts: what is worn or failed and what it costs.
if !sam.comps.whole() {
v.push(check(false, format!("узлы: {}", sam.parts_text())));
let parts = Part::ALL.into_iter().filter(|p| p.on(sam.kind) && sam.comps.get(*p) < 1.0)
.map(|p| (p, sam.comps.get(p))).collect();
v.push(part_check(CheckReason::Parts(parts)));
}
for p in Part::ALL {
if !p.on(sam.kind) || sam.part_state(p) == PartState::Ok {
@@ -301,20 +330,20 @@ impl Sim {
}
let failed = sam.part_state(p) == PartState::Failed;
let effect = match (p, failed) {
(Part::Sensor, true) => "РЛС отказала: своих измерений нет, стволы не стреляют, ракеты — по данным сети".to_string(),
(Part::Sensor, false) => format!("РЛС повреждена: дальность {:.0}%, обзор вдвое реже при худшем", sam.radar_fit() * 100.0),
(Part::Mount, true) => format!("привод наведения заклинил: стоит на {:.0}°, не доворачивается", sam.yaw.to_degrees().rem_euclid(360.0)),
(Part::Mount, false) => format!("привод наведения повреждён: поворот {:.0}% от нормы", sam.drive_fit() * 100.0),
(Part::Launcher, true) => "пусковая отказала: пуски невозможны".to_string(),
(Part::Launcher, false) => format!("пусковая повреждена: {} ячеек из {}, перезарядка {:.0} с", sam.cells(), spec.missile.as_ref().map_or(0, |m| m.magazine), sam.reload_time() as f64 / 20.0),
(Part::Gun, true) => "орудие заклинило: не стреляет".to_string(),
(Part::Gun, false) => format!("орудие повреждено: меткость {:.0}%, заклинивает", sam.gun_fit() * 100.0),
(Part::Tracks, true) => "ходовая отказала: на марш не выйдет".to_string(),
(Part::Tracks, false) => format!("ходовая повреждена: скорость марша {:.0}%", sam.drive_speed() * 100.0),
(Part::Comms, true) => "связь отказала: сети нет, цели только свои".to_string(),
(Part::Comms, false) => format!("связь повреждена: линия {:.0} м", sam.link_range()),
(Part::Sensor, true) => PartEffect::SensorFailed,
(Part::Sensor, false) => PartEffect::SensorDamaged { fit: sam.radar_fit() },
(Part::Mount, true) => PartEffect::MountFailed { yaw_degrees: sam.yaw.to_degrees().rem_euclid(360.0) },
(Part::Mount, false) => PartEffect::MountDamaged { fit: sam.drive_fit() },
(Part::Launcher, true) => PartEffect::LauncherFailed,
(Part::Launcher, false) => PartEffect::LauncherDamaged { cells: sam.cells(), capacity: spec.missile.as_ref().map_or(0, |m| m.magazine), reload_seconds: sam.reload_time() as f64 / 20.0 },
(Part::Gun, true) => PartEffect::GunFailed,
(Part::Gun, false) => PartEffect::GunDamaged { fit: sam.gun_fit() },
(Part::Tracks, true) => PartEffect::TracksFailed,
(Part::Tracks, false) => PartEffect::TracksDamaged { speed: sam.drive_speed() },
(Part::Comms, true) => PartEffect::CommsFailed,
(Part::Comms, false) => PartEffect::CommsDamaged { range: sam.link_range() },
};
v.push(check(false, effect));
v.push(part_check(CheckReason::PartEffect(effect)));
}
if sam.jam > 0 {
v.push(check(false, format!("орудие заклинило, ещё {:.1} с", sam.jam as f64 / 20.0)));
@@ -551,10 +580,10 @@ mod tests {
sim.step(&mut ground, None, &mut out);
}
let w = sim.why(0);
assert!(w.last().is_some_and(|c| !c.ok), "{:?}", w.iter().map(|c| &c.text).collect::<Vec<_>>());
assert!(w.iter().any(|c| c.text.contains("нет целей классов")));
assert!(w.last().is_some_and(|c| !c.ok), "{:?}", w.iter().map(|c| c.legacy_text()).collect::<Vec<_>>());
assert!(w.iter().any(|c| c.legacy_text().contains("нет целей классов")));
sim.sams[0].roles = 0xFF;
assert!(sim.why(0).iter().any(|c| c.text.starts_with("роль")));
assert!(sim.why(0).iter().any(|c| c.legacy_text().starts_with("роль")));
}
use super::sites::testland::{Land, serve};
@@ -599,7 +628,7 @@ mod tests {
sim.step(&mut ground, None, &mut out);
}
let w = sim.why(0);
assert!(w.iter().any(|c| c.ok && c.text.starts_with("последний шанс")), "{:?}", w.iter().map(|c| &c.text).collect::<Vec<_>>());
assert!(w.iter().any(|c| c.ok && c.legacy_text().starts_with("последний шанс")), "{:?}", w.iter().map(|c| c.legacy_text()).collect::<Vec<_>>());
}
#[test]
@@ -610,8 +639,8 @@ mod tests {
assert!(until_fired(&mut sim, &mut ground, &land, &mut out, 150).is_none(), "fired at a Shahed far from its aim");
let w = sim.why(0);
// Still named as the role block it was: no target of its classes.
assert!(w.last().is_some_and(|c| !c.ok) && w.iter().any(|c| c.text.contains("нет целей классов")), "{:?}", w.iter().map(|c| &c.text).collect::<Vec<_>>());
assert!(!w.iter().any(|c| c.text.starts_with("последний шанс")));
assert!(w.last().is_some_and(|c| !c.ok) && w.iter().any(|c| c.legacy_text().contains("нет целей классов")), "{:?}", w.iter().map(|c| c.legacy_text()).collect::<Vec<_>>());
assert!(!w.iter().any(|c| c.legacy_text().starts_with("последний шанс")));
}
#[test]
@@ -678,7 +707,7 @@ mod tests {
let (mut sim, mut ground, land, mut out) = far_setup(true, max * 1.4);
assert!(fire_far(&mut sim, &mut ground, &land, &mut out, 80) >= 1, "no long shot at 1.4 x max with a radar");
let w = sim.why(0);
assert!(w.iter().any(|c| c.text.starts_with("дальний пуск по РЛС")), "{:?}", w.iter().map(|c| &c.text).collect::<Vec<_>>());
assert!(w.iter().any(|c| c.legacy_text().starts_with("дальний пуск по РЛС")), "{:?}", w.iter().map(|c| c.legacy_text()).collect::<Vec<_>>());
// No radar, no shot (the system's own radar may well see it: that is not a cue).
let (mut sim, mut ground, land, mut out) = far_setup(false, max * 1.4);
assert_eq!(fire_far(&mut sim, &mut ground, &land, &mut out, 80), 0, "a long shot with no radar");
+66
View File
@@ -0,0 +1,66 @@
//! Text for diagnostics produced by the simulation.
use crate::sim::why::{CheckReason, PartEffect};
pub fn reason(reason: &CheckReason) -> String {
match reason {
CheckReason::Text(text) => text.clone(),
CheckReason::Parts(parts) => format!("узлы: {}", parts.iter().map(|(part, value)| {
if *value <= 0.0 { format!("{} отказ", part.short()) }
else { format!("{} {:.0}%", part.short(), value * 100.0) }
}).collect::<Vec<_>>().join(", ")),
CheckReason::PartEffect(effect) => match effect {
PartEffect::SensorFailed => "РЛС отказала: своих измерений нет, стволы не стреляют, ракеты — по данным сети".into(),
PartEffect::SensorDamaged { fit } => format!("РЛС повреждена: дальность {:.0}%, обзор вдвое реже при худшем", fit * 100.0),
PartEffect::MountFailed { yaw_degrees } => format!("привод наведения заклинил: стоит на {yaw_degrees:.0}°, не доворачивается"),
PartEffect::MountDamaged { fit } => format!("привод наведения повреждён: поворот {:.0}% от нормы", fit * 100.0),
PartEffect::LauncherFailed => "пусковая отказала: пуски невозможны".into(),
PartEffect::LauncherDamaged { cells, capacity, reload_seconds } => format!("пусковая повреждена: {cells} ячеек из {capacity}, перезарядка {reload_seconds:.0} с"),
PartEffect::GunFailed => "орудие заклинило: не стреляет".into(),
PartEffect::GunDamaged { fit } => format!("орудие повреждено: меткость {:.0}%, заклинивает", fit * 100.0),
PartEffect::TracksFailed => "ходовая отказала: на марш не выйдет".into(),
PartEffect::TracksDamaged { speed } => format!("ходовая повреждена: скорость марша {:.0}%", speed * 100.0),
PartEffect::CommsFailed => "связь отказала: сети нет, цели только свои".into(),
PartEffect::CommsDamaged { range } => format!("связь повреждена: линия {range:.0} м"),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sim::Part;
#[test]
fn damaged_simulation_produces_player_diagnostics_in_order() {
use crate::sim::{Sam, SamKind, Sim};
let mut sim = Sim::new(1);
let mut sam = Sam::new(1, SamKind::Pantsir, glam::DVec3::ZERO, 0.0);
sam.comps.0[Part::Sensor as usize] = 0.4;
sam.comps.0[Part::Tracks as usize] = 0.0;
sim.sams.push(sam);
let lines: Vec<String> = sim.why(0).iter().map(|c| reason(&c.reason)).collect();
let summary = lines.iter().position(|s| s == "узлы: РЛС 40%, Ход отказ").unwrap();
let sensor = lines.iter().position(|s| s.starts_with("РЛС повреждена: дальность ")).unwrap();
let tracks = lines.iter().position(|s| s == "ходовая отказала: на марш не выйдет").unwrap();
assert!(summary < sensor && sensor < tracks, "{lines:?}");
}
#[test]
fn renders_damage_diagnostics() {
assert_eq!(reason(&CheckReason::Parts(vec![(Part::Sensor, 0.4), (Part::Tracks, 0.0)])), "узлы: РЛС 40%, Ход отказ");
let cases = [
(PartEffect::SensorFailed, "РЛС отказала: своих измерений нет, стволы не стреляют, ракеты — по данным сети"),
(PartEffect::SensorDamaged { fit: 0.4 }, "РЛС повреждена: дальность 40%, обзор вдвое реже при худшем"),
(PartEffect::MountFailed { yaw_degrees: 42.0 }, "привод наведения заклинил: стоит на 42°, не доворачивается"),
(PartEffect::MountDamaged { fit: 0.3 }, "привод наведения повреждён: поворот 30% от нормы"),
(PartEffect::LauncherFailed, "пусковая отказала: пуски невозможны"),
(PartEffect::LauncherDamaged { cells: 2, capacity: 4, reload_seconds: 12.0 }, "пусковая повреждена: 2 ячеек из 4, перезарядка 12 с"),
(PartEffect::GunFailed, "орудие заклинило: не стреляет"),
(PartEffect::GunDamaged { fit: 0.5 }, "орудие повреждено: меткость 50%, заклинивает"),
(PartEffect::TracksFailed, "ходовая отказала: на марш не выйдет"),
(PartEffect::TracksDamaged { speed: 0.2 }, "ходовая повреждена: скорость марша 20%"),
(PartEffect::CommsFailed, "связь отказала: сети нет, цели только свои"),
(PartEffect::CommsDamaged { range: 123.0 }, "связь повреждена: линия 123 м"),
];
for (effect, expected) in cases { assert_eq!(reason(&CheckReason::PartEffect(effect)), expected); }
}
}