Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a693763fd | |||
| 557346548a | |||
| 38e515dea9 |
+49
-15
@@ -827,9 +827,9 @@ impl AirDef {
|
||||
}
|
||||
|
||||
fn draw_hud(&mut self, player: DVec3, yaw: f32) {
|
||||
let air: Vec<&sim::Drone> = self.sim.drones.iter().filter(|d| d.alive()).collect();
|
||||
let pic = self.sim.air_picture();
|
||||
// The command post's grid and replays cover the screen: the other panels wait under them.
|
||||
if self.grid || self.replay.is_some() || (air.is_empty() && self.sim.sams.is_empty() && self.sim.missiles.is_empty()) {
|
||||
if self.grid || self.replay.is_some() || (pic.tracks.is_empty() && self.sim.sams.is_empty() && self.sim.missiles.is_empty()) {
|
||||
if self.hud_shown {
|
||||
hud::clear();
|
||||
render::clear_corner();
|
||||
@@ -838,7 +838,7 @@ impl AirDef {
|
||||
return;
|
||||
}
|
||||
self.hud_shown = true;
|
||||
hud::set_panel(Corner::TopRight, &panel::summary(&self.sim, &air, player));
|
||||
hud::set_panel(Corner::TopRight, &panel::summary(&self.sim, &pic, player));
|
||||
render::set_corner(render::Corner::BottomRight, radar::SIZE, radar::SIZE + 12.0, &radar::draw(&self.sim, player, yaw));
|
||||
}
|
||||
|
||||
@@ -917,7 +917,23 @@ impl AirDef {
|
||||
}
|
||||
|
||||
fn load(&mut self) {
|
||||
self.load_from(&self.state_key());
|
||||
let key = self.state_key();
|
||||
// A save from before worlds were told apart by seed (kept under the folder name alone) goes to the
|
||||
// world that opens first under that name and is then removed, so a later world of the same name
|
||||
// starts empty.
|
||||
if storage::get(&key).is_none()
|
||||
&& let Some(Some(id)) = &self.world
|
||||
&& let Some((base, _)) = id.split_once('#')
|
||||
{
|
||||
let old = state_key_of(Some(base));
|
||||
if storage::get(&old).is_some() {
|
||||
self.load_from(&old);
|
||||
let _ = storage::delete(&old);
|
||||
self.dirty = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.load_from(&key);
|
||||
}
|
||||
|
||||
fn load_from(&mut self, key: &str) {
|
||||
@@ -1022,6 +1038,28 @@ impl AirDef {
|
||||
}
|
||||
}
|
||||
|
||||
/// The storage key of a world's state: a hash of its identity (`world_identity`).
|
||||
fn state_key_of(world: Option<&str>) -> String {
|
||||
match world {
|
||||
Some(w) => {
|
||||
// FNV-1a: storage keys are short, lowercase and plain.
|
||||
let h = w.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3));
|
||||
format!("state-{h:016x}")
|
||||
}
|
||||
None => "state".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Which world is open: its folder (`game.world-id`) and, for a saved world, its seed, so a new world
|
||||
/// with the name of an old one has a layout of its own.
|
||||
fn world_identity() -> Option<String> {
|
||||
let id = game::world_id()?;
|
||||
Some(match game::world_seed() {
|
||||
Some(seed) => format!("{id}#{seed:x}"),
|
||||
None => id,
|
||||
})
|
||||
}
|
||||
|
||||
impl AirDef {
|
||||
/// The EW stations: on if none is, else off.
|
||||
fn toggle_ew(&mut self) {
|
||||
@@ -1038,14 +1076,7 @@ impl AirDef {
|
||||
|
||||
/// Where the open world's state is kept: a key of its own per world.
|
||||
fn state_key(&self) -> String {
|
||||
match self.world.as_ref().and_then(|w| w.as_ref()) {
|
||||
Some(w) => {
|
||||
// FNV-1a: storage keys are short, lowercase and plain.
|
||||
let h = w.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3));
|
||||
format!("state-{h:016x}")
|
||||
}
|
||||
None => "state".into(),
|
||||
}
|
||||
state_key_of(self.world.as_ref().and_then(|w| w.as_deref()))
|
||||
}
|
||||
|
||||
/// Another world opened: the last one's state saved, a clean simulation with this one's loaded.
|
||||
@@ -1682,7 +1713,7 @@ impl Mod for AirDef {
|
||||
self.sim.engine_tick = tick;
|
||||
let Some(p) = player() else { return };
|
||||
// Each world keeps its own systems, objects and settings: loaded when it opens.
|
||||
let world = game::world_id();
|
||||
let world = world_identity();
|
||||
if self.world.as_ref() != Some(&world) {
|
||||
self.open_world(world);
|
||||
}
|
||||
@@ -1732,10 +1763,11 @@ impl Mod for AirDef {
|
||||
}
|
||||
}
|
||||
let now = self.sim.tick;
|
||||
self.ground.answer(render::ground(), now);
|
||||
// The blocks themselves (issues #42, #43): what changed, and the sections asked for last tick.
|
||||
// The blocks themselves (issues #42, #43): what changed, and the sections asked for last tick. First, so
|
||||
// that the answers below are judged by the world they come in.
|
||||
let changes = render::world_changes();
|
||||
self.ground.world.changed_rev(changes.epoch, changes.revision, changes.overflow, &changes.sections);
|
||||
self.ground.answer(render::ground(), now);
|
||||
let sections = render::sections()
|
||||
.into_iter()
|
||||
.map(|s| sim::world::SectionAnswer {
|
||||
@@ -2110,6 +2142,8 @@ impl Mod for AirDef {
|
||||
sam.roles = *r;
|
||||
}
|
||||
self.sim.sams.push(sam);
|
||||
// Standing in the ground (a chunk that had not loaded as the player fell): it comes up to the surface.
|
||||
self.sim.settle(sid);
|
||||
self.dirty = true;
|
||||
Some(format!("{} развёрнут", kind.name()))
|
||||
}
|
||||
|
||||
+1027
-182
File diff suppressed because it is too large
Load Diff
+24
-26
@@ -1,6 +1,6 @@
|
||||
//! The summary panel (top right): the alert, what is in the air, each
|
||||
//! air-defence system's state and the score.
|
||||
use crate::sim::{Drone, DroneKind, Sim};
|
||||
use crate::sim::{AirPicture, DroneKind, Sim};
|
||||
use glam::DVec3;
|
||||
use horizon_mod_sdk::hud::Line;
|
||||
|
||||
@@ -23,18 +23,21 @@ fn kmh(v: DVec3) -> f64 {
|
||||
v.length() * 20.0 * 3.6
|
||||
}
|
||||
|
||||
pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec<Line> {
|
||||
/// The panel reads the air picture only (#93): what the network holds, with the age of a forecast. The sandbox's
|
||||
/// `truth` adds one marked line of the real figures; it changes nothing else.
|
||||
pub fn summary(sim: &Sim, pic: &AirPicture, player: DVec3) -> Vec<Line> {
|
||||
let mut lines = Vec::new();
|
||||
let line = |text: String, color: u32| Line { text, color };
|
||||
if air.is_empty() {
|
||||
let now = pic.now;
|
||||
if pic.tracks.is_empty() {
|
||||
lines.push(line("Небо чистое".into(), GREEN));
|
||||
} else {
|
||||
let blink = (sim.tick / 10) % 2 == 0;
|
||||
lines.push(line(format!("ВОЗДУШНАЯ ТРЕВОГА — целей: {}", air.len()), if blink { RED } else { PINK }));
|
||||
// By type.
|
||||
lines.push(line(format!("ВОЗДУШНАЯ ТРЕВОГА — целей: {}", pic.tracks.len()), if blink { RED } else { PINK }));
|
||||
// By type, as the radars classed them.
|
||||
let mut by_type = String::new();
|
||||
for kind in DroneKind::ALL {
|
||||
let n = air.iter().filter(|d| d.kind == kind).count();
|
||||
let n = pic.tracks.iter().filter(|t| t.kind == kind).count();
|
||||
if n > 0 {
|
||||
if !by_type.is_empty() {
|
||||
by_type += ", ";
|
||||
@@ -43,29 +46,24 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec<Line> {
|
||||
}
|
||||
}
|
||||
lines.push(line(by_type, PINK));
|
||||
// The nearest threat to the player.
|
||||
if let Some(d) = air.iter().min_by(|a, b| a.pos.distance(player).total_cmp(&b.pos.distance(player))) {
|
||||
// The nearest track to the player: where it was last measured (a forecast says so, with its age).
|
||||
if let Some(t) = pic.nearest(player) {
|
||||
let e = t.est(now);
|
||||
let mark = if t.forecast(now) { format!(" ~{}с", t.age(now) / 20) } else { String::new() };
|
||||
lines.push(line(
|
||||
format!(
|
||||
"Ближ.: {} {} выс.{:.0} {:.0}км/ч",
|
||||
d.kind.spec().name,
|
||||
distance(d.pos.distance(player)),
|
||||
d.pos.y - player.y,
|
||||
kmh(d.vel)
|
||||
),
|
||||
format!("Ближ.: {} {} выс.{:.0} {:.0}км/ч{mark}", t.kind.spec().name, distance(e.distance(player)), e.y - player.y, kmh(t.vel)),
|
||||
PINK,
|
||||
));
|
||||
}
|
||||
}
|
||||
if sim.planning() {
|
||||
lines.push(line("Противник планирует налёт…".into(), YELLOW));
|
||||
}
|
||||
if !sim.queue.is_empty() {
|
||||
lines.push(line(format!("Ещё взлетает: {}", sim.queue.len()), PINK));
|
||||
}
|
||||
let hidden = air.iter().filter(|d| !sim.sams.is_empty() && !sim.tracked(d.id)).count();
|
||||
if hidden > 0 {
|
||||
lines.push(line(format!("Вне видимости РЛС: {hidden}"), GREY));
|
||||
if sim.truth {
|
||||
let real = sim.drones.iter().filter(|d| d.alive()).count();
|
||||
let unseen = sim.drones.iter().filter(|d| d.alive() && pic.get(d.id).is_none()).count();
|
||||
let mut text = format!("ИСТИНА: в воздухе {real}, вне картины {unseen}, взлетает {}", sim.queue.len());
|
||||
if sim.planning() {
|
||||
text += ", противник планирует налёт";
|
||||
}
|
||||
lines.push(line(text, YELLOW));
|
||||
}
|
||||
// The launch sites found, and the strike missiles (#101).
|
||||
if !sim.strikes.known.is_empty() || !sim.strikes.flights.is_empty() {
|
||||
@@ -103,8 +101,8 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec<Line> {
|
||||
} else {
|
||||
sam.status()
|
||||
};
|
||||
if let Some(t) = sam.gun_target.or(sam.target).and_then(|t| sim.drones.iter().find(|d| d.id == t)) {
|
||||
text += &format!(" > {} {}", t.kind.spec().name, distance(t.pos.distance(sam.pos)));
|
||||
if let Some(t) = sam.gun_target.or(sam.target).and_then(|t| pic.get(t)) {
|
||||
text += &format!(" > {} {}", t.kind.spec().name, distance(t.est(now).distance(sam.pos)));
|
||||
}
|
||||
if sam.kills > 0 {
|
||||
text += &format!(" ({})", sam.kills);
|
||||
|
||||
+21
-24
@@ -147,35 +147,32 @@ pub fn draw(sim: &Sim, player: DVec3, yaw: f32) -> Vec<Shape> {
|
||||
}
|
||||
let mut far = 0;
|
||||
// The air picture is what the systems' radars hold, as they last measured it (a track older than a second
|
||||
// is a forecast, dimmer); with no system deployed, everything as it is.
|
||||
let any = !sim.sams.is_empty();
|
||||
let picture = sim.picture();
|
||||
let now = sim.tick;
|
||||
for d in sim.drones.iter().filter(|d| !any || !d.alive() || picture.contains_key(&d.id)) {
|
||||
let track = picture.get(&d.id).filter(|_| any && d.alive());
|
||||
let (pos, vel) = track.map_or((d.pos, d.vel), |t| (t.est(now), t.vel));
|
||||
// is a forecast, dimmer); with no system deployed, the sky as it is. Nothing else is read (#93).
|
||||
let pic = sim.air_picture();
|
||||
let now = pic.now;
|
||||
for t in &pic.tracks {
|
||||
let (pos, vel) = (t.est(now), t.vel);
|
||||
match scope(pos) {
|
||||
Some((x, y, bearing)) => {
|
||||
if d.alive() {
|
||||
// Fresh: bright and red; a forecast: orange, fading as it ages towards the track's drop.
|
||||
let (a, color) = match track {
|
||||
Some(t) if t.forecast(now) => ((1.0 - t.age(now) as f64 / t.ttl.max(1) as f64 * 0.7).max(0.3), 0xFFB040),
|
||||
Some(_) => (1.0, 0xFF4040),
|
||||
None => (fade(bearing), 0xFF4040),
|
||||
};
|
||||
s.push(square(x, y, 1.8, alpha(color, a)));
|
||||
// Its heading, a short tail.
|
||||
let v = vel.normalize_or_zero();
|
||||
s.push(quad(pt(x, y), pt(x + 0.6, y), pt(x - v.x as f32 * 6.0 + 0.6, y - v.z as f32 * 6.0), pt(x - v.x as f32 * 6.0, y - v.z as f32 * 6.0), alpha(color, a * 0.6)));
|
||||
// Fresh: bright and red; a forecast: orange, fading as it ages towards the track's drop.
|
||||
let (a, color) = if pic.unaided {
|
||||
(fade(bearing), 0xFF4040)
|
||||
} else if t.forecast(now) {
|
||||
((1.0 - t.age(now) as f64 / t.ttl.max(1) as f64 * 0.7).max(0.3), 0xFFB040)
|
||||
} else {
|
||||
s.push(square(x, y, 1.2, alpha(0x909090, fade(bearing))));
|
||||
}
|
||||
(1.0, 0xFF4040)
|
||||
};
|
||||
s.push(square(x, y, 1.8, alpha(color, a)));
|
||||
// Its heading, a short tail.
|
||||
let v = vel.normalize_or_zero();
|
||||
s.push(quad(pt(x, y), pt(x + 0.6, y), pt(x - v.x as f32 * 6.0 + 0.6, y - v.z as f32 * 6.0), pt(x - v.x as f32 * 6.0, y - v.z as f32 * 6.0), alpha(color, a * 0.6)));
|
||||
}
|
||||
None if d.alive() => far += 1,
|
||||
None => {}
|
||||
None => far += 1,
|
||||
}
|
||||
// The true place, when asked for: a white dot beside the track, to see how far the picture is off.
|
||||
if sim.truth && d.alive() && any {
|
||||
}
|
||||
// The true places, when the sandbox asks for them: a white dot beside the track, to see how far the picture is off.
|
||||
if sim.truth && !pic.unaided {
|
||||
for d in sim.drones.iter().filter(|d| d.alive()) {
|
||||
if let Some((x, y, _)) = scope(d.pos) {
|
||||
s.push(square(x, y, 0.8, 0xFFFFFFFF));
|
||||
}
|
||||
|
||||
+296
-14
@@ -1,7 +1,6 @@
|
||||
//! Balance measurements: a whole raid on flat land, flown headlessly against a cluster of systems.
|
||||
//! The `#[ignore]`d tests print tables (`cargo test --release balance -- --ignored --nocapture`);
|
||||
//! the others assert the cheap invariants. The tables are what the air defence's tuning is judged by.
|
||||
use super::sites::testland::{Land, serve};
|
||||
use super::*;
|
||||
|
||||
/// The ten armed kinds in the order a cluster takes them (long-range heavy ones spread evenly).
|
||||
@@ -48,12 +47,20 @@ pub(super) struct Result {
|
||||
pub untracked: u32,
|
||||
pub hits: u32,
|
||||
pub crashes: u32,
|
||||
/// Interceptors lost against the land, and ones that blew up short of a ridge.
|
||||
pub blocked: u32,
|
||||
pub scrubbed: u32,
|
||||
pub unshot: u32,
|
||||
pub missed: u32,
|
||||
/// Missiles fired by the heavy systems (S-400, S-500, Patriot).
|
||||
pub heavy: u32,
|
||||
pub by_kind: Vec<(DroneKind, u32, u32)>,
|
||||
pub tally: String,
|
||||
/// Drones that vanished short of their aim without being shot down: kind, where, how far from the aim.
|
||||
pub crashed: Vec<(DroneKind, DVec3, f64)>,
|
||||
/// Chunk tickets the sim asked for.
|
||||
pub tickets: usize,
|
||||
pub scrubs: Vec<(MissileKind, u32, f64, f64, DVec3, f64, f64)>,
|
||||
}
|
||||
|
||||
pub(super) struct Setup {
|
||||
@@ -65,20 +72,137 @@ pub(super) struct Setup {
|
||||
pub objects: usize,
|
||||
/// Called on the sim before the raid (a test sets its switches).
|
||||
pub tweak: fn(&mut Sim),
|
||||
/// Hilly land instead of flat (`hills`).
|
||||
pub hilly: bool,
|
||||
/// Only a disc of this radius round the player (the origin) is loaded, as in the game; beyond it the land is not
|
||||
/// there (`Void`) unless a chunk ticket was asked for (loaded after a delay, for a while).
|
||||
pub disc: Option<f64>,
|
||||
/// Mountains on top of the hills (ridges of up to 150 blocks, 600-900 blocks apart).
|
||||
pub mountains: bool,
|
||||
/// How far out the raid launches (the order's distance).
|
||||
pub dist: f64,
|
||||
/// Systems and objects sunk this far into the land (a player who stood inside it).
|
||||
pub bury: f64,
|
||||
/// How steep the hills are (1 is the usual), and whether a forest's canopy stands on them (the game's height map shows the leaves).
|
||||
pub steep: f64,
|
||||
pub trees: bool,
|
||||
}
|
||||
|
||||
/// Rolling hills: ridges of 20-35 blocks over a plain at 64, wavelengths of 90-250 blocks.
|
||||
pub(super) fn hills(x: i32, z: i32) -> i32 {
|
||||
hills_k(x, z, 1.0)
|
||||
}
|
||||
|
||||
/// `hills` with its ridges `k` times as steep (higher and closer together).
|
||||
pub(super) fn hills_k(x: i32, z: i32, k: f64) -> i32 {
|
||||
let (x, z) = (f64::from(x) * k, f64::from(z) * k);
|
||||
let n = (x / 53.0 + (z / 91.0).sin() * 1.7).sin() * 0.5 + (z / 41.0 - (x / 77.0).cos()).sin() * 0.35 + ((x + z) / 130.0).sin() * 0.4 + ((x - 2.0 * z) / 29.0).sin() * 0.12;
|
||||
64 + (n * 28.0 * k + 14.0).max(-4.0) as i32
|
||||
}
|
||||
|
||||
/// Mountains: broad ridges over the hills, nothing within about 300 blocks of the origin.
|
||||
pub(super) fn mountains(x: i32, z: i32) -> i32 {
|
||||
let (x, z) = (f64::from(x), f64::from(z));
|
||||
let r = (x * x + z * z).sqrt();
|
||||
let ridge = ((x / 410.0 + 0.7).sin() * (z / 530.0 - 0.4).sin() + (x / 700.0 - z / 610.0).sin() * 0.6).max(0.0);
|
||||
(ridge * 150.0 * ((r - 300.0) / 400.0).clamp(0.0, 1.0)) as i32
|
||||
}
|
||||
|
||||
/// The game's view of the land: all of it, or a disc round the player and the chunks asked for.
|
||||
pub(super) struct Loader {
|
||||
pub hilly: bool,
|
||||
pub mountains: bool,
|
||||
pub disc: Option<f64>,
|
||||
steep: f64,
|
||||
trees: bool,
|
||||
asked: std::cell::RefCell<HashMap<(i32, i32), (u64, u64)>>,
|
||||
pub tickets: usize,
|
||||
}
|
||||
const TICKET_DELAY: u64 = 40;
|
||||
const TICKET_LIFE: u64 = 400;
|
||||
|
||||
impl Loader {
|
||||
pub fn new(s: &Setup) -> Self {
|
||||
Loader { hilly: s.hilly, mountains: s.mountains, disc: s.disc, steep: s.steep, trees: s.trees, asked: Default::default(), tickets: 0 }
|
||||
}
|
||||
/// The land without the forest on it.
|
||||
pub fn bare(&self, x: i32, z: i32) -> f64 {
|
||||
f64::from(hills_k(x, z, self.steep) + if self.mountains { mountains(x, z) } else { 0 })
|
||||
}
|
||||
pub fn height(&self, x: i32, z: i32) -> i32 {
|
||||
if !self.hilly {
|
||||
return 64;
|
||||
}
|
||||
// A forest: a quarter of the 3x3 patches carry a canopy 8 blocks over the ground (the game's height map shows the leaves).
|
||||
let canopy = if self.trees && ((x.div_euclid(3).wrapping_mul(73856093) ^ z.div_euclid(3).wrapping_mul(19349663)) as u32 >> 3) % 4 == 0 { 8 } else { 0 };
|
||||
hills_k(x, z, self.steep) + canopy + if self.mountains { mountains(x, z) } else { 0 }
|
||||
}
|
||||
fn loaded(&self, c: (i32, i32), now: u64) -> Option<bool> {
|
||||
let Some(r) = self.disc else { return Some(true) };
|
||||
let (x, z) = (f64::from(c.0 * 16 + 8), f64::from(c.1 * 16 + 8));
|
||||
if (x * x + z * z).sqrt() <= r {
|
||||
return Some(true);
|
||||
}
|
||||
match self.asked.borrow().get(&c) {
|
||||
Some(&(since, until)) if now < until => Some(now >= since + TICKET_DELAY),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn serve(&mut self, ground: &mut Ground, now: u64) {
|
||||
for c in ground.take_chunks() {
|
||||
self.tickets += 1;
|
||||
if std::env::var("NOTICKETS").is_err() {
|
||||
let mut asked = self.asked.borrow_mut();
|
||||
let e = asked.entry(c).or_insert((now, now));
|
||||
if now >= e.1 {
|
||||
*e = (now, now);
|
||||
}
|
||||
e.1 = now + TICKET_DELAY + TICKET_LIFE;
|
||||
}
|
||||
}
|
||||
let cols = ground
|
||||
.take_wanted()
|
||||
.into_iter()
|
||||
.map(|(x, z)| {
|
||||
let y = match self.loaded((x >> 4, z >> 4), now) {
|
||||
Some(true) => self.height(x, z),
|
||||
Some(false) => PENDING,
|
||||
None => -64,
|
||||
};
|
||||
(x, z, y)
|
||||
})
|
||||
.collect();
|
||||
ground.answer(cols, now);
|
||||
let secs = ground.world.take_wanted(now);
|
||||
let view = Seen { l: self, now };
|
||||
ground.world.serve(&view, secs, now);
|
||||
}
|
||||
}
|
||||
struct Seen<'a> {
|
||||
l: &'a Loader,
|
||||
now: u64,
|
||||
}
|
||||
impl world::WorldSource for Seen<'_> {
|
||||
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
|
||||
self.l.loaded((x >> 4, z >> 4), self.now).filter(|&l| l).map(|_| y < self.l.height(x, z))
|
||||
}
|
||||
}
|
||||
|
||||
impl Setup {
|
||||
pub fn new(systems: usize, smart: bool, seed: u64) -> Self {
|
||||
Setup { systems, smart, seed, size: 36, radars: (systems / 10).max(1), objects: 36, tweak: |_| {} }
|
||||
Setup { systems, smart, seed, size: 36, radars: (systems / 10).max(1), objects: 36, tweak: |_| {}, hilly: false, bury: 0.0, disc: None, mountains: false, dist: 500.0, steep: std::env::var("STEEP").ok().and_then(|v| v.parse().ok()).unwrap_or(1.0), trees: std::env::var("TREES").is_ok() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The cluster: `objects` in a grid, systems of the mix scattered round them, radars among them.
|
||||
pub(super) fn cluster(sim: &mut Sim, s: &Setup) {
|
||||
let l = Loader::new(s);
|
||||
let y = |x: f64, z: f64| if s.hilly { l.bare(x as i32, z as i32) - s.bury } else { 70.0 };
|
||||
let mut rng = Rng::new(s.seed.wrapping_mul(977) + 5);
|
||||
let side = (s.objects as f64).sqrt().ceil() as usize;
|
||||
for k in 0..s.objects {
|
||||
let p = DVec3::new(((k % side) as f64 - side as f64 / 2.0) * 70.0, 70.0, ((k / side) as f64 - side as f64 / 2.0) * 70.0);
|
||||
let (px, pz) = (((k % side) as f64 - side as f64 / 2.0) * 70.0, ((k / side) as f64 - side as f64 / 2.0) * 70.0);
|
||||
let p = DVec3::new(px, if s.hilly { y(px, pz) + 6.0 + s.bury } else { 70.0 }, pz);
|
||||
sim.targets.push(p);
|
||||
sim.target_health.push(100.0);
|
||||
}
|
||||
@@ -88,13 +212,15 @@ pub(super) fn cluster(sim: &mut Sim, s: &Setup) {
|
||||
let a = rng.f() * TAU;
|
||||
let r = 30.0 + rng.f() * 110.0;
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, MIX[i % MIX.len()], at + DVec3::new(a.sin() * r, 0.0, a.cos() * r), rng.f() * TAU));
|
||||
let (px, pz) = (at.x + a.sin() * r, at.z + a.cos() * r);
|
||||
sim.sams.push(Sam::new(id, MIX[i % MIX.len()], DVec3::new(px, y(px, pz), pz), rng.f() * TAU));
|
||||
}
|
||||
for _ in 0..s.radars {
|
||||
let a = rng.f() * TAU;
|
||||
let r = rng.f() * 220.0;
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(a.sin() * r, 70.0, a.cos() * r), 0.0));
|
||||
let (px, pz) = (a.sin() * r, a.cos() * r);
|
||||
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(px, if s.hilly { y(px, pz) } else { 70.0 }, pz), 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,13 +230,13 @@ pub(super) fn fly(s: &Setup) -> Result {
|
||||
cluster(&mut sim, s);
|
||||
(s.tweak)(&mut sim);
|
||||
let mut ground = Ground::default();
|
||||
let land = Land(|_, _| 64);
|
||||
let mut land = Loader::new(s);
|
||||
let mut out = Out::default();
|
||||
for _ in 0..200 {
|
||||
serve(&mut ground, &land, sim.tick);
|
||||
land.serve(&mut ground, sim.tick);
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
let mut order = crate::wave::WaveConfig::mix(s.size, 500.0);
|
||||
let mut order = crate::wave::WaveConfig::mix(s.size, s.dist);
|
||||
// The Oreshnik's blocks are a raid of their own; this measures the ordinary mass raid.
|
||||
order.kinds.retain(|k| k.0 != DroneKind::Oreshnik);
|
||||
if s.smart {
|
||||
@@ -125,7 +251,7 @@ pub(super) fn fly(s: &Setup) -> Result {
|
||||
let start = sim.tick;
|
||||
let mut gone_unkilled: Vec<Hist> = Vec::new();
|
||||
for _ in 0..9000 {
|
||||
serve(&mut ground, &land, sim.tick);
|
||||
land.serve(&mut ground, sim.tick);
|
||||
out = Out::default();
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
let now = sim.tick;
|
||||
@@ -184,6 +310,9 @@ pub(super) fn fly(s: &Setup) -> Result {
|
||||
break;
|
||||
}
|
||||
}
|
||||
result.tickets = land.tickets;
|
||||
result.scrubs = sim.scrubs.clone();
|
||||
result.crashed = sim.crashed.iter().map(|c| (c.0, c.1, c.1.distance(c.2))).collect();
|
||||
result.ticks = sim.tick - start;
|
||||
result.launched = sim.launched;
|
||||
result.kills = sim.kills;
|
||||
@@ -191,6 +320,8 @@ pub(super) fn fly(s: &Setup) -> Result {
|
||||
result.heavy = sim.sams.iter().filter(|m| matches!(m.kind, SamKind::S400 | SamKind::S500 | SamKind::Patriot)).map(|m| m.fired).sum();
|
||||
result.hits = sim.hits;
|
||||
result.crashes = sim.crashes;
|
||||
result.blocked = sim.tally.blocked;
|
||||
result.scrubbed = sim.tally.scrubbed;
|
||||
result.leaked = sim.hits;
|
||||
let mut kinds: HashMap<DroneKind, (u32, u32)> = HashMap::new();
|
||||
for h in &gone_unkilled {
|
||||
@@ -215,33 +346,60 @@ pub(super) fn fly(s: &Setup) -> Result {
|
||||
}
|
||||
|
||||
pub(super) fn table(title: &str, smart: bool, size: u32, systems: &[usize], seeds: &[u64], tweak: fn(&mut Sim)) -> Vec<(usize, f64)> {
|
||||
table_on(title, smart, size, systems, seeds, tweak, std::env::var("HILLS").is_ok())
|
||||
}
|
||||
|
||||
pub(super) fn table_on(title: &str, smart: bool, size: u32, systems: &[usize], seeds: &[u64], tweak: fn(&mut Sim), hilly: bool) -> Vec<(usize, f64)> {
|
||||
let mut means = Vec::new();
|
||||
eprintln!("== {title} ({} raid)", if smart { "smart" } else { "mix" });
|
||||
eprintln!("{:>4} {:>4} {:>5} {:>5} {:>5} {:>6} {:>6} | leaks: unseen nocover untrk unshot missed hits crash", "sams", "seed", "sent", "kills", "leak", "missil", "heavy");
|
||||
eprintln!("== {title} ({} raid{})", if smart { "smart" } else { "mix" }, if hilly { ", hills" } else { "" });
|
||||
eprintln!("{:>4} {:>4} {:>5} {:>5} {:>5} {:>6} {:>6} | leaks: unseen nocover untrk unshot missed hits crash blkd scrub", "sams", "seed", "sent", "kills", "leak", "missil", "heavy");
|
||||
for &n in systems {
|
||||
let (mut total, mut missiles, mut heavy, mut clean) = (0, 0, 0, 0);
|
||||
let (mut total, mut missiles, mut heavy, mut clean, mut crashes, mut blocked) = (0, 0, 0, 0, 0, 0);
|
||||
for &seed in seeds {
|
||||
let mut s = Setup::new(n, smart, seed);
|
||||
s.tweak = tweak;
|
||||
s.size = size;
|
||||
s.hilly = hilly;
|
||||
let r = fly(&s);
|
||||
total += r.leaked;
|
||||
clean += u32::from(r.leaked == 0);
|
||||
crashes += r.crashes;
|
||||
blocked += r.blocked + r.scrubbed;
|
||||
missiles += r.missiles;
|
||||
heavy += r.heavy;
|
||||
if std::env::var("ROWS").is_ok() { eprintln!("{n:>4} {seed:>4} {:>5} {:>5} {:>5} {:>6} {:>6} | {:>12} {:>7} {:>5} {:>6} {:>6} {:>4} {:>5} {:?}", r.launched, r.kills, r.leaked, r.missiles, r.heavy, r.unseen, r.no_cover, r.untracked, r.unshot, r.missed, r.hits, r.crashes, r.by_kind.iter().map(|k| format!("{:?}:{}", k.0, k.1)).collect::<Vec<_>>()); }
|
||||
if std::env::var("ROWS").is_ok() { eprintln!("{n:>4} {seed:>4} {:>5} {:>5} {:>5} {:>6} {:>6} | {:>12} {:>7} {:>5} {:>6} {:>6} {:>4} {:>5} {:>4} {:>5} {:?}", r.launched, r.kills, r.leaked, r.missiles, r.heavy, r.unseen, r.no_cover, r.untracked, r.unshot, r.missed, r.hits, r.crashes, r.blocked, r.scrubbed, r.by_kind.iter().map(|k| format!("{:?}:{}", k.0, k.1)).collect::<Vec<_>>()); }
|
||||
if seed == seeds[0] && std::env::var("ROWS").is_ok() {
|
||||
eprintln!(" tally: {}", r.tally);
|
||||
}
|
||||
}
|
||||
let mean = total as f64 / seeds.len() as f64;
|
||||
let k = seeds.len() as f64;
|
||||
eprintln!("{n:>4} mean leaks {mean:.1} (clean seeds {clean}/{}), missiles {:.0}, of them heavy {:.0}", seeds.len(), missiles as f64 / k, heavy as f64 / k);
|
||||
eprintln!("{n:>4} mean leaks {mean:.1} (clean seeds {clean}/{}), missiles {:.0}, of them heavy {:.0}, drone crashes {:.1}, interceptors lost to land {:.1}", seeds.len(), missiles as f64 / k, heavy as f64 / k, crashes as f64 / k, blocked as f64 / k);
|
||||
means.push((n, mean));
|
||||
}
|
||||
means
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn buried_table() {
|
||||
for n in [20usize, 150] {
|
||||
for seed in 1..=4u64 {
|
||||
let mut s = Setup::new(n, false, seed);
|
||||
s.hilly = true;
|
||||
s.bury = 7.0;
|
||||
let r = fly(&s);
|
||||
eprintln!("buried {n} seed {seed}: sent {} kills {} leaked {} crashes {} blocked {} missiles {}", r.launched, r.kills, r.leaked, r.crashes, r.blocked, r.missiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn hills_table() {
|
||||
table_on("hills", false, 36, &[20, 50, 150], &[1, 2, 3, 4, 5, 6], |_| {}, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn raid_table() {
|
||||
@@ -265,6 +423,66 @@ fn a_small_cluster_loses_and_a_big_one_holds() {
|
||||
assert!(big.leaked <= 5, "150 systems leaked {}", big.leaked);
|
||||
}
|
||||
|
||||
/// The same on rolling hills: 150 systems still hold about as on flat land, drones do not fly into the land on
|
||||
/// their own, and the interceptors do not routinely strike it (a few that blow up short of a ridge are allowed).
|
||||
#[test]
|
||||
fn on_hills_a_big_cluster_holds_and_nothing_routinely_hits_the_land() {
|
||||
let mut s = Setup::new(150, false, 2);
|
||||
s.size = 80;
|
||||
s.hilly = true;
|
||||
let big = fly(&s);
|
||||
let mut s = Setup::new(20, false, 2);
|
||||
s.size = 80;
|
||||
s.hilly = true;
|
||||
let small = fly(&s);
|
||||
eprintln!("hills, 20 systems: {small:?}\nhills, 150 systems: {big:?}");
|
||||
assert!(small.leaked >= 10, "20 systems should clearly lose on hills too, leaked {}", small.leaked);
|
||||
assert!(big.leaked <= 6, "150 systems on hills leaked {}", big.leaked);
|
||||
assert!(big.crashes <= 2, "drones flew into the hills: {}", big.crashes);
|
||||
assert!(big.blocked * 20 <= big.missiles, "interceptors struck the land: {} of {}", big.blocked, big.missiles);
|
||||
}
|
||||
|
||||
/// A raid from 2500 blocks over mountains, with only a disc round the player known (and chunks that load on request):
|
||||
/// the flights do not fly into the land by themselves (a rare one that cannot climb a ridge in time is allowed).
|
||||
#[test]
|
||||
fn far_raids_over_mountains_do_not_crash_into_them() {
|
||||
let (mut flown, mut crashed) = (0, 0);
|
||||
for seed in [3u64, 4, 8] {
|
||||
for smart in [false, true] {
|
||||
let mut s = Setup::new(50, smart, seed);
|
||||
s.hilly = true;
|
||||
s.mountains = true;
|
||||
s.dist = 2500.0;
|
||||
s.disc = Some(300.0);
|
||||
let r = fly(&s);
|
||||
eprintln!("seed {seed} smart {smart}: {} crashed of {}: {:?}", r.crashed.len(), r.launched, r.crashed.iter().map(|c| c.0).collect::<Vec<_>>());
|
||||
flown += r.launched;
|
||||
crashed += r.crashed.len() as u32;
|
||||
}
|
||||
}
|
||||
assert!(crashed * 40 <= flown, "{crashed} of {flown} flew into the land");
|
||||
}
|
||||
|
||||
/// Steep forested hills: an interceptor that is fired is one the land lets through. The launch check asks the same
|
||||
/// of a shot as the flight does (the margin over the land, the loft), so few blow up short of a ridge.
|
||||
#[test]
|
||||
fn on_steep_forested_hills_few_interceptors_are_scrubbed() {
|
||||
let (mut fired, mut scrubbed) = (0, 0);
|
||||
for seed in 1..=3u64 {
|
||||
let mut s = Setup::new(15, false, seed);
|
||||
s.radars = 2;
|
||||
s.objects = 4;
|
||||
s.hilly = true;
|
||||
s.steep = 1.6;
|
||||
s.trees = true;
|
||||
let r = fly(&s);
|
||||
fired += r.missiles;
|
||||
scrubbed += r.scrubbed;
|
||||
}
|
||||
eprintln!("scrubbed {scrubbed} of {fired}");
|
||||
assert!(scrubbed * 100 <= fired * 10, "{scrubbed} of {fired} interceptors blew up short of a ridge");
|
||||
}
|
||||
|
||||
/// What each of the new rules adds, one at a time (the raid of 36 and a heavier one of 80).
|
||||
#[test]
|
||||
#[ignore]
|
||||
@@ -295,3 +513,67 @@ fn one_raid() {
|
||||
eprintln!("seed {seed}: sent {} kills {} leaked {} missiles {} ticks {}", r.launched, r.kills, r.leaked, r.missiles, r.ticks);
|
||||
}
|
||||
}
|
||||
|
||||
/// Far launches over mountains where only a disc round the player is known: who crashes, where and how often.
|
||||
/// `cargo test --release far_table -- --ignored --nocapture` (DISC=300 sets the radius, 0 for all known).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn far_table() {
|
||||
let disc: f64 = std::env::var("DISC").ok().and_then(|v| v.parse().ok()).unwrap_or(300.0);
|
||||
let dist: f64 = std::env::var("DIST").ok().and_then(|v| v.parse().ok()).unwrap_or(2500.0);
|
||||
let mut by_kind: HashMap<DroneKind, (u32, u32)> = HashMap::new();
|
||||
let (mut launched, mut kills, mut leaked, mut tickets) = (0, 0, 0, 0);
|
||||
for seed in std::env::var("SEED").ok().and_then(|v| v.parse::<u64>().ok()).map_or(1..=8u64, |v| v..=v) {
|
||||
let mut s = Setup::new(50, std::env::var("SMART").is_ok(), seed);
|
||||
s.hilly = true;
|
||||
s.mountains = true;
|
||||
s.dist = dist;
|
||||
s.disc = (disc > 0.0).then_some(disc);
|
||||
let r = fly(&s);
|
||||
launched += r.launched;
|
||||
kills += r.kills;
|
||||
leaked += r.leaked;
|
||||
tickets += r.tickets;
|
||||
for c in &r.crashed {
|
||||
by_kind.entry(c.0).or_default().0 += 1;
|
||||
if std::env::var("ROWS").is_ok() {
|
||||
let (x, z) = (c.1.x as i32, c.1.z as i32);
|
||||
eprintln!(" seed {seed} {:?} crashed at {:.0},{:.0},{:.0} ({:.0} from origin, land {}), {:.0} from its aim", c.0, c.1.x, c.1.y, c.1.z, c.1.x.hypot(c.1.z), Loader::new(&s).height(x, z), c.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut v: Vec<_> = by_kind.into_iter().collect();
|
||||
v.sort_by_key(|k| k.0 as u8);
|
||||
eprintln!("disc {disc} dist {dist}: launched {launched} kills {kills} leaked {leaked} chunk tickets {tickets}; crashed by kind {:?}", v.iter().map(|k| (k.0.clone(), k.1.0)).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// Interceptors that blew up short of a ridge (`STEEP=1.6`, `SAMS=15`, `HILLS=0` for flat): by kind, and what the target was doing.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn scrub_table() {
|
||||
let n: usize = std::env::var("SAMS").ok().and_then(|v| v.parse().ok()).unwrap_or(15);
|
||||
let hilly = std::env::var("HILLS").map_or(true, |v| v != "0");
|
||||
let (mut fired, mut scrubbed, mut kills, mut launched, mut leaked) = (0, 0, 0, 0, 0);
|
||||
let mut by: HashMap<String, (u32, u32)> = HashMap::new();
|
||||
let (mut near, mut low) = (0, 0);
|
||||
for seed in 1..=8u64 {
|
||||
let mut s = Setup::new(n, false, seed);
|
||||
s.radars = 2;
|
||||
s.objects = 4;
|
||||
s.hilly = hilly;
|
||||
let r = fly(&s);
|
||||
fired += r.missiles;
|
||||
scrubbed += r.scrubbed;
|
||||
kills += r.kills;
|
||||
launched += r.launched;
|
||||
leaked += r.leaked;
|
||||
for c in &r.scrubs {
|
||||
by.entry(format!("{:?}", c.0)).or_default().0 += 1;
|
||||
near += u32::from(c.2 < 150.0);
|
||||
low += u32::from(c.3 < 30.0);
|
||||
}
|
||||
}
|
||||
let mut v: Vec<_> = by.into_iter().collect();
|
||||
v.sort();
|
||||
eprintln!("sams {n} hilly {hilly}: launched {launched} kills {kills} leaked {leaked} missiles {fired} scrubbed {scrubbed} (target within 150: {near}, target under 30 over land: {low}) by kind {:?}", v.iter().map(|k| (k.0.clone(), k.1.0)).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn track(pos: DVec3, vel: DVec3) -> Track {
|
||||
Track { id: 1, pos, vel, tick: 0, own_tick: 0, ttl: 100, hits: 3, locked: false }
|
||||
Track { id: 1, kind: DroneKind::Shahed, pos, vel, tick: 0, own_tick: 0, ttl: 100, hits: 3, locked: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -145,6 +145,12 @@ impl Sim {
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the enemy has this unit in its intelligence: a radar emits, so it is known; the rest only once
|
||||
/// they gave themselves away (a launch, jamming) and are in `known`.
|
||||
pub(super) fn enemy_knows(s: &Sam, known: &[(u32, DVec3)]) -> bool {
|
||||
s.alive() && (s.kind == SamKind::Radar || known.iter().any(|k| k.0 == s.id))
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
|
||||
+184
-11
@@ -36,7 +36,7 @@ mod regress;
|
||||
mod sensor_tests;
|
||||
pub use ew::EW_RADIUS;
|
||||
pub use damage::{Comps, Part, PartState};
|
||||
pub use sight::{Ident, Track};
|
||||
pub use sight::{AirPicture, Ident, LowSensor, Track, RIM_RAYS};
|
||||
use damage::dice;
|
||||
use outcome::{Impact, Outcome};
|
||||
pub use units::{Family, SamKind};
|
||||
@@ -245,10 +245,19 @@ pub struct Ground {
|
||||
swept: u64,
|
||||
/// The tick of the last answer round: what the heights' ages are measured against.
|
||||
now: u64,
|
||||
/// The world's epoch when the last questions went out: an answer to them is of that world or dimension, or of none.
|
||||
asked_epoch: Option<u32>,
|
||||
/// The blocks themselves (issues #42, #43): what stops a flight.
|
||||
pub world: world::WorldCache,
|
||||
}
|
||||
|
||||
/// What `render.ground` answers where the chunk is not loaded (and never will be, unless asked for).
|
||||
pub const NO_LAND: i32 = -64;
|
||||
/// A height the game answered as a land height: none where it had no chunk. The one place that reads `NO_LAND`.
|
||||
pub(crate) fn land_height(y: i32) -> Option<f64> {
|
||||
(y > NO_LAND).then_some(f64::from(y))
|
||||
}
|
||||
|
||||
/// What `render.ground` answers (`i32::MIN`) for a column whose chunk it is loading for the mod.
|
||||
pub const PENDING: i32 = i32::MIN;
|
||||
/// Ticks before a column that was loading is asked about again.
|
||||
@@ -279,7 +288,7 @@ impl Ground {
|
||||
if (when == STALE || now.saturating_sub(when) > 200) && self.wanted.len() < 64 && self.asked.insert(key) {
|
||||
self.wanted.push(key);
|
||||
}
|
||||
(y > -64).then_some(f64::from(y))
|
||||
land_height(y)
|
||||
}
|
||||
None => {
|
||||
if self.wanted.len() < 256 && self.asked.insert(key) {
|
||||
@@ -289,8 +298,18 @@ impl Ground {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The game's answers to the questions of `take_wanted`. Each is a height (`land_height`), "no land" or
|
||||
/// "loading"; one to a question of another world or dimension than the one open now is dropped (it is
|
||||
/// asked again when needed), not taken as this one's.
|
||||
pub fn answer(&mut self, answers: Vec<(i32, i32, i32)>, now: u64) {
|
||||
self.now = now;
|
||||
let answers = match self.asked_epoch.take() {
|
||||
Some(e) if e != 0 && e != self.world.epoch() => {
|
||||
self.world.stats.late_dropped += answers.len() as u64;
|
||||
Vec::new()
|
||||
}
|
||||
_ => answers,
|
||||
};
|
||||
for (x, z, y) in answers {
|
||||
if y == PENDING {
|
||||
self.loading.insert((x, z), now);
|
||||
@@ -304,6 +323,12 @@ impl Ground {
|
||||
self.loading.retain(|_, t| now.saturating_sub(*t) < 400);
|
||||
}
|
||||
self.age_heights();
|
||||
// Blasts the game did not report in the blocks: the land there is looked at again.
|
||||
for at in self.world.overdue_blasts(now) {
|
||||
let how = if self.peek(at).is_some() { world::Settled::Unchanged } else { world::Settled::Unloaded };
|
||||
self.world.note_settled(how);
|
||||
self.forget(at, 8);
|
||||
}
|
||||
if self.known.len() > 300_000 && now.saturating_sub(self.swept) >= SWEEP_EVERY {
|
||||
self.swept = now;
|
||||
self.known.retain(|_, (_, when)| now.saturating_sub(*when) < 2000);
|
||||
@@ -312,6 +337,13 @@ impl Ground {
|
||||
/// Heights over blocks that changed (the block cache's notes of changes, chunk replacements and a new world)
|
||||
/// are marked old (`when` = STALE): still used, asked again within the budget. #64.
|
||||
fn age_heights(&mut self) {
|
||||
// Another world or dimension: nothing known, asked or loading belongs to this one.
|
||||
if std::mem::take(&mut self.world.epoch_reset) {
|
||||
self.known.clear();
|
||||
self.loading.clear();
|
||||
self.wanted.clear();
|
||||
self.asked.clear();
|
||||
}
|
||||
let all = std::mem::take(&mut self.world.changed_all);
|
||||
let cols = std::mem::take(&mut self.world.changed_cols);
|
||||
if !all && cols.is_empty() {
|
||||
@@ -356,6 +388,21 @@ impl Ground {
|
||||
return false;
|
||||
}
|
||||
self.chunks.push(c);
|
||||
// What was answered as "no chunk" there is asked again: the chunk is being loaded now.
|
||||
for x in c.0 * 16..c.0 * 16 + 16 {
|
||||
for z in c.1 * 16..c.1 * 16 + 16 {
|
||||
if self.known.get(&(x, z)).is_some_and(|v| land_height(v.0).is_none()) {
|
||||
self.known.remove(&(x, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
for x in (c.0 * 16..c.0 * 16 + 16).step_by(8) {
|
||||
for z in (c.1 * 16..c.1 * 16 + 16).step_by(8) {
|
||||
if self.known.get(&(x + 4, z + 4)).is_some_and(|v| land_height(v.0).is_none()) {
|
||||
self.known.remove(&(x + 4, z + 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// How many chunks are wanted so far this tick.
|
||||
@@ -368,6 +415,7 @@ impl Ground {
|
||||
}
|
||||
pub fn take_wanted(&mut self) -> Vec<(i32, i32)> {
|
||||
self.asked.clear();
|
||||
self.asked_epoch = Some(self.world.epoch());
|
||||
std::mem::take(&mut self.wanted)
|
||||
}
|
||||
/// The first solid block on the way from `a` to `b` (the real blocks, not the height of the land).
|
||||
@@ -911,6 +959,40 @@ pub struct Drone {
|
||||
err: DVec3,
|
||||
/// Ticks held at the edge of blocks the game has not told of (#61); reset by a clear step.
|
||||
hold: u16,
|
||||
/// Its progress towards the aim: a flight that stops getting nearer runs out of fuel (see `Pace`).
|
||||
pace: Pace,
|
||||
}
|
||||
|
||||
/// Ticks a cruise flight may go without getting nearer its aim or passing a route point before it is out of fuel:
|
||||
/// long enough for a detour round a radar, far short of `MAX_FLIGHT`. It stops a drone circling an aim it
|
||||
/// cannot turn onto, or one that holds for an answer that never comes, from loitering over the map for ever.
|
||||
pub const STALL_TICKS: u32 = 3600;
|
||||
/// How much nearer counts as getting nearer (blocks).
|
||||
const STALL_GAIN: f64 = 10.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Pace {
|
||||
/// The least distance to the aim so far, the age it was reached at, and the route points left then.
|
||||
best: f64,
|
||||
at: u32,
|
||||
route: usize,
|
||||
}
|
||||
|
||||
impl Default for Pace {
|
||||
fn default() -> Self {
|
||||
Pace { best: f64::INFINITY, at: 0, route: usize::MAX }
|
||||
}
|
||||
}
|
||||
|
||||
impl Pace {
|
||||
/// One tick: true when the flight has gone `STALL_TICKS` without progress.
|
||||
fn stalled(&mut self, age: u32, distance: f64, route: usize) -> bool {
|
||||
if distance + STALL_GAIN < self.best || route < self.route {
|
||||
*self = Pace { best: distance.min(self.best), at: age, route };
|
||||
return false;
|
||||
}
|
||||
age.saturating_sub(self.at) > STALL_TICKS
|
||||
}
|
||||
}
|
||||
|
||||
/// A drone's terrain following: refreshed every few ticks, used every one.
|
||||
@@ -920,11 +1002,14 @@ struct Nav {
|
||||
need: f64,
|
||||
/// The height from which a straight dive on its target clears the land (unknown: -inf).
|
||||
dive: f64,
|
||||
/// The land ahead is not known (a chunk the game does not have, or not yet): it keeps a transit height
|
||||
/// above anything it could meet and comes down only once the land ahead is told of.
|
||||
blind: bool,
|
||||
}
|
||||
|
||||
impl Default for Nav {
|
||||
fn default() -> Self {
|
||||
Nav { need: f64::NEG_INFINITY, dive: f64::NEG_INFINITY }
|
||||
Nav { need: f64::NEG_INFINITY, dive: f64::NEG_INFINITY, blind: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1492,6 +1577,16 @@ pub struct Sim {
|
||||
pub hits: u32,
|
||||
/// Drones that struck the land or a building short of their aim.
|
||||
pub crashes: u32,
|
||||
/// Cruise flights that ran out of fuel for making no headway (`STALL_TICKS`).
|
||||
pub stalled: u32,
|
||||
/// Systems just deployed by hand and not yet checked for standing in the land: (id, until tick).
|
||||
settling: Vec<(u32, u64)>,
|
||||
/// What crashed, where, and its aim (balance measurements only).
|
||||
#[cfg(test)]
|
||||
pub crashed: Vec<(DroneKind, DVec3, DVec3)>,
|
||||
/// Interceptors that blew up short of a ridge: kind, age, distance to the target, the target's height over the land under it (balance measurements only).
|
||||
#[cfg(test)]
|
||||
pub scrubs: Vec<(MissileKind, u32, f64, f64, DVec3, f64, f64)>,
|
||||
pub launched: u32,
|
||||
/// Explosions damage the land (primed TNT through commands).
|
||||
pub damage: bool,
|
||||
@@ -1578,6 +1673,12 @@ impl Sim {
|
||||
kills: 0,
|
||||
hits: 0,
|
||||
crashes: 0,
|
||||
stalled: 0,
|
||||
settling: Vec::new(),
|
||||
#[cfg(test)]
|
||||
crashed: Vec::new(),
|
||||
#[cfg(test)]
|
||||
scrubs: Vec::new(),
|
||||
launched: 0,
|
||||
damage: true,
|
||||
seed: seed.wrapping_mul(0x2545_F491_4F6C_DD1D) | 1,
|
||||
@@ -1707,12 +1808,43 @@ impl Sim {
|
||||
self.fires.extinguish();
|
||||
}
|
||||
|
||||
/// A system just put down by the player is looked at for a few seconds: one standing inside the land (a player
|
||||
/// who fell into a chunk that had not loaded) is brought up to the surface. Buried, its interceptors would
|
||||
/// strike the dirt above it and the raid's aim at it would lie under the land. Only a spot whose block is known
|
||||
/// solid and whose land height is known is moved, to that height.
|
||||
pub fn settle(&mut self, id: u32) {
|
||||
self.settling.push((id, self.tick + 100));
|
||||
}
|
||||
|
||||
fn step_settling(&mut self, ground: &mut Ground) {
|
||||
let now = self.tick;
|
||||
let sams = &mut self.sams;
|
||||
self.settling.retain(|&(id, until)| {
|
||||
let Some(sam) = sams.iter_mut().find(|s| s.id == id) else { return false };
|
||||
let p = sam.pos;
|
||||
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
if ground.world.is_known_open(x, y, z, now) {
|
||||
return false;
|
||||
}
|
||||
if ground.world.is_known_solid(x, y, z, now) {
|
||||
if let Some(g) = ground.at(p, now) {
|
||||
if g > p.y + 1.5 && g < p.y + 96.0 {
|
||||
sam.pos.y = g;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
now < until
|
||||
});
|
||||
}
|
||||
|
||||
/// One game tick.
|
||||
pub fn step(&mut self, ground: &mut Ground, player: Option<DVec3>, out: &mut Out) {
|
||||
self.tick += 1;
|
||||
self.plan_step(Some(&mut *ground), out);
|
||||
self.adapt();
|
||||
self.release_queue(out);
|
||||
self.step_settling(ground);
|
||||
self.release_queue(ground, out);
|
||||
self.step_drones(ground, player, out);
|
||||
self.step_later(ground, player, out);
|
||||
self.step_moves(ground, out);
|
||||
@@ -1754,11 +1886,12 @@ impl Sim {
|
||||
let from = d.pos;
|
||||
ground.ahead(d.pos, d.vel, (120.0 / d.vel.length().max(0.05)).max(20.0), now);
|
||||
// Near its aim it looks at the land there again: a platform or hilltop blown away
|
||||
// meanwhile, it goes for what is left below, not for the air where it stood.
|
||||
// meanwhile, it goes for what is left below, not for the air where it stood; an aim inside the land (a mark
|
||||
// set from a spot the player stood in the ground) is the surface over it, else the strike counts as a crash.
|
||||
if d.flight != Flight::Falling
|
||||
&& horizontal(d.target - d.pos).length() < 250.0
|
||||
&& let Some(g) = ground.at(d.target, now)
|
||||
&& g < d.target.y - 1.5
|
||||
&& (g < d.target.y - 1.5 || (g > d.target.y + 1.5 && !spec.decoy && spec.ballistic.is_none() && g < d.target.y + 96.0))
|
||||
{
|
||||
d.target.y = g;
|
||||
}
|
||||
@@ -1820,6 +1953,10 @@ impl Sim {
|
||||
// Into the land or a building short of its aim: a crash, not a hit.
|
||||
if landed && d.pos.distance(d.target) >= speed.max(2.0) + 6.0 {
|
||||
crashes.push((d.id, d.kind, d.pos, spec.warhead));
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.crashed.push((d.kind, d.pos, d.target));
|
||||
}
|
||||
removed.push(d.id);
|
||||
continue;
|
||||
}
|
||||
@@ -1882,6 +2019,15 @@ impl Sim {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Out of fuel: no nearer its aim for a long time (it circles, or waits for what never comes): it falls
|
||||
// where it is, a wreck like any other, and the raid is not held open by it.
|
||||
if d.pace.stalled(d.age, d.pos.distance(d.target), d.route.len()) {
|
||||
d.flight = Flight::Falling;
|
||||
d.age = 0;
|
||||
d.vel *= 0.35;
|
||||
self.stalled += 1;
|
||||
continue;
|
||||
}
|
||||
// An interceptor coming: drones drop to the treetops (radars lose them sooner).
|
||||
if d.engaged > 0 && matches!(spec.class, Class::Drone | Class::Jet) {
|
||||
d.low = true;
|
||||
@@ -1921,7 +2067,9 @@ impl Sim {
|
||||
lines.push(((heading + towards).normalize_or_zero(), reach));
|
||||
}
|
||||
}
|
||||
d.nav.need = terrain::required_height(ground, d.pos, &lines, spec.clearance, &climb).unwrap_or(f64::NEG_INFINITY);
|
||||
let (need, blind) = terrain::required_height(ground, d.pos, &lines, spec.clearance, &climb);
|
||||
d.nav.need = need.unwrap_or(f64::NEG_INFINITY);
|
||||
d.nav.blind = blind;
|
||||
d.nav.dive = if to_target < dive_at * 3.0 {
|
||||
terrain::dive_height(ground, d.pos, d.target, terrain::DIVE_MARGIN).unwrap_or(f64::NEG_INFINITY)
|
||||
} else {
|
||||
@@ -1954,13 +2102,18 @@ impl Sim {
|
||||
} else {
|
||||
let heading = horizontal(way - d.pos).normalize_or(horizontal(old).normalize_or(DVec3::NEG_Z));
|
||||
// Cruise height over the target; as low as the land lets it when under the radars.
|
||||
let base = if d.low { d.target.y + 4.0 } else { d.target.y + spec.cruise };
|
||||
let base = if d.low && !d.nav.blind { d.target.y + 4.0 } else { d.target.y + spec.cruise };
|
||||
let own = below.map_or(f64::NEG_INFINITY, |g| g + spec.clearance);
|
||||
let need = d.nav.need.max(own);
|
||||
let safe = if need.is_finite() { need + 2.0 } else { d.target.y + spec.clearance };
|
||||
let height = base.max(safe).max(d.nav.dive + 1.0);
|
||||
let mut height = base.max(safe).max(d.nav.dive + 1.0);
|
||||
// Blind: it does not come down over land nobody has told of; it holds the height it has.
|
||||
if d.nav.blind && d.low && to_target > 300.0 {
|
||||
height = height.max(d.pos.y);
|
||||
}
|
||||
let full = climb.pitch.tan();
|
||||
let urgent = need.is_finite() && d.pos.y < need;
|
||||
// Blind (land ahead unknown): to the transit height at once, not at the gentle slope of a descent plan.
|
||||
let urgent = (need.is_finite() && d.pos.y < need) || (d.nav.blind && d.pos.y < base - 2.0);
|
||||
let slope = if urgent {
|
||||
full
|
||||
} else {
|
||||
@@ -1997,6 +2150,10 @@ impl Sim {
|
||||
if in_ground && d.pos.distance(d.target) >= speed.max(1.5) + 3.0 {
|
||||
if !spec.decoy {
|
||||
crashes.push((d.id, d.kind, d.pos, spec.warhead));
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.crashed.push((d.kind, d.pos, d.target));
|
||||
}
|
||||
}
|
||||
removed.push(d.id);
|
||||
continue;
|
||||
@@ -2084,6 +2241,7 @@ impl Sim {
|
||||
nav: Nav::default(),
|
||||
err: DVec3::ZERO,
|
||||
hold: 0,
|
||||
pace: Pace::default(),
|
||||
});
|
||||
}
|
||||
for d in &self.drones {
|
||||
@@ -2177,6 +2335,7 @@ impl Sim {
|
||||
));
|
||||
}
|
||||
ground.forget(pos, 8);
|
||||
ground.world.expect_blast(pos, 8.0, self.tick);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2702,6 +2861,8 @@ impl Sim {
|
||||
let mut done: Vec<(u32, Option<u32>, bool, u32)> = Vec::new(); // (missile, target, kill, sam)
|
||||
let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32);
|
||||
let (mut blocked, mut scrubbed) = (0u32, 0u32);
|
||||
#[cfg(test)]
|
||||
let mut scrub_log = Vec::new();
|
||||
let sams_fit: HashMap<u32, f64> = self.sams.iter().map(|s| (s.id, 1.0 - 0.3 * (1.0 - s.health.clamp(0.0, 1.0)))).collect();
|
||||
// Where each launcher stands and how far its missiles reach: a hit past that is less likely (`why::reach_fit`).
|
||||
let launchers: HashMap<u32, (DVec3, f64)> = self.sams.iter().filter_map(|s| Some((s.id, (s.pos, s.kind.spec().missile.as_ref()?.max)))).collect();
|
||||
@@ -2762,9 +2923,19 @@ impl Sim {
|
||||
let closing = (speed - vel.dot(los)).max(1.0);
|
||||
let tgo = at.distance(m.pos) / closing;
|
||||
// Over the land in the way: lofted as far as the missile can, else it blows up before the ridge.
|
||||
let (want, fail) = terrain::steer(ground, m.kind, m.pos, speed, at + vel * tgo);
|
||||
let (want, fail) = terrain::steer(ground, m.kind, m.pos, speed, at + vel * tgo, now);
|
||||
if fail {
|
||||
scrubbed += 1;
|
||||
#[cfg(test)]
|
||||
{
|
||||
let over = target.and_then(|t| ground.at(t.pos, now).map(|g| t.pos.y - g)).unwrap_or(f64::NAN);
|
||||
let dist = target.map_or(f64::NAN, |t| t.pos.distance(m.pos));
|
||||
let mover = ground.at(m.pos, now).map_or(f64::NAN, |g| m.pos.y - g);
|
||||
let from = launchers.get(&m.sam).map_or(0.0, |l| m.pos.distance(l.0));
|
||||
if scrub_log.len() < 30 {
|
||||
scrub_log.push((m.kind, m.age, dist, over, m.pos, mover, from));
|
||||
}
|
||||
}
|
||||
done.push((m.id, target.map(|t| t.id), false, m.sam));
|
||||
continue;
|
||||
}
|
||||
@@ -2854,6 +3025,8 @@ impl Sim {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
self.scrubs.extend(scrub_log);
|
||||
self.note(|t| {
|
||||
t.missed += missed;
|
||||
t.expired += expired;
|
||||
|
||||
@@ -237,6 +237,9 @@ impl PartialOrd for Open {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticks an air start waits for the land under it to be told of.
|
||||
const AIR_START_WAIT: u32 = 120;
|
||||
|
||||
impl Sim {
|
||||
/// Orders a raid on `objects` (at least one point). A smart raid is planned over the next ticks
|
||||
/// (each route found in full, a few a tick) and launched when its plan is ready.
|
||||
@@ -509,6 +512,7 @@ impl Sim {
|
||||
nav: Nav::default(),
|
||||
err,
|
||||
hold: 0,
|
||||
pace: Pace::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,8 +561,11 @@ impl Sim {
|
||||
}
|
||||
// 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 known = self.known_units();
|
||||
let known_ew: Vec<&Sam> = alive.iter().copied().filter(|s| s.kind == SamKind::Ew && known.iter().any(|k| k.0 == s.id)).collect();
|
||||
let alive: Vec<&Sam> = alive.iter().copied().filter(|s| Self::enemy_knows(s, &known)).collect();
|
||||
let known_ew: Vec<&Sam> = alive.iter().copied().filter(|s| s.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 (s.pos, Some(s.id));
|
||||
@@ -590,6 +597,7 @@ impl Sim {
|
||||
let mut cost = 0;
|
||||
let spec = kind.spec();
|
||||
let height = spec.clearance.max(6.0);
|
||||
let known_now = self.known_units();
|
||||
let n = grid.size;
|
||||
let mut prices = vec![1.0f32; (n * n) as usize];
|
||||
// Known jamming stations: drones that navigate by satellite keep away.
|
||||
@@ -607,7 +615,8 @@ impl Sim {
|
||||
}
|
||||
}
|
||||
}
|
||||
for s in self.sams.iter().filter(|s| s.alive() && s.roles & spec.class.bit() != 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 sp = s.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);
|
||||
@@ -649,8 +658,11 @@ impl Sim {
|
||||
(prices, cost)
|
||||
}
|
||||
|
||||
/// A planned raid's drones take off when their time comes.
|
||||
pub(super) fn release_queue(&mut self, out: &mut Out) {
|
||||
/// Ticks an air start waits for the land under it to be told of.
|
||||
const AIR_START_WAIT: u32 = 120;
|
||||
|
||||
/// A planned raid's drones take off when their time comes.
|
||||
pub(super) fn release_queue(&mut self, ground: &mut Ground, out: &mut Out) {
|
||||
let now = self.tick;
|
||||
if self.queue.iter().all(|q| q.0 > now) {
|
||||
return;
|
||||
@@ -658,16 +670,41 @@ impl Sim {
|
||||
let (go, wait): (Vec<_>, Vec<_>) = std::mem::take(&mut self.queue).into_iter().partition(|q| q.0 <= now);
|
||||
self.queue = wait;
|
||||
let mut cancelled = 0;
|
||||
for (t, d) in go {
|
||||
for (t, mut d) in go {
|
||||
// A site's job leaves its site one at a time, from its stock; a destroyed or empty site cancels it.
|
||||
let from_site = self.sites.site_of(d.id);
|
||||
// An air start (an off-map site's drone) was planned over land nobody had read. It waits (a second or
|
||||
// two) for the land under it to be known, and begins above it if that is higher than the start: else it
|
||||
// is born inside a hill and crashes at once.
|
||||
let air = from_site.is_some_and(|id| self.sites.list.iter().any(|s| s.id == id && s.offmap));
|
||||
if air {
|
||||
let clear = if d.kind.spec().ballistic.is_some() { 10.0 } else { d.kind.spec().clearance };
|
||||
match ground.at(d.pos, now) {
|
||||
None if d.age < AIR_START_WAIT => {
|
||||
// The game has no chunk there: it is asked to load the ones round the start (and kept a while).
|
||||
let c = ((d.pos.x.floor() as i32) >> 4, (d.pos.z.floor() as i32) >> 4);
|
||||
for (dx, dz) in [(0, 0), (1, 0), (-1, 0), (0, 1), (0, -1)] {
|
||||
ground.want_chunk((c.0 + dx, c.1 + dz));
|
||||
}
|
||||
d.age += 1;
|
||||
self.queue.push((t, d));
|
||||
continue;
|
||||
}
|
||||
Some(g) if d.pos.y < g + clear => d.pos.y = g + clear,
|
||||
_ => {}
|
||||
}
|
||||
d.age = 0;
|
||||
}
|
||||
let slot = self.site_slot(d.id);
|
||||
if let (Some(site), sites::Slot::Go | sites::Slot::Air) = (from_site, &slot) {
|
||||
self.scout_launch(d.id, site, d.kind, d.pos);
|
||||
}
|
||||
match slot {
|
||||
// Free: not a site's job. Air: an off-map site's drone, already in flight position.
|
||||
sites::Slot::Free | sites::Slot::Air => {}
|
||||
sites::Slot::Free => {}
|
||||
// The air start was planned over land nobody had read; if it is read by now and the land there is
|
||||
// higher than the start, it begins above it (else it is born inside a hill and crashes at once).
|
||||
sites::Slot::Air => {}
|
||||
sites::Slot::Busy => {
|
||||
self.queue.push((t, d));
|
||||
continue;
|
||||
@@ -677,6 +714,14 @@ impl Sim {
|
||||
continue;
|
||||
}
|
||||
sites::Slot::Go => {
|
||||
// On a slope the pad's side or the muzzle's far end can stand in the land that rises there: the
|
||||
// drone leaves from above the land at its own column, not from inside the hill.
|
||||
if let Some(g) = ground.at(d.pos, now)
|
||||
&& d.pos.y < g + 1.5
|
||||
&& g < d.pos.y + 40.0
|
||||
{
|
||||
d.pos.y = g + 1.5;
|
||||
}
|
||||
let dir = d.vel.normalize_or_zero();
|
||||
out.sound(d.kind.launch_sound(), d.pos, 1.0, 1.0);
|
||||
out.particles("minecraft:flame", d.pos - dir * 1.5, 0.15, -dir * 0.1, 0.02, 4);
|
||||
|
||||
@@ -51,6 +51,10 @@ fn network_launcher_must_contribute_to_route_risk() {
|
||||
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::P1sun,DVec3::new(0.,70.,0.),0.));
|
||||
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::Radar,DVec3::new(0.,70.,0.),0.));
|
||||
let grid=Grid{min:DVec3::new(-160.,0.,-160.),cell:16.,size:20};
|
||||
// #93: the planner prices only what the enemy knows; the radar emits, the launcher is not known until it gives itself away.
|
||||
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));
|
||||
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");
|
||||
}
|
||||
@@ -1594,3 +1598,209 @@ fn mass_strikes_keep_fires_effects_and_lights_bounded() {
|
||||
assert!(sim.fires.is_empty(), "fires burn for ever");
|
||||
let _ = commands;
|
||||
}
|
||||
|
||||
/// A system put down inside the land (the player stood in the ground) is brought up to the surface, and its
|
||||
/// interceptors then leave the launcher; one on the surface or in the open air is left where it is.
|
||||
#[test]
|
||||
fn a_system_deployed_inside_the_land_comes_up() {
|
||||
let land = sites::testland::Land(|_, _| 63);
|
||||
let mut sim = Sim::new(1);
|
||||
let mut ground = Ground::default();
|
||||
let mut out = Out::default();
|
||||
let buried = sim.id();
|
||||
sim.sams.push(Sam::new(buried, SamKind::Patriot, DVec3::new(20.0, 56.0, 17.0), 0.0));
|
||||
sim.settle(buried);
|
||||
let open = sim.id();
|
||||
sim.sams.push(Sam::new(open, SamKind::S400, DVec3::new(60.0, 63.0, 17.0), 0.0));
|
||||
sim.settle(open);
|
||||
let air = sim.id();
|
||||
sim.sams.push(Sam::new(air, SamKind::Tor, DVec3::new(90.0, 70.0, 17.0), 0.0));
|
||||
sim.settle(air);
|
||||
for _ in 0..60 {
|
||||
sites::testland::step(&mut sim, &mut ground, &land, &mut out);
|
||||
}
|
||||
let y = |id| sim.sams.iter().find(|s| s.id == id).unwrap().pos.y;
|
||||
assert_eq!(y(buried), 63.0, "a buried system comes up to the surface");
|
||||
assert_eq!(y(open), 63.0);
|
||||
assert_eq!(y(air), 70.0, "one in the open air is not moved");
|
||||
}
|
||||
|
||||
/// An aim point inside the land (an object marked where the player stood in the ground) is the surface over it:
|
||||
/// a drone that arrives is a hit, not a crash into the land short of its aim.
|
||||
#[test]
|
||||
fn a_drone_at_an_aim_under_the_land_hits() {
|
||||
let (mut ground, land) = (Ground::default(), sites::testland::Land(|_, _| 63));
|
||||
let mut sim = Sim::new(5);
|
||||
sim.targets.push(DVec3::new(0.0, 56.0, 0.0));
|
||||
sim.target_health.push(100.0);
|
||||
let d = sim.make_drone(DroneKind::Geran3, DVec3::new(-400.0, 100.0, 0.0), DVec3::X, DVec3::new(0.0, 56.0, 0.0));
|
||||
sim.drones.push(d);
|
||||
let mut out = Out::default();
|
||||
for _ in 0..1200 {
|
||||
sites::testland::step(&mut sim, &mut ground, &land, &mut out);
|
||||
if sim.drones.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!((sim.hits, sim.crashes), (1, 0), "hits {} crashes {}", sim.hits, sim.crashes);
|
||||
}
|
||||
|
||||
/// Over land the game has not told of, a low-flying drone does not hug the height of its aim: it holds the transit
|
||||
/// height (the cruise height over the aim) and comes down only once the land ahead is known.
|
||||
#[test]
|
||||
fn over_unknown_land_a_low_flight_holds_its_transit_height() {
|
||||
let aim = DVec3::new(0.0, 64.0, 3000.0);
|
||||
let fly = |known: bool| {
|
||||
let mut sim = Sim::new(4);
|
||||
let mut d = sim.make_drone(DroneKind::Kh101, DVec3::new(0.0, 70.0, 0.0), DVec3::Z, aim);
|
||||
d.low = true;
|
||||
d.route = vec![aim];
|
||||
sim.drones.push(d);
|
||||
let (mut ground, land) = (Ground::default(), sites::testland::Land(|_, _| 64));
|
||||
let mut out = Out::default();
|
||||
for _ in 0..300 {
|
||||
if known {
|
||||
sites::testland::step(&mut sim, &mut ground, &land, &mut out);
|
||||
} else {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
}
|
||||
sim.drones[0].pos.y
|
||||
};
|
||||
let spec = DroneKind::Kh101.spec();
|
||||
let blind = fly(false);
|
||||
let seen = fly(true);
|
||||
assert!(blind > aim.y + spec.cruise - 8.0, "blind and low: {blind}");
|
||||
assert!(seen < aim.y + 20.0, "over land it knows it keeps hugging it: {seen}");
|
||||
}
|
||||
|
||||
/// The height map is a coarse upper bound (a forest's canopy shows as land): an interceptor under it, with the blocks
|
||||
/// around it open, is not blown up for "no way over"; it is blown up only before blocks that are really there.
|
||||
#[test]
|
||||
fn an_interceptor_under_the_canopy_is_not_scrubbed_before_open_air() {
|
||||
let blocks = sites::testland::Land(|_, _| 64);
|
||||
let steer = |solid_to: i32| {
|
||||
let mut ground = Ground::default();
|
||||
let land = sites::testland::Land(move |_, _| solid_to);
|
||||
let kind = MissileKind::M57e6;
|
||||
let pos = DVec3::new(0.0, 66.0, 0.0);
|
||||
let mut fail = false;
|
||||
for t in 0..30 {
|
||||
// The heights say 76 (the canopy); the blocks are solid only up to `solid_to`.
|
||||
let cols = ground.take_wanted().into_iter().map(|(x, z)| (x, z, 76)).collect();
|
||||
ground.answer(cols, t);
|
||||
let secs = ground.world.take_wanted(t);
|
||||
ground.world.serve(&land, secs, t);
|
||||
fail = terrain::steer(&mut ground, kind, pos, kind.spec().speed, DVec3::new(0.0, 70.0, 200.0), t).1;
|
||||
}
|
||||
fail
|
||||
};
|
||||
let _ = blocks;
|
||||
assert!(!steer(64), "open air under the canopy");
|
||||
assert!(steer(80), "real blocks ahead");
|
||||
}
|
||||
|
||||
/// A flight that is high over land nobody has told of does not come down towards the height of its aim.
|
||||
#[test]
|
||||
fn over_unknown_land_a_flight_does_not_descend() {
|
||||
let aim = DVec3::new(0.0, 64.0, 3000.0);
|
||||
let mut sim = Sim::new(4);
|
||||
let mut d = sim.make_drone(DroneKind::Kh101, DVec3::new(0.0, 250.0, 0.0), DVec3::Z, aim);
|
||||
d.low = true;
|
||||
d.route = vec![aim];
|
||||
sim.drones.push(d);
|
||||
let (mut ground, mut out) = (Ground::default(), Out::default());
|
||||
for _ in 0..150 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
assert!(sim.drones[0].pos.y > 245.0, "it came down over unknown land: {}", sim.drones[0].pos.y);
|
||||
}
|
||||
|
||||
/// #90: a cruise flight that stops getting nearer its aim (a turn circle wider than the distance, a hold that
|
||||
/// never ends) falls out of the sky after `STALL_TICKS`, so a raid is not held open by it.
|
||||
#[test]
|
||||
fn a_flight_that_makes_no_headway_runs_out_of_fuel() {
|
||||
let mut pace = Pace::default();
|
||||
// Nearer and nearer: never stalled.
|
||||
for age in 1..10_000u32 {
|
||||
assert!(!pace.stalled(age, 5000.0 - f64::from(age) * 0.5, 1), "a flight under way stalled at {age}");
|
||||
}
|
||||
// Circling at 40 blocks from the aim.
|
||||
let mut pace = Pace::default();
|
||||
let mut ended = None;
|
||||
for age in 1..10_000u32 {
|
||||
if pace.stalled(age, 40.0 + f64::from(age % 7), 1) {
|
||||
ended = Some(age);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let ended = ended.expect("a circling flight never ran out of fuel");
|
||||
assert!((STALL_TICKS..STALL_TICKS + 20).contains(&ended), "{ended}");
|
||||
// Passing a route point is progress, however far off the aim the way goes.
|
||||
let mut pace = Pace::default();
|
||||
for age in 1..10_000u32 {
|
||||
assert!(!pace.stalled(age, 3000.0, 10 - (age / 1000) as usize), "route points passed, still stalled at {age}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #90: the same in the simulation: a drone that gets no nearer its aim (here the aim keeps its distance, as one
|
||||
/// does that it cannot turn onto) ends, falling, within the stall time, and the raid is not held open by it.
|
||||
#[test]
|
||||
fn a_drone_that_never_arrives_ends_the_raid() {
|
||||
let mut sim = Sim::new(4);
|
||||
let mut d = sim.make_drone(DroneKind::Kh101, DVec3::new(0.0, 400.0, 0.0), DVec3::X, DVec3::new(3000.0, 400.0, 0.0));
|
||||
d.low = false;
|
||||
sim.drones.push(d);
|
||||
let (mut ground, mut out) = (Ground::default(), Out::default());
|
||||
let mut ended = None;
|
||||
for t in 0..STALL_TICKS + 400 {
|
||||
if let Some(d) = sim.drones.first_mut() {
|
||||
d.target = d.pos + DVec3::X * 3000.0;
|
||||
}
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
if !sim.drones.iter().any(|d| d.alive()) {
|
||||
ended = Some(t);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let ended = ended.expect("still airborne after the stall time");
|
||||
assert!(ended >= STALL_TICKS, "ended early: {ended}");
|
||||
assert_eq!(sim.stalled, 1);
|
||||
}
|
||||
|
||||
/// #90: an answer says which world it is of. Heights of another dimension are not this one's, and an answer to a
|
||||
/// question asked before the world changed is dropped, not read as the new world's.
|
||||
#[test]
|
||||
fn heights_of_another_world_are_void_and_late_answers_are_dropped() {
|
||||
let mut ground = Ground::default();
|
||||
ground.world.changed_rev(1, 0, false, &[]);
|
||||
ground.answer(vec![(4, 4, 70), (12, 4, NO_LAND)], 1);
|
||||
assert_eq!(ground.land_state(DVec3::new(4.5, 80.0, 4.5)), Some(Some(70.0)));
|
||||
assert_eq!(ground.land_state(DVec3::new(12.5, 80.0, 4.5)), Some(None), "no land is an answer too");
|
||||
// A question goes out in world 1; the dimension changes before the answer comes.
|
||||
let _ = ground.land(DVec3::new(100.5, 80.0, 100.5));
|
||||
assert_eq!(ground.take_wanted().len(), 1);
|
||||
ground.world.changed_rev(2, 0, false, &[]);
|
||||
ground.answer(vec![(100, 100, 55)], 2);
|
||||
assert_eq!(ground.world.stats.late_dropped, 1);
|
||||
assert_eq!(ground.land_state(DVec3::new(4.5, 80.0, 4.5)), None, "the old dimension's height is gone, asked again");
|
||||
assert_eq!(ground.land_state(DVec3::new(100.5, 80.0, 100.5)), None, "the late answer was not taken");
|
||||
}
|
||||
|
||||
/// #90: a blast asked of the game in the blocks is confirmed by the game's own report of blocks changed there; one
|
||||
/// that is not reported is told apart by whether the land was there.
|
||||
#[test]
|
||||
fn a_blast_in_the_blocks_waits_for_the_games_word() {
|
||||
let mut ground = Ground::default();
|
||||
ground.answer(vec![(4, 4, 70)], 1);
|
||||
let near = DVec3::new(4.5, 70.0, 4.5);
|
||||
let far = DVec3::new(900.5, 70.0, 900.5);
|
||||
ground.world.expect_blast(near, 8.0, 1);
|
||||
ground.world.expect_blast(near + DVec3::X * 200.0, 8.0, 1);
|
||||
ground.world.expect_blast(far, 8.0, 1);
|
||||
ground.world.changed_rev(1, 1, false, &[(0, 4, 0)]);
|
||||
assert_eq!(ground.world.stats.blasts_confirmed, 1);
|
||||
ground.answer(vec![], 1 + world::CONFIRM_TICKS);
|
||||
let s = ground.world.stats;
|
||||
assert_eq!((s.blasts_asked, s.blasts_confirmed, s.blasts_unchanged, s.blasts_unloaded), (3, 1, 0, 2));
|
||||
}
|
||||
|
||||
@@ -93,6 +93,97 @@ pub(super) fn reach(sam: &Sam, kind: DroneKind, height: f64) -> f64 {
|
||||
radar.min(horizon).max(NEAR)
|
||||
}
|
||||
|
||||
/// The low flyer the command map shows the radars' coverage for: a Shahed over the land.
|
||||
const LOW_KIND: DroneKind = DroneKind::Shahed;
|
||||
|
||||
/// A radar as the command map sees it: where its antenna is and how far it reaches a low flyer.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct LowSensor {
|
||||
pub id: u32,
|
||||
pub ant: DVec3,
|
||||
/// The ground under the unit (a place whose land is unknown is taken as level with it).
|
||||
pub base: f64,
|
||||
pub reach: f64,
|
||||
/// Its horizon is known: the land hides what it hides.
|
||||
pub terrain: bool,
|
||||
}
|
||||
|
||||
/// Rays of `Sim::low_rim`.
|
||||
pub const RIM_RAYS: usize = RAYS;
|
||||
|
||||
impl Ground {
|
||||
/// The land's height at `p` from what the game has already answered; asks for nothing.
|
||||
pub fn peek(&self, p: DVec3) -> Option<f64> {
|
||||
const LAT: i32 = super::terrain::LATTICE;
|
||||
let k = (p.x.floor() as i32, p.z.floor() as i32);
|
||||
let hit = self.known.get(&k).or_else(|| {
|
||||
self.known.get(&(k.0.div_euclid(LAT) * LAT + LAT / 2, k.1.div_euclid(LAT) * LAT + LAT / 2))
|
||||
});
|
||||
hit.and_then(|&(y, _)| land_height(y))
|
||||
}
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
/// A radar for a unit of `kind` at `pos` as the map draws it; `id`: the unit it is (its parts and horizon), None for a ghost.
|
||||
pub fn low_sensor(&self, kind: SamKind, pos: DVec3, id: Option<u32>) -> Option<LowSensor> {
|
||||
let spec = kind.spec();
|
||||
if spec.sensor <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let fit = id.and_then(|i| self.sams.iter().find(|s| s.id == i)).map_or(1.0, |s| s.radar_fit());
|
||||
let height = LOW_KIND.spec().clearance;
|
||||
let radar = spec.sensor * fit * LOW_KIND.rcs().powf(0.25);
|
||||
let reach = radar.min(HORIZON * (spec.mast.sqrt() + height.sqrt())).max(NEAR);
|
||||
let terrain = id.is_some_and(|i| self.horizons.contains_key(&i));
|
||||
Some(LowSensor { id: id.unwrap_or(u32::MAX), ant: pos + DVec3::Y * spec.mast, base: pos.y, reach, terrain })
|
||||
}
|
||||
|
||||
/// The radars that are up now.
|
||||
pub fn low_sensors(&self) -> Vec<LowSensor> {
|
||||
self.sams.iter().filter(|s| s.radar_up()).filter_map(|s| self.low_sensor(s.kind, s.pos, Some(s.id))).collect()
|
||||
}
|
||||
|
||||
/// Whether the radar sees a low flyer (over the land) at the column (`x`, `z`): in its reach and not behind the land.
|
||||
pub fn low_sees(&self, ground: &Ground, s: &LowSensor, x: f64, z: f64) -> bool {
|
||||
let (dx, dz) = (x - s.ant.x, z - s.ant.z);
|
||||
let d2 = dx * dx + dz * dz;
|
||||
if d2 > s.reach * s.reach {
|
||||
return false;
|
||||
}
|
||||
if !s.terrain || d2 <= NEAR * NEAR {
|
||||
return true;
|
||||
}
|
||||
let Some(h) = self.horizons.get(&s.id) else { return true };
|
||||
let g = ground.peek(DVec3::new(x, 0.0, z)).unwrap_or(s.base);
|
||||
!h.hides(s.ant, DVec3::new(x, g + LOW_KIND.spec().clearance, z))
|
||||
}
|
||||
|
||||
/// Per ray round the radar, how far a low flyer is seen before the land first hides it (at most the reach).
|
||||
pub fn low_rim(&self, ground: &Ground, s: &LowSensor) -> Vec<f32> {
|
||||
let mut out = vec![s.reach as f32; RAYS];
|
||||
let Some(h) = self.horizons.get(&s.id).filter(|_| s.terrain) else { return out };
|
||||
let steps = ((s.reach / STEP) as usize).min(h.rays[0].len());
|
||||
let height = LOW_KIND.spec().clearance;
|
||||
for (r, o) in out.iter_mut().enumerate() {
|
||||
let a = r as f64 / RAYS as f64 * TAU;
|
||||
let (sx, sz) = (a.sin(), a.cos());
|
||||
for k in 1..=steps {
|
||||
let d = k as f64 * STEP;
|
||||
if d <= NEAR {
|
||||
continue;
|
||||
}
|
||||
let p = DVec3::new(s.ant.x + sx * d, 0.0, s.ant.z + sz * d);
|
||||
let y = ground.peek(p).unwrap_or(s.base) + height - s.ant.y;
|
||||
if ((y / d) as f32) < h.rays[r][k - 1] {
|
||||
*o = (d - STEP).max(NEAR) as f32;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// How a kind's radar looks round.
|
||||
pub(super) struct Scan {
|
||||
/// Ticks for the beam to go round.
|
||||
@@ -144,6 +235,8 @@ impl Ident {
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Track {
|
||||
pub id: u32,
|
||||
/// What the radar made of it when it measured (the class the picture shows).
|
||||
pub kind: DroneKind,
|
||||
/// Where it was and how it went when last measured (blocks, blocks a tick).
|
||||
pub pos: DVec3,
|
||||
pub vel: DVec3,
|
||||
@@ -157,6 +250,24 @@ pub struct Track {
|
||||
pub locked: bool,
|
||||
}
|
||||
|
||||
/// What the player's side knows of the air: the network's tracks as last measured, never the drones themselves.
|
||||
pub struct AirPicture {
|
||||
pub now: u64,
|
||||
pub tracks: Vec<Track>,
|
||||
/// No system is deployed: the tracks are the sky as it is.
|
||||
pub unaided: bool,
|
||||
}
|
||||
|
||||
impl AirPicture {
|
||||
pub fn get(&self, id: u32) -> Option<&Track> {
|
||||
self.tracks.iter().find(|t| t.id == id)
|
||||
}
|
||||
/// The track nearest to a point, by its forecast place.
|
||||
pub fn nearest(&self, to: DVec3) -> Option<&Track> {
|
||||
self.tracks.iter().min_by(|a, b| a.est(self.now).distance(to).total_cmp(&b.est(self.now).distance(to)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Track {
|
||||
pub fn age(&self, now: u64) -> u64 {
|
||||
now.saturating_sub(self.tick)
|
||||
@@ -305,6 +416,7 @@ impl Sim {
|
||||
let noise = DVec3::new(dice(seed, now, key, 1) - 0.5, (dice(seed, now, key, 2) - 0.5) * 0.5, dice(seed, now, key, 3) - 0.5) * 2.0 * sigma;
|
||||
fresh.push(Track {
|
||||
id: d.id,
|
||||
kind: d.kind,
|
||||
pos: d.pos + noise,
|
||||
vel: d.vel,
|
||||
tick: now,
|
||||
@@ -356,6 +468,25 @@ impl Sim {
|
||||
self.sams.iter().any(|s| s.seen.contains(&id))
|
||||
}
|
||||
|
||||
/// The air picture as the player's panel and radar read it (the only thing they read).
|
||||
pub fn air_picture(&self) -> AirPicture {
|
||||
let now = self.tick;
|
||||
// With no system deployed there is no radar to be blind: the sky as it is (the same fallback the screens
|
||||
// always had); a network's picture is only what it holds.
|
||||
let unaided = self.sams.is_empty();
|
||||
let mut tracks: Vec<Track> = if unaided {
|
||||
self.drones
|
||||
.iter()
|
||||
.filter(|d| d.alive())
|
||||
.map(|d| Track { id: d.id, kind: d.kind, pos: d.pos, vel: d.vel, tick: now, own_tick: now, ttl: 1, hits: 3, locked: false })
|
||||
.collect()
|
||||
} else {
|
||||
self.picture().into_values().collect()
|
||||
};
|
||||
tracks.sort_by_key(|t| t.id);
|
||||
AirPicture { now, tracks, unaided }
|
||||
}
|
||||
|
||||
/// The freshest track of each drone any system holds: the air picture.
|
||||
pub fn picture(&self) -> HashMap<u32, Track> {
|
||||
let mut best: HashMap<u32, Track> = HashMap::new();
|
||||
@@ -367,3 +498,74 @@ impl Sim {
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod low_view_tests {
|
||||
use super::*;
|
||||
|
||||
/// A radar on level land (70) with a hill east of it (140 high, x 128..160): the east ray is cut at the hill, the others are not.
|
||||
fn world() -> (Sim, Ground, u32) {
|
||||
let mut sim = Sim::new(3);
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Buk, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
let mut ground = Ground::default();
|
||||
let cols: Vec<(i32, i32, i32)> = (-60..60).map(|k| (k * 16, 0, if (128..160).contains(&(k * 16)) { 140 } else { 70 })).collect();
|
||||
ground.answer(cols, 0);
|
||||
let h = Horizon::build(antenna(&sim.sams[0]), sim.sams[0].kind.spec().sensor, &mut ground, 0);
|
||||
sim.horizons.insert(id, h);
|
||||
(sim, ground, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_land_cuts_the_coverage_edge_behind_a_hill() {
|
||||
let (sim, ground, id) = world();
|
||||
let ls = sim.low_sensor(SamKind::Buk, DVec3::new(0.0, 70.0, 0.0), Some(id)).unwrap();
|
||||
assert!(ls.terrain && ls.reach > 200.0, "{}", ls.reach);
|
||||
let rim = sim.low_rim(&ground, &ls);
|
||||
let east = rim[RIM_RAYS / 4];
|
||||
assert!((128.0..=176.0).contains(&east), "east edge {east}");
|
||||
// Rays whose land is unknown hide nothing: the whole reach.
|
||||
assert_eq!(rim[0], ls.reach as f32);
|
||||
assert_eq!(rim[RIM_RAYS * 3 / 4], ls.reach as f32);
|
||||
assert!(sim.low_sees(&ground, &ls, -200.0, 0.0));
|
||||
assert!(sim.low_sees(&ground, &ls, 100.0, 0.0));
|
||||
assert!(!sim.low_sees(&ground, &ls, 200.0, 0.0), "behind the hill");
|
||||
assert!(!sim.low_sees(&ground, &ls, ls.reach + 50.0, 0.0), "out of reach");
|
||||
// A ghost (no horizon) sees the whole circle.
|
||||
let ghost = sim.low_sensor(SamKind::Buk, DVec3::new(0.0, 70.0, 0.0), None).unwrap();
|
||||
assert!(sim.low_sees(&ground, &ghost, 200.0, 0.0));
|
||||
}
|
||||
|
||||
// The map's tint asks `low_sees` for a cell of a coarse grid at every radar that reaches it, and the edges
|
||||
// ask `low_rim`: with a Polygon's worth of systems both stay cheap (native time, a few ms; wasm is a few times that).
|
||||
#[test]
|
||||
fn coverage_of_128_systems_is_cheap() {
|
||||
let (mut sim, mut ground, _) = world();
|
||||
for i in 0..127 {
|
||||
let id = sim.id();
|
||||
let pos = DVec3::new((i % 16) as f64 * 40.0 - 300.0, 70.0, (i / 16) as f64 * 40.0 - 150.0);
|
||||
sim.sams.push(Sam::new(id, SamKind::Buk, pos, 0.0));
|
||||
let k = sim.sams.len() - 1;
|
||||
let h = Horizon::build(antenna(&sim.sams[k]), sim.sams[k].kind.spec().sensor, &mut ground, 0);
|
||||
sim.horizons.insert(id, h);
|
||||
}
|
||||
let sensors = sim.low_sensors();
|
||||
assert_eq!(sensors.len(), 128);
|
||||
let t = std::time::Instant::now();
|
||||
let mut seen = 0;
|
||||
for cy in 0..28 {
|
||||
for cx in 0..36 {
|
||||
let (x, z) = (cx as f64 * 30.0 - 500.0, cy as f64 * 30.0 - 400.0);
|
||||
// Worst case for the early exit: a cell no radar sees is tested against all that reach it.
|
||||
if sensors.iter().any(|s| sim.low_sees(&ground, s, x, z)) {
|
||||
seen += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let tint = t.elapsed();
|
||||
let t = std::time::Instant::now();
|
||||
let rims: usize = sensors.iter().take(3).map(|s| sim.low_rim(&ground, s).len()).sum();
|
||||
eprintln!("tint of 1008 cells: {tint:?} ({seen} seen); 3 rims: {:?} ({rims})", t.elapsed());
|
||||
assert!(seen > 0 && tint.as_millis() < 100, "{tint:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -805,6 +805,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An off-map site's drones start in the air at the height planned over land nobody had read; if the land
|
||||
/// turns out higher there by the time they leave, they start above it instead of being born inside the hill.
|
||||
#[test]
|
||||
fn an_off_map_start_over_a_hill_begins_above_it() {
|
||||
let (mut ground, mut out) = (Ground::default(), Out::default());
|
||||
let mut sim = Sim::new(3);
|
||||
sim.order_raid(&order(Approach::TwoSectors, 300.0), &[DVec3::new(0.0, 70.0, 0.0)], &mut out);
|
||||
while sim.planning() {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
assert!(sim.sites.list.iter().all(|s| s.offmap));
|
||||
let land = Land(|x, z| if x * x + z * z > 200 * 200 { 150 } else { 64 });
|
||||
for _ in 0..500 {
|
||||
step(&mut sim, &mut ground, &land, &mut out);
|
||||
assert!(sim.drones.iter().all(|d| d.age > 3 || d.pos.y > 148.0 || d.flight == Flight::Ballistic), "a drone started inside the hill");
|
||||
}
|
||||
assert!(sim.launched > 0);
|
||||
}
|
||||
|
||||
/// Chunks that load on request: a column of a chunk asked for answers `PENDING` until `delay` ticks
|
||||
/// after the first request, and a chunk never asked for is not there (-64).
|
||||
struct Lazy {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
use super::*;
|
||||
|
||||
/// Land heights are sampled at the centres of cells this wide.
|
||||
const LATTICE: i32 = 8;
|
||||
pub(super) const LATTICE: i32 = 8;
|
||||
/// Land questions waiting at once: flights ask for more than a tick can send, and wait.
|
||||
const ASK_MAX: usize = 192;
|
||||
|
||||
@@ -57,7 +57,7 @@ impl Ground {
|
||||
if self.due(when) && self.wanted.len() < ASK_MAX / 2 && self.asked.insert(key) {
|
||||
self.wanted.push(key);
|
||||
}
|
||||
if y > -64 { Rise::At(f64::from(y)) } else { Rise::Void }
|
||||
land_height(y).map_or(Rise::Void, Rise::At)
|
||||
}
|
||||
None => {
|
||||
if self.wanted.len() < ASK_MAX && self.asked.insert(key) {
|
||||
@@ -76,7 +76,7 @@ impl Ground {
|
||||
if self.due(when) && self.wanted.len() < cap / 2 && self.asked.insert((x, z)) {
|
||||
self.wanted.push((x, z));
|
||||
}
|
||||
Some((y > -64).then_some(f64::from(y)))
|
||||
Some(land_height(y))
|
||||
}
|
||||
None => {
|
||||
// A chunk the game is loading is asked about again in a few ticks, not every one.
|
||||
@@ -168,6 +168,9 @@ pub(super) fn clamp_pitch(v: DVec3, lo: f64, hi: f64, level: DVec3) -> DVec3 {
|
||||
horizontal(v).normalize_or(level) * pitch.cos() + DVec3::Y * pitch.sin()
|
||||
}
|
||||
|
||||
/// Chunks a tick the flights' look-ahead may ask the game to load (the rest of the budget is the launch-site search's).
|
||||
const CHUNK_BUDGET: usize = 46;
|
||||
|
||||
/// Distances ahead where the land is sampled.
|
||||
const REACH: [f64; 21] = [
|
||||
0.0, 8.0, 16.0, 24.0, 32.0, 44.0, 56.0, 72.0, 90.0, 112.0, 136.0, 164.0, 196.0, 232.0, 272.0, 316.0, 366.0, 424.0, 500.0, 600.0,
|
||||
@@ -177,8 +180,10 @@ const REACH: [f64; 21] = [
|
||||
/// The height a flight must be at *now* to clear the land along each of `lines` (a unit horizontal direction and
|
||||
/// how far out to look) by `clear`, given what it can climb: the greatest over the samples of the land's height plus clearance
|
||||
/// less the height it can gain on the way. `None` when nothing is known.
|
||||
pub(super) fn required_height(ground: &mut Ground, pos: DVec3, lines: &[(DVec3, f64)], clear: f64, climb: &Climb) -> Option<f64> {
|
||||
pub(super) fn required_height(ground: &mut Ground, pos: DVec3, lines: &[(DVec3, f64)], clear: f64, climb: &Climb) -> (Option<f64>, bool) {
|
||||
let mut need: Option<f64> = None;
|
||||
// Land nobody has told of within the first half of the look-ahead: the flight is blind there.
|
||||
let mut blind = false;
|
||||
for &(dir, len) in lines {
|
||||
for &s in REACH.iter().chain(&[900.0]) {
|
||||
if s > len {
|
||||
@@ -187,10 +192,20 @@ pub(super) fn required_height(ground: &mut Ground, pos: DVec3, lines: &[(DVec3,
|
||||
if let Some(g) = ground.land(pos + dir * s) {
|
||||
let h = g + clear - climb.gain(s);
|
||||
need = Some(need.map_or(h, |n: f64| n.max(h)));
|
||||
} else {
|
||||
if s <= len * 0.5 {
|
||||
blind = true;
|
||||
}
|
||||
// Land the game has not told of ahead: its chunk is asked for (it loads in a couple of seconds, a flight
|
||||
// is that far from it), so the pull-up starts from the first known column and not at the ridge.
|
||||
if ground.chunks_wanted() < CHUNK_BUDGET {
|
||||
let p = pos + dir * s;
|
||||
ground.want_chunk(((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
need
|
||||
(need, blind)
|
||||
}
|
||||
|
||||
/// The least height to start from, over here, so that the straight line down to `target` stays `margin` over the
|
||||
@@ -339,7 +354,7 @@ pub(super) const VERDICT_AGE: u64 = 20;
|
||||
|
||||
/// Which way an interceptor steers to keep clear of the land: `aim` is where it would go; the result is a direction
|
||||
/// (unit) raised as far as its loft allows, and whether the land is in the way beyond that.
|
||||
pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f64, aim: DVec3) -> (DVec3, bool) {
|
||||
pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f64, aim: DVec3, now: u64) -> (DVec3, bool) {
|
||||
let spec = kind.spec();
|
||||
let want = (aim - pos).normalize_or(DVec3::Y);
|
||||
let flat = horizontal(want);
|
||||
@@ -357,10 +372,12 @@ pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f
|
||||
// No way over, and the land close enough ahead that the next ticks will end in it: blow up before.
|
||||
let mut fail = false;
|
||||
if slope > cap + 0.02 && at < (speed * 6.0).max(40.0) {
|
||||
// The heights are a coarse upper bound (the canopy of a forest, the roof of a building): it blows up only before
|
||||
// blocks that are really there, not before what the height map says is higher than the missile.
|
||||
fail = (1..=4).any(|k| {
|
||||
let p = pos + raised * speed * f64::from(k);
|
||||
ground.land(p).is_some_and(|g| g + 2.0 > p.y)
|
||||
});
|
||||
}) && matches!(ground.world.probe(pos, pos + raised * speed * 4.0, now), world::Sweep::Hit { .. });
|
||||
}
|
||||
(raised, fail)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
//! Flights and the land. Strike drones follow the terrain with a look-ahead worth several seconds of flight
|
||||
//! (what they can still climb is a question of their speed and turn rate), dive only down a clear line, and
|
||||
//! interceptors keep a safety height over the land ahead, loft over a ridge when the line is blocked, and
|
||||
//! blow up high when there is no way over. The launch predicates (`why`) ask the same questions of a
|
||||
//! shot before it is fired, so a system doesn't spend missiles on targets behind a hill.
|
||||
//!
|
||||
//! The land comes from `Ground::land`: heights sampled on a coarse lattice, so many flights share columns and
|
||||
//! the questions to the game stay few; what the heights miss (trees, buildings, overhangs) is caught by a
|
||||
//! probe through the block cache.
|
||||
use super::*;
|
||||
|
||||
/// Land heights are sampled at the centres of cells this wide.
|
||||
pub(super) const LATTICE: i32 = 8;
|
||||
/// Land questions waiting at once: flights ask for more than a tick can send, and wait.
|
||||
const ASK_MAX: usize = 192;
|
||||
|
||||
/// What the game has said of the land at a place.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub(super) enum Rise {
|
||||
/// Not yet: asked.
|
||||
Pending,
|
||||
/// There is no land to tell of (no chunk).
|
||||
Void,
|
||||
/// One above the highest solid block.
|
||||
At(f64),
|
||||
}
|
||||
|
||||
impl Ground {
|
||||
/// One above the highest solid block at the lattice cell holding `p`, if known; asked for when it isn't.
|
||||
/// Like `at` it asks again for a column that is old or was reported changed (#64), and it leaves room for the questions that matter more.
|
||||
pub fn land(&mut self, p: DVec3) -> Option<f64> {
|
||||
match self.rise(p) {
|
||||
Rise::At(g) => Some(g),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// For a planner: None while the game has not answered, Some(None) where it has no land to
|
||||
/// tell of, else Some(Some(height)).
|
||||
pub fn land_state(&mut self, p: DVec3) -> Option<Option<f64>> {
|
||||
match self.rise(p) {
|
||||
Rise::Pending => None,
|
||||
Rise::Void => Some(None),
|
||||
Rise::At(g) => Some(Some(g)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The same, saying whether the game has not answered yet or has no land there to tell of.
|
||||
pub(super) fn rise(&mut self, p: DVec3) -> Rise {
|
||||
let key = (
|
||||
(p.x.floor() as i32).div_euclid(LATTICE) * LATTICE + LATTICE / 2,
|
||||
(p.z.floor() as i32).div_euclid(LATTICE) * LATTICE + LATTICE / 2,
|
||||
);
|
||||
match self.known.get(&key) {
|
||||
Some(&(y, when)) => {
|
||||
// Old or reported changed: still used, asked again with the questions to spare.
|
||||
if self.due(when) && self.wanted.len() < ASK_MAX / 2 && self.asked.insert(key) {
|
||||
self.wanted.push(key);
|
||||
}
|
||||
if y > -64 { Rise::At(f64::from(y)) } else { Rise::Void }
|
||||
}
|
||||
None => {
|
||||
if self.wanted.len() < ASK_MAX && self.asked.insert(key) {
|
||||
self.wanted.push(key);
|
||||
}
|
||||
Rise::Pending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The land's height at a column for the raid planner: `None` while the game hasn't answered, `Some(None)`
|
||||
/// where it has no land to tell of. Asks, up to `cap` questions waiting.
|
||||
pub(super) fn column(&mut self, x: i32, z: i32, cap: usize) -> Option<Option<f64>> {
|
||||
match self.known.get(&(x, z)) {
|
||||
Some(&(y, when)) => {
|
||||
if self.due(when) && self.wanted.len() < cap / 2 && self.asked.insert((x, z)) {
|
||||
self.wanted.push((x, z));
|
||||
}
|
||||
Some((y > -64).then_some(f64::from(y)))
|
||||
}
|
||||
None => {
|
||||
// A chunk the game is loading is asked about again in a few ticks, not every one.
|
||||
let loading = self.loading.get(&(x, z)).is_some_and(|&t| self.now.saturating_sub(t) < LOAD_RECHECK);
|
||||
if !loading && self.wanted.len() < cap && self.asked.insert((x, z)) {
|
||||
self.wanted.push((x, z));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a flight can do about a rise ahead.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Climb {
|
||||
/// Radius of its tightest pull-up (blocks).
|
||||
pub radius: f64,
|
||||
/// The steepest climb it holds (radians).
|
||||
pub pitch: f64,
|
||||
}
|
||||
|
||||
impl Climb {
|
||||
/// How much height it can have gained by the time it has gone `s` blocks on, starting level.
|
||||
pub fn gain(&self, s: f64) -> f64 {
|
||||
let arc = self.radius * self.pitch.sin();
|
||||
if s <= arc {
|
||||
self.radius - (self.radius * self.radius - s * s).max(0.0).sqrt()
|
||||
} else {
|
||||
self.radius * (1.0 - self.pitch.cos()) + (s - arc) * self.pitch.tan()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DroneKind {
|
||||
/// The steepest climb it holds in cruise (radians): cruise missiles are the shallowest.
|
||||
pub(super) fn climb_pitch(self) -> f64 {
|
||||
match self {
|
||||
DroneKind::Kh101 | DroneKind::Kalibr => 0.45,
|
||||
DroneKind::Lancet => 0.7,
|
||||
DroneKind::Geran3 | DroneKind::Kh22 => 0.6,
|
||||
_ => 0.5,
|
||||
}
|
||||
}
|
||||
pub(super) fn climb(self) -> Climb {
|
||||
let s = self.spec();
|
||||
Climb { radius: (s.speed / s.turn.max(0.01) * 1.15).max(12.0), pitch: self.climb_pitch() }
|
||||
}
|
||||
/// Blocks it looks ahead: eight seconds of flight, at least a pull-up and a climb over a hill.
|
||||
pub(super) fn look_len(self) -> f64 {
|
||||
let s = self.spec();
|
||||
(s.speed * 160.0).clamp(160.0, 900.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A flight's new heading: it turns in yaw towards `heading` and in pitch towards a climb of `slope`, together
|
||||
/// at most `rate` radians a tick, so a turn on to a new course doesn't dip or climb by itself (a great-circle
|
||||
/// turn between nearly opposite headings can go either way). With `climb` set the pitch comes first and the
|
||||
/// yaw gets what is left of the rate.
|
||||
pub(super) fn fly_towards(old: DVec3, heading: DVec3, slope: f64, rate: f64, climb: bool) -> DVec3 {
|
||||
let (old_yaw, old_pitch) = (old.x.atan2(old.z), old.y.clamp(-1.0, 1.0).asin());
|
||||
let want_yaw = heading.x.atan2(heading.z);
|
||||
let mut yaw = angle_diff(old_yaw, want_yaw);
|
||||
let mut pitch = slope.atan() - old_pitch;
|
||||
if climb {
|
||||
pitch = pitch.clamp(-rate, rate);
|
||||
yaw = yaw.clamp(-(rate * rate - pitch * pitch).max(0.0).sqrt(), (rate * rate - pitch * pitch).max(0.0).sqrt());
|
||||
} else {
|
||||
let total = yaw.hypot(pitch);
|
||||
if total > rate {
|
||||
yaw *= rate / total;
|
||||
pitch *= rate / total;
|
||||
}
|
||||
}
|
||||
let (yaw, pitch) = (old_yaw + yaw, old_pitch + pitch);
|
||||
DVec3::new(yaw.sin() * pitch.cos(), pitch.sin(), yaw.cos() * pitch.cos())
|
||||
}
|
||||
|
||||
/// How far over the land a dive's line passes.
|
||||
pub(super) const DIVE_MARGIN: f64 = 8.0;
|
||||
|
||||
/// `v` with its pitch held between `lo` and `hi` (radians); `level` is the way it faces if it points straight up or down.
|
||||
pub(super) fn clamp_pitch(v: DVec3, lo: f64, hi: f64, level: DVec3) -> DVec3 {
|
||||
let pitch = v.y.clamp(-1.0, 1.0).asin();
|
||||
if (lo..=hi).contains(&pitch) {
|
||||
return v;
|
||||
}
|
||||
let pitch = pitch.clamp(lo, hi);
|
||||
horizontal(v).normalize_or(level) * pitch.cos() + DVec3::Y * pitch.sin()
|
||||
}
|
||||
|
||||
/// Chunks a tick the flights' look-ahead may ask the game to load (the rest of the budget is the launch-site search's).
|
||||
const CHUNK_BUDGET: usize = 46;
|
||||
|
||||
/// Distances ahead where the land is sampled.
|
||||
const REACH: [f64; 21] = [
|
||||
0.0, 8.0, 16.0, 24.0, 32.0, 44.0, 56.0, 72.0, 90.0, 112.0, 136.0, 164.0, 196.0, 232.0, 272.0, 316.0, 366.0, 424.0, 500.0, 600.0,
|
||||
720.0,
|
||||
];
|
||||
|
||||
/// The height a flight must be at *now* to clear the land along each of `lines` (a unit horizontal direction and
|
||||
/// how far out to look) by `clear`, given what it can climb: the greatest over the samples of the land's height plus clearance
|
||||
/// less the height it can gain on the way. `None` when nothing is known.
|
||||
pub(super) fn required_height(ground: &mut Ground, pos: DVec3, lines: &[(DVec3, f64)], clear: f64, climb: &Climb) -> (Option<f64>, bool) {
|
||||
let mut need: Option<f64> = None;
|
||||
// Land nobody has told of within the first half of the look-ahead: the flight is blind there.
|
||||
let mut blind = false;
|
||||
for &(dir, len) in lines {
|
||||
for &s in REACH.iter().chain(&[900.0]) {
|
||||
if s > len {
|
||||
break;
|
||||
}
|
||||
if let Some(g) = ground.land(pos + dir * s) {
|
||||
let h = g + clear - climb.gain(s);
|
||||
need = Some(need.map_or(h, |n: f64| n.max(h)));
|
||||
} else {
|
||||
if s <= len * 0.5 {
|
||||
blind = true;
|
||||
}
|
||||
// Land the game has not told of ahead: its chunk is asked for (it loads in a couple of seconds, a flight
|
||||
// is that far from it), so the pull-up starts from the first known column and not at the ridge.
|
||||
if ground.chunks_wanted() < CHUNK_BUDGET {
|
||||
let p = pos + dir * s;
|
||||
ground.want_chunk(((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(need, blind)
|
||||
}
|
||||
|
||||
/// The least height to start from, over here, so that the straight line down to `target` stays `margin` over the
|
||||
/// land (the last stretch before the aim is the target's own business). `None` when the land isn't known.
|
||||
pub(super) fn dive_height(ground: &mut Ground, pos: DVec3, target: DVec3, margin: f64) -> Option<f64> {
|
||||
let flat = horizontal(target - pos);
|
||||
let total = flat.length();
|
||||
if total < 20.0 {
|
||||
return None;
|
||||
}
|
||||
let dir = flat / total;
|
||||
let mut need: Option<f64> = None;
|
||||
let n = ((total - 14.0) / 12.0).clamp(1.0, 40.0) as i32;
|
||||
for k in 0..=n {
|
||||
let s = (total - 14.0) * f64::from(k) / f64::from(n);
|
||||
if let Some(g) = ground.land(pos + dir * s) {
|
||||
let f = s / total;
|
||||
let y = (g + margin - target.y * f) / (1.0 - f);
|
||||
need = Some(need.map_or(y, |m: f64| m.max(y)));
|
||||
}
|
||||
}
|
||||
need
|
||||
}
|
||||
|
||||
/// The slope (rise a block) a flight at `from` must hold to clear the land by `margin` on its way towards
|
||||
/// `to`, with a pull-up of `radius` for the climb to begin early, ignoring the last `skip` blocks before `to`.
|
||||
/// Returns the slope and how far away the land that asks for it is; `None` when nothing is in the way or known.
|
||||
pub(super) fn rope(ground: &mut Ground, from: DVec3, to: DVec3, margin: f64, radius: f64, (near, reach, skip): (f64, f64, f64), pending: &mut bool) -> Option<(f64, f64)> {
|
||||
let flat = horizontal(to - from);
|
||||
let total = flat.length();
|
||||
if total < 1.0 {
|
||||
return None;
|
||||
}
|
||||
let dir = flat / total;
|
||||
let end = (total - skip).min(reach);
|
||||
let mut best: Option<(f64, f64)> = None;
|
||||
let mut s = near;
|
||||
while s <= end {
|
||||
match ground.rise(from + dir * s) {
|
||||
Rise::At(g) => {
|
||||
let slope = (g + margin - from.y) / (s - radius * 0.6).max(6.0);
|
||||
if best.is_none_or(|b| slope > b.0) {
|
||||
best = Some((slope, s));
|
||||
}
|
||||
}
|
||||
Rise::Pending => *pending = true,
|
||||
Rise::Void => {}
|
||||
}
|
||||
s += if s < 64.0 { 8.0 } else { 16.0 };
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- interceptors
|
||||
|
||||
impl MissileKind {
|
||||
/// The steepest climb it flies to get over land (radians): long-range missiles loft, short-range ones don't.
|
||||
pub(super) fn loft(self) -> f64 {
|
||||
match self {
|
||||
MissileKind::M77n6 => 1.25,
|
||||
MissileKind::M48n6 => 1.1,
|
||||
MissileKind::Pac3 | MissileKind::IrisT | MissileKind::Amraam => 0.9,
|
||||
MissileKind::M9m317 => 0.8,
|
||||
MissileKind::P1sun => 0.5,
|
||||
MissileKind::M57e6 | MissileKind::M9m338 => 0.3,
|
||||
}
|
||||
}
|
||||
/// Whether it lofts over a ridge to a target behind it.
|
||||
pub(super) fn lofts(self) -> bool {
|
||||
self.loft() >= 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// How high above the land an interceptor keeps.
|
||||
pub(super) const MISSILE_MARGIN: f64 = 8.0;
|
||||
|
||||
/// What keeps an interceptor from being launched at a target for the land in the way.
|
||||
pub(super) const MASKED: u8 = 1;
|
||||
pub(super) const PATH_BLOCKED: u8 = 2;
|
||||
/// The land under the line of fire has been asked for and not answered: the shot waits a tick or two.
|
||||
pub(super) const WAIT: u8 = 4;
|
||||
/// Ticks a shot waits for the land to be answered before it is judged on what is known.
|
||||
pub(super) const WAIT_TICKS: u64 = 30;
|
||||
|
||||
/// Whether a shot from `muzzle` (launcher at `antenna`) at a target forecast at `est` moving at `vel` may be
|
||||
/// fired for the land: the intercept point must not be behind a hill from the launcher, unless the missile
|
||||
/// lofts, and the path the missile would fly must be clear.
|
||||
pub(super) fn launch_blocks(ground: &mut Ground, kind: MissileKind, muzzle: DVec3, antenna: DVec3, est: DVec3, vel: DVec3) -> u8 {
|
||||
let spec = kind.spec();
|
||||
// Where it meets the target: the forecast carried on for the flight time.
|
||||
let mut at = est;
|
||||
for _ in 0..2 {
|
||||
let tgo = muzzle.distance(at) / (spec.speed * 0.8);
|
||||
at = est + vel * tgo;
|
||||
}
|
||||
let flat = horizontal(at - antenna);
|
||||
let total = flat.length();
|
||||
if total < 40.0 {
|
||||
return 0;
|
||||
}
|
||||
let dir = flat / total;
|
||||
// The line of sight from the launcher: any rise of the land over the line to the intercept point masks it.
|
||||
let mut masked = false;
|
||||
let mut waiting = false;
|
||||
let mut s = 12.0;
|
||||
while s < total - 12.0 {
|
||||
match ground.rise(antenna + dir * s) {
|
||||
Rise::At(g) if g + 1.0 > antenna.y + (at.y - antenna.y) * s / total => {
|
||||
masked = true;
|
||||
break;
|
||||
}
|
||||
Rise::Pending => waiting = true,
|
||||
_ => {}
|
||||
}
|
||||
s += 16.0;
|
||||
}
|
||||
if !masked && waiting {
|
||||
return WAIT;
|
||||
}
|
||||
if !masked {
|
||||
return 0;
|
||||
}
|
||||
if !kind.lofts() {
|
||||
return MASKED;
|
||||
}
|
||||
// A lofted path: climbs at the slope the highest land in the way asks for, and comes down on the target.
|
||||
let radius = spec.speed / spec.turn.max(0.05);
|
||||
let mut pending = false;
|
||||
let path = rope(ground, muzzle, at, MISSILE_MARGIN, radius, (32.0, total, 24.0), &mut pending);
|
||||
match path {
|
||||
Some((slope, _)) if slope > kind.loft().tan() => PATH_BLOCKED,
|
||||
_ if pending => WAIT,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
impl Sam {
|
||||
/// What the land said of a shot at `target` when it was last judged, if lately.
|
||||
pub(super) fn terrain_verdict(&self, target: u32, now: u64) -> Option<u8> {
|
||||
self.terrain.get(&target).filter(|(when, _)| now.saturating_sub(*when) <= VERDICT_AGE).map(|(_, bits)| *bits)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticks a launch verdict (the land in the way) is kept before the shot is judged again.
|
||||
pub(super) const VERDICT_AGE: u64 = 20;
|
||||
|
||||
/// Which way an interceptor steers to keep clear of the land: `aim` is where it would go; the result is a direction
|
||||
/// (unit) raised as far as its loft allows, and whether the land is in the way beyond that.
|
||||
pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f64, aim: DVec3, now: u64) -> (DVec3, bool) {
|
||||
let spec = kind.spec();
|
||||
let want = (aim - pos).normalize_or(DVec3::Y);
|
||||
let flat = horizontal(want);
|
||||
let flat_len = flat.length();
|
||||
let reach = (speed * 18.0).clamp(60.0, 320.0);
|
||||
let radius = speed / spec.turn.max(0.05);
|
||||
let Some((slope, at)) = rope(ground, pos, aim, MISSILE_MARGIN, radius, (8.0, reach, 20.0), &mut false) else { return (want, false) };
|
||||
let wanted = if flat_len > 1e-6 { want.y / flat_len } else { f64::INFINITY };
|
||||
if slope <= wanted {
|
||||
return (want, false);
|
||||
}
|
||||
let cap = kind.loft().tan();
|
||||
let dir = flat.normalize_or(DVec3::X);
|
||||
let raised = (dir + DVec3::Y * slope.min(cap)).normalize();
|
||||
// No way over, and the land close enough ahead that the next ticks will end in it: blow up before.
|
||||
let mut fail = false;
|
||||
if slope > cap + 0.02 && at < (speed * 6.0).max(40.0) {
|
||||
// The heights are a coarse upper bound (the canopy of a forest, the roof of a building): it blows up only before
|
||||
// blocks that are really there, not before what the height map says is higher than the missile.
|
||||
fail = (1..=4).any(|k| {
|
||||
let p = pos + raised * speed * f64::from(k);
|
||||
ground.land(p).is_some_and(|g| g + 2.0 > p.y)
|
||||
}) && matches!(ground.world.probe(pos, pos + raised * speed * 4.0, now), world::Sweep::Hit { .. });
|
||||
}
|
||||
(raised, fail)
|
||||
}
|
||||
@@ -640,7 +640,7 @@ mod tests {
|
||||
.drones
|
||||
.iter()
|
||||
.filter(|d| d.alive())
|
||||
.map(|d| Track { id: d.id, pos: d.pos, vel: d.vel, tick: now, own_tick: now, ttl: 400, hits: 3, locked: false })
|
||||
.map(|d| Track { id: d.id, kind: d.kind, pos: d.pos, vel: d.vel, tick: now, own_tick: now, ttl: 400, hits: 3, locked: false })
|
||||
.collect();
|
||||
for r in sim.sams.iter_mut().filter(|s| s.kind == SamKind::Radar) {
|
||||
for t in &tracks {
|
||||
|
||||
@@ -94,6 +94,13 @@ pub struct WorldStats {
|
||||
/// Sections dropped or re-read for a chunk's unload or replacement, or for age (#62).
|
||||
pub aged: u64,
|
||||
pub columns_changed: u64,
|
||||
/// Blasts asked in the blocks, and what became of them (`Settled`).
|
||||
pub blasts_asked: u64,
|
||||
pub blasts_confirmed: u64,
|
||||
pub blasts_unchanged: u64,
|
||||
pub blasts_unloaded: u64,
|
||||
/// Answers dropped for being to a question of another world or dimension.
|
||||
pub late_dropped: u64,
|
||||
}
|
||||
|
||||
/// What the game's exact ray said of a flight's step, once it answered (#63).
|
||||
@@ -182,9 +189,37 @@ pub struct WorldCache {
|
||||
/// Columns (section x, z) where blocks changed since the land heights last looked (#64); `all`: too many to say.
|
||||
pub(super) changed_cols: HashSet<(i32, i32)>,
|
||||
pub(super) changed_all: bool,
|
||||
/// Another world or dimension opened since the land heights last looked: they are all void.
|
||||
pub(super) epoch_reset: bool,
|
||||
/// Blasts the mod asked the game to make in the blocks (`Out.commands`), waiting for the game's word that
|
||||
/// blocks changed there. The game's damage (the simulation's own) and the geometry the game confirms
|
||||
/// are two things: this is the second (#90). Singleplayer: the client's world is the authority.
|
||||
expected: Vec<Expected>,
|
||||
pub stats: WorldStats,
|
||||
}
|
||||
|
||||
/// A blast asked for: the sections it reaches (lowest, highest) and the tick it was asked.
|
||||
struct Expected {
|
||||
lo: Sec,
|
||||
hi: Sec,
|
||||
at: DVec3,
|
||||
since: u64,
|
||||
}
|
||||
|
||||
/// Ticks the game has to report a block changed after a blast was asked for (the fuse is 0; commands run 64 a tick).
|
||||
pub const CONFIRM_TICKS: u64 = 100;
|
||||
|
||||
/// What became of a blast asked for in the blocks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Settled {
|
||||
/// The game reported blocks changed in the sections it reaches.
|
||||
Confirmed,
|
||||
/// The chunk was there and nothing changed: nothing to break, a rule or the server's rights said no.
|
||||
Unchanged,
|
||||
/// There was no chunk to change.
|
||||
Unloaded,
|
||||
}
|
||||
|
||||
fn sec_of(x: i32, y: i32, z: i32) -> Sec {
|
||||
(x >> 4, y >> 4, z >> 4)
|
||||
}
|
||||
@@ -207,7 +242,7 @@ impl WorldCache {
|
||||
pub fn text(&self) -> String {
|
||||
let s = &self.stats;
|
||||
format!(
|
||||
"мир: {} секций ({} КБ), запросов {}, неизвестно {}, лучей {} (ударов {}, неизв. {}), клеток {}",
|
||||
"мир: {} секций ({} КБ), запросов {}, неизвестно {}, лучей {} (ударов {}, неизв. {}), клеток {}; взрывы в блоках: {} (подтверждено {}, без перемен {}, нет чанка {}), чужих ответов {}",
|
||||
self.len(),
|
||||
self.bytes() / 1024,
|
||||
s.sections_asked,
|
||||
@@ -215,7 +250,12 @@ impl WorldCache {
|
||||
s.sweeps,
|
||||
s.hits,
|
||||
s.unknown_sweeps,
|
||||
s.cells
|
||||
s.cells,
|
||||
s.blasts_asked,
|
||||
s.blasts_confirmed,
|
||||
s.blasts_unchanged,
|
||||
s.blasts_unloaded,
|
||||
s.late_dropped
|
||||
)
|
||||
}
|
||||
|
||||
@@ -241,6 +281,8 @@ impl WorldCache {
|
||||
if epoch != self.epoch {
|
||||
self.rays = RayDesk { next: self.rays.next, ..RayDesk::default() };
|
||||
if self.epoch != 0 {
|
||||
self.epoch_reset = true;
|
||||
self.expected.clear();
|
||||
self.secs.clear();
|
||||
self.urgent.clear();
|
||||
self.ahead.clear();
|
||||
@@ -264,6 +306,11 @@ impl WorldCache {
|
||||
let set: HashSet<Sec> = sections.iter().copied().collect();
|
||||
self.drop_rays(|b| set.contains(&sec_of(b[0], b[1], b[2])));
|
||||
}
|
||||
if !self.expected.is_empty() {
|
||||
let before = self.expected.len();
|
||||
self.expected.retain(|e| !sections.iter().any(|s| (e.lo.0..=e.hi.0).contains(&s.0) && (e.lo.1..=e.hi.1).contains(&s.1) && (e.lo.2..=e.hi.2).contains(&s.2)));
|
||||
self.stats.blasts_confirmed += (before - self.expected.len()) as u64;
|
||||
}
|
||||
for s in sections {
|
||||
self.note_column(s.0, s.2);
|
||||
if let Some(e) = self.secs.get_mut(s) {
|
||||
@@ -274,6 +321,37 @@ impl WorldCache {
|
||||
self.stats.world_changes += sections.len() as u64;
|
||||
}
|
||||
|
||||
/// The game's epoch as last told: another world or dimension opening changes it.
|
||||
pub fn epoch(&self) -> u32 {
|
||||
self.epoch
|
||||
}
|
||||
|
||||
/// A blast of `radius` blocks was asked of the game at `at`: expect the game to report blocks changed there.
|
||||
pub fn expect_blast(&mut self, at: DVec3, radius: f64, now: u64) {
|
||||
let (lo, hi) = (at - DVec3::splat(radius), at + DVec3::splat(radius));
|
||||
let sec = |p: DVec3| sec_of(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
if self.expected.len() < 256 {
|
||||
self.expected.push(Expected { lo: sec(lo), hi: sec(hi), at, since: now });
|
||||
}
|
||||
self.stats.blasts_asked += 1;
|
||||
}
|
||||
|
||||
/// The blasts that the game did not report in `CONFIRM_TICKS`, taken: where each was.
|
||||
pub fn overdue_blasts(&mut self, now: u64) -> Vec<DVec3> {
|
||||
let (late, keep): (Vec<_>, Vec<_>) = std::mem::take(&mut self.expected).into_iter().partition(|e| now.saturating_sub(e.since) >= CONFIRM_TICKS);
|
||||
self.expected = keep;
|
||||
late.into_iter().map(|e| e.at).collect()
|
||||
}
|
||||
|
||||
/// What became of a blast that was not reported.
|
||||
pub fn note_settled(&mut self, how: Settled) {
|
||||
match how {
|
||||
Settled::Confirmed => self.stats.blasts_confirmed += 1,
|
||||
Settled::Unchanged => self.stats.blasts_unchanged += 1,
|
||||
Settled::Unloaded => self.stats.blasts_unloaded += 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn note_column(&mut self, sx: i32, sz: i32) {
|
||||
if self.changed_cols.len() >= 4096 {
|
||||
self.changed_all = true;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 12:37:45 +0300
|
||||
Subject: [PATCH 01/41] Mod API (AirDef fork): world models, client particles,
|
||||
Subject: [PATCH 01/42] Mod API (AirDef fork): world models, client particles,
|
||||
sounds, ground heights
|
||||
|
||||
render.set-models draws block models with an interpolated pose per id;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 12:39:45 +0300
|
||||
Subject: [PATCH 02/41] cargo-horizon-mod: report particles and sounds
|
||||
Subject: [PATCH 02/42] cargo-horizon-mod: report particles and sounds
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 13:33:10 +0300
|
||||
Subject: [PATCH 03/41] Mod API (AirDef fork): full-screen post pass for mods
|
||||
Subject: [PATCH 03/42] Mod API (AirDef fork): full-screen post pass for mods
|
||||
|
||||
render.set-post compiles a mod's WGSL after a prelude (camera matrices,
|
||||
time, 256 vec4 of params, the world's colour and depth) and runs it after
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 22:09:30 +0300
|
||||
Subject: [PATCH 04/41] Mod models: draw far models as scaled impostors past
|
||||
Subject: [PATCH 04/42] Mod models: draw far models as scaled impostors past
|
||||
the far plane, cull at 4096
|
||||
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 22:31:00 +0300
|
||||
Subject: [PATCH 05/41] Mod cameras: render.set-views draws the world from
|
||||
Subject: [PATCH 05/42] Mod cameras: render.set-views draws the world from
|
||||
other points into pictures that HUD view shapes show (API level 9)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 23:08:21 +0300
|
||||
Subject: [PATCH 06/41] Mods: more fuel per tick and per event (AirDef plans
|
||||
Subject: [PATCH 06/42] Mods: more fuel per tick and per event (AirDef plans
|
||||
raids)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 23:25:29 +0300
|
||||
Subject: [PATCH 07/41] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
Subject: [PATCH 07/42] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 11:15:11 +0300
|
||||
Subject: [PATCH 08/41] Mod cameras: distant terrain tiles, fog measured from
|
||||
Subject: [PATCH 08/42] Mod cameras: distant terrain tiles, fog measured from
|
||||
the camera, no impostors while one is shown
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 11:25:45 +0300
|
||||
Subject: [PATCH 09/41] Mods: game.world-id, so mods keep state per world (API
|
||||
Subject: [PATCH 09/42] Mods: game.world-id, so mods keep state per world (API
|
||||
level 9)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 12:01:08 +0300
|
||||
Subject: [PATCH 10/41] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
Subject: [PATCH 10/42] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
packing-only tool skips wasmtime
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 13:18:43 +0300
|
||||
Subject: [PATCH 11/41] HD lighting: G-buffer pass, per-pixel mod point lights
|
||||
Subject: [PATCH 11/42] HD lighting: G-buffer pass, per-pixel mod point lights
|
||||
in HDR, bloom and tone curve; render.set-lights and render.set-lighting (API
|
||||
level 10)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 13:59:43 +0300
|
||||
Subject: [PATCH 12/41] HD lighting stage 2: sun and moon shadow cascades, the
|
||||
Subject: [PATCH 12/42] HD lighting stage 2: sun and moon shadow cascades, the
|
||||
world relit (sun by face angle and shadow, sky and block light through the
|
||||
lightmap), light shafts and halos, fog over it again, a light colour grade
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:19:53 +0300
|
||||
Subject: [PATCH 13/41] HD: bind groups cached until a resize or a new source,
|
||||
Subject: [PATCH 13/42] HD: bind groups cached until a resize or a new source,
|
||||
shadow passes draw only the entities that cast shadows
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:25:44 +0300
|
||||
Subject: [PATCH 14/41] HD: lights culled per 16x16 tile (surface and halo
|
||||
Subject: [PATCH 14/42] HD: lights culled per 16x16 tile (surface and halo
|
||||
lists built on the CPU), a naga test for the lighting shaders
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:43:36 +0300
|
||||
Subject: [PATCH 15/41] HD: GPU time per pass in the F3 timing list,
|
||||
Subject: [PATCH 15/42] HD: GPU time per pass in the F3 timing list,
|
||||
half-resolution GTAO with a depth-aware blur
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:02:02 +0300
|
||||
Subject: [PATCH 16/41] HD: shadows draw only sun-facing quads and skip buried
|
||||
Subject: [PATCH 16/42] HD: shadows draw only sun-facing quads and skip buried
|
||||
sections, the far cascade every second frame; TAA (Halton jitter,
|
||||
reprojection by depth, YCoCg variance clipping) before bloom
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:02:02 +0300
|
||||
Subject: [PATCH 17/41] Mods: model handles (render.model-id, model-generation,
|
||||
Subject: [PATCH 17/42] Mods: model handles (render.model-id, model-generation,
|
||||
set-models-by-id; API level 11) so mods send no model names each tick;
|
||||
clear-post and set-lighting documented
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:19:57 +0300
|
||||
Subject: [PATCH 18/41] HD: temporal upscaling (HORIZON_HD_SCALE: world,
|
||||
Subject: [PATCH 18/42] HD: temporal upscaling (HORIZON_HD_SCALE: world,
|
||||
G-buffer and lighting at a lower size, rebuilt to the window in the TAA
|
||||
pass); fix flickering shadows near the player (the near cascade lost its
|
||||
sections on frames the far one was held, the held far matrix ignored the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:47:52 +0300
|
||||
Subject: [PATCH 19/41] HD water: its own pass (colour, normal, depth), the
|
||||
Subject: [PATCH 19/42] HD water: its own pass (colour, normal, depth), the
|
||||
floor under it lit, a composite with world-space waves, refraction with depth
|
||||
absorption in the biome colour, screen-space reflections, Fresnel and a
|
||||
shadowed sun glint (HORIZON_HD_WATER=0 to switch off)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:56:04 +0300
|
||||
Subject: [PATCH 20/41] HD water: no vanilla texture on the surface (the biome
|
||||
Subject: [PATCH 20/42] HD water: no vanilla texture on the surface (the biome
|
||||
colour only), a darker, deeper colour
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 18:38:19 +0300
|
||||
Subject: [PATCH 21/41] HD sky and light: a physical atmosphere (Rayleigh, Mie,
|
||||
Subject: [PATCH 21/42] HD sky and light: a physical atmosphere (Rayleigh, Mie,
|
||||
ozone; constants and transmittance after Photon, MIT) for the sky, haze, fog
|
||||
and water reflections; warmer sunlight from the atmosphere's transmittance, a
|
||||
gentle sky-coloured ambient, soft PCSS-lite shadows near the player, a haze
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 19:02:16 +0300
|
||||
Subject: [PATCH 22/41] HD water: a darker blue, a little clearer, ripples a
|
||||
Subject: [PATCH 22/42] HD water: a darker blue, a little clearer, ripples a
|
||||
little higher
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 21:41:48 +0300
|
||||
Subject: [PATCH 23/41] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
|
||||
Subject: [PATCH 23/42] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
|
||||
tint, blue hour; MIT), volumetric clouds with ground shadows instead of
|
||||
vanilla's in HD, volumetric fog with sun shafts and morning mist
|
||||
(HORIZON_HD_CLOUDS=0, HORIZON_HD_VOLFOG=0)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 24971b3c67d8c87673eb2e0d204cedf8a2f46d1c Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 21:43:22 +0300
|
||||
Subject: [PATCH 24/41] Mods: world queries for simulations (render.query-rays,
|
||||
Subject: [PATCH 24/42] Mods: world queries for simulations (render.query-rays,
|
||||
query-sections, world-changes; API level 12): block-exact rays and section
|
||||
occupancy by collision, sight or sound, answered next tick within budgets,
|
||||
and a log of changed sections
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 22:56:15 +0300
|
||||
Subject: [PATCH 25/41] HD fixes: rain level from the world not from rain
|
||||
Subject: [PATCH 25/42] HD fixes: rain level from the world not from rain
|
||||
geometry (#41), TAA history on the jittered grid (#38), vanilla cloud tint
|
||||
without a depth heuristic (#35), air fog once over water (#37), translucent
|
||||
things in front of HD water (#36), cheaper sunrise fan (#39), halos in thick
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 21391afb26f49505d6cdaf454a6355cd4e747503 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 23:19:53 +0300
|
||||
Subject: [PATCH 26/41] HD split into modules (gfx/hd/: one Rust file and one
|
||||
Subject: [PATCH 26/42] HD split into modules (gfx/hd/: one Rust file and one
|
||||
WGSL file per pass, a shared prelude), contracts for colour, depth, alpha,
|
||||
time and coordinates written down, tests that the frame layout and bindings
|
||||
match between Rust and WGSL (#45)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 23:22:19 +0300
|
||||
Subject: [PATCH 27/41] HD clouds: noise scales that divide the camera's
|
||||
Subject: [PATCH 27/42] HD clouds: noise scales that divide the camera's
|
||||
65536-block wrap, so the clouds and their shadows no longer jump at x or z =
|
||||
0
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0fb59c782abf269fe555faa3241b31acf41e26d7 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:17:11 +0300
|
||||
Subject: [PATCH 28/41] HD: volumetric smoke and missile trails with clear
|
||||
Subject: [PATCH 28/42] HD: volumetric smoke and missile trails with clear
|
||||
tunnels through fog and cloud (render.set-smoke, API level 14; #54); sun
|
||||
speculars by material, wet surfaces and puddles in rain, wind in foliage
|
||||
(HORIZON_HD_SPECULAR=0, HORIZON_HD_WIND=0, HORIZON_HD_SMOKE=0); HD shaders
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:26:09 +0300
|
||||
Subject: [PATCH 29/41] HD clouds composited by depth so terrain and vehicles
|
||||
Subject: [PATCH 29/42] HD clouds composited by depth so terrain and vehicles
|
||||
inside or behind the layer are hidden and those in front stay in front (#67);
|
||||
world-anchored noise everywhere (cloud detail by height above the layer, fog,
|
||||
smoke and puddle scales dividing the wrap) (#68); the replay's time is visual
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:44:45 +0300
|
||||
Subject: [PATCH 30/41] HD smoke: young trails drawn at least as wide as a grid
|
||||
Subject: [PATCH 30/42] HD smoke: young trails drawn at least as wide as a grid
|
||||
cell (density scaled to keep the mass), gentler bulges, so a trail is a
|
||||
smooth ribbon and not a chain of puffs
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 35ffbaa26dd91855c8b93446646ba8c58448d3d0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:59:41 +0300
|
||||
Subject: [PATCH 31/41] Mods: render.set-main-view (API level 15) renders the
|
||||
Subject: [PATCH 31/42] Mods: render.set-main-view (API level 15) renders the
|
||||
screen's world from a mod camera with the full pipeline, so replays get HD
|
||||
and the post effects; small mod views get the post pass with their own camera
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 27e24527550942695164dd3a769a42a659e797ca Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 09:28:05 +0300
|
||||
Subject: [PATCH 32/41] Spot lights for mods (render.set-spot-lights, API level
|
||||
Subject: [PATCH 32/42] Spot lights for mods (render.set-spot-lights, API level
|
||||
16): surfaces lit in the cone, a volumetric beam stopped by depth (#60); film
|
||||
capture (render.capture-*, set-film-quality, API level 17): fixed-step clock,
|
||||
frames piped to ffmpeg or PNGs, offline audio, no chat in the frames (#58);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 615f5c3fa7c19a9895f8972a88333c6f250ad78a Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 14:30:39 +0300
|
||||
Subject: [PATCH 33/41] HD clouds and fog polish: cloud panorama, cirrus,
|
||||
Subject: [PATCH 33/42] HD clouds and fog polish: cloud panorama, cirrus,
|
||||
terrain-aware fog, far smoke cascade; replay world log and film clock;
|
||||
material overlays by sprite name
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0254984fdcce53a4a0f2bff15f9be3b78c93842e Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 15:01:09 +0300
|
||||
Subject: [PATCH 34/41] HD: coloured sun through glass and leaves, rain ripples
|
||||
Subject: [PATCH 34/42] HD: coloured sun through glass and leaves, rain ripples
|
||||
on water, film motion blur and depth of field
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9645d3f1f47d8bb358d5630288bdd801bd874911 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 15:19:32 +0300
|
||||
Subject: [PATCH 35/41] Film export: every frame in flight reaches the writer,
|
||||
Subject: [PATCH 35/42] Film export: every frame in flight reaches the writer,
|
||||
listener follows the main view, cloud wind on film frames, set-smoke reports
|
||||
real activity
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 22d6835507d5627b0fe4a749b082b025b35e0262 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 15:43:11 +0300
|
||||
Subject: [PATCH 36/41] Mod command trees: command.set-tree and
|
||||
Subject: [PATCH 36/42] Mod command trees: command.set-tree and
|
||||
set-suggestions, typed parse, colouring and completion from one tree (API 19)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9be21a54fff055e78db245488c28a971a823fdba Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 16:10:23 +0300
|
||||
Subject: [PATCH 37/41] Persistent mod sound sources: start, update, stop with
|
||||
Subject: [PATCH 37/42] Persistent mod sound sources: start, update, stop with
|
||||
generations, click-free voices, loop sections, voice budget and virtual loops
|
||||
(API 20)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From e84cdc21a9fabc4518c18edbeacb2df24c85b983 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 19:07:37 +0300
|
||||
Subject: [PATCH 38/41] HD clouds: rays from a far point, so the view bob's eye
|
||||
Subject: [PATCH 38/42] HD clouds: rays from a far point, so the view bob's eye
|
||||
offset does not bend them
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 19f07ff4295766c7bee4bd1fbfc509b6a82d12d4 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 22:09:23 +0300
|
||||
Subject: [PATCH 39/41] Acoustics stage 1: direct-path occlusion ported from
|
||||
Subject: [PATCH 39/42] Acoustics stage 1: direct-path occlusion ported from
|
||||
Sound Physics Remastered 453654e (GPL-3.0, separate files), off by default;
|
||||
stereo output on surround devices; mixer sum in a fixed order (API 21)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 449edb87be5b5ddc61d0d2fbeef3a91316a97b62 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 23:44:14 +0300
|
||||
Subject: [PATCH 40/41] Mod chunk tickets through the server thread (API 22),
|
||||
Subject: [PATCH 40/42] Mod chunk tickets through the server thread (API 22),
|
||||
softer searchlights and light halos, acoustics stage 2: echo taps
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From ce3b535af682ff475e5256a05136f653a2e2608c Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Sat, 31 Oct 2026 00:59:02 +0300
|
||||
Subject: [PATCH 41/41] Mod canvas with terrain tiles and mouse input (API 24),
|
||||
Subject: [PATCH 41/42] Mod canvas with terrain tiles and mouse input (API 24),
|
||||
set-acoustics (API 23), acoustics stage 3: reverb
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
From a6dcb0b5f73640e0324c8bec3508e8e396e713bd Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Sat, 31 Oct 2026 03:20:06 +0300
|
||||
Subject: [PATCH 42/42] game.world-seed (API 25)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
crates/horizon-client/src/game/mods.rs | 3 +++
|
||||
crates/horizon-mod-sdk/wit/horizon.wit | 4 ++++
|
||||
crates/horizon-mods/src/api_level.rs | 6 ++++--
|
||||
crates/horizon-mods/src/runtime.rs | 5 +++++
|
||||
docs/api/mods-reference.md | 1 +
|
||||
5 files changed, 17 insertions(+), 2 deletions(-)
|
||||
|
||||
diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs
|
||||
index 7f9714a18..e96744775 100644
|
||||
--- a/crates/horizon-client/src/game/mods.rs
|
||||
+++ b/crates/horizon-client/src/game/mods.rs
|
||||
@@ -599,6 +599,9 @@ impl Game<'_> {
|
||||
.is_some()
|
||||
.then(|| self.mods.world_id.clone())
|
||||
.flatten(),
|
||||
+ world_seed: (self.player.is_some()
|
||||
+ && self.mods.world_id.as_deref().is_some_and(|w| w.starts_with("world:")))
|
||||
+ .then_some(self.level.seed),
|
||||
}
|
||||
}
|
||||
/// The first block along the player's view within
|
||||
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
index 805c2a515..774cc5851 100644
|
||||
--- a/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
|
||||
@@ -51,6 +51,10 @@ interface game {
|
||||
/// world, `server:<address>` on a server; none on the title screen.
|
||||
/// Mods keep their state apart by it.
|
||||
world-id: func() -> option<string>;
|
||||
+ /// The open saved world's seed (AirDef fork, API level 25): with the
|
||||
+ /// folder name it tells a new world from an old one that had the same
|
||||
+ /// name. None on the title screen and on a server.
|
||||
+ world-seed: func() -> option<s64>;
|
||||
/// A block along the player's view.
|
||||
record hit {
|
||||
/// The block hit.
|
||||
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
|
||||
index 579af9e25..3af36e7c1 100644
|
||||
--- a/crates/horizon-mods/src/api_level.rs
|
||||
+++ b/crates/horizon-mods/src/api_level.rs
|
||||
@@ -7,13 +7,13 @@
|
||||
//! version's level so the launcher can tell before it installs.
|
||||
|
||||
/// The newest level this Horizon serves.
|
||||
-pub const LEVEL: u32 = 24;
|
||||
+pub const LEVEL: u32 = 25;
|
||||
|
||||
/// Every function the game offers mods, as `interface.function`, with its
|
||||
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
|
||||
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
|
||||
/// test fails until it is listed here).
|
||||
-pub const FUNCTIONS: [(&str, u32); 90] = [
|
||||
+pub const FUNCTIONS: [(&str, u32); 91] = [
|
||||
("log.write", 0),
|
||||
("game.get-player", 0),
|
||||
("game.get-clock", 0),
|
||||
@@ -118,6 +118,8 @@ pub const FUNCTIONS: [(&str, u32); 90] = [
|
||||
("render.set-canvas", 24),
|
||||
("render.canvas-events", 24),
|
||||
("render.canvas-open", 24),
|
||||
+ // AirDef fork: the open world's seed, so a new world is not an old one of the same name.
|
||||
+ ("game.world-seed", 25),
|
||||
];
|
||||
|
||||
/// What the game's program carries so the launcher can read its level
|
||||
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
|
||||
index 81286975f..910986549 100644
|
||||
--- a/crates/horizon-mods/src/runtime.rs
|
||||
+++ b/crates/horizon-mods/src/runtime.rs
|
||||
@@ -189,6 +189,8 @@ pub struct Snapshot {
|
||||
pub creative: bool,
|
||||
/// The open world (`game.world-id`, AirDef fork).
|
||||
pub world: Option<String>,
|
||||
+ /// The open saved world's seed (`game.world-seed`, AirDef fork).
|
||||
+ pub world_seed: Option<i64>,
|
||||
}
|
||||
|
||||
/// Capability `recipes`'s tables: `items` in the registry's order, which
|
||||
@@ -1930,6 +1932,9 @@ impl game::Host for State {
|
||||
fn world_id(&mut self) -> Option<String> {
|
||||
self.snapshot.world.clone()
|
||||
}
|
||||
+ fn world_seed(&mut self) -> Option<i64> {
|
||||
+ self.snapshot.world_seed
|
||||
+ }
|
||||
fn raycast(&mut self, distance: f32) -> Option<Hit> {
|
||||
let (at, hit) = self.snapshot.view_hit.as_ref()?;
|
||||
// NaN compares false: no hit.
|
||||
diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md
|
||||
index cf6e61440..3ab23b490 100644
|
||||
--- a/docs/api/mods-reference.md
|
||||
+++ b/docs/api/mods-reference.md
|
||||
@@ -126,6 +126,7 @@ A block along the player's view.
|
||||
| `get-player` | `func() -> option<player>` | None on the title screen. |
|
||||
| `get-clock` | `func() -> option<clock>` | None on the title screen. |
|
||||
| `world-id` | `func() -> option<string>` | Which world is open (AirDef fork): `world:<folder>` for a saved world, `server:<address>` on a server; none on the title screen. Mods keep their state apart by it. |
|
||||
+| `world-seed` | `func() -> option<s64>` | The open saved world's seed (AirDef fork, API level 25): with the folder name it tells a new world from an old one that had the same name. None on the title screen and on a server. |
|
||||
| `raycast` | `func(distance: f32) -> option<hit>` | The first block along the player's view (fluids pass), up to `distance` blocks (at most 1024; past the loaded chunks, across the distant terrain when LOD Distance is on), as of this tick. None when nothing is hit, and on the title screen. |
|
||||
|
||||
## `chat`
|
||||
--
|
||||
2.54.0 (Apple Git-157)
|
||||
|
||||
Reference in New Issue
Block a user