Compare commits

..

2 Commits

Author SHA1 Message Date
chexit 38e515dea9 Flights over unknown land hold a safe height, air starts wait for the land, systems and aims settle on the surface; map labels, filters, coverage by terrain and scenes
build / mod (push) Successful in 8m34s
Far raids over mountains with a 300-block known disc: crashes from 19 to 2 of 368 (mixed), 22 to about 9 (smart). Balance tests gain hilly land.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 02:19:31 +03:00
chexit 57a5426e65 Strikes on launch sites, command map and placement editor, fire doctrines, ground routes, acoustics switch and reverb
build / mod (push) Successful in 4m13s
#101: radars locate sites from launches, /airdef sites, /airdef strike; sites launch slower (15/25/50 ticks) so a strike can come in time. #48: M opens the map on the network's picture, with an editor. #47: guard, sector, reserve and salvo per system. #94: vehicles route round slopes and water and say when they cannot get through. #96: reverb, and a menu switch for the acoustics. #92: generated files are validated before the swap, repo map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:59:03 +03:00
69 changed files with 8682 additions and 109 deletions
+7
View File
@@ -157,7 +157,14 @@ API мода `render.set-lights` (до 512, плавно интерполиру
- `/airdef import` — перенести в текущий мир комплексы и цели из старой версии мода (до 0.2.x они были общими для всех миров);
- `/airdef raid` — налёт, настроенный в меню; `/airdef target here` — отметить объект под ногами; `/airdef repair` — ремонт;
- `/airdef lastchance <on|off>` — «последний шанс»: комплекс стреляет и по классу вне ролей, если цель вот-вот попадёт (по умолчанию вкл; сохраняется);
- `/airdef sites` — известные пусковые площадки (ПУ) и ударные ракеты; `/airdef strike <номер ПУ>` — ответный удар по ПУ (#101, «удары по ПУ»). Как это играется:
- **Разведка без волшебства.** Площадку находит только работающая РЛС: либо пуск в её досягаемости (низкая цель: радиогоризонт и холмы решают) даёт точечное наблюдение, либо собственный трек вылетевшей цели «указывает назад» лучом в пару градусов, а время полёта с момента пуска (тревоги) на скорости цели даёт дальность (±8% и секунда времени пуска); у баллистической ракеты дуга точнее. Две РЛС, держащие одну цель, дают два наблюдения. Наблюдения сводятся методом наименьших квадратов, ответ всегда с кругом ошибки (90%), он сужается с каждым пуском. Если места не получилось (круг шире 800 блоков или есть только лучи вдоль одной линии), известен только **азимут** (на скопе метка «N?» на краю), удар невозможен. С местом: ромб на скопе (круг ошибки пунктиром) или метка на краю; в панели строка «Известных ПУ…». Далёкие площадки (километры) открываются грубо (сотни блоков) и бить их почти бесполезно, пока не накопятся пуски.
- **Удар.** Ударных ракет мало (3 в запасе, максимум 4, новая за 5 минут), дальность до 4500 блоков; стартуют оттуда, где вы стоите, летят 4 блока в тик (2 км за 25 с) в точку *оценки*. На подлёте ракету может сбить ПВО противника: у ПУ её нет (40%), лёгкая (35%, сбивает в 30%) или тяжёлая (25%, 65%), у баллистических батарей тяжёлая чаще; узнаёте только по итогу. Разброс ракеты 14 блоков; решает истинное расстояние до ПУ: до 35 блоков — **уничтожена** (запас сгорел и рвётся, отложенные запуски отменены, огонь и дымящийся остов, взрыв общим кодом), до 90 — **подавлена** на 30 с (пуски отложены, не отменены), дальше — **промах**, воронка рядом, ПУ цела: очередь за попадание в соседний грунт не списывается. Плохо известную ПУ (круг в сотни блоков) бить почти бесполезно.
- **Вне карты.** ПУ без земли (нет загруженной карты) тоже находится: азимут и дальность по трекам, но не рисуется. Удар, который попал бы, прекращает её пуски («результат не виден»); промах теряется.
- **Цена выбора.** Ракета запаса не вернётся, пока летит, налёт идёт: близкие площадки не остановить, далёкие (крылатые и баллистические за 2–4 км) успеваете, но для этого нужны РЛС с обзором в сторону налёта и риск промаха или ПВО противника. Отчёт налёта называет сорванные пуски: «Налёт сорван ударами по ПУ (№…): не взлетело N из заявленных M».
- Запись: удар попадает в журнал повтора (строка и взрыв/огонь на карте), сами ПУ в повторе не рисуются. Запас ракет входит в сохранение и сценарий (`strikes <запас> <тиков до следующей>`). Все числа механики в одной таблице `TUNE` в `src/sim/strike.rs`.
- `/airdef roles` — показать, по каким классам целей работает каждый комплекс; `/airdef roles <patriot|gepard|pantsir|s400|s500|all> <классы|default>` — задать (классы через запятую: `drone`, `jet`, `cruise`, `supersonic`, `ballistic` или `all`), например `/airdef roles s400 cruise,ballistic`;
- `/airdef doctrine` — доктрины комплексов (#47; в меню N: «Доктрины…»): `/airdef doctrine <номер|all> reserve <K>` — держать K ракет для срочных целей (баллистика или до удара меньше 12 с); `salvo eco|normal|heavy` — сколько перехватчиков на цель (только базовые / как обычно / на один больше); `sector <центр°> <полуширина°>` или `sector off` — сектор ответственности (0° — север); `guard <объект№>[,<№>]` или `guard off` — стрелять только по тому, что идёт на охраняемые объекты (отметка: `/airdef target here`). По умолчанию ограничений нет; последний шанс сильнее доктрины; `/airdef why <номер>` называет, какая настройка остановила или разрешила пуск; сохраняется с миром и сценариями. Карта охраны и пробелов в ней — вместе с картой (#48).
- `/airdef deploy <patriot|gepard|pantsir|s400|s500>` — поставить комплекс в 8 блоках перед собой;
- `/airdef hold on|off` — запретить или разрешить ПВО огонь, удобно для съёмки ударов;
- `/airdef damage on|off` — разрушать ли ландшафт взрывами (через TNT, нужны читы);
+19 -4
View File
@@ -58,6 +58,8 @@ pub fn tree() -> Tree {
]),
Node::lit("why").runs().then(Node::arg("номер", Value::int_from(1)).runs()),
Node::lit("raid").runs(),
Node::lit("sites").runs(),
Node::lit("strike").then(Node::arg("номер", Value::int_from(1)).runs()),
Node::lit("repair").runs(),
Node::lit("import").runs(),
toggle("ew"),
@@ -87,6 +89,16 @@ pub fn tree() -> Tree {
.runs(),
),
),
Node::lit("doctrine").runs().then(Node::arg("номер", Value::word()).with([
Node::lit("reserve").then(Node::arg("K", Value::int(0, 64)).runs()),
Node::lit("salvo").then(Node::arg("залп", Value::choice(["eco", "normal", "heavy"])).runs()),
Node::lit("sector").with([
Node::lit("off").runs(),
Node::arg("центр°", Value::number(-720.0, 720.0)).then(Node::arg("полуширина°", Value::number(0.0, 180.0)).runs()),
]),
Node::lit("guard").with([Node::lit("off").runs(), Node::arg("объекты", Value::word()).runs()]),
Node::lit("reset").runs(),
])),
Node::lit("stats").runs(),
])
}
@@ -149,9 +161,9 @@ mod tests {
"replay export mine all fps=30 size=1280x720 crf=18 sec=12.5 noaudio", "replay export fps=60", "replay stop",
"scenario save a", "scenario save a fresh", "scenario load a", "scenario load a force", "scenario list",
"scenario delete a", "why", "why 2", "ew on", "ew off", "move 4", "target here", "targets clear",
"deploy patriot", "deploy light", "deploy s400", "wave mix", "wave shahed", "wave shahed 20", "wave kh101 80 800", "wave shahed 20 4000",
"sites", "strike 3", "deploy patriot", "deploy light", "deploy s400", "wave mix", "wave shahed", "wave shahed 20", "wave kh101 80 800", "wave shahed 20 4000",
"wave oreshnik 1 60", "damage on", "damage off", "truth on", "shader hd", "shader off", "shader on", "hold on", "lastchance on", "lastchance off",
"roles all default", "roles patriot drone,jet", "roles pantsir all", "roles tor ballistic",
"roles all default", "doctrine", "doctrine 1 reserve 4", "doctrine all salvo eco", "doctrine 2 sector 90 45", "doctrine 2 sector off", "doctrine 1 guard 1,2", "doctrine 1 reset", "roles patriot drone,jet", "roles pantsir all", "roles tor ballistic",
] {
ok(line);
}
@@ -187,6 +199,9 @@ mod tests {
assert_eq!(bad_at("why x"), 4);
assert_eq!(bad_at("move"), 4);
assert_eq!(bad_at("move 0"), 5);
assert_eq!(bad_at("strike"), 6);
assert_eq!(bad_at("strike 0"), 7);
assert_eq!(bad_at("sites 1"), 6);
assert_eq!(bad_at("scenario"), 8);
assert_eq!(bad_at("scenario load"), 13);
assert_eq!(bad_at("scenario wipe"), 9);
@@ -219,10 +234,10 @@ mod tests {
fn completion_offers_the_next_word_only() {
let top = complete("", &[]);
for w in ["menu", "monitor", "grid", "replay", "scenario", "why", "raid", "repair", "import", "ew", "flare", "move",
"target", "give", "deploy", "wave", "clear", "targets", "damage", "truth", "shader", "hold", "lastchance", "roles", "stats"] {
"target", "give", "deploy", "wave", "clear", "targets", "damage", "truth", "shader", "hold", "lastchance", "roles", "doctrine", "stats", "sites", "strike"] {
assert!(top.contains(&w.to_string()), "{w} in {top:?}");
}
assert_eq!(top.len(), 25);
assert_eq!(top.len(), 28);
assert_eq!(complete("rep", &[]), ["repair", "replay"]);
assert_eq!(complete("replay ", &[]), ["export", "film", "full", "map", "reel", "stop"]);
assert_eq!(complete("replay film ", &[]), ["all", "best"]);
+2
View File
@@ -454,6 +454,8 @@ fn plan_event(rec: &Recording, i: usize, floor: f64) -> Vec<Shot> {
});
add(Shot::new(a, b, &[(a, 1.0), (te - 4.0, 0.3), (te + 40.0, 1.0)], variants, &title(rec, i), "", i));
}
// A strike on a launch site (#101) is in the event list and on the map, not in the auto-camera's films.
EventKind::Strike { .. } => {}
}
shots
}
+21
View File
@@ -34,8 +34,13 @@ pub struct Layout {
pub seed: Option<u64>,
pub wave: Option<Vec<String>>,
pub far: Option<bool>,
pub acoustics: Option<bool>,
pub effects: Option<(bool, bool)>,
pub roles: Vec<(SamKind, u8)>,
/// Fire doctrines by the system's place in the file (#47).
pub doctrines: Vec<(usize, sim::doctrine::Doctrine)>,
/// Strike missiles in stock and ticks towards the next (#101).
pub strikes: Option<(u32, u32)>,
}
/// One line's words, with the line number for the messages.
@@ -124,7 +129,9 @@ impl Layout {
}
"damage" => out.damage = l.flag(1, "значение")?,
"lastchance" => out.last_chance = l.flag(1, "значение")?,
"strikes" => out.strikes = Some((l.need(1, "запас", 0.0, COUNT)? as u32, l.num(2, "до следующей", 0.0, COUNT)?.map_or(0, |v| v as u32))),
"far" => out.far = l.flag(1, "значение")?,
"acoustics" => out.acoustics = l.flag(1, "значение")?,
"effects" => out.effects = Some((l.flag(1, "шейдер")?.unwrap_or(true), l.flag(2, "HD")?.unwrap_or(false))),
"seed" => {
let v = l.w.get(1).ok_or_else(|| l.bad("значение", "строка оборвана"))?;
@@ -134,6 +141,16 @@ impl Layout {
crate::wave::WaveConfig::default().check(&l.w[1..]).map_err(|e| l.bad("налёт", &e))?;
out.wave = Some(l.w[1..].iter().map(|s| s.to_string()).collect());
}
"doctrine" => {
// doctrine <system> <salvo> <reserve> <sector centre|-> <half|-> <guards> [x y z]...
let at = l.need(1, "комплекс", 0.0, MAX_UNITS as f64)? as usize;
let salvo = l.w.get(2).and_then(|v| sim::doctrine::Salvo::from_key(v)).ok_or_else(|| l.bad("залп", "eco, normal или heavy"))?;
let reserve = l.need(3, "резерв", 0.0, sim::doctrine::MAX_RESERVE as f64)? as u32;
let sector = if l.w.get(4) == Some(&"-") { None } else { Some((l.need(4, "центр сектора", -1.0e6, 1.0e6)?, l.need(5, "полуширина", 0.0, 7.0)?)) };
let n = l.need(6, "число объектов", 0.0, 64.0)? as usize;
let guards = (0..n).map(|k| l.xyz(7 + 3 * k, "охраняемый объект")).collect::<Result<Vec<_>, _>>()?;
out.doctrines.push((at, sim::doctrine::Doctrine { guards, sector, reserve, salvo }));
}
"roles" => {
let kind = l.kind(1)?;
out.roles.push((kind, l.need(2, "роли", 0.0, 255.0)? as u8));
@@ -196,6 +213,10 @@ mod tests {
assert_eq!(l.sams[3].comps.len(), 6);
assert_eq!((l.damage, l.far, l.effects, l.seed), (Some(false), Some(true), Some((true, false)), Some(123456789)));
assert_eq!(l.roles.len(), 1);
// Saves without strikes load as before; with them, the stock is read (and refused when wrong).
assert_eq!(l.strikes, None);
assert_eq!(Layout::parse("strikes 2 3500\n").unwrap().strikes, Some((2, 3500)));
assert!(Layout::parse("strikes -1 0\n").is_err() && Layout::parse("strikes x\n").is_err());
// What the build writes now reads back (a wave line from the menu's own text).
let w = crate::wave::WaveConfig::default().text();
assert!(Layout::parse(&format!("wave {w}\n")).is_ok(), "{w}");
+235 -1
View File
@@ -16,6 +16,7 @@ mod cinema;
mod engines;
mod commands;
mod layout;
mod map;
mod film;
mod monitor;
mod panel;
@@ -87,6 +88,8 @@ struct AirDef {
session: u64,
/// The world drawn beyond the render distance (replays and cameras see far off).
far: bool,
/// The acoustics asked for, until the game has them (its answer comes a tick later).
acoustics: Option<bool>,
/// The game's log of block changes (`render.world-log-start`): a replay shows the world as
/// it was at its moment, not the holes the raid left (None where the game has no such log).
block_log: Option<u32>,
@@ -119,6 +122,8 @@ struct AirDef {
model_generation: u32,
/// The instances handed to the game, kept between ticks so nothing is allocated for them.
by_id: Vec<ModelInstanceId>,
/// The command map (#48).
map: map::MapUi,
}
/// What `/airdef replay export` was asked for.
@@ -212,6 +217,9 @@ enum Page {
More,
/// The last raid's events and its replays.
Replays,
/// The systems with their doctrines, and one system's doctrine (by id) (#47).
Fleet,
Doctrine(u32),
}
const MENU_KINDS: u32 = 1;
@@ -236,6 +244,15 @@ const BTN_MAP: u32 = 40;
const BTN_FULL: u32 = 41;
const BTN_REEL: u32 = 42;
const BTN_FAR: u32 = 19;
const BTN_ACOUSTICS: u32 = 20;
const BTN_DOCTRINE: u32 = 21;
const FLEET_ROWS: u32 = 4;
const BTN_RESERVE: u32 = 50;
const BTN_SALVO: u32 = 51;
const BTN_SECTOR_DIR: u32 = 52;
const BTN_SECTOR_WIDTH: u32 = 53;
const BTN_GUARD: u32 = 54;
const BTN_DOC_RESET: u32 = 55;
const BTN_CLASS: u32 = 100;
const BTN_ALL: u32 = 120;
const BTN_DEFAULT: u32 = 121;
@@ -506,6 +523,11 @@ impl AirDef {
.collect()
}
/// Whether the game's acoustics are on, or asked to be.
fn acoustics_on(&self) -> bool {
self.acoustics.unwrap_or_else(render::acoustics)
}
/// The effects preset: none, the post pass, or the post pass under the HD lighting.
fn set_effects(&mut self, shader: bool, hd: bool) {
self.sim.shader = shader;
@@ -861,13 +883,21 @@ impl AirDef {
}
text += &format!("damage {}\n", self.sim.damage);
text += &format!("lastchance {}\n", self.sim.last_chance);
// A scenario to replay from the start begins with the full stock.
let (stock, progress) = if fresh { (sim::strike::TUNE.stock, 0) } else { (self.sim.strikes.stock, self.sim.strikes.progress) };
text += &format!("strikes {stock} {progress}\n");
if view {
text += &format!("far {}\n", self.far);
text += &format!("acoustics {}\n", self.acoustics_on());
text += &format!("effects {} {}\n", self.sim.shader, self.hd);
}
for (kind, roles) in &self.sim.roles {
text += &format!("roles {} {}\n", kind.key(), roles);
}
// Doctrines by the system's place in this file; the default ones aren't written.
for (i, s) in self.sim.sams.iter().enumerate().filter(|(_, s)| !s.doctrine.is_default()) {
text += &format!("doctrine {i} {}\n", s.doctrine.save_text());
}
text
}
@@ -912,6 +942,7 @@ impl AirDef {
/// Adds a checked layout to the scene: nothing here can fail.
fn commit_layout(&mut self, layout: layout::Layout) {
let base = self.sim.sams.len();
for r in layout.sams {
let id = self.sim.id();
let mut sam = Sam::new(id, r.kind, r.pos, r.heading);
@@ -952,6 +983,9 @@ impl AirDef {
if let Some(l) = layout.last_chance {
self.sim.last_chance = l;
}
if let Some((stock, progress)) = layout.strikes {
self.sim.strikes.restore(stock, progress);
}
// A scenario's: the random generator, and the wave menu's raid.
if let Some(v) = layout.seed {
self.sim.reseed(v);
@@ -960,6 +994,10 @@ impl AirDef {
let w: Vec<&str> = w.iter().map(String::as_str).collect();
self.wave.parse(&w);
}
if let Some(a) = layout.acoustics {
self.acoustics = Some(a);
render::set_acoustics(a);
}
if let Some(f) = layout.far {
self.far = f;
}
@@ -969,6 +1007,11 @@ impl AirDef {
for (kind, r) in layout.roles {
self.sim.roles.insert(kind, r);
}
for (i, d) in layout.doctrines {
if let Some(s) = self.sim.sams.get_mut(base + i) {
s.doctrine = d;
}
}
}
fn give(&self) {
@@ -1020,6 +1063,7 @@ impl AirDef {
self.sim.shader = shader;
self.ground = Ground::default();
self.monitor = None;
self.map_close();
self.grid = false;
self.grid_ids.clear();
// The last world's transient state: its sounds were due on its own
@@ -1363,6 +1407,8 @@ impl AirDef {
widgets.push(Widget::List(ListWidget { id: MENU_KINDS, rows, selected: None }));
let _ = &self.monitor;
widgets.push(Widget::Button(ButtonWidget { id: BTN_GRID, label: "Командный пункт: все комплексы (J)".into() }));
let custom = self.sim.sams.iter().filter(|s| !s.doctrine.is_default()).count();
widgets.push(Widget::Button(ButtonWidget { id: BTN_DOCTRINE, label: format!("Доктрины: охрана, сектор, резерв (особых: {custom})…") }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_WAVE, label: "Налёт: состав и запуск…".into() }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_REPLAYS, label: "Повторы…".into() }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_MORE, label: "Огонь, ремонт, эффекты…".into() }));
@@ -1401,6 +1447,7 @@ impl AirDef {
widgets.push(Widget::Button(ButtonWidget { id: BTN_SHADER, label: format!("Эффекты: {fx}") }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_DAMAGE, label: format!("Разрушения: {}", onoff(self.sim.damage)) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_FAR, label: format!("Дальний ландшафт: {}", onoff(self.far)) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_ACOUSTICS, label: format!("Акустика (преграды, эхо): {}", onoff(self.acoustics_on())) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Назад".into() }));
}
Page::Wave => {
@@ -1425,6 +1472,37 @@ impl AirDef {
widgets.push(Widget::Button(ButtonWidget { id: BTN_LAUNCH, label: format!("▶ ПУСК ({})", w.total()) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Состав".into() }));
}
Page::Fleet => {
title = "Доктрины — клик по комплексу".to_string();
let rows = self
.fleet()
.iter()
.map(|&i| {
let s = &self.sim.sams[i];
format!("№{} {} · {}", i + 1, s.kind.name(), s.doctrine.text(&self.sim.targets))
})
.collect();
widgets.push(Widget::List(ListWidget { id: FLEET_ROWS, rows, selected: None }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Назад".into() }));
}
Page::Doctrine(id) => {
let Some(i) = self.sim.sams.iter().position(|s| s.id == id) else { return self.open_menu(Page::Fleet) };
let s = &self.sim.sams[i];
let d = &s.doctrine;
title = format!("№{} {} — доктрина (объяснение: /airdef why {})", i + 1, s.kind.name(), i + 1);
let guard = match d.guards.len() {
0 => "нет (все цели своих классов)".to_string(),
1 => sim::doctrine::object_name(&self.sim.targets, d.guards[0]),
n => format!("{n} объектов (клик — снять)"),
};
widgets.push(Widget::Button(ButtonWidget { id: BTN_GUARD, label: format!("Охраняемый объект: {guard}") }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_SECTOR_DIR, label: format!("Сектор: {}", d.sector_text()) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_SECTOR_WIDTH, label: format!("Ширина сектора: ±{:.0}°", d.sector.map_or(45.0, |(_, h)| h.to_degrees())) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_RESERVE, label: format!("Резерв ракет: {} (в магазине {})", d.reserve, s.missiles) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_SALVO, label: format!("Залп по цели: {}", d.salvo.name()) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_DOC_RESET, label: "Сбросить доктрину".into() }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Комплексы".into() }));
}
Page::Roles(kind) => {
title = format!("{} ({}) — по каким целям стрелять", kind.name(), panel::distance(kind.range()));
let _ = ItemWidget { id: 0, item: String::new(), name: String::new() };
@@ -1443,6 +1521,75 @@ impl AirDef {
ui::open_screen(&Screen { title, widgets });
}
/// The systems that have a doctrine to set: those with missiles (indices into the sim's list).
fn fleet(&self) -> Vec<usize> {
(0..self.sim.sams.len()).filter(|&i| self.sim.sams[i].kind.armed()).collect()
}
/// Changes one system's doctrine (the menu's buttons and `/airdef doctrine`).
fn edit_doctrine(&mut self, i: usize, f: impl FnOnce(&mut sim::doctrine::Doctrine)) {
if let Some(s) = self.sim.sams.get_mut(i) {
f(&mut s.doctrine);
// It chooses again under the new rules.
s.target = None;
s.gun_target = None;
self.dirty = true;
}
}
/// `/airdef doctrine [<n|all> reserve K | salvo eco|normal|heavy | sector C H | sector off | guard N[,M]|off | reset]` (#47).
fn doctrine_command(&mut self, w: &[&str]) -> Result<String, String> {
const USAGE: &str = "/airdef doctrine <номер|all> reserve <K> | salvo eco|normal|heavy | sector <центр°> <полуширина°> | sector off | guard <объект№>[,<№>] | guard off | reset";
let Some((who, what)) = w.split_first() else {
for i in self.fleet() {
let s = &self.sim.sams[i];
say(&format!("№{} {}: {}", i + 1, s.kind.name(), s.doctrine.text(&self.sim.targets)));
}
return Ok(format!("Изменить: {USAGE}"));
};
let which: Vec<usize> = if *who == "all" {
self.fleet()
} else {
let i = who.parse::<usize>().ok().and_then(|n| n.checked_sub(1)).filter(|&i| self.sim.sams.get(i).is_some_and(|s| s.kind.armed())).ok_or("Нет такого вооружённого комплекса")?;
vec![i]
};
let num = |v: Option<&&str>, what: &str, lo: f64, hi: f64| -> Result<f64, String> {
v.and_then(|v| v.parse::<f64>().ok()).filter(|x| x.is_finite() && (lo..=hi).contains(x)).ok_or(format!("{what}: число от {lo} до {hi}"))
};
let mut edit: Box<dyn Fn(&mut sim::doctrine::Doctrine)> = match what {
["reserve", k] => {
let k = num(Some(k), "резерв", 0.0, sim::doctrine::MAX_RESERVE as f64)? as u32;
Box::new(move |d| d.reserve = k)
}
["salvo", v] => {
let v = sim::doctrine::Salvo::from_key(v).ok_or("Залп: eco, normal или heavy")?;
Box::new(move |d| d.salvo = v)
}
["sector", "off"] => Box::new(|d| d.sector = None),
["sector", c, h] => {
let c = num(Some(c), "центр сектора", -720.0, 720.0)?.to_radians();
let h = num(Some(h), "полуширина сектора", 0.0, 180.0)?.to_radians();
Box::new(move |d| d.sector = Some((c, h)))
}
["guard", "off"] => Box::new(|d| d.guards.clear()),
["guard", list] => {
let mut at = Vec::new();
for n in list.split(',') {
let k = n.parse::<usize>().ok().and_then(|n| n.checked_sub(1)).and_then(|k| self.sim.targets.get(k)).ok_or(format!("Нет объекта «{n}» (отметьте его /airdef target here)"))?;
at.push(*k);
}
Box::new(move |d| d.guards = at.clone())
}
["reset"] => Box::new(|d| *d = sim::doctrine::Doctrine::default()),
_ => return Err(USAGE.into()),
};
for &i in &which {
self.edit_doctrine(i, &mut edit);
}
let first = which.first().map(|&i| self.sim.sams[i].doctrine.text(&self.sim.targets)).unwrap_or_default();
Ok(format!("Доктрина ({} компл.): {first}", which.len()))
}
fn set_roles(&mut self, kind: SamKind, mask: Option<u8>) {
let mask = match mask {
Some(m) => {
@@ -1490,6 +1637,7 @@ impl Mod for AirDef {
session: 0,
smoke: Default::default(),
far: true,
acoustics: None,
moving: None,
hd: false,
world: None,
@@ -1504,7 +1652,9 @@ impl Mod for AirDef {
model_ids: std::collections::HashMap::new(),
model_generation: u32::MAX,
by_id: Vec::new(),
map: map::MapUi::default(),
};
let _ = input::register_key("map", "Карта: командная, расстановка", "key.keyboard.m");
let _ = input::register_key("menu", "Меню AirDef", "key.keyboard.n");
let _ = input::register_key("monitor", "Монитор ПВО: следующий комплекс", "key.keyboard.k");
let _ = input::register_key("grid", "Командный пункт: все комплексы", "key.keyboard.j");
@@ -1539,6 +1689,7 @@ impl Mod for AirDef {
for event in input::poll() {
match event {
Event::UseStart(u) => self.use_item(&u.item.id),
Event::KeyDown(k) if k == "map" => self.map_toggle(),
Event::KeyDown(k) if k == "menu" => {
if self.replay.is_some() {
self.stop_replay();
@@ -1662,6 +1813,9 @@ impl Mod for AirDef {
}
}
self.emit(out);
self.map.sample(&self.sim);
// The map's events and shapes: at the tick's pace (a frame hook's budget is too small for it).
self.map_frame();
self.draw_hud(DVec3::new(p.x, p.y, p.z), p.yaw);
// The replay takes the screen; else the monitors and the cameras they show.
if let Some(r) = &self.replay {
@@ -1734,6 +1888,58 @@ impl Mod for AirDef {
}
return;
}
(Page::Main, BTN_DOCTRINE) => {
self.open_menu(Page::Fleet);
return;
}
(Page::Fleet, FLEET_ROWS) => {
if let Some(&i) = self.fleet().get(row as usize) {
self.open_menu(Page::Doctrine(self.sim.sams[i].id));
}
return;
}
(Page::Fleet, BTN_BACK) => {
self.open_menu(Page::Main);
return;
}
(Page::Doctrine(_), BTN_BACK) => {
self.open_menu(Page::Fleet);
return;
}
(Page::Doctrine(id), b) => {
let Some(i) = self.sim.sams.iter().position(|s| s.id == id) else { return };
let objects = self.sim.targets.clone();
match b {
BTN_RESERVE => self.edit_doctrine(i, |d| d.reserve = [0, 1, 2, 4, 8, 16].into_iter().find(|&r| r > d.reserve).unwrap_or(0)),
BTN_SALVO => self.edit_doctrine(i, |d| d.salvo = d.salvo.next()),
// Eight directions of 45 degrees, then off.
BTN_SECTOR_DIR => self.edit_doctrine(i, |d| {
let half = d.sector.map_or(45f64.to_radians(), |(_, h)| h);
d.sector = match d.sector {
None => Some((0.0, half)),
Some((c, _)) if (c.to_degrees().rem_euclid(360.0) / 45.0).round() < 7.0 => Some((c + 45f64.to_radians(), half)),
Some(_) => None,
};
}),
BTN_SECTOR_WIDTH => self.edit_doctrine(i, |d| {
if let Some((c, h)) = d.sector {
let deg = [30.0, 45.0, 60.0, 90.0, 135.0].into_iter().find(|&w| w > h.to_degrees() + 0.5).unwrap_or(30.0);
d.sector = Some((c, f64::to_radians(deg)));
}
}),
// None, then each object in turn, then none.
BTN_GUARD => self.edit_doctrine(i, |d| {
let at = d.guards.first().and_then(|g| objects.iter().position(|t| t.distance(*g) < 16.0));
d.guards = match (d.guards.len(), at) {
(0, _) => objects.first().copied().into_iter().collect(),
(1, Some(k)) => objects.get(k + 1).copied().into_iter().collect(),
_ => Vec::new(),
};
}),
BTN_DOC_RESET => self.edit_doctrine(i, |d| *d = sim::doctrine::Doctrine::default()),
_ => return,
}
}
(Page::More, BTN_HOLD) => self.sim.hold = !self.sim.hold,
(Page::Main, BTN_MONITOR) => self.cycle_monitor(),
(Page::Main, BTN_WAVE) => {
@@ -1840,6 +2046,12 @@ impl Mod for AirDef {
self.apply_far();
self.dirty = true;
}
(Page::More, BTN_ACOUSTICS) => {
let on = !self.acoustics_on();
self.acoustics = Some(on);
render::set_acoustics(on);
self.dirty = true;
}
(Page::More, BTN_DAMAGE) => {
self.sim.damage = !self.sim.damage;
self.dirty = true;
@@ -1898,6 +2110,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()))
}
@@ -1950,6 +2164,20 @@ impl Mod for AirDef {
self.launch_wave(&mut out)?;
None
}
["sites"] => {
// The launch sites the radars have found, and the strike missiles (#101).
let p = player().ok_or("Нет игрока")?;
for l in self.sim.site_lines(DVec3::new(p.x, p.y, p.z)) {
say(&l);
}
None
}
["strike", n] => {
// A strike missile at known site n, from where the player stands.
let p = player().ok_or("Нет игрока")?;
let id = n.parse::<u32>().map_err(|_| "/airdef strike <номер ПУ из /airdef sites>")?;
Some(self.sim.order_strike(id, DVec3::new(p.x, p.y, p.z), &mut out)?)
}
["move", n] => {
// Unit n (as the summary numbers them) drives to where the player stands.
let p = player().ok_or("Нет игрока")?;
@@ -2007,6 +2235,7 @@ impl Mod for AirDef {
self.dirty = true;
Some(format!("Разрушения: {}", if self.sim.damage { "вкл" } else { "выкл" }))
}
["doctrine", rest @ ..] => Some(self.doctrine_command(rest)?),
["roles"] => {
for kind in SamKind::ARMED {
let r = self.sim.roles.get(&kind).copied().unwrap_or(kind.spec().roles);
@@ -2061,13 +2290,18 @@ impl Mod for AirDef {
Some(format!("Эффекты: {}", match *v { "off" => "выкл", "hd" => "HD", _ => "обычные" }))
}
["stats"] => Some(format!(
"В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}, разбились о препятствия: {}; {}",
"В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}, разбились о препятствия: {}; удары по ПУ: пущено {}, уничтожено {}, подавлено {}, промахов {}, сбито ПВО {}; {}",
self.sim.drones.iter().filter(|d| d.alive()).count(),
self.sim.missiles.len(),
self.sim.sams.len(),
self.sim.kills,
self.sim.hits,
self.sim.crashes,
self.sim.strikes.fired,
self.sim.strikes.destroyed,
self.sim.strikes.stunned,
self.sim.strikes.missed,
self.sim.strikes.shot_down,
self.ground.world.text()
)),
_ => return Err(help()),
File diff suppressed because it is too large Load Diff
+10
View File
@@ -67,6 +67,16 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec<Line> {
if hidden > 0 {
lines.push(line(format!("Вне видимости РЛС: {hidden}"), GREY));
}
// The launch sites found, and the strike missiles (#101).
if !sim.strikes.known.is_empty() || !sim.strikes.flights.is_empty() {
let st = &sim.strikes;
let live = st.known.iter().filter(|k| !matches!(k.state, crate::sim::strike::SiteState::Destroyed(_))).count();
let mut text = format!("Известных ПУ: {live} · ударных ракет: {}", st.stock);
if let Some(f) = st.flights.iter().map(|f| f.arrive.saturating_sub(sim.tick)).min() {
text += &format!(" · в полёте {} (до цели {} с)", st.flights.len(), f / 20);
}
lines.push(line(text, YELLOW));
}
if !sim.target_health.is_empty() {
let list: Vec<String> = sim.target_health.iter().map(|h| format!("{h:.0}%")).collect();
let worst = sim.target_health.iter().copied().fold(100.0, f64::min);
+38
View File
@@ -107,6 +107,44 @@ pub fn draw(sim: &Sim, player: DVec3, yaw: f32) -> Vec<Shape> {
s.push(square(x, y, 1.0, 0xFFFFE060));
}
}
// Launch sites the radars found: a diamond where the place is known (with the circle of its error), and a mark on
// the rim along the bearing when it is beyond the scope or only the bearing is known.
for k in &sim.strikes.known {
let Some(e) = &k.est else { continue };
let gone = matches!(k.state, crate::sim::strike::SiteState::Destroyed(_));
let color = if gone { 0xFF707070 } else { 0xFFFFA040 };
let inside = e.fix.then(|| scope(DVec3::new(e.pos.x, player.y, e.pos.y))).flatten();
if let Some((x, y, _)) = inside {
let q = 3.0;
s.push(quad(pt(x, y - q), pt(x + q, y), pt(x, y + q), pt(x - q, y), color));
let rad = (e.radius / RANGE) as f32 * r;
if rad > 3.5 {
for i in 0..SEGMENTS {
let (a0, a1) = (i as f32 / SEGMENTS as f32 * tau, (i + 1) as f32 / SEGMENTS as f32 * tau);
if i % 2 == 0 {
let p = |a: f32, rr: f32| pt(x + a.sin() * rr, y - a.cos() * rr);
s.push(quad(p(a0, rad - 0.4), p(a0, rad + 0.4), p(a1, rad + 0.4), p(a1, rad - 0.4), alpha(color, 0.5)));
}
}
}
s.push(text(k.site.to_string(), x + 5.0, y - 9.0, color, false));
} else {
// On the rim, along the bearing (compass: north up, east right).
let a = e.bearing as f32;
let (x, y) = (cx + a.sin() * (r - 3.0), cy - a.cos() * (r - 3.0));
let q = if e.fix { 3.0 } else { 2.0 };
s.push(quad(pt(x, y - q), pt(x + q, y), pt(x, y + q), pt(x - q, y), color));
s.push(text(format!("{}{}", k.site, if e.fix { "" } else { "?" }), x - a.sin() * 8.0, y + a.cos() * -8.0 - 4.0, color, true));
}
}
// Strike missiles in flight, on the same footing: a small bright dot where the scope reaches, else nothing.
for f in &sim.strikes.flights {
let t = (sim.tick.saturating_sub(f.start)) as f64 / (f.arrive - f.start).max(1) as f64;
let p = f.from.lerp(DVec3::new(f.aim.x, f.from.y, f.aim.y), t.min(1.0));
if let Some((x, y, _)) = scope(p) {
s.push(square(x, y, 1.5, 0xFFFFFFA0));
}
}
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.
+25 -2
View File
@@ -7,7 +7,7 @@
use crate::film::{self, Film};
use crate::panel::distance;
use crate::sim::Ground;
use crate::sim::record::{EventKind, Recording, STEP, What};
use crate::sim::record::{EventKind, Recording, STEP, StrikeResult, What};
use std::cell::Cell;
use crate::sim::{Class, DroneKind, Out, Sim};
use glam::DVec3;
@@ -137,6 +137,17 @@ fn outcome_note(outcome: crate::sim::outcome::Outcome) -> &'static str {
}
}
/// How a strike on a launch site ended, in words.
fn strike_word(result: StrikeResult) -> &'static str {
match result {
StrikeResult::Destroyed => "площадка уничтожена",
StrikeResult::Stunned => "площадка подавлена",
StrikeResult::Missed => "промах",
StrikeResult::Intercepted => "ракета сбита ПВО противника",
StrikeResult::Halted => "пуски прекращены (вне карты)",
}
}
/// What an event was, in a line.
pub fn title(rec: &Recording, i: usize) -> String {
let e = &rec.events[i];
@@ -151,6 +162,7 @@ pub fn title(rec: &Recording, i: usize) -> String {
EventKind::SamLost { kind, index, .. } => format!("{} №{} уничтожен", kind.name(), index + 1),
EventKind::Split { .. } => "«Орешник»: разделение боевых блоков".into(),
EventKind::Crash { kind, .. } => format!("«{}» разбивается о препятствие", kind.spec().name),
EventKind::Strike { site, result } => format!("Удар по ПУ №{site}: {}", strike_word(result)),
}
}
@@ -167,6 +179,7 @@ pub fn row(rec: &Recording, i: usize) -> String {
EventKind::SamLost { kind, index, .. } => format!("{}-{} уничтожен", kind.name(), index + 1),
EventKind::Split { .. } => "Орешник: разделение".into(),
EventKind::Crash { kind, .. } => format!("{}: о рельеф", kind.spec().name),
EventKind::Strike { site, result } => format!("ПУ {site}: {}", strike_word(result)),
};
format!("{} {what}", clock(e.at as f64))
}
@@ -199,6 +212,7 @@ pub fn interest(rec: &Recording, i: usize) -> f64 {
}
EventKind::SamLost { .. } => 12.0,
EventKind::Crash { power, .. } => power as f64,
EventKind::Strike { .. } => 0.0,
EventKind::Split { drone } => {
// The bus's split, not each block's.
let first = rec.events.iter().position(|o| matches!(o.kind, EventKind::Split { .. }));
@@ -216,6 +230,7 @@ fn clip(rec: &Recording, i: usize) -> Clip {
EventKind::SamLost { .. } => (80.0, 90.0),
EventKind::Split { .. } => (40.0, 170.0),
EventKind::Crash { .. } => (120.0, 60.0),
EventKind::Strike { .. } => (60.0, 80.0),
};
Clip { event: i, from: (at - before).max(0.0), to: (at + after).min(rec.len as f64) }
}
@@ -499,6 +514,12 @@ impl Player {
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][(power as usize).min(4)],
EventKind::SamLost { .. } => "airdef:blast.big",
EventKind::Split { .. } => "airdef:boom.sonic",
EventKind::Strike { result: StrikeResult::Halted, .. } => continue,
EventKind::Strike { result, .. } => ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][match result {
StrikeResult::Destroyed => 4,
StrikeResult::Intercepted => 0,
_ => 3,
}],
};
let gain = if let EventKind::Kill { kind, outcome, .. } = e.kind { crate::sim::outcome::look(kind, outcome).2 } else { 1.0 };
out.sound(event, heard(e.pos), gain, 1.0);
@@ -694,7 +715,7 @@ impl Player {
let look = rec.track(drone).and_then(|tr| tr.at(o, t)).filter(|_| t < te).unwrap_or(e.pos);
(eye, look, 50.0, 1)
}
EventKind::SamLost { .. } => (side(e.pos, o + DVec3::new(300.0, 0.0, 0.0), 45.0), e.pos, 50.0, 0),
EventKind::SamLost { .. } | EventKind::Strike { .. } => (side(e.pos, o + DVec3::new(300.0, 0.0, 0.0), 45.0), e.pos, 50.0, 0),
EventKind::Split { .. } => {
let ground = DVec3::new(e.pos.x, o.y, e.pos.z);
let eye = ground + DVec3::new(170.0, 8.0, 40.0);
@@ -953,6 +974,7 @@ impl Player {
}
EventKind::SamLost { .. } => s.push(fill(x - 4.0, y - 4.0, 8.0, 8.0, 0xFF200000)),
EventKind::Split { .. } => s.push(fill(x - 2.0, y - 2.0, 4.0, 4.0, 0xFFFF9020)),
EventKind::Strike { .. } => s.push(fill(x - 3.0, y - 3.0, 6.0, 6.0, 0xFFFFA040)),
}
}
// Header, legend, time line.
@@ -983,6 +1005,7 @@ impl Player {
EventKind::Crash { .. } => 0xFF903020,
EventKind::SamLost { .. } => 0xFFFF00FF,
EventKind::Split { .. } => 0xFFFF9020,
EventKind::Strike { .. } => 0xFFFFA040,
};
s.push(fill(x, ty - 2.0, 1.0, 7.0, c));
}
+227 -14
View File
@@ -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,19 @@ 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(super) struct Setup {
@@ -65,20 +71,121 @@ 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,
}
/// 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 {
let (x, z) = (f64::from(x), f64::from(z));
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 + 14.0).max(-4.0) as i32
}
fn ground_at(hilly: bool, x: f64, z: f64) -> f64 {
if hilly { f64::from(hills(x as i32, z as i32)) } else { 64.0 }
}
/// 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>,
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, asked: Default::default(), tickets: 0 }
}
pub fn height(&self, x: i32, z: i32) -> i32 {
if self.hilly { hills(x, z) + if self.mountains { mountains(x, z) } else { 0 } } else { 64 }
}
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 }
}
}
/// 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 y = |x: f64, z: f64| if s.hilly { ground_at(true, x, z) - 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 +195,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 +213,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 +234,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 +293,8 @@ pub(super) fn fly(s: &Setup) -> Result {
break;
}
}
result.tickets = land.tickets;
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 +302,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 +328,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 +405,46 @@ 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");
}
/// 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 +475,36 @@ 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 1..=8u64 {
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, k.1.0)).collect::<Vec<_>>());
}
+269
View File
@@ -0,0 +1,269 @@
//! Fire doctrine (#47): what a system is told about its job beyond the roles. Protected objects (it fires
//! only at what threatens them), a sector of responsibility, a reserve of missiles held back for what
//! matters, and how many interceptors it asks for on one target. The predicates are shared by `step_sams`
//! (through `why::basis`) and the diagnostic; the default doctrine blocks nothing and changes nothing.
//! Everything is judged on the system's track (its forecast), never on the drone's true place.
use super::*;
/// How many interceptors a system asks for on one target: the base shoot-look-shoot only, as now, or one more.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum Salvo {
Economy,
#[default]
Normal,
Heavy,
}
impl Salvo {
pub fn name(self) -> &'static str {
match self {
Salvo::Economy => "экономный",
Salvo::Normal => "обычный",
Salvo::Heavy => "усиленный",
}
}
pub fn key(self) -> &'static str {
match self {
Salvo::Economy => "eco",
Salvo::Normal => "normal",
Salvo::Heavy => "heavy",
}
}
pub fn from_key(s: &str) -> Option<Salvo> {
[Salvo::Economy, Salvo::Normal, Salvo::Heavy].into_iter().find(|v| v.key() == s)
}
pub fn next(self) -> Salvo {
match self {
Salvo::Economy => Salvo::Normal,
Salvo::Normal => Salvo::Heavy,
Salvo::Heavy => Salvo::Economy,
}
}
}
/// A system's doctrine. `Default` is the sandbox: no guards, no sector, no reserve, the usual salvo.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Doctrine {
/// Protected objects (their places): when any, the system fires only at what threatens one of them.
pub guards: Vec<DVec3>,
/// Sector of responsibility: centre (a yaw as `heading_of`, radians) and half-width.
pub sector: Option<(f64, f64)>,
/// Missiles held back: below this many, only what matters is shot at.
pub reserve: u32,
pub salvo: Salvo,
}
/// Where a track counts as threatening an object: within this many blocks of it, or flying at it.
pub const GUARD_R: f64 = 120.0;
/// Most missiles one can reserve.
pub const MAX_RESERVE: u32 = 64;
pub(super) const OUT_SECTOR: u8 = 1;
pub(super) const NO_THREAT: u8 = 2;
pub(super) const RESERVE: u8 = 4;
impl Doctrine {
pub fn is_default(&self) -> bool {
*self == Doctrine::default()
}
/// Whether the track is within the sector (judged from the system's place).
pub fn in_sector(&self, pos: DVec3, at: DVec3) -> bool {
self.sector.is_none_or(|(centre, half)| {
let to = at - pos;
to.x.hypot(to.z) < 30.0 || angle_diff(centre, heading_of(to)).abs() <= half
})
}
/// The first protected object the track threatens (its index in `guards`): near it, or flying towards it.
pub fn threatens(&self, track: &Track, now: u64) -> Option<usize> {
let at = track.est(now);
let slack = GUARD_R + track.uncertainty(now);
self.guards.iter().position(|g| {
let (dx, dz) = (g.x - at.x, g.z - at.z);
let near = dx.hypot(dz);
if near <= slack {
return true;
}
let (vx, vz) = (track.vel.x, track.vel.z);
let speed = vx.hypot(vz);
// Closing, and the line of flight passes the object within the slack.
speed > 1e-3 && vx * dx + vz * dz > 0.0 && (vx * dz - vz * dx).abs() / speed <= slack
})
}
/// Why the area (sector, protected objects) rules the track out; 0 is a pass.
pub fn area_blocks(&self, pos: DVec3, track: &Track, now: u64) -> u8 {
(if self.in_sector(pos, track.est(now)) { 0 } else { OUT_SECTOR }) | (if self.guards.is_empty() || self.threatens(track, now).is_some() { 0 } else { NO_THREAT })
}
/// What matters enough to spend the reserve: a ballistic missile, or anything about to hit.
pub fn matters(d: &Drone) -> bool {
d.kind.spec().class == Class::Ballistic || why::arrival(d) < why::URGENT
}
/// The reserve holds the launch back: the magazine is at the reserve and the target doesn't matter.
pub fn reserve_blocks(&self, missiles: u32, d: &Drone) -> u8 {
if self.reserve > 0 && missiles <= self.reserve && !Self::matters(d) { RESERVE } else { 0 }
}
/// Interceptors this doctrine asks for on `d`: `why::needed` by default, the base only when economical, one more when heavy.
pub fn needed(&self, d: &Drone) -> u32 {
let base = if d.kind.spec().class == Class::Ballistic { 2 } else { 1 };
match self.salvo {
Salvo::Economy => base,
Salvo::Normal => why::needed(d),
Salvo::Heavy => why::needed(d) + 1,
}
}
/// The sector in words (`90±45°`); degrees on the same scale as `/airdef doctrine ... sector`.
pub fn sector_text(&self) -> String {
match self.sector {
Some((c, h)) => format!("{:.0}±{:.0}°", c.to_degrees().rem_euclid(360.0), h.to_degrees()),
None => "круговой".to_string(),
}
}
/// The doctrine as the player reads it, empty for the default.
pub fn text(&self, targets: &[DVec3]) -> String {
if self.is_default() {
return "обычная (без ограничений)".to_string();
}
let guards = if self.guards.is_empty() {
"нет".to_string()
} else {
self.guards.iter().map(|g| object_name(targets, *g)).collect::<Vec<_>>().join(", ")
};
format!("охрана: {guards}; сектор: {}; резерв: {}; залп: {}", self.sector_text(), self.reserve, self.salvo.name())
}
/// One line of the save: `salvo reserve sector-centre sector-half guards x y z ...` (`-` for no sector).
pub fn save_text(&self) -> String {
let sector = self.sector.map_or("- -".to_string(), |(c, h)| format!("{c} {h}"));
let mut s = format!("{} {} {sector} {}", self.salvo.key(), self.reserve, self.guards.len());
for g in &self.guards {
s += &format!(" {} {} {}", g.x, g.y, g.z);
}
s
}
}
/// An object by number in the world's list ("объект №2"), else by place.
pub fn object_name(targets: &[DVec3], g: DVec3) -> String {
match targets.iter().position(|t| t.distance(g) < 16.0) {
Some(i) => format!("объект №{}", i + 1),
None => format!("точка {:.0} {:.0}", g.x, g.z),
}
}
#[cfg(test)]
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 }
}
#[test]
fn the_default_doctrine_blocks_nothing() {
let d = Doctrine::default();
let t = track(DVec3::new(500.0, 100.0, 0.0), DVec3::ZERO);
assert_eq!(d.area_blocks(DVec3::ZERO, &t, 0), 0);
assert!(d.is_default());
assert_eq!(Doctrine::default().save_text(), "normal 0 - - 0");
}
#[test]
fn a_sector_and_guards_rule_targets_out_by_their_tracks() {
let mut d = Doctrine::default();
// North is yaw 0 (towards -z): a sector of 45 degrees either side of north.
d.sector = Some((0.0, 45f64.to_radians()));
let north = track(DVec3::new(0.0, 100.0, -800.0), DVec3::ZERO);
let south = track(DVec3::new(0.0, 100.0, 800.0), DVec3::ZERO);
assert_eq!(d.area_blocks(DVec3::ZERO, &north, 0), 0);
assert_eq!(d.area_blocks(DVec3::ZERO, &south, 0), OUT_SECTOR);
d.sector = None;
d.guards = vec![DVec3::new(1000.0, 70.0, 0.0)];
// Flying at the object from afar threatens it; flying past, or away, doesn't.
let at = track(DVec3::new(-2000.0, 100.0, 10.0), DVec3::new(2.0, 0.0, 0.0));
let past = track(DVec3::new(-2000.0, 100.0, 900.0), DVec3::new(2.0, 0.0, 0.0));
let away = track(DVec3::new(-2000.0, 100.0, 0.0), DVec3::new(-2.0, 0.0, 0.0));
assert_eq!(d.area_blocks(DVec3::ZERO, &at, 0), 0);
assert_eq!(d.area_blocks(DVec3::ZERO, &past, 0), NO_THREAT);
assert_eq!(d.area_blocks(DVec3::ZERO, &away, 0), NO_THREAT);
}
#[test]
fn the_save_line_is_what_the_layout_reads() {
let mut d = Doctrine { salvo: Salvo::Economy, reserve: 3, ..Default::default() };
d.sector = Some((1.5, 0.5));
d.guards = vec![DVec3::new(1.0, 2.0, 3.0)];
let text = format!("sam {} 0 70 0 0\ndoctrine 0 {}\n", SamKind::Patriot.key(), d.save_text());
let l = crate::layout::Layout::parse(&text).unwrap();
assert_eq!(l.doctrines, vec![(0, d)]);
assert!(crate::layout::Layout::parse("doctrine 0 loud 1 - - 0\n").is_err());
assert!(crate::layout::Layout::parse("doctrine 0 eco 1 - - 2 1 2 3\n").is_err(), "guards cut short");
}
use super::sites::testland::{Land, serve};
/// One Patriot against one Shahed flying in from the north at its aim, the doctrine set before the raid:
/// the missiles it fired, the Shahed's ticks to its aim at the first launch, and the `why` lines at the end.
fn fire(doc: Doctrine) -> (u32, Option<f64>, Vec<String>) {
let mut sim = Sim::new(5);
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Buk, DVec3::new(0.0, 70.0, 0.0), 0.0));
sim.sams[0].roles = 0xFF;
sim.sams[0].doctrine = doc;
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, -600.0), DVec3::Z, DVec3::new(0.0, 70.0, -80.0));
let (mut ground, land, mut out) = (Ground::default(), Land(|_, _| 64), Out::default());
let mut first = None;
let mut lines: Vec<String> = Vec::new();
for _ in 0..900 {
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
if first.is_none() && sim.sams[0].fired > 0 {
first = sim.drones.first().map(why::arrival);
}
if sim.drones.iter().all(|d| !d.alive()) {
break;
}
// The explanation while the target is in the sky: all the lines it gave then.
if sim.tick % 10 == 0 {
lines.extend(sim.why(0).into_iter().map(|c| c.text));
}
}
(sim.sams[0].fired, first, lines)
}
// #47: the defaults fire as before; each setting changes the decision and `why` names it
#[test]
fn each_setting_changes_the_shot_and_why_says_which() {
let (base, first, w) = fire(Doctrine::default());
assert!(base > 0, "the baseline never fired: {w:?}");
let first = first.unwrap();
assert!(first > why::URGENT, "the baseline fires late: {first}");
let has = |text: &[String], what: &str| text.iter().any(|l| l.contains(what));
// The raid comes from the north (yaw 0): a sector round north holds it, one round south does not.
let sector = |c: f64| Doctrine { sector: Some((f64::to_radians(c), f64::to_radians(40.0))), ..Default::default() };
assert_eq!(fire(sector(0.0)).0, base, "a sector that holds the raid changes nothing");
let (n, _, why) = fire(sector(180.0));
assert_eq!(n, 0);
assert!(has(&why, "вне сектора"), "{why:?}");
// A reserve above the magazine holds the shot until the target is urgent.
let (n, late, _) = fire(Doctrine { reserve: 64, ..Default::default() });
assert!(n > 0 && late.unwrap() < why::URGENT, "{n} {late:?}");
// An object far off the raid's line is no one's to guard here; the one it flies at is.
let (n, _, why) = fire(Doctrine { guards: vec![DVec3::new(0.0, 70.0, 50000.0)], ..Default::default() });
assert_eq!(n, 0);
assert!(has(&why, "не угрожает"), "{why:?}");
let (n, _, why) = fire(Doctrine { guards: vec![DVec3::new(0.0, 70.0, -80.0)], ..Default::default() });
assert_eq!(n, base);
assert!(has(&why, "прикрывает"), "{why:?}");
// Economical asks for no more interceptors than the usual, heavy no fewer.
let (eco, _, _) = fire(Doctrine { salvo: Salvo::Economy, ..Default::default() });
let (heavy, _, _) = fire(Doctrine { salvo: Salvo::Heavy, ..Default::default() });
assert!(eco <= base && base <= heavy, "{eco} {base} {heavy}");
}
}
+5 -4
View File
@@ -54,6 +54,7 @@ impl Sim {
continue;
}
let at = truck.pos;
let truck_id = truck.id;
// Where it was going: hand over, or load up.
if let Some(job) = truck.job
&& let Some(j) = self.sams.iter().position(|s| s.id == job)
@@ -73,7 +74,7 @@ impl Sim {
// Stopped short of it (it drove off meanwhile): on to where it stands now.
let to = self.sams[j].pos;
let side = horizontal(at - to).normalize_or(DVec3::X) * (HANDOVER * 0.6);
self.order_move(t, to + side);
self.order_move_near(t, to + side, 3.5);
continue;
} else {
self.sams[t].job = None;
@@ -84,7 +85,7 @@ impl Sim {
let next = if self.sams[t].cargo > 0 {
self.sams
.iter()
.filter(|s| s.empty && s.alive() && !s.moving() && !taken.contains(&s.id))
.filter(|s| s.empty && s.alive() && !s.moving() && !taken.contains(&s.id) && !self.routes.out_of_reach(truck_id, s.id, now))
.min_by(|a, b| a.pos.distance(at).total_cmp(&b.pos.distance(at)))
.map(|s| (s.id, s.pos))
} else {
@@ -95,7 +96,7 @@ impl Sim {
.then(|| {
self.sams
.iter()
.filter(|d| d.kind == SamKind::Depot && d.alive() && d.cargo > 0)
.filter(|d| d.kind == SamKind::Depot && d.alive() && d.cargo > 0 && !self.routes.out_of_reach(truck_id, d.id, now))
.min_by(|a, b| a.pos.distance(at).total_cmp(&b.pos.distance(at)))
.map(|d| (d.id, d.pos))
})
@@ -106,7 +107,7 @@ impl Sim {
// Stop beside it, not on it.
let side = horizontal(at - to).normalize_or(DVec3::X) * (HANDOVER * 0.6);
if to.distance(at) > HANDOVER {
self.order_move(t, to + side);
self.order_move_near(t, to + side, 3.5);
}
}
}
+93 -10
View File
@@ -10,9 +10,11 @@ use glam::{DQuat, DVec3};
use std::collections::HashMap;
mod damage;
pub mod doctrine;
pub mod fires;
mod raid;
pub mod sites;
pub mod strike;
pub mod record;
mod sight;
mod terrain;
@@ -20,6 +22,7 @@ mod ew;
pub mod lights;
mod logistics;
mod orders;
mod route;
mod voices;
pub mod outcome;
mod units;
@@ -33,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::{Ident, LowSensor, Track, RIM_RAYS};
use damage::dice;
use outcome::{Impact, Outcome};
pub use units::{Family, SamKind};
@@ -353,6 +356,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| v.0 <= -64) {
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| v.0 <= -64) {
self.known.remove(&(x + 4, z + 4));
}
}
}
true
}
/// How many chunks are wanted so far this tick.
@@ -917,11 +935,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 }
}
}
@@ -1272,6 +1293,8 @@ pub struct Sam {
pub fired: u32,
/// The classes of target it engages (`Class::bit`s).
pub roles: u8,
/// Its fire doctrine (#47): protected objects, sector, reserve, salvo.
pub doctrine: doctrine::Doctrine,
/// 1 whole, 0 destroyed: a damaged system turns slower, sees less and hits worse.
pub health: f64,
/// Ticks its wreck still burns.
@@ -1344,6 +1367,7 @@ impl Sam {
kills: 0,
fired: 0,
roles: spec.roles,
doctrine: doctrine::Doctrine::default(),
state: SamState::Search,
launched: 0,
health: 1.0,
@@ -1486,6 +1510,11 @@ pub struct Sim {
pub hits: u32,
/// Drones that struck the land or a building short of their aim.
pub crashes: 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)>,
pub launched: u32,
/// Explosions damage the land (primed TNT through commands).
pub damage: bool,
@@ -1509,8 +1538,12 @@ pub struct Sim {
pub queue: Vec<(u64, Drone)>,
/// The raid's launch sites and their jobs (#100).
pub sites: sites::Sites,
/// Launch sites found by the radars, and the counter-strikes on them (#101).
pub strikes: strike::Strikes,
/// A raid being planned.
plan: Option<raid::Plan>,
/// The vehicles' ground routes, and the jobs the trucks found out of reach (#94).
routes: route::Routes,
/// Each marked object's health, 0 to 100 (`targets`' order).
pub target_health: Vec<f64>,
/// The raid in progress, for its report.
@@ -1568,6 +1601,9 @@ impl Sim {
kills: 0,
hits: 0,
crashes: 0,
settling: Vec::new(),
#[cfg(test)]
crashed: Vec::new(),
launched: 0,
damage: true,
seed: seed.wrapping_mul(0x2545_F491_4F6C_DD1D) | 1,
@@ -1580,7 +1616,9 @@ impl Sim {
later: Vec::new(),
queue: Vec::new(),
sites: sites::Sites::default(),
strikes: strike::Strikes::new(),
plan: None,
routes: Default::default(),
target_health: Vec::new(),
wave: None,
rec: None,
@@ -1695,12 +1733,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);
@@ -1710,6 +1779,7 @@ impl Sim {
self.step_lights(ground, out);
self.step_sams(ground, out);
self.step_missiles(ground, out);
self.step_strikes(ground, player, out);
self.step_fx(ground, out);
self.draw_targets(out);
self.draw_sites(out);
@@ -1741,11 +1811,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;
}
@@ -1807,6 +1878,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;
}
@@ -1908,7 +1983,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 {
@@ -1941,13 +2018,14 @@ 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 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 {
@@ -1984,6 +2062,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;
@@ -2359,7 +2441,8 @@ impl Sim {
// the forecast of the last measurement, never the true place.
let drone = |tr: &Track| drone_at.get(&tr.id).map(|&k| &self.drones[k]);
let claimed = |d: &Drone| why::claimed(d, &missile_on, had_target);
let needed = why::needed;
let doc = &me.doctrine;
let needed = |d: &Drone| doc.needed(d);
let opts = why::Opts { last_chance: self.last_chance, far_shots: self.far_shots };
let target = spec.missile.as_ref().map(|load| {
// A launcher with nothing to fire (reloading, empty, failed) claims no target: others take it.
@@ -2396,7 +2479,7 @@ impl Sim {
tracks
.iter()
.filter_map(|tr| drone(tr).map(|d| (tr, d)))
.filter(|(tr, d)| why::gun_candidate(roles, tr, now, gun.track, pos, d))
.filter(|(tr, d)| why::gun_candidate(roles, doc, tr, now, gun.track, pos, d))
.map(|(tr, d)| {
let busy = 1.0 + 2.0 * on_it(d) + if d.engaged > 0 { 1.5 } else { 0.0 };
(tr.est(now).distance(pos) * busy, d.id)
+70 -8
View File
@@ -38,11 +38,18 @@ impl Sam {
impl Sim {
/// Orders unit `i` to drive to `to`. False for one that can't (a depot, a wreck, failed running gear).
pub fn order_move(&mut self, i: usize, to: DVec3) -> bool {
self.order_move_near(i, to, 0.0)
}
/// The same, where arriving within `slack` blocks of `to` will do (a truck stops beside its job).
pub fn order_move_near(&mut self, i: usize, to: DVec3, slack: f64) -> bool {
let Some(sam) = self.sams.get_mut(i) else { return false };
if !sam.alive() || !sam.kind.mobile() || sam.drive_speed() <= 0.0 {
return false;
}
sam.dest = Some(to);
let route = route::Route::new(sam.pos, to, slack, sam.kind.climbs(), self.tick);
self.routes.map.insert(sam.id, route);
sam.target = None;
sam.gun_target = None;
if sam.state != SamState::Moving {
@@ -52,11 +59,17 @@ impl Sim {
true
}
/// Packing, driving and deploying, once a tick.
/// Packing, driving and deploying, once a tick. The way is searched on the ground the game has told of (a
/// few hundred cells a tick for all the vehicles); a vehicle stands while it waits, and stops with a word
/// when there is no way.
pub(super) fn step_moves(&mut self, ground: &mut Ground, out: &mut Out) {
let now = self.tick;
for sam in &mut self.sams {
let planners = self.routes.map.values().filter(|r| r.searching()).count().max(1);
let share = route::WORK_PER_TICK / planners;
let Sim { sams, routes, .. } = self;
for (n, sam) in sams.iter_mut().enumerate() {
if !sam.alive() {
routes.map.remove(&sam.id);
continue;
}
let spec = sam.kind.spec();
@@ -66,12 +79,16 @@ impl Sim {
}
match sam.state {
SamState::Packing => {
// The launcher comes down for the march.
// The launcher comes down for the march; the way is searched meanwhile.
sam.pitch += (0.0 - sam.pitch) * 0.06;
sam.yaw = slew(sam.yaw, sam.heading, 0.04);
sam.march = sam.march.saturating_sub(1);
if sam.march == 0 {
sam.state = SamState::Moving;
} else if let Some(r) = routes.map.get_mut(&sam.id)
&& r.searching()
{
let _ = r.drive(ground, now, sam.pos, share);
}
}
SamState::Moving => {
@@ -80,14 +97,43 @@ impl Sim {
sam.dest = None;
}
let Some(to) = sam.dest else {
routes.map.remove(&sam.id);
sam.state = SamState::Deploying;
sam.march = sam.kind.march_ticks();
continue;
};
let flat = horizontal(to - sam.pos);
if flat.length() < SPEED * 2.0 {
sam.pos = DVec3::new(to.x, sam.pos.y, to.z);
let route = routes.map.entry(sam.id).or_insert_with(|| route::Route::new(sam.pos, to, 0.0, sam.kind.climbs(), now));
let (go, last) = match route.drive(ground, now, sam.pos, share) {
route::Drive::Wait(why) => {
if route.waited(now) > 40 && !route.told {
route.told = true;
out.messages.push(format!("{} №{}: {}", sam.kind.name(), n + 1, why.text()));
}
continue;
}
route::Drive::Fail(why) => {
out.messages.push(format!("{} №{}: {}", sam.kind.name(), n + 1, why.text()));
// A truck leaves that job alone for a while, and takes another.
if let Some(job) = sam.job.take() {
routes.unreachable.insert((sam.id, job), now + route::UNREACHABLE_FOR);
}
routes.map.remove(&sam.id);
sam.dest = None;
sam.state = SamState::Deploying;
sam.march = sam.kind.march_ticks();
continue;
}
route::Drive::Go(p, last) => (p, last),
};
let flat = horizontal(go - sam.pos);
if last && flat.length() < SPEED * 2.0 {
let partial = route.partial;
sam.pos = DVec3::new(go.x, sam.pos.y, go.z);
sam.dest = None;
routes.map.remove(&sam.id);
if partial {
out.messages.push(format!("{} №{}: {}", sam.kind.name(), n + 1, route::Why::Partial.text()));
}
sam.state = SamState::Deploying;
sam.march = sam.kind.march_ticks();
out.sound("minecraft:block.piston.contract", sam.pos, 2.0, 0.6);
@@ -99,8 +145,24 @@ impl Sim {
let turning = angle_diff(sam.heading, heading_of(flat)).abs();
let step = SPEED * sam.drive_speed() * (1.0 - turning / std::f64::consts::PI).max(0.2);
let next = sam.pos + ahead * step;
let y = ground.at(next, now).unwrap_or(sam.pos.y);
sam.pos = DVec3::new(next.x, sam.pos.y + (y - sam.pos.y).clamp(-0.5, 0.5), next.z);
// The column ahead: a wall or a drop the grid missed turns the vehicle back to the planner.
let Some(y) = ground.at(next, now) else {
if route.wait_ahead() {
out.messages.push(format!("{} №{}: {}", sam.kind.name(), n + 1, route::Why::NoMap.text()));
routes.map.remove(&sam.id);
sam.dest = None;
sam.state = SamState::Deploying;
sam.march = sam.kind.march_ticks();
}
continue;
};
route.ahead_ok();
let rise = y - sam.pos.y;
if rise > route::STEP_UP || rise < -route::STEP_DOWN {
route.blocked_ahead(next, sam.pos);
continue;
}
sam.pos = DVec3::new(next.x, sam.pos.y + rise.clamp(-0.5, 0.5), next.z);
sam.yaw = sam.heading;
if now % 4 == 0 {
out.particles("minecraft:campfire_cosy_smoke", sam.pos - ahead * 2.0 * spec.scale, 0.3, DVec3::new(0.0, 0.03, 0.0), 0.01, 1);
+56 -5
View File
@@ -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.
@@ -649,8 +652,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,11 +664,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.
match self.site_slot(d.id) {
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;
@@ -672,6 +708,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);
@@ -820,6 +864,13 @@ impl Sim {
if !why.is_empty() {
out.messages.push(format!("Неудачи ПВО: {why}"));
}
// What the counter-strikes did to the raid (#101): the launches that never happened.
let cut: u32 = self.strikes.cancelled.iter().map(|c| c.1).sum();
if cut > 0 {
let sites: Vec<String> = self.strikes.cancelled.iter().filter(|c| c.1 > 0).map(|c| format!("№{}", c.0)).collect();
out.messages.push(format!("Налёт сорван ударами по ПУ ({}): не взлетело {cut} из заявленных {}", sites.join(", "), w.size));
}
self.strikes.cancelled.clear();
self.wave = None;
}
}
+34
View File
@@ -179,6 +179,22 @@ pub enum EventKind {
Split { drone: u32 },
/// A drone's warhead went off against the land or a building short of its aim.
Crash { drone: u32, kind: DroneKind, power: u32 },
/// A counter-strike on a launch site landed (#101): where it landed and how it ended. Launch sites themselves are
/// not drawn in a replay.
Strike { site: u32, result: StrikeResult },
}
/// How a strike on a launch site ended.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum StrikeResult {
Destroyed,
/// The site was stunned for a while.
Stunned,
Missed,
/// Shot down by the enemy's cover.
Intercepted,
/// Off the map: its launches ended, nothing to see.
Halted,
}
#[derive(Clone, Copy, Debug)]
@@ -336,6 +352,16 @@ pub fn kill_look(kind: DroneKind) -> (f32, u32, f32) {
(size, life, gain)
}
/// The fire a strike's end leaves (0: none): the wreck of a site burns long, a crater a while.
pub fn strike_power(result: StrikeResult) -> u32 {
match result {
StrikeResult::Destroyed => 3,
StrikeResult::Stunned => 2,
StrikeResult::Missed => 1,
StrikeResult::Intercepted | StrikeResult::Halted => 0,
}
}
/// A blast's look for an event: (fireball radius, ticks it burns). The live blasts use the same.
pub fn blast_spec(kind: EventKind) -> (f32, u32) {
const SIZE: [f32; 5] = [2.5, 4.5, 7.0, 10.0, 13.0];
@@ -351,6 +377,12 @@ pub fn blast_spec(kind: EventKind) -> (f32, u32) {
}
EventKind::SamLost { .. } => (8.0, 18 * 9),
EventKind::Split { .. } => (2.0, 7 * 9),
EventKind::Strike { result, .. } => match result {
StrikeResult::Destroyed => (SIZE[4], LIFE[4] * 9),
StrikeResult::Stunned | StrikeResult::Missed => (SIZE[3], LIFE[3] * 9),
StrikeResult::Intercepted => (SIZE[0], LIFE[0] * 9),
StrikeResult::Halted => (0.0, 0),
},
}
}
@@ -382,6 +414,7 @@ pub fn replay_fires(rec: &Recording, t: f64) -> Vec<ReplayFire> {
EventKind::Crash { power, .. } => power,
EventKind::Hit { power, .. } => power.max(1),
EventKind::SamLost { .. } => 2,
EventKind::Strike { result, .. } => strike_power(result),
_ => continue,
};
let (size, life) = (fires::size_for(power), fires::life_for(power));
@@ -518,6 +551,7 @@ impl Sim {
self.ignite(pos, power.max(1), host)
}
EventKind::SamLost { sam, .. } => self.ignite(pos, 2, Some(sam)),
EventKind::Strike { result, .. } if strike_power(result) > 0 => self.ignite(pos, strike_power(result), None),
_ => {}
}
if let Some(r) = &mut self.rec {
+75
View File
@@ -1594,3 +1594,78 @@ 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}");
}
+579
View File
@@ -0,0 +1,579 @@
//! Ground routes for the vehicles (#94): a march is no longer a straight line through the relief. A bounded
//! A* over a coarse grid of the land the game has told of finds a way that avoids cliffs, water and walls; it
//! is worked on a few hundred cells a tick, shared by all the vehicles, and it keeps its state between ticks.
//! Land the game has not answered yet is waited for (the search parks those edges and goes on elsewhere), and
//! after a while the vehicle goes as far as the known land; a place nobody can reach is refused with a reason.
//! While driving, the column ahead is checked once more (a wall the grid missed, a drop): the cell is marked bad
//! and the way is searched again.
use super::*;
use std::collections::{BinaryHeap, HashSet};
/// Cells opened a tick, all vehicles together.
pub(super) const WORK_PER_TICK: usize = 600;
/// Cells opened for one route before it settles for the nearest it got.
const MAX_NODES: usize = 24_000;
/// Ticks the search waits for land the game has not told of, before the vehicle goes as far as is known.
const WAIT_MAP: u64 = 300;
/// Ticks a driver waits for the column ahead.
const WAIT_AHEAD: u32 = 200;
/// Searches again, after a way was found blocked on the march, before giving up.
const REPLANS: u32 = 16;
/// The tallest step a vehicle climbs between two columns, and the deepest drop it takes.
pub(super) const STEP_UP: f64 = 1.5;
pub(super) const STEP_DOWN: f64 = 3.5;
/// Ticks a truck keeps away from a job it found unreachable.
pub(super) const UNREACHABLE_FOR: u64 = 3600;
/// Why a vehicle waits or stops.
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Why {
/// Waiting for the game's answers about the land.
Map,
/// The place itself can't be driven to (cliff, water, wall).
Goal,
/// No way there over the known land.
NoWay,
/// The way was found blocked on the march, again and again.
Stuck,
/// The map did not come.
NoMap,
/// It drove as far as the land is known.
Partial,
}
impl Why {
pub fn text(self) -> &'static str {
match self {
Why::Map => "ждёт карту местности",
Why::Goal => "туда не проехать: обрыв, вода или стена в точке назначения",
Why::NoWay => "прохода нет: путь перекрыт обрывами, водой или стенами",
Why::Stuck => "путь перекрыт на марше, объезда нет",
Why::NoMap => "карта местности не пришла, остался на месте",
Why::Partial => "дальше карта неизвестна: остановился на краю известного",
}
}
}
/// What the route says a vehicle does this tick.
pub(super) enum Drive {
/// Stand: for the land, or the planner.
Wait(Why),
/// Drive at this point; whether it is the last, and the route ends short of the order.
Go(DVec3, bool),
/// Stop here, and say why.
Fail(Why),
}
#[derive(Clone, Copy)]
struct Info {
h: f32,
ok: bool,
}
enum Eval {
Ok(f32),
Bad,
Pending,
}
#[derive(PartialEq)]
struct Open(f32, (i32, i32));
impl Eq for Open {}
impl Ord for Open {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
other.0.total_cmp(&self.0)
}
}
impl PartialOrd for Open {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
#[derive(PartialEq)]
enum Phase {
Search,
Follow,
}
/// One vehicle's way to its destination.
pub(super) struct Route {
dest: DVec3,
/// How near the destination counts (a truck stops beside its job); 0: the place itself.
slack: f64,
cell: i32,
slope: f64,
start: (i32, i32),
lo: (i32, i32),
hi: (i32, i32),
cells: HashMap<(i32, i32), Info>,
bad: HashSet<(i32, i32)>,
g: HashMap<(i32, i32), f32>,
came: HashMap<(i32, i32), (i32, i32)>,
open: BinaryHeap<Open>,
/// Edges waiting for a cell the game has not answered.
parked: Vec<((i32, i32), (i32, i32))>,
retry_at: u64,
stuck_since: Option<u64>,
/// The cell opened that is nearest the destination.
near: ((i32, i32), f64),
expanded: usize,
goal_checked: bool,
phase: Phase,
pts: Vec<DVec3>,
next: usize,
pub(super) partial: bool,
replans: u32,
ahead_wait: u32,
began: u64,
pub(super) told: bool,
failed: Option<Why>,
}
impl Route {
pub(super) fn new(pos: DVec3, dest: DVec3, slack: f64, slope: f64, now: u64) -> Self {
let dist = horizontal(dest - pos).length();
let cell = if dist <= 400.0 { 4 } else if dist <= 900.0 { 6 } else { 8 };
let mut r = Self {
dest,
slack,
cell,
slope,
start: (0, 0),
lo: (0, 0),
hi: (0, 0),
cells: HashMap::new(),
bad: HashSet::new(),
g: HashMap::new(),
came: HashMap::new(),
open: BinaryHeap::new(),
parked: Vec::new(),
retry_at: 0,
stuck_since: None,
near: ((0, 0), f64::MAX),
expanded: 0,
goal_checked: slack > 0.0,
phase: Phase::Search,
pts: Vec::new(),
next: 0,
partial: false,
replans: 0,
ahead_wait: 0,
began: now,
told: false,
failed: None,
};
r.restart(pos);
r
}
fn cell_of(&self, p: DVec3) -> (i32, i32) {
((p.x.floor() as i32).div_euclid(self.cell), (p.z.floor() as i32).div_euclid(self.cell))
}
fn centre(&self, c: (i32, i32)) -> DVec3 {
let k = f64::from(self.cell);
DVec3::new((f64::from(c.0) + 0.5) * k, 0.0, (f64::from(c.1) + 0.5) * k)
}
fn to_goal(&self, c: (i32, i32)) -> f64 {
horizontal(self.centre(c) - self.dest).length()
}
fn is_goal(&self, c: (i32, i32)) -> bool {
if self.slack > 0.0 { self.to_goal(c) <= self.slack } else { c == self.cell_of(self.dest) }
}
/// Searches afresh from `pos`, keeping the cells found bad.
fn restart(&mut self, pos: DVec3) {
self.start = self.cell_of(pos);
let goal = self.cell_of(self.dest);
let margin = (((self.start.0 - goal.0).abs().max((self.start.1 - goal.1).abs()) / 2).max(16)).min(64);
self.lo = (self.start.0.min(goal.0) - margin, self.start.1.min(goal.1) - margin);
self.hi = (self.start.0.max(goal.0) + margin, self.start.1.max(goal.1) + margin);
self.g.clear();
self.came.clear();
self.open.clear();
self.parked.clear();
self.pts.clear();
self.next = 0;
self.phase = Phase::Search;
self.stuck_since = None;
self.expanded = 0;
self.near = (self.start, f64::MAX);
self.partial = false;
self.cells.insert(self.start, Info { h: pos.y as f32, ok: true });
self.g.insert(self.start, 0.0);
let h = self.heur(self.start);
self.open.push(Open(h, self.start));
}
fn heur(&self, c: (i32, i32)) -> f32 {
(self.to_goal(c) * 1.1) as f32
}
fn eval(&mut self, ground: &mut Ground, now: u64, c: (i32, i32)) -> Eval {
if let Some(i) = self.cells.get(&c) {
return if i.ok { Eval::Ok(i.h) } else { Eval::Bad };
}
if self.bad.contains(&c) {
return Eval::Bad;
}
let p = self.centre(c);
let (x, z) = (p.x.floor() as i32, p.z.floor() as i32);
let verdict = match ground.column(x, z, 1024) {
None => return Eval::Pending,
Some(None) => Info { h: 0.0, ok: false },
Some(Some(h)) => {
// The height is the top of what blocks motion or holds a fluid: water has nothing solid under it.
let top = DVec3::new(f64::from(x) + 0.5, h, f64::from(z) + 0.5);
match ground.world.sweep(top + DVec3::Y * 0.5, top - DVec3::Y * 0.5, false, now) {
world::Sweep::Hit { .. } => Info { h: h as f32, ok: true },
world::Sweep::Clear => Info { h: h as f32, ok: false },
world::Sweep::Unknown { pending: true, .. } | world::Sweep::Wait { .. } => return Eval::Pending,
world::Sweep::Unknown { .. } => Info { h: h as f32, ok: false },
}
}
};
self.cells.insert(c, verdict);
if verdict.ok { Eval::Ok(verdict.h) } else { Eval::Bad }
}
/// Relaxes the edge from the opened cell `c` to `nb`; parks it when the land between isn't known yet.
fn relax(&mut self, ground: &mut Ground, now: u64, c: (i32, i32), nb: (i32, i32)) {
if nb.0 < self.lo.0 || nb.1 < self.lo.1 || nb.0 > self.hi.0 || nb.1 > self.hi.1 {
return;
}
let (di, dj) = (nb.0 - c.0, nb.1 - c.1);
let len = if di != 0 && dj != 0 { std::f64::consts::SQRT_2 } else { 1.0 };
let dist = len * f64::from(self.cell);
let Some(h0) = self.cells.get(&c).map(|i| f64::from(i.h)) else { return };
let h1 = match self.eval(ground, now, nb) {
Eval::Ok(h) => f64::from(h),
Eval::Bad => return,
Eval::Pending => return self.park(c, nb),
};
// A diagonal doesn't cut a corner of something in the way.
if di != 0 && dj != 0 {
for side in [(c.0 + di, c.1), (c.0, c.1 + dj)] {
match self.eval(ground, now, side) {
Eval::Ok(h) if (f64::from(h) - h0).abs() <= (self.slope * f64::from(self.cell)).max(STEP_UP) => {}
Eval::Pending => return self.park(c, nb),
_ => return,
}
}
}
// Between the centres: a wall one block thick or a ditch there is what the centres don't show.
let (a, b) = (self.centre(c), self.centre(nb));
let mid = (a + b) * 0.5;
let hm = match ground.column(mid.x.floor() as i32, mid.z.floor() as i32, 1024) {
None => return self.park(c, nb),
Some(None) => return,
Some(Some(h)) => h,
};
let half = dist * 0.5;
let allow = |l: f64| (self.slope * l).max(STEP_UP);
if (hm - h0).abs() > allow(half) || (h1 - hm).abs() > allow(half) || hm - h0 < -STEP_DOWN * (half / 2.0).max(1.0) {
return;
}
let dh = (h1 - h0).abs();
if dh > allow(dist) {
return;
}
let cost = (dist + 2.0 * dh) as f32;
let ng = self.g.get(&c).copied().unwrap_or(0.0) + cost;
if self.g.get(&nb).is_none_or(|&g| ng < g) {
self.g.insert(nb, ng);
self.came.insert(nb, c);
self.open.push(Open(ng + self.heur(nb), nb));
}
}
fn park(&mut self, c: (i32, i32), nb: (i32, i32)) {
if self.parked.len() < 4096 {
self.parked.push((c, nb));
}
}
/// Works on the search: at most `budget` cells.
fn search(&mut self, ground: &mut Ground, now: u64, mut budget: usize) {
if !self.goal_checked {
match self.eval(ground, now, self.cell_of(self.dest)) {
Eval::Bad => {
self.failed = Some(Why::Goal);
return;
}
Eval::Ok(_) => self.goal_checked = true,
Eval::Pending => {}
}
}
if now >= self.retry_at && !self.parked.is_empty() {
self.retry_at = now + 6;
let parked = std::mem::take(&mut self.parked);
for (k, &(c, nb)) in parked.iter().enumerate() {
// The game loads the chunk of the first few, if it loads on request.
if k < 6 {
let p = self.centre(nb);
ground.want_chunk(((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4));
}
self.relax(ground, now, c, nb);
}
}
let before = self.expanded;
while budget > 0 {
let Some(Open(f, c)) = self.open.pop() else { break };
let g = self.g.get(&c).copied().unwrap_or(f32::INFINITY);
if f > g + self.heur(c) + 1e-3 {
continue;
}
budget -= 1;
self.expanded += 1;
if self.is_goal(c) {
self.finish(c, false);
return;
}
let d = self.to_goal(c);
if d < self.near.1 {
self.near = (c, d);
}
for (di, dj) in [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)] {
self.relax(ground, now, c, (c.0 + di, c.1 + dj));
}
if self.expanded >= MAX_NODES {
self.settle(now);
return;
}
}
if self.expanded > before {
self.stuck_since = None;
}
if self.open.is_empty() {
if self.parked.is_empty() {
let goal = self.cell_of(self.dest);
self.failed = Some(if self.slack == 0.0 && !self.cells.get(&goal).is_none_or(|i| i.ok) { Why::Goal } else { Why::NoWay });
} else {
let since = *self.stuck_since.get_or_insert(now);
if now.saturating_sub(since) > WAIT_MAP {
self.settle(now);
}
}
} else {
self.stuck_since = None;
}
}
/// The land stays unknown (or the search is too big): the way to the nearest cell it got to.
fn settle(&mut self, _now: u64) {
let (c, d) = self.near;
let here = self.to_goal(self.start);
if c == self.start || here - d < f64::from(self.cell) * 2.0 {
self.failed = Some(Why::NoMap);
} else {
self.finish(c, true);
}
}
/// The way to cell `c`, as points with the straight stretches dropped.
fn finish(&mut self, c: (i32, i32), partial: bool) {
let mut cells = vec![c];
let mut k = c;
while let Some(&p) = self.came.get(&k) {
cells.push(p);
k = p;
if cells.len() > 100_000 {
break;
}
}
cells.reverse();
let mut pts: Vec<DVec3> = Vec::new();
for w in 1..cells.len() {
let dir = (cells[w].0 - cells[w - 1].0, cells[w].1 - cells[w - 1].1);
let last = w + 1 == cells.len();
let turns = !last && (cells[w + 1].0 - cells[w].0, cells[w + 1].1 - cells[w].1) != dir;
if last || turns {
pts.push(self.centre(cells[w]));
}
}
let end = if partial || self.slack > 0.0 { self.centre(c) } else { self.dest };
match pts.last_mut() {
Some(l) => *l = end,
None => pts.push(end),
}
self.pts = pts;
self.next = 0;
self.partial = partial;
self.phase = Phase::Follow;
}
/// The column ahead stopped the vehicle: mark it, and search again from here.
pub(super) fn blocked_ahead(&mut self, at: DVec3, pos: DVec3) {
let c = self.cell_of(at);
if c != self.cell_of(pos) {
self.bad.insert(c);
self.cells.insert(c, Info { h: 0.0, ok: false });
} else {
// In the cell itself: the stretch ahead, one cell on.
let d = horizontal(at - pos).normalize_or(DVec3::X) * f64::from(self.cell);
let c = self.cell_of(pos + d);
self.bad.insert(c);
self.cells.insert(c, Info { h: 0.0, ok: false });
}
self.replans += 1;
if self.replans > REPLANS {
self.failed = Some(Why::Stuck);
} else {
self.restart(pos);
}
}
/// Ticks the vehicle has waited for the land ahead; true when it has waited too long.
pub(super) fn wait_ahead(&mut self) -> bool {
self.ahead_wait += 1;
self.ahead_wait > WAIT_AHEAD
}
pub(super) fn ahead_ok(&mut self) {
self.ahead_wait = 0;
}
pub(super) fn waited(&self, now: u64) -> u64 {
now.saturating_sub(self.began)
}
/// What the vehicle at `pos` does: works the search on its share of the tick, then follows the way.
pub(super) fn drive(&mut self, ground: &mut Ground, now: u64, pos: DVec3, budget: usize) -> Drive {
if self.phase == Phase::Search && self.failed.is_none() {
self.search(ground, now, budget);
}
if let Some(why) = self.failed {
return Drive::Fail(why);
}
if self.phase == Phase::Search {
return Drive::Wait(Why::Map);
}
let reach = f64::from(self.cell) * 0.7;
while self.next + 1 < self.pts.len() && horizontal(self.pts[self.next] - pos).length() < reach {
self.next += 1;
}
Drive::Go(self.pts[self.next], self.next + 1 >= self.pts.len())
}
pub(super) fn searching(&self) -> bool {
self.phase == Phase::Search && self.failed.is_none()
}
}
/// The routes of the vehicles that drive, by system id, and the jobs a truck found out of reach.
#[derive(Default)]
pub(super) struct Routes {
pub map: HashMap<u32, Route>,
/// (truck id, the job's id) and until when the truck leaves it alone.
pub unreachable: HashMap<(u32, u32), u64>,
}
impl Routes {
pub(super) fn out_of_reach(&self, truck: u32, job: u32, now: u64) -> bool {
self.unreachable.get(&(truck, job)).is_some_and(|&t| t > now)
}
}
impl SamKind {
/// The steepest slope (rise a block) it drives: trucks the least.
pub(super) fn climbs(self) -> f64 {
match self.spec().family {
Family::Truck => 0.5,
_ => 0.6,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The land as the game would tell it: the height map, and under it the blocks that are solid.
struct Src<H: Fn(i32, i32) -> i32, B: Fn(i32, i32) -> i32> {
height: H,
floor: B,
}
impl<H: Fn(i32, i32) -> i32, B: Fn(i32, i32) -> i32> world::WorldSource for Src<H, B> {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
Some(y < (self.floor)(x, z))
}
}
fn answer<H: Fn(i32, i32) -> i32, B: Fn(i32, i32) -> i32>(ground: &mut Ground, src: &Src<H, B>, now: u64) {
let columns = ground.take_wanted().into_iter().map(|(x, z)| (x, z, (src.height)(x, z))).collect();
ground.answer(columns, now);
let sections = ground.world.take_wanted(now);
ground.world.serve(src, sections, now);
}
/// Drives a Patriot from the origin to `to` over land of the given heights (water: a height over a lower floor).
fn march<H: Fn(i32, i32) -> i32, B: Fn(i32, i32) -> i32>(height: H, floor: B, to: DVec3, ticks: u32) -> (Sim, Out, u32) {
let src = Src { height, floor };
let mut sim = Sim::new(3);
sim.damage = false;
let mut ground = Ground::default();
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.5, 64.0, 0.5), 0.0));
let mut out = Out::default();
for _ in 0..3 {
sim.tick += 1;
answer(&mut ground, &src, sim.tick);
}
assert!(sim.order_move(0, to));
let mut used = 0;
let mut worst = 0usize;
for _ in 0..ticks {
sim.tick += 1;
sim.step_moves(&mut ground, &mut out);
answer(&mut ground, &src, sim.tick);
used += 1;
let p = sim.sams[0].pos;
let (x, z) = (p.x.floor() as i32, p.z.floor() as i32);
assert!(((src.height)(x, z) as f64) <= p.y + 2.5, "inside the land at {p:?}");
assert!((src.floor)(x, z) >= (src.height)(x, z), "drove onto water at {p:?}");
worst = worst.max(sim.routes.map.values().filter(|r| r.searching()).count());
if !sim.sams[0].moving() {
break;
}
}
let _ = worst;
(sim, out, used)
}
// A wall across the way, with a gap far to one side: the vehicle goes round and arrives.
#[test]
fn a_wall_with_a_gap_is_driven_round() {
let wall = |x: i32, z: i32| if (40..44).contains(&x) && (-60..=40).contains(&z) { 90 } else { 64 };
let (sim, _, _) = march(wall, wall, DVec3::new(120.0, 64.0, 0.5), 6000);
let s = &sim.sams[0];
assert!(!s.moving() && s.pos.distance(DVec3::new(120.0, 64.0, 0.5)) < 3.0, "ended at {:?} {:?}", s.pos, s.state);
}
// A closed pocket: refused with a reason, no teleport and no drive into the wall.
#[test]
fn an_unreachable_place_is_refused() {
let walls = |x: i32, z: i32| if (60..64).contains(&x) && (-300..300).contains(&z) { 100 } else { 64 };
let (sim, out, _) = march(walls, walls, DVec3::new(120.0, 64.0, 0.5), 6000);
let s = &sim.sams[0];
assert!(!s.moving() && s.pos.x < 62.0, "{:?}", s.pos);
assert!(out.messages.iter().any(|m| m.contains("прохода нет") || m.contains("перекрыт") || m.contains("карта")), "{:?}", out.messages);
}
// A lake (height 64 over a floor of 50) is no road: it is gone round, and a destination in it is refused.
#[test]
fn a_lake_is_not_a_road() {
let floor = |x: i32, z: i32| if (50..90).contains(&x) && z.abs() < 40 { 50 } else { 64 };
let (sim, _, _) = march(|_, _| 64, floor, DVec3::new(130.0, 64.0, 0.5), 6000);
let s = &sim.sams[0];
assert!(!s.moving() && s.pos.distance(DVec3::new(130.0, 64.0, 0.5)) < 3.0, "{:?}", s.pos);
let (sim, out, _) = march(|_, _| 64, floor, DVec3::new(70.0, 64.0, 0.5), 3000);
assert!(!sim.sams[0].moving() && sim.sams[0].pos.x < 10.0, "{:?}", sim.sams[0].pos);
assert!(out.messages.iter().any(|m| m.contains("не проехать")), "{:?}", out.messages);
}
// Open ground: straight on, arrives.
#[test]
fn open_ground_is_driven_straight() {
let (sim, _, _) = march(|_, _| 64, |_, _| 64, DVec3::new(100.0, 64.0, 30.5), 1500);
let s = &sim.sams[0];
assert!(!s.moving() && s.pos.distance(DVec3::new(100.0, 64.0, 30.5)) < 3.0, "{:?}", s.pos);
}
}
+162
View File
@@ -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, _)| (y > -64).then_some(f64::from(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.
@@ -367,3 +458,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:?}");
}
}
+33 -4
View File
@@ -54,9 +54,9 @@ impl SiteClass {
/// Ticks between two launches from one site.
pub fn cadence(self) -> u64 {
match self {
SiteClass::Ramp => 3,
SiteClass::Launcher => 8,
SiteClass::Battery => 20,
SiteClass::Ramp => 15,
SiteClass::Launcher => 25,
SiteClass::Battery => 50,
}
}
fn slots(self) -> u32 {
@@ -474,7 +474,17 @@ impl Sim {
/// many. (The counter-strike, #101, uses this.)
pub fn destroy_site(&mut self, id: u32) -> u32 {
// An off-map site is nowhere to be found: nothing to destroy.
let Some(s) = self.sites.list.iter_mut().find(|s| s.id == id && !s.offmap) else { return 0 };
self.end_site(id, false)
}
/// Ends a site's launches wherever it is, off the map too (a strike that would have hit it): the same as
/// destroying it, without anything to draw.
pub fn halt_site(&mut self, id: u32) -> u32 {
self.end_site(id, true)
}
fn end_site(&mut self, id: u32, offmap: bool) -> u32 {
let Some(s) = self.sites.list.iter_mut().find(|s| s.id == id && (offmap || !s.offmap)) else { return 0 };
s.alive = false;
s.stock = 0;
let ids: Vec<u32> = self.sites.jobs.iter().filter(|j| j.1 == id).map(|j| j.0).collect();
@@ -795,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 {
File diff suppressed because it is too large Load Diff
+18 -3
View File
@@ -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;
@@ -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
+40 -8
View File
@@ -141,6 +141,10 @@ pub(super) fn basis(sam: &Sam, load: &units::MissileLoad, track: &Track, d: &Dro
let est = track.est(now);
let reach = reach_blocks(load.min, load.max, k, sam.pos, est);
if blocks & ROLE == 0 {
// The doctrine (sector, protected objects, reserve) rules over the role's shots and the long ones; the last chance is past it.
if sam.doctrine.area_blocks(sam.pos, track, now) | sam.doctrine.reserve_blocks(sam.missiles, d) != 0 {
return None;
}
if reach == 0 {
return Some(Basis::Role);
}
@@ -158,8 +162,8 @@ pub(super) fn reach_fit(max: f64, dist: f64) -> f64 {
/// A gun's candidates: measured by the system's own radar a moment ago and within the gun's tracking range
/// (by the track's forecast, not the target's true place).
pub(super) fn gun_candidate(roles: u8, track: &Track, now: u64, gun_track: f64, pos: DVec3, d: &Drone) -> bool {
allowed(roles, track, now, d) && track.own_within(now, sight::GUN_PICK_AGE) && track.est(now).distance(pos) < gun_track
pub(super) fn gun_candidate(roles: u8, doc: &doctrine::Doctrine, track: &Track, now: u64, gun_track: f64, pos: DVec3, d: &Drone) -> bool {
allowed(roles, track, now, d) && doc.area_blocks(pos, track, now) == 0 && track.own_within(now, sight::GUN_PICK_AGE) && track.est(now).distance(pos) < gun_track
}
/// How many systems have each drone as their missile target and as their gun target.
@@ -316,7 +320,11 @@ impl Sim {
self.drones
.iter()
.filter(|d| d.alive() && (sam.roles & d.kind.spec().class.bit() != 0 || last_chance(self.last_chance, sam.roles, d, claimed(d, &missile_on, sam.target))))
.max_by(|a, b| a.threat(sam.pos).total_cmp(&b.threat(sam.pos)))
.max_by(|a, b| {
// What the doctrine lets the system shoot at comes first; then the most threatening.
let area = |d: &Drone| sam.track(d.id).is_none_or(|tr| sam.doctrine.area_blocks(sam.pos, tr, self.tick) == 0);
area(a).cmp(&area(b)).then(a.threat(sam.pos).total_cmp(&b.threat(sam.pos)))
})
});
let Some(t) = subject else {
v.push(check(false, format!("нет целей классов [{}]", roles_text(sam.roles))));
@@ -348,6 +356,29 @@ impl Sim {
format!("роль: {} не в ролях [{}]", t.kind.spec().class.name(), roles_text(sam.roles))
};
v.push(check(blocks & ROLE == 0 || chance, role_text));
// The doctrine: which of its settings let the shot through or stopped it. The last chance is past them.
let doc = &sam.doctrine;
let area = track.map_or(0, |tr| doc.area_blocks(sam.pos, tr, now));
let held_back = if spec.missile.is_some() { doc.reserve_blocks(sam.missiles, t) } else { 0 };
if !doc.is_default() {
v.push(check(true, format!("доктрина: {}", doc.text(&self.targets))));
}
if area & doctrine::OUT_SECTOR != 0 {
let bearing = heading_of(est - sam.pos).to_degrees().rem_euclid(360.0);
v.push(check(chance, format!("вне сектора {}: цель на {bearing:.0}°{}", doc.sector_text(), if chance { " — последний шанс сильнее" } else { "" })));
} else if doc.sector.is_some() {
v.push(check(true, format!("в секторе {}", doc.sector_text())));
}
if area & doctrine::NO_THREAT != 0 {
v.push(check(chance, format!("не угрожает охраняемым объектам ({}){}", doc.guards.iter().map(|g| doctrine::object_name(&self.targets, *g)).collect::<Vec<_>>().join(", "), if chance { " — последний шанс сильнее" } else { "" })));
} else if let Some(g) = track.and_then(|tr| doc.threatens(tr, now)) {
v.push(check(true, format!("прикрывает {}: цель идёт на него", doctrine::object_name(&self.targets, doc.guards[g]))));
}
if held_back != 0 {
v.push(check(chance, format!("сохраняет резерв {}: ракет {}, цель не срочная{}", doc.reserve, sam.missiles, if chance { " — последний шанс сильнее" } else { "" })));
} else if doc.reserve > 0 && sam.missiles <= doc.reserve {
v.push(check(true, format!("резерв {} открыт: цель срочная (до удара {:.0} с)", doc.reserve, arrival(t) / 20.0)));
}
let detect = match (track, sam.own.contains(&t.id)) {
(Some(tr), own) => {
let kind = if own { "своей РЛС" } else { "по сети (стволы не стреляют)" };
@@ -398,7 +429,7 @@ impl Sim {
let radars: Vec<&Sam> = self.sams.iter().filter(|s| s.kind == SamKind::Radar).collect();
let cued = sam.kind.far_shooter() && radar_cue(sam, &radars, t.id, now);
let role_in_reach = self.drones.iter().any(|o| {
sam.track(o.id).is_some_and(|tr| allowed(sam.roles, tr, now, o) && in_reach(load.min, load.max, 1.0, sam.pos, tr.est(now)) && claimed(o, &missile_on, sam.target) < needed(o))
sam.track(o.id).is_some_and(|tr| allowed(sam.roles, tr, now, o) && in_reach(load.min, load.max, 1.0, sam.pos, tr.est(now)) && sam.doctrine.area_blocks(sam.pos, tr, now) | sam.doctrine.reserve_blocks(sam.missiles, o) == 0 && claimed(o, &missile_on, sam.target) < sam.doctrine.needed(o))
});
let far = reach == TOO_FAR && reach_blocks(load.min, load.max, FAR_K, sam.pos, est) == 0;
let far_ok = far && track.is_some_and(|tr| basis(sam, load, tr, t, now, 0, 1.0, Opts { last_chance: self.last_chance, far_shots: self.far_shots }, cued, role_in_reach) == Some(Basis::FarShot));
@@ -419,8 +450,8 @@ impl Sim {
v.push(check(far_ok, if far_ok { format!("дальний пуск по РЛС: цель на {} дальше обычных {}", crate::panel::distance(dist), crate::panel::distance(load.max)) } else { format!("дальний пуск по РЛС не разрешён: {}", no.join(", ")) }));
}
let free = first_claim;
let need = needed(t);
v.push(check(free < need, format!("цель занята перехватчиками: {free} из {need}{}", if free < need { "" } else { " — другие уже на ней" })));
let need = sam.doctrine.needed(t);
v.push(check(free < need, format!("цель занята перехватчиками: {free} из {need} (залп {}){}", doc.salvo.name(), if free < need { "" } else { " — другие уже на ней" })));
let mb = missile_blocks(sam);
let mut why = Vec::new();
if mb & COOLDOWN != 0 {
@@ -451,12 +482,13 @@ impl Sim {
},
));
}
let grounds = blocks & !ROLE == 0 && (blocks & ROLE == 0 || chance) && (reach == 0 || far_ok);
let doc_ok = chance || area | held_back == 0;
let grounds = blocks & !ROLE == 0 && (blocks & ROLE == 0 || chance) && doc_ok && (reach == 0 || far_ok);
fires |= grounds && free < need && mb == 0 && aligned && land == 0;
}
if let Some(gun) = &spec.gun {
let on_it = guns_on.get(&t.id).copied().unwrap_or(0) - u32::from(sam.gun_target == Some(t.id));
let cand = track.is_some_and(|tr| gun_candidate(sam.roles, tr, now, gun.track, sam.pos, t));
let cand = track.is_some_and(|tr| gun_candidate(sam.roles, &sam.doctrine, tr, now, gun.track, sam.pos, t));
v.push(check(
cand,
format!(
+61 -5
View File
@@ -13,9 +13,9 @@
## Статус
**Стадия 1 (прямой звук) и стадия 2 (отводы эха) сделаны**, включается `HORIZON_ACOUSTICS=1` (по умолчанию выключено,
звук как был). Стадия 3 (реверб) не начата. Коммит SPR прежний, `453654ec`; атрибуция: `horizon/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md`.
GPL-файлы два: `acoustics/spr_occlusion.rs` и (стадия 2) `acoustics/spr_bounce.rs`; остальное (движок `acoustics.rs`, склейка `game/acoustics.rs`, DSP в `audio.rs`) наше.
**Стадии 1 (прямой звук), 2 (отводы эха) и 3 (реверб) сделаны**, включается кнопкой мода или `HORIZON_ACOUSTICS=1` (по умолчанию выключено,
звук как был). Коммит SPR прежний, `453654ec`; атрибуция: `horizon/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md`.
GPL-файлы три: `acoustics/spr_occlusion.rs`, (стадия 2) `acoustics/spr_bounce.rs` и (стадия 3) `acoustics/spr_reverb.rs`; остальное (движок `acoustics.rs`, `acoustics/room.rs`, склейка `game/acoustics.rs`, DSP в `audio.rs` и `audio/reverb.rs`) наше.
Что вышло и чем отличается от плана выше:
@@ -91,7 +91,61 @@ GPL-файлы два: `acoustics/spr_occlusion.rs` и (стадия 2) `acousti
Не сделано / не проверено: запуск в игре и экспорт видео с настоящей сценой (склейка `game/acoustics.rs` — таблица состояний,
снимок чанков — компилируется, но в игре не прогонялась); влияние на кадр при налёте; жидкости; дверь как проём (считается
ячейкой); потолок памяти снимка. Стадия 3 (шины реверба) не начата.
ячейкой); потолок памяти снимка.
### Стадия 3: реверб (сделано)
Что игрок получает: под крышей и в тоннеле звук звенит иначе, чем в поле; в чистом поле реверба нет. Код: `acoustics/room.rs`
(наш: замер пространства, уровень и спад, посылки), `acoustics/spr_reverb.rs` (GPL: четыре слота SPR, `0.7*0.85`, затухание посылок
с расстоянием), `audio/reverb.rs` (наш: DSP общей шины), посылки голосов и шина в `audio.rs` (`Channel.send`, `Mixer.reverb`),
склейка в `acoustics.rs` (`take_sends`).
- Замер пространства (`room::measure`): 48 лучей по сфере Фибоначчи, берутся те, что идут вбок и вверх (пол комнаты не делает), длина 64 блока
(столько видит и SPR). Вес попадания `1 - (d/64)^2`. Из них: **замкнутость** (сумма весов / число лучей, 0..1), **размер** (средняя
дальность стен) и **отражательность** (средняя по таблице SPR). Слушатель замеряется по своей клетке 1x1x1 (кеш 64, сброс по секциям в
кубе ±65 и по эпохе, на рабочем потоке; пока новый замер в пути, остаётся прежний); у звуков с эхом замеряется ещё и пространство
источника (в той же трассе эха, свой потолок 60 тыс. клеток). Пространство звука = слушатель 60% + источник 40% (без источника: слушатель).
- Уровень и спад (`wet_and_decay`): `wet = smoothstep(0.03, 0.4, замкнутость)`; `спад = замкнутость^2 * (0.4 + 0.1*min(размер,30)) *
(0.6 + 0.8*отражательность)`, в пределах 0.15-4.14 с (слоты SPR). Камень (1.5) звенит дольше шерсти (0.1). Ущелье с открытым верхом
почти не даёт реверба (его ответ - эхо стадии 2); комната и тоннель - да.
- Посылки: спад ложится между двумя соседними слотами SPR (0.15 / 0.55 / 1.68 / 4.14 с, по логарифму) равномерной по мощности
перекрёстной кривой (квадрат положения у длинного, иначе по Шрёдеру читается только он), каждый слот выравнен по своему `gain` из
таблицы SPR: громкость реверба не растёт с его длиной. Посылка голоса умножается на `distance_fade` SPR (`1 - d/(1.5*дальность)`).
- Шина (`audio/reverb.rs`): 4 слота, в каждом пред-задержка (late delay SPR), 2 allpass, 4 гребёнки с lowpass в петле (коэффициент из
`decay_hf_ratio` SPR на 5 кГц). Левое ухо берёт гребёнки 0 и 2, правое 1 и 3 (разные хвосты), моно = среднее всех четырёх. Голос
кладёт в шину сигнал после стены (окклюзии) и до панорамы; посылки голоса плавно (постоянная 0.3 с) идут к цели, так что вход в тоннель
и выход из него без щелчка. Шина считается и без голосов (хвост звучит после конца хлопка), слот без входа и хвоста не считается и
обнуляется (без денормалей), шина сбрасывается, когда затихла.
- Выключение (`set_acoustics(false)`): посылки идут к нулю за ~1-2 с, хвост доигрывает сам; `stop_all` (и `film_begin/film_end`) шину сбрасывают.
Съёмка: замер синхронный (счёт клеток), шина считается без случайности и от часов не зависит - дорожка та же при каждом прогоне
(`audio::tests::reverb_rendering_is_deterministic`).
- Измерено (release, M-серия, буфер 512 кадров = 10.67 мс): шина с четырьмя занятыми слотами 0.017 мс; 100 голосов 0.119 мс без реверба ->
+0.04 мс с реверб-посылками (25 или 100 голосов, около 0.4% буфера); замер пространства: комната 0.0075 мс (392 клетки), тоннель
0.0037, ущелье 0.033 (2208), поле 0.035 мс (2315); всё на рабочем потоке. Счётчики в `status`: `acoustics.reverb` (замеры, попадания
кеша, клетки, посылки, замкнутость и спад сейчас); в игре не снимались.
- Проверка без слуха: `audio::tests::export_reverb_tracks` (настоящие `Acoustics`, посылки, `mix`, щелчок в комнате 24x10x24, тоннеле 5x4,
ущелье, поле) + `python3 tools/acoustics_check.py --reverb-dir папка`. Порядок **поле < ущелье < тоннель < комната** (в плане было
«ущелье длиннее всех»; открытый верх ущелья этого не даёт):
| сцена | спад пространства, с | RT60 по посылкам, с | RT60 измерено, с | хвост/щелчок, дБ |
|---|---|---|---|---|
| поле | 0.15 | - | хвоста нет | нет |
| ущелье | 0.15 | 0.15 | 0.10 | -47.7 |
| тоннель | 0.79 | 0.97 | 0.87 | -11.7 |
| комната | 1.37 | 1.67 | 1.39 | -7.7 |
Колонка «по посылкам» - сумма экспонент слотов по Шрёдеру (`room::effective_decay`), измеренное лежит на 10-20% ниже (lowpass в петле
укорачивает). Каждый слот отдельно (импульс в одну шину, `reverb::tests::each_slot_decays_at_its_time`) читается в 0.5-1.15 от
номинала. Тесты: `--lib acoustics` (замеры комната/тоннель/ущелье/поле, порядок спадов, гладкость посылок, кеш, сброс, рабочий поток)
и `--lib audio` (хвост после конца звука и сброс шины, нулевая посылка - ровно прежний вывод, без щелчка при смене и выключении,
детерминизм, выключение и `film_begin`).
Не слушали: громкость реверба (`WET = 1.6` и уровни слотов не подстраивались на слух; в комнате хвост -8 дБ от щелчка по энергии), формулы
замкнутости/спада (пороги 0.03-0.4, веса, перекрёстная кривая - выбраны по замерам сцен, не по слуху), звучание гребёнок (металличность на
коротких слотах, пред-задержки), окраска левого и правого, 60/40 слушатель/источник, скорость скольжения посылок 0.3 с. Замкнутость
по 24 лучам вверх-вбок грубая: узкая щель в крыше может не найтись, деревья и листва (окклюзия 0) пространства не делают. Реверб не
получают относительные звуки (интерфейс, музыка) и звуки дальше 512 блоков; эхо-отводы в шину не идут. `gain_hf` слотов, `density`,
отражения слота SPR не перенесены.
## 1. Как SPR идёт от источника к результату
@@ -395,7 +449,7 @@ GPL-файлы два: `acoustics/spr_occlusion.rs` и (стадия 2) `acousti
классов не даёт скачка; сброс при `film_begin/film_end`; лимитер не срабатывает на нормальном налёте.
- Проверка без слуха: импульсный отклик шины из `film_mix` → RT60 по спаду (`tools/acoustics_check.py`) для четырёх
классов сцены (поле, ущелье, комната, тоннель) — таблица в отчёте; спад должен упорядочиться
поле < комната < тоннель < ущелье; налёт 100 источников: пиковый уровень, число срабатываний лимитера, CPU.
поле < ущелье < тоннель < комната (у ущелья открытый верх); налёт 100 источников: пиковый уровень, число срабатываний лимитера, CPU.
- Критерий: хвост после конца исходного хлопка длится ожидаемое RT60 и не продолжается после смены мира.
### Стадия 4 (по #96): движущиеся источники и интеграция с #95/cinema
@@ -414,3 +468,5 @@ GPL-файлы два: `acoustics/spr_occlusion.rs` и (стадия 2) `acousti
- Коммит из #96 (`76f680eb…`) не найден в недавней истории `master`; разбор сделан по `453654ec`.
- Мод-API `query-rays` с политикой `sound` для этого недостаточно (нет материала и полупрозрачных долей), поэтому
акустика в клиенте; мод только сообщает события (#95).
Включается кнопкой в меню мода (ещё → «Акустика»), сохраняется с миром. `HORIZON_ACOUSTICS=1` задаёт только начальное значение.
+1
View File
@@ -88,6 +88,7 @@ python3 tools/airdef.py play # или: python3 tools/airdef.py play --world "
пересоберутся сами, если патчи изменились.
- Windows: нужна видеокарта с Vulkan. Экспорт фильмов (`/airdef replay export`) требует `ffmpeg` в PATH или `HORIZON_FFMPEG`.
- Шейдеры уровня шейдерпаков: `/airdef shader hd`. Для слабой видеокарты перед запуском: `$env:HORIZON_HD_SCALE="0.67"` (PowerShell).
- Преграды и эхо для звука: меню мода → «Акустика».
- Создай мир с читами (или включи их через «Открыть для сети»): мод выдаёт пульты и взрывы командами.
- `/airdef give` — пульты, **N** — командный пункт (роли ПВО), **K** — монитор комплекса,
`/airdef wave mix 20` — налёт. Подробнее — README.
+42
View File
@@ -0,0 +1,42 @@
# Карта репозитория
Что здесь что: исходники, сгенерированное, архив. Порядок сборки и установки — в `README.md` и `docs/INSTALL.md`.
## Исходники (правим руками)
| Путь | Что это |
|---|---|
| `airdef-mod/src/`, `airdef-mod/shaders/` | Мод на Rust (WASM) для форка Horizon |
| `airdef-mod/Cargo.toml`, `horizon-mod.toml` | Сборка мода |
| `airdef-mod/tests/`, `airdef-mod/examples/` | Тесты и примеры |
| `airdef-mod/NOTES-terrain-guidance.md` | Рабочие заметки по рельефу |
| `horizon/`, `HORIZON_COMMIT`, `horizon-patches/` | Форк Horizon и патчи к нему |
| `tools/airdef.py` | Сборка мода и установка в папку игры |
| `tools/gen_models.py`, `tools/gen_tex.py`, `tools/gen_sounds.py` | Генераторы контента (см. ниже) |
| `tools/acoustics_check.py`, `tools/hd_bench.py` | Проверочные скрипты |
| `docs/` | Документация (этот файл, `INSTALL.md`, `ACOUSTICS.md`, `HD-RENDERER.md`) |
| `install.sh`, `play.sh`, `dist/` | Установка и запуск |
| `run-home/`, `run-home-bench/`, `run-home-smoke/` | Тестовые игровые папки, не часть мода |
| `screenshots/` | Скриншоты |
## Сгенерировано (не правим руками)
`tools/gen_models.py` создаёт:
- `airdef-mod/assets/airdef/{blockstates,models,items,lang,textures}/`
- `airdef-mod/data/airdef/horizon/`
Список этих файлов с sha256 лежит в `airdef-mod/generated-manifest.txt`. Генератор пишет во временный каталог, проверяет набор (ссылки моделей, blockstate и текстур; JSON; PNG; координаты элементов; парность lang) и только потом переносит файлы. При ошибке проверки ничего не записывается. Файл, который правили вручную или который занимает путь генерируемого, останавливает запуск (`КОНФЛИКТ`).
Команды:
- `python3 tools/gen_models.py` — перегенерировать.
- `python3 tools/gen_models.py --check` — только сравнить, выход 1 при различиях.
`tools/gen_sounds.py` создаёт `airdef-mod/assets/airdef/sounds/*.ogg` и `sounds.json`. Этот генератор не использует manifest и не входит в проверку `gen_models.py`.
## Архив: `legacy/`
Первая версия мода: датапак на командах (`legacy/airdef-datapack/`) и контент-мод (`legacy/airdef-content/`) с генератором `legacy/gen_content.py`. Ничего из текущей сборки на него не ссылается: ни скрипты в `tools/`, ни `install.sh`, ни `Cargo.toml`, ни исходники мода. Не собирается и не устанавливается. Оставлен как история. Не редактировать и не удалять без отдельного решения.
`legacy/airdef-content/data/` сейчас не отслеживается git и не входит в эту карту.
@@ -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/40] Mod API (AirDef fork): world models, client particles,
Subject: [PATCH 01/41] 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/40] cargo-horizon-mod: report particles and sounds
Subject: [PATCH 02/41] 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/40] Mod API (AirDef fork): full-screen post pass for mods
Subject: [PATCH 03/41] 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/40] Mod models: draw far models as scaled impostors past
Subject: [PATCH 04/41] 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/40] Mod cameras: render.set-views draws the world from
Subject: [PATCH 05/41] 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/40] Mods: more fuel per tick and per event (AirDef plans
Subject: [PATCH 06/41] 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/40] Mod cameras up to 1920x1080 (full-screen replays)
Subject: [PATCH 07/41] 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/40] Mod cameras: distant terrain tiles, fog measured from
Subject: [PATCH 08/41] 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/40] Mods: game.world-id, so mods keep state per world (API
Subject: [PATCH 09/41] 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/40] cargo-horizon-mod: trial runs behind feature full, so a
Subject: [PATCH 10/41] 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/40] HD lighting: G-buffer pass, per-pixel mod point lights
Subject: [PATCH 11/41] 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/40] HD lighting stage 2: sun and moon shadow cascades, the
Subject: [PATCH 12/41] 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/40] HD: bind groups cached until a resize or a new source,
Subject: [PATCH 13/41] 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/40] HD: lights culled per 16x16 tile (surface and halo
Subject: [PATCH 14/41] 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/40] HD: GPU time per pass in the F3 timing list,
Subject: [PATCH 15/41] 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/40] HD: shadows draw only sun-facing quads and skip buried
Subject: [PATCH 16/41] 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/40] Mods: model handles (render.model-id, model-generation,
Subject: [PATCH 17/41] 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/40] HD: temporal upscaling (HORIZON_HD_SCALE: world,
Subject: [PATCH 18/41] 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/40] HD water: its own pass (colour, normal, depth), the
Subject: [PATCH 19/41] 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/40] HD water: no vanilla texture on the surface (the biome
Subject: [PATCH 20/41] 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/40] HD sky and light: a physical atmosphere (Rayleigh, Mie,
Subject: [PATCH 21/41] 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/40] HD water: a darker blue, a little clearer, ripples a
Subject: [PATCH 22/41] 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/40] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
Subject: [PATCH 23/41] 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/40] Mods: world queries for simulations (render.query-rays,
Subject: [PATCH 24/41] 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/40] HD fixes: rain level from the world not from rain
Subject: [PATCH 25/41] 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/40] HD split into modules (gfx/hd/: one Rust file and one
Subject: [PATCH 26/41] 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/40] HD clouds: noise scales that divide the camera's
Subject: [PATCH 27/41] 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/40] HD: volumetric smoke and missile trails with clear
Subject: [PATCH 28/41] 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/40] HD clouds composited by depth so terrain and vehicles
Subject: [PATCH 29/41] 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/40] HD smoke: young trails drawn at least as wide as a grid
Subject: [PATCH 30/41] 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/40] Mods: render.set-main-view (API level 15) renders the
Subject: [PATCH 31/41] 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/40] Spot lights for mods (render.set-spot-lights, API level
Subject: [PATCH 32/41] 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/40] HD clouds and fog polish: cloud panorama, cirrus,
Subject: [PATCH 33/41] 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/40] HD: coloured sun through glass and leaves, rain ripples
Subject: [PATCH 34/41] 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/40] Film export: every frame in flight reaches the writer,
Subject: [PATCH 35/41] 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/40] Mod command trees: command.set-tree and
Subject: [PATCH 36/41] 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/40] Persistent mod sound sources: start, update, stop with
Subject: [PATCH 37/41] 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/40] HD clouds: rays from a far point, so the view bob's eye
Subject: [PATCH 38/41] 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/40] Acoustics stage 1: direct-path occlusion ported from
Subject: [PATCH 39/41] 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/40] Mod chunk tickets through the server thread (API 22),
Subject: [PATCH 40/41] 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>
File diff suppressed because it is too large Load Diff
+64
View File
@@ -17,6 +17,15 @@
python3 tools/acoustics_check.py --echo-dir папка # gorge/field/room и expected.txt из export_echo_tracks
Дорожки пишет `audio::tests::export_echo_tracks` (HORIZON_ACOUSTICS_OUT=папка). Коды выхода те же.
Режим `--reverb-dir` (стадия 3): папка из `audio::tests::export_reverb_tracks` (щелчок в комнате, тоннеле, ущелье
и поле; `expected_reverb.txt`: предсказанный спад и уровень из замера пространства). По хвосту после прямого звука
считаются RT60 (обратное интегрирование Шрёдера, спад с -5 до -35 дБ, удвоенный) и энергия хвоста к энергии щелчка:
python3 tools/acoustics_check.py --reverb-dir папка [--rt-tol 0.35]
Проверяется: в поле хвоста нет (энергия хвоста меньше -90 дБ от щелчка), спад упорядочен поле < ущелье < тоннель <
комната, у комнаты и тоннеля измеренный RT60 в пределах `--rt-tol` от того, что дают их посылки в слоты (колонка
`RT60 expected`: сумма экспонент слотов, спад -5...-35 дБ; слой из двух слотов читается длиннее короткого).
Только стандартная библиотека.
"""
import argparse
@@ -98,6 +107,57 @@ def check_echo(path, expect, tol, rate, channels):
return ok
def reverb_track(path, rate, channels, dry_ms=60.0):
"""(tail energy relative to the click in dB or None, RT60 in s or None) of a click's track."""
raw = array.array("h")
with open(path, "rb") as f:
data = f.read()
raw.frombytes(data[: len(data) // 2 * 2])
left = [s / 32768.0 for s in raw[0::channels]]
cut = int(dry_ms * rate / 1000)
dry = sum(v * v for v in left[:cut])
tail = left[cut:]
energy = sum(v * v for v in tail)
if dry <= 0 or energy <= 0 or 10 * math.log10(energy / dry) < -90:
return None, None
edc, acc = [0.0] * len(tail), 0.0
for i in range(len(tail) - 1, -1, -1):
acc += tail[i] * tail[i]
edc[i] = acc
level = lambda i: 10 * math.log10(max(edc[i], 1e-30) / edc[0])
a = next((i for i in range(len(tail)) if level(i) <= -5), None)
b = next((i for i in range(len(tail)) if level(i) <= -35), None)
rt = 2 * (b - a) / rate if a is not None and b is not None else None
return 10 * math.log10(energy / dry), rt
def check_reverb(folder, rate, channels, tol):
import os
want = {}
with open(os.path.join(folder, "expected_reverb.txt")) as f:
for line in f:
name, decay, wet, effective = line.split()
want[name] = (float(decay), float(wet), float(effective))
got = {}
print(f"{'scene':>8} {'decay of space':>16} {'RT60 expected':>14} {'measured RT60 s':>16} {'tail/click dB':>14}")
for name in ("field", "gorge", "tunnel", "room"):
level, rt = reverb_track(os.path.join(folder, name + ".raw"), rate, channels)
got[name] = (level, rt)
print(f"{name:>8} {want[name][0]:16.2f} {want[name][2]:14.2f} {('none' if rt is None else f'{rt:.2f}'):>16} {('none' if level is None else f'{level:.1f}'):>14}")
ok = got["field"][1] is None
print(f" field has no tail: {'ok' if ok else 'FAIL'}")
order = [got[n][1] for n in ("gorge", "tunnel", "room")]
ordered = all(v is not None for v in order) and order[0] < order[1] < order[2]
print(f" gorge < tunnel < room: {'ok' if ordered else 'FAIL'}")
close = True
for n in ("tunnel", "room"):
rt = got[n][1]
good = rt is not None and abs(rt - want[n][2]) <= tol * want[n][2]
close &= good
print(f" {n} within {tol:.0%} of the RT60 its sends make: {'ok' if good else 'FAIL'}")
return ok and ordered and close
def db(a, b):
return 10 * math.log10(max(a, 1e-20) / max(b, 1e-20))
@@ -107,6 +167,8 @@ def main():
p.add_argument("open", nargs="?")
p.add_argument("wall", nargs="?")
p.add_argument("--echo", help="track with one click: check the delays of its echoes")
p.add_argument("--reverb-dir", help="folder written by export_reverb_tracks: check the decay of room, tunnel, gorge, field")
p.add_argument("--rt-tol", type=float, default=0.35, help="how far a measured RT60 may be from the predicted, as a fraction")
p.add_argument("--echo-dir", help="folder written by export_echo_tracks: check gorge, field and room")
p.add_argument("--expect", default="none", help="expected delays in ms, comma separated, or none")
p.add_argument("--tol", type=float, default=1.0, help="ms a peak may be off")
@@ -115,6 +177,8 @@ def main():
p.add_argument("--min-high-cut", type=float, default=15.0, help="dB the band above 5 kHz must drop")
p.add_argument("--max-low-loss", type=float, default=8.0, help="dB the band below 300 Hz may drop")
a = p.parse_args()
if a.reverb_dir:
return 0 if check_reverb(a.reverb_dir, a.rate, a.channels, a.rt_tol) else 1
if a.echo_dir:
import os
ok = True
+107 -1
View File
@@ -7,7 +7,8 @@
Запуск: python3 tools/gen_models.py [--check]
Скрипт пишет во временный каталог и только потом переносит результат в airdef-mod/. Список созданных им файлов
Скрипт пишет во временный каталог, проверяет набор (validate: ссылки моделей и текстур, JSON, PNG) и только потом
переносит результат в airdef-mod/; при ошибке проверки ничего не записывает. Список созданных им файлов
с sha256 лежит в airdef-mod/generated-manifest.txt (формат sha256sum). Удаляются и перезаписываются только файлы
из этого списка и только если их не правили вручную; чужой файл на пути генерируемого останавливает запуск.
--check ничего не пишет и выходит с кодом 1, если airdef-mod/ отличается от того, что дал бы запуск.
@@ -1745,6 +1746,105 @@ def generate(out):
return len(MODELS), len(ITEMS), len(TEXTURES)
# ---------------------------------------------------------------- проверка набора до переноса
def check_png(path, rel):
try:
with Image.open(path) as im:
w, h = im.size
im.load()
except Exception as e: # битый файл или не PNG
return [f"{rel}: PNG не открывается: {e}"]
errs = []
if w != h:
errs.append(f"{rel}: текстура не квадратная, {w}x{h}")
if w < 8 or w > 256 or w & (w - 1):
errs.append(f"{rel}: размер {w}x{h}, ожидается степень двойки от 8 до 256")
return errs
def validate(stage):
# Проверяет набор в каталоге stage до того, как он попадёт в airdef-mod/. Возвращает список ошибок.
# Ссылки airdef:<путь> проверяем по набору; ссылки minecraft:... и без пространства имён — ванильные, их не проверяем.
errors = []
files, docs = set(), {}
for dirpath, _, names in os.walk(stage):
for name in sorted(names):
path = os.path.join(dirpath, name)
rel = os.path.relpath(path, stage).replace(os.sep, "/")
files.add(rel)
if name.endswith(".json"):
try:
with open(path, encoding="utf-8") as f:
docs[rel] = json.load(f)
except (OSError, ValueError) as e:
errors.append(f"{rel}: не разбирается как JSON: {e}")
elif name.endswith(".png"):
errors.extend(check_png(path, rel))
def check_ref(ref, kind, ext, owner):
if not isinstance(ref, str) or not ref.startswith(NS + ":"):
return
path = ref[len(NS) + 1:]
if not path or path.startswith("/") or ".." in path.split("/"):
errors.append(f"{owner}: недопустимая ссылка {ref}")
return
rel = f"assets/{NS}/{kind}/{path}{ext}"
if rel not in files:
errors.append(f"{owner}: ссылка {ref} не найдена ({rel})")
def pick(prefix):
return sorted(rel for rel in docs if rel.startswith(f"assets/{NS}/{prefix}"))
for rel in pick("models/"):
doc = docs[rel]
if not isinstance(doc, dict):
errors.append(f"{rel}: модель должна быть объектом JSON")
continue
if "parent" in doc:
check_ref(doc["parent"], "models", ".json", rel)
tex = doc.get("textures", {})
for key, val in tex.items():
if isinstance(val, str) and val.startswith("#"):
if val[1:] not in tex:
errors.append(f"{rel}: текстура {val} не объявлена")
else:
check_ref(val, "textures", ".png", rel)
for i, el in enumerate(doc.get("elements", [])):
for corner in ("from", "to"):
pos = el.get(corner, [])
if len(pos) != 3 or any(not -16 <= c <= 32 for c in pos):
errors.append(f"{rel}: элемент {i}, {corner} {pos} вне -16..32")
for face, fd in el.get("faces", {}).items():
tex_ref = fd.get("texture")
if tex_ref is None:
errors.append(f"{rel}: элемент {i}, грань {face} без texture")
elif tex_ref.startswith("#"):
if tex_ref[1:] not in tex:
errors.append(f"{rel}: элемент {i}, грань {face}: {tex_ref} не объявлена")
else:
check_ref(tex_ref, "textures", ".png", rel)
for rel in pick("blockstates/"):
for variant in docs[rel].get("variants", {}).values():
for m in (variant if isinstance(variant, list) else [variant]):
check_ref(m.get("model"), "models", ".json", rel)
for rel in pick("items/"):
check_ref(docs[rel].get("model", {}).get("model"), "models", ".json", rel)
# У каждого блока есть blockstate, и наоборот.
blocks = {rel[len(f"assets/{NS}/models/block/"):] for rel in pick("models/block/")}
states = {rel[len(f"assets/{NS}/blockstates/"):] for rel in pick("blockstates/")}
for name in sorted(blocks - states):
errors.append(f"models/block/{name}: нет blockstate")
for name in sorted(states - blocks):
errors.append(f"blockstates/{name}: нет модели блока")
en = docs.get(f"assets/{NS}/lang/en_us.json")
ru = docs.get(f"assets/{NS}/lang/ru_ru.json")
if en is None or ru is None:
errors.append("нет lang/en_us.json или lang/ru_ru.json")
elif set(en) != set(ru):
errors.append(f"lang: ключи en_us и ru_ru различаются: {sorted(set(en) ^ set(ru))[:5]}")
return errors
MANIFEST = os.path.join(ROOT, "generated-manifest.txt")
@@ -1782,6 +1882,12 @@ def main():
old = read_manifest()
with tempfile.TemporaryDirectory() as stage:
counts = generate(stage)
errors = validate(stage)
if errors:
print(f"ПРОВЕРКА НАБОРА НЕ ПРОЙДЕНА, ошибок: {len(errors)}. Ничего не записано в {ROOT}.")
for err in errors:
print(f" - {err}")
sys.exit(1)
new = {}
for dirpath, dirs, names in os.walk(stage):
dirs.sort()