Compare commits

...

2 Commits

Author SHA1 Message Date
chexit f16bc2387f /airdef command tree with typed arguments, live scenario names and generated help (#98, fork patch 36)
build / mod (push) Successful in 3m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:43:11 +03:00
chexit 9bc94fa8aa Raids launch from real sites: 1, 2 or 4-5 sides, stock and launch queue, land first, then nearer land, then barges on water (#100)
build / mod (push) Successful in 3m50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:36:43 +03:00
53 changed files with 7663 additions and 58 deletions
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "airdef:block/barge"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "airdef:block/launch_ramp"
}
}
}
@@ -0,0 +1,7 @@
{
"variants": {
"": {
"model": "airdef:block/launcher_truck"
}
}
}
+3
View File
@@ -55,6 +55,9 @@
"block.airdef.radar_truck": "Шасси РЛС",
"block.airdef.depot": "Склад боеприпасов",
"block.airdef.tzm_truck": "Транспортно-заряжающая машина",
"block.airdef.barge": "Баржа",
"block.airdef.launch_ramp": "Рампа запуска дронов",
"block.airdef.launcher_truck": "Пусковая машина ракет",
"block.airdef.ew_truck": "Станция РЭБ",
"block.airdef.searchlight": "Прожектор",
"block.airdef.searchlight_head": "Голова прожектора",
+3
View File
@@ -55,6 +55,9 @@
"block.airdef.radar_truck": "Шасси РЛС",
"block.airdef.depot": "Склад боеприпасов",
"block.airdef.tzm_truck": "Транспортно-заряжающая машина",
"block.airdef.barge": "Баржа",
"block.airdef.launch_ramp": "Рампа запуска дронов",
"block.airdef.launcher_truck": "Пусковая машина ракет",
"block.airdef.ew_truck": "Станция РЭБ",
"block.airdef.searchlight": "Прожектор",
"block.airdef.searchlight_head": "Голова прожектора",
@@ -0,0 +1,896 @@
{
"ambientocclusion": false,
"textures": {
"dark_metal": "airdef:block/dark_metal",
"metal": "airdef:block/metal",
"ru_khaki_s31": "airdef:block/ru_khaki_s31",
"ru_khaki_s33": "airdef:block/ru_khaki_s33",
"particle": "airdef:block/dark_metal"
},
"elements": [
{
"from": [
-16.0,
0.0,
-16.0
],
"to": [
32.0,
3.0,
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
16,
3.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
16,
3.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
16,
3.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
16,
3.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
16,
16
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
16,
16
],
"texture": "#dark_metal"
}
}
},
{
"from": [
-16.0,
3.0,
-16.0
],
"to": [
32.0,
4.0,
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
16,
1.0
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
16,
1.0
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
16,
1.0
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
16,
1.0
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
16,
16
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
16,
16
],
"texture": "#metal"
}
}
},
{
"from": [
-15.0,
3.0,
-15.0
],
"to": [
31.0,
4.4,
31.0
],
"faces": {
"north": {
"uv": [
0,
0,
16,
1.4000000000000004
],
"texture": "#ru_khaki_s31"
},
"south": {
"uv": [
0,
0,
16,
1.4000000000000004
],
"texture": "#ru_khaki_s31"
},
"east": {
"uv": [
0,
0,
16,
1.4000000000000004
],
"texture": "#ru_khaki_s31"
},
"west": {
"uv": [
0,
0,
16,
1.4000000000000004
],
"texture": "#ru_khaki_s31"
},
"up": {
"uv": [
0,
0,
16,
16
],
"texture": "#ru_khaki_s33"
},
"down": {
"uv": [
0,
0,
16,
16
],
"texture": "#ru_khaki_s33"
}
}
},
{
"from": [
-16.0,
4.0,
-16.0
],
"to": [
-14.0,
6.0,
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#dark_metal"
}
}
},
{
"from": [
30.0,
4.0,
-16.0
],
"to": [
32.0,
6.0,
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#dark_metal"
}
}
},
{
"from": [
-16.0,
4.0,
30.0
],
"to": [
32.0,
6.0,
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
-16.0,
4.0,
-16.0
],
"to": [
32.0,
6.0,
-14.0
],
"faces": {
"north": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
-12.0,
4.4,
-10.0
],
"to": [
-9.0,
6.5,
-7.0
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
}
}
},
{
"from": [
25.0,
4.4,
-10.0
],
"to": [
28.0,
6.5,
-7.0
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
}
}
},
{
"from": [
-12.0,
4.4,
8.0
],
"to": [
-9.0,
6.5,
11.0
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
}
}
},
{
"from": [
25.0,
4.4,
8.0
],
"to": [
28.0,
6.5,
11.0
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
}
}
},
{
"from": [
-12.0,
4.4,
26.0
],
"to": [
-9.0,
6.5,
29.0
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
}
}
},
{
"from": [
25.0,
4.4,
26.0
],
"to": [
28.0,
6.5,
29.0
],
"faces": {
"north": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
3.0,
2.0999999999999996
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
3.0,
3.0
],
"texture": "#metal"
}
}
}
]
}
@@ -0,0 +1,709 @@
{
"ambientocclusion": false,
"textures": {
"dark_metal": "airdef:block/dark_metal",
"metal": "airdef:block/metal",
"ru_khaki": "airdef:block/ru_khaki",
"particle": "airdef:block/dark_metal"
},
"elements": [
{
"from": [
1.0,
0.0,
-6.0
],
"to": [
15.0,
2.0,
30.0
],
"faces": {
"north": {
"uv": [
0,
0,
14.0,
2.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
14.0,
2.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
14.0,
16
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
14.0,
16
],
"texture": "#dark_metal"
}
}
},
{
"from": [
6.0,
2.0,
0.0
],
"to": [
10.0,
4.0,
26.0
],
"faces": {
"north": {
"uv": [
0,
0,
4.0,
2.0
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
4.0,
2.0
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
4.0,
16
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
4.0,
16
],
"texture": "#metal"
}
}
},
{
"from": [
3.0,
9.0,
-14.0
],
"to": [
5.0,
11.0,
30.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#metal"
}
},
"rotation": {
"angle": 22.5,
"axis": "x",
"origin": [
4.0,
10.0,
8.0
]
}
},
{
"from": [
11.0,
9.0,
-14.0
],
"to": [
13.0,
11.0,
30.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#metal"
},
"south": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#metal"
},
"east": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#metal"
},
"west": {
"uv": [
0,
0,
16,
2.0
],
"texture": "#metal"
},
"up": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#metal"
},
"down": {
"uv": [
0,
0,
2.0,
16
],
"texture": "#metal"
}
},
"rotation": {
"angle": 22.5,
"axis": "x",
"origin": [
12.0,
10.0,
8.0
]
}
},
{
"from": [
3.0,
0.0,
-1.0
],
"to": [
5.0,
12.0,
1.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
11.0,
0.0,
-1.0
],
"to": [
13.0,
12.0,
1.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
2.0,
12.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
3.0,
0.0,
17.0
],
"to": [
5.0,
6.0,
19.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
11.0,
0.0,
17.0
],
"to": [
13.0,
6.0,
19.0
],
"faces": {
"north": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
2.0,
6.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
2.0,
2.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
4.0,
10.0,
24.0
],
"to": [
12.0,
14.0,
30.0
],
"faces": {
"north": {
"uv": [
0,
0,
8.0,
4.0
],
"texture": "#dark_metal"
},
"south": {
"uv": [
0,
0,
8.0,
4.0
],
"texture": "#dark_metal"
},
"east": {
"uv": [
0,
0,
6.0,
4.0
],
"texture": "#dark_metal"
},
"west": {
"uv": [
0,
0,
6.0,
4.0
],
"texture": "#dark_metal"
},
"up": {
"uv": [
0,
0,
8.0,
6.0
],
"texture": "#dark_metal"
},
"down": {
"uv": [
0,
0,
8.0,
6.0
],
"texture": "#dark_metal"
}
}
},
{
"from": [
1.0,
2.0,
28.0
],
"to": [
15.0,
6.0,
32.0
],
"faces": {
"north": {
"uv": [
0,
0,
14.0,
4.0
],
"texture": "#ru_khaki"
},
"south": {
"uv": [
0,
0,
14.0,
4.0
],
"texture": "#ru_khaki"
},
"east": {
"uv": [
0,
0,
4.0,
4.0
],
"texture": "#ru_khaki"
},
"west": {
"uv": [
0,
0,
4.0,
4.0
],
"texture": "#ru_khaki"
},
"up": {
"uv": [
0,
0,
14.0,
4.0
],
"texture": "#ru_khaki"
},
"down": {
"uv": [
0,
0,
14.0,
4.0
],
"texture": "#ru_khaki"
}
}
}
]
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+275
View File
@@ -0,0 +1,275 @@
//! The `/airdef` command tree: the one description the game's chat box (colours, errors,
//! Tab completion), the help line and the tests read (#98). The handler in `lib.rs` still
//! splits its line itself; this tree decides what reaches it.
use crate::sim::{DroneKind, SamKind};
use horizon_mod_sdk::tree::{Node, Opt, Tree, Value};
/// The suggestion list of the saved scenarios' names (`command::set_suggestions`).
pub const SCENARIOS: &str = "scenarios";
fn on_off() -> Value {
Value::choice(["on", "off"])
}
/// `/airdef ...` as `lib.rs` accepts it.
pub fn tree() -> Tree {
let wave_kinds: Vec<&str> = DroneKind::ALL.iter().map(|k| k.spec().key).chain(["mix"]).collect();
let deploy_kinds: Vec<&str> = SamKind::ALL.iter().map(|k| k.key()).collect();
let armed: Vec<&str> = SamKind::ARMED.iter().map(|k| k.key()).chain(["all"]).collect();
let toggle = |word: &str| Node::lit(word).then(Node::arg("on/off", on_off()).runs());
Tree::new("airdef").runs().with([
Node::lit("menu").runs(),
Node::lit("monitor").runs(),
Node::lit("grid").runs(),
Node::lit("replay").runs().with([
Node::lit("map").runs(),
Node::lit("full").runs(),
Node::lit("reel").runs(),
Node::lit("film").runs().with([
Node::arg("режим", Value::choice(["best", "all"])).runs(),
Node::arg("N", Value::int_from(1)).runs(),
]),
// The words after `export` come in any order: a name, best/all/N, key=value options.
Node::lit("export").runs().then(
Node::options(
"параметры",
vec![
Opt::new("fps", Value::int_from(1)),
Opt::new("size", Value::word()),
Opt::new("crf", Value::int_from(0)),
Opt::new("sec", Value::Number(Some(0.0), None)),
Opt::flag("noaudio"),
],
Some(vec!["best".into(), "all".into()]),
)
.runs(),
),
Node::lit("stop").runs(),
]),
Node::lit("scenario").with([
Node::lit("save").then(
Node::arg("имя", Value::word()).runs().then(Node::arg("fresh", Value::choice(["fresh"])).runs()),
),
Node::lit("load").then(
Node::arg("имя", Value::names(SCENARIOS)).runs().then(Node::arg("force", Value::choice(["force"])).runs()),
),
Node::lit("list").runs(),
Node::lit("delete").then(Node::arg("имя", Value::names(SCENARIOS)).runs()),
]),
Node::lit("why").runs().then(Node::arg("номер", Value::int_from(1)).runs()),
Node::lit("raid").runs(),
Node::lit("repair").runs(),
Node::lit("import").runs(),
toggle("ew"),
Node::lit("flare").runs(),
Node::lit("move").then(Node::arg("номер", Value::int_from(1)).runs()),
Node::lit("target").then(Node::lit("here").runs()),
Node::lit("give").runs(),
Node::lit("deploy").then(Node::arg("комплекс", Value::choice(deploy_kinds)).runs()),
Node::lit("wave").then(
Node::arg("тип", Value::choice(wave_kinds)).runs().then(
Node::arg("кол-во", Value::int(1, 80)).runs().then(Node::arg("дальность", Value::number(60.0, 800.0)).runs()),
),
),
Node::lit("clear").runs(),
Node::lit("targets").then(Node::lit("clear").runs()),
toggle("damage"),
toggle("truth"),
Node::lit("shader").then(Node::arg("режим", Value::choice(["on", "off", "hd"])).runs()),
toggle("hold"),
Node::lit("roles").runs().then(
Node::arg("комплекс", Value::choice(armed)).then(
Node::arg(
"классы",
Value::Word(["default", "all", "drone", "jet", "cruise", "supersonic", "ballistic"].map(String::from).to_vec()),
)
.runs(),
),
),
Node::lit("stats").runs(),
])
}
/// The help line, from the same tree.
pub fn help() -> String {
tree().usage()
}
/// The scenario names after `name` is saved (once, in the order saved).
pub fn names_after_save(mut all: Vec<String>, name: &str) -> Vec<String> {
if !all.iter().any(|n| n == name) {
all.push(name.to_string());
}
all
}
/// The scenario names after `name` is deleted.
pub fn names_after_delete(mut all: Vec<String>, name: &str) -> Vec<String> {
all.retain(|n| n != name);
all
}
/// Registers the tree with the game, and the scenario names it suggests.
pub fn register(scenarios: &[String]) {
horizon_mod_sdk::command::set_suggestions(SCENARIOS, scenarios);
if let Err(e) = tree().register() {
horizon_mod_sdk::warn(&format!("airdef: command tree refused: {e}"));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ok(line: &str) {
if let Err(e) = tree().parse(line) {
panic!("`{line}` should parse: {e}");
}
}
fn bad_at(line: &str) -> usize {
match tree().parse(line) {
Ok(_) => panic!("`{line}` should not parse"),
Err(e) => e.at,
}
}
fn complete(line: &str, scenarios: &[&str]) -> Vec<String> {
let names: Vec<String> = scenarios.iter().map(|s| s.to_string()).collect();
tree().complete(line, &|_| names.clone())
}
/// Every branch of the old `HELP` line and of the handler's match parses, with its
/// options.
#[test]
fn every_branch_of_the_handler_parses() {
for line in [
"", "menu", "monitor", "grid", "give", "raid", "repair", "import", "flare", "clear", "stats", "roles",
"replay", "replay map", "replay full", "replay reel", "replay film", "replay film best", "replay film all",
"replay film 3", "replay export", "replay export mine", "replay export best", "replay export all", "replay export 3",
"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 oreshnik 1 60", "damage on", "damage off", "truth on", "shader hd", "shader off", "shader on", "hold on",
"roles all default", "roles patriot drone,jet", "roles pantsir all", "roles tor ballistic",
] {
ok(line);
}
}
#[test]
fn the_trees_kinds_are_the_enums_own() {
for k in SamKind::ALL {
ok(&format!("deploy {}", k.key()));
}
for k in DroneKind::ALL {
ok(&format!("wave {}", k.spec().key));
}
for k in SamKind::ARMED {
ok(&format!("roles {} all", k.key()));
}
}
#[test]
fn numbers_and_words_outside_what_the_handler_takes_are_refused_where_they_are() {
assert_eq!(bad_at("wave shahed 0"), 12);
assert_eq!(bad_at("wave shahed 81"), 12);
assert_eq!(bad_at("wave shahed 5 59"), 14);
assert_eq!(bad_at("wave shahed 5 801"), 14);
assert_eq!(bad_at("wave shahed x"), 12);
assert_eq!(bad_at("wave scud"), 5);
assert_eq!(bad_at("wave"), 4);
assert_eq!(bad_at("deploy tank"), 7);
assert_eq!(bad_at("deploy"), 6);
assert_eq!(bad_at("ew maybe"), 3);
assert_eq!(bad_at("shader ultra"), 7);
assert_eq!(bad_at("why 0"), 4);
assert_eq!(bad_at("why x"), 4);
assert_eq!(bad_at("move"), 4);
assert_eq!(bad_at("move 0"), 5);
assert_eq!(bad_at("scenario"), 8);
assert_eq!(bad_at("scenario load"), 13);
assert_eq!(bad_at("scenario wipe"), 9);
assert_eq!(bad_at("scenario save a keep"), 16);
assert_eq!(bad_at("target there"), 7);
assert_eq!(bad_at("roles tank all"), 6);
assert_eq!(bad_at("roles patriot"), 13);
assert_eq!(bad_at("replay sideways"), 7);
assert_eq!(bad_at("replay film best now"), 17);
assert_eq!(bad_at("nothing"), 0);
assert_eq!(bad_at("menu 1"), 5);
}
/// The key=value options of `replay export` stay what `start_export` reads.
#[test]
fn export_options_are_checked_by_key_and_value() {
assert_eq!(bad_at("replay export fps=0"), 14);
assert_eq!(bad_at("replay export fps=x"), 14);
assert_eq!(bad_at("replay export fps"), 14);
assert_eq!(bad_at("replay export noaudio=1"), 14);
assert_eq!(bad_at("replay export fps=30 fps=60"), 21);
assert_eq!(bad_at("replay export sec=-1"), 14);
assert_eq!(bad_at("replay export crf=-2"), 14);
assert_eq!(bad_at("replay export size="), 14);
let p = tree().parse("replay export mine fps=30 noaudio").unwrap();
assert_eq!(p.get("параметры"), Some("mine fps=30 noaudio"));
}
#[test]
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", "roles", "stats"] {
assert!(top.contains(&w.to_string()), "{w} in {top:?}");
}
assert_eq!(top.len(), 24);
assert_eq!(complete("rep", &[]), ["repair", "replay"]);
assert_eq!(complete("replay ", &[]), ["export", "film", "full", "map", "reel", "stop"]);
assert_eq!(complete("replay film ", &[]), ["all", "best"]);
assert_eq!(complete("scenario ", &[]), ["delete", "list", "load", "save"]);
assert_eq!(complete("wave ", &[]).len(), 14);
assert!(complete("wave s", &[]).contains(&"shahed".to_string()));
assert_eq!(complete("shader ", &[]), ["hd", "off", "on"]);
assert_eq!(complete("replay export ", &[]), ["all", "best", "crf=", "fps=", "noaudio", "sec=", "size="]);
assert_eq!(complete("replay export fps=30 no", &[]), ["noaudio"]);
assert!(complete("menu ", &[]).is_empty());
assert!(complete("wave nothing ", &[]).is_empty());
}
/// The names follow the data: after a save the new name is offered, after a delete it is gone.
#[test]
fn scenario_names_follow_a_save_and_a_delete() {
let mut all: Vec<String> = Vec::new();
let names = |all: &[String]| -> Vec<String> { tree().complete("scenario load ", &|_| all.to_vec()) };
assert!(names(&all).is_empty());
all = names_after_save(all, "base");
all = names_after_save(all, "night");
all = names_after_save(all, "base");
assert_eq!(names(&all), ["base", "night"]);
let of = |all: &[String], line: &str| tree().complete(line, &|_| all.to_vec());
assert_eq!(of(&all, "scenario delete n"), ["night"]);
all = names_after_delete(all, "base");
assert_eq!(names(&all), ["night"]);
assert_eq!(of(&all, "scenario delete "), ["night"]);
all = names_after_delete(all, "night");
assert!(names(&all).is_empty());
}
#[test]
fn the_help_line_is_the_trees() {
let help = help();
assert!(help.starts_with("/airdef menu | monitor | grid | replay "), "{help}");
assert!(help.contains("scenario save <имя> [fresh]"), "{help}");
assert!(help.contains("wave <тип> [<кол-во> [<дальность>]]"), "{help}");
assert!(help.ends_with("| stats"), "{help}");
assert!(help.contains("export [fps=… size=… crf=… sec=… noaudio]"), "{help}");
assert!(help.contains("deploy <комплекс>"), "{help}");
}
/// The nodes handed to the game are a tree the game accepts.
#[test]
fn the_nodes_are_flat_and_ordered() {
let nodes = tree().nodes();
assert!(nodes.len() < 512, "{}", nodes.len());
assert!(nodes.iter().enumerate().all(|(i, n)| n.children.iter().all(|&c| c as usize > i)));
}
}
+8 -2
View File
@@ -13,6 +13,7 @@ use horizon_mod_sdk::{
};
mod cinema;
mod commands;
mod layout;
mod film;
mod monitor;
@@ -1368,7 +1369,10 @@ impl AirDef {
}
}
const HELP: &str = "/airdef menu | monitor | grid | replay [map|full|reel|film [best|all|N]|export [имя] [best|all|N] [fps=60 size=1920x1080 crf=14 sec=N noaudio]|stop] | scenario save|load|list|delete | why [номер] | raid | repair | import | ew on|off | flare | move <номер> | target here | give | deploy <комплекс> | wave <тип|mix> [кол-во] [дальность] | clear | targets clear | damage on|off | truth on|off | shader on|off|hd | hold on|off | roles [комплекс классы] | stats";
/// The help line: generated from the command tree (`commands.rs`), which the game checks lines with.
fn help() -> String {
commands::help()
}
impl Mod for AirDef {
fn init() -> Result<(Self, Registration), String> {
@@ -1422,6 +1426,8 @@ impl Mod for AirDef {
me.block_log = Some(render::world_log_start());
me.sim.shader = true;
me.apply_far();
// Subcommands, typed arguments and the saved scenarios' names for the chat box (#98).
commands::register(&scenario::saved_names());
Ok((me, Registration::new().command("airdef").on_tick()))
}
@@ -1946,7 +1952,7 @@ impl Mod for AirDef {
self.sim.crashes,
self.ground.world.text()
)),
_ => return Err(HELP.to_string()),
_ => return Err(help()),
};
self.emit(out);
Ok(reply)
+17 -5
View File
@@ -20,6 +20,16 @@ fn valid(name: &str) -> Result<String, String> {
Ok(n)
}
/// The saved scenarios' names.
pub(crate) fn saved_names() -> Vec<String> {
names()
}
/// Tells the chat box the names now saved, so Tab offers them.
fn publish(all: &[String]) {
command::set_suggestions(commands::SCENARIOS, all);
}
fn names() -> Vec<String> {
storage::get(INDEX).map(|b| String::from_utf8_lossy(&b).lines().map(str::to_string).filter(|l| !l.is_empty()).collect()).unwrap_or_default()
}
@@ -82,11 +92,12 @@ impl AirDef {
let fresh = flags.contains(&"fresh");
let text = self.scenario_text(&name, fresh);
storage::set(&key(&name), text.as_bytes()).map_err(|e| format!("Не удалось сохранить: {e}"))?;
let mut all = names();
if !all.contains(&name) {
all.push(name.clone());
let before = names();
let all = commands::names_after_save(before.clone(), &name);
if all != before {
storage::set(INDEX, all.join("\n").as_bytes()).map_err(|e| format!("Не удалось записать список: {e}"))?;
}
publish(&all);
Ok(format!(
"Сценарий «{name}» сохранён: комплексов {}, объектов {}{}",
self.sim.sams.len(),
@@ -109,13 +120,14 @@ impl AirDef {
}
["delete", name] => {
let name = valid(name)?;
let mut all = names();
let all = names();
if !all.contains(&name) {
return Err(format!("Нет сценария «{name}»"));
}
all.retain(|n| *n != name);
let all = commands::names_after_delete(all, &name);
storage::delete(&key(&name));
storage::set(INDEX, all.join("\n").as_bytes()).map_err(|e| format!("Не удалось записать список: {e}"))?;
publish(&all);
Ok(format!("Сценарий «{name}» удалён"))
}
_ => Err("/airdef scenario save <имя> [fresh] | load <имя> [force] | list | delete <имя>".into()),
+7 -1
View File
@@ -12,6 +12,7 @@ use std::collections::HashMap;
mod damage;
pub mod fires;
mod raid;
pub mod sites;
pub mod record;
mod sight;
mod terrain;
@@ -1424,6 +1425,8 @@ pub struct Sim {
later: Vec<(u64, DVec3, u32, bool)>,
/// A planned raid's drones waiting for their launch tick.
pub queue: Vec<(u64, Drone)>,
/// The raid's launch sites and their jobs (#100).
pub sites: sites::Sites,
/// A raid being planned.
plan: Option<raid::Plan>,
/// Each marked object's health, 0 to 100 (`targets`' order).
@@ -1492,6 +1495,7 @@ impl Sim {
horizons: HashMap::new(),
later: Vec::new(),
queue: Vec::new(),
sites: sites::Sites::default(),
plan: None,
target_health: Vec::new(),
wave: None,
@@ -1597,6 +1601,7 @@ impl Sim {
self.fx.clear();
self.blasts.clear();
self.queue.clear();
self.sites.clear();
self.plan = None;
self.wave = None;
// Ammunition still to cook off, flares in the sky.
@@ -1611,7 +1616,7 @@ impl Sim {
self.tick += 1;
self.plan_step(Some(&mut *ground), out);
self.adapt();
self.release_queue();
self.release_queue(out);
self.step_drones(ground, player, out);
self.step_later(ground, player, out);
self.step_moves(ground, out);
@@ -1623,6 +1628,7 @@ impl Sim {
self.step_missiles(ground, out);
self.step_fx(ground, out);
self.draw_targets(out);
self.draw_sites(out);
self.step_wrecks(out);
self.step_fires(ground, out);
self.report_wave(out);
+200 -14
View File
@@ -69,6 +69,9 @@ struct Item {
key: Option<(DroneKind, i32, i32, i32)>,
early: f64,
gap: f64,
/// The site it is a job of: the want (see `sites`) and its pad there.
want: usize,
pad: u32,
}
/// A raid being planned: its drones, the prices and routes so far, and the planned ones.
@@ -78,8 +81,12 @@ pub(super) struct Plan {
routes: HashMap<(DroneKind, i32, i32, i32), Vec<DVec3>>,
items: Vec<Item>,
next: usize,
/// (drone, flight time, how early it arrives, its wave's delay)
done: Vec<(Drone, f64, f64, f64)>,
/// (drone, flight time, how early it arrives, its wave's delay, its site)
done: Vec<(Drone, f64, f64, f64, u32)>,
/// The launch sites wanted, and whether they are settled; the ticks spent waiting for the game's answers.
wants: Vec<sites::Want>,
sited: bool,
site_wait: u32,
smart: bool,
approach: Approach,
total: u32,
@@ -242,7 +249,9 @@ impl Sim {
let smart = order.aim == Aim::Smart;
// A smart enemy comes from where its raids got through best.
let bearing = if smart { self.best_bearing() } else { self.rng.f() * TAU };
let second = bearing + std::f64::consts::PI * (0.6 + self.rng.f() * 0.6);
// The raid's sides (1, 2, or 4-5): its launch sites go there (#100).
let sides = sites::pick_sides(&mut self.rng, order.approach, bearing);
let mut wants: Vec<sites::Want> = Vec::new();
// One grid over the whole raid; each kind's prices are worked out once, routes are shared
// by the drones of a kind going from the same bearing to the same target.
let span = order.dist + 450.0;
@@ -252,25 +261,31 @@ impl Sim {
for (group, &(kind, count)) in order.kinds.iter().enumerate() {
let spec = kind.spec();
for _ in 0..count {
let a = match order.approach {
Approach::Sector | Approach::Waves => bearing + self.rng.s() * 0.35,
Approach::TwoSectors => (if self.rng.chance(0.5) { bearing } else { second }) + self.rng.s() * 0.3,
Approach::AllSides => self.rng.f() * TAU,
let side = match order.approach {
Approach::Sector | Approach::Waves => 0,
Approach::TwoSectors => usize::from(self.rng.chance(0.5)),
Approach::AllSides => (self.rng.f() * sides.len() as f64) as usize % sides.len(),
};
let a = sides[side];
let object = objects[(self.rng.f() * objects.len() as f64) as usize % objects.len()];
let (target, at_sam) = self.choose_target(kind, object, order.aim);
let aim = target + DVec3::new(self.rng.s() * 4.0, 0.0, self.rng.s() * 4.0);
let dist = spec.ballistic.map_or(order.dist, |far| far.max(order.dist)) + self.rng.f() * 50.0;
let height = if spec.ballistic.is_some() { 10.0 } else if spec.cruise > 0.0 { spec.cruise } else { 25.0 };
// Where it starts is provisional: the site's pad, once the site is placed.
let from = centre + DVec3::new(a.sin() * dist, height + self.rng.s() * 6.0, a.cos() * dist);
let mut d = self.make_drone(kind, from, horizontal(aim - from), aim);
let range = spec.ballistic.map_or(order.dist, |far| far.max(order.dist));
let want = sites::want_index(&mut wants, centre, side, a, sites::SiteClass::of(kind), range, order.dist.max(120.0));
let pad = wants[want].jobs;
wants[want].jobs += 1;
d.sector = ew::sector(a) as u8;
let routed = smart && spec.ballistic.is_none() && !spec.decoy && !at_sam.is_some_and(|_| kind == DroneKind::Lancet);
let key = routed.then(|| (kind, (a.rem_euclid(TAU) / 0.35) as i32, (target.x / 24.0) as i32, (target.z / 24.0) as i32));
// Decoys come 15 s ahead, the rest together; "waves" one kind after another.
let early = if spec.decoy { 300.0 } else { 0.0 };
let gap = if order.approach == Approach::Waves { group as f64 * 300.0 } else { 0.0 };
items.push(Item { d, from, aim, key, early, gap });
items.push(Item { d, from, aim, key, early, gap, want, pad });
}
}
self.plan = Some(Plan {
@@ -280,6 +295,9 @@ impl Sim {
items,
next: 0,
done: Vec::new(),
wants,
sited: false,
site_wait: 0,
smart,
approach: order.approach,
total,
@@ -297,6 +315,20 @@ impl Sim {
/// Works on the raid's plan a while (a tick's share), and launches it when it is ready.
pub(super) fn plan_step(&mut self, ground: Option<&mut Ground>, out: &mut Out) {
let Some(mut plan) = self.plan.take() else { return };
let mut ground = ground;
// First the launch sites: suitable ground the game has told of, or no raid (#100).
if !plan.sited {
let Some(g) = ground.as_deref_mut() else {
self.plan = Some(plan);
return;
};
if !self.settle_sites(&mut plan, g, out) {
if !plan.items.is_empty() {
self.plan = Some(plan);
}
return;
}
}
// A smart raid first learns the land under its grid: asked for in bulk, a thousand columns a tick, for
// up to two seconds (what the game has no land for stays unknown and costs nothing).
if !plan.mapped {
@@ -342,7 +374,7 @@ impl Sim {
let mut d = plan.items[i].d.clone();
// It starts over the land, not in it: a hill under the starting point lifts it.
let mut from = from;
if !plan.heights.is_empty() {
if !plan.heights.is_empty() && plan.wants[plan.items[i].want].found.is_none() {
let (gi, gj) = plan.grid.index(from);
let g = plan.heights[(gi * plan.grid.size + gj) as usize];
let floor = f64::from(g) + if spec.ballistic.is_some() { 10.0 } else { spec.clearance + 10.0 };
@@ -388,7 +420,7 @@ impl Sim {
Flight::Ballistic => (horizontal(aim - from).length() / spec.speed).max(30.0),
_ => path / spec.speed,
};
plan.done.push((d, time, plan.items[i].early, plan.items[i].gap));
plan.done.push((d, time, plan.items[i].early, plan.items[i].gap, plan.wants[plan.items[i].want].site));
plan.next += 1;
work += 1;
}
@@ -398,10 +430,13 @@ impl Sim {
}
// Timing: smart raids arrive at once; others leave at once (a moment apart).
let now = self.tick;
let arrive = plan.done.iter().map(|(_, t, e, g)| t + e - g).fold(0.0, f64::max);
for (mut d, time, early, gap) in plan.done {
let arrive = plan.done.iter().map(|(_, t, e, g, _)| t + e - g).fold(0.0, f64::max);
for (mut d, time, early, gap, site) in plan.done {
d.smart = plan.smart;
let wait = if plan.smart { arrive - (early - gap) - time } else { gap + self.rng.f() * 40.0 };
if site != 0 {
self.sites.job(d.id, site);
}
self.queue.push((now + wait.clamp(0.0, 2400.0) as u64, d));
}
self.wave = Some(WaveStats {
@@ -575,17 +610,156 @@ impl Sim {
}
/// A planned raid's drones take off when their time comes.
pub(super) fn release_queue(&mut self) {
pub(super) fn release_queue(&mut self, out: &mut Out) {
let now = self.tick;
if self.queue.iter().all(|q| q.0 > now) {
return;
}
let (go, wait): (Vec<_>, Vec<_>) = std::mem::take(&mut self.queue).into_iter().partition(|q| q.0 <= now);
self.queue = wait;
for (_, d) in go {
let mut cancelled = 0;
for (t, 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) {
sites::Slot::Free => {}
sites::Slot::Busy => {
self.queue.push((t, d));
continue;
}
sites::Slot::Gone => {
cancelled += 1;
continue;
}
sites::Slot::Go => {
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);
out.particles("minecraft:large_smoke", d.pos - dir * 2.5, 0.5, DVec3::new(0.0, 0.05, 0.0), 0.02, 4);
}
}
self.drones.push(d);
self.launched += 1;
}
if cancelled > 0 {
out.messages.push(format!("Пусковая площадка не запустила {cancelled}: нет запаса или уничтожена"));
}
}
/// Looks for the raid's launch sites. Returns whether they are settled (placed, or given up: drones whose
/// side has no suitable ground go to another side's site, or are cancelled with a message; a raid left with
/// nothing is cancelled whole).
fn settle_sites(&mut self, plan: &mut Plan, ground: &mut Ground, out: &mut Out) -> bool {
plan.site_wait += 1;
let mut keep: Vec<DVec3> = self.sams.iter().filter(|s| s.alive()).map(|s| s.pos).collect();
keep.extend(self.targets.iter().copied());
keep.push(plan.centre);
let last = plan.site_wait >= sites::SEARCH_TICKS;
if !sites::search(&mut plan.wants, ground, plan.centre, &keep, self.tick, last) {
return false;
}
use sites::Search;
// A side that found nothing hands its drones to a site of the same class and range on another side.
let mut moved = 0;
let mut dropped = 0;
let found: Vec<usize> = (0..plan.wants.len()).filter(|&i| plan.wants[i].state == Search::Found).collect();
let mut keep_items = Vec::new();
for mut it in std::mem::take(&mut plan.items) {
let w = &plan.wants[it.want];
if w.state != Search::Found {
let (class, range) = (w.class, w.ordered);
let near = |x: &sites::Want| x.class == class && (x.ordered - range).abs() < range * 0.35 + 30.0;
match found.iter().copied().find(|&i| near(&plan.wants[i])) {
Some(i) => {
it.want = i;
moved += 1;
}
None => {
dropped += 1;
continue;
}
}
}
keep_items.push(it);
}
plan.items = keep_items;
if moved + dropped > 0 {
out.messages.push(format!(
"Нет пригодной земли для части площадок: {moved} целей ушли с другой стороны, {dropped} отменено"
));
}
plan.total -= dropped;
if plan.items.is_empty() {
out.messages.push("Налёт отменён: нет пригодной известной земли для пусковых площадок".into());
return false;
}
// The pads, and the sites themselves; the old raid's sites go unless they still have jobs to fly.
let queued: Vec<u32> = self.queue.iter().map(|q| q.1.id).collect();
self.sites.retire(&queued);
for w in plan.wants.iter_mut() {
w.jobs = 0;
}
for it in plan.items.iter_mut() {
it.pad = plan.wants[it.want].jobs;
plan.wants[it.want].jobs += 1;
}
let mut shortened = false;
for w in plan.wants.iter_mut().filter(|w| w.state == Search::Found && w.jobs > 0) {
let mut pos = w.found.unwrap_or_default();
if w.water {
pos.y += sites::DECK;
}
shortened |= w.range < w.ordered - if w.class == sites::SiteClass::Battery { 1.0 } else { 75.0 };
w.site = self.sites.add(sites::LaunchSite {
id: 0,
side: w.side as u8,
sector: ew::sector(w.bearing) as u8,
bearing: w.bearing,
class: w.class,
pos,
range: w.range,
water: w.water,
stock: w.jobs,
planned: w.jobs,
alive: true,
next_free: 0,
});
}
if shortened {
out.messages.push("Пуск ближе обычного: дальше нет известной земли или воды".into());
}
// Each drone onto its pad, climbing off the ramp or leaving the tube (ballistic ones aim as always).
for it in plan.items.iter_mut() {
let w = &plan.wants[it.want];
let Some(site) = self.sites.get(w.site) else { continue };
let from = site.muzzle(it.pad);
let spec = it.d.kind.spec();
it.from = from;
it.d.pos = from;
if spec.ballistic.is_some() {
let flat = horizontal(it.d.target - from);
let time = (flat.length() / spec.speed).max(30.0);
it.d.vel = flat / time + DVec3::Y * ((it.d.target.y - from.y + 0.5 * GRAVITY * time * time) / time);
} else {
let pitch = site.class.pitch();
let dir = horizontal(it.d.target - from).normalize_or(site.inward());
it.d.vel = (dir * pitch.cos() + DVec3::Y * pitch.sin()) * spec.speed;
}
it.d.sector = site.sector;
}
let n = self.sites.list.iter().filter(|s| plan.wants.iter().any(|w| w.site == s.id)).count();
let barges = self.sites.list.iter().filter(|s| s.water && plan.wants.iter().any(|w| w.site == s.id)).count();
out.messages.push(format!("Пусковые площадки противника: {n}{}", if barges > 0 { format!(" (на баржах: {barges})") } else { String::new() }));
// Where they are is the enemy's secret, unless the sandbox's true picture is on.
if self.truth {
for site in self.sites.list.iter().filter(|s| plan.wants.iter().any(|w| w.site == s.id)) {
out.messages.push(format!(
"Площадка #{} ({:?}, сторона {}): {:.0} {:.0} {:.0}, запас {}",
site.id, site.class, site.side, site.pos.x, site.pos.y, site.pos.z, site.stock
));
}
}
plan.sited = true;
true
}
/// When the raid is over: what it cost both sides.
@@ -632,6 +806,7 @@ fn route_length(from: DVec3, route: &[DVec3], to: DVec3) -> f64 {
#[cfg(test)]
mod tests {
use super::*;
use super::sites::testland::{Land, serve};
#[test]
fn a_smart_raid_plans_and_flies_in_time() {
@@ -641,8 +816,10 @@ mod tests {
sim.sams.push(Sam::new(id, kind, DVec3::new(-30.0 + 15.0 * i as f64, 70.0, 0.0), 0.0));
}
let mut ground = Ground::default();
let land = Land(|_, _| 64);
let mut out = Out::default();
for _ in 0..30 {
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
}
let t = std::time::Instant::now();
@@ -655,6 +832,7 @@ mod tests {
sim.order_raid(&order, &[DVec3::new(0.0, 70.0, -40.0)], &mut out);
let mut ticks = 0;
while sim.planning() {
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
ticks += 1;
}
@@ -663,6 +841,7 @@ mod tests {
for k in 0..4000 {
let s = std::time::Instant::now();
out = Out::default();
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
assert!(s.elapsed().as_millis() < 200, "tick {k} took {:?}", s.elapsed());
}
@@ -679,8 +858,10 @@ mod tests {
sim.sams.push(Sam::new(id, SamKind::Tor, DVec3::new(2.0 * i as f64, 70.0, 0.0), 0.0));
}
let mut ground = Ground::default();
let land = Land(|_, _| 64);
let mut out = Out::default();
for _ in 0..30 {
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
}
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, -400.0), DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
@@ -697,6 +878,7 @@ mod tests {
#[cfg(test)]
mod big {
use super::*;
use super::sites::testland::{Land, serve};
#[test]
fn sixty_one_systems_and_a_big_smart_raid() {
@@ -710,8 +892,10 @@ mod big {
sim.targets.push(DVec3::new(0.0, 70.0, 0.0));
sim.target_health.push(100.0);
let mut ground = Ground::default();
let land = Land(|_, _| 64);
let mut out = Out::default();
for _ in 0..200 {
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
}
let mut w = crate::wave::WaveConfig::default();
@@ -725,6 +909,7 @@ mod big {
let mut slowest = std::time::Duration::ZERO;
while sim.planning() {
let s = std::time::Instant::now();
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
slowest = slowest.max(s.elapsed());
ticks += 1;
@@ -734,6 +919,7 @@ mod big {
for _ in 0..3000 {
let s = std::time::Instant::now();
out = Out::default();
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
worst = worst.max(s.elapsed());
}
+3 -1
View File
@@ -1348,11 +1348,13 @@ fn raids_have_the_same_strike_accuracy_as_manual_launches() {
sim.intel.revealed.push((id, DVec3::new(0.0, 70.0, 900.0), 0));
let order = RaidOrder { kinds: vec![(DroneKind::Zircon, 20)], approach: Approach::Sector, dist: 400.0, aim: Aim::Objects };
sim.order_raid(&order, &[DVec3::new(0.0, 70.0, 0.0)], &mut Out::default());
// (The raid's launch sites need land the game has told of: a flat one is served.)
let (mut ground, land) = (Ground::default(), sites::testland::Land(|_, _| 64));
for _ in 0..40 {
if !sim.planning() {
break;
}
sim.step(&mut Ground::default(), None, &mut Out::default());
sites::testland::step(&mut sim, &mut ground, &land, &mut Out::default());
}
assert!(!sim.queue.is_empty() && sim.queue.iter().all(|(_, d)| d.err != DVec3::ZERO), "raid drones without a navigation error");
let mut d = sim.make_drone(DroneKind::Zircon, DVec3::new(0.0, 100.0, -500.0), DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
+832
View File
@@ -0,0 +1,832 @@
//! Launch sites (#100): where a raid really starts from. A raid picks its sides (one, two, or four to five),
//! looks for suitable ground on each side (only land the game has told of: flat, open to the sky, clear in
//! front, away from the defence), and sets up a site there: a ramp for the drones, a launcher vehicle for the
//! cruise missiles, a battery for the ballistic ones (at their own range). The raid's drones are jobs of the
//! sites: each one leaves its site, one at a time, from the stock the site holds. Ground nobody has told of is
//! never suitable, and nothing is force-loaded: a side without suitable ground waits for the game's answers
//! for a few seconds, and is then given up with a message.
use super::*;
/// The most candidate places tried for one site, and how long (ticks) a raid waits for the game's answers.
pub const MAX_TRIES: usize = 90;
pub const SEARCH_TICKS: u32 = 300;
/// Candidates asked about ahead of the one being waited for.
const PREFETCH: usize = 3;
/// The footprint's samples (offsets in blocks), the most the ground may differ over it, and how far from
/// the defence and the objects a site must be.
const FOOTPRINT: [(f64, f64); 9] = [(0.0, 0.0), (8.0, 0.0), (-8.0, 0.0), (0.0, 8.0), (0.0, -8.0), (6.0, 6.0), (6.0, -6.0), (-6.0, 6.0), (-6.0, -6.0)];
const FLAT: i32 = 2;
const KEEP_OUT: f64 = 45.0;
/// What a site is for.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SiteClass {
/// Drones: a catapult ramp.
Ramp,
/// Cruise and supersonic missiles: a launcher vehicle.
Launcher,
/// Ballistic missiles: a heavier launcher, far out.
Battery,
}
impl SiteClass {
pub fn of(kind: DroneKind) -> SiteClass {
let spec = kind.spec();
if spec.ballistic.is_some() {
SiteClass::Battery
} else if matches!(spec.class, Class::Drone | Class::Jet) {
SiteClass::Ramp
} else {
SiteClass::Launcher
}
}
/// Whether a site of this class can launch this kind.
pub fn supports(self, kind: DroneKind) -> bool {
SiteClass::of(kind) == self
}
/// Ticks between two launches from one site.
pub fn cadence(self) -> u64 {
match self {
SiteClass::Ramp => 3,
SiteClass::Launcher => 8,
SiteClass::Battery => 20,
}
}
fn slots(self) -> u32 {
match self {
SiteClass::Ramp => 4,
SiteClass::Launcher => 3,
SiteClass::Battery => 2,
}
}
fn spacing(self) -> f64 {
match self {
SiteClass::Ramp => 4.0,
SiteClass::Launcher => 9.0,
SiteClass::Battery => 12.0,
}
}
fn model(self) -> &'static str {
match self {
SiteClass::Ramp => "airdef:launch_ramp",
SiteClass::Launcher | SiteClass::Battery => "airdef:launcher_truck",
}
}
fn scale(self) -> f64 {
match self {
SiteClass::Ramp => 1.4,
SiteClass::Launcher => 1.5,
SiteClass::Battery => 1.9,
}
}
/// The barge's scale: its deck is 3 blocks a side at 1.
fn barge(self) -> f64 {
match self {
SiteClass::Ramp => 4.5,
_ => 7.0,
}
}
/// Where a drone leaves the kit: blocks ahead of its centre, and up from the ground.
fn muzzle(self) -> (f64, f64) {
match self {
SiteClass::Ramp => (1.8, 1.7),
SiteClass::Launcher => (2.6, 3.4),
SiteClass::Battery => (3.2, 4.2),
}
}
/// How steeply a drone leaves it (radians).
pub fn pitch(self) -> f64 {
match self {
SiteClass::Ramp => 0.35,
SiteClass::Launcher => 0.6,
SiteClass::Battery => 0.9,
}
}
}
/// One launch site of a raid: stable id, side, where, what it can launch, what is left, and when it may launch next.
#[derive(Clone, Debug)]
pub struct LaunchSite {
pub id: u32,
/// Which side of the raid it is on (0-4) and the enemy's sector of it.
pub side: u8,
pub sector: u8,
pub bearing: f64,
pub class: SiteClass,
/// The ground at its centre (the top of the surface), and how far from the raid's centre it is.
pub pos: DVec3,
pub range: f64,
/// On a barge over open water (its `pos` is the water's surface plus the deck).
pub water: bool,
/// Rounds left, and the jobs given to it (also how many pads are drawn).
pub stock: u32,
pub planned: u32,
pub alive: bool,
pub next_free: u64,
}
impl LaunchSite {
/// The direction a launch goes: from the site in, towards the raid's centre.
pub fn inward(&self) -> DVec3 {
DVec3::new(-self.bearing.sin(), 0.0, -self.bearing.cos())
}
fn lateral(&self) -> DVec3 {
DVec3::new(self.bearing.cos(), 0.0, -self.bearing.sin())
}
/// The base of pad `i` on the ground.
pub fn pad(&self, i: u32) -> DVec3 {
let n = self.class.slots();
self.pos + self.lateral() * ((f64::from(i % n) - f64::from(n - 1) / 2.0) * self.class.spacing())
}
/// Where a drone of pad `i` leaves the kit.
pub fn muzzle(&self, i: u32) -> DVec3 {
let (ahead, up) = self.class.muzzle();
self.pad(i) + self.inward() * ahead + DVec3::Y * up
}
pub fn supports(&self, kind: DroneKind) -> bool {
self.class.supports(kind)
}
}
/// The sites in play and the launch jobs given to them: (drone id, site id) for each drone still in the queue.
#[derive(Default)]
pub struct Sites {
pub list: Vec<LaunchSite>,
next: u32,
jobs: Vec<(u32, u32)>,
}
/// What a site says about a drone due to leave.
pub(super) enum Slot {
/// Not a site's job (a manual launch, an old save): off it goes.
Free,
Go,
/// The site launched a moment ago.
Busy,
/// The site is gone or empty: the job is cancelled.
Gone,
}
impl Sites {
pub fn get(&self, id: u32) -> Option<&LaunchSite> {
self.list.iter().find(|s| s.id == id)
}
/// Jobs waiting for a site.
pub fn pending(&self, id: u32) -> usize {
self.jobs.iter().filter(|j| j.1 == id).count()
}
/// The site a queued drone is a job of.
pub fn site_of(&self, drone: u32) -> Option<u32> {
self.jobs.iter().find(|j| j.0 == drone).map(|j| j.1)
}
pub fn clear(&mut self) {
self.list.clear();
self.jobs.clear();
}
pub(super) fn add(&mut self, mut site: LaunchSite) -> u32 {
self.next += 1;
site.id = self.next;
let id = site.id;
self.list.push(site);
id
}
pub(super) fn job(&mut self, drone: u32, site: u32) {
self.jobs.push((drone, site));
}
/// The old raid's sites go, but not those with jobs still to fly.
pub(super) fn retire(&mut self, queued: &[u32]) {
self.jobs.retain(|j| queued.contains(&j.0));
let jobs = &self.jobs;
self.list.retain(|s| jobs.iter().any(|j| j.1 == s.id));
}
fn take(&mut self, drone: u32, now: u64) -> Slot {
let Some(j) = self.jobs.iter().position(|j| j.0 == drone) else { return Slot::Free };
let site = self.jobs[j].1;
let Some(s) = self.list.iter_mut().find(|s| s.id == site) else {
self.jobs.swap_remove(j);
return Slot::Gone;
};
if !s.alive || s.stock == 0 {
self.jobs.swap_remove(j);
return Slot::Gone;
}
if s.next_free > now {
return Slot::Busy;
}
s.stock -= 1;
s.next_free = now + s.class.cadence();
self.jobs.swap_remove(j);
Slot::Go
}
}
// ---------------------------------------------------------------- choosing sides and places
/// The sides of a raid as bearings: one, two far apart (108° or more), or four to five spread round (at least
/// 43° apart). The seed decides which.
pub fn pick_sides(rng: &mut Rng, approach: Approach, bearing: f64) -> Vec<f64> {
match approach {
Approach::Sector | Approach::Waves => vec![bearing],
Approach::TwoSectors => vec![bearing, bearing + std::f64::consts::PI * (0.6 + rng.f() * 0.6)],
Approach::AllSides => {
let n = if rng.chance(0.5) { 4 } else { 5 };
let step = TAU / f64::from(n);
(0..n).map(|k| bearing + f64::from(k) * step + rng.s() * 0.2 * step).collect()
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Search {
Open,
Found,
Failed,
}
/// A site wanted by a raid: its side and class, the places to try in order, and how the search stands.
pub(super) struct Want {
pub side: usize,
pub bearing: f64,
pub class: SiteClass,
pub range: f64,
/// The range ordered (`range` is where the site stood in the end).
pub ordered: f64,
cands: Vec<(DVec3, bool)>,
cursor: usize,
pub found: Option<DVec3>,
pub water: bool,
pub state: Search,
pub jobs: u32,
pub site: u32,
}
/// The want of this side, class and range, made if there is none yet.
pub(super) fn want_index(wants: &mut Vec<Want>, centre: DVec3, side: usize, bearing: f64, class: SiteClass, range: f64, lo: f64) -> usize {
if let Some(i) = wants.iter().position(|w| w.side == side && w.class == class && (w.range - range).abs() < 1.0) {
return i;
}
let out = DVec3::new(bearing.sin(), 0.0, bearing.cos());
let lat = DVec3::new(bearing.cos(), 0.0, -bearing.sin());
// In order: known land at the ordered range (round it for most, the ordered range for a ballistic battery),
// known land nearer in steps, then a barge on open water at the ordered range, then on water nearer.
let near = |from: f64| -> Vec<f64> {
let min = if class == SiteClass::Battery { lo.min(from).max(80.0) } else { (from * 0.3).max(80.0) };
(1..=6).map(|i| from - (from - min) * f64::from(i) / 6.0).collect()
};
let round: Vec<f64> = if class == SiteClass::Battery {
vec![range]
} else {
[0.0, 24.0, -24.0, 48.0, -48.0, 72.0, -72.0].iter().map(|d| (range + d).max(KEEP_OUT)).collect()
};
let laterals: &[f64] = &[0.0, 20.0, -20.0];
let mut cands = Vec::new();
for water in [false, true] {
for r in round.iter().copied().chain(near(range)) {
for l in laterals {
cands.push((centre + out * r + lat * *l, water));
}
}
}
cands.truncate(MAX_TRIES);
wants.push(Want { side, bearing, class, range, ordered: range, cands, cursor: 0, found: None, water: false, state: Search::Open, jobs: 0, site: 0 });
wants.len() - 1
}
enum Verdict {
/// The game hasn't told enough yet.
Pending,
Bad,
/// Suitable: the surface at its centre, and whether it is water's.
Good(DVec3, bool),
}
/// Whether a site fits at `p` (x, z): every sample of its footprint is land the game told of and within
/// `FLAT` blocks of the others, the sky above is open, the way ahead is clear, and nothing of the defence is near.
fn judge(ground: &mut Ground, p: DVec3, inward: DVec3, keep: &[DVec3], now: u64) -> Verdict {
if p.x.abs() > 29_000_000.0 || p.z.abs() > 29_000_000.0 || keep.iter().any(|k| horizontal(*k - p).length() < KEEP_OUT) {
return Verdict::Bad;
}
let (mut lo, mut hi, mut pending) = (i32::MAX, i32::MIN, false);
for (dx, dz) in FOOTPRINT {
match ground.column((p.x + dx).floor() as i32, (p.z + dz).floor() as i32, 256) {
None => pending = true,
// No land to tell of (the sea, the void): never a site.
Some(None) => return Verdict::Bad,
Some(Some(h)) => {
lo = lo.min(h as i32);
hi = hi.max(h as i32);
}
}
}
if pending {
return Verdict::Pending;
}
if hi - lo > FLAT {
return Verdict::Bad;
}
let top = f64::from(hi);
let at = DVec3::new(p.x.floor() + 0.5, top, p.z.floor() + 0.5);
// The game's height is the top of what blocks motion *or holds a fluid*: the block under the surface is
// solid at the footprint's centre and edges (land), or at none of them (water); a shore is neither.
let mut solid = 0;
for (dx, dz) in FOOTPRINT.iter().take(5) {
let c = DVec3::new((p.x + dx).floor() + 0.5, top, (p.z + dz).floor() + 0.5);
match ground.world.sweep(c + DVec3::Y * 0.5, c - DVec3::Y * 0.5, false, now) {
world::Sweep::Hit { .. } => solid += 1,
world::Sweep::Clear => {}
world::Sweep::Unknown { pending: true, .. } | world::Sweep::Wait { .. } => return Verdict::Pending,
world::Sweep::Unknown { .. } => return Verdict::Bad,
}
}
if solid != 0 && solid != 5 {
return Verdict::Bad;
}
let up = ground.world.sweep(at + DVec3::Y * 30.0, at + DVec3::Y * 1.0, false, now);
let ahead = ground.world.sweep(at + DVec3::Y * 3.0, at + inward * 14.0 + DVec3::Y * 3.0, false, now);
for s in [up, ahead] {
match s {
world::Sweep::Clear => {}
world::Sweep::Unknown { pending: true, .. } | world::Sweep::Wait { .. } => return Verdict::Pending,
world::Sweep::Unknown { .. } | world::Sweep::Hit { .. } => return Verdict::Bad,
}
}
Verdict::Good(at, solid == 0)
}
/// Works on the open wants: each tries its places in order, a place the game hasn't told enough of holds the
/// search (the next few are asked about meanwhile), and a bad place is passed over. After `last` nothing waits.
/// Returns whether every want is settled.
pub(super) fn search(wants: &mut [Want], ground: &mut Ground, centre: DVec3, keep: &[DVec3], now: u64, last: bool) -> bool {
let mut all = true;
for k in 0..wants.len() {
if wants[k].state != Search::Open {
continue;
}
// Two sites never share a place: the others' kit is in the way.
let mut keep: Vec<DVec3> = keep.to_vec();
keep.extend(wants.iter().filter_map(|o| o.found));
let w = &mut wants[k];
let inward = DVec3::new(-w.bearing.sin(), 0.0, -w.bearing.cos());
loop {
if w.cursor >= w.cands.len() {
w.state = Search::Failed;
break;
}
let (cand, water) = w.cands[w.cursor];
match judge(ground, cand, inward, &keep, now) {
Verdict::Bad => w.cursor += 1,
// Land when land was asked for, a barge when water was.
Verdict::Good(_, got) if got != water => w.cursor += 1,
Verdict::Good(at, _) => {
w.found = Some(at);
w.water = water;
w.range = horizontal(at - centre).length();
w.state = Search::Found;
break;
}
Verdict::Pending => {
for k in 1..=PREFETCH {
if let Some(&(c, _)) = w.cands.get(w.cursor + k) {
let _ = judge(ground, c, inward, &keep, now);
}
}
if last {
w.state = Search::Failed;
} else {
all = false;
}
break;
}
}
}
}
all
}
// ---------------------------------------------------------------- in the sim
/// Id base of the kit's models.
const KIT: u32 = 0x7400_0000;
/// The barge's deck over the water.
pub const DECK: f64 = 0.3;
impl Sim {
/// Takes site `id` out of play (destroyed): its stock is gone and its queued jobs are cancelled. Returns how
/// many. (The counter-strike, #101, uses this.)
pub fn destroy_site(&mut self, id: u32) -> u32 {
let Some(s) = self.sites.list.iter_mut().find(|s| s.id == id) 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();
self.queue.retain(|q| !ids.contains(&q.1.id));
self.sites.jobs.retain(|j| j.1 != id);
ids.len() as u32
}
/// Asks a site whether a queued drone may leave now.
pub(super) fn site_slot(&mut self, drone: u32) -> Slot {
let now = self.tick;
self.sites.take(drone, now)
}
/// Draws every site's kit: a ramp or launcher per pad in use, turned to the way the launches go.
pub(super) fn draw_sites(&self, out: &mut Out) {
for s in self.sites.list.iter().filter(|s| s.alive) {
let rot = DQuat::from_rotation_y(heading_of(s.inward()));
let sc = s.class.scale();
if s.water {
let b = s.class.barge();
out.model(KIT + (s.id * 8 + 7) * PARTS, "airdef:barge", s.pos - DVec3::Y * (DECK + 0.8 - 0.5 * b), rot, b, false);
}
for i in 0..s.planned.min(s.class.slots()) {
let id = KIT + (s.id * 8 + i) * PARTS;
out.model(id, s.class.model(), s.pad(i) + DVec3::Y * 0.5 * sc, rot, sc, false);
}
}
}
}
#[cfg(test)]
pub(super) mod testland {
use super::*;
/// Land from a height function; blocks below it are solid, and the game's height map says the same.
pub struct Land<F: Fn(i32, i32) -> i32>(pub F);
impl<F: Fn(i32, i32) -> i32> world::WorldSource for Land<F> {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
Some(y < (self.0)(x, z))
}
}
/// What the game answers a tick after the sim asked: heights, and the sections.
pub fn serve<F: Fn(i32, i32) -> i32>(ground: &mut Ground, land: &Land<F>, now: u64) {
let columns = ground.take_wanted().into_iter().map(|(x, z)| (x, z, (land.0)(x, z))).collect();
ground.answer(columns, now);
let sections = ground.world.take_wanted(now);
ground.world.serve(land, sections, now);
}
pub fn step<F: Fn(i32, i32) -> i32>(sim: &mut Sim, ground: &mut Ground, land: &Land<F>, out: &mut Out) {
serve(ground, land, sim.tick);
sim.step(ground, None, out);
}
}
#[cfg(test)]
mod tests {
use super::testland::*;
use super::*;
fn sep(a: f64, b: f64) -> f64 {
angle_diff(a, b).abs().to_degrees()
}
fn min_sep(sides: &[f64]) -> f64 {
let mut m = 360.0f64;
for i in 0..sides.len() {
for j in 0..i {
m = m.min(sep(sides[i], sides[j]));
}
}
m
}
#[test]
fn sector_modes_give_one_two_or_four_to_five_sides_apart() {
let mut fours = 0;
let mut fives = 0;
for seed in 1..200u64 {
for approach in Approach::ALL {
let mut rng = Rng::new(seed);
let b = rng.f() * TAU;
let sides = pick_sides(&mut rng, approach, b);
match approach {
Approach::Sector | Approach::Waves => assert_eq!(sides.len(), 1),
Approach::TwoSectors => {
assert_eq!(sides.len(), 2);
assert!(min_sep(&sides) >= 100.0, "two sectors only {:.0} deg apart", min_sep(&sides));
}
Approach::AllSides => {
assert!((4..=5).contains(&sides.len()), "{} sides", sides.len());
assert!(min_sep(&sides) >= 40.0, "all sides: two only {:.0} deg apart", min_sep(&sides));
if sides.len() == 4 {
fours += 1;
} else {
fives += 1;
}
}
}
}
}
assert!(fours > 20 && fives > 20, "the seed should pick both 4 and 5: {fours} / {fives}");
}
fn flat() -> Land<impl Fn(i32, i32) -> i32> {
Land(|_, _| 64)
}
fn order(approach: Approach, dist: f64) -> RaidOrder {
RaidOrder {
kinds: vec![(DroneKind::Shahed, 6), (DroneKind::Gerbera, 3), (DroneKind::Kh101, 3), (DroneKind::Iskander, 2)],
approach,
dist,
aim: Aim::Objects,
}
}
/// Orders a raid over `land` and plans it to the end; returns the ticks it took.
fn plan<F: Fn(i32, i32) -> i32>(sim: &mut Sim, ground: &mut Ground, land: &Land<F>, o: &RaidOrder, out: &mut Out) -> u32 {
sim.order_raid(o, &[DVec3::new(0.0, 70.0, 0.0)], out);
let mut n = 0;
while sim.planning() {
step(sim, ground, land, out);
n += 1;
assert!(n < 1000, "the raid never finished planning");
}
n
}
#[test]
fn sites_per_sector_mode_launch_every_drone_from_a_site_on_a_side() {
for (approach, sides) in [(Approach::Sector, 1usize), (Approach::TwoSectors, 2), (Approach::AllSides, 5), (Approach::Waves, 1)] {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(21);
plan(&mut sim, &mut ground, &land, &order(approach, 300.0), &mut out);
let mut sides_used: Vec<u8> = sim.sites.list.iter().map(|s| s.side).collect();
sides_used.sort_unstable();
sides_used.dedup();
match approach {
Approach::AllSides => assert!((4..=5).contains(&sides_used.len()), "{} sides", sides_used.len()),
_ => assert_eq!(sides_used.len(), sides, "{approach:?}"),
}
// No hidden extra position: at most a site per class on a side (ramp, launcher, one battery per range).
assert!(sim.sites.list.len() <= sides_used.len() * 3, "{} sites", sim.sites.list.len());
// Every drone has a site that supports its kind.
assert_eq!(sim.queue.len() + sim.drones.len(), 14, "{:?}", out.messages);
for (_, d) in &sim.queue {
let site = sim.sites.site_of(d.id).and_then(|id| sim.sites.get(id)).expect("a drone without a source site");
assert!(site.supports(d.kind), "{:?} from a {:?}", d.kind, site.class);
// It waits on the site's pad, over its ground.
assert!(horizontal(d.pos - site.pos).length() < 25.0, "{:?} is {:.0} away from its site", d.kind, horizontal(d.pos - site.pos).length());
assert!(d.pos.y > site.pos.y && d.pos.y < site.pos.y + 6.0);
}
for (i, a) in sim.sites.list.iter().enumerate() {
for b in &sim.sites.list[..i] {
assert!(horizontal(a.pos - b.pos).length() >= KEEP_OUT, "two sites on one place");
}
}
// Stock and jobs agree; ground is real: flat at 64.
for s in &sim.sites.list {
assert_eq!(s.stock as usize, sim.sites.pending(s.id));
assert_eq!(s.pos.y, 64.0);
}
}
}
#[test]
fn ballistic_sites_keep_their_own_range_and_ramps_the_ordered_one() {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(4);
plan(&mut sim, &mut ground, &land, &order(Approach::Sector, 300.0), &mut out);
let centre = DVec3::new(0.0, 70.0, 0.0);
for s in &sim.sites.list {
let r = horizontal(s.pos - centre).length();
match s.class {
SiteClass::Battery => assert!(r > 990.0, "a battery only {r:.0} out"),
_ => assert!((200.0..=400.0).contains(&r), "{:?} {r:.0} out", s.class),
}
}
}
#[test]
fn launches_start_at_the_site_with_a_real_climb_and_in_turn() {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(9);
plan(&mut sim, &mut ground, &land, &order(Approach::TwoSectors, 300.0), &mut out);
let jobs: HashMap<u32, u32> = sim.queue.iter().map(|(_, d)| (d.id, sim.sites.site_of(d.id).unwrap())).collect();
// One or two may have left in the tick the plan was ready.
let early = sim.drones.len();
let stock: u32 = sim.sites.list.iter().map(|s| s.stock).sum();
assert_eq!(stock as usize + early, 14);
let mut seen = HashMap::new();
let mut last_launch: HashMap<u32, u64> = HashMap::new();
for _ in 0..4000 {
step(&mut sim, &mut ground, &land, &mut out);
for d in sim.drones.iter().filter(|d| d.age == 1) {
let site = sim.sites.get(jobs[&d.id]).unwrap().clone();
// Its first tick is at the kit, not in the air somewhere.
assert!(horizontal(d.pos - site.pos).length() < 25.0, "{:?} first seen {:.0} from its site", d.kind, horizontal(d.pos - site.pos).length());
assert!(d.pos.y - site.pos.y < 12.0, "{:?} first seen {:.1} above the ground", d.kind, d.pos.y - site.pos.y);
if d.kind.spec().ballistic.is_none() {
assert!(d.vel.y > 0.0, "{:?} leaves level, not climbing", d.kind);
}
// One launch at a time from a site.
if let Some(t) = last_launch.insert(site.id, sim.tick) {
assert!(sim.tick - t >= site.class.cadence(), "two launches {} ticks apart", sim.tick - t);
}
seen.insert(d.id, d.pos.y);
}
}
assert_eq!(seen.len() + early, 14, "not every drone left a site");
assert_eq!(sim.sites.list.iter().map(|s| s.stock).sum::<u32>(), 0, "stock left after the raid");
// The climb is real: an early drone is higher a second later, and clear of the ground.
assert!(sim.queue.is_empty());
}
#[test]
fn a_site_launches_only_what_it_holds() {
let mut sim = Sim::new(2);
let mut ground = Ground::default();
let mut out = Out::default();
let id = sim.sites.add(LaunchSite {
id: 0,
side: 0,
sector: 0,
bearing: 0.0,
class: SiteClass::Ramp,
pos: DVec3::new(0.0, 64.0, 300.0),
range: 300.0,
water: false,
stock: 3,
planned: 5,
alive: true,
next_free: 0,
});
for i in 0..5 {
let site = sim.sites.get(id).unwrap().clone();
let d = sim.make_drone(DroneKind::Shahed, site.muzzle(i), -DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
sim.sites.job(d.id, id);
sim.queue.push((1, d));
}
let mut times = Vec::new();
for _ in 0..80 {
let before = sim.launched;
sim.step(&mut ground, None, &mut out);
if sim.launched > before {
times.push(sim.tick);
}
}
assert_eq!(sim.launched, 3, "launched {:?}", times);
assert!(sim.queue.is_empty(), "the unlaunchable jobs stay in the queue");
assert_eq!(sim.sites.get(id).unwrap().stock, 0);
assert!(times.windows(2).all(|w| w[1] - w[0] >= SiteClass::Ramp.cadence()), "{times:?}");
}
#[test]
fn a_destroyed_site_cancels_its_future_jobs() {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(5);
plan(&mut sim, &mut ground, &land, &order(Approach::Sector, 300.0), &mut out);
let ramp = sim.sites.list.iter().find(|s| s.class == SiteClass::Ramp).unwrap().id;
let early = sim.launched as usize;
let n = sim.sites.pending(ramp);
assert!(n >= 8 && n <= 9, "{n}");
assert_eq!(sim.destroy_site(ramp) as usize, n);
assert_eq!(sim.queue.len() + early + n, 14);
for _ in 0..3000 {
step(&mut sim, &mut ground, &land, &mut out);
}
assert_eq!(sim.launched as usize + n, 14);
}
#[test]
fn no_suitable_ground_means_a_clear_refusal() {
// Never answered: unknown ground is not suitable, and nothing is launched.
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);
let mut n = 0;
while sim.planning() {
sim.step(&mut ground, None, &mut out);
n += 1;
assert!(n <= SEARCH_TICKS + 5, "a raid waits for ever");
}
assert!(sim.queue.is_empty() && sim.sites.list.is_empty());
assert!(out.messages.iter().any(|m| m.contains("площад")), "{:?}", out.messages);
assert!(!sim.drones.iter().any(|d| d.alive()));
// A cliff face everywhere (too steep to stand on) is refused as well, and a sea (no land) too.
for steep in [true, false] {
let land = Land(move |x, _| if steep { x * 4 } else { -100 });
let (mut ground, mut out) = (Ground::default(), Out::default());
let mut sim = Sim::new(3);
plan(&mut sim, &mut ground, &land, &order(Approach::Sector, 300.0), &mut out);
assert!(sim.queue.is_empty() && sim.sites.list.is_empty(), "steep {steep}");
assert!(out.messages.iter().any(|m| m.contains("площад")));
}
}
/// Land inside a radius of the origin (surface 64), open water outside it (surface 63 over a floor at 50):
/// the game's height over water is the surface, and the blocks under it are not solid.
struct Island(f64);
impl Island {
fn land(&self, x: i32, z: i32) -> bool {
f64::from(x).hypot(f64::from(z)) < self.0
}
}
impl world::WorldSource for Island {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
Some(y < if self.land(x, z) { 64 } else { 50 })
}
}
fn run_island(radius: f64, seed: u64, o: &RaidOrder) -> (Sim, Out) {
let (mut ground, mut out, island) = (Ground::default(), Out::default(), Island(radius));
let mut sim = Sim::new(seed);
sim.order_raid(o, &[DVec3::new(0.0, 70.0, 0.0)], &mut out);
for _ in 0..SEARCH_TICKS + 5 {
let cols = ground.take_wanted().into_iter().map(|(x, z)| (x, z, if island.land(x, z) { 64 } else { 63 })).collect();
ground.answer(cols, sim.tick);
let secs = ground.world.take_wanted(sim.tick);
ground.world.serve(&island, secs, sim.tick);
sim.step(&mut ground, None, &mut out);
}
for _ in 0..3000 {
let cols = ground.take_wanted().into_iter().map(|(x, z)| (x, z, if island.land(x, z) { 64 } else { 63 })).collect();
ground.answer(cols, sim.tick);
let secs = ground.world.take_wanted(sim.tick);
ground.world.serve(&island, secs, sim.tick);
sim.step(&mut ground, None, &mut out);
}
(sim, out)
}
#[test]
fn an_all_water_map_gives_barges_and_every_drone_launches() {
let o = RaidOrder { kinds: vec![(DroneKind::Shahed, 6), (DroneKind::Kh101, 3)], approach: Approach::TwoSectors, dist: 200.0, aim: Aim::Objects };
// Water from the first block: nothing but the sea round the raid (its centre is the player's slab, not read).
let (sim, out) = run_island(0.0, 6, &o);
assert!(!sim.sites.list.is_empty(), "no barge: {:?}", out.messages);
assert!(sim.sites.list.iter().all(|s| s.water && s.pos.y > 63.0 && s.pos.y < 64.0), "a site that is not on the surface");
assert_eq!(sim.launched, 9, "{:?}", out.messages);
let mut o2 = Out::default();
sim.draw_sites(&mut o2);
assert!(o2.models.iter().any(|m| m.name == "airdef:barge"));
}
#[test]
fn land_is_preferred_over_water_when_both_are_known() {
// An island of radius 260 in the sea; the raid is ordered 300 out: its sites stand on the island, nearer.
let o = RaidOrder { kinds: vec![(DroneKind::Shahed, 6), (DroneKind::Kh101, 3)], approach: Approach::TwoSectors, dist: 300.0, aim: Aim::Objects };
let (sim, out) = run_island(260.0, 6, &o);
assert!(!sim.sites.list.is_empty());
assert!(sim.sites.list.iter().all(|s| !s.water), "a barge where land was known: {:?} {:?}", out.messages, sim.sites.list.iter().map(|s| (s.class, s.water, s.pos, s.range)).collect::<Vec<_>>());
assert_eq!(sim.launched, 9);
}
#[test]
fn a_site_never_stands_in_a_hill_or_under_a_roof() {
// A hill 40 blocks high round the raid's ring at 300 out (radius 100): the sites find flat land elsewhere.
let hill = |x: i32, z: i32| {
let (dx, dz) = (f64::from(x) - 0.0, f64::from(z) - 300.0);
if dx * dx + dz * dz < 100.0 * 100.0 { 64 + 40 } else { 64 }
};
let land = Land(hill);
let (mut ground, mut out) = (Ground::default(), Out::default());
let mut sim = Sim::new(8);
let o = RaidOrder { kinds: vec![(DroneKind::Shahed, 5)], approach: Approach::Sector, dist: 300.0, aim: Aim::Objects };
plan(&mut sim, &mut ground, &land, &o, &mut out);
for s in &sim.sites.list {
for (dx, dz) in FOOTPRINT {
let h = hill((s.pos.x + dx).floor() as i32, (s.pos.z + dz).floor() as i32);
assert!((f64::from(h) - s.pos.y).abs() <= f64::from(FLAT), "a site over a slope");
}
}
assert!(!sim.sites.list.is_empty());
}
#[test]
fn repeated_raids_do_not_pile_up_sites_and_clear_removes_them() {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(12);
plan(&mut sim, &mut ground, &land, &order(Approach::AllSides, 300.0), &mut out);
let first: Vec<u32> = sim.sites.list.iter().map(|s| s.id).collect();
for _ in 0..3000 {
step(&mut sim, &mut ground, &land, &mut out);
}
plan(&mut sim, &mut ground, &land, &order(Approach::TwoSectors, 300.0), &mut out);
assert!(sim.sites.list.iter().all(|s| !first.contains(&s.id)), "old sites stayed");
assert!(sim.sites.list.len() <= 6);
sim.clear();
assert!(sim.sites.list.is_empty() && sim.queue.is_empty());
let mut o = Out::default();
sim.draw_sites(&mut o);
assert!(o.models.is_empty());
}
#[test]
fn the_same_seed_picks_the_same_sites_and_graphics_do_not_move_them() {
let run = |draw: bool| {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(33);
plan(&mut sim, &mut ground, &land, &order(Approach::AllSides, 300.0), &mut out);
if draw {
// Rendering must not spend the combat randomness.
let mut o = Out::default();
sim.draw_sites(&mut o);
assert!(!o.models.is_empty());
}
(sim.sites.list.iter().map(|s| (s.id, s.pos.x.to_bits(), s.pos.z.to_bits(), s.stock)).collect::<Vec<_>>(), sim.queue.iter().map(|q| q.0).collect::<Vec<_>>(), sim.rng.state())
};
assert_eq!(run(false), run(true));
}
}
@@ -1,7 +1,7 @@
From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 12:37:45 +0300
Subject: [PATCH 01/35] Mod API (AirDef fork): world models, client particles,
Subject: [PATCH 01/36] 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/35] cargo-horizon-mod: report particles and sounds
Subject: [PATCH 02/36] 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/35] Mod API (AirDef fork): full-screen post pass for mods
Subject: [PATCH 03/36] 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/35] Mod models: draw far models as scaled impostors past
Subject: [PATCH 04/36] 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/35] Mod cameras: render.set-views draws the world from
Subject: [PATCH 05/36] 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/35] Mods: more fuel per tick and per event (AirDef plans
Subject: [PATCH 06/36] 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/35] Mod cameras up to 1920x1080 (full-screen replays)
Subject: [PATCH 07/36] 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/35] Mod cameras: distant terrain tiles, fog measured from
Subject: [PATCH 08/36] 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/35] Mods: game.world-id, so mods keep state per world (API
Subject: [PATCH 09/36] 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/35] cargo-horizon-mod: trial runs behind feature full, so a
Subject: [PATCH 10/36] 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/35] HD lighting: G-buffer pass, per-pixel mod point lights
Subject: [PATCH 11/36] 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/35] HD lighting stage 2: sun and moon shadow cascades, the
Subject: [PATCH 12/36] 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/35] HD: bind groups cached until a resize or a new source,
Subject: [PATCH 13/36] 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/35] HD: lights culled per 16x16 tile (surface and halo
Subject: [PATCH 14/36] 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/35] HD: GPU time per pass in the F3 timing list,
Subject: [PATCH 15/36] 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/35] HD: shadows draw only sun-facing quads and skip buried
Subject: [PATCH 16/36] 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/35] Mods: model handles (render.model-id, model-generation,
Subject: [PATCH 17/36] 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/35] HD: temporal upscaling (HORIZON_HD_SCALE: world,
Subject: [PATCH 18/36] 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/35] HD water: its own pass (colour, normal, depth), the
Subject: [PATCH 19/36] 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/35] HD water: no vanilla texture on the surface (the biome
Subject: [PATCH 20/36] 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/35] HD sky and light: a physical atmosphere (Rayleigh, Mie,
Subject: [PATCH 21/36] 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/35] HD water: a darker blue, a little clearer, ripples a
Subject: [PATCH 22/36] 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/35] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
Subject: [PATCH 23/36] 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/35] Mods: world queries for simulations (render.query-rays,
Subject: [PATCH 24/36] 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/35] HD fixes: rain level from the world not from rain
Subject: [PATCH 25/36] 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/35] HD split into modules (gfx/hd/: one Rust file and one
Subject: [PATCH 26/36] 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/35] HD clouds: noise scales that divide the camera's
Subject: [PATCH 27/36] 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/35] HD: volumetric smoke and missile trails with clear
Subject: [PATCH 28/36] 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/35] HD clouds composited by depth so terrain and vehicles
Subject: [PATCH 29/36] 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/35] HD smoke: young trails drawn at least as wide as a grid
Subject: [PATCH 30/36] 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/35] Mods: render.set-main-view (API level 15) renders the
Subject: [PATCH 31/36] 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/35] Spot lights for mods (render.set-spot-lights, API level
Subject: [PATCH 32/36] 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/35] HD clouds and fog polish: cloud panorama, cirrus,
Subject: [PATCH 33/36] 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/35] HD: coloured sun through glass and leaves, rain ripples
Subject: [PATCH 34/36] 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/35] Film export: every frame in flight reaches the writer,
Subject: [PATCH 35/36] Film export: every frame in flight reaches the writer,
listener follows the main view, cloud wind on film frames, set-smoke reports
real activity
File diff suppressed because it is too large Load Diff
+33
View File
@@ -1193,6 +1193,36 @@ def ew_truck():
return p
def launch_ramp():
"""Рампа-катапульта для дронов: основание, наклонные рельсы (нос на север поднят), опоры и ложе."""
p = [el((1, 0, -6), (15, 2, 30), "dark_metal"), el((6, 2, 0), (10, 4, 26), "metal")]
p += both(el((3, 9, -14), (5, 11, 30), "metal", rot=("x", 22.5)))
p += both(el((3, 0, -1), (5, 12, 1), "dark_metal")) + both(el((3, 0, 17), (5, 6, 19), "dark_metal"))
p += [el((4, 10, 24), (12, 14, 30), "dark_metal"), el((1, 2, 28), (15, 6, 32), "ru_khaki")]
return p
def launcher_truck():
"""Пусковая машина ракет: шасси МАЗ и наклонный пакет труб, смотрящий вперёд (на север) вверх."""
p = maz_chassis("ru_khaki")
for x in (4, 10):
p.append(el((x, 15, -4), (x + 5, 20, 30), "tube", {"north": "tube_cap", "south": "tube_cap"}, rot=("x", 45)))
p += [el((3, 13, 22), (13, 17, 28), "dark_metal")]
return p
def barge():
"""Баржа под пусковой комплект: плоская палуба 3×3 блока, корпус, борта, кнехты и скошенный нос."""
p = [el((-16, 0, -16), (32, 3, 32), "dark_metal"),
el((-16, 3, -16), (32, 4, 32), "metal"),
el((-15, 3, -15), (31, 4.4, 31), "ru_khaki")]
p += both(el((-16, 4, -16), (-14, 6, 32), "dark_metal"))
p += [el((-16, 4, 30), (32, 6, 32), "dark_metal"), el((-16, 4, -16), (32, 6, -14), "dark_metal")]
for z in (-10, 8, 26):
p += both(el((-12, 4.4, z), (-9, 6.5, z + 3), "metal"))
return p
MODELS = {
"shahed": (shahed, "Шахед-136"),
"shahed_prop": (shahed_prop, "Винт"),
@@ -1251,6 +1281,9 @@ MODELS = {
"radar_truck": (radar_truck, "Шасси РЛС"),
"depot": (depot, "Склад боеприпасов"),
"tzm_truck": (tzm_truck, "Транспортно-заряжающая машина"),
"barge": (barge, "Баржа"),
"launch_ramp": (launch_ramp, "Рампа запуска дронов"),
"launcher_truck": (launcher_truck, "Пусковая машина ракет"),
"ew_truck": (ew_truck, "Станция РЭБ"),
"searchlight": (searchlight, "Прожектор"),
"searchlight_head": (searchlight_head, "Голова прожектора"),