5f07027faa
Preserve the adapter facade and move adapter-only checks out of domain tests. Late tracks do not reveal an unseen launch origin or its timestamp. Co-Authored-By: Codex GPT-6.1 Terra <noreply@openai.com>
718 lines
38 KiB
Rust
718 lines
38 KiB
Rust
//! The conditions that decide whether a system fires, as predicates: `step_sams` decides with
|
||
//! them, and `Sim::why` lists them for a chosen system (`/airdef why`, the monitor), so the
|
||
//! explanation can't drift from the behaviour. Each predicate returns the set of reasons it
|
||
//! fails (bits; 0 is a pass), so the diagnostic names the reasons and the simulation only asks
|
||
//! whether there are none.
|
||
use super::*;
|
||
|
||
// ---------------------------------------------------------------- choosing a target
|
||
|
||
/// Why a drone is not a candidate for a system at all.
|
||
pub(super) const DEAD: u8 = 1;
|
||
pub(super) const ROLE: u8 = 2;
|
||
pub(super) const UNSEEN: u8 = 4;
|
||
/// Its track is older than a launcher fires on.
|
||
pub(super) const STALE: u8 = 8;
|
||
|
||
pub(super) fn target_blocks(roles: u8, track: Option<&Track>, now: u64, d: &Drone) -> u8 {
|
||
(if d.alive() { 0 } else { DEAD })
|
||
| (if roles & d.kind.spec().class.bit() != 0 { 0 } else { ROLE })
|
||
| (if track.is_some() { 0 } else { UNSEEN })
|
||
| (if track.is_some_and(|t| t.age(now) > sight::MISSILE_AGE) { STALE } else { 0 })
|
||
}
|
||
|
||
/// Alive, of a class the system engages, and on its picture (own radar or the network's), fresh enough.
|
||
pub(super) fn allowed(roles: u8, track: &Track, now: u64, d: &Drone) -> bool {
|
||
target_blocks(roles, Some(track), now, d) == 0
|
||
}
|
||
|
||
pub(super) const TOO_CLOSE: u8 = 1;
|
||
pub(super) const TOO_FAR: u8 = 2;
|
||
|
||
/// Missile range: `min` to `max * k` (a target already held may stray to 1.1 × max).
|
||
pub(super) fn reach_blocks(min: f64, max: f64, k: f64, pos: DVec3, at: DVec3) -> u8 {
|
||
let dist = at.distance(pos);
|
||
(if dist >= min { 0 } else { TOO_CLOSE }) | (if dist < max * k { 0 } else { TOO_FAR })
|
||
}
|
||
|
||
pub(super) fn in_reach(min: f64, max: f64, k: f64, pos: DVec3, at: DVec3) -> bool {
|
||
reach_blocks(min, max, k, pos, at) == 0
|
||
}
|
||
|
||
/// Interceptors a target has or will have: in flight, and other systems' launchers on it.
|
||
pub(super) fn claimed(d: &Drone, missile_on: &HashMap<u32, u32>, had_target: Option<u32>) -> u32 {
|
||
let on = missile_on.get(&d.id).copied().unwrap_or(0) - u32::from(had_target == Some(d.id));
|
||
d.engaged + on
|
||
}
|
||
|
||
/// Ticks until the target reaches its aim at its present speed.
|
||
pub(super) fn arrival(d: &Drone) -> f64 {
|
||
d.pos.distance(d.target) / d.vel.length().max(0.1)
|
||
}
|
||
|
||
/// Shoot-look-shoot that grows with urgency: ballistic missiles get a pair, anything else one; a target
|
||
/// this close to its aim (ticks) has one more interceptor allowed on it at once, closer still another, and in the last seconds a fourth.
|
||
pub(super) const URGENT: f64 = 240.0;
|
||
pub(super) const DESPERATE: f64 = 110.0;
|
||
pub(super) const FINAL: f64 = 60.0;
|
||
|
||
pub(super) fn needed(d: &Drone) -> u32 {
|
||
let base = if d.kind.spec().class == Class::Ballistic { 2 } else { 1 };
|
||
let t = arrival(d);
|
||
base + u32::from(t < URGENT) + u32::from(t < DESPERATE) + u32::from(t < FINAL)
|
||
}
|
||
|
||
/// Ticks a launcher may hold a target without launching before it lets go, and how long it then keeps away from it.
|
||
pub(super) const HOLD_MAX: u32 = 24;
|
||
pub(super) const SHY_TICKS: u64 = 120;
|
||
|
||
// ---------------------------------------------------------------- the grounds to launch on
|
||
|
||
/// Last chance (`/airdef lastchance`): a target of a class the system does not engage that is about to
|
||
/// hit, with nobody's interceptor on it, is shot at anyway; a system told to hold fire (no roles) keeps to it.
|
||
pub(super) const LAST_CHANCE_TICKS: f64 = 200.0;
|
||
|
||
pub(super) fn last_chance(on: bool, roles: u8, d: &Drone, claimed: u32) -> bool {
|
||
on && roles != 0 && roles & d.kind.spec().class.bit() == 0 && claimed == 0 && arrival(d) < LAST_CHANCE_TICKS
|
||
}
|
||
|
||
/// The big radar's cue (heavy systems only): they may fire out to `FAR_K` × max at a target the radar holds.
|
||
pub(super) const FAR_K: f64 = 1.6;
|
||
/// Ticks between one system's long shots; the share of the magazine it keeps for them; the radar track's age.
|
||
pub(super) const FAR_COOLDOWN: u32 = 300;
|
||
pub(super) const FAR_RADAR_AGE: u64 = 150;
|
||
|
||
impl SamKind {
|
||
/// Whether the kind takes long shots on a big radar's data.
|
||
pub(super) fn far_shooter(self) -> bool {
|
||
matches!(self, SamKind::S400 | SamKind::S500 | SamKind::Patriot)
|
||
}
|
||
}
|
||
|
||
/// A live big radar in data-link reach of `sam` that measured `id` recently.
|
||
pub(super) fn radar_cue(sam: &Sam, radars: &[&Sam], id: u32, now: u64) -> bool {
|
||
radars.iter().any(|r| {
|
||
r.alive()
|
||
&& !r.moving()
|
||
&& r.radar_up()
|
||
&& r.pos.distance(sam.pos) <= r.link_range().min(sam.link_range())
|
||
&& r.track(id).is_some_and(|t| t.own_tick > 0 && t.age(now) <= FAR_RADAR_AGE)
|
||
})
|
||
}
|
||
|
||
/// The ground beyond `max`: a long shot is allowed (the system, its launcher and magazine, the flag, a cue).
|
||
pub(super) fn far_blocks(on: bool, sam: &Sam, load: &units::MissileLoad, cued: bool, in_reach_of_any: bool) -> u8 {
|
||
(if on && sam.kind.far_shooter() { 0 } else { FAR_OFF })
|
||
| (if cued { 0 } else { FAR_NO_CUE })
|
||
| (if sam.far_cooldown == 0 { 0 } else { FAR_WAIT })
|
||
| (if sam.missiles * 2 >= load.magazine { 0 } else { FAR_LOW })
|
||
| (if in_reach_of_any { FAR_BUSY } else { 0 })
|
||
}
|
||
pub(super) const FAR_OFF: u8 = 1;
|
||
pub(super) const FAR_NO_CUE: u8 = 2;
|
||
pub(super) const FAR_WAIT: u8 = 4;
|
||
pub(super) const FAR_LOW: u8 = 8;
|
||
pub(super) const FAR_BUSY: u8 = 16;
|
||
|
||
/// Why a system may launch at a target: its role, the last chance, or a long shot on the radar's cue.
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||
pub(super) enum Basis {
|
||
Role,
|
||
LastChance,
|
||
FarShot,
|
||
}
|
||
|
||
/// What is switched on beyond the roles (the sim's settings).
|
||
#[derive(Clone, Copy)]
|
||
pub(super) struct Opts {
|
||
pub last_chance: bool,
|
||
pub far_shots: bool,
|
||
}
|
||
|
||
/// Whether, and on what basis, `sam` may launch at `d` on `track`: `k` stretches the reach (a held target
|
||
/// may stray); `claimed` is the interceptors others have on it; `cued` that a big radar holds it; and
|
||
/// `role_in_reach` that the system has a target of its roles in range anyway (no long shots then).
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(super) fn basis(sam: &Sam, load: &units::MissileLoad, track: &Track, d: &Drone, now: u64, claimed: u32, k: f64, opts: Opts, cued: bool, role_in_reach: bool) -> Option<Basis> {
|
||
let blocks = target_blocks(sam.roles, Some(track), now, d);
|
||
if blocks & !ROLE != 0 {
|
||
return None;
|
||
}
|
||
let est = track.est(now);
|
||
let reach = reach_blocks(load.min, load.max, k, sam.pos, est);
|
||
if blocks & ROLE == 0 {
|
||
// The doctrine (sector, protected objects, reserve) rules over the role's shots and the long ones; the last chance is past it.
|
||
if sam.doctrine.area_blocks(sam.pos, track, now) | sam.doctrine.reserve_blocks(sam.missiles, d) != 0 {
|
||
return None;
|
||
}
|
||
if reach == 0 {
|
||
return Some(Basis::Role);
|
||
}
|
||
let far = reach == TOO_FAR && reach_blocks(load.min, load.max, FAR_K, sam.pos, est) == 0;
|
||
return (far && far_blocks(opts.far_shots, sam, load, cued, role_in_reach) == 0).then_some(Basis::FarShot);
|
||
}
|
||
(reach == 0 && last_chance(opts.last_chance, sam.roles, d, claimed)).then_some(Basis::LastChance)
|
||
}
|
||
|
||
/// The kill chance of an interceptor that met its target `dist` from the launcher whose reach is `max`:
|
||
/// whole up to `max`, falling linearly to half at `FAR_K` × max.
|
||
pub(super) fn reach_fit(max: f64, dist: f64) -> f64 {
|
||
1.0 - 0.5 * ((dist - max) / (max * (FAR_K - 1.0))).clamp(0.0, 1.0)
|
||
}
|
||
|
||
/// A gun's candidates: measured by the system's own radar a moment ago and within the gun's tracking range
|
||
/// (by the track's forecast, not the target's true place).
|
||
pub(super) fn gun_candidate(roles: u8, doc: &doctrine::Doctrine, track: &Track, now: u64, gun_track: f64, pos: DVec3, d: &Drone) -> bool {
|
||
allowed(roles, track, now, d) && doc.area_blocks(pos, track, now) == 0 && track.own_within(now, sight::GUN_PICK_AGE) && track.est(now).distance(pos) < gun_track
|
||
}
|
||
|
||
/// How many systems have each drone as their missile target and as their gun target.
|
||
pub(super) fn engagement_counts(sams: &[Sam]) -> (HashMap<u32, u32>, HashMap<u32, u32>) {
|
||
let mut missile_on = HashMap::new();
|
||
let mut guns_on = HashMap::new();
|
||
for s in sams {
|
||
retarget(&mut missile_on, None, s.target);
|
||
retarget(&mut guns_on, None, s.gun_target);
|
||
}
|
||
(missile_on, guns_on)
|
||
}
|
||
|
||
// ---------------------------------------------------------------- aiming
|
||
|
||
/// Where the lead point is and the yaw and pitch that point the mount at it.
|
||
pub(super) fn aim_wants(mount: DVec3, at: DVec3, vel: DVec3, lead_speed: f64) -> (DVec3, f64, f64) {
|
||
let lead = at + vel * (at.distance(mount) / lead_speed);
|
||
let to = lead - mount;
|
||
let want_yaw = heading_of(to);
|
||
let want_pitch = (to.y / to.length().max(0.01)).asin().clamp(-0.1, 1.45);
|
||
(lead, want_yaw, want_pitch)
|
||
}
|
||
|
||
/// How far the mount still is from the aim, over the axes this family aims.
|
||
pub(super) fn aim_error(family: Family, yaw: f64, pitch: f64, want_yaw: f64, want_pitch: f64) -> f64 {
|
||
let yaw_error = angle_diff(yaw, want_yaw).abs();
|
||
match family {
|
||
Family::Trainable | Family::Vertical => yaw_error,
|
||
_ => yaw_error + (want_pitch - pitch).abs(),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- firing
|
||
|
||
pub(super) const COOLDOWN: u8 = 1;
|
||
pub(super) const RELOADING: u8 = 2;
|
||
pub(super) const NO_ROUNDS: u8 = 4;
|
||
/// The launcher has failed.
|
||
pub(super) const LAUNCHER_DOWN: u8 = 8;
|
||
|
||
/// What keeps a loaded launcher from firing now (aim aside).
|
||
pub(super) fn missile_blocks(s: &Sam) -> u8 {
|
||
(if s.part(Part::Launcher) > 0.0 { 0 } else { LAUNCHER_DOWN })
|
||
| (if s.cooldown == 0 { 0 } else { COOLDOWN })
|
||
| (if s.reloading == 0 { 0 } else { RELOADING })
|
||
| (if s.missiles > 0 { 0 } else { NO_ROUNDS })
|
||
}
|
||
|
||
/// The launcher points near enough: a trainable one only by its training, a turret by its
|
||
/// whole aim at this target, the vertical ones need nothing.
|
||
pub(super) fn missile_aligned(family: Family, yaw: f64, mount: DVec3, t: Option<DVec3>, aim_id: u32, mt: u32, aim_error: f64) -> bool {
|
||
match family {
|
||
Family::Trainable => t.is_some_and(|t| angle_diff(yaw, heading_of(t - mount)).abs() < 0.15),
|
||
Family::Turret => aim_id == mt && aim_error < 0.15,
|
||
_ => true,
|
||
}
|
||
}
|
||
|
||
pub(super) const NOT_AIMED: u8 = 8;
|
||
pub(super) const AIM_ERROR: u8 = 16;
|
||
pub(super) const OUT_OF_RANGE: u8 = 32;
|
||
/// The gun is jammed (for a while, or failed).
|
||
pub(super) const JAMMED: u8 = 64;
|
||
/// Its radar has no fresh measurement of the target.
|
||
pub(super) const NO_LOCK: u8 = 128;
|
||
|
||
/// What keeps a gun from firing now: the turret on another target, not yet laid, out of range,
|
||
/// and the gun's own cooldown, reload and shells (`COOLDOWN`, `RELOADING`, `NO_ROUNDS` bits).
|
||
pub(super) fn gun_blocks(s: &Sam, aim_id: u32, gt: u32, aim_error: f64, dist: f64, range: f64, now: u64) -> u8 {
|
||
(if aim_id == gt { 0 } else { NOT_AIMED })
|
||
| (if s.gun_up() { 0 } else { JAMMED })
|
||
| (if s.track(gt).is_some_and(|t| t.own_within(now, sight::GUN_AGE)) { 0 } else { NO_LOCK })
|
||
| (if aim_error < 0.1 { 0 } else { AIM_ERROR })
|
||
| (if dist < range { 0 } else { OUT_OF_RANGE })
|
||
| (if s.gun_cooldown == 0 { 0 } else { COOLDOWN })
|
||
| (if s.reloading == 0 { 0 } else { RELOADING })
|
||
| (if s.bursts > 0 { 0 } else { NO_ROUNDS })
|
||
}
|
||
|
||
// ---------------------------------------------------------------- the diagnostic
|
||
|
||
/// One condition and whether it holds.
|
||
pub struct Check {
|
||
pub ok: bool,
|
||
pub text: String,
|
||
}
|
||
|
||
fn check(ok: bool, text: impl Into<String>) -> Check {
|
||
Check { ok, text: text.into() }
|
||
}
|
||
|
||
impl Sim {
|
||
/// Why system `index` fires or doesn't, at its current target or at the most threatening
|
||
/// drone of a class it engages; computed on demand from the very predicates `step_sams`
|
||
/// uses. The last line is the verdict.
|
||
pub fn why(&self, index: usize) -> Vec<Check> {
|
||
let Some(sam) = self.sams.get(index) else { return vec![check(false, "Нет такой техники")] };
|
||
let spec = sam.kind.spec();
|
||
let mut v = vec![check(true, format!("№{} {}: {}", index + 1, spec.name, sam.state.name()))];
|
||
if !sam.alive() {
|
||
v.push(check(false, "уничтожен"));
|
||
return v;
|
||
}
|
||
if self.hold {
|
||
v.push(check(false, "огонь запрещён (/airdef hold off)"));
|
||
return v;
|
||
}
|
||
if sam.moving() {
|
||
v.push(check(false, format!("{}: не стреляет", sam.state.name().to_lowercase())));
|
||
return v;
|
||
}
|
||
if !sam.kind.armed() {
|
||
v.push(check(false, "нет оружия (РЛС, склад, РЭБ, прожектор)"));
|
||
return v;
|
||
}
|
||
let logistics = self.logistics();
|
||
let (missile_on, guns_on) = engagement_counts(&self.sams);
|
||
let alive = self.drones.iter().filter(|d| d.alive()).count();
|
||
let now = self.tick;
|
||
let seen = self.drones.iter().filter(|d| d.alive() && sam.seen.contains(&d.id)).count();
|
||
let own = self.drones.iter().filter(|d| d.alive() && sam.own.contains(&d.id)).count();
|
||
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())));
|
||
}
|
||
for p in Part::ALL {
|
||
if !p.on(sam.kind) || sam.part_state(p) == PartState::Ok {
|
||
continue;
|
||
}
|
||
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()),
|
||
};
|
||
v.push(check(false, effect));
|
||
}
|
||
if sam.jam > 0 {
|
||
v.push(check(false, format!("орудие заклинило, ещё {:.1} с", sam.jam as f64 / 20.0)));
|
||
}
|
||
let drone_at = |id: u32| self.drones.iter().find(|d| d.id == id);
|
||
let subject = sam.target.or(sam.gun_target).and_then(drone_at).or_else(|| {
|
||
self.drones
|
||
.iter()
|
||
.filter(|d| d.alive() && (sam.roles & d.kind.spec().class.bit() != 0 || last_chance(self.last_chance, sam.roles, d, claimed(d, &missile_on, sam.target))))
|
||
.max_by(|a, b| {
|
||
// What the doctrine lets the system shoot at comes first; then the most threatening.
|
||
let area = |d: &Drone| sam.track(d.id).is_none_or(|tr| sam.doctrine.area_blocks(sam.pos, tr, self.tick) == 0);
|
||
area(a).cmp(&area(b)).then(a.threat(sam.pos).total_cmp(&b.threat(sam.pos)))
|
||
})
|
||
});
|
||
let Some(t) = subject else {
|
||
v.push(check(false, format!("нет целей классов [{}]", roles_text(sam.roles))));
|
||
return v;
|
||
};
|
||
let held = sam.target == Some(t.id) || sam.gun_target == Some(t.id);
|
||
let dist = sam.track(t.id).map_or(t.pos, |tr| tr.est(self.tick)).distance(sam.pos);
|
||
v.push(check(
|
||
true,
|
||
format!("цель: {} {}, {}", t.kind.spec().name, if held { "(сопровождается)" } else { "(самая опасная)" }, super::distance(dist)),
|
||
));
|
||
let track = sam.track(t.id);
|
||
let blocks = target_blocks(sam.roles, track, now, t);
|
||
// Where the system believes the target is: its track's forecast (the true place only when it has no track).
|
||
let est = track.map_or(t.pos, |tr| tr.est(now));
|
||
let evel = track.map_or(t.vel, |tr| tr.vel);
|
||
let first_claim = claimed(t, &missile_on, sam.target);
|
||
let chance = blocks & ROLE != 0 && last_chance(self.last_chance, sam.roles, t, first_claim);
|
||
let role_text = if blocks & ROLE == 0 {
|
||
format!("роль: {} в ролях [{}]", t.kind.spec().class.name(), roles_text(sam.roles))
|
||
} else if chance {
|
||
format!(
|
||
"последний шанс: {} не в ролях [{}], но до цели {:.0} с, перехватчиков на ней нет",
|
||
t.kind.spec().class.name(),
|
||
roles_text(sam.roles),
|
||
arrival(t) / 20.0
|
||
)
|
||
} else {
|
||
format!("роль: {} не в ролях [{}]", t.kind.spec().class.name(), roles_text(sam.roles))
|
||
};
|
||
v.push(check(blocks & ROLE == 0 || chance, role_text));
|
||
// The doctrine: which of its settings let the shot through or stopped it. The last chance is past them.
|
||
let doc = &sam.doctrine;
|
||
let area = track.map_or(0, |tr| doc.area_blocks(sam.pos, tr, now));
|
||
let held_back = if spec.missile.is_some() { doc.reserve_blocks(sam.missiles, t) } else { 0 };
|
||
if !doc.is_default() {
|
||
v.push(check(true, format!("доктрина: {}", doc.text(&self.targets))));
|
||
}
|
||
if area & doctrine::OUT_SECTOR != 0 {
|
||
let bearing = heading_of(est - sam.pos).to_degrees().rem_euclid(360.0);
|
||
v.push(check(chance, format!("вне сектора {}: цель на {bearing:.0}°{}", doc.sector_text(), if chance { " — последний шанс сильнее" } else { "" })));
|
||
} else if doc.sector.is_some() {
|
||
v.push(check(true, format!("в секторе {}", doc.sector_text())));
|
||
}
|
||
if area & doctrine::NO_THREAT != 0 {
|
||
v.push(check(chance, format!("не угрожает охраняемым объектам ({}){}", doc.guards.iter().map(|g| doctrine::object_name(&self.targets, *g)).collect::<Vec<_>>().join(", "), if chance { " — последний шанс сильнее" } else { "" })));
|
||
} else if let Some(g) = track.and_then(|tr| doc.threatens(tr, now)) {
|
||
v.push(check(true, format!("прикрывает {}: цель идёт на него", doctrine::object_name(&self.targets, doc.guards[g]))));
|
||
}
|
||
if held_back != 0 {
|
||
v.push(check(chance, format!("сохраняет резерв {}: ракет {}, цель не срочная{}", doc.reserve, sam.missiles, if chance { " — последний шанс сильнее" } else { "" })));
|
||
} else if doc.reserve > 0 && sam.missiles <= doc.reserve {
|
||
v.push(check(true, format!("резерв {} открыт: цель срочная (до удара {:.0} с)", doc.reserve, arrival(t) / 20.0)));
|
||
}
|
||
let detect = match (track, sam.own.contains(&t.id)) {
|
||
(Some(tr), own) => {
|
||
let kind = if own { "своей РЛС" } else { "по сети (стволы не стреляют)" };
|
||
let age = tr.age(now) as f64 / 20.0;
|
||
let lock = if tr.own_within(now, sight::GUN_AGE) && tr.locked { ", захват" } else { "" };
|
||
let how = if tr.forecast(now) { format!("прогноз ±{:.0} м", tr.uncertainty(now)) } else { "измерение".to_string() };
|
||
format!("трек {kind}: {}, возраст {age:.1} с, {how}{lock}, {}", tr.ident().name(), if blocks & STALE != 0 { "устарел для пуска" } else { "годен для пуска" })
|
||
}
|
||
_ => "не обнаружена ни своей РЛС, ни сетью".to_string(),
|
||
};
|
||
v.push(check(blocks & (UNSEEN | STALE) == 0, detect));
|
||
if sam.kind.spec().sensor > 0.0 {
|
||
let period = sam.scan_ticks();
|
||
v.push(check(
|
||
sam.radar_up(),
|
||
if sam.radar_up() {
|
||
format!("обзор: оборот {:.1} с{}, луч на {:.0}°", period / 20.0, if sam.kind.scan().agile { ", электронный, цели в треке чаще" } else { "" }, sam.beam.to_degrees())
|
||
} else {
|
||
"обзор: РЛС не работает".to_string()
|
||
},
|
||
));
|
||
}
|
||
// Ammunition and its supply.
|
||
let mut rounds = String::new();
|
||
if let Some(load) = &spec.missile {
|
||
rounds += &format!("ракет {}/{} ", sam.missiles, load.magazine);
|
||
}
|
||
if let Some(gun) = &spec.gun {
|
||
rounds += &format!("очередей {}/{} ", sam.bursts, gun.bursts);
|
||
}
|
||
let supply = if sam.reloading > 0 {
|
||
format!("перезарядка, ещё {:.1} с", sam.reloading as f64 / 20.0)
|
||
} else if sam.empty {
|
||
if logistics { "пуст, ждёт машину-заряжающую (ТЗМ) со склада".into() } else { "пуст".into() }
|
||
} else {
|
||
"боезапас есть".into()
|
||
};
|
||
v.push(check(sam.reloading == 0 && !sam.empty && (sam.missiles > 0 || sam.bursts > 0), format!("{rounds}— {supply}")));
|
||
let mount = sam.mount().0;
|
||
let lead_speed = spec.gun.as_ref().map_or(5.0, |g| g.shell);
|
||
let (_, want_yaw, want_pitch) = aim_wants(mount, est, evel, lead_speed);
|
||
let err = aim_error(spec.family, sam.yaw, sam.pitch, want_yaw, want_pitch);
|
||
let vertical = spec.family == Family::Vertical;
|
||
let mut fires = false;
|
||
if let Some(load) = &spec.missile {
|
||
let reach = reach_blocks(load.min, load.max, 1.0, sam.pos, est);
|
||
// Beyond the normal reach, a heavy system may fire on a big radar's cue.
|
||
let radars: Vec<&Sam> = self.sams.iter().filter(|s| s.kind == SamKind::Radar).collect();
|
||
let cued = sam.kind.far_shooter() && radar_cue(sam, &radars, t.id, now);
|
||
let role_in_reach = self.drones.iter().any(|o| {
|
||
sam.track(o.id).is_some_and(|tr| allowed(sam.roles, tr, now, o) && in_reach(load.min, load.max, 1.0, sam.pos, tr.est(now)) && sam.doctrine.area_blocks(sam.pos, tr, now) | sam.doctrine.reserve_blocks(sam.missiles, o) == 0 && claimed(o, &missile_on, sam.target) < sam.doctrine.needed(o))
|
||
});
|
||
let far = reach == TOO_FAR && reach_blocks(load.min, load.max, FAR_K, sam.pos, est) == 0;
|
||
let far_ok = far && track.is_some_and(|tr| basis(sam, load, tr, t, now, 0, 1.0, Opts { last_chance: self.last_chance, far_shots: self.far_shots }, cued, role_in_reach) == Some(Basis::FarShot));
|
||
if far {
|
||
let fb = far_blocks(self.far_shots, sam, load, cued, role_in_reach);
|
||
let mut no = Vec::new();
|
||
for (bit, name) in [
|
||
(FAR_OFF, "не для этого комплекса"),
|
||
(FAR_NO_CUE, "нет свежего трека большой РЛС"),
|
||
(FAR_WAIT, "дальний пуск уже был недавно"),
|
||
(FAR_LOW, "меньше половины боезапаса"),
|
||
(FAR_BUSY, "есть цель в обычной дальности"),
|
||
] {
|
||
if fb & bit != 0 {
|
||
no.push(name);
|
||
}
|
||
}
|
||
v.push(check(far_ok, if far_ok { format!("дальний пуск по РЛС: цель на {} дальше обычных {}", super::distance(dist), super::distance(load.max)) } else { format!("дальний пуск по РЛС не разрешён: {}", no.join(", ")) }));
|
||
}
|
||
let free = first_claim;
|
||
let need = sam.doctrine.needed(t);
|
||
v.push(check(free < need, format!("цель занята перехватчиками: {free} из {need} (залп {}){}", doc.salvo.name(), if free < need { "" } else { " — другие уже на ней" })));
|
||
let mb = missile_blocks(sam);
|
||
let mut why = Vec::new();
|
||
if mb & COOLDOWN != 0 {
|
||
why.push(format!("интервал пусков ({:.1} с)", sam.cooldown as f64 / 20.0));
|
||
}
|
||
if mb & RELOADING != 0 {
|
||
why.push("перезарядка".to_string());
|
||
}
|
||
if mb & NO_ROUNDS != 0 {
|
||
why.push("нет ракет".to_string());
|
||
}
|
||
if mb & LAUNCHER_DOWN != 0 {
|
||
why.push("пусковая отказала".to_string());
|
||
}
|
||
v.push(check(mb == 0, if mb == 0 { "пусковая готова".to_string() } else { format!("пусковая не готова: {}", why.join(", ")) }));
|
||
let aligned = missile_aligned(spec.family, sam.yaw, mount, Some(est), t.id, t.id, err);
|
||
let turn = if vertical { "вертикальный старт" } else { "доворот" };
|
||
v.push(check(aligned, format!("{turn}: ошибка наведения {:.0}°{}", err.to_degrees(), if aligned { "" } else { " — ещё доворачивается" })));
|
||
// The land: the verdict the launch decision gave this target (`terrain::launch_blocks`) a moment ago.
|
||
let land = sam.terrain_verdict(t.id, now).unwrap_or(0);
|
||
if land != 0 {
|
||
v.push(check(
|
||
false,
|
||
if land & terrain::MASKED != 0 {
|
||
"рельеф: цель за рельефом — ракета не стартует, ждём, пока цель выйдет из-за него"
|
||
} else {
|
||
"рельеф: траектория перекрыта рельефом — подняться над ним ракета не может"
|
||
},
|
||
));
|
||
}
|
||
let doc_ok = chance || area | held_back == 0;
|
||
let grounds = blocks & !ROLE == 0 && (blocks & ROLE == 0 || chance) && doc_ok && (reach == 0 || far_ok);
|
||
fires |= grounds && free < need && mb == 0 && aligned && land == 0;
|
||
}
|
||
if let Some(gun) = &spec.gun {
|
||
let on_it = guns_on.get(&t.id).copied().unwrap_or(0) - u32::from(sam.gun_target == Some(t.id));
|
||
let cand = track.is_some_and(|tr| gun_candidate(sam.roles, &sam.doctrine, tr, now, gun.track, sam.pos, t));
|
||
v.push(check(
|
||
cand,
|
||
format!(
|
||
"орудие: сопровождение до {}, цель на {}, других орудий на ней {on_it}{}",
|
||
super::distance(gun.track),
|
||
super::distance(dist),
|
||
if cand { "" } else { " — вне сопровождения (нужна своя РЛС)" }
|
||
),
|
||
));
|
||
let gb = gun_blocks(sam, t.id, t.id, err, mount.distance(est), gun.range, now);
|
||
let mut why = Vec::new();
|
||
for (bit, name) in [
|
||
(AIM_ERROR, format!("орудие ещё наводится ({:.0}°)", err.to_degrees())),
|
||
(OUT_OF_RANGE, format!("дальше {}", super::distance(gun.range))),
|
||
(COOLDOWN, "пауза между очередями".to_string()),
|
||
(RELOADING, "перезарядка".to_string()),
|
||
(NO_ROUNDS, "нет снарядов".to_string()),
|
||
(JAMMED, "орудие заклинило".to_string()),
|
||
(NO_LOCK, "нет свежего захвата своей РЛС".to_string()),
|
||
] {
|
||
if gb & bit != 0 {
|
||
why.push(name);
|
||
}
|
||
}
|
||
v.push(check(gb == 0, if gb == 0 { "орудие готово".to_string() } else { format!("орудие не стреляет: {}", why.join(", ")) }));
|
||
fires |= gb == 0 && cand;
|
||
}
|
||
v.push(check(fires, if fires { "итог: готова стрелять" } else { "итог: стрелять сейчас не может" }));
|
||
v
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn why_names_the_reason_a_system_does_not_fire() {
|
||
let mut sim = Sim::new(3);
|
||
let id = sim.id();
|
||
sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||
let mut ground = Ground::default();
|
||
let mut out = Out::default();
|
||
sim.sams[0].roles = 0;
|
||
sim.raid(DroneKind::Shahed, DVec3::new(0.0, 70.0, 0.0), 1, 200.0, &mut out);
|
||
for _ in 0..60 {
|
||
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("нет целей классов")));
|
||
sim.sams[0].roles = 0xFF;
|
||
assert!(sim.why(0).iter().any(|c| c.text.starts_with("роль")));
|
||
}
|
||
|
||
use super::sites::testland::{Land, serve};
|
||
|
||
fn setup(kind: SamKind) -> (Sim, Ground, Land<fn(i32, i32) -> i32>, Out) {
|
||
let mut sim = Sim::new(5);
|
||
let id = sim.id();
|
||
sim.sams.push(Sam::new(id, kind, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||
(sim, Ground::default(), Land(|_, _| 64), Out::default())
|
||
}
|
||
|
||
/// Steps `ticks` or until something is fired; returns the drone's ticks to its aim at the first launch.
|
||
fn until_fired(sim: &mut Sim, ground: &mut Ground, land: &Land<fn(i32, i32) -> i32>, out: &mut Out, ticks: u32) -> Option<f64> {
|
||
for _ in 0..ticks {
|
||
serve(ground, land, sim.tick);
|
||
sim.step(ground, None, out);
|
||
if sim.sams[0].fired > 0 {
|
||
return sim.drones.first().map(arrival);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// A Shahed `blocks` short of its aim, which is `80` out from the system, coming in along z.
|
||
fn shahed_at(sim: &mut Sim, blocks: f64) {
|
||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, -80.0 - blocks), DVec3::Z, DVec3::new(0.0, 70.0, -80.0));
|
||
}
|
||
|
||
#[test]
|
||
fn last_chance_fires_at_a_class_out_of_roles_only_when_it_is_about_to_hit() {
|
||
// The S-400 does not engage drones by default.
|
||
assert_eq!(SamKind::S400.spec().roles & Class::Drone.bit(), 0);
|
||
let (mut sim, mut ground, land, mut out) = setup(SamKind::S400);
|
||
shahed_at(&mut sim, 60.0);
|
||
let at = until_fired(&mut sim, &mut ground, &land, &mut out, 140).expect("the S-400 never fired on a Shahed about to hit");
|
||
assert!(at < LAST_CHANCE_TICKS, "fired with {at:.0} ticks to go");
|
||
// The diagnostic names why: the last chance.
|
||
let (mut sim, mut ground, land, mut out) = setup(SamKind::S400);
|
||
shahed_at(&mut sim, 100.0);
|
||
for _ in 0..30 {
|
||
serve(&mut ground, &land, sim.tick);
|
||
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<_>>());
|
||
}
|
||
|
||
#[test]
|
||
fn last_chance_waits_for_a_target_far_from_its_aim() {
|
||
let (mut sim, mut ground, land, mut out) = setup(SamKind::S400);
|
||
// In plain view 120 blocks off, but flying away from its aim, 480 blocks (800 ticks) ahead.
|
||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, -120.0), DVec3::NEG_Z, DVec3::new(0.0, 70.0, -600.0));
|
||
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("последний шанс")));
|
||
}
|
||
|
||
#[test]
|
||
fn last_chance_is_off_when_switched_off_and_with_no_roles() {
|
||
for (off, roles) in [(true, 30u8), (false, 0u8)] {
|
||
let (mut sim, mut ground, land, mut out) = setup(SamKind::S400);
|
||
sim.last_chance = !off;
|
||
sim.sams[0].roles = roles;
|
||
shahed_at(&mut sim, 60.0);
|
||
assert!(until_fired(&mut sim, &mut ground, &land, &mut out, 140).is_none(), "fired with last chance off={off}, roles {roles}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn last_chance_leaves_a_target_that_already_has_an_interceptor_on_it() {
|
||
let (mut sim, mut ground, land, mut out) = setup(SamKind::S400);
|
||
shahed_at(&mut sim, 60.0);
|
||
// Another system's interceptor is on it.
|
||
let id = sim.drones[0].id;
|
||
sim.drones[0].engaged = 1;
|
||
assert!(until_fired(&mut sim, &mut ground, &land, &mut out, 40).is_none(), "fired at {id} that is claimed");
|
||
}
|
||
|
||
/// A Kh101 `dist` from the origin on the far side, at height, coming in along z; a radar beside the system.
|
||
fn far_setup(radar: bool, dist: f64) -> (Sim, Ground, Land<fn(i32, i32) -> i32>, Out) {
|
||
let (mut sim, ground, land, out) = setup(SamKind::S400);
|
||
if radar {
|
||
let id = sim.id();
|
||
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(40.0, 70.0, 0.0), 0.0));
|
||
}
|
||
sim.add_drone(DroneKind::Kh101, DVec3::new(0.0, 300.0, -dist), DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
|
||
(sim, ground, land, out)
|
||
}
|
||
|
||
/// The radar's measurement of every live drone, fresh this tick (as if its beam had just passed).
|
||
fn radar_sees(sim: &mut Sim) {
|
||
let now = sim.tick;
|
||
let tracks: Vec<Track> = sim
|
||
.drones
|
||
.iter()
|
||
.filter(|d| d.alive())
|
||
.map(|d| Track { id: d.id, kind: d.kind, pos: d.pos, vel: d.vel, tick: now, own_tick: now, ttl: 400, hits: 3, locked: false })
|
||
.collect();
|
||
for r in sim.sams.iter_mut().filter(|s| s.kind == SamKind::Radar) {
|
||
for t in &tracks {
|
||
r.tracks.retain(|o| o.id != t.id);
|
||
r.tracks.push(*t);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn fire_far(sim: &mut Sim, ground: &mut Ground, land: &Land<fn(i32, i32) -> i32>, out: &mut Out, ticks: u32) -> u32 {
|
||
for _ in 0..ticks {
|
||
radar_sees(sim);
|
||
serve(ground, land, sim.tick);
|
||
sim.step(ground, None, out);
|
||
}
|
||
sim.sams[0].fired
|
||
}
|
||
|
||
#[test]
|
||
fn heavy_systems_shoot_far_on_a_radars_cue_but_not_without_one() {
|
||
let max = SamKind::S400.spec().missile.as_ref().unwrap().max;
|
||
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<_>>());
|
||
// 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");
|
||
// And a kind that is not a heavy one never does.
|
||
let (mut sim, mut ground, land, mut out) = far_setup(true, max * 1.4);
|
||
sim.far_shots = false;
|
||
assert_eq!(fire_far(&mut sim, &mut ground, &land, &mut out, 80), 0, "a long shot with them switched off");
|
||
}
|
||
|
||
#[test]
|
||
fn a_long_shot_waits_out_its_cooldown_and_for_nothing_closer() {
|
||
let max = SamKind::S400.spec().missile.as_ref().unwrap().max;
|
||
// Two far targets: one long shot, the second waits its 300 ticks.
|
||
let (mut sim, mut ground, land, mut out) = far_setup(true, max * 1.4);
|
||
sim.add_drone(DroneKind::Kh101, DVec3::new(0.0, 300.0, max * 1.4), DVec3::NEG_Z, DVec3::new(0.0, 70.0, 0.0));
|
||
assert_eq!(fire_far(&mut sim, &mut ground, &land, &mut out, 70), 1, "not exactly one long shot in the cooldown");
|
||
// A closer target in normal reach: the far one is not fired at while it stands.
|
||
let (mut sim, mut ground, land, mut out) = far_setup(true, max * 1.4);
|
||
let far = sim.drones[0].id;
|
||
sim.add_drone(DroneKind::Kh101, DVec3::new(300.0, 120.0, 0.0), DVec3::NEG_X, DVec3::new(0.0, 70.0, 0.0));
|
||
for _ in 0..40 {
|
||
radar_sees(&mut sim);
|
||
serve(&mut ground, &land, sim.tick);
|
||
sim.step(&mut ground, None, &mut out);
|
||
assert!(!sim.missiles.iter().any(|m| m.target == far), "a long shot while a closer target was in reach");
|
||
}
|
||
assert!(sim.sams[0].fired > 0, "the closer target was not fired at");
|
||
}
|
||
|
||
#[test]
|
||
fn the_kill_chance_falls_to_half_at_the_far_end() {
|
||
assert_eq!(reach_fit(100.0, 80.0), 1.0);
|
||
assert_eq!(reach_fit(100.0, 100.0), 1.0);
|
||
assert!((reach_fit(100.0, 130.0) - 0.75).abs() < 1e-9);
|
||
assert!((reach_fit(100.0, 160.0) - 0.5).abs() < 1e-9);
|
||
assert!((reach_fit(100.0, 400.0) - 0.5).abs() < 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn every_interceptor_lives_long_enough_to_fly_its_reach() {
|
||
// Life × top speed must cover the far end of a heavy system's reach with room to chase; the rest their plain reach.
|
||
for kind in SamKind::ARMED {
|
||
let Some(load) = kind.spec().missile.as_ref() else { continue };
|
||
let m = load.kind.spec();
|
||
let reach = load.max * if kind.far_shooter() { FAR_K } else { 1.0 };
|
||
assert!(m.life as f64 * m.speed > reach * 1.2, "{}: {} x {} does not cover {reach}", kind.name(), m.life, m.speed);
|
||
}
|
||
}
|
||
}
|