From 8fc10c07a1682dc842156fef98619d0963884a66 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 6 Nov 2026 17:58:17 +0300 Subject: [PATCH] Give the enemy a last-observation picture and observed-capability pricing (#127) --- airdef-core/examples/intel_bench.rs | 8 + airdef-mod/examples/intel_bench.rs | 53 ++ airdef-mod/examples/sim_run/harness.rs | 3 + airdef-mod/src/sim/battery.rs | 22 +- airdef-mod/src/sim/ew.rs | 627 ++++++++++++++++-- airdef-mod/src/sim/mod.rs | 23 +- airdef-mod/src/sim/raid.rs | 219 +++--- airdef-mod/src/sim/regress.rs | 40 +- airdef-mod/src/sim/sight.rs | 6 +- airdef-mod/src/sim/strike.rs | 3 +- docs/CODEX-HANDOFF.md | 52 +- docs/IMPLEMENTATION-STATUS.md | 11 +- .../issue-127-enemy-picture-baseline.json | 155 +++++ .../issue-127-enemy-picture-result.json | 155 +++++ 14 files changed, 1189 insertions(+), 188 deletions(-) create mode 100644 airdef-core/examples/intel_bench.rs create mode 100644 airdef-mod/examples/intel_bench.rs create mode 100644 docs/measurements/issue-127-enemy-picture-baseline.json create mode 100644 docs/measurements/issue-127-enemy-picture-result.json diff --git a/airdef-core/examples/intel_bench.rs b/airdef-core/examples/intel_bench.rs new file mode 100644 index 0000000..4e4a4b6 --- /dev/null +++ b/airdef-core/examples/intel_bench.rs @@ -0,0 +1,8 @@ +//! The enemy-picture bench (#127) without building the Horizon SDK. +extern crate airdef_core as airdef; +#[path = "../../airdef-mod/examples/intel_bench.rs"] +mod bench; + +fn main() { + bench::main(); +} diff --git a/airdef-mod/examples/intel_bench.rs b/airdef-mod/examples/intel_bench.rs new file mode 100644 index 0000000..a9b5998 --- /dev/null +++ b/airdef-mod/examples/intel_bench.rs @@ -0,0 +1,53 @@ +//! Enemy-picture outcomes on fixed seeds (#127): what changed for the raids, measured. +//! +//! Runs the standard scenarios over a fixed seed set and prints one JSON object with the +//! completion, losses and failure reasons of every run — the before/after comparison of the +//! honest EnemyPicture is judged on these, not on "it got smarter". +//! +//! cargo run --release --example intel_bench +#[path = "sim_run/harness.rs"] +mod harness; + +use harness::{big_smart, ew_adaptive, logistics, Scenario}; + +fn esc(s: &str) -> String { + s.chars().flat_map(|c| match c { + '"' => "\\\"".chars().collect::>(), + '\\' => "\\\\".chars().collect::>(), + c => vec![c], + }).collect() +} + +fn run(sc: &Scenario) -> String { + let r = harness::run(sc); + format!( + "{{\"scenario\":\"{}\",\"seed\":{},\"ticks_run\":{},\"raids_ordered\":{},\"raids_ended\":{},\"all_ended\":{},\"launched\":{},\"kills\":{},\"hits\":{},\"crashes\":{},\"sams_lost\":{},\"fired\":{},\"failures\":\"{}\"}}", + esc(&r.name), + r.seed, + r.ticks_run, + r.raids_ordered, + r.raids_ended, + r.all_ended, + r.launched, + r.kills, + r.hits, + r.crashes, + r.sams_lost, + r.fired, + esc(&r.failures), + ) +} + +pub fn main() { + let scenarios: Vec Scenario> = vec![big_smart, logistics, ew_adaptive]; + let seeds = [1u64, 7, 42]; + let mut rows = Vec::new(); + for make in &scenarios { + for &seed in &seeds { + let mut sc = make(); + sc.seed = seed; + rows.push(run(&sc)); + } + } + println!("[{}]", rows.join(",\n")); +} diff --git a/airdef-mod/examples/sim_run/harness.rs b/airdef-mod/examples/sim_run/harness.rs index 050c685..e90730b 100644 --- a/airdef-mod/examples/sim_run/harness.rs +++ b/airdef-mod/examples/sim_run/harness.rs @@ -128,6 +128,8 @@ pub struct Report { pub raids_ordered: usize, pub raids_ended: usize, pub all_ended: bool, + /// Systems the defence lost (destroyed units at the end). + pub sams_lost: u32, } fn finite(v: DVec3) -> bool { @@ -227,6 +229,7 @@ pub fn run(sc: &Scenario) -> Report { } } r.launched = sim.launched; + r.sams_lost = sim.sams.iter().filter(|s| !s.alive()).count() as u32; r.kills = sim.kills; r.hits = sim.hits; r.fired = sim.sams.iter().map(|s| s.fired).sum(); diff --git a/airdef-mod/src/sim/battery.rs b/airdef-mod/src/sim/battery.rs index e5f7060..562a626 100644 --- a/airdef-mod/src/sim/battery.rs +++ b/airdef-mod/src/sim/battery.rs @@ -285,7 +285,7 @@ mod tests { mod emission_tests { use super::*; #[test] - fn silence_accepts_network_without_inventing_own_measurements_and_contacts_age() { + fn silence_discloses_nothing_and_contacts_age_and_channels_still_place_a_unit() { let mut sim = Sim::new(121); sim.tick = 20; let mut source = Sam::new(1, SamKind::Radar, DVec3::ZERO, 0.0); @@ -301,22 +301,30 @@ mod emission_tests { assert_eq!(sim.sams[1].track(id).unwrap().own_tick, 0); assert_eq!(sim.sams[1].track(id).unwrap().tick, 20); assert!(!sim.sams[1].radar_up()); - assert_eq!(sim.known_units(), vec![(1, DVec3::ZERO)]); + // The emitting source is fixed by the drone; the silent receiver is not. + let picture = sim.picture_of_defences(); + assert_eq!(picture.len(), 1); + assert_eq!((picture[0].id, picture[0].at), (1, DVec3::ZERO)); + assert_eq!(picture[0].seen, ew::Seen::Emission); sim.sams[0].radar_on = false; + // The truth moving is not an observation: the contact keeps the observed place. sim.sams[0].pos = DVec3::new(400.0, 0.0, 0.0); sim.tick = 40; sim.update_sight(&mut ground); - assert_eq!(sim.known_units(), vec![(1, DVec3::ZERO)]); assert_eq!(sim.sams[1].track(id).unwrap().tick, 20); + let picture = sim.picture_of_defences(); + assert_eq!(picture.len(), 1); + assert_eq!(picture[0].at, DVec3::ZERO, "a hidden move changed the picture"); + // A fresh emission fix re-places it where seen then. sim.sams[0].radar_on = true; sim.tick = 60; sim.update_sight(&mut ground); - assert_eq!(sim.known_units(), vec![(1, DVec3::new(400.0, 0.0, 0.0))]); + assert_eq!(sim.picture_of_defences()[0].at, DVec3::new(400.0, 0.0, 0.0)); sim.sams[0].radar_on = false; // Other disclosure channels still reveal a silent unit. - sim.intel.revealed.push((2, DVec3::ZERO, 60)); - assert!(Sim::enemy_knows(&sim.sams[1], &sim.known_units())); + sim.intel.observe(&sim.sams[1], 60, ew::Seen::Launch); + assert!(sim.known_live(2)); sim.tick = 6060; - assert!(sim.known_units().is_empty()); + assert!(sim.picture_of_defences().is_empty(), "an unrefreshed contact never ages out"); } } diff --git a/airdef-mod/src/sim/ew.rs b/airdef-mod/src/sim/ew.rs index b2d15fe..5824b83 100644 --- a/airdef-mod/src/sim/ew.rs +++ b/airdef-mod/src/sim/ew.rs @@ -7,8 +7,20 @@ //! away. The enemy keeps what it learns — which systems fired and which //! stations jammed (its next drones go for them), and from which bearings its //! raids were shot down or got through (its next raid comes from the best). +//! +//! What it learns is a picture, not the world (#127). Every unit it knows of +//! is a *contact*: the last observation a disclosure channel made of it — +//! a launch, a radar emission, a jamming transmission, or its fate watched by +//! the enemy's own munition. A contact is written only at the moment of the +//! observation and is never refreshed from what the unit does while +//! unobserved: a moved, switched-off, re-roled or destroyed unit stays in the +//! picture exactly as seen until something discloses it again. Between +//! observations the planner works from the picture alone; its confidence in a +//! contact is a plain function of the contact's age, and its idea of a unit's +//! capabilities is the type's published one — a contact says nothing about +//! damage, roles, link health or magazine. use super::*; -use std::collections::{HashSet, VecDeque}; +use std::collections::{BTreeMap, VecDeque}; use std::sync::Arc; /// A route still to be worked out after a retarget (#127). The drone already has its new aim; the job @@ -24,7 +36,7 @@ pub(super) struct RouteJob { /// The adaptive tick whose picture chose the aim: the knowledge version. version: u64, /// The enemy picture that tick knew, shared by every job of the tick. - known: Arc>, + known: Arc>, /// The land that tick's plan knew, shared by every job of the version. A later plan replacing the /// global snapshot must not change a pending job's relief (#127). relief: Option>, @@ -75,7 +87,7 @@ impl RouteJob { aim: DVec3, unit: u32, version: u64, - known: Arc>, + known: Arc>, relief: Option>, ) -> Self { Self { drone, aim, unit, version, known, relief } @@ -101,13 +113,177 @@ const READY: f64 = 0.3; /// Approach sectors the enemy keeps score of. pub const SECTORS: usize = 12; +/// How a contact's trust decays with age alone (#127). The bands are the only source of +/// confidence: nothing is invented about a unit between observations. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub enum Confidence { + /// Older than `PROBABLE_AGE`: kept on the map and priced, not worth a strike of its own. + Doubtful, + /// Seen within `PROBABLE_AGE`: good enough to send SEAD at. + Probable, + /// Seen within `CONFIRMED_AGE`: as observed. + Confirmed, +} + +impl Confidence { + pub fn name(self) -> &'static str { + match self { + Self::Doubtful => "doubtful", + Self::Probable => "probable", + Self::Confirmed => "confirmed", + } + } +} + +/// What a disclosure channel showed the moment it disclosed a unit (#127). The channel +/// shows only its own fact: a launch says nothing about the unit's radar, an emission +/// fix says nothing about its weapons. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum Seen { + /// A launch was seen: it fired a missile. + Launch, + /// Its radar emission was fixed: the radar was up and transmitting. + Emission, + /// Its EW transmission was fixed: it was jamming. + Jamming, +} + +impl Seen { + pub fn name(self) -> &'static str { + match self { + Self::Launch => "launch", + Self::Emission => "emission", + Self::Jamming => "jamming", + } + } +} + +/// The enemy's last observation of one unit (#127): where it was, what it was seen doing, +/// and when. Written only when a channel discloses the unit; what the unit does while +/// unobserved — moves, switches off, re-roles, spends its magazine, dies — never updates +/// it. Only a newer observation of the same unit can. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Contact { + pub id: u32, + /// The type identified at the observation. + pub kind: SamKind, + /// Where it was when last observed. + pub at: DVec3, + /// When that was. + pub tick: u64, + /// What the last observation showed. + pub seen: Seen, + /// When its emission was last fixed (within this contact's life): the belief that its + /// own radar works rests on this, never on the unit's actual switch. + pub emission: Option, + /// When the enemy's own munition watched it die (battle damage assessment). Truth + /// read at that moment only; a unit destroyed unobserved keeps living in the picture. + pub destroyed: Option, +} + +/// A contact this old (ticks) is forgotten entirely (five minutes of silence). +pub(super) const CONTACT_TTL: u64 = 6000; +/// Younger than this a contact is `Confirmed`; younger than `PROBABLE_AGE`, `Probable`. +const CONFIRMED_AGE: u64 = 600; +const PROBABLE_AGE: u64 = 3000; + +impl Contact { + pub fn age(&self, now: u64) -> u64 { + now.saturating_sub(self.tick) + } + /// Whether the picture counts it as a live unit: observed, not watched dead, not aged out. + pub fn live(&self, now: u64) -> bool { + self.destroyed.is_none() && self.age(now) < CONTACT_TTL + } + pub fn confidence(&self, now: u64) -> Confidence { + match self.age(now) { + 0..=CONFIRMED_AGE => Confidence::Confirmed, + ..=PROBABLE_AGE => Confidence::Probable, + _ => Confidence::Doubtful, + } + } + /// Whether the enemy believes this unit sees on its own radar (#127). A kind without a + /// sensor never does. A kind with one does while an emission fix of this contact is + /// alive; the only contrary evidence the channels give is a launch seen with the unit's + /// emissions never fixed — a launcher fighting on the network (its radar is off or it + /// has none in its launcher-only mode). + pub fn believes_own_sensor(&self, now: u64) -> bool { + let spec = self.kind.spec(); + if spec.sensor <= 0.0 { + return false; + } + match self.emission { + Some(t) => now.saturating_sub(t) < CONTACT_TTL, + None => !(self.seen == Seen::Launch && self.network_launcher_kind()), + } + } + /// The kinds that can fire without their own radar in some configuration: an observation + /// of a launch without any emission fix reads as that configuration. + fn network_launcher_kind(&self) -> bool { + matches!(self.kind, SamKind::S400 | SamKind::S500) + } +} + +/// The radar reach the enemy's planners price by: the type's published figure, undamaged +/// and role-default. What a unit suffers or is configured to while unobserved is not in it. +pub(super) fn published_reach(kind: SamKind, drone: DroneKind, height: f64) -> f64 { + let spec = kind.spec(); + if spec.sensor <= 0.0 { + return 0.0; + } + let radar = spec.sensor * drone.rcs().powf(0.25); + let horizon = sight::HORIZON * (spec.mast.sqrt() + height.max(0.0).sqrt()); + radar.min(horizon).max(sight::NEAR) +} + #[derive(Default)] pub(super) struct Intel { /// Shot down and got through, by approach sector, over every raid. pub kills: [u32; SECTORS], pub hits: [u32; SECTORS], - /// Units that gave themselves away: (id, where, when) — launches, jamming. - pub revealed: Vec<(u32, DVec3, u64)>, + /// The last observation of each unit the enemy knows of, by id. One entry per unit: + /// a newer observation replaces the older, so no log ever needs rescanning (#122). + contacts: BTreeMap, +} + +impl Intel { + /// The one writer of contacts: what was seen of `s`, at its place, this moment. + pub(super) fn observe(&mut self, s: &Sam, now: u64, seen: Seen) { + self.note(s.id, s.kind, s.pos, s.alive(), now, seen); + } + + pub(super) fn note(&mut self, id: u32, kind: SamKind, at: DVec3, alive: bool, now: u64, seen: Seen) { + let c = self.contacts.entry(id).or_insert(Contact { id, kind, at, tick: now, seen, emission: None, destroyed: None }); + // The newest observation wins; a fresh look at a live unit clears an old wreck call. + c.kind = kind; + c.at = at; + c.tick = now; + c.seen = seen; + if seen == Seen::Emission { + c.emission = Some(now); + } + if alive { + c.destroyed = None; + } + } + + /// Battle damage assessment: the enemy's own munition went down by a unit it knows of + /// and its last moments showed the unit's fate (truth read at this instant only). + pub(super) fn outcome(&mut self, id: u32, alive: bool, at: DVec3, now: u64) { + let Some(c) = self.contacts.get_mut(&id) else { return }; + if alive { + c.at = at; + c.tick = now; + c.destroyed = None; + } else { + c.destroyed = Some(now); + } + } + + /// Drops the contacts no observation has refreshed for `CONTACT_TTL`: silence is forgetting. + pub(super) fn expire(&mut self, now: u64) { + self.contacts.retain(|_, c| c.age(now) < CONTACT_TTL); + } } impl DroneKind { @@ -176,7 +352,7 @@ impl Sim { } // A transmitter on the air: the enemy finds it. if now % 20 == 0 { - self.intel.revealed.push((s.id, s.pos, now)); + self.intel.observe(s, now, Seen::Jamming); } } } @@ -216,25 +392,21 @@ impl Sim { (pick as f64 + 0.5 + self.rng.s() * 0.4) / SECTORS as f64 * TAU } - /// Units the enemy knows of and would go for, newest first: (id, where). - /// One pass over the log: aliveness and duplicates by set, not by rescanning - /// the lists for every entry — a big log with many systems must not make the - /// planner's snapshot quadratic (#122). - pub(super) fn known_units(&self) -> Vec<(u32, DVec3)> { - let alive: HashSet = self.sams.iter().filter(|s| s.alive()).map(|s| s.id).collect(); - let mut seen: HashSet = HashSet::new(); - let mut out: Vec<(u32, DVec3)> = Vec::new(); - for &(id, at, observed) in self.intel.revealed.iter().rev() { - if self.tick.saturating_sub(observed) >= 6000 { continue; } - if !seen.insert(id) { continue; } - if alive.contains(&id) { out.push((id, at)); } - } + /// The enemy's live picture of the defences at this moment: the contacts a decision may + /// rest on, newest first (ties by id, newest id first — the order the old revelation log + /// produced, kept so the planner's picks stay as they were; deterministic either way). + /// What a unit does while unobserved changes nothing here; only a new observation can (#127). + pub(super) fn picture_of_defences(&self) -> Vec { + let now = self.tick; + let mut out: Vec = self.intel.contacts.values().copied().filter(|c| c.live(now)).collect(); + out.sort_by(|a, b| b.tick.cmp(&a.tick).then(b.id.cmp(&a.id))); out } - /// Whether an observation or another disclosure placed this unit in enemy intelligence. - pub(super) fn enemy_knows(s: &Sam, known: &[(u32, DVec3)]) -> bool { - s.alive() && known.iter().any(|k| k.0 == s.id) + /// Whether the picture holds a live contact for this unit: what the old revelation log + /// answered at a scan, now one map lookup. + pub(super) fn known_live(&self, id: u32) -> bool { + self.intel.contacts.get(&id).is_some_and(|c| c.live(self.tick)) } /// During a smart raid: its drones not yet launched go for what just gave itself away (a jamming station @@ -250,17 +422,16 @@ impl Sim { /// The adaptive tick: chooses the new aims and leaves a route job for each drone that needs one. The /// old route stays until a new one is ready: a drone is never left with no way round the defences. fn adapt_targets(&mut self) { - self.intel.revealed.retain(|r| self.tick - r.2 < 6000); - let known = Arc::new(self.known_units()); - let ew: Vec<(u32, DVec3)> = - known.iter().copied().filter(|(id, _)| self.sams.iter().any(|s| s.id == *id && s.kind == SamKind::Ew)).collect(); - if known.is_empty() { + self.intel.expire(self.tick); + let picture = Arc::new(self.picture_of_defences()); + let ew: Vec = picture.iter().copied().filter(|c| c.kind == SamKind::Ew).collect(); + if picture.is_empty() { return; } // Every job of this adaptive tick plans over the same relief version, even if another smart // plan finishes before the job gets its budget (#127). let relief = self.known_relief.clone(); - let pick = |i: usize, list: &[(u32, DVec3)]| list[i % list.len()]; + let pick = |i: usize, list: &[Contact]| list[i % list.len()]; // The drones sent at a new target: a route job each, to be worked out as the planner's budget allows. let mut jobs: Vec = Vec::new(); let mut n = 0usize; @@ -271,32 +442,39 @@ impl Sim { continue; } // Only those not yet sent at a unit. - if known.iter().any(|(_, pos)| pos.distance(d.target - d.err) < 6.0) { + if picture.iter().any(|c| c.at.distance(d.target - d.err) < 6.0) { continue; } - let (unit, at) = if !ew.is_empty() { pick(n, &ew) } else { pick(n, &known) }; - d.target = at + d.err; + let c = if !ew.is_empty() { pick(n, &ew) } else { pick(n, &picture) }; + d.target = c.at + d.err; if Self::route_plannable(d) { - jobs.push(RouteJob { drone: d.id, aim: d.target, unit, version: self.tick, known: Arc::clone(&known), relief: relief.clone() }); + jobs.push(RouteJob { drone: d.id, aim: d.target, unit: c.id, version: self.tick, known: Arc::clone(&picture), relief: relief.clone() }); } else { // Nothing will be replanned for it: its old route leads to the old aim, so it goes straight. d.route.clear(); } n += 1; } - // In flight: a target system destroyed — the next known one, or keep going. + // In flight: the aim is kept only while the picture says it is live. A unit watched dying, + // or forgotten after its contact aged out mid-flight, leaves the drone its course; the next + // observation of anything is a new decision. A unit merely moved is still aimed where seen. for d in self.drones.iter_mut().filter(|d| d.alive() && d.smart) { - let aimed_at_wreck = self.sams.iter().any(|s| !s.alive() && s.pos.distance(d.target - d.err) < 6.0); - if aimed_at_wreck { + let aim = d.target - d.err; + let aim_gone = self + .intel + .contacts + .values() + .any(|c| c.at.distance(aim) < 6.0 && !c.live(self.tick)); + if aim_gone { let plannable = Self::route_plannable(d); - let next = known + let next = picture .iter() - .filter(|k| !plannable || Self::can_turn_to(d, k.1)) - .min_by(|a, b| a.1.distance(d.pos).total_cmp(&b.1.distance(d.pos))); - if let Some(&(unit, at)) = next { - d.target = at + d.err; + .filter(|c| !plannable || Self::can_turn_to(d, c.at)) + .min_by(|a, b| a.at.distance(d.pos).total_cmp(&b.at.distance(d.pos))); + if let Some(c) = next { + d.target = c.at + d.err; if plannable { - jobs.push(RouteJob { drone: d.id, aim: d.target, unit, version: self.tick, known: Arc::clone(&known), relief: relief.clone() }); + jobs.push(RouteJob { drone: d.id, aim: d.target, unit: c.id, version: self.tick, known: Arc::clone(&picture), relief: relief.clone() }); } else { d.route.clear(); } @@ -352,14 +530,16 @@ impl Sim { self.drones.iter_mut().find(|d| d.id == id) } - /// Whether a job still says what its drone is doing. A dead drone, an aim changed again, or a unit - /// destroyed since cancels the job: the next adaptive tick will make a new one if it is still wanted. + /// Whether a job still says what its drone is doing. A dead drone or an aim changed again + /// cancels it; so does a unit the picture watched die or forgot: the enemy no longer plans + /// a route to a target it does not believe in. A unit destroyed unobserved cancels nothing — + /// the picture still believes in it (#127). fn job_live(&self, job: &RouteJob) -> bool { let Some(d) = self.drone_of(job.drone) else { return false }; if !d.alive() || d.target != job.aim { return false; } - job.unit == 0 || !self.sams.iter().any(|s| s.id == job.unit && !s.alive()) + job.unit == 0 || self.known_live(job.unit) } /// The land this job's knowledge version knew, sampled on `grid` (NaN outside it). It reads the job's @@ -469,3 +649,362 @@ impl Sim { self.retargets.iter().find(|j| j.drone == id)?.relief.as_deref() } } + +/// One contact as the diagnostic reports it: the observation, its age and confidence, and the +/// threat the planner would price from it — the belief, not the truth (#127). +#[derive(Clone, Debug, PartialEq)] +pub struct ContactReport { + pub id: u32, + /// The type identified at the observation. + pub kind: SamKind, + /// Where it was last observed. + pub at: DVec3, + /// What the last observation showed: "launch", "emission" or "jamming". + pub seen: &'static str, + /// Ticks since the last observation. + pub age: u64, + pub confidence: Confidence, + /// Whether the picture counts it as a live unit (unexpired, not watched dead). + pub live: bool, + /// When its destruction was observed, if it was. + pub observed_destroyed: Option, + /// The belief the threat model prices by: that its own radar works. + pub own_sensor: bool, + /// Whether a live radar contact stands within data-link range of its observed place. + pub donor_in_link: bool, + /// What the planner makes of it, in words. + pub threat: &'static str, +} + +/// The enemy's whole picture at one tick: what it knows and what it assumes, structured for +/// diagnostics (#127). No truth anywhere in it — a unit the picture got wrong is reported wrong. +#[derive(Clone, Debug, PartialEq)] +pub struct EnemyPictureReport { + pub now: u64, + pub contacts: Vec, +} + +impl Sim { + /// The structured picture: observation, confidence and priced threat per contact. + pub fn enemy_picture(&self) -> EnemyPictureReport { + let now = self.tick; + let live: Vec = self.picture_of_defences(); + let contacts = self + .intel + .contacts + .values() + .map(|c| { + let donor = live.iter().any(|d| d.kind == SamKind::Radar && d.id != c.id && d.at.distance(c.at) <= sight::LINK); + ContactReport { + id: c.id, + kind: c.kind, + at: c.at, + seen: c.seen.name(), + age: c.age(now), + confidence: c.confidence(now), + live: c.live(now), + observed_destroyed: c.destroyed, + own_sensor: c.believes_own_sensor(now), + donor_in_link: donor, + threat: Self::threat_text(c, now, donor), + } + }) + .collect(); + EnemyPictureReport { now, contacts } + } + + /// What the planner makes of a contact, in words for the diagnostic (#127). + fn threat_text(c: &Contact, now: u64, donor: bool) -> &'static str { + if !c.live(now) { + return "not priced: forgotten with age or watched dead"; + } + let spec = c.kind.spec(); + if spec.missile.is_none() && spec.gun.is_none() { + return "not armed: a jamming or support station, priced only for satellite-nav drones"; + } + match (c.believes_own_sensor(now), donor) { + (true, true) => "missiles on its own sensor, reaching further on the linked radar; guns on its own sensor", + (true, false) => "missiles and guns on its own sensor", + (false, true) => "no own sensor believed: it fires on the linked radar's tracks", + (false, false) => "believed blind: no own sensor and no linked radar in reach", + } + } +} + +#[cfg(test)] +mod picture_tests { + use super::*; + use super::super::{battery::SamMode, raid::Grid}; + + fn grid() -> Grid { + Grid { min: DVec3::new(-480.0, 0.0, -480.0), cell: 16.0, size: 60 } + } + + /// A small force the picture knows about: an emitting patriot, a fixed radar, a jammer. + fn watched_force(sim: &mut Sim) -> Vec { + let mut ids = Vec::new(); + for (kind, at, seen) in [ + (SamKind::Patriot, DVec3::new(-120.0, 70.0, 0.0), Seen::Emission), + (SamKind::Radar, DVec3::new(0.0, 70.0, 0.0), Seen::Emission), + (SamKind::Ew, DVec3::new(120.0, 70.0, 0.0), Seen::Jamming), + ] { + let id = sim.id(); + let sam = Sam::new(id, kind, at, 0.0); + sim.intel.observe(&sam, 0, seen); + sim.sams.push(sam); + ids.push(id); + } + ids + } + + // #127: what a unit does while unobserved — moves, switches off, re-roles, re-modes, spends + // its magazine, takes damage, dies — changes neither the picture nor anything priced or + // decided from it. Only a new observation can. + #[test] + fn hidden_changes_do_not_move_the_picture_or_its_decisions() { + let mut base = Sim::new(17); + let ids = watched_force(&mut base); + let mut changed = Sim::new(17); + let _ = watched_force(&mut changed); + base.tick = 100; + changed.tick = 100; + let grid = grid(); + let snapshot = |sim: &Sim| { + let (prices, cost) = sim.price_grid(&grid, DroneKind::Kh101); + (sim.picture_of_defences(), prices, cost, sim.enemy_picture()) + }; + let before = snapshot(&base); + // Every hidden change at once: moves, silence, roles emptied, launcher-only mode, + // an empty magazine, damage, and one unit destroyed where nothing could watch. + changed.sams[0].pos = DVec3::new(-500.0, 70.0, 400.0); + changed.sams[1].radar_on = false; + changed.sams[1].radar_role = super::super::battery::RadarRole::Search; + changed.sams[2].roles = 0; + changed.sams[0].set_mode(SamMode::LauncherOnly); + changed.sams[0].missiles = 0; + changed.sams[0].health = 0.3; + changed.sams[1].health = 0.0; + let after = snapshot(&changed); + assert_eq!(before, after, "a hidden change moved the enemy's picture"); + // The adaptive tick decides the same aims on both. + for (sim, id) in [(&mut base, ids[0]), (&mut changed, ids[0])] { + let _ = id; + let from = DVec3::new(0.0, 120.0, -900.0); + let mut d = sim.make_drone(DroneKind::Kh22, from, DVec3::Z, DVec3::new(0.0, 70.0, 900.0)); + d.smart = true; + sim.queue.push((0, d)); + sim.adapt(); + } + let aims = |sim: &Sim| sim.queue.iter().map(|(_, d)| d.target).collect::>(); + assert_eq!(aims(&base), aims(&changed), "the same picture chose different aims"); + assert_eq!(base.retarget_ids(), changed.retarget_ids()); + } + + // #127: a new disclosure is what updates the picture. The moved patriot is re-fixed at its + // new place: the picture, the prices and the adaptive aims follow the observation. + #[test] + fn a_new_observation_is_what_updates_the_picture() { + let mut sim = Sim::new(19); + let ids = watched_force(&mut sim); + sim.tick = 100; + let grid = grid(); + let (before, _) = sim.price_grid(&grid, DroneKind::Kh101); + // A hidden move changes nothing... + sim.sams[0].pos = DVec3::new(-120.0, 70.0, 360.0); + let (mid, _) = sim.price_grid(&grid, DroneKind::Kh101); + assert_eq!(before, mid, "a hidden move changed the prices"); + // ...until something discloses the unit again. + sim.intel.observe(&sim.sams[0], 200, Seen::Emission); + let picture = sim.picture_of_defences(); + let c = picture.iter().find(|c| c.id == ids[0]).unwrap(); + assert_eq!(c.at, DVec3::new(-120.0, 70.0, 360.0), "the new observation is not the one kept"); + assert_eq!(c.tick, 200); + let (after, _) = sim.price_grid(&grid, DroneKind::Kh101); + assert_ne!(before, after, "the prices never noticed the re-observation"); + } + + // #127: a launcher-only S400 seen firing without its emissions ever fixed fights on the + // network in the picture: blind without a donor, dangerous out to the donor's reach with one. + #[test] + fn launcher_only_is_blind_in_the_picture_without_a_donor() { + let mut sim = Sim::new(5); + let id = sim.id(); + let mut sam = Sam::new(id, SamKind::S400, DVec3::new(0.0, 70.0, 0.0), 0.0); + assert!(sam.set_mode(SamMode::LauncherOnly)); + sim.intel.observe(&sam, 0, Seen::Launch); + sim.sams.push(sam); + let grid = grid(); + let (prices, _) = sim.price_grid(&grid, DroneKind::Kh101); + assert!(prices.iter().all(|p| *p == 1.0), "a launcher believed blind priced a threat"); + let report = sim.enemy_picture(); + let c = report.contacts.iter().find(|c| c.id == id).unwrap(); + assert!(!c.own_sensor && !c.donor_in_link, "the report disagrees with the blindness"); + + // A radar contact within data-link range of the observed place feeds it. The radar's + // actual role is not observable: the picture assumes any live radar contact feeds the + // network, and the report says so. + let id = sim.id(); + let mut radar = Sam::new(id, SamKind::Radar, DVec3::new(300.0, 70.0, 0.0), 0.0); + radar.set_radar_role(super::super::battery::RadarRole::Search); // truth: cannot authorize + sim.intel.observe(&radar, 0, Seen::Emission); + sim.sams.push(radar); + let (prices, _) = sim.price_grid(&grid, DroneKind::Kh101); + assert!(prices.iter().any(|p| *p > 1.0), "a launcher with a linked radar priced no threat"); + let report = sim.enemy_picture(); + let c = report.contacts.iter().find(|c| c.kind == SamKind::S400).unwrap(); + assert!(c.donor_in_link && !c.own_sensor, "the report does not name the network"); + + // The link breaking unobserved changes nothing in the picture: the radar's death is a + // secret until something watches it. Watched, the launcher goes blind again. + sim.sams.iter_mut().find(|s| s.kind == SamKind::Radar).unwrap().health = 0.0; + let (prices, _) = sim.price_grid(&grid, DroneKind::Kh101); + assert!(prices.iter().any(|p| *p > 1.0), "an unobserved link break changed the picture"); + let radar_id = sim.sams.iter().find(|s| s.kind == SamKind::Radar).unwrap().id; + sim.intel.outcome(radar_id, false, DVec3::new(300.0, 70.0, 0.0), 50); + let (prices, _) = sim.price_grid(&grid, DroneKind::Kh101); + assert!(prices.iter().all(|p| *p == 1.0), "the watched donor died and the launcher stayed dangerous"); + } + + // #127: destruction is knowledge only when watched. A unit dying unseen keeps living in the + // picture and in the prices; the enemy's own munition watching the kill takes it out, and a + // fresh look at a live unit (a repair) brings it back. + #[test] + fn destruction_moves_the_picture_only_when_watched() { + let mut sim = Sim::new(23); + let ids = watched_force(&mut sim); + sim.tick = 100; + let grid = grid(); + // Killed where nothing of the enemy's is watching: the picture never learns. + sim.sams[0].health = 0.0; + assert!(sim.picture_of_defences().iter().any(|c| c.id == ids[0]), "an unobserved kill removed the contact"); + let (prices, _) = sim.price_grid(&grid, DroneKind::Kh101); + assert!(prices.iter().any(|p| *p > 1.0), "an unobserved kill changed the prices"); + // The wreck watched by a strike: out of the picture and the prices... + sim.intel.outcome(ids[0], false, sim.sams[0].pos, 150); + assert!(!sim.known_live(ids[0])); + assert!(sim.picture_of_defences().iter().all(|c| c.id != ids[0])); + let (prices, _) = sim.price_grid(&grid, DroneKind::Kh101); + // The same force without the patriot: the watched death prices exactly as if it had + // never been seen (the jammer's cells stay: the jammer lives in the picture). + let mut without = Sim::new(23); + let kept = watched_force(&mut without); + without.intel.contacts.remove(&kept[0]); + let (expect, _) = without.price_grid(&grid, DroneKind::Kh101); + assert_eq!(prices, expect, "the watched-dead unit was still priced"); + // ...and a fresh look at the rebuilt unit restores it. + sim.sams[0].health = 1.0; + sim.intel.observe(&sim.sams[0], 200, Seen::Emission); + assert!(sim.known_live(ids[0]) && sim.picture_of_defences().iter().find(|c| c.id == ids[0]).unwrap().tick == 200); + } + + // #127: confidence is age alone. The bands gate what SEAD trusts (checked in raid.rs); + // here the picture and the report carry it. + #[test] + fn confidence_is_a_function_of_age_alone() { + let mut sim = Sim::new(29); + let ids = watched_force(&mut sim); + for (tick, want) in [(0, Confidence::Confirmed), (600, Confidence::Confirmed), (601, Confidence::Probable), (3000, Confidence::Probable), (3001, Confidence::Doubtful)] { + sim.tick = tick; + let c = sim.picture_of_defences().iter().find(|c| c.id == ids[0]).unwrap().confidence(tick); + assert_eq!(c, want, "tick {tick}"); + let report = sim.enemy_picture(); + let c = report.contacts.iter().find(|c| c.id == ids[0]).unwrap(); + assert_eq!(c.confidence, want); + assert_eq!(c.age, tick); + } + sim.tick = ew::CONTACT_TTL - 1; + assert!(sim.known_live(ids[0]), "a contact dies before its TTL"); + sim.tick = ew::CONTACT_TTL; + assert!(!sim.known_live(ids[0]), "a contact outlived its TTL"); + sim.tick = ew::CONTACT_TTL + 2; + sim.adapt(); + assert!(sim.picture_of_defences().is_empty(), "expired contacts were not pruned"); + } + + // #127: the structured report separates what was seen from what is believed, in English. + #[test] + fn enemy_picture_report_describes_observation_and_belief() { + let mut sim = Sim::new(31); + let ids = watched_force(&mut sim); + let id = sim.id(); + let mut launcher = Sam::new(id, SamKind::S400, DVec3::new(0.0, 70.0, -3000.0), 0.0); + launcher.set_mode(SamMode::LauncherOnly); + sim.intel.observe(&launcher, 0, Seen::Launch); + sim.sams.push(launcher); + sim.tick = 40; + let report = sim.enemy_picture(); + assert_eq!(report.contacts.len(), 4); + let by_id = |report: &EnemyPictureReport, id: u32| report.contacts.iter().find(|c| c.id == id).unwrap().clone(); + let ew_c = by_id(&report, ids[2]); + assert_eq!(ew_c.seen, "jamming"); + assert_eq!(ew_c.confidence, Confidence::Confirmed); + assert!(ew_c.live && ew_c.threat.contains("jamming"), "{}", ew_c.threat); + let sam_c = by_id(&report, id); + assert_eq!(sam_c.seen, "launch"); + assert!(!sam_c.own_sensor, "a launch without emission fixes read as an own sensor"); + assert!(sam_c.threat.contains("blind"), "{}", sam_c.threat); + let pat = by_id(&report, ids[0]); + assert!(pat.own_sensor && pat.seen == "emission"); + assert!(pat.donor_in_link, "the radar contact should read as the patriot's network"); + } +} + +#[cfg(test)] +mod outcome_tests { + use super::*; + + // #127: the battle-damage channel end to end. A drone the picture knows the target of flies + // to a unit that cannot shoot back (a radar); its impact watches the unit die, and the + // picture — not the truth — is what learns it. An identical loss nobody watches stays a + // live contact. + #[test] + fn an_impact_watches_the_kill_and_updates_only_the_picture() { + let mut sim = Sim::new(37); + let at = DVec3::new(0.0, 70.0, 0.0); + let id = sim.id(); + let sam = Sam::new(id, SamKind::Radar, at, 0.0); + sim.intel.observe(&sam, 0, Seen::Launch); + sim.sams.push(sam); + // Low and level, so the impact sweeps the hull instead of diving into the ground early. + sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 71.0, -200.0), DVec3::Z, at); + let mut ground = Ground::default(); + let mut out = Out::default(); + let mut seen_dead = None; + for now in 1..1200 { + sim.tick = now; + sim.step(&mut ground, None, &mut out); + seen_dead = sim.intel.contacts.get(&id).and_then(|c| c.destroyed); + if seen_dead.is_some() && !sim.drones.iter().any(|d| d.alive()) { + break; + } + } + let contact = sim.intel.contacts.get(&id).expect("the contact is forgotten while fresh"); + assert!(!sim.sams[0].alive(), "the setup: the impact must kill the unit"); + assert_eq!(seen_dead, contact.destroyed); + assert!(contact.destroyed.is_some(), "the impact watched the kill and the picture learned nothing"); + assert!(!sim.known_live(id)); + assert_eq!(sim.picture_of_defences(), Vec::new(), "a watched-dead unit stayed a target"); + + // The same loss, unwatched: the drone dies far from the unit and the contact lives on. + let mut other = Sim::new(37); + let id2 = other.id(); + let sam = Sam::new(id2, SamKind::Radar, at, 0.0); + other.intel.observe(&sam, 0, Seen::Launch); + other.sams.push(sam); + other.add_drone(DroneKind::Shahed, DVec3::new(0.0, 80.0, -600.0), DVec3::Z, at); + // The drone is shot down far short of the aim: nothing watches the unit's fate. + other.drones[0].pos = DVec3::new(0.0, 80.0, -400.0); + let _ = other.shoot_down(0, 0, None, &Ground::default(), &mut Out::default()); + let mut ground = Ground::default(); + let mut out = Out::default(); + for now in 1..1200 { + other.tick = now; + other.step(&mut ground, None, &mut out); + if !other.drones.iter().any(|d| d.alive()) { + break; + } + } + assert!(other.intel.contacts.get(&id2).unwrap().destroyed.is_none(), "an unwatched fate changed the picture"); + assert!(other.known_live(id2)); + } +} \ No newline at end of file diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index 565b1d7..adc87e4 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -25,6 +25,7 @@ pub mod record; mod sight; mod terrain; mod ew; +pub use ew::{Confidence, ContactReport, EnemyPictureReport}; pub mod lights; mod logistics; mod orders; @@ -1932,7 +1933,7 @@ impl Sim { }; self.emit_combat_event(pos, record::EventKind::Crash { drone, kind, power }); } - for (drone, kind, pos, tnt) in impacts { + for &(drone, kind, pos, tnt) in &impacts { if let Some(sector) = self.drones.iter().find(|x| x.id == drone).map(|x| x.sector as usize) { self.learn(sector, false); } @@ -1949,6 +1950,10 @@ impl Sim { let vel = self.drones.iter().find(|x| x.id == drone).map_or(DVec3::ZERO, |x| x.vel); self.direct_hit(pos, vel, tnt, out); } + // Battle damage assessment: a munition going down watches the fate of whatever it was + // sent at. Truth is read at this instant only — the one honest moment — and written + // into the picture as an observation; a unit missed by everything keeps its secret. + self.observe_outcomes(&impacts); for (pos, warhead, small) in boom { self.explode(pos, warhead, small, ground, player, out); } @@ -2020,6 +2025,18 @@ impl Sim { } } + /// What the enemy's munitions just watched (#127): for every impact, the fate of each known + /// unit near it — dead or alive as the explosion showed, at the place it showed. Units the + /// blasts missed keep their contacts exactly as they were: unobserved is unobserved. + fn observe_outcomes(&mut self, impacts: &[(u32, DroneKind, DVec3, u32)]) { + let now = self.tick; + for &(_, _, pos, _) in impacts { + for s in self.sams.iter().filter(|s| s.pos.distance(pos) < 16.0) { + self.intel.outcome(s.id, s.alive(), s.pos, now); + } + } + } + /// A blast: a fireball, smoke, sound, a jolt for a near player, and (with /// `damage`) primed TNT for the land. fn explode(&mut self, pos: DVec3, tnt: u32, small: bool, ground: &mut Ground, player: Option, out: &mut Out) { @@ -2560,9 +2577,9 @@ impl Sim { t.ammo_waits += ammo_waits; }); for (kind, muzzle, dir, target, sam) in launches { - // A launch gives the system away. + // A launch gives the system away: the enemy saw it fire, no more than that. if let Some(s) = self.sams.iter().find(|s| s.id == sam) { - self.intel.revealed.push((sam, s.pos, self.tick)); + self.intel.observe(s, self.tick, ew::Seen::Launch); } // It leaves with the track the system fired on (#66). let fix = self diff --git a/airdef-mod/src/sim/raid.rs b/airdef-mod/src/sim/raid.rs index 35acd1b..313a01b 100644 --- a/airdef-mod/src/sim/raid.rs +++ b/airdef-mod/src/sim/raid.rs @@ -275,9 +275,9 @@ impl Sim { } let smart = order.aim == Aim::Smart; // The planner's picture of the layout: one snapshot for the whole order. It is - // read-only while the plan is built, and recomputing it per drone made a big - // second order quadratic in the intel log and the force's size (#122). - let known = self.known_units(); + // read-only while the plan is built, and taking it per drone made a big second order + // quadratic in the force's size (#122); a map of contacts makes it linear in them (#127). + let picture = self.picture_of_defences(); // A smart enemy comes from where its raids got through best. let bearing = if smart { self.best_bearing() } else { self.rng.f() * TAU }; // The raid's sides (1, 2, or 4-5): its launch sites go there (#100). @@ -290,7 +290,7 @@ impl Sim { let grid = Grid { min: centre - DVec3::new(span, 0.0, span), cell, size: (2.0 * span / cell) as i32 + 1 }; let mut items = Vec::new(); // A smart raid goes for the radars too (a few strikers each); the others pick as they always did. - let radar_jobs = if smart { self.radar_strikes(&order.kinds, &known) } else { Vec::new() }; + let radar_jobs = if smart { self.radar_strikes(&order.kinds, &picture) } else { Vec::new() }; let mut index = 0usize; for (group, &(kind, count)) in order.kinds.iter().enumerate() { let spec = kind.spec(); @@ -306,7 +306,7 @@ impl Sim { index += 1; let (target, at_sam) = match job { Some((at, id)) => (at, Some(id)), - None => self.choose_target(kind, object, order.aim, &known), + None => self.choose_target(kind, object, order.aim, &picture), }; let aim = target + DVec3::new(self.rng.s() * 4.0, 0.0, self.rng.s() * 4.0); let dist = spec.ballistic.map_or(order.dist, |far| far.max(order.dist)) + self.rng.f() * 50.0; @@ -547,9 +547,12 @@ impl Sim { /// The strikes a smart raid sends at observed radars: for drones of `kinds` in order, /// the last observed place and id for those that go for one. Each radar /// gets two or three, the fast strikers first, and all together no more than about a quarter of the raid. - pub(super) fn radar_strikes(&self, kinds: &[(DroneKind, u32)], known: &[(u32, DVec3)]) -> Vec> { - let mut radars: Vec<(u32, DVec3)> = known.iter().copied() - .filter(|(id, _)| self.sams.iter().any(|s| s.id == *id && s.alive() && s.kind == SamKind::Radar)) + /// Only contacts the picture still trusts: a doubtful one (aged, never re-seen) is not worth a missile. + pub(super) fn radar_strikes(&self, kinds: &[(DroneKind, u32)], picture: &[ew::Contact]) -> Vec> { + let mut radars: Vec<(u32, DVec3)> = picture + .iter() + .filter(|c| c.kind == SamKind::Radar && c.confidence(self.tick) >= ew::Confidence::Probable) + .map(|c| (c.id, c.at)) .collect(); radars.sort_by_key(|(id, _)| *id); let flat: Vec = kinds.iter().flat_map(|&(k, n)| std::iter::repeat_n(k, n as usize)).collect(); @@ -571,48 +574,40 @@ impl Sim { jobs } - /// What a drone goes for: an object, or (by its kind and the raid's aim) a system. Returns the - /// point and the system's id. - fn choose_target(&mut self, kind: DroneKind, object: DVec3, aim: Aim, known: &[(u32, DVec3)]) -> (DVec3, Option) { - let alive: Vec<&Sam> = self.sams.iter().filter(|s| s.alive()).collect(); - if alive.is_empty() || kind.spec().decoy || aim == Aim::Objects { + /// What a drone goes for: an object, or (by its kind and the raid's aim) a unit. Returns the + /// point and the unit's id. The planner has only the picture (#93, #127): what it knows of a + /// unit is the last observation — the place seen then, the type identified then — never where + /// the unit actually is or what it has become since. + fn choose_target(&mut self, kind: DroneKind, object: DVec3, aim: Aim, picture: &[ew::Contact]) -> (DVec3, Option) { + if picture.is_empty() || kind.spec().decoy || aim == Aim::Objects { return (object, None); } - let pick = |list: &[&Sam], r: f64| -> Option<(DVec3, u32)> { - (!list.is_empty()).then(|| { - let s = list[(r * list.len() as f64) as usize % list.len()]; - (s.pos, s.id) - }) + let pick = |list: &[ew::Contact], r: f64| -> Option<(DVec3, u32)> { + let c = list.get((r * list.len() as f64) as usize)?; + Some((c.at, c.id)) }; let r = self.rng.f(); let roll = self.rng.f(); if aim == Aim::ObjectsAndSams { - return if roll < 0.33 { pick(&alive, r).map_or((object, None), |(p, id)| (p, Some(id))) } else { (object, None) }; + return if roll < 0.33 { pick(picture, r).map_or((object, None), |(p, id)| (p, Some(id))) } else { (object, None) }; } // Smart: each kind at what it is good against; what gave itself away first (a jamming // station, a radar, a system that fired). - // The planner has only what the enemy knows (#93): the radars (they emit) and the units that gave - // themselves away; the rest of the player's layout is not in its hands. - let alive: Vec<&Sam> = alive.iter().copied().filter(|s| Self::enemy_knows(s, known)).collect(); - let pick_known = |list: &[&Sam], r: f64| -> Option<(DVec3, u32)> { - let s = *list.get((r * list.len() as f64) as usize)?; - known.iter().find(|(id, _)| *id == s.id).map(|(_, pos)| (*pos, s.id)) - }; - let known_ew: Vec<&Sam> = alive.iter().copied().filter(|s| s.kind == SamKind::Ew).collect(); + let known_ew: Vec = picture.iter().copied().filter(|c| c.kind == SamKind::Ew).collect(); if matches!(kind, DroneKind::Lancet | DroneKind::Kh22) && !known_ew.is_empty() && roll < 0.6 { - let s = known_ew[(r * known_ew.len() as f64) as usize % known_ew.len()]; - return (known.iter().find(|(id, _)| *id == s.id).unwrap().1, Some(s.id)); + let c = known_ew[(r * known_ew.len() as f64) as usize % known_ew.len()]; + return (c.at, Some(c.id)); } // Short-range systems (guns, Tor, P1-SUN) for the Lancets; long-range ones and the radars // that feed them for the fast missiles. - let guns: Vec<&Sam> = alive.iter().copied().filter(|s| s.kind.armed() && s.kind.range() <= 300.0).collect(); - let long: Vec<&Sam> = - alive.iter().copied().filter(|s| s.kind.range() >= 450.0 || s.kind == SamKind::Radar).collect(); + let guns: Vec = picture.iter().copied().filter(|c| c.kind.armed() && c.kind.range() <= 300.0).collect(); + let long: Vec = + picture.iter().copied().filter(|c| c.kind.range() >= 450.0 || c.kind == SamKind::Radar).collect(); let choice = match kind { - DroneKind::Lancet if roll < 0.75 => pick_known(&guns, r).or_else(|| pick_known(&alive, r)), - DroneKind::Kh22 | DroneKind::Zircon if roll < 0.45 => pick_known(&long, r), - DroneKind::Iskander | DroneKind::Kinzhal if roll < 0.3 => pick_known(&long, r), - DroneKind::Shahed | DroneKind::Geran3 if roll < 0.2 => pick_known(&alive, r), + DroneKind::Lancet if roll < 0.75 => pick(&guns, r).or_else(|| pick(picture, r)), + DroneKind::Kh22 | DroneKind::Zircon if roll < 0.45 => pick(&long, r), + DroneKind::Iskander | DroneKind::Kinzhal if roll < 0.3 => pick(&long, r), + DroneKind::Shahed | DroneKind::Geran3 if roll < 0.2 => pick(picture, r), _ => None, }; choice.map_or((object, None), |(p, id)| (p, Some(id))) @@ -625,52 +620,54 @@ impl Sim { self.price_grid_on(grid, kind, None) } - /// The same over known land: a cell's drone flies `height` over the land there, not over the system's feet, - /// so what a radar sees and what a hill hides follow the relief. + /// The same over the picture this moment holds: a smart plan's own prices are priced on the + /// picture the plan was ordered with (#127). Later observations change nothing here. pub(super) fn price_grid_on(&self, grid: &Grid, kind: DroneKind, land: Option<&[f32]>) -> (Vec, usize) { - let known_now = self.known_units(); - self.price_grid_known(grid, kind, land, &known_now) + self.price_grid_known(grid, kind, land, &self.picture_of_defences()) } - /// The same over a picture of the enemy: a retarget is priced on the picture its target was chosen - /// from, so the jobs of one adaptive tick do not each see a different world (#127). - pub(super) fn price_grid_known(&self, grid: &Grid, kind: DroneKind, land: Option<&[f32]>, known_now: &[(u32, DVec3)]) -> (Vec, usize) { + /// The same over a picture of the defences: a retarget is priced on the picture its target was + /// chosen from, so the jobs of one adaptive tick do not each see a different world (#127). Every + /// capability read here is the contact's published kind and the contact's own observations — + /// never the unit's actual switch settings, damage, roles, link health or magazine. + pub(super) fn price_grid_known(&self, grid: &Grid, kind: DroneKind, land: Option<&[f32]>, picture: &[ew::Contact]) -> (Vec, usize) { let mut cost = 0; let spec = kind.spec(); let height = spec.clearance.max(6.0); let n = grid.size; + let now = self.tick; let mut prices = vec![1.0f32; (n * n) as usize]; // Known jamming stations: drones that navigate by satellite keep away. if kind.jammable() { - for s in self.sams.iter().filter(|s| s.kind == SamKind::Ew && s.alive() && known_now.iter().any(|k| k.0 == s.id)) { - let pos = known_now.iter().find(|(id, _)| *id == s.id).unwrap().1; - let (ci, cj) = grid.index(pos); - let r = (EW_RADIUS / grid.cell).ceil() as i32; + for c in picture.iter().filter(|c| c.kind == SamKind::Ew) { + let (ci, cj) = grid.index(c.at); + let r = (ew::EW_RADIUS / grid.cell).ceil() as i32; for i in (ci - r).max(0)..=(ci + r).min(n - 1) { for j in (cj - r).max(0)..=(cj + r).min(n - 1) { - if horizontal(grid.centre((i, j), pos.y) - pos).length() < EW_RADIUS { + if horizontal(grid.centre((i, j), c.at.y) - c.at).length() < ew::EW_RADIUS { prices[(i * n + j) as usize] += 12.0; } } } } } - // Only the systems the enemy knows of are priced in (#93): a hidden one is flown into. - for s in self.sams.iter().filter(|s| s.alive() && s.roles & spec.class.bit() != 0 && Self::enemy_knows(s, &known_now)) { - let pos = known_now.iter().find(|(id, _)| *id == s.id).unwrap().1; - let sp = s.kind.spec(); + // Only the systems the picture holds are priced in (#93): a hidden one is flown into. + for c in picture.iter().filter(|c| c.kind.spec().roles & spec.class.bit() != 0) { + let pos = c.at; + let sp = c.kind.spec(); let (lo, hi) = sp.missile.as_ref().map_or((0.0, 0.0), |m| (m.min, m.max)); let gun = sp.gun.as_ref().map_or(0.0, |g| g.range); - // A gun needs its own radar; a missile also flies on the tracks of any radar within data-link range. - let own = sight::reach(s, kind, height); - let net = self.sams.iter().filter_map(|r| { - let at = known_now.iter().find(|(id, _)| *id == r.id)?.1; - (r.id != s.id && at.distance(pos) <= sight::LINK).then(|| sight::reach(r, kind, height)) - }).fold(0.0, f64::max); + // A gun needs its own radar; a missile also flies on the tracks of any radar the picture + // holds within data-link range of the unit's observed place. What those radars are set to + // (role, switch, damage) is not observable: a live contact of one is assumed to feed. + let own = if c.believes_own_sensor(now) { ew::published_reach(c.kind, kind, height) } else { 0.0 }; + let net = picture + .iter() + .filter(|d| d.kind == SamKind::Radar && d.id != c.id && d.at.distance(pos) <= sight::LINK) + .map(|d| ew::published_reach(d.kind, kind, height)) + .fold(0.0, f64::max); let (hi, gun) = (hi.min(own.max(net)), gun.min(own)); let far = hi.max(gun); - let at = pos + (sight::antenna(s) - s.pos); - let horizon = (pos == s.pos).then(|| self.horizons.get(&s.id)).flatten(); // Only the cells round it. let (ci, cj) = grid.index(pos); let r = (far / grid.cell).ceil() as i32 + 1; @@ -687,9 +684,6 @@ impl Sim { if !(missile || d < gun) { continue; } - if horizon.is_some_and(|h| h.hides(at, p)) { - continue; - } prices[(i * n + j) as usize] += if missile { 30.0 } else { 18.0 }; } } @@ -985,23 +979,27 @@ mod tests { fn sead_uses_only_unexpired_observations_at_the_last_known_position() { let mut sim = Sim::new(121); let old = DVec3::new(100.0, 70.0, 200.0); - let mut radar = Sam::new(1, SamKind::Radar, old, 0.0); - radar.radar_on = false; + let radar = Sam::new(1, SamKind::Radar, old, 0.0); sim.sams.push(radar); let kinds = [(DroneKind::Kh22, 8)]; let none = Vec::new(); assert!(sim.radar_strikes(&kinds, &none).iter().all(Option::is_none)); sim.tick = 20; - sim.intel.revealed.push((1, old, sim.tick)); + sim.intel.observe(&sim.sams[0], sim.tick, ew::Seen::Emission); + // The truth moving after the fix is not an observation: the strike goes to the seen place. sim.sams[0].pos = DVec3::new(900.0, 70.0, 900.0); sim.tick = 40; - let known = sim.known_units(); - let jobs = sim.radar_strikes(&kinds, &known); + let picture = sim.picture_of_defences(); + let jobs = sim.radar_strikes(&kinds, &picture); assert_eq!(jobs.iter().flatten().count(), 2); assert!(jobs.iter().flatten().all(|job| *job == (old, 1))); + // An old contact is not worth a strike either. + sim.tick = 3021; + let picture = sim.picture_of_defences(); + assert!(sim.radar_strikes(&kinds, &picture).iter().all(Option::is_none), "a doubtful contact was struck"); sim.tick = 6020; - let known = sim.known_units(); - assert!(sim.radar_strikes(&kinds, &known).iter().all(Option::is_none)); + let picture = sim.picture_of_defences(); + assert!(sim.radar_strikes(&kinds, &picture).iter().all(Option::is_none)); } #[test] @@ -1011,9 +1009,10 @@ mod tests { for (i, at) in [(-300.0, -200.0), (250.0, -250.0), (50.0, 300.0)].into_iter().enumerate() { let id = sim.id(); let p = DVec3::new(at.0, 70.0, at.1); - sim.sams.push(Sam::new(id, SamKind::Radar, p, i as f64)); + let sam = Sam::new(id, SamKind::Radar, p, i as f64); + sim.intel.observe(&sam, sim.tick, ew::Seen::Emission); + sim.sams.push(sam); radars.push((id, p)); - sim.intel.revealed.push((id, p, sim.tick)); } let ew = sim.id(); sim.sams.push(Sam::new(ew, SamKind::Ew, DVec3::new(0.0, 70.0, 0.0), 0.0)); @@ -1049,7 +1048,7 @@ mod tests { // The first strikers are the fast ones. assert!(radars.iter().any(|(_, p)| aimed_at(&sim, *p).iter().any(|k| matches!(k, DroneKind::Kh22 | DroneKind::Zircon | DroneKind::Iskander | DroneKind::Kinzhal)))); // The enemy has learnt of the jamming station and re-aims what it can: not these. - sim.intel.revealed.push((ew, DVec3::new(0.0, 70.0, 0.0), sim.tick)); + sim.intel.observe(&sim.sams.iter().find(|s| s.id == ew).unwrap(), sim.tick, ew::Seen::Jamming); for _ in 0..160 { serve(&mut ground, &land, sim.tick); sim.step(&mut ground, None, &mut out); @@ -1057,50 +1056,45 @@ mod tests { check(&sim, "after adapting"); } - /// The snapshot the planner works from must not depend on how it is computed: - /// the set-based pass is checked here against the former per-entry rescan, over - /// duplicates, the dead, the aged-out, ids the force never had, and ordering. + /// The picture the planner works from: one contact per unit, the newest observation + /// winning, the forgotten and the watched-dead out, the order deterministic — and it + /// never reads the force itself, so a contact of a unit that no longer exists stays + /// until its observations age out. #[test] - fn known_units_matches_the_naive_reference_on_a_fat_log() { + fn picture_is_newest_per_unit_and_reads_no_truth() { let mut sim = Sim::new(9); - let mut places = Vec::new(); + let mut ids = Vec::new(); for (i, kind) in [SamKind::S400, SamKind::Ew, SamKind::Radar, SamKind::Tor, SamKind::Patriot].into_iter().enumerate() { let id = sim.id(); - let mut sam = Sam::new(id, kind, DVec3::new(i as f64 * 100.0, 70.0, -50.0), 0.0); - // The second system is dead: known only while it lived. - if i == 1 { sam.health = 0.0; } - sim.sams.push(sam); - places.push((id, DVec3::new(i as f64 * 100.0, 70.0, -50.0))); + sim.sams.push(Sam::new(id, kind, DVec3::new(i as f64 * 100.0, 70.0, -50.0), 0.0)); + ids.push(id); } - let naive = |sim: &Sim| { - let mut out: Vec<(u32, DVec3)> = Vec::new(); - for &(id, at, observed) in sim.intel.revealed.iter().rev() { - if sim.tick.saturating_sub(observed) >= 6000 { continue; } - if !out.iter().any(|o| o.0 == id) && sim.sams.iter().any(|s| s.id == id && s.alive()) { - out.push((id, at)); - } - } - out - }; sim.tick = 7000; - let dead = places[1].0; - let (a, b, c, d, e) = (places[0].0, places[1].0, places[2].0, places[3].0, places[4].0); - // A long log: repeated observations (newest wins), one dead system, one never-seen - // system, one id the force does not have, and an entry that aged out. + let (a, b, c, d, e) = (ids[0], ids[1], ids[2], ids[3], ids[4]); + // A long fight: repeated observations (newest wins), one unit watched dying, one + // never seen, one id the force does not have, and one observation that aged out. for t in 0..200u64 { - sim.intel.revealed.push((a, DVec3::new(11.0, 70.0, t as f64), 1000 + t)); - sim.intel.revealed.push((c, DVec3::new(13.0, 70.0, t as f64), 1000 + t)); - if t % 7 == 0 { sim.intel.revealed.push((b, DVec3::new(12.0, 70.0, t as f64), 1000 + t)); } - if t % 11 == 0 { sim.intel.revealed.push((d, DVec3::new(14.0, 70.0, t as f64), 1000 + t)); } - if t % 13 == 0 { sim.intel.revealed.push((999_999, DVec3::new(1.0, 70.0, 0.0), 1000 + t)); } + sim.intel.observe(&sim.sams[0], 1000 + t, ew::Seen::Launch); + sim.intel.observe(&sim.sams[2], 1000 + t, ew::Seen::Emission); + if t % 7 == 0 { sim.intel.observe(&sim.sams[1], 1000 + t, ew::Seen::Jamming); } + if t % 11 == 0 { sim.intel.observe(&sim.sams[3], 1000 + t, ew::Seen::Launch); } + if t % 13 == 0 { + sim.intel.note(999_999, SamKind::Radar, DVec3::new(1.0, 70.0, 0.0), true, 1000 + t, ew::Seen::Emission); + } } - sim.intel.revealed.push((e, DVec3::new(15.0, 70.0, 0.0), 2)); // aged out at tick 7000 - let expected = naive(&sim); - assert_eq!(sim.known_units(), expected); - assert_eq!(expected.iter().map(|k| k.0).collect::>(), vec![c, a, d]); - assert!(expected.iter().all(|k| k.0 != dead && k.0 != e && k.0 != 999_999)); - // The newest observation's place is the one kept. - assert_eq!(expected.iter().find(|k| k.0 == a).unwrap().1, DVec3::new(11.0, 70.0, 199.0)); + sim.intel.note(e, SamKind::Patriot, DVec3::new(15.0, 70.0, 0.0), true, 2, ew::Seen::Launch); // aged out at tick 7000 + // The EW station is watched dying by a strike: observed, and so out of the targets. + sim.intel.outcome(b, false, sim.sams[1].pos, 1349); + let picture = sim.picture_of_defences(); + let got: Vec = picture.iter().map(|c| c.id).collect(); + // a and c were both seen last at tick 1199 (tie: the newer id first); d at 1198; the + // fake at 1195. The watched-dead b, the never-seen e and the aged-out are out. + assert_eq!(got, vec![c, a, d, 999_999]); + assert_eq!(picture[0].at, DVec3::new(200.0, 70.0, -50.0), "the observed place is the unit's own"); + assert_eq!(picture[0].seen, ew::Seen::Emission); + assert_eq!(picture[1].kind, SamKind::S400); + assert!(picture.iter().all(|c| c.id != b && c.id != e)); + assert_eq!(sim.picture_of_defences(), picture, "the picture is not deterministic"); } /// The reported Windows stop (#122): the second big order from the UI path, with a fat @@ -1116,11 +1110,12 @@ mod tests { sim.sams.push(Sam::new(id, [SamKind::S400, SamKind::Patriot, SamKind::Nasams, SamKind::Pantsir][i % 4], DVec3::new((i % 8 * 40) as f64, 70.0, (i / 8 * 40) as f64), 0.0)); ids.push(id); } - // A fought-off first raid leaves a long log: launches, emitting radars, stations. - // Its entries sit in the past of the sim clock, like real ones. + // A fought-off first raid leaves a long observation history: launches, emitting + // radars, stations. Its entries sit in the past of the sim clock, like real ones. sim.tick = 4000; for t in 0..3000u64 { - sim.intel.revealed.push((ids[(t as usize) % ids.len()], DVec3::new(10.0, 70.0, t as f64), 1001 + t)); + let sam = &sim.sams[(t as usize) % ids.len()]; + sim.intel.observe(sam, 1001 + t, if t % 3 == 0 { ew::Seen::Emission } else { ew::Seen::Launch }); } sim.targets.push(DVec3::new(160.0, 70.0, 160.0)); sim.target_health.push(100.0); diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index 8672a87..49ce58e 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -54,9 +54,9 @@ fn network_launcher_must_contribute_to_route_risk() { // The planner prices only observed units; neither unit is known initially. let (prices,_)=sim.price_grid(&grid,DroneKind::Shahed); assert!(prices.iter().all(|p|*p==1.),"an unknown launcher must not be priced"); - sim.intel.revealed.push((sim.sams[0].id,DVec3::new(0.,70.,0.),0)); - // Both the launcher and its supporting radar have been observed. - sim.intel.revealed.push((sim.sams[1].id,DVec3::new(0.,70.,0.),0)); + // The launcher is seen firing without a sensor of its own; the radar is fixed emitting. + sim.intel.observe(&sim.sams[0],0,ew::Seen::Launch); + sim.intel.observe(&sim.sams[1],0,ew::Seen::Emission); let (prices,_)=sim.price_grid(&grid,DroneKind::Shahed); assert!(prices.iter().any(|p|*p>1.),"armed launcher with a network radar is invisible to route risk"); } @@ -131,11 +131,14 @@ fn retargeted_smart_drone_gets_a_route_round_the_defences() { sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(0.0, 70.0, 0.0), 0.0)); let goal = sim.id(); sim.sams.push(Sam::new(goal, SamKind::Ew, DVec3::new(0.0, 70.0, 1000.0), 0.0)); - sim.intel.revealed.push((goal, DVec3::new(0.0, 70.0, 1000.0), 0)); - // Its first target, a system that is destroyed by now. + sim.intel.observe(&sim.sams[2], 0, ew::Seen::Jamming); + // Its first target: a system the picture watched die — seen firing, then the enemy's + // munition watched the wreck. A unit destroyed unobserved would keep the drone coming. let id = sim.id(); let mut wreck = Sam::new(id, SamKind::Patriot, DVec3::new(3000.0, 70.0, -1000.0), 0.0); + sim.intel.observe(&wreck, 0, ew::Seen::Launch); wreck.health = 0.0; + sim.intel.outcome(wreck.id, false, wreck.pos, 0); sim.sams.push(wreck); sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 100.0, -1000.0), DVec3::Z, DVec3::new(3000.0, 70.0, -1000.0)); sim.drones[0].pos = DVec3::new(0.0, 100.0, -1000.0); @@ -157,8 +160,10 @@ fn mass_retarget_sim(seed: u64) -> (Sim, HashMap>, u32) { for j in 0..5 { let at = DVec3::new(-560.0 + i as f64 * 160.0, 70.0, -320.0 + j as f64 * 160.0); let id = sim.id(); - sim.sams.push(Sam::new(id, SamKind::S400, at, 0.0)); - sim.intel.revealed.push((id, at, 0)); + let sam = Sam::new(id, SamKind::S400, at, 0.0); + // Every system is on the picture, its radar fixed emitting once. + sim.intel.observe(&sam, 0, ew::Seen::Emission); + sim.sams.push(sam); } } let mut old = HashMap::new(); @@ -357,10 +362,23 @@ fn same_relief_different_known_snapshots_do_not_share_prices_or_routes() { sim.sams.push(Sam::new(threat, SamKind::S400, threat_at, 0.0)); let target = sim.id(); sim.sams.push(Sam::new(target, SamKind::S400, aim, 0.0)); - let known_all = std::sync::Arc::new(vec![(threat, threat_at), (target, aim)]); - let known_target = std::sync::Arc::new(vec![(target, aim)]); + let contact = |id: u32, at: DVec3| super::ew::Contact { + id, + kind: SamKind::S400, + at, + tick: 0, + seen: super::ew::Seen::Emission, + emission: Some(0), + destroyed: None, + }; + let known_all = std::sync::Arc::new(vec![contact(threat, threat_at), contact(target, aim)]); + let known_target = std::sync::Arc::new(vec![contact(target, aim)]); + // The picture holds both units: a job's unit is alive only while the picture believes in it. + for c in known_all.iter() { + sim.intel.note(c.id, c.kind, c.at, true, 0, super::ew::Seen::Emission); + } - let mut add = |known: std::sync::Arc>, version: u64| -> u32 { + let mut add = |known: std::sync::Arc>, version: u64| -> u32 { let mut d = sim.make_drone(DroneKind::Kh22, start, DVec3::X, aim); d.smart = true; d.err = DVec3::ZERO; @@ -1560,7 +1578,7 @@ fn raids_have_the_same_strike_accuracy_as_manual_launches() { let mut sim = Sim::new(3); let id = sim.id(); sim.sams.push(Sam::new(id, SamKind::Ew, DVec3::new(0.0, 70.0, 900.0), 0.0)); - sim.intel.revealed.push((id, DVec3::new(0.0, 70.0, 900.0), 0)); + sim.intel.observe(&sim.sams[0], 0, ew::Seen::Jamming); let order = RaidOrder { kinds: vec![(DroneKind::Zircon, 20)], approach: Approach::Sector, dist: 400.0, aim: Aim::Objects }; sim.order_raid(&order, &[DVec3::new(0.0, 70.0, 0.0)], &mut Out::default()); // (The raid's launch sites need land the game has told of: a flat one is served.) diff --git a/airdef-mod/src/sim/sight.rs b/airdef-mod/src/sim/sight.rs index c33a661..2065678 100644 --- a/airdef-mod/src/sim/sight.rs +++ b/airdef-mod/src/sim/sight.rs @@ -29,9 +29,9 @@ pub(super) const GUN_PICK_AGE: u64 = 40; /// A track older than this is shown as a forecast. pub const FORECAST_AGE: u64 = 20; /// Radio horizon: `HORIZON * (√mast + √height)` blocks. -const HORIZON: f64 = 35.0; +pub(super) const HORIZON: f64 = 35.0; /// Anything this near is seen (and heard), whatever hides it. -const NEAR: f64 = 24.0; +pub(super) const NEAR: f64 = 24.0; pub(super) struct Horizon { /// Per ray, per sample out: the steepest rise of the land so far (tan of its elevation). @@ -471,7 +471,7 @@ impl Sim { if now % 20 == 0 { for s in self.sams.iter().filter(|s| s.radar_up()) { if self.drones.iter().any(|d| d.alive() && d.pos.distance(s.pos) <= 1600.0) { - self.intel.revealed.push((s.id, s.pos, now)); + self.intel.observe(s, now, ew::Seen::Emission); } } } diff --git a/airdef-mod/src/sim/strike.rs b/airdef-mod/src/sim/strike.rs index 4e268f0..a96ac87 100644 --- a/airdef-mod/src/sim/strike.rs +++ b/airdef-mod/src/sim/strike.rs @@ -767,7 +767,8 @@ impl Sim { sam.launched = 12; sam.fired += 1; let dist = (p.aim - DVec2::new(sam.pos.x, sam.pos.z)).length(); - self.intel.revealed.push((p.carrier, sam.pos, self.tick)); + // The launch betrays the carrier: the enemy saw it fire, no more than that. + self.intel.observe(&self.sams[i], self.tick, ew::Seen::Launch); self.strikes.fired += 1; self.strikes.next += 1; // The counted flight includes the unlit ejection: the missile is honest about diff --git a/docs/CODEX-HANDOFF.md b/docs/CODEX-HANDOFF.md index 5316263..24ff39a 100644 --- a/docs/CODEX-HANDOFF.md +++ b/docs/CODEX-HANDOFF.md @@ -454,8 +454,56 @@ Lancet, баллистика и decoy, как и раньше, летят пря траектории, а не задания. Регрессии: `mass_retarget_finishes_its_route_jobs_over_the_budget` (40+ ретаргетов, прогресс каждый бюджетный тик, старый маршрут до нового, нет пустых маршрутов) и `mass_retarget_is_deterministic_and_never_duplicates_a_drone` (тот же seed — те же цели и -маршруты; повторный `adapt` не создаёт дублей). Полный EnemyPicture, общая модель возможностей, -честное разделение наблюдения/истины и структурированное намерение остаются за #127. +маршруты; повторный `adapt` не создаёт дублей). + +### #127, этап 1: честная EnemyPicture + +Журнал `intel.revealed: Vec<(id, место, тик)>` заменён картой контактов: `Intel.contacts: BTreeMap`, +одна запись на юнита, последнее наблюдение побеждает. Контакт пишется только в момент раскрытия и +читает истину только тогда: пуск (mod.rs/strike.rs) → `Seen::Launch`; фиксация излучения + радаром рядом с чужим аппаратом (sight.rs, каждые 20 тиков, 1600 блоков) → `Seen::Emission` +(отдельно помечается `emission` — вера в собственный сенсор опирается только на неё); работающая +РЭБ (ew.rs) → `Seen::Jamming`; четвёртый канал — BSA: падение собственной боеголовки рядом с +известным юнитом (`Sim::observe_outcomes`, радиус 16) фиксирует `destroyed` либо обновляет «жив» +и место. Пока юнит незамечен, его перемещения, выключение радара, смена ролей/режима, расход БК, +урон и гибель картину не меняют — это метаморфные тесты `sim::ew::picture_tests`. + +Из контактов решается всё: снимок `picture_of_defences()` на приказ и на адаптивный тик (newest first, +при равном тике — больший id, как у старого reversed-журнала), цели `choose_target` и SEAD +(`radar_strikes` — только Probable и свежее), ретаргет в полёте (цель «мертва» лишь по контакту с +`destroyed`; забытого контакта дрон не замечает), живость задания очереди (`job_live` — по картине, +не по истине). Уверенность — функция одного возраста: Confirmed ≤ 600, Probable ≤ 3000, Doubtful до +TTL 6000 тиков (`Confidence`, диагностика и SEAD её используют, цены — нет: консервативно считаются +все живые контакты). + +Модель возможностей в `price_grid_known` читает только картину: роли — published `kind.spec().roles` +(смена `/airdef roles` невидима), дальности — `ew::published_reach` (без урона, фактора роли и +мачт истины), донор — любой живой контакт вида Radar в link range от наблюдённого места (роль +донора не наблюдаема: рабочий контакт РЛС предполагается кормящим сеть — это допущение, а не +знание; разрыв связи/гибель донора незамеченными картину не меняют). Собственный сенсор верится, +пока жива фиксация эмиссии; пуск при нуля эмиссионных фиксаций у С-400/С-500 читается как +launcher-only: без донорского контакта такая ПУ в ценовой сетке «слепа», с ним бьёт на дальность +донора. Горизонт РЛС игрока (`self.horizons`) из чужого планировщика убран: маскировка рельефа — +тайна обороны, цены по ней консервативнее и объяснимее. + +Диагностика без GUI: `Sim::enemy_picture() -> EnemyPictureReport` (англ.): по каждому контакту — +наблюдение (канал, место, возраст), уверенность, живость, наблюдённое уничтожение, вера в сенсор, +наличие донора и текстовое «что планировщик из этого делает». Адаптация по-прежнему ограничена +статистикой секторов (`best_bearing` по kills/hits): новых всеведущих данных и усиления +характеристик нет. + +Форматы не изменились: intel — рантайм (save хранит Layout, replay — траектории), durable-очередь +по-прежнему не делается. Измерения до/после на одних seed: `docs/measurements/issue-127-enemy-picture-baseline.json` +(0b645d77e) и `issue-127-enemy-picture-result.json` (эта ветка), bench — `cargo run --release --example intel_bench`. +big-smart и logistics побайтово совпали с базой; ew-adaptive изменился (kills 62→59/61→59/61→56, +hits 3→7/5→7/7→8, потери ЗРК 1→3/4→3/4→4) — фиксируется как факт перехода на честную картину, +балансные параметры не трогали. Регрессии: метаморфные `hidden_changes_do_not_move_the_picture_or_its_decisions`, +`a_new_observation_is_what_updates_the_picture`, `launcher_only_is_blind_in_the_picture_without_a_donor` +(донор любой роли; разрыв связи наблюдённый/нет), `destruction_moves_the_picture_only_when_watched`, +`confidence_is_a_function_of_age_alone`, `enemy_picture_report_describes_observation_and_belief`, +`an_impact_watches_the_kill_and_updates_only_the_picture` (BSA end-to-end), обновлённые SEAD/#4/#21/#122 +и обе регрессии очереди. Остаток #127: durable save queue (новый формат save), полный structured intent, +GUI поверх `enemy_picture()`, оценка баланса ew-adaptive. ## Что следующему агенту проверить в первую очередь diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index 5c298a4..a821697 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -51,12 +51,13 @@ | #120 | Конечное горение, powered/coast, сопротивление и снижение доступного манёвра, один остаточный pk по фактической скорости; swept-встреча; события burnout/energy loss; согласованное прекращение факела/дыма/звука | Игровой профиль, без аэродинамики и реальных параметров; сравнительная выборка 12 рейдов и расширенная сетка 48 рейдов (6 вариантов × 8 seed, батареи/дальности/классы) записаны в measurements; без подтверждённого дефекта параметры не менялись; прослушивание остаётся | | #121 | On/Off в команде и карте, save/replay; Off не создаёт own measurements и сохраняет сетевой приём; раскрытие работающего радара наблюдателем; SEAD использует last-known и возраст | Условный наблюдатель по расстоянию, без радиотехнической модели и ошибки пеленга; выбор на карте ещё не проверен в GUI | | #122 | Воспроизведены два класса: tick fuel exhaustion на двух максимальных составах, HD (полный обход height-cache на каждом взрыве заменён удалением нужных ключей; max135.3M/300M) и остановка в UI event на втором крупном приказе (переснимок known_units на каждом дроне заменён одним снимком на приказ и O(журнал+группировка); max command 32.9M/1e9, `/stats` до/после совпал); нативные тесты эквивалентности и второго приказа; WASM fixture с 4 принятыми приказами; callback diagnostics/profile | Требуется приёмка на Windows; без подтверждённого build identity нельзя утверждать ту же причину пользовательского сеанса; FPS/GPU не измерялись | -| #127 | Узкая часть: массовое перенацеливание не теряет необработанные маршруты. Задания (ID дрона, новая цель, версия знаний) живут в FIFO-очереди между тиками; бюджет `WORK_PER_TICK` тот же, задания снимаются/заменяются без роста очереди, старый безопасный маршрут остаётся до готовности нового, а при отмене задания используется прямой полёт как fallback. Снимок рельефа принадлежит версии задания (`Arc` в job), поэтому поздняя умная волна не переписывает ожидающий маршрут; рельеф, цены и кэш маршрутов ключуются версией знаний целиком (adapt tick + указатели на known/relief snapshots), поэтому jobs с одним рельефом, но разными known snapshots не делят кэш. Регрессии: 40+ ретаргетов, продвижение каждый бюджетный тик, отсутствие вечных пустых маршрутов, детерминизм повторного seed, отсутствие дублей, сохранение рельефа pending job после замены `known_relief` и разделение кэша по known snapshots | Полный #127 (EnemyPicture, общая модель возможностей батареи/связи, честное разделение наблюдения и истины, структурированное намерение, объяснимость) не реализован. Рельеф повторного плана — выборка из сетки версии задания; вне её Unknown. Очередь — рантайм-состояние планировщика: drones/queue не входят в текущий формат save, поэтому старые saves не меняются; отдельная durable-очередь потребовала бы нового формата | +| #127 | Этап 1: честная EnemyPicture и актуальная модель возможностей. Журнал раскрытий заменён картой контактов: `Intel.contacts` хранит последнее наблюдение юнита (тип, место, канал «пуск/излучение/глушение», время, метка эмиссии, наблюдённое уничтожение) и никогда не обновляется тем, что юнит делает незамеченным. Уверенность — функция одного возраста (Confirmed/Probable/Doubtful; TTL контакта 6000 тиков). Угроза в `price_grid` читает только контакты: published-роли и published-дальности вида (без урона/ролей/мастов истины), собственный сенсор — по факту фиксации эмиссии, пуск без эмиссии у С-400/С-500 читается как боевая работа по сети (без донора такой контакт «слеп»; донор — любой живой контакт-РЛС в пределах link range от наблюдённого места), горизонт РЛС игрока из чужой ценовой сетки убран. Уничтожение учится только из BSA: последние мгновения собственной боеголовки у наблюдённого юнита (радиус 16). SEAD бьёт по контактам Probable и свежее; цели и ретаргет — по картинке, задание отменяется при наблюдённой гибели/забвении цели. Структурированная диагностика `Sim::enemy_picture()` (англ., без GUI): наблюдение/уверенность/допущение угрозы словами. Очередь #127 и WORK_PER_TICK без изменений; сейвы/replay не затронуты (intel — рантайм). Метаморфные регрессии: скрытые перемещение/off/роли/режим/БК/урон/гибель не двигают картинку, цены и выбор adapt; launcher-only без донора/с донором/разрыв связи наблюдённый и нет; повторное наблюдение обновляет; BSA end-to-end. База/результат по seed 1/7/42: `docs/measurements/issue-127-enemy-picture-{baseline,result}.json` (big-smart/logistics идентичны, ew-adaptive: kills 62→59/61→59/61→56, hits 3→7/5→7/7→8, потери 1→3/4→3/4→4 — фиксируется как факт, параметры не менялись) | Остаток #127: durable save queue (новый формат save), полный structured intent, GUI для диагностики картинки, оценка баланса изменений ew-adaptive (без подтверждённого дефекта параметры не трогали) | -По #127 реализована только устойчивая очередь маршрутов, а не полный «EnemyPicture». Очередь не -сохраняется в autosave: текущий формат вообще не хранит дронов и очередь запуска, поэтому изменение -не ломает старые saves и не требует миграции. Replay пишет траектории, а не внутренние задания -планировщика; формат записи не менялся. +По #127 реализованы устойчивая очередь маршрутов и этап 1 честной EnemyPicture (карта контактов, +уверенность по возрасту, угроза по наблюдённым параметрам, BSA-канал уничтожения, диагностика +`enemy_picture()`). Очередь и картина не сохраняются в autosave: текущий формат вообще не хранит +дронов, очередь запуска и intel, поэтому старые saves и replay читаются как прежде и миграция не +нужна. Точные коммиты, формат сохранения, тесты и порядок продолжения: [CODEX-HANDOFF.md](CODEX-HANDOFF.md). Формальный open-статус не меняется после реализации. diff --git a/docs/measurements/issue-127-enemy-picture-baseline.json b/docs/measurements/issue-127-enemy-picture-baseline.json new file mode 100644 index 0000000..d6c96f2 --- /dev/null +++ b/docs/measurements/issue-127-enemy-picture-baseline.json @@ -0,0 +1,155 @@ +{ + "schema": 1, + "world": "EnemyPicture127", + "branch": "kimi-work (baseline, before #127 stage 1)", + "code_commit": "0b645d77e", + "bench": "cargo run --release --example intel_bench (airdef-core, SDK-free)", + "scenarios": [ + "big-smart", + "logistics", + "ew-adaptive" + ], + "seeds": [ + 1, + 7, + 42 + ], + "metrics": "completion: raids_ended/raids_ordered, all_ended; losses: sams_lost; exchange: launched/kills/hits/crashes/fired; failure reasons: sim.tally text (Russian, as the tally words them)", + "runs": [ + { + "scenario": "big-smart", + "seed": 1, + "ticks_run": 1642, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 38, + "kills": 38, + "hits": 0, + "crashes": 0, + "sams_lost": 0, + "fired": 50, + "failures": "5 промахов по взрывателю, 30 ракет остались без цели, 4 потерь трека" + }, + { + "scenario": "big-smart", + "seed": 7, + "ticks_run": 1514, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 38, + "kills": 38, + "hits": 0, + "crashes": 0, + "sams_lost": 0, + "fired": 49, + "failures": "2 промахов по взрывателю, 22 ракет остались без цели, 6 потерь трека" + }, + { + "scenario": "big-smart", + "seed": 42, + "ticks_run": 1490, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 38, + "kills": 38, + "hits": 0, + "crashes": 0, + "sams_lost": 0, + "fired": 56, + "failures": "5 промахов по взрывателю, 35 ракет остались без цели, 9 потерь трека" + }, + { + "scenario": "logistics", + "seed": 1, + "ticks_run": 1009, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 27, + "kills": 11, + "hits": 8, + "crashes": 0, + "sams_lost": 0, + "fired": 18, + "failures": "1 промахов по взрывателю, 6 ракет остались без цели, 1 потерь трека, 3 ожиданий БК" + }, + { + "scenario": "logistics", + "seed": 7, + "ticks_run": 970, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 27, + "kills": 12, + "hits": 7, + "crashes": 0, + "sams_lost": 0, + "fired": 18, + "failures": "6 ракет остались без цели, 2 потерь трека, 3 ожиданий БК" + }, + { + "scenario": "logistics", + "seed": 42, + "ticks_run": 1003, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 27, + "kills": 12, + "hits": 8, + "crashes": 0, + "sams_lost": 0, + "fired": 18, + "failures": "6 ракет остались без цели, 3 потерь трека, 3 ожиданий БК" + }, + { + "scenario": "ew-adaptive", + "seed": 1, + "ticks_run": 8661, + "raids_ordered": 3, + "raids_ended": 3, + "all_ended": true, + "launched": 69, + "kills": 62, + "hits": 3, + "crashes": 4, + "sams_lost": 1, + "fired": 82, + "failures": "8 промахов по взрывателю, 1 ракет выработали ресурс, 24 ракет остались без цели, 14 потерь трека" + }, + { + "scenario": "ew-adaptive", + "seed": 7, + "ticks_run": 8809, + "raids_ordered": 3, + "raids_ended": 3, + "all_ended": true, + "launched": 69, + "kills": 61, + "hits": 5, + "crashes": 3, + "sams_lost": 4, + "fired": 78, + "failures": "7 промахов по взрывателю, 20 ракет остались без цели, 10 потерь трека" + }, + { + "scenario": "ew-adaptive", + "seed": 42, + "ticks_run": 6976, + "raids_ordered": 3, + "raids_ended": 3, + "all_ended": true, + "launched": 69, + "kills": 61, + "hits": 7, + "crashes": 1, + "sams_lost": 4, + "fired": 75, + "failures": "8 промахов по взрывателю, 1 ракет выработали ресурс, 25 ракет остались без цели, 10 потерь трека" + } + ] +} \ No newline at end of file diff --git a/docs/measurements/issue-127-enemy-picture-result.json b/docs/measurements/issue-127-enemy-picture-result.json new file mode 100644 index 0000000..10b2177 --- /dev/null +++ b/docs/measurements/issue-127-enemy-picture-result.json @@ -0,0 +1,155 @@ +{ + "schema": 1, + "world": "EnemyPicture127", + "branch": "agent/issue-127-enemy-picture", + "code_commit": "agent/issue-127-enemy-picture (see git log for the exact SHA)", + "bench": "cargo run --release --example intel_bench (airdef-core, SDK-free)", + "scenarios": [ + "big-smart", + "logistics", + "ew-adaptive" + ], + "seeds": [ + 1, + 7, + 42 + ], + "metrics": "completion: raids_ended/raids_ordered, all_ended; losses: sams_lost; exchange: launched/kills/hits/crashes/fired; failure reasons: sim.tally text (Russian, as the tally words them)", + "runs": [ + { + "scenario": "big-smart", + "seed": 1, + "ticks_run": 1716, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 38, + "kills": 38, + "hits": 0, + "crashes": 0, + "sams_lost": 0, + "fired": 49, + "failures": "3 промахов по взрывателю, 27 ракет остались без цели, 3 потерь трека" + }, + { + "scenario": "big-smart", + "seed": 7, + "ticks_run": 1542, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 38, + "kills": 38, + "hits": 0, + "crashes": 0, + "sams_lost": 0, + "fired": 48, + "failures": "2 промахов по взрывателю, 22 ракет остались без цели, 5 потерь трека" + }, + { + "scenario": "big-smart", + "seed": 42, + "ticks_run": 1433, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 38, + "kills": 38, + "hits": 0, + "crashes": 0, + "sams_lost": 0, + "fired": 49, + "failures": "2 промахов по взрывателю, 27 ракет остались без цели, 7 потерь трека" + }, + { + "scenario": "logistics", + "seed": 1, + "ticks_run": 1009, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 27, + "kills": 11, + "hits": 8, + "crashes": 0, + "sams_lost": 0, + "fired": 18, + "failures": "1 промахов по взрывателю, 6 ракет остались без цели, 1 потерь трека, 3 ожиданий БК" + }, + { + "scenario": "logistics", + "seed": 7, + "ticks_run": 970, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 27, + "kills": 12, + "hits": 7, + "crashes": 0, + "sams_lost": 0, + "fired": 18, + "failures": "6 ракет остались без цели, 2 потерь трека, 3 ожиданий БК" + }, + { + "scenario": "logistics", + "seed": 42, + "ticks_run": 1003, + "raids_ordered": 1, + "raids_ended": 1, + "all_ended": true, + "launched": 27, + "kills": 12, + "hits": 8, + "crashes": 0, + "sams_lost": 0, + "fired": 18, + "failures": "6 ракет остались без цели, 3 потерь трека, 3 ожиданий БК" + }, + { + "scenario": "ew-adaptive", + "seed": 1, + "ticks_run": 8084, + "raids_ordered": 3, + "raids_ended": 3, + "all_ended": true, + "launched": 69, + "kills": 59, + "hits": 7, + "crashes": 3, + "sams_lost": 3, + "fired": 69, + "failures": "7 промахов по взрывателю, 20 ракет остались без цели, 11 потерь трека" + }, + { + "scenario": "ew-adaptive", + "seed": 7, + "ticks_run": 8193, + "raids_ordered": 3, + "raids_ended": 3, + "all_ended": true, + "launched": 69, + "kills": 59, + "hits": 7, + "crashes": 3, + "sams_lost": 3, + "fired": 81, + "failures": "4 промахов по взрывателю, 3 ракет выработали ресурс, 26 ракет остались без цели, 8 потерь трека" + }, + { + "scenario": "ew-adaptive", + "seed": 42, + "ticks_run": 7916, + "raids_ordered": 3, + "raids_ended": 3, + "all_ended": true, + "launched": 69, + "kills": 56, + "hits": 8, + "crashes": 5, + "sams_lost": 4, + "fired": 69, + "failures": "7 промахов по взрывателю, 20 ракет остались без цели, 12 потерь трека" + } + ] +} \ No newline at end of file