diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index b3ae2d2..76520b5 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -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 } diff --git a/airdef-mod/src/monitor.rs b/airdef-mod/src/monitor.rs index b06c704..64a07c3 100644 --- a/airdef-mod/src/monitor.rs +++ b/airdef-mod/src/monitor.rs @@ -273,7 +273,7 @@ pub fn draw(sim: &Sim, index: usize) -> Vec { 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 } diff --git a/airdef-mod/src/sim/doctrine.rs b/airdef-mod/src/sim/doctrine.rs index 508f016..fb8f5da 100644 --- a/airdef-mod/src/sim/doctrine.rs +++ b/airdef-mod/src/sim/doctrine.rs @@ -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) diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index b2d005a..7acc781 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -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; diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index 3dc9c48..5d47e75 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -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"); } diff --git a/airdef-mod/src/sim/sensor_tests.rs b/airdef-mod/src/sim/sensor_tests.rs index 1b41ead..55854b1 100644 --- a/airdef-mod/src/sim/sensor_tests.rs +++ b/airdef-mod/src/sim/sensor_tests.rs @@ -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::>()); + assert!(sim.why(0).iter().any(|c| matches!(c.reason, why::CheckReason::PartEffect(why::PartEffect::TracksFailed))), "{:?}", sim.why(0)); found = true; break; } diff --git a/airdef-mod/src/sim/why.rs b/airdef-mod/src/sim/why.rs index 909bc95..c34e9db 100644 --- a/airdef-mod/src/sim/why.rs +++ b/airdef-mod/src/sim/why.rs @@ -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) -> 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::>()); - 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::>()); + 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::>()); + assert!(w.iter().any(|c| c.ok && c.legacy_text().starts_with("последний шанс")), "{:?}", w.iter().map(|c| c.legacy_text()).collect::>()); } #[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::>()); - 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::>()); + 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::>()); + assert!(w.iter().any(|c| c.legacy_text().starts_with("дальний пуск по РЛС")), "{:?}", w.iter().map(|c| c.legacy_text()).collect::>()); // 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"); diff --git a/airdef-mod/src/why_view.rs b/airdef-mod/src/why_view.rs new file mode 100644 index 0000000..8950a96 --- /dev/null +++ b/airdef-mod/src/why_view.rs @@ -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::>().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 = 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); } + } +}