State per world, damage saved, /airdef import
build / build (push) Failing after 26m9s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 11:25:45 +03:00
parent 94170823cb
commit 3443bafedf
11 changed files with 211 additions and 12 deletions
+4
View File
@@ -65,6 +65,9 @@ CI: `.gitea/workflows/build.yml` на каждый пуш собирает мо
Радар игрока и мониторы показывают только то, что держат РЛС; в сводке видно «Вне видимости РЛС: N».
Мёртвые зоны — ближе минимальной дальности ракет (Patriot 10 м, Панцирь 25, С-400 30, С-500 40).
**Сохранение.** Комплексы, объекты, их повреждения и настройки сохраняются отдельно для каждого мира (и для каждого
сервера); уничтоженный комплекс остаётся остовом и после перезахода, пока его не отремонтируют.
**Повреждения.** Взрыв рядом с комплексом повреждает его: он хуже поворачивается, хуже видит и попадает,
дымит, ниже 35% горит. Уничтоженный остаётся горящим остовом, его боекомплект детонирует: чем больше ракет
оставалось, тем мощнее взрыв и дольше вторичные (у Гепарда рвутся снаряды); соседние комплексы тоже могут
@@ -105,6 +108,7 @@ CI: `.gitea/workflows/build.yml` на каждый пуш собирает мо
Команды:
- `/airdef wave <тип> [кол-во] [дальность]` — налёт на ближайшую цель. Типы: `shahed`, `geran3`, `lancet`, `gerbera`, `kh101`, `kalibr`, `kh22`, `zircon`, `iskander`, `kinzhal`, `oreshnik` и `mix` (комбинированный удар двумя секторами: «Герберы», шахеды, Герани-3, Х-101, «Калибры», Х-22, Циркон, «Искандер», «Кинжал», при 30+ — «Орешник»);
- `/airdef replay [map|full|reel|stop]` — повтор последнего налёта;
- `/airdef import` — перенести в текущий мир комплексы и цели из старой версии мода (до 0.2.x они были общими для всех миров);
- `/airdef raid` — налёт, настроенный в меню; `/airdef target here` — отметить объект под ногами; `/airdef repair` — ремонт;
- `/airdef roles` — показать, по каким классам целей работает каждый комплекс; `/airdef roles <patriot|gepard|pantsir|s400|s500|all> <классы|default>` — задать (классы через запятую: `drone`, `jet`, `cruise`, `supersonic`, `ballistic` или `all`), например `/airdef roles s400 cruise,ballistic`;
- `/airdef deploy <patriot|gepard|pantsir|s400|s500>` — поставить комплекс в 8 блоках перед собой;
+73 -4
View File
@@ -58,6 +58,10 @@ struct AirDef {
replay: Option<replay::Player>,
/// The world drawn beyond the render distance (replays and cameras see far off).
far: bool,
/// The world whose state is loaded (`game.world-id`), once one is.
world: Option<Option<String>>,
/// The systems' and objects' health as last saved, to save again when it changes.
health_mark: u64,
/// The menu's page while it is open.
menu: Option<Page>,
/// Where the player is, for the sounds' delay and far variants.
@@ -362,14 +366,18 @@ impl AirDef {
for (kind, roles) in &self.sim.roles {
text += &format!("roles {} {}\n", kind.key(), roles);
}
if let Err(e) = storage::set("state", text.as_bytes()) {
if let Err(e) = storage::set(&self.state_key(), text.as_bytes()) {
horizon_mod_sdk::warn(&format!("could not save: {e}"));
}
self.dirty = false;
}
fn load(&mut self) {
let Some(bytes) = storage::get("state") else { return };
self.load_from(&self.state_key());
}
fn load_from(&mut self, key: &str) {
let Some(bytes) = storage::get(key) else { return };
let text = String::from_utf8_lossy(&bytes);
for line in text.lines() {
let w: Vec<&str> = line.split_whitespace().collect();
@@ -417,6 +425,42 @@ impl AirDef {
}
impl AirDef {
/// Where the open world's state is kept: a key of its own per world.
fn state_key(&self) -> String {
match self.world.as_ref().and_then(|w| w.as_ref()) {
Some(w) => {
// FNV-1a: storage keys are short, lowercase and plain.
let h = w.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3));
format!("state-{h:016x}")
}
None => "state".into(),
}
}
/// Another world opened: the last one's state saved, a clean simulation with this one's loaded.
fn open_world(&mut self, world: Option<String>) {
if self.world.is_some() && self.dirty {
self.save();
}
if self.replay.is_some() {
self.stop_replay();
}
let shader = self.sim.shader;
let seed = game::get_clock().map_or(0x5eed, |c| c.game_time.wrapping_mul(2654435761) ^ 0x9e37);
self.sim = Sim::new(seed);
self.sim.shader = shader;
self.ground = Ground::default();
self.monitor = None;
self.grid = false;
self.grid_ids.clear();
self.far = true;
self.world = Some(world);
self.load();
self.apply_far();
self.dirty = false;
self.health_mark = 0;
}
/// Distant terrain (48 chunks, coarse) while `far` is on: the raid's whole area has land,
/// in the world and in the cameras.
fn apply_far(&self) {
@@ -640,7 +684,7 @@ impl AirDef {
}
}
const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|stop] | raid | repair | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | shader on|off | hold on|off | roles [комплекс классы] | stats";
const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|stop] | raid | repair | import | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | shader on|off | hold on|off | roles [комплекс классы] | stats";
impl Mod for AirDef {
fn init() -> Result<(Self, Registration), String> {
@@ -657,6 +701,8 @@ impl Mod for AirDef {
wave: wave::WaveConfig::default(),
replay: None,
far: true,
world: None,
health_mark: 0,
menu: None,
listener: DVec3::ZERO,
pending: Vec::new(),
@@ -673,7 +719,6 @@ impl Mod for AirDef {
// Not Enter: it opens the chat.
let _ = input::register_key("rp_pause", "Повтор: пауза", "key.keyboard.space");
let _ = input::register_key("rp_exit", "Повтор: выход", "key.keyboard.backspace");
me.load();
input::claim_items(&ITEMS.map(String::from));
render::set_post(include_str!("../shaders/effects.wgsl"));
me.sim.shader = true;
@@ -683,6 +728,11 @@ impl Mod for AirDef {
fn tick(&mut self, _tick: u64) {
let Some(p) = player() else { return };
// Each world keeps its own systems, objects and settings: loaded when it opens.
let world = game::world_id();
if self.world.as_ref() != Some(&world) {
self.open_world(world);
}
for event in input::poll() {
match event {
Event::UseStart(u) => self.use_item(&u.item.id),
@@ -779,6 +829,18 @@ impl Mod for AirDef {
}
}
}
// Damage is saved too: a destroyed system stays destroyed.
let mark = self
.sim
.sams
.iter()
.map(|s| (s.health * 1000.0) as u64)
.chain(self.sim.target_health.iter().map(|h| (h * 10.0) as u64))
.fold(self.sim.sams.len() as u64, |a, h| a.wrapping_mul(31).wrapping_add(h));
if mark != self.health_mark {
self.health_mark = mark;
self.dirty = true;
}
if self.dirty {
self.save();
}
@@ -988,6 +1050,13 @@ impl Mod for AirDef {
self.launch_wave(&mut out)?;
None
}
["import"] => {
// The state the mod kept before worlds had their own: brought into this world.
let before = self.sim.sams.len();
self.load_from("state");
self.dirty = true;
Some(format!("Перенесено комплексов из старой версии: {}", self.sim.sams.len() - before))
}
["repair"] => {
let n = self.sim.repair();
self.dirty = true;
@@ -1,7 +1,7 @@
From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 12:37:45 +0300
Subject: [PATCH 1/8] Mod API (AirDef fork): world models, client particles,
Subject: [PATCH 1/9] 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 2/8] cargo-horizon-mod: report particles and sounds
Subject: [PATCH 2/9] 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 3/8] Mod API (AirDef fork): full-screen post pass for mods
Subject: [PATCH 3/9] 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 4/8] Mod models: draw far models as scaled impostors past the
Subject: [PATCH 4/9] 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 5/8] Mod cameras: render.set-views draws the world from other
Subject: [PATCH 5/9] 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 6/8] Mods: more fuel per tick and per event (AirDef plans
Subject: [PATCH 6/9] 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 7/8] Mod cameras up to 1920x1080 (full-screen replays)
Subject: [PATCH 7/9] 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 8/8] Mod cameras: distant terrain tiles, fog measured from the
Subject: [PATCH 8/9] 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>
@@ -0,0 +1,126 @@
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 9/9] Mods: game.world-id, so mods keep state per world (API
level 9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/game.rs | 1 +
crates/horizon-client/src/game/mods.rs | 3 +++
crates/horizon-client/src/game/remote.rs | 4 ++++
crates/horizon-mod-sdk/wit/horizon.wit | 4 ++++
crates/horizon-mods/src/api_level.rs | 3 ++-
crates/horizon-mods/src/runtime.rs | 5 +++++
6 files changed, 19 insertions(+), 1 deletion(-)
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index e5ffb30c4..c6b642345 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -8918,6 +8918,7 @@ impl Game<'_> {
create: Option<&horizon_client::title::WorldOptions>,
) -> Result<()> {
world_loading::world_data_used();
+ self.mods.world_id = Some(format!("world:{folder}"));
if self.defer_world_entry(PendingWorldEntry::Local {
folder: folder.to_owned(),
name: name.map(str::to_owned),
diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs
index 3eef96aa3..ebdd18a24 100644
--- a/crates/horizon-client/src/game/mods.rs
+++ b/crates/horizon-client/src/game/mods.rs
@@ -38,6 +38,8 @@ use {
#[derive(Default)]
pub(super) struct ModsState {
+ /// The open world, for `game.world-id` (AirDef fork): its folder, or the server's address.
+ pub(super) world_id: Option<String>,
#[cfg(feature = "mods")]
pub(super) mods: Mods,
/// Capability `input`: a claimed item's use while the button is down.
@@ -482,6 +484,7 @@ impl Game<'_> {
known_recipes,
placing: self.mod_recipe_screen(),
creative: self.creative,
+ world: self.player.is_some().then(|| self.mods.world_id.clone()).flatten(),
}
}
/// The first block along the player's view within
diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs
index 22b7c13df..89df0616b 100644
--- a/crates/horizon-client/src/game/remote.rs
+++ b/crates/horizon-client/src/game/remote.rs
@@ -342,6 +342,10 @@ impl Game<'_> {
cookies: Option<horizon_client::net::Cookies>,
) -> Result<()> {
self.perf.world_opening();
+ // A server's own world; joining the world this window hosts keeps its folder.
+ if local.is_none() {
+ self.mods.world_id = Some(format!("server:{address}"));
+ }
let root = self.options.resources.clone();
let builtin = Builtin::find(&root)
.and_then(|p| Builtin::load(&p).ok())
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
index e30f3a30e..b6758d9fb 100644
--- a/crates/horizon-mod-sdk/wit/horizon.wit
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
@@ -47,6 +47,10 @@ interface game {
get-player: func() -> option<player>;
/// None on the title screen.
get-clock: func() -> option<clock>;
+ /// Which world is open (AirDef fork): `world:<folder>` for a saved
+ /// world, `server:<address>` on a server; none on the title screen.
+ /// Mods keep their state apart by it.
+ world-id: func() -> option<string>;
/// A block along the player's view.
record hit {
/// The block hit.
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
index 9d15851b4..cf2993186 100644
--- a/crates/horizon-mods/src/api_level.rs
+++ b/crates/horizon-mods/src/api_level.rs
@@ -13,7 +13,7 @@ pub const LEVEL: u32 = 9;
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
/// test fails until it is listed here).
-pub const FUNCTIONS: [(&str, u32); 55] = [
+pub const FUNCTIONS: [(&str, u32); 56] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -70,6 +70,7 @@ pub const FUNCTIONS: [(&str, u32); 55] = [
("render.clear-post", 8),
("render.set-params", 8),
("render.set-views", 9),
+ ("game.world-id", 9),
];
/// What the game's program carries so the launcher can read its level
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
index 1389407be..abed5b31f 100644
--- a/crates/horizon-mods/src/runtime.rs
+++ b/crates/horizon-mods/src/runtime.rs
@@ -168,6 +168,8 @@ pub struct Snapshot {
pub placing: bool,
/// The player is in creative mode (`recipes.give`).
pub creative: bool,
+ /// The open world (`game.world-id`, AirDef fork).
+ pub world: Option<String>,
}
/// Capability `recipes`'s tables: `items` in the registry's order, which
@@ -1475,6 +1477,9 @@ impl game::Host for State {
fn get_clock(&mut self) -> Option<Clock> {
self.snapshot.clock
}
+ fn world_id(&mut self) -> Option<String> {
+ self.snapshot.world.clone()
+ }
fn raycast(&mut self, distance: f32) -> Option<Hit> {
let (at, hit) = self.snapshot.view_hit.as_ref()?;
// NaN compares false: no hit.
--
2.54.0 (Apple Git-157)