Give the enemy a last-observation picture and observed-capability pricing (#127)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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::<Vec<_>>(),
|
||||
'\\' => "\\\\".chars().collect::<Vec<_>>(),
|
||||
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<fn() -> 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"));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+583
-44
@@ -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<Vec<(u32, DVec3)>>,
|
||||
known: Arc<Vec<Contact>>,
|
||||
/// 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<Arc<KnownRelief>>,
|
||||
@@ -75,7 +87,7 @@ impl RouteJob {
|
||||
aim: DVec3,
|
||||
unit: u32,
|
||||
version: u64,
|
||||
known: Arc<Vec<(u32, DVec3)>>,
|
||||
known: Arc<Vec<Contact>>,
|
||||
relief: Option<Arc<KnownRelief>>,
|
||||
) -> 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<u64>,
|
||||
/// 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<u64>,
|
||||
}
|
||||
|
||||
/// 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<u32, Contact>,
|
||||
}
|
||||
|
||||
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<u32> = self.sams.iter().filter(|s| s.alive()).map(|s| s.id).collect();
|
||||
let mut seen: HashSet<u32> = 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<Contact> {
|
||||
let now = self.tick;
|
||||
let mut out: Vec<Contact> = 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<Contact> = 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<RouteJob> = 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<u64>,
|
||||
/// 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<ContactReport>,
|
||||
}
|
||||
|
||||
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<Contact> = 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<u32> {
|
||||
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::<Vec<_>>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<DVec3>, 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
|
||||
|
||||
+107
-112
@@ -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<Option<(DVec3, u32)>> {
|
||||
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<Option<(DVec3, u32)>> {
|
||||
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<DroneKind> = 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<u32>) {
|
||||
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<u32>) {
|
||||
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<ew::Contact> = 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<ew::Contact> = picture.iter().copied().filter(|c| c.kind.armed() && c.kind.range() <= 300.0).collect();
|
||||
let long: Vec<ew::Contact> =
|
||||
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<f32>, 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<f32>, 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<f32>, 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<_>>(), 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<u32> = 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);
|
||||
|
||||
@@ -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, Vec<DVec3>>, 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<Vec<(u32, DVec3)>>, version: u64| -> u32 {
|
||||
let mut add = |known: std::sync::Arc<Vec<super::ew::Contact>>, 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.)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+50
-2
@@ -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<u32, Contact>`,
|
||||
одна запись на юнита, последнее наблюдение побеждает. Контакт пишется только в момент раскрытия и
|
||||
читает истину только тогда: пуск (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.
|
||||
|
||||
## Что следующему агенту проверить в первую очередь
|
||||
|
||||
|
||||
@@ -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<KnownRelief>` в 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-статус не меняется после реализации.
|
||||
|
||||
@@ -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 потерь трека"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 потерь трека"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user