diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index d192779..0d5a789 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -317,6 +317,7 @@ impl AirDef { // A searchlight's spot where its beam meets the target. if s.kind == SamKind::Light && let Some(t) = s.target.and_then(|t| self.sim.drones.iter().find(|d| d.id == t)) + && self.sim.lit.contains(&t.id) { add(s.id + 14, t.pos, (0.95, 0.97, 1.0), 220.0, 30.0); } @@ -487,7 +488,24 @@ impl AirDef { fn save(&mut self) { let mut text = String::new(); for s in &self.sim.sams { - text += &format!("sam {} {} {} {} {} {} {} {}\n", s.kind.key(), s.pos.x, s.pos.y, s.pos.z, s.heading, s.roles, s.health, s.cargo); + // Then its ammunition (missiles, bursts, out and waiting for a truck, reload ticks + // left) and where it was driving, if anywhere. + let dest = s.dest.map_or("- - -".to_string(), |d| format!("{} {} {}", d.x, d.y, d.z)); + text += &format!( + "sam {} {} {} {} {} {} {} {} {} {} {} {} {dest}\n", + s.kind.key(), + s.pos.x, + s.pos.y, + s.pos.z, + s.heading, + s.roles, + s.health, + s.cargo, + s.missiles, + s.bursts, + u8::from(s.empty), + s.reloading, + ); } for (i, t) in self.sim.targets.iter().enumerate() { let h = self.sim.target_health.get(i).copied().unwrap_or(100.0); @@ -531,7 +549,22 @@ impl AirDef { if let Some(c) = num(8) { sam.cargo = c as u32; } + // Older saves have no ammunition: full, as before. + if let (Some(m), Some(b)) = (num(9), num(10)) { + sam.missiles = (m as u32).min(sam.missiles); + sam.bursts = (b as u32).min(sam.bursts); + sam.empty = num(11) == Some(1.0); + sam.reloading = num(12).map_or(0, |r| r as u32); + } + let dest = match (num(13), num(14), num(15)) { + (Some(x), Some(y), Some(z)) => Some(DVec3::new(x, y, z)), + _ => None, + }; self.sim.sams.push(sam); + if let Some(to) = dest { + let i = self.sim.sams.len() - 1; + self.sim.order_move(i, to); + } } } Some(&"target") => { @@ -993,12 +1026,18 @@ impl Mod for AirDef { } } } - // Damage is saved too: a destroyed system stays destroyed. + // Damage is saved too: a destroyed system stays destroyed. So are where the units stand + // (or drive to: not every step of the way), their ammunition and the depots' stock. let mark = self .sim .sams .iter() - .map(|s| (s.health * 1000.0) as u64) + .map(|s| { + let at = s.dest.unwrap_or(s.pos); + let place = (at.x * 10.0) as i64 as u64 ^ ((at.z * 10.0) as i64 as u64).rotate_left(21) ^ (if s.dest.is_some() { 0 } else { (s.heading * 100.0) as i64 as u64 }).rotate_left(42); + let ammo = u64::from(s.missiles) | u64::from(s.bursts) << 16 | u64::from(s.cargo) << 32 | u64::from(s.empty) << 48 | u64::from(s.reloading > 0) << 49; + ((s.health * 1000.0) as u64).wrapping_mul(1_000_003) ^ place ^ ammo.rotate_left(7) + }) .chain(self.sim.target_health.iter().map(|h| (h * 10.0) as u64)) .fold(self.sim.sams.len() as u64, |a, h| a.wrapping_mul(31).wrapping_add(h)); if mark != self.health_mark { diff --git a/airdef-mod/src/sim/ew.rs b/airdef-mod/src/sim/ew.rs index a259537..4307824 100644 --- a/airdef-mod/src/sim/ew.rs +++ b/airdef-mod/src/sim/ew.rs @@ -145,7 +145,7 @@ impl Sim { out } - /// During a raid: drones not yet launched go for what just gave itself away (a jamming station + /// During a smart raid: its drones not yet launched go for what just gave itself away (a jamming station /// first, then systems that fired), and drones whose system target is gone pick another. pub(super) fn adapt(&mut self) { if self.tick % 40 != 0 { @@ -160,7 +160,7 @@ impl Sim { } let pick = |i: usize, list: &[(u32, DVec3)]| list[i % list.len()].1; let mut n = 0usize; - for (_, d) in self.queue.iter_mut() { + for (_, d) in self.queue.iter_mut().filter(|(_, d)| d.smart) { let hunter = matches!(d.kind, DroneKind::Lancet | DroneKind::Kh22 | DroneKind::Zircon); if !hunter && !(d.kind == DroneKind::Shahed && n % 4 == 0) { n += 1; @@ -176,7 +176,7 @@ impl Sim { n += 1; } // In flight: a target system destroyed — the next known one, or keep going. - for d in self.drones.iter_mut().filter(|d| d.alive()) { + 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) < 6.0); if aimed_at_wreck { let next = known.iter().min_by(|a, b| a.1.distance(d.pos).total_cmp(&b.1.distance(d.pos))); diff --git a/airdef-mod/src/sim/lights.rs b/airdef-mod/src/sim/lights.rs index edcdfce..0a14cd1 100644 --- a/airdef-mod/src/sim/lights.rs +++ b/airdef-mod/src/sim/lights.rs @@ -103,12 +103,15 @@ impl Sim { sam.yaw = slew(sam.yaw, want_yaw, 0.12); sam.pitch = slew(sam.pitch, want_pitch.clamp(0.05, 1.5), 0.08); sam.target = target.map(|t| t.0); - if let Some((t, _)) = target { - self.lit.push(t); - } // The beam: segments widening with distance, out to the target. let (head, rot) = self.sams[i].mount(); let dir = rot * DVec3::NEG_Z; + // The target is lit once the beam is on it, not while it still swings round. + if let Some((t, at)) = target + && dir.angle_between(at - head) < 0.08 + { + self.lit.push(t); + } let mut along = 1.0; for k in 0..10u32 { if along >= reach { diff --git a/airdef-mod/src/sim/logistics.rs b/airdef-mod/src/sim/logistics.rs index 4bdf398..318121b 100644 --- a/airdef-mod/src/sim/logistics.rs +++ b/airdef-mod/src/sim/logistics.rs @@ -12,9 +12,11 @@ const HANDOVER: f64 = 10.0; const TRUCK_LOAD: u32 = 2; impl Sim { - /// Whether ammunition is counted: a depot stands (without one, magazines refill as before). + /// Whether ammunition is counted: a depot was set up (without one, magazines refill as + /// before). A destroyed depot still counts: losing the last one leaves the systems short, + /// not supplied from nowhere again. pub fn logistics(&self) -> bool { - self.sams.iter().any(|s| s.kind == SamKind::Depot && s.alive()) + self.sams.iter().any(|s| s.kind == SamKind::Depot) } /// Reloads from depots near the systems, and the trucks' rounds. diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index 72e140d..8a3eb11 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -657,6 +657,8 @@ pub struct Drone { pub jammed: bool, /// The approach sector it came from (the enemy keeps score). sector: u8, + /// Sent by the smart enemy: it may be turned on systems that give themselves away. + smart: bool, } impl Drone { @@ -1122,7 +1124,7 @@ pub struct Sim { pub night: bool, /// Illumination flares in the sky, and the drones lit this tick. pub flares: Vec, - lit: Vec, + pub lit: Vec, } /// Ids: objects get multiples of 16, their parts the low bits. @@ -1230,6 +1232,7 @@ impl Sim { low: false, jammed: false, sector: 0, + smart: false, }); self.launched += 1; } @@ -1519,6 +1522,7 @@ impl Sim { low: false, jammed: false, sector: 0, + smart: false, }); } for d in &self.drones { @@ -1715,7 +1719,10 @@ impl Sim { if !self.sams[i].kind.armed() { let sam = &mut self.sams[i]; sam.state = SamState::Search; - sam.target = None; + // A searchlight's target is its beam's (step_lights): kept for its spot of light. + if sam.kind != SamKind::Light { + sam.target = None; + } sam.gun_target = None; continue; } @@ -1879,7 +1886,11 @@ impl Sim { let n = load.magazine - sam.missiles; let (muzzle, dir) = sam.muzzle(n); launches.push((load.kind, muzzle, dir, mt, sam.id)); - // Its missile is on it: the next launch looks for another target. + // Its missile is on it from now, so the systems after this one this tick see + // the target taken; this one's next launch looks for another. + if let Some(d) = self.drones.iter_mut().find(|d| d.id == mt) { + d.engaged += 1; + } sam.target = None; sam.launched = 12; sam.missiles -= 1; @@ -1900,9 +1911,6 @@ impl Sim { let id = self.id(); let v0 = if spec.cold > 0 { 0.7 } else { 0.8 }; self.missiles.push(Missile { id, kind, pos: muzzle, vel: dir * v0, target, sam, age: 0 }); - if let Some(d) = self.drones.iter_mut().find(|d| d.id == target) { - d.engaged += 1; - } if spec.cold > 0 { // Thrown out by gas: a puff, a thump; the motor lights later. out.particles("minecraft:cloud", muzzle, 0.6, dir * 0.05, 0.05, 30); diff --git a/airdef-mod/src/sim/raid.rs b/airdef-mod/src/sim/raid.rs index 3b6d7c7..e943c99 100644 --- a/airdef-mod/src/sim/raid.rs +++ b/airdef-mod/src/sim/raid.rs @@ -298,7 +298,8 @@ impl Sim { // Timing: smart raids arrive at once; others leave at once (a moment apart). let now = self.tick; let arrive = plan.done.iter().map(|(_, t, e, g)| t + e - g).fold(0.0, f64::max); - for (d, time, early, gap) in plan.done { + for (mut d, time, early, gap) in plan.done { + d.smart = plan.smart; let wait = if plan.smart { arrive - (early - gap) - time } else { gap + self.rng.f() * 40.0 }; self.queue.push((now + wait.clamp(0.0, 2400.0) as u64, d)); } @@ -348,6 +349,7 @@ impl Sim { low: false, jammed: false, sector: 0, + smart: false, } } @@ -537,6 +539,29 @@ mod tests { eprintln!("4000 ticks in {:?}, kills {} hits {}", t.elapsed(), sim.kills, sim.hits); assert!(sim.queue.is_empty() && !sim.drones.iter().any(|d| d.alive()), "the raid never ended"); } + #[test] + fn systems_ready_together_send_one_missile_at_a_drone() { + let mut sim = Sim::new(3); + // Vertical launchers of one kind side by side: the drone comes into their reach in the + // same tick, and they fire without turning. + for i in 0..6 { + let id = sim.id(); + sim.sams.push(Sam::new(id, SamKind::Tor, DVec3::new(2.0 * i as f64, 70.0, 0.0), 0.0)); + } + let mut ground = Ground::default(); + let mut out = Out::default(); + for _ in 0..30 { + sim.step(&mut ground, None, &mut out); + } + sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, -400.0), DVec3::Z, DVec3::new(0.0, 70.0, 0.0)); + let id = sim.drones[0].id; + for _ in 0..1200 { + sim.step(&mut ground, None, &mut out); + let on_it = sim.missiles.iter().filter(|m| m.target == id).count(); + assert!(on_it <= 1, "{on_it} missiles on one Shahed"); + } + assert!(sim.sams.iter().any(|s| s.fired > 0), "nothing was fired"); + } } #[cfg(test)] diff --git a/tools/airdef.py b/tools/airdef.py index 63bbc99..622f66b 100644 --- a/tools/airdef.py +++ b/tools/airdef.py @@ -12,6 +12,7 @@ Minecraft 26.3), AIRDEF_GAME (готовый horizon-client вместо собранного). """ import glob +import hashlib import os import platform import shutil @@ -51,21 +52,43 @@ def cargo() -> str: return found +def fork_stamp(commit: str, patches) -> str: + """Что должно стоять в horizon/: коммит апстрима и содержимое патчей.""" + h = hashlib.sha256(commit.encode()) + for p in patches: + h.update(p.name.encode()) + h.update(p.read_bytes()) + return h.hexdigest() + + def fork(): - """horizon/ = апстрим на коммите HORIZON_COMMIT + патчи horizon-patches/.""" + """horizon/ = апстрим на коммите HORIZON_COMMIT + патчи horizon-patches/. + + Ветка airdef пересобирается, когда коммит или патчи изменились (после обновления репозитория); + прежняя остаётся в ветке airdef-prev.""" commit = (ROOT / "HORIZON_COMMIT").read_text().strip() patches = sorted((ROOT / "horizon-patches").glob("*.patch")) + stamp = fork_stamp(commit, patches) + stamp_file = FORK / ".git" / "airdef-stamp" if not (FORK / ".git").exists(): run(["git", "clone", "--filter=blob:none", UPSTREAM, FORK]) branch = subprocess.run(["git", "-C", FORK, "branch", "--show-current"], capture_output=True, text=True).stdout.strip() - if branch == "airdef": - print("horizon/: ветка airdef уже есть") + if branch == "airdef" and stamp_file.exists() and stamp_file.read_text().strip() == stamp: + print("horizon/: ветка airdef на нужных коммите и патчах") return + dirty = subprocess.run(["git", "-C", FORK, "status", "--porcelain", "--untracked-files=no"], + capture_output=True, text=True).stdout.strip() + if dirty: + sys.exit("horizon/: есть незакоммиченные изменения, а движок надо обновить. Закоммить или убери их и запусти снова.") + if branch == "airdef": + print("horizon/: коммит или патчи изменились, ветка airdef пересобирается (прежняя: airdef-prev)") + run(["git", "-C", FORK, "branch", "-f", "airdef-prev", "airdef"]) run(["git", "-C", FORK, "fetch", "--quiet", "origin", commit]) run(["git", "-C", FORK, "checkout", "-B", "airdef", commit]) if patches: run(["git", "-C", FORK, "-c", "user.name=airdef", "-c", "user.email=airdef@localhost", "am", "--3way", *patches]) + stamp_file.write_text(stamp + "\n") def game():