Compare commits

..

5 Commits

Author SHA1 Message Date
chexit 1a693763fd The player's picture and the enemy's intel apart from the truth (#93); world answers carry their context, worlds told apart by seed, a drone that can't reach its aim gives up (#90)
build / mod (push) Successful in 8m25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:20:07 +03:00
chexit 557346548a Interceptors blow up only before blocks a probe confirms, not on a tree-top height; low flights hold height over unknown land
build / mod (push) Successful in 8m54s
release / windows (push) Successful in 22m27s
In a clean real game with 15 systems: interceptors scrubbed at relief 58 to 2, kills 33 to 46 of 82. Smart raids from 2500 blocks: crashes 10 to 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:03:31 +03:00
chexit 38e515dea9 Flights over unknown land hold a safe height, air starts wait for the land, systems and aims settle on the surface; map labels, filters, coverage by terrain and scenes
build / mod (push) Successful in 8m34s
Far raids over mountains with a 300-block known disc: crashes from 19 to 2 of 368 (mixed), 22 to about 9 (smart). Balance tests gain hilly land.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:59:03 +03:00
chexit ccc3e20902 Playtest fixes: smoke by engine, searchlights, far launch sites that load their chunks, air defence balance, last chance and radar-cued shots, radars in smart raids, echo, Windows release build from a tag
build / mod (push) Successful in 4m4s
release / windows (push) Successful in 23m13s
Smoke: one rule by engine, no heat on trails, no trail on P1-SUN. Searchlights: two lamps a target, off with an empty sky. Halos fade before their reach and their sum is limited. Launch sites load chunks through the server thread, go off-map as a last resort, and reach 4000 blocks. Air defence: ranges x1.5, shots grow with urgency, claims released, /airdef lastchance, long shots on a big radar's tracks. Build: rust-toolchain.toml, airdef.py check, generator manifest and --check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:44:14 +03:00
84 changed files with 14227 additions and 449 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
# На каждый пуш: только мод (.hmod, один для всех ОС) и релиз build-<номер> с ним.
# Раннер живёт на одной машине с Gitea и другими сервисами (2 ядра, 4 ГБ): сборка мода лёгкая —
# один поток компиляции, низший приоритет, без системных пакетов и без кэша target.
# Игра-форк собирается отдельно и только вручную: game.yml (Linux) и desktop.yml (macOS/Windows).
# Игра-форк здесь не собирается: только по тегу версии (release.yml, Windows) и вручную (game.yml Linux, desktop.yml macOS).
name: build
on:
@@ -47,7 +47,7 @@ jobs:
run: |
set -e
mkdir -p out && cp airdef-mod/target/horizon-mod/airdef.hmod out/airdef.hmod
BODY=$(printf 'Сборка %s (%s).\n\n- airdef.hmod — мод, для всех ОС.\n\nИгра-форк Horizon (нужна для мода): собрать у себя — python3 tools/airdef.py all, или запустить вручную workflow game (Linux) / desktop (macOS, Windows) с этим тегом.\nКак запускать: docs/INSTALL.md.' "$TAG" "$SHA")
BODY=$(printf 'Сборка %s (%s).\n\n- airdef.hmod — мод, для всех ОС.\n\nИгра-форк Horizon (нужна для мода): собрать у себя — python3 tools/airdef.py all, или скачать из большого релиза v* (Windows), или запустить вручную workflow game (Linux) / desktop (macOS) с этим тегом.\nКак запускать: docs/INSTALL.md.' "$TAG" "$SHA")
DATA=$(BODY="$BODY" python3 -c 'import json,os;print(json.dumps({"tag_name":os.environ["TAG"],"target_commitish":os.environ["SHA"],"name":os.environ["TAG"],"body":os.environ["BODY"]}))')
if [ -z "$TOKEN" ]; then echo "::error::нет токена: добавь секрет RELEASE_TOKEN"; exit 1; fi
CODE=$(curl -sS -o release.json -w '%{http_code}' -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" "$API/releases" -d "$DATA")
+3 -75
View File
@@ -1,6 +1,6 @@
# Сборки игры-форка для macOS и Windows: запускаются вручную (Actions → desktop → Run workflow)
# с тегом готового релиза (build-N) и дописывают свои архивы в него. Нужны раннеры с метками
# macos-latest / windows-latest (например, act_runner в режиме host на своём Маке или ПК).
# Сборка игры-форка для macOS: запускается вручную (Actions → desktop → Run workflow) с тегом готового
# релиза (build-N) и дописывает свой архив в него. Нужен раннер с меткой macos-latest (например, act_runner
# в режиме host на своём Маке). Windows собирается на сервере по тегу версии: release.yml.
name: desktop
on:
@@ -9,17 +9,12 @@ on:
tag:
description: Тег релиза, например build-3
required: true
os:
description: macos, windows или both
required: true
default: both
env:
CARGO_TERM_COLOR: never
jobs:
game-macos:
if: inputs.os == 'macos' || inputs.os == 'both'
runs-on: macos-latest
steps:
- name: Проверка релиза
@@ -83,70 +78,3 @@ jobs:
set -e
# RELEASE_ID взят в шаге «Проверка релиза» (релиз build-N, созданный build.yml)
curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/airdef-macos-arm64.tar.gz" "$API/releases/$RELEASE_ID/assets?name=airdef-macos-arm64.tar.gz" > /dev/null
game-windows:
if: inputs.os == 'windows' || inputs.os == 'both'
runs-on: windows-latest
defaults:
run:
shell: pwsh
steps:
- name: Проверка релиза
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ inputs.tag }}
run: |
$ErrorActionPreference = 'Stop'
if ($env:TAG -notmatch '^build-\d+$') { Write-Output "::error::тег '$env:TAG' не похож на build-N"; exit 1 }
$h = @{ Authorization = "token $env:TOKEN" }
try { $r = Invoke-RestMethod -Headers $h "$env:API/releases/tags/$env:TAG" }
catch { Write-Output "::error::релиза с тегом $env:TAG нет: сначала дождись сборки build.yml на main"; exit 1 }
$assets = @($r.assets | ForEach-Object { $_.name })
if ($r.tag_name -ne $env:TAG -or $r.name -ne $env:TAG -or ($assets -notcontains 'airdef.hmod')) {
Write-Output "::error::релиз $env:TAG не создан build.yml (нужны имя = тег и вложение airdef.hmod): чужой релиз не трогаем"; exit 1
}
"RELEASE_ID=$($r.id)" | Out-File -Append -Encoding utf8 $env:GITHUB_ENV
"RELEASE_SHA=$($r.target_commitish)" | Out-File -Append -Encoding utf8 $env:GITHUB_ENV
- uses: actions/checkout@v4
with:
ref: refs/tags/${{ inputs.tag }}
- name: Проверка коммита
env:
TAG: ${{ inputs.tag }}
run: |
$ErrorActionPreference = 'Stop'
$head = git rev-parse HEAD
if ($env:RELEASE_SHA -match '^[0-9a-f]{40}$' -and $head -ne $env:RELEASE_SHA) { Write-Output "::error::тег $env:TAG указывает на $head, а релиз создан из $env:RELEASE_SHA"; exit 1 }
Write-Output "собираю $env:TAG = $head"
- name: Rust
run: |
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
Invoke-WebRequest https://win.rustup.rs/x86_64 -OutFile rustup-init.exe
.\rustup-init.exe -y --profile minimal
}
"$env:USERPROFILE\.cargo\bin" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH
- name: Форк Horizon
run: python tools/airdef.py fork
- name: Игра
run: python tools/airdef.py game
- name: Мод
run: |
rustup target add wasm32-unknown-unknown
python tools/airdef.py mod
New-Item -ItemType Directory -Force out | Out-Null
Copy-Item airdef-mod\target\horizon-mod\airdef.hmod out\
- name: Архив
run: |
$D = "airdef-windows-x86_64"
New-Item -ItemType Directory -Force $D | Out-Null
Copy-Item horizon\target\fast\horizon-client.exe, horizon\target\fast\horizon-ctl.exe, out\airdef.hmod, dist\play.bat, docs\INSTALL.md $D
Compress-Archive -Path $D -DestinationPath "out\$D.zip" -Force
- name: В релиз
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
# RELEASE_ID взят в шаге «Проверка релиза» (релиз build-N, созданный build.yml)
curl.exe -sf -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@out\airdef-windows-x86_64.zip" "$env:API/releases/$env:RELEASE_ID/assets?name=airdef-windows-x86_64.zip"
+96
View File
@@ -0,0 +1,96 @@
# Большой релиз: только по тегу vX.Y.Z (git tag v0.3.0 && git push origin v0.3.0) или вручную с готовым тегом.
# Игра-форк для Windows собирается на Linux-сервере кросс-компиляцией (x86_64-pc-windows-gnu), без Windows-раннера.
# Сервер общий (2 ядра, 4 ГБ, Gitea и боты), поэтому контейнер ограничен: при нехватке памяти убивается сборка, а не
# сервисы. Замер 2026-10-30: 20,5 минуты, 2 ГБ памяти постоянно в потолке, у сервера не меньше 726 МБ свободно.
# Обычные коммиты сюда не попадают: build.yml собирает только мод.
name: release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: Существующий тег, например v0.3.0
required: true
env:
CARGO_TERM_COLOR: never
CARGO_BUILD_JOBS: "1"
CARGO_INCREMENTAL: "0"
CARGO_PROFILE_FAST_INCREMENTAL: "false"
CARGO_PROFILE_FAST_DEBUG: "false"
CARGO_PROFILE_FAST_STRIP: "true"
TAG: ${{ inputs.tag || github.ref_name }}
jobs:
windows:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
options: --memory=2g --memory-swap=4500m --cpus=1.5 --cpu-shares=128 --blkio-weight=100 --oom-score-adj=800
steps:
- name: Место на диске
run: |
FREE=$(df --output=avail -BG / | tail -1 | tr -dc 0-9)
echo "свободно: ${FREE} ГБ"
if [ "$FREE" -lt 12 ]; then echo "::error::на сервере свободно ${FREE} ГБ, нужно хотя бы 12"; exit 1; fi
- uses: actions/checkout@v4
with:
ref: refs/tags/${{ inputs.tag || github.ref_name }}
- name: Инструменты
run: |
SUDO=$(command -v sudo || true)
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 zip
curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain none
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# Версия Rust берётся из rust-toolchain.toml.
- name: Цели Rust
run: rustup target add x86_64-pc-windows-gnu wasm32-unknown-unknown
- name: Форк Horizon
run: nice -n 19 python3 tools/airdef.py fork
- name: Мод
run: nice -n 19 python3 tools/airdef.py mod
- name: Игра для Windows
working-directory: horizon
run: |
# Сторож: сервер важнее сборки. Память контейнера ограничена, это на случай, если её съело что-то ещё.
( while sleep 10; do
AVAIL=$(awk '/MemAvailable/{print int($2/1024)}' /proc/meminfo)
DISK=$(df --output=avail -BG / | tail -1 | tr -dc 0-9)
if [ "$AVAIL" -lt 250 ] || [ "$DISK" -lt 6 ]; then
echo "::error::сборка остановлена: свободно ${AVAIL} МБ памяти и ${DISK} ГБ диска"
pkill -9 cargo; pkill -9 rustc; exit 1
fi
done ) &
WATCH=$!
nice -n 19 ionice -c3 cargo build --profile fast -p horizon-client --features mods --target x86_64-pc-windows-gnu
kill "$WATCH"
- name: Архив
run: |
set -e
D=airdef-windows-x86_64
T=horizon/target/x86_64-pc-windows-gnu/fast
mkdir -p "$D" out
cp "$T/horizon-client.exe" "$T/horizon-ctl.exe" airdef-mod/target/horizon-mod/airdef.hmod dist/play.bat docs/INSTALL.md "$D/"
zip -qr "out/$D.zip" "$D"
cp airdef-mod/target/horizon-mod/airdef.hmod out/
- name: Релиз
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
if [ -z "$TOKEN" ]; then echo "::error::нет токена: добавь секрет RELEASE_TOKEN"; exit 1; fi
# Тег с дефисом (v0.3.0-rc1) выходит как предварительный релиз; уже созданный релиз тега дополняется.
DATA=$(python3 -c 'import json,os;t=os.environ["TAG"];print(json.dumps({"tag_name":t,"name":t,"prerelease":"-" in t,"body":"Windows: распакуй airdef-windows-x86_64.zip и запусти play.bat. Нужен Minecraft Java 26.3 и лаунчер Horizon, запущенный один раз. Подробнее: docs/INSTALL.md.\n\nmacOS: сборка из исходников, python3 tools/airdef.py all."}))')
CODE=$(curl -sS -o release.json -w '%{http_code}' -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG")
if [ "$CODE" != "200" ]; then
CODE=$(curl -sS -o release.json -w '%{http_code}' -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" "$API/releases" -d "$DATA")
if [ "$CODE" != "201" ]; then echo "::error::Gitea не создала релиз (HTTP $CODE): $(cat release.json)"; exit 1; fi
fi
ID=$(python3 -c 'import json;print(json.load(open("release.json"))["id"])')
for F in airdef-windows-x86_64.zip airdef.hmod; do
curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/$F" "$API/releases/$ID/assets?name=$F" > /dev/null
done
+34 -16
View File
@@ -55,7 +55,8 @@ CI: `.gitea/workflows/build.yml` на каждый пуш собирает то
- *умный противник* — планировщик знает, где стоят комплексы и что видят их РЛС: ведёт дроны и крылатые ракеты
маршрутами в обход зон поражения, низко, за рельефом и через мёртвые зоны; «Герберы» прилетают на 15 с раньше,
чтобы оттянуть ракеты; «Ланцеты» идут по Гепардам и Панцирям, Х-22/Циркон и часть баллистики — по дальнобойным
комплексам; всё остальное стартует со сдвигом так, чтобы прилететь одновременно.
комплексам; на каждую РЛС дальнего обнаружения идут 2–3 быстрых ударника (Х-22, Циркон, Искандер, Кинжал, затем Х-101,
Герань-3, Ланцет, Шахед; не больше четверти налёта); всё остальное стартует со сдвигом так, чтобы прилететь одновременно.
После налёта приходит отчёт: сколько сбито и прорвалось, ракет ПВО потрачено, комплексов потеряно, урон объектам.
@@ -72,7 +73,7 @@ CI: `.gitea/workflows/build.yml` на каждый пуш собирает то
**Сохранение.** Комплексы, объекты, их повреждения и настройки сохраняются отдельно для каждого мира (и для каждого
сервера); уничтоженный комплекс остаётся остовом и после перезахода, пока его не отремонтируют.
**Сеть ПВО.** Каждая единица делится целями со всеми в радиусе 1,5 км (линия связи): РЛС дальнего обнаружения
**Сеть ПВО.** Каждая единица делится целями со всеми в радиусе 2,25 км (линия связи): РЛС дальнего обнаружения
даёт раннее предупреждение всем, пусковая без своей РЛС (P1-SUN) стреляет по чужим данным. Пушкам (Гепард,
пушки Панциря) нужна своя цель: они стреляют только по тому, что видят сами. Уничтоженная РЛС — дыра в сети.
@@ -149,13 +150,21 @@ API мода `render.set-lights` (до 512, плавно интерполиру
Клавиши N, K, J, R и U можно переназначить в «Настройки → Моды».
Команды:
- `/airdef wave <тип> [кол-во] [дальность]` — налёт на ближайшую цель. Типы: `shahed`, `geran3`, `lancet`, `gerbera`, `kh101`, `kalibr`, `kh22`, `zircon`, `iskander`, `kinzhal`, `oreshnik` и `mix` (комбинированный удар двумя секторами: «Герберы», шахеды, Герани-3, Х-101, «Калибры», Х-22, Циркон, «Искандер», «Кинжал», при 30+ — «Орешник»);
- `/airdef wave <тип> [кол-во] [дальность]` — налёт на ближайшую цель. Дальность старта — от 60 до 4000 блоков (в меню: 200–4000); землю для площадок, которой нет в загруженных чанках, игра подгружает сама (API 22, `render.load-chunks`, пока идёт поиск), а где подгрузить нельзя (сервер) или нет пригодного места, дроны всё равно стартуют: «за картой», с высоты полёта на заказанной дальности, без площадки. Типы: `shahed`, `geran3`, `lancet`, `gerbera`, `kh101`, `kalibr`, `kh22`, `zircon`, `iskander`, `kinzhal`, `oreshnik` и `mix` (комбинированный удар двумя секторами: «Герберы», шахеды, Герани-3, Х-101, «Калибры», Х-22, Циркон, «Искандер», «Кинжал», при 30+ — «Орешник»);
- `/airdef replay [map|full|reel|stop]` — повтор последнего налёта;
- `/airdef replay film [best|all|N]` — фильм режиссёра: для каждого перехвата/прилёта несколько планов (комплекс, пуск, погоня за ракетой, взгляд с цели, замедленный крупный план поражения, общий план после), склейки, плавные движения камеры, камера не уходит под землю; играется в максимальном качестве (HD, мир в полном разрешении, больше шагов облаков и тумана; настройки возвращаются после);
- `/airdef replay export [имя] [best|all|N] [fps=60] [size=1920x1080] [crf=14] [sec=N] [noaudio]` — тот же фильм кадр за кадром с фиксированным шагом (скорость компьютера не важна) в MP4 с звуком: `<HORIZON_HOME>/airdef-films/<имя>.mp4` (нужен `ffmpeg` в PATH или `HORIZON_FFMPEG`; без него — PNG-последовательность и готовая команда ffmpeg). Прогресс в чате, отмена — `/airdef replay stop`. Окно игры держите в фокусе (пауза останавливает съёмку); чат попадает в кадр.
- `/airdef import` — перенести в текущий мир комплексы и цели из старой версии мода (до 0.2.x они были общими для всех миров);
- `/airdef raid` — налёт, настроенный в меню; `/airdef target here` — отметить объект под ногами; `/airdef repair` — ремонт;
- `/airdef lastchance <on|off>` — «последний шанс»: комплекс стреляет и по классу вне ролей, если цель вот-вот попадёт (по умолчанию вкл; сохраняется);
- `/airdef sites` — известные пусковые площадки (ПУ) и ударные ракеты; `/airdef strike <номер ПУ>` — ответный удар по ПУ (#101, «удары по ПУ»). Как это играется:
- **Разведка без волшебства.** Площадку находит только работающая РЛС: либо пуск в её досягаемости (низкая цель: радиогоризонт и холмы решают) даёт точечное наблюдение, либо собственный трек вылетевшей цели «указывает назад» лучом в пару градусов, а время полёта с момента пуска (тревоги) на скорости цели даёт дальность (±8% и секунда времени пуска); у баллистической ракеты дуга точнее. Две РЛС, держащие одну цель, дают два наблюдения. Наблюдения сводятся методом наименьших квадратов, ответ всегда с кругом ошибки (90%), он сужается с каждым пуском. Если места не получилось (круг шире 800 блоков или есть только лучи вдоль одной линии), известен только **азимут** (на скопе метка «N?» на краю), удар невозможен. С местом: ромб на скопе (круг ошибки пунктиром) или метка на краю; в панели строка «Известных ПУ…». Далёкие площадки (километры) открываются грубо (сотни блоков) и бить их почти бесполезно, пока не накопятся пуски.
- **Удар.** Ударных ракет мало (3 в запасе, максимум 4, новая за 5 минут), дальность до 4500 блоков; стартуют оттуда, где вы стоите, летят 4 блока в тик (2 км за 25 с) в точку *оценки*. На подлёте ракету может сбить ПВО противника: у ПУ её нет (40%), лёгкая (35%, сбивает в 30%) или тяжёлая (25%, 65%), у баллистических батарей тяжёлая чаще; узнаёте только по итогу. Разброс ракеты 14 блоков; решает истинное расстояние до ПУ: до 35 блоков — **уничтожена** (запас сгорел и рвётся, отложенные запуски отменены, огонь и дымящийся остов, взрыв общим кодом), до 90 — **подавлена** на 30 с (пуски отложены, не отменены), дальше — **промах**, воронка рядом, ПУ цела: очередь за попадание в соседний грунт не списывается. Плохо известную ПУ (круг в сотни блоков) бить почти бесполезно.
- **Вне карты.** ПУ без земли (нет загруженной карты) тоже находится: азимут и дальность по трекам, но не рисуется. Удар, который попал бы, прекращает её пуски («результат не виден»); промах теряется.
- **Цена выбора.** Ракета запаса не вернётся, пока летит, налёт идёт: близкие площадки не остановить, далёкие (крылатые и баллистические за 2–4 км) успеваете, но для этого нужны РЛС с обзором в сторону налёта и риск промаха или ПВО противника. Отчёт налёта называет сорванные пуски: «Налёт сорван ударами по ПУ (№…): не взлетело N из заявленных M».
- Запись: удар попадает в журнал повтора (строка и взрыв/огонь на карте), сами ПУ в повторе не рисуются. Запас ракет входит в сохранение и сценарий (`strikes <запас> <тиков до следующей>`). Все числа механики в одной таблице `TUNE` в `src/sim/strike.rs`.
- `/airdef roles` — показать, по каким классам целей работает каждый комплекс; `/airdef roles <patriot|gepard|pantsir|s400|s500|all> <классы|default>` — задать (классы через запятую: `drone`, `jet`, `cruise`, `supersonic`, `ballistic` или `all`), например `/airdef roles s400 cruise,ballistic`;
- `/airdef doctrine` — доктрины комплексов (#47; в меню N: «Доктрины…»): `/airdef doctrine <номер|all> reserve <K>` — держать K ракет для срочных целей (баллистика или до удара меньше 12 с); `salvo eco|normal|heavy` — сколько перехватчиков на цель (только базовые / как обычно / на один больше); `sector <центр°> <полуширина°>` или `sector off` — сектор ответственности (0° — север); `guard <объект№>[,<№>]` или `guard off` — стрелять только по тому, что идёт на охраняемые объекты (отметка: `/airdef target here`). По умолчанию ограничений нет; последний шанс сильнее доктрины; `/airdef why <номер>` называет, какая настройка остановила или разрешила пуск; сохраняется с миром и сценариями. Карта охраны и пробелов в ней — вместе с картой (#48).
- `/airdef deploy <patriot|gepard|pantsir|s400|s500>` — поставить комплекс в 8 блоках перед собой;
- `/airdef hold on|off` — запретить или разрешить ПВО огонь, удобно для съёмки ударов;
- `/airdef damage on|off` — разрушать ли ландшафт взрывами (через TNT, нужны читы);
@@ -192,25 +201,34 @@ API мода `render.set-lights` (до 512, плавно интерполиру
| ПВО | Вооружение | Дальность |
|---|---|---|
| Patriot | 4 × PAC-3, пусковая разворачивается на цель | 10–300 |
| Гепард | 2 × 35 мм, башня и стволы со слежением и упреждением, 160 очередей | 90 (слежение с 130) |
| Панцирь-С1 | 12 × 57Э6 и 2 × 30 мм; модуль поворачивается, пакеты и стволы поднимаются | ракеты 25–150, пушки 60 |
| С-400 | 4 × 48Н6, вертикальный холодный старт, РЛС обзора на прицепе | 30–480 |
| С-500 | 4 × 77Н6, холодный старт, самые быстрые ракеты | 40–700 |
| IRIS-T SLM | 8 ракет, вертикальный пуск, быстрые и манёвренные | 15–400 |
| NASAMS | 6 × AMRAAM, наводимая пусковая на прицепе | 10–300 |
| Бук-М2 | 4 × 9М317 на направляющих, башня с РЛС подсвета | 20–450 |
| Тор-М2 | 8 × 9М338 вертикально из башни, быстрая реакция против дронов | 8–160 |
| P1-SUN | 8 дронов-перехватчиков против Шахедов и Герань; своей РЛС нет — стреляет по данным сети | 5–450 |
| Patriot | 4 × PAC-3, пусковая разворачивается на цель | 10–450 |
| Гепард | 2 × 35 мм, башня и стволы со слежением и упреждением, 160 очередей | 135 (слежение с 195) |
| Панцирь-С1 | 12 × 57Э6 и 2 × 30 мм; модуль поворачивается, пакеты и стволы поднимаются | ракеты 25–225, пушки 90 |
| С-400 | 4 × 48Н6, вертикальный холодный старт, РЛС обзора на прицепе | 30–720 |
| С-500 | 4 × 77Н6, холодный старт, самые быстрые ракеты | 40–1050 |
| IRIS-T SLM | 8 ракет, вертикальный пуск, быстрые и манёвренные | 15–600 |
| NASAMS | 6 × AMRAAM, наводимая пусковая на прицепе | 10–450 |
| Бук-М2 | 4 × 9М317 на направляющих, башня с РЛС подсвета | 20–675 |
| Тор-М2 | 8 × 9М338 вертикально из башни, быстрая реакция против дронов | 8–240 |
| P1-SUN | 8 дронов-перехватчиков против Шахедов и Герань; своей РЛС нет — стреляет по данным сети | 5–675 |
Обслуживающая техника (тоже пультами или `/airdef deploy <ключ>`): **РЛС дальнего обнаружения** (`radar`,
видит на 1,7 км с высокой мачты, хуже прячутся за рельефом), **склад боеприпасов** (`depot`), **ТЗМ** (`tzm`),
видит на 2,5 км с высокой мачты, хуже прячутся за рельефом), **склад боеприпасов** (`depot`), **ТЗМ** (`tzm`),
**станция РЭБ** (`ew`), **прожектор** (`light`). Новые комплексы: `irist`, `nasams`, `buk`, `tor`, `p1sun`.
**Согласование огня.** Комплексы делят цели между собой: на обычную цель идёт одна ракета, на баллистическую две,
пушки расходятся по разным целям. Первыми бьют по самым опасным (быстрым и тяжёлым) целям, при этом ложные
**Согласование огня.** Комплексы делят цели между собой: на обычную цель идёт одна ракета, на баллистическую две; чем ближе цель к своей точке попадания,
тем больше перехватчиков разрешено на неё сразу (за ~12 с до удара ещё один, за ~5 с — ещё один). Комплекс, у которого кончились ракеты
или идёт перезарядка, цель не держит; тот, кто не смог навестись за ~2 с, отпускает её другим. Пушки расходятся по разным целям. Первыми бьют по самым опасным (быстрым и тяжёлым) целям, при этом ложные
«Герберы» выглядят для них как настоящие. Шанс поражения падает, если цель быстрее перехватчика.
**Последний шанс** (`/airdef lastchance on|off`, по умолчанию вкл). Комплекс стреляет и по классу целей вне своих ролей
(С-400, С-500 и Patriot по умолчанию не бьют дроны), если цель долетит до своей точки меньше чем за 10 с и на ней ещё нет
ничьего перехватчика. Комплексу с пустыми ролями (огонь запрещён) это не касается. `/airdef why` пишет «последний шанс».
**Дальний пуск по РЛС.** С-400, С-500 и Patriot могут стрелять дальше своей дальности (до 1,6×) по цели, которую держит
свежим треком РЛС дальнего обнаружения в радиусе связи: не чаще раза в 15 с, при не менее чем половине боезапаса, только по
своим ролям и только пока в обычной дальности нет цели; шанс поражения на краю вдвое ниже. `/airdef why`: «дальний пуск по РЛС».
**Роли по умолчанию** (в сводке в квадратных скобках: Д — дроны, Г — реактивные, К — крылатые, С — сверхзвуковые,
Б — баллистика): Гепард ДГК, Панцирь ДГКС, Patriot ГКСБ, С-400 ГКСБ, С-500 СБ. Сбитый дрон падает с остатком скорости, и его боевая часть
может сдетонировать на земле.
+572
View File
@@ -0,0 +1,572 @@
61d8c0b90973a3f1398d4db914b1fc9c39ff5ac4316085d7cc05d070ea62d15f assets/airdef/blockstates/amraam_missile.json
7f8fe0d11f6c8929e48c136b2f34d1c653f060511957a32fe4988377fc61d62c assets/airdef/blockstates/barge.json
dc5d543e28c50474df938163495a70fb81becd0814dc59d516493adb7c087110 assets/airdef/blockstates/beam.json
b2cc12fe02e4ccba502f47085046defe6a51e22be3fd739c4678677ed977c312 assets/airdef/blockstates/buk_chassis.json
8e2fe3bce22d718a9384a3805034691ee0ea068aad81d10aededa20da20afb9a assets/airdef/blockstates/buk_chassis_burnt.json
035b3898bfee5c0dfeed946e64e9365ad111c6e017ce0222e6455ee860f8d8cf assets/airdef/blockstates/buk_rails.json
b8ff32da42179af8d3762b5bddd3f33df7172fbef5352fbf51ddd1a8d5899c21 assets/airdef/blockstates/buk_rails_burnt.json
a4b1563f2c4578014b48778177d1e7032cc3f23afa99a715d4c9b08a67397b9e assets/airdef/blockstates/buk_turret.json
413edabb0c8db5b5f39f10f91ace49ee658036a00ba53db7d4ffc20615f70814 assets/airdef/blockstates/buk_turret_burnt.json
3b3145ddb3dad08986aeebacee5a389aea74e6ae7f2c778da52201fd8f24f2f4 assets/airdef/blockstates/debris.json
254aaebdf2c9c63d0c3bd95093368ebd6466cbb768d31ede32f07fc4fef5e808 assets/airdef/blockstates/depot.json
4062e069929859ea7fb18487b42953ac43cb9bd6e90d349e3ae49afbd29426c6 assets/airdef/blockstates/depot_burnt.json
d02848bd8df8af516fd12c3412636457e4f62577e221082a8627f041b714609c assets/airdef/blockstates/ew_truck.json
6921ae900410d3415aa5357d008c9ef122891082f1f71d11dfda01b145e2127e assets/airdef/blockstates/ew_truck_burnt.json
3cd4de622d0c8e43caf42f22547df388dbb78ff55ac868d9cfad37a23d37800f assets/airdef/blockstates/fireball.json
7355433bd94843c5c0d7848f1a5afb810ef08221b3f0389c0061e50970b5d92f assets/airdef/blockstates/flame.json
840655b48624534badc6367d88cc1472d748c989ff8c7082ecacaf79f54cc2e5 assets/airdef/blockstates/flare.json
0654962121ca92b60cf95beced4b062fb377cb2776ad1ff08f44d92624d59a08 assets/airdef/blockstates/flash.json
2453542113245c015b7c54971fd57184b80beb970eaefb420e7adab3618da005 assets/airdef/blockstates/gepard_dish.json
0a3fe2f20da19c69744f304cf769e3ce3ef236a6d251afee380477e2b5276292 assets/airdef/blockstates/gepard_guns.json
4645e4959fa2b126dc913e710dd15babbb30c139f8450d86cdb3676ec590ea0f assets/airdef/blockstates/gepard_guns_burnt.json
477ce5eb4b76da642c6b758098715c20d0fbb7c114177fceb705f3bbe97b5c59 assets/airdef/blockstates/gepard_hull.json
7b18a4ba743d64e554529efa25c881ab3ea7a500aa3390fca4a98c5691724380 assets/airdef/blockstates/gepard_hull_burnt.json
ca3e0e913af91bfffc6d9910a557b3acb1e01cfa497e3452ad37e935be491697 assets/airdef/blockstates/gepard_turret.json
80c3bfd8b7fc2252110ccd54b3ae8118ddf3100d7b983ce326aa6b1fcc6e6d97 assets/airdef/blockstates/gepard_turret_burnt.json
41108d18eda14c14e6259e3c68d3894d86aee8ac10cccec33fe271e0345e9202 assets/airdef/blockstates/geran3.json
4f0737d7739bee31106845b4062dc65336ae49bde0f310a3bbccc74c17f31ad5 assets/airdef/blockstates/geran3_flame.json
b6fe8c5937cf30957d0ad01f7313146760beb3958d8f25a8586021b5a8ed3c8f assets/airdef/blockstates/gerbera.json
2c270b14b4130c4831eda0ea9acc378e4f720e42f4adab5babea25662d7500ef assets/airdef/blockstates/irist_chassis.json
22f2e998245f414f789ca3929d43d73d66b03bf95334ad41c2c5ce768e6b23b4 assets/airdef/blockstates/irist_chassis_burnt.json
16a21bd9b4051a1087b64e9c606541d098ee3074e492aedb6142e6f16eac4c58 assets/airdef/blockstates/irist_missile.json
e6e1cb2e1aa92837a187016cc1d6238f962fb1a256c168395aeb15fc2dbb8efc assets/airdef/blockstates/irist_tubes.json
204d6cb12a261672f190d8b7958837fe9eb5efd960b891accb68fc61b46a110b assets/airdef/blockstates/irist_tubes_burnt.json
1d1fdd506878e5ad8fdbb64bb1608179c4dc247f7369b8e0ffd85835d05a8685 assets/airdef/blockstates/iskander.json
c62e1d8ffaeaf14e3b57ecf1e161cec6bcb13a9aee36dca3e88442584f1dd155 assets/airdef/blockstates/kalibr.json
c09ae784b9bc87e969a845975294f0d4880b9a90692f57c79e2de04c5a650146 assets/airdef/blockstates/kh101.json
0d30bb49f1227ac4c78a9f05babfcf6856b17d8ba059e00669a4d27a558ff519 assets/airdef/blockstates/kh22.json
f37a2c415049fe1579bc71cb8caa14b05c6e96c17e6a2f9760bc83a97b93c4a4 assets/airdef/blockstates/kinzhal.json
88b47dffffe1d95cf2c5981ef67837e597f6be06d417177d4967451bb55b3b8c assets/airdef/blockstates/lancet.json
83f736f00a673c90c0c1dcf726dd8a7350ab1d1efa37dcd85eacd8dfe94383f8 assets/airdef/blockstates/launch_ramp.json
de0bc51183c7a8379dddab26952f063a4a4a4b51b8dd862bc061989ec89681d7 assets/airdef/blockstates/launcher_truck.json
5352e83f412ea524aba1519367f0f9a921958a5d4caefe17d509a5b7e04a1cbe assets/airdef/blockstates/m48n6.json
64bc9d9193ca5f20a2bbb189bf6216211c167e051cc63c297a72655d711cfe67 assets/airdef/blockstates/m57e6.json
dc55ec1579702d9d48c01e5268dfae7097d0ece87d85b1a469222134f962f4b1 assets/airdef/blockstates/m77n6.json
2331d3c3c614e38d39d67d8d85c4b16030ed7ecab37bfaa9da938379534f57cd assets/airdef/blockstates/nasams_launcher.json
f05bb84135b39bc469fadbc4f144e0f69d203b1887396a0b20adac59e549c1ed assets/airdef/blockstates/nasams_launcher_burnt.json
9fa63fdb0fe84521f00326dad6955af6adb3dea1093f42404e6e07770cd9bcf5 assets/airdef/blockstates/nasams_trailer.json
640b83fc5b43f6fce1d13106016e9b14c0150603035a6d9b19ad6f0598511959 assets/airdef/blockstates/nasams_trailer_burnt.json
446bd55fb9bacd7828c6a7028595de64e26cc5cbe3ef4280713c3d9f1cdf850e assets/airdef/blockstates/p1sun.json
9e2e00e75afb10c38684b1994688ea11bcc2faace46ff551967e1e05ae08ec85 assets/airdef/blockstates/p1sun_launcher.json
02f9474f5f459e33096931887b71b515d957b7a1be14ccb81a0bba36af551ec9 assets/airdef/blockstates/p1sun_launcher_burnt.json
da5130347d961fe0ca0df11d31b250dc1fa268cacfeed184662fe22901b6ebd7 assets/airdef/blockstates/pac3.json
f5674075063a8dbd897394de194b980a426e08faf7803ec10b8117070a579a78 assets/airdef/blockstates/pantsir_dish.json
365f6ca767ad9dd9cc724ea859f41ea6879f0d0ba88d6aaaab2ae7d5fe34acd8 assets/airdef/blockstates/pantsir_pack.json
53af2cf24abb2df609f02d41e711ba49e38be4b6a9a0a6e1d8cf395dde3b58a5 assets/airdef/blockstates/pantsir_pack_burnt.json
6a93f7b1eee7b8ff02567c38c05196f8326d66b319cc5d69c942902986bed946 assets/airdef/blockstates/pantsir_truck.json
81804b614abfd0776e1513c9f759d1cc22952346cbbc980b006fd367b68792a2 assets/airdef/blockstates/pantsir_truck_burnt.json
c274aaf1c5e81683c540c784bf0eb7de15ac8522a5e4d7397cd7b181367c7e83 assets/airdef/blockstates/pantsir_turret.json
05c3dd4f66c8cd7332654e19aeb60115f00e246c8f57f4eef2c300f1ddb97a2a assets/airdef/blockstates/pantsir_turret_burnt.json
c96640ff19099af66c2da0a9a7561b847d6355ab2de8bbfbe03f2daf38b89ff3 assets/airdef/blockstates/patriot_launcher.json
c3b0b6f1d9b7da434c95f2794b97b9e2c05c62ccbce9f0884df071e4ec28a468 assets/airdef/blockstates/patriot_launcher_burnt.json
83058657f466b769c9140f5d6c4a5079f9567f9cb13ece7a453da191aee5b89d assets/airdef/blockstates/patriot_radar.json
3ecae997dc4fe1939848fc60347979c02075396a164b31dec3995a2e907a8447 assets/airdef/blockstates/patriot_trailer.json
31fecc6476129990a7d368f6d7cdb954b13e777b28d1396a68168770024112ce assets/airdef/blockstates/patriot_trailer_burnt.json
183d0b2ae9c24c94c3d83f2819e1167b5e2d134cc7889e0e3bda12aa784c7baa assets/airdef/blockstates/patriot_truck.json
37714e19e555cc50a9f195e1a8845a28c9985c27ed38b577c0813f4629b66fcb assets/airdef/blockstates/radar_panel.json
95fe14b9d3e30597f5505aa93f31c3357cebea6117596bd9f5a5bc840ab22415 assets/airdef/blockstates/radar_trailer.json
a95a1b8731d6bedda7d8b352ff3c448ea8be691b0ead01f14acab39234590c45 assets/airdef/blockstates/radar_truck.json
332e6f39c40063ca690064e13a1a256ef215fc2bb96985b842d9dda58abee2eb assets/airdef/blockstates/radar_truck_burnt.json
67b93302bea867248b6fa0109a4b5af2065e5c857d4a5c7e088bc9849fe624de assets/airdef/blockstates/reentry.json
7f3e952f5de83880549012945827f8469883355b0a3dd966e7f03c44aee532ab assets/airdef/blockstates/s400_chassis.json
411d0a0ac102b21016bf48d30482e073a7e8054c2775d8fd5a7c91364a2cb1f7 assets/airdef/blockstates/s400_chassis_burnt.json
3ffc2edef1362f318d6c087a20cd730feda6f35f7381942cfc8012f06f4dc174 assets/airdef/blockstates/s400_tubes.json
e570e68b27a7554e3aa8b63b30108a2f4e2c3bec6e91cd3cff7509e3d7eb3400 assets/airdef/blockstates/s400_tubes_burnt.json
eb8061c3cb198600ad8f587738e8a676d9ef114ed171764bc905f4730eb3a0ba assets/airdef/blockstates/s500_chassis.json
f0cf8bafdf7053ae757e5b0baee92accb984a24bfbc18d65e1e824384e09180c assets/airdef/blockstates/s500_chassis_burnt.json
2b7bd3099ec300184a185e2beb48dd748004af869696f4a26793032bf9675439 assets/airdef/blockstates/s500_tubes.json
3d6fc384684a24218888e871ba0efc72d15ce29de84cf45e3adffc5f7f98a45c assets/airdef/blockstates/s500_tubes_burnt.json
3af7b3f01780265cbff35e20aa149691bceb8de10a6991129b32046c753c6f8b assets/airdef/blockstates/searchlight.json
8e530da837dba9ec426651efa136c3389c4f9f9e8baf453b9055b4d260b5900f assets/airdef/blockstates/searchlight_burnt.json
48b14bddcb967c4a4933a827568c0b66d9e6c9e2d3a62c63b6ca55ddab8764dd assets/airdef/blockstates/searchlight_head.json
bc57f94b431786c60a6b204ea54126c9d2b363427673c39a45ce0a53336ee7c8 assets/airdef/blockstates/searchlight_head_burnt.json
9c25df8f750763b5e3011557a6aa59289e9a41efa1811c18c30522747473dde6 assets/airdef/blockstates/shahed.json
ab6d7a96cda417cf70fa31aca6f6c7bcdaaf4f8d413b079386f97ecef6d33709 assets/airdef/blockstates/shahed_prop.json
137dc0f2bc5723534dc2e4f44f1706d589c0dd85318d17c207f2f6772d98d989 assets/airdef/blockstates/target.json
e766e18158d00f0480f6dfe32dfe142890b8d452bc493b7474fda0984cc2dd4c assets/airdef/blockstates/tor_chassis.json
ff125150e206ada5a7fb47394e809039d338c22a843207a32352e9ee59e278a3 assets/airdef/blockstates/tor_chassis_burnt.json
04a5fb931f419086995791c1defd9486b36b3c725581bf5a10191a2c71c69984 assets/airdef/blockstates/tor_turret.json
fdcbe36a0f4968005e8c6588f2fa5d8196c55da3183cf89760ea221a5c4cb431 assets/airdef/blockstates/tor_turret_burnt.json
cf7c26abeb1d8f7d84106bd83b84bf541a3d11369b7745a77802b7e51dcdf447 assets/airdef/blockstates/tracer.json
8b547ccbc890fa53b613cdb2cf55ceaa5833348479b0863651c6df01a9f778b3 assets/airdef/blockstates/tzm_truck.json
11fca31a88e658c819954fbb86b53d7709b66b971af8747a5edda24be34e5dbb assets/airdef/blockstates/tzm_truck_burnt.json
4a74928eae32adeaf6e0e5d964ccbda97be0a2208961772848f109c59a9c6c1f assets/airdef/blockstates/zircon.json
48aeeb65e6be762ef975540826b2b3303a2df5378c3181e5e99826206df370e4 assets/airdef/items/cam_geran3.json
e6d0a3b45670cda451ebe4689323b269de22afb7d99b341510a711a22db75c85 assets/airdef/items/cam_gerbera.json
e22e53fbfadaa018ec8091213406b57eee49b39341e029daf96d7915f500e50a assets/airdef/items/cam_iskander.json
a3174ebb0b1720b803316c3bdce3abf09146d2c1a2b78bb6c091aa4a363716ec assets/airdef/items/cam_kalibr.json
f357a1510ead55131a7f93bfe43d46c3e0c229deb25cfe82bfecfa84caba543e assets/airdef/items/cam_kh101.json
65625082b0ca6b0835b9a7bb04657b83b76103a25f263d85254835e0d29f6725 assets/airdef/items/cam_kh22.json
16fe2fa4cfa528878261fde9c3a9923989608ffc2ebbdc770cdda2b9098b0287 assets/airdef/items/cam_kinzhal.json
f4d13fcb599d8fed03ab4fa5a95900df6a0c766d29b3095d32fe9dc2f06d3554 assets/airdef/items/cam_lancet.json
ed5405e2cd20da02053d8b614a630ef5c32f476c08dabccbbeaaf0b261823956 assets/airdef/items/cam_reentry.json
7654d42382b9f072ea8a0f348c30e78c147921f5bf95723fe08bfe23b1b28c1a assets/airdef/items/cam_shahed.json
adb65d10541f62f8e0a4c0b9da63a9e7bf4581f44672d2542d11bb16f89e75fc assets/airdef/items/cam_zircon.json
0c5fd99e7447602834eae3296026031fc7419ba6126b507132503925b01af1e4 assets/airdef/items/deploy_buk.json
b5c9bbceb576dcf3e4ddc3d01f6e3743d67f087ab98a24966c506585aaa67b2b assets/airdef/items/deploy_depot.json
b50fe5804cbbe10df992ebbc7e095a32ce8b73fc35159fa19889640681e408e9 assets/airdef/items/deploy_ew.json
728e5e8bfb4c351f11db6be872834e4a07a9b04fb6be63d9bb1ad42495bb57d3 assets/airdef/items/deploy_gepard.json
0c9c5ee3fc87e2dc6d716bdb0bb5f30d3b265268bfd5083462b4861a8da65f0f assets/airdef/items/deploy_irist.json
09e46a6c4b59f66c24060ac627e6a287bf6797c7b1e32a9ec6c9d9662fdc3de3 assets/airdef/items/deploy_light.json
6983561b3558aee16662aa83d2d9af760fb6236cc7dbe79ba20802f8a2ff15b9 assets/airdef/items/deploy_nasams.json
d8ac6a9258e2fc2e0791bbce18a3a07a72e471fbc72b6be7b5ca545c53f8d633 assets/airdef/items/deploy_p1sun.json
e2353c70f6e5053864ab2fdf51df886d72c03e5c34db84fef8f3fff9c84a7ae4 assets/airdef/items/deploy_pantsir.json
68dd9b1382b2dd6ad23d0f507a8cfcdcfa18ef5c4203e69c61f6ade9cfbf238e assets/airdef/items/deploy_patriot.json
d93b6bf77de4aed1f1955e76353661729f06445788ad7a6654ed6af1a67fc48d assets/airdef/items/deploy_radar.json
0b9445f3b54a9e958da0b9bd4243a41d85fc8f12b5c4e56bbc070edb8f1bf618 assets/airdef/items/deploy_s400.json
dd7478f447d87dcad42e1a7e6dcb5fae555c832e933a3722022d0e10e2de011e assets/airdef/items/deploy_s500.json
14e9a20dcddee5a6262ef624783900576dac2bd16e5079e731cf6115f13c76ec assets/airdef/items/deploy_tor.json
d4ff57f630ca92bf52ba228d8c6fd5292928b685d6bb5a9c9b9dc0c3d586fc2e assets/airdef/items/deploy_tzm.json
044fc626ad99ff8be6db0800f96dc743fc80606d813bd59abad531396bf9d8ce assets/airdef/items/designator.json
5a07f4adb780e52b920d1c8ca5296e43e84f44897b0f6afe258a8c2c7712f83c assets/airdef/items/flare_gun.json
25fc0a8ac7b5e01c6de19c86584e5bfc2dd9cbfa8e7ffb0468db82cb89f5585a assets/airdef/items/launch_geran3.json
5dd3ca665c44c43bef0c4c25361b9a4d45d0b2a7b01d2c8e66cefbf62ee38180 assets/airdef/items/launch_iskander.json
106695745868cf5ed80534ca35d81944a9fc40a74d30d4d4fdf43d716dc5b28b assets/airdef/items/launch_kalibr.json
5c45ad03265b1331ee73b8085cfc33a8538339c9fd1c9916c3f5471bb2ed0bfb assets/airdef/items/launch_kh101.json
ed9356cb86466025eab00d4283a72adfd1cf6aa9549f051c5e6088c72078263c assets/airdef/items/launch_kh22.json
8af907c3597963f6332c2cd677d5236e41dc9584d816fdbbd820ddb99217fdf1 assets/airdef/items/launch_kinzhal.json
54045e7d6cb53e92700accb04f6caf68e229d71cba6038669c425e9c109b904d assets/airdef/items/launch_lancet.json
4a007ad0f768a53c605236476667178e981e47980baf04a973fefde2bd4b20c3 assets/airdef/items/launch_oreshnik.json
b4d98a938486d49db28c6c7a608ca3b6ab0c9c3eac266ecf12c7dd931cc28f93 assets/airdef/items/launch_shahed.json
489799dcb5b441e9d4be8bfb3d73a6ebd5fdf2b5ee614b62f38b85bb2fbfad75 assets/airdef/items/launch_zircon.json
6b6da5887aa8d9bc987b2c181c243bc2afdd5846a869818f699b59204e559929 assets/airdef/items/mover.json
177f7535e4c52ed975d6f737c2b2acb7741b7a928955c8899aced85211443bbf assets/airdef/items/remover.json
9c54b89d2840841494bdfb2161c038385b23e41a42a87a24e95d485a1a541a82 assets/airdef/lang/en_us.json
9c54b89d2840841494bdfb2161c038385b23e41a42a87a24e95d485a1a541a82 assets/airdef/lang/ru_ru.json
b9b5b5bec33a77e944552c225ee2f526ab9820fa8fca4b3ccf311f2a325925ee assets/airdef/models/block/amraam_missile.json
c54e6b24ab69e9d9a2dbbbc63fe2ac60fa37735bf644500ddd5c3de0b91908cf assets/airdef/models/block/barge.json
a4198ff647de5866919f9b82a3d53e6486fc56cc9f175f7907951d063a6f4526 assets/airdef/models/block/beam.json
050bc585d82f48b39fee175ba795fafb642fc2658c932901549d8907e5d63401 assets/airdef/models/block/buk_chassis.json
384ef97ea938470c82e68ab9659b9cb67cb69494fe3b9eb14357107bc6ad416b assets/airdef/models/block/buk_chassis_burnt.json
e2b031f4edbfd8395ff04311bca5bb3c4a2fd5cd7022d1070c8c7f67a6aa7177 assets/airdef/models/block/buk_rails.json
a76b58dd0c282353b9101beb48774d5619369303e0665fc821458a47cd3d0402 assets/airdef/models/block/buk_rails_burnt.json
bdbb639071f3ff18fd624f3179aeb5dcd1216828b599e976617c13e5c77d115b assets/airdef/models/block/buk_turret.json
7f1248fe849972d51a37c4162d92991b5567487b5415e2e9ea2cf43644bb500b assets/airdef/models/block/buk_turret_burnt.json
684edfac71d15f328b92aee2f2cc508532f6654d5a664ac78c13b6868c3307ba assets/airdef/models/block/debris.json
aef93931af7ed7bf71eca89070adaf52af9e0d8894d3d4e36c926b219e196e02 assets/airdef/models/block/depot.json
107aec61d1dcbbfc6ec9203cf8590c23cde3cb22e8debc121b6ea30d675e6401 assets/airdef/models/block/depot_burnt.json
89ea90f53a06814d71cec4d8e89a43d7dbbc4536a8104d099b743332cabafedd assets/airdef/models/block/ew_truck.json
b7cd8f04aaf8a6c5b7021a59dc7f6c60e29b1159237ba666d7eeefb91254f348 assets/airdef/models/block/ew_truck_burnt.json
c00df220e46a265c5a33e68df1cf407dc1fd25bb70978b6b8d850d7826044f3c assets/airdef/models/block/fireball.json
fd5ca8c85d054580e6f95cb88bfed6216c9b41ddab9efec2eb4a642ad408c17c assets/airdef/models/block/flame.json
3962ad6a9bfd88ed4638dd72d27ddbd0933583e7f0d40b808ddabfd6e3a35399 assets/airdef/models/block/flare.json
6bf4a388b9a38a5359053be38cf09f1c86d0ea47d61e5de0016b2b1c7f7dbb4a assets/airdef/models/block/flash.json
9fa54aaabfc0dd1b6af4fdd1fd4ac9a4248c4837801a7d74e441627f565a18fa assets/airdef/models/block/gepard_dish.json
993b426346250e248abea70a461a7e9343d264585997adee252d8f803d635acd assets/airdef/models/block/gepard_guns.json
9f5b572110c02fbae42a1aff8db716094d198b209d19dfc2e6a72430378fc179 assets/airdef/models/block/gepard_guns_burnt.json
288029fc93a84ee5d68605d061bde9fdb31b759a4a17766bb8df008beb9c2064 assets/airdef/models/block/gepard_hull.json
743dba3635ae9683e744c1691315a104e48e10011cfbe6e74565eecd6ce7dfcb assets/airdef/models/block/gepard_hull_burnt.json
38ae7a16ba13e5ac0cff7f8f1856e24b512f4e68bff7b71297b3d2838333fc2a assets/airdef/models/block/gepard_turret.json
cf464948fd36c74cf6f49b10f4f2e0e4b9e52b9d8324c27302e8e1c2ae8ae73f assets/airdef/models/block/gepard_turret_burnt.json
d43f9cda780b1821a6e778555a1be5bd112a1d9655ff96d35acebec61cf11e0b assets/airdef/models/block/geran3.json
83cf0a42e59f6aee1039925f849398960334f2545558409e7ccd7173ac841d73 assets/airdef/models/block/geran3_flame.json
ac93e872743844465f0688162dddd7433352e8710612dd391a1e92b7f36e6b0d assets/airdef/models/block/gerbera.json
8c273c2e02dd1884012f5597a6ad2022a0cfcf56c9c5b23043729d3c34635783 assets/airdef/models/block/irist_chassis.json
417c68e386d715da987c2d2e9fe86d25dcf0d923546b602d5f01c1eba1d25543 assets/airdef/models/block/irist_chassis_burnt.json
db7b914b2d027b8ebde485b940951244e4bc4cb614eb2eefc49ccf0b26c98bdd assets/airdef/models/block/irist_missile.json
89453602195bc437ecb70885ad4475bc04ee300eff377682321bc3cd1113e9d7 assets/airdef/models/block/irist_tubes.json
c8cb7d80202b72070e955015d4abeffb33586c9fa94a3ca29311ae68cfd7ca9a assets/airdef/models/block/irist_tubes_burnt.json
be87037b84cd45b2c53d7fde6653f51769e782eea1b8586383afcfababf19ab6 assets/airdef/models/block/iskander.json
98d488ca797fb3d0193957b8a93a82f48376a89f8e091032e6365d1845016a90 assets/airdef/models/block/kalibr.json
be9eb1389e169a76732f16e604499260b147b524b4c929d2a4e77b580a032fb7 assets/airdef/models/block/kh101.json
ea7c9af888f1285502b2ca51114f226573311be6fb90b8d449b9f75cfbb6b885 assets/airdef/models/block/kh22.json
7b73015e33d85d4cef31660ef874ba8dcd6e07ae3a95416d0ead1d325bd0cb6c assets/airdef/models/block/kinzhal.json
911e55de28058e1723a61d15de80676789e57ab15bfc09fd75757cc85095fbcb assets/airdef/models/block/lancet.json
15c9884ce51311cf4b9b9d106e142e33d72c5ba18dc9236c5b51da01232f8e44 assets/airdef/models/block/launch_ramp.json
159fa5e3b6a096a1872dec2a550bfa30575d57519654a860607e20dab8f35f53 assets/airdef/models/block/launcher_truck.json
2069198ba24a932816f504fb52f53dc4bc740cacaf45637698a17d9175da851c assets/airdef/models/block/m48n6.json
61968f4d63adb6dfebd5770ce5225f4cf5610a7d7ef863c4665875e82abb4f25 assets/airdef/models/block/m57e6.json
238ed59a5dfa261272c0a4dabbda3ee62d2c1c7b9829cced644bf8b2322d4bfc assets/airdef/models/block/m77n6.json
e8786fdbf6feb21f4819015c7faaf87983f4a7ed88c6f7917bd49d1b14eb9140 assets/airdef/models/block/nasams_launcher.json
574e069129ff20e6137b469740cb279e16e70beeafd1e933c22cd7dca583d74d assets/airdef/models/block/nasams_launcher_burnt.json
56a0a41e54b5056689525909179651dd5ec0fe551c06bd2bd2b1edb63c369aec assets/airdef/models/block/nasams_trailer.json
79b26bc489f644afbff7a9f616481b81034fb13a0590e88ca1753601eca4f3ea assets/airdef/models/block/nasams_trailer_burnt.json
ce9f0dc61bd07cfcb3be5cadf12df0d2ef1b3167aa2260bc4b42414dc3825b58 assets/airdef/models/block/p1sun.json
b5346ab109ffb8c259592574b091a47bd396de34b0fe96e684b672316cee6b86 assets/airdef/models/block/p1sun_launcher.json
4457a527977ad5c7d381991ca1c9d304cac0b7074f632a04328e836bfd83f784 assets/airdef/models/block/p1sun_launcher_burnt.json
2e11e64942baaf057ccc60f42dc629f7519eb3892198c1906c22fcfa95584e7b assets/airdef/models/block/pac3.json
ba90d89595c3c5d6d5a30f1041540f50504676bc64df5cf34038f8682307bd3e assets/airdef/models/block/pantsir_dish.json
622c6f537f6adb99b1a536e38eb0a5d30c05c62f671997365ff0b2e9fa9e6562 assets/airdef/models/block/pantsir_pack.json
5413c0a546ee6b82dcf3e782d6cdab4e7080642b71bbb08a4b800a9105f39216 assets/airdef/models/block/pantsir_pack_burnt.json
52aacf44022a6826bd88fb161bce81966033de89f67b9d505c1b649bcf294eb0 assets/airdef/models/block/pantsir_truck.json
53e0a5c2bec25e3a52eb52bb098c9aa186f737fff7d6c5b2ccf927c313416e1e assets/airdef/models/block/pantsir_truck_burnt.json
84857ebb50b8e98796e3907d8ce4eeb8427c29de9f81aec7a8bd8d9ba4ca516e assets/airdef/models/block/pantsir_turret.json
fc3555a87f290805ad723607f034d90645799cd8d9ef3ba42fedee2a50248059 assets/airdef/models/block/pantsir_turret_burnt.json
33884253bc3ab463d15a7fe952187b1f9fb3b78632e825e1fabc85ca5335b543 assets/airdef/models/block/patriot_launcher.json
3fed073655020b72dd515dca7eb362f642af50251b473012429a5ca946426f8d assets/airdef/models/block/patriot_launcher_burnt.json
2a8ca10c933e236270810ce9de40c83dfd8016b0f0d32413433e3a5f8622329f assets/airdef/models/block/patriot_radar.json
eb9a8af98374220657fa722a281bc18f6d884d51c5869975d43eeabcb171677b assets/airdef/models/block/patriot_trailer.json
428fbe50bea9dbca55bc4933b15ccecf57164dc915e661ec9ae610151810a50a assets/airdef/models/block/patriot_trailer_burnt.json
4c3dcfe38527de677cc1d56f6fdf17980604b93ce24f4337b3f825a2f446620b assets/airdef/models/block/patriot_truck.json
e5733ece9b976998c9c6a6c968b17cada868b62d9e2a7bc67e1272e333e35399 assets/airdef/models/block/radar_panel.json
b0ae344fbad3b982e7821cd1b26f9922e2a5713b38107521ddc5a5b2abd9e39c assets/airdef/models/block/radar_trailer.json
088f42bb17f4d6099c16adf6cc0426b6d04ff9a43abdf125476a3571e924fcbd assets/airdef/models/block/radar_truck.json
4cd60dfb1e598efbd127894b0ad1b1737569dfc0fc5c9b193034f9125daa9c15 assets/airdef/models/block/radar_truck_burnt.json
f102765390762d45954b26405b6acdde845df7f0a319bac53d25508e768957b5 assets/airdef/models/block/reentry.json
0a27047ed66a18b513c5113e4b4ba1d8667ac75d36f661e58415a9d6870c0bc9 assets/airdef/models/block/s400_chassis.json
b86ccc0a31dc63239b598542070173ebbc6d354b3d8dec880c62ea192d6ea9f7 assets/airdef/models/block/s400_chassis_burnt.json
0107a6689048c7267ea1c68713593026fa495cb33443a6b1aab815a17602fe81 assets/airdef/models/block/s400_tubes.json
6e4a35843d29bba30b9969af7ed9a60ad05ea4e4b9fde0507ea6ba6dcb9d7dd7 assets/airdef/models/block/s400_tubes_burnt.json
088f42bb17f4d6099c16adf6cc0426b6d04ff9a43abdf125476a3571e924fcbd assets/airdef/models/block/s500_chassis.json
3e35ed19ea22c8309ac7c0ccc9c12e31b2387de97c6b72ba519bae34f681c58c assets/airdef/models/block/s500_chassis_burnt.json
6ce00881e2a5107874e82e94a243fcd95c3eb6e9a24e8839c11227c8e330485f assets/airdef/models/block/s500_tubes.json
88109407ec749d6f45a4a38a9a43fd1594ac6190d7f08433b07da572c040458d assets/airdef/models/block/s500_tubes_burnt.json
536928e90a907c50b078a488bad49061f79aafe12479f58a2b2ce79aaad5adda assets/airdef/models/block/searchlight.json
0e3cbae5029377886325aedc43705d9975cb1dc889a37613b92a97bdf36b4db7 assets/airdef/models/block/searchlight_burnt.json
2d790e8845598e8cf7ae577492632ef52e075d590011e66fa8ab686096cda313 assets/airdef/models/block/searchlight_head.json
f0f9ec6cefc0750e4d6cb330c21df8c2bcf02b1b7408f90f40ebb35a572a13cb assets/airdef/models/block/searchlight_head_burnt.json
b2e28d73f50b238f6bc7d6dae560695e70ae8b843f6853f9e468bf8d2b371e6c assets/airdef/models/block/shahed.json
d5b9ed76b1744d0c93a809b6a4c16bc3dfa7ff4784e3cb9968aa36b12fc30484 assets/airdef/models/block/shahed_prop.json
465e6aa2a8ac3c563b1e546fc844a0520fc89f3ce528d49f193aa6c21e19308f assets/airdef/models/block/target.json
69c5c103c228abe6162f2e1252139689b768ff8dd3f835f8f79ce7d324c3f124 assets/airdef/models/block/tor_chassis.json
81e66314bfcd4786950ab4c23c6ede79ff9b30687b44f23e2b7b9be233fe380f assets/airdef/models/block/tor_chassis_burnt.json
b2d2f856a4004853cb799785c0a586d605c623759d5a099dc70e19ba6a67b100 assets/airdef/models/block/tor_turret.json
e95be4a89de1384038df47861705b934b0091f61f7b135c9ea6933fe2197f31f assets/airdef/models/block/tor_turret_burnt.json
b2380510f56dc60e914919cb6e20b82e3613a9d62b08a1c81740f960569c3425 assets/airdef/models/block/tracer.json
447d237b290255f01c7ce51047df3de1b9261cb75cfeb83ab190f6abe914747d assets/airdef/models/block/tzm_truck.json
1954ece9e339d89a6ce63b78d255637011b3661572fdfe051ea4814a891746ce assets/airdef/models/block/tzm_truck_burnt.json
b85d09a73a1c251bbc76945aa8f99b5e7e606ed41e1d120f811a435f441b1e96 assets/airdef/models/block/zircon.json
8360c341dbe0aac6c485bf0d2c9ace49378173f1749f5b52793a79b98697685b assets/airdef/models/item/cam_geran3.json
5b35d42715fa9c4afe8d0bccf5060380a8b5216edc9509ba0d859e8e953232d7 assets/airdef/models/item/cam_gerbera.json
f06af8a128e76975bdf3178e7db1453d01a888f06b2b5e9f997fbc43e0b1266a assets/airdef/models/item/cam_iskander.json
300ef4f62f89aff9b59567da8ea32749abec2966c77041846835953b2dca0d0e assets/airdef/models/item/cam_kalibr.json
8e7b91623d1d3e72dab4e701b506eb1de6c87ab774d3f6e6ed9ab76a4d82270b assets/airdef/models/item/cam_kh101.json
5ea688448ece076431da2d4539806211825c2a63b816d00416f946351b92f509 assets/airdef/models/item/cam_kh22.json
c6977e576991ba245796d82caa87a0ae87105bae7067ae2bf33d933779bfcaa5 assets/airdef/models/item/cam_kinzhal.json
56400ae5307d0f02273ebf919f12f7263e5fe410a3e1833b83a5f19d32ddb9d5 assets/airdef/models/item/cam_lancet.json
ee660fe4736f801646f944d54144f72a777048c269d828d1b71239c8732b0730 assets/airdef/models/item/cam_reentry.json
99637f9ce3aa4ba3517b682890bbf7e35835145df666a9f74f53302684ecd1ba assets/airdef/models/item/cam_shahed.json
182acfb9fb1d8f42741378a0bf17484c89a757118ffd7bd3bdd9bda2941906b9 assets/airdef/models/item/cam_zircon.json
c6cba2497f834e0a0e0dbc7604afeb1f1106e0c64e2a1b497a66e932db0ce073 assets/airdef/models/item/deploy_buk.json
43ad086ad7cff44263c30b2404bb6266834f36310ed044e661537bf665f12150 assets/airdef/models/item/deploy_depot.json
5047028c52687c2c789be9d339f97d520619e595ea7a33b589ea6ef45d41f067 assets/airdef/models/item/deploy_ew.json
a5d0450da8d6757b9b45f86e6b36977894dd9e3e1278c3668b99a8acab36f399 assets/airdef/models/item/deploy_gepard.json
e80ee81085241b9712e024bbef73ef744ef5493eebb88770f4912d8aae138fe6 assets/airdef/models/item/deploy_irist.json
637b9bfe426ee50045f8fcbfd4f25dfbc1d819ce36dedbd60abdac63c274f088 assets/airdef/models/item/deploy_light.json
3398844e90203f97ee73e35619684c775d1856c8ca3399b5ed682786170d9327 assets/airdef/models/item/deploy_nasams.json
76019c05d070cf1141cdf95b744b3d6198ab99469dba1f88a6903de1de303bde assets/airdef/models/item/deploy_p1sun.json
8b1af688bc83be5a8065673bca7054deee86ca425f0bfd1fb4ffb491844e90bf assets/airdef/models/item/deploy_pantsir.json
d0091d809816350c648e532a90ae59a3c5c9b94f837b2a46b5695e7f985a00f9 assets/airdef/models/item/deploy_patriot.json
332f9de8c88658a562748fc7a6de7ec37847273ac0cd0733e23c0dff4b44418c assets/airdef/models/item/deploy_radar.json
9ee1dfa7d2c2587c85a8f73554962a02a06f1969a9a56c56110162dd93db7301 assets/airdef/models/item/deploy_s400.json
55f1dae5cd7f0126533825c9da3ed3ee316e74d4be8f9bb217a3ebc6f827d24d assets/airdef/models/item/deploy_s500.json
aca5ea765ca0d84ed12444bf433c024b57d6ea9b9b7cf06449dfbd6892a66ae6 assets/airdef/models/item/deploy_tor.json
67912cc711c2f1514e6e2f8f17aa340fa9a99534fbbaeb2b8e319aa6ef80f38d assets/airdef/models/item/deploy_tzm.json
d0976d990130c6398f4adf5d3086e9cf50e5a2adbec081e4682e810af368c332 assets/airdef/models/item/designator.json
b20ec2241060123a208ef66e86183261a7514611206b1b4389d2ababf40b6ca8 assets/airdef/models/item/flare_gun.json
4fc1487d0ea8634bd164c01b54833d243933fd4dd8e40029d3c662f6c0afe9d8 assets/airdef/models/item/launch_geran3.json
a5685a17b3169165faa53b27357933388fcc46ce55ae73049360ca0deee58c64 assets/airdef/models/item/launch_iskander.json
ea86f852034e540430bd7c624fec49072eb775fee4263e48f22e722387393fd3 assets/airdef/models/item/launch_kalibr.json
8954e2d7de8f39b2702e5aec20b10f39cfc4911acb4f7bb438fee76948040487 assets/airdef/models/item/launch_kh101.json
6a27d903c35b802c6a755e4fb24af5bd38421b0dab9f6e39425cc7b5236a3d2b assets/airdef/models/item/launch_kh22.json
148e92840c1884c4915cb14316f6018fbc96ad4cd701f3871407753e5bc4ecd9 assets/airdef/models/item/launch_kinzhal.json
8a2a008a9b58913120acb044a72403679e57c1daa55e9e115676576e7f7ad217 assets/airdef/models/item/launch_lancet.json
dc99bbddf69f5666530f35bd5d92b0e01999afaa69e007f00f03c06bcad8065f assets/airdef/models/item/launch_oreshnik.json
f2fae6baddbb09d9c1889657a58bf9b31d012e357118108c3f8ff45120e31a2a assets/airdef/models/item/launch_shahed.json
45f5442bc6b47c15955f2c080c28db7cccd7485c9fa70fc4f3e9ed976b74ff95 assets/airdef/models/item/launch_zircon.json
0528d0a362eb44e43ef26771e2527ba14eb6f59bc7e71a6b7dd40a908e469b0c assets/airdef/models/item/mover.json
0be54b1273d2a3ab75fd4c643004a753643a423db0ad099b80f4bff66a6b9b4c assets/airdef/models/item/remover.json
b68f4a6564e4a76d66502c9f765d967d80b66f0f8672309dddee5b7928d2ebeb assets/airdef/textures/block/antenna.png
8123e74fd56c6fa211b7490373625756e8792b3d17bb050d0a8ed81689bb2ad3 assets/airdef/textures/block/bare_antenna.png
09a2dd50acbaa8bcd08c9063730898e9601c74661446279c3c2cc10a373a1f1c assets/airdef/textures/block/bare_antenna_b.png
048e8d01ddffd59665e07c1954a6e2d725089dd9ac84933ecf85f093d048751d assets/airdef/textures/block/bare_barrel.png
0decb34bef6baf602616899053ead3378dbe798282ab2f6c931f20c98aa67820 assets/airdef/textures/block/bare_barrel_b.png
dcf8d38a0275fd73c1c1592384962fbe0f0327103414fc8a00c41e8758af113f assets/airdef/textures/block/bare_dish.png
3b5cc50eaf6d3eaf6f6fcc7d7f28935980420fa2a5f8ad680a4790d09f58313f assets/airdef/textures/block/bare_lamp.png
fcb6a151da7c25d755c012629ad5f8b55e2de951ce745c2d7b3b2f0d191babb1 assets/airdef/textures/block/bare_lamp_b.png
de9acb4f73b59d80a4bcc6d0569dfb286d627991c40c1643d7c3c4535a02ac56 assets/airdef/textures/block/bare_nozzle.png
b83bf9da366623e9060981ad77909398ecec55c2f0a7a0aefc941070db961c5b assets/airdef/textures/block/bare_nozzle_b.png
070ce486efc729c756072e19e0f469763e1401289889c302800e45c68d1fb92a assets/airdef/textures/block/barrel.png
b3dfaf452ffe4569477fa270e73d57e16eaed0e7639276336a0ffa4050b53507 assets/airdef/textures/block/beam.png
510a01548a0dce9d3caf767f1a9f172045b253be4f3b3e98e367a281f550ec55 assets/airdef/textures/block/booster.png
7de5699fd66a82395d03e389d2ba0b2aed87d7a52c4f7bcfbcf9a6fdb34ca301 assets/airdef/textures/block/booster_b.png
2d9d72a90bee81630131ecf60f84b74691f6f16e8a7427fef62e2fa7923e5334 assets/airdef/textures/block/canister.png
909b3acf5ac784c4917acba2e1e5c290d412c71fae5457e3916a770c9b20809a assets/airdef/textures/block/canister_b.png
082e2d3fd7df0d548c294c410a6a7643b54d482d76643e0737bae1dcc54736fe assets/airdef/textures/block/canister_front.png
53adaaade4d845d87ec3535adddfba8c0fc84ce373e9722650508ddc0fec7420 assets/airdef/textures/block/canister_front_b.png
2248649b97f906d44208c5d83be7482867e1439d7bdab3e974b0b63af5224249 assets/airdef/textures/block/concrete.png
e43d62b61bb7a5cf7973be9bbe197218c63924c87274852a46c1deb7713c5f30 assets/airdef/textures/block/concrete_b.png
68a21931d31f90f74f1101047ba307b9587ef2cd13139b03fb13cd1119f5d551 assets/airdef/textures/block/crate.png
5fbef49d433c12ff76842d66d8b6bcc9aaeab0e7d47305523f97d12842b2ff0c assets/airdef/textures/block/crate_b.png
331cacfccc1c61b16a986b33e422d751e57cd8d07cb064a012f762105bfca93d assets/airdef/textures/block/dark_metal.png
d262dfab6a1e4bde719f85493246c2ccecdd367a5240d8e7a25cf0eaef2bda7a assets/airdef/textures/block/dark_metal_b.png
deee51e051cb1fdf7495a8dc1fa05bf9a166df9cb5e7e446bf3d29cea856efb3 assets/airdef/textures/block/dish.png
698c9095df8f6b48c260759655797e67df35b390791ceba3e7ce2eb3cce3f8ab assets/airdef/textures/block/fire.png
8532e81c8d5056db50100d963c84522205b6ee698081081a1888f1ee48b7bf08 assets/airdef/textures/block/flame.png
b5b75b2380ed2a12a7ae1b0618efe17358e3a9ebcb9af5de46adf5195dbba271 assets/airdef/textures/block/flecktarn.png
6be4508463644978a41aa426cf9fb547ba2c313e22140b6cb898dd1f54e7446e assets/airdef/textures/block/flecktarn_b.png
b9d5beda0383b16f3f52bafad020768929f0910d0cfd5555b58d176c2bcbf3a7 assets/airdef/textures/block/flecktarn_s13.png
52ca49f3c196de9b7216a1a40b359942a2da3204a39aa0716b085a67dfc073cf assets/airdef/textures/block/flecktarn_s13_b.png
e6b307c552e008962695052a7136cc9037ca8c524a697d8450eb591cd7518e78 assets/airdef/textures/block/flecktarn_s21.png
979c60d195732ac97352408a7e42aa645de153dbf1c55bbb3d72ccf65f8fe695 assets/airdef/textures/block/flecktarn_s21_b.png
1aa7201611114554ce054a9d46a60da566f9c571f2a4ff4ebd4133258c0bbcfb assets/airdef/textures/block/flecktarn_s23.png
7439316183817ce23322367cbd51c80602f0e1537977c78b410671dbbab166bd assets/airdef/textures/block/flecktarn_s23_b.png
5517a43554b08fbbf5cccec8e537ecb23965e1472323e5ae4ad1420a12fcdd4a assets/airdef/textures/block/flecktarn_s31.png
5c44f0f17d55077549982690bab313711543fd706ee944207759262c76e00cb8 assets/airdef/textures/block/flecktarn_s31_b.png
007b3b806bc431be1c84dfca31f2f81fb4c2f9d5a522e40ce0d03f5d3716b106 assets/airdef/textures/block/foam.png
7f8f88fff7d9f4b872ab7bbdc3e976e782730d91355941c7837ef2f7685730f4 assets/airdef/textures/block/geran.png
014374b3428ffe8c438c0df2806c3edd9215c707ec3fd4e18edefd304b9d49cb assets/airdef/textures/block/geran_engine.png
d44f6059ac51c7d707dcb8152036f87fe83af63cdece0c06f88370b5db56a5e0 assets/airdef/textures/block/glass.png
5f733a261a5e8c8eb6a7837ab1e6e28d058410d7cca16e124d7b315f032c3ebc assets/airdef/textures/block/glass_cab.png
b1a6096232481d08c710ff4133547badd1e0061676d6fc4f8e110e691c5e2e0e assets/airdef/textures/block/glass_cab_b.png
3c836747774bc6c5f037eb5f6f581e9b9913c365747251f702164690ec3241e7 assets/airdef/textures/block/glow_ember.png
8f2a9c7485d8b56c7bcf26ee3823f9efdea7ffdb5e3d770f991228cf883eab30 assets/airdef/textures/block/hub.png
0b9e2514e1ff4832ade4ce231e4f1fda0ad50c16ebe7ae726ad749e454e07e1c assets/airdef/textures/block/hub_b.png
216ec5f36c0ff06a55fef95691190eedb0d3ca7af5c42b97c7b901eed91fd1e8 assets/airdef/textures/block/intake.png
d76e4cecd7bedde37dd5d7f84fa2ff6c2b690bcc6e3d9fe905589e24849c045a assets/airdef/textures/block/kh101.png
3413594a1f8ef3e91fd85660114f81c36f3db54a03dc645d030b545d1ba16e4e assets/airdef/textures/block/kh101_belly.png
b52ffbc21c7d5f7cb4d48b5b379e09e92656c5677b8aa66116483521fb3cb7e1 assets/airdef/textures/block/lamp.png
d6f46d3018b98fd8d5f36df9ca11c07f77e5983e05db24bbc59e10916dd6efcd assets/airdef/textures/block/lamp_b.png
e25bd88852f4a50609487eea13bbf58f397c54926f0bc9d02e9d596b17e058e1 assets/airdef/textures/block/lancet.png
d76ebbd51a689233c256776b2c55893268f40567b2c059c9805e3cfc323307ec assets/airdef/textures/block/metal.png
b8f411252cbce0a9e08cccfb4788aad721f6d7aa76c05a40a1c25757c27404d8 assets/airdef/textures/block/metal_b.png
ca2f6efebc39b27cc2a3f974b3ea6b4d9f64929a8355ce4ec1d96ce44fa3dca2 assets/airdef/textures/block/missile.png
898c77bf0dca8c5242468969ad9ae96d13a36d0cfe2c12798f9f51804244a840 assets/airdef/textures/block/missile_grey.png
bf63b80d2b4e9c2f229fccf4e1162dafe738e5522c4784d5262f89afb3eab2cb assets/airdef/textures/block/missile_ru.png
b3c7d5fd6518311f7d22f5b98c9edf230814716f5ec33e5ef84747f49cc7a639 assets/airdef/textures/block/missile_ru_b.png
22730d3a16bf4793e7a2b0cedde24fa015312f6cd91d00d6cd7767faf82f6a06 assets/airdef/textures/block/mk_de.png
9089911e06b239c2f782268c4d637a96e75e0a1eb48a1228f09201245ccc7c9c assets/airdef/textures/block/mk_de_b.png
b8c28ed48383b17725d1d4229e52db9a153bbbcce114e6cfabe6ac86dca86f18 assets/airdef/textures/block/mk_ru.png
7cc8d37e381e965b44cbb53e82626a2d52692a3085ea142c0ec9faa77cf99690 assets/airdef/textures/block/mk_ru2.png
812fee3fc8c346e8eeed2ad30e1caab80c0f6bcb5e94a33ee6d47a6732cb4ddb assets/airdef/textures/block/mk_ru2_b.png
e6a89c73305fc2429df0a7a3fab1b6e6c2d1eb2de48251f1d556dc9a8476a14f assets/airdef/textures/block/mk_ru_b.png
a9de0c4e228fd2adaa996d0ca6123b6d8195fc5c1719201ae741c4979882f1d5 assets/airdef/textures/block/mk_star.png
0b2017578093f28b49994c7d120fa19ec4fa07d137f5b8d30c58326aad00164c assets/airdef/textures/block/mk_star_b.png
8526c1439d9723d1592c3c7c06da8bb8ac2f44cbd5310aaa689019e583a1cc19 assets/airdef/textures/block/mk_us.png
65c308e09a0ba82cdffb87d334ff5bb517a27c2d60d6244cfd53cd9bff1c5080 assets/airdef/textures/block/nato_green.png
5bf07917f3ac31f74ccffd1ae381e8d62f58fd4748bce7b16bfb135850c9bd74 assets/airdef/textures/block/nato_green_b.png
66eb485456876d3aea75cd5e3afb676998d3315056041ee04220b7708ced7856 assets/airdef/textures/block/nato_green_s21.png
76174f752b5e96040e0edb0454b4fcd354182d95786033e396e2bab29e57f2e1 assets/airdef/textures/block/nato_green_s21_b.png
09af19d2003a7ef61c83cb4d4ce4ad544ca760d98c2cb60a881ee9039cf39d65 assets/airdef/textures/block/nato_green_s22.png
6559a12b24829a933b05b21a4bb673878e53bdfa5d7f87a8810c6e9e27ea3d00 assets/airdef/textures/block/nato_green_s22_b.png
28737c6592a187f4b542c18084dfa26ef5e2a5ef0424b94fa5fc1db06825f0e8 assets/airdef/textures/block/nato_green_s23.png
d5aa425480264085dd0a5654e97be78620a28b0adb2e17ad952d6faadcf5a168 assets/airdef/textures/block/nato_green_s23_b.png
491f3e54e771764a9a3a345a577838bb5c2d4bdf80a827d05ff6e8d98f229551 assets/airdef/textures/block/nato_green_s31.png
dd8c9056df254fe788a6848c96af4e56c5951f08717b25ebaa8815d1fb823751 assets/airdef/textures/block/nato_green_s31_b.png
311932fa43f0b513342e8f3746208bd845b936f883f6f5c8d453fd4ef6d315dc assets/airdef/textures/block/nato_tan.png
6a1289cfba0b17732cedfadceb6522b0b9f847f754dd56975716b3e2e21e777a assets/airdef/textures/block/nose_dark.png
a8c0d707e86dedb7591b7e89f07ffd2df85ab0763e903a4c4c9ea17c97c4baee assets/airdef/textures/block/nozzle.png
3f908df37b1456884d840e346f2da60ccce5b61bd226317de9ca24430bd59a8c assets/airdef/textures/block/prop.png
98442b32c81dd7523c8d38bc6ae9810d8e0295474c814533b7fbde33f4910e3b assets/airdef/textures/block/radar_big.png
5dff7780ae2f934bc05bb58742f1965b7cf06ad28459d6dd63b574a0a327f949 assets/airdef/textures/block/radar_face.png
83a012f282bf709a4628530632fac3e0e3227325e931d7774747d7b95401d3d3 assets/airdef/textures/block/radar_face_b.png
144ce90863430c724243ba27bfb212ab1e4e5ff58650d0ec884b14d47232ddeb assets/airdef/textures/block/rm_dark.png
46a86ef31a44a7672c9f5d4e8c0605b78964ccbe1131127dd94d9c4bb307695e assets/airdef/textures/block/rm_dark_t.png
5e37e431975e5816388bf8906cd8d32e41b6ee44760f355983ca9c26cc004932 assets/airdef/textures/block/rm_grey.png
a0d4a601d1d29b1988e73b74c1198c9b6fde7cbfccceb3057465b22124959fd2 assets/airdef/textures/block/rm_grey_t.png
0d21b93c78581892e08f31ca8e4f01c9edf7793f61bca78c8ee72cb0bfa2e40f assets/airdef/textures/block/rm_khaki.png
e832c1bbff632eac6c9b62be8d68981a62a7837499437c0b85f9fb51a4506550 assets/airdef/textures/block/rm_khaki_t.png
4a8e048b9219e04b143da3ae9214f12fa96c68b4e33201b9ec248d65c9eb7f7f assets/airdef/textures/block/rm_pac3.png
08c9ad4315458bc7c59632aba8bc65de051639b2039dc9d252b79917dce15f7a assets/airdef/textures/block/rm_pac3_t.png
a4280a067f3c3068b3ba47dcf4881e091e70a269685e192e19dd025bf7532843 assets/airdef/textures/block/rm_ru.png
7992de763ec57589788eedb01bfb656cec49e73d3dbc15b13aab8a65506c6f5b assets/airdef/textures/block/rm_ru_t.png
c9abfcb99b86708b009270d96a563036c4a539c7f7cfa734c94a97c05b3f36ac assets/airdef/textures/block/rm_white.png
f336ac70e704bbb2977ef36ce6c6ce853ad57e3970cc44ee16eabb9554485707 assets/airdef/textures/block/rm_white_t.png
b92c1817d1b25d626b03998762d8ae29900f6ef41d883be35191877edd4bfd93 assets/airdef/textures/block/ru_green.png
4c932c1c226af577ed1b77c6846d1f1ce95e5da053bda1723e7ddb3442d78959 assets/airdef/textures/block/ru_green_b.png
c3cea9d7709650d88515a7fbfaeec719eb3395687d10cfd92ce374c8b1687e4d assets/airdef/textures/block/ru_green_s12.png
b8cdde5132e534c46c7521a3612b490656221e7549045990edf881f9c2e5e388 assets/airdef/textures/block/ru_green_s12_b.png
bc82211cc1a25e52672452dcd5cd533e818821c79b06a705402d2aff36d48286 assets/airdef/textures/block/ru_green_s13.png
4ca0d50c73cbb661c6c86da9dc2add011da52de185f844dd7dfb515c26b9db72 assets/airdef/textures/block/ru_green_s13_b.png
58729c9509463a4858dddd7fa0964de696e265f6c409a77fae732a73a177542b assets/airdef/textures/block/ru_green_s21.png
167683d721a45aa4a104edeafdf231b0eecfc70442b59e190460009bee3b2e89 assets/airdef/textures/block/ru_green_s21_b.png
7795c2ef19789d465b9b1420ca3aacf1201b0904414b7e39610172c84049c0c1 assets/airdef/textures/block/ru_green_s22.png
5d92c96f896314e390711b66c07922c832a443c3c56e1daec2890195960de47a assets/airdef/textures/block/ru_green_s22_b.png
1c53b3d9cecaa5ed932bf1d58a864e77481e515729c7a72c4032a734897e0b34 assets/airdef/textures/block/ru_green_s23.png
c1424132a342f0245430fa165a1b2d18b6660580cf4ae100fcc19dd3feb2e20d assets/airdef/textures/block/ru_green_s23_b.png
ba4511238d904460c91a55af88781560eac6272de937ed57d54714590f0a3907 assets/airdef/textures/block/ru_green_s31.png
c7425dcb7916bb5ec48bfa57def2a8d6a44a88600fc2651392e9fae8b2fe4888 assets/airdef/textures/block/ru_green_s31_b.png
934ea2bc2623e42310adc0dd67e01916cd21dd32dd691ed759bd681cac958525 assets/airdef/textures/block/ru_khaki.png
abab312510b9d18ca5561c8424aa0ad33feab2575b8d5dfe42b0a6bf31430bd3 assets/airdef/textures/block/ru_khaki_b.png
055fdbc8ed3b808e263a13f694cce5444c4fada440994cd443103d308d54fd20 assets/airdef/textures/block/ru_khaki_s13.png
a53ae1e5fd32671e28d74a090ab3828e2dc34ef223b4c71a1b6f535c0e999a9e assets/airdef/textures/block/ru_khaki_s13_b.png
8df45cca0a57f01efeb85da2c76544c2b103fc474387aa76215d093e998242d9 assets/airdef/textures/block/ru_khaki_s21.png
06dc7f90bdf077097534322435be402f08e5ee9bc34135e410809a8892b940e8 assets/airdef/textures/block/ru_khaki_s21_b.png
194d9a3bca192f65b4f2e808b21c28346dc85d96e923e4bebe2138bc5a0b4927 assets/airdef/textures/block/ru_khaki_s22.png
5f8a6dfed6add932e9b4d0cee17adc4c811cba85433877fcbf0f7dfd9773cb1b assets/airdef/textures/block/ru_khaki_s22_b.png
6d0aa0dcc1702486c764120f49fc3e2ae6f17c953b8c06f53b395a645463a666 assets/airdef/textures/block/ru_khaki_s23.png
cea69d4094324363c2088e1f186927f8dabab4e7eb34a97add85587ea144fda4 assets/airdef/textures/block/ru_khaki_s23_b.png
898405a1bb59a934a499409431003f3dc44a5f3f1874f41e28750f2c4331befc assets/airdef/textures/block/ru_khaki_s31.png
dddcaf43fdf7f5d4e2433c72e5267548e3cb7d65c745082be0a60b14367810b0 assets/airdef/textures/block/ru_khaki_s31_b.png
76b33dc7f36d1a67cb07f9e65b5dbda1e07ddbc41fbc75ad974654aa1ebacb57 assets/airdef/textures/block/ru_khaki_s33.png
3e81c65d26070613c59fc6e363f0e0772c376bea5ab86c37b6cb19431a816535 assets/airdef/textures/block/shahed.png
36e55b26a48838c8c25ec6d1660cfcdfcb1fc7e430dda0ac8908709c0c05f9b4 assets/airdef/textures/block/shahed_nose.png
29a415d359ee6eb0b4b85a18edb38b77e852b5f6a441abbeb9485d1068726e35 assets/airdef/textures/block/target.png
c047457ac99436c8a69bfc7fa755fa55e842ae3261890965df09a2011b8e6201 assets/airdef/textures/block/tire.png
97bd7e7731bd1ad8e4eb582e9acd44981ac4fc47a51a1a5ec4e39d9873a42079 assets/airdef/textures/block/tire_b.png
4edb1f39d7ffeb69257098023b6c3e4709014e5d54cd306ef0930e54257ee93a assets/airdef/textures/block/tracer.png
564d4e443a0270a09cc2e7202ca73e40ffb00422c68a9842b19c10d4c2c184b9 assets/airdef/textures/block/tracer_hot.png
e1da001ded920abd57814b7301d0388a04db72b0eeac51f73d67b6287d56ef43 assets/airdef/textures/block/track.png
337e60bf5be059e10649e78da7fc98f1f12cc47ae8dddeb85f733535d3fa0c48 assets/airdef/textures/block/track_b.png
68b9407648c541ea0a167e731340358b4ef45862be13ee5a582f7e088818cfaf assets/airdef/textures/block/track_side.png
fa2848684a76002a17932aae6a0eead4af19ac594be39e1f7f995d2a239d6c6c assets/airdef/textures/block/track_side_s31.png
02fa231c4ba6ba4d42c254fe815d1e0d74308a7f8fc1735cec5f955b494c9970 assets/airdef/textures/block/track_side_s31_b.png
7a83f09a9c4feca1eb1dbde5abefa770b3623d5e491666d6835d2272d70d3709 assets/airdef/textures/block/tube.png
137d90569624d7b164c16bf5d8dec2e9c4aed8096e3ec0d96f9e3661df90ee63 assets/airdef/textures/block/tube_b.png
03570f9d5963c5fd61472d10cb68d56c26a7fd5806ccc86b86c5095476966b58 assets/airdef/textures/block/tube_cap.png
e622dfd1caabbfde83081c89447fe49c13301f4edb92efe149402e110cbcb64e assets/airdef/textures/block/tube_cap_b.png
bfd57114127057959dc3d46c60b1234e27dcdb35387a5e2b4f045ceb676ca09f assets/airdef/textures/block/tubes6.png
4a229d23538c8f22c812633362f70ee4d887e515ef1d798fcd8a26c28f715696 assets/airdef/textures/block/tubes6_b.png
92c449cdceb109949b31d49ff5627173cc0a33daf2c610748aef709623dcef23 assets/airdef/textures/block/white.png
fb41e5b003f0c16308bc819fd632828d21fe1855ad9cd32283e02de3defb67fe assets/airdef/textures/item/deploy_buk.png
0c06c682684abbf03e01d5ce7d8d2a4ece2ddc96e96bf87d7597bb0247ce3806 assets/airdef/textures/item/deploy_depot.png
d0a6911668824195a4630a60f2b618958a134dada220a269a1c1c8faf2b77922 assets/airdef/textures/item/deploy_ew.png
303c3352b21762b59eb23f2402dcb59f77983e5d2db08711414be99624695364 assets/airdef/textures/item/deploy_gepard.png
a3dd2f7ad102f84be1df0182e22f2c2189bb872a8dcf3ddb7136ac05434c6abc assets/airdef/textures/item/deploy_irist.png
805e883f86175f4f16ac8e43488f0db469f8c7d4bf0545c80439440517ea4685 assets/airdef/textures/item/deploy_light.png
e7e303005314c70ec142e69ca6511d7dd1d6daba909563ecf6e3cbd6d146a9a8 assets/airdef/textures/item/deploy_nasams.png
cfe4373e86a9e2f268e0623279da6498b4327afbbeec3b7cef0b1775f6007c73 assets/airdef/textures/item/deploy_p1sun.png
e7522f1c583dae4cdf313a0ddbf22aae837dc34e5ec36a46daa5d7cb459d59fb assets/airdef/textures/item/deploy_pantsir.png
2cffed28123c0e4eb828eb314e7ea65ced2576bb158913e0a837b96f0fb67730 assets/airdef/textures/item/deploy_patriot.png
8b015f6480fbd7d9c1078efc73f3097fe32832fb5bbd6b8fe45307cd545a3026 assets/airdef/textures/item/deploy_radar.png
37ad7777de7034487d3c53f5224eca34b5dac6de41aeb2080fdbd112ec7eae0e assets/airdef/textures/item/deploy_s400.png
4cfb6250543f00b37fdb9e09e63c3107f7d88beeeaf72838150f9f301284cdd8 assets/airdef/textures/item/deploy_s500.png
c473b97913fe7afab44d84768c80e11b752645b8bf9de9ffe5a026d3eb4d8e9c assets/airdef/textures/item/deploy_tor.png
483c40bf24d5dae5ffad872c1b1df719cebcfb1f36c1153c2a5790981588d493 assets/airdef/textures/item/deploy_tzm.png
3be70aa7d5210da09a910701605dcf8b2b4dbc5c43ef2d2c77ce36a7f2029012 assets/airdef/textures/item/designator.png
f336d25be27818f3b0f4b2fc7c6f7b3e8be9050c9f9231f5703056a4bd8c1685 assets/airdef/textures/item/flare_gun.png
51d28156b180efa780ad91dc7b5d0b5d89880ae3a35a5f228d3dc8ca1ec80938 assets/airdef/textures/item/launch_geran3.png
f6165a2f251c61a6b89711e493da86ec0facb422f6b1e0364a8b7e707d641bf5 assets/airdef/textures/item/launch_iskander.png
748f050d5229d70414e81ef204f66f075f659e78117c40818bc51cb728d73180 assets/airdef/textures/item/launch_kalibr.png
925224a3deffa75f32207bd4324f8c06a6e1c08739126a96ab83b327902e55bf assets/airdef/textures/item/launch_kh101.png
dc6e92e3386badb8b3b8fc17dffe93f19ae9a406e4e2275141d6313743d8ed4c assets/airdef/textures/item/launch_kh22.png
0e81d738ddf1d7eb1db828f27e88e45066c413fd2ade293561d9c08965232908 assets/airdef/textures/item/launch_kinzhal.png
41586acc732102fbc07a5183d91dd614a622aaecf3fed6de795e34e59af41fe6 assets/airdef/textures/item/launch_lancet.png
f446b6d8b37f5be99390bd77e4e36752547085f004b98520359b3f3e80edfdea assets/airdef/textures/item/launch_oreshnik.png
7e0643346b7ad04caa4f234c793dd671abdbb9dbc5138a3836d867aebec95a48 assets/airdef/textures/item/launch_shahed.png
a1439f23a2d368f3d3ebd1b7d26d44af27c05797c3aad76a30878a79e225cce9 assets/airdef/textures/item/launch_zircon.png
60332733e12c2c0d346db2f3b8108d0083167cfc75e8b1e0c76a52c495b95924 assets/airdef/textures/item/mover.png
e43175307463d0946562f21d44ec5393c2605537820708dae3687065d3284aa1 assets/airdef/textures/item/remover.png
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/amraam_missile.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/barge.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/beam.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/buk_chassis.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/buk_chassis_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/buk_rails.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/buk_rails_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/buk_turret.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/buk_turret_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/debris.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/depot.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/depot_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/ew_truck.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/ew_truck_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/fireball.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/flame.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/flare.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/flash.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/gepard_dish.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/gepard_guns.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/gepard_guns_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/gepard_hull.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/gepard_hull_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/gepard_turret.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/gepard_turret_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/geran3.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/geran3_flame.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/gerbera.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/irist_chassis.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/irist_chassis_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/irist_missile.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/irist_tubes.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/irist_tubes_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/iskander.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/kalibr.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/kh101.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/kh22.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/kinzhal.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/lancet.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/launch_ramp.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/launcher_truck.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/m48n6.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/m57e6.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/m77n6.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/nasams_launcher.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/nasams_launcher_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/nasams_trailer.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/nasams_trailer_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/p1sun.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/p1sun_launcher.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/p1sun_launcher_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/pac3.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/pantsir_dish.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/pantsir_pack.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/pantsir_pack_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/pantsir_truck.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/pantsir_truck_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/pantsir_turret.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/pantsir_turret_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/patriot_launcher.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/patriot_launcher_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/patriot_radar.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/patriot_trailer.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/patriot_trailer_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/patriot_truck.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/radar_panel.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/radar_trailer.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/radar_truck.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/radar_truck_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/reentry.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/s400_chassis.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/s400_chassis_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/s400_tubes.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/s400_tubes_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/s500_chassis.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/s500_chassis_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/s500_tubes.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/s500_tubes_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/searchlight.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/searchlight_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/searchlight_head.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/searchlight_head_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/shahed.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/shahed_prop.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/target.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tor_chassis.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tor_chassis_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tor_turret.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tor_turret_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tracer.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tzm_truck.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/tzm_truck_burnt.json
cc0877f40434365b45b03429222926aa0cf135e504017311be53263fd27c5f8f data/airdef/horizon/block/zircon.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_geran3.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_gerbera.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_iskander.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_kalibr.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_kh101.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_kh22.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_kinzhal.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_lancet.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_reentry.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_shahed.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/cam_zircon.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_buk.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_depot.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_ew.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_gepard.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_irist.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_light.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_nasams.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_p1sun.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_pantsir.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_patriot.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_radar.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_s400.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_s500.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_tor.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/deploy_tzm.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/designator.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/flare_gun.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_geran3.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_iskander.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_kalibr.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_kh101.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_kh22.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_kinzhal.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_lancet.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_oreshnik.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_shahed.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/launch_zircon.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/mover.json
2a3912a7c9d5bb8025b1ac16a1a1d6b5eff0360bb3184e810d319e498255bf97 data/airdef/horizon/item/remover.json
+23 -7
View File
@@ -58,6 +58,8 @@ pub fn tree() -> Tree {
]),
Node::lit("why").runs().then(Node::arg("номер", Value::int_from(1)).runs()),
Node::lit("raid").runs(),
Node::lit("sites").runs(),
Node::lit("strike").then(Node::arg("номер", Value::int_from(1)).runs()),
Node::lit("repair").runs(),
Node::lit("import").runs(),
toggle("ew"),
@@ -68,7 +70,7 @@ pub fn tree() -> Tree {
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::arg("кол-во", Value::int(1, 80)).runs().then(Node::arg("дальность", Value::number(60.0, 4000.0)).runs()),
),
),
Node::lit("clear").runs(),
@@ -77,6 +79,7 @@ pub fn tree() -> Tree {
toggle("truth"),
Node::lit("shader").then(Node::arg("режим", Value::choice(["on", "off", "hd"])).runs()),
toggle("hold"),
toggle("lastchance"),
Node::lit("roles").runs().then(
Node::arg("комплекс", Value::choice(armed)).then(
Node::arg(
@@ -86,6 +89,16 @@ pub fn tree() -> Tree {
.runs(),
),
),
Node::lit("doctrine").runs().then(Node::arg("номер", Value::word()).with([
Node::lit("reserve").then(Node::arg("K", Value::int(0, 64)).runs()),
Node::lit("salvo").then(Node::arg("залп", Value::choice(["eco", "normal", "heavy"])).runs()),
Node::lit("sector").with([
Node::lit("off").runs(),
Node::arg("центр°", Value::number(-720.0, 720.0)).then(Node::arg("полуширина°", Value::number(0.0, 180.0)).runs()),
]),
Node::lit("guard").with([Node::lit("off").runs(), Node::arg("объекты", Value::word()).runs()]),
Node::lit("reset").runs(),
])),
Node::lit("stats").runs(),
])
}
@@ -148,9 +161,9 @@ mod tests {
"replay export mine all fps=30 size=1280x720 crf=18 sec=12.5 noaudio", "replay export fps=60", "replay stop",
"scenario save a", "scenario save a fresh", "scenario load a", "scenario load a force", "scenario list",
"scenario delete a", "why", "why 2", "ew on", "ew off", "move 4", "target here", "targets clear",
"deploy patriot", "deploy light", "deploy s400", "wave mix", "wave shahed", "wave shahed 20", "wave kh101 80 800",
"wave 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",
"sites", "strike 3", "deploy patriot", "deploy light", "deploy s400", "wave mix", "wave shahed", "wave shahed 20", "wave kh101 80 800", "wave shahed 20 4000",
"wave oreshnik 1 60", "damage on", "damage off", "truth on", "shader hd", "shader off", "shader on", "hold on", "lastchance on", "lastchance off",
"roles all default", "doctrine", "doctrine 1 reserve 4", "doctrine all salvo eco", "doctrine 2 sector 90 45", "doctrine 2 sector off", "doctrine 1 guard 1,2", "doctrine 1 reset", "roles patriot drone,jet", "roles pantsir all", "roles tor ballistic",
] {
ok(line);
}
@@ -174,7 +187,7 @@ mod tests {
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 5 4001"), 14);
assert_eq!(bad_at("wave shahed x"), 12);
assert_eq!(bad_at("wave scud"), 5);
assert_eq!(bad_at("wave"), 4);
@@ -186,6 +199,9 @@ mod tests {
assert_eq!(bad_at("why x"), 4);
assert_eq!(bad_at("move"), 4);
assert_eq!(bad_at("move 0"), 5);
assert_eq!(bad_at("strike"), 6);
assert_eq!(bad_at("strike 0"), 7);
assert_eq!(bad_at("sites 1"), 6);
assert_eq!(bad_at("scenario"), 8);
assert_eq!(bad_at("scenario load"), 13);
assert_eq!(bad_at("scenario wipe"), 9);
@@ -218,10 +234,10 @@ mod tests {
fn completion_offers_the_next_word_only() {
let top = complete("", &[]);
for w in ["menu", "monitor", "grid", "replay", "scenario", "why", "raid", "repair", "import", "ew", "flare", "move",
"target", "give", "deploy", "wave", "clear", "targets", "damage", "truth", "shader", "hold", "roles", "stats"] {
"target", "give", "deploy", "wave", "clear", "targets", "damage", "truth", "shader", "hold", "lastchance", "roles", "doctrine", "stats", "sites", "strike"] {
assert!(top.contains(&w.to_string()), "{w} in {top:?}");
}
assert_eq!(top.len(), 24);
assert_eq!(top.len(), 28);
assert_eq!(complete("rep", &[]), ["repair", "replay"]);
assert_eq!(complete("replay ", &[]), ["export", "film", "full", "map", "reel", "stop"]);
assert_eq!(complete("replay film ", &[]), ["all", "best"]);
+2
View File
@@ -454,6 +454,8 @@ fn plan_event(rec: &Recording, i: usize, floor: f64) -> Vec<Shot> {
});
add(Shot::new(a, b, &[(a, 1.0), (te - 4.0, 0.3), (te + 40.0, 1.0)], variants, &title(rec, i), "", i));
}
// A strike on a launch site (#101) is in the event list and on the map, not in the auto-camera's films.
EventKind::Strike { .. } => {}
}
shots
}
+23
View File
@@ -30,11 +30,17 @@ pub struct Layout {
pub sams: Vec<SamRec>,
pub targets: Vec<(DVec3, f64)>,
pub damage: Option<bool>,
pub last_chance: Option<bool>,
pub seed: Option<u64>,
pub wave: Option<Vec<String>>,
pub far: Option<bool>,
pub acoustics: Option<bool>,
pub effects: Option<(bool, bool)>,
pub roles: Vec<(SamKind, u8)>,
/// Fire doctrines by the system's place in the file (#47).
pub doctrines: Vec<(usize, sim::doctrine::Doctrine)>,
/// Strike missiles in stock and ticks towards the next (#101).
pub strikes: Option<(u32, u32)>,
}
/// One line's words, with the line number for the messages.
@@ -122,7 +128,10 @@ impl Layout {
out.targets.push((pos, hp));
}
"damage" => out.damage = l.flag(1, "значение")?,
"lastchance" => out.last_chance = l.flag(1, "значение")?,
"strikes" => out.strikes = Some((l.need(1, "запас", 0.0, COUNT)? as u32, l.num(2, "до следующей", 0.0, COUNT)?.map_or(0, |v| v as u32))),
"far" => out.far = l.flag(1, "значение")?,
"acoustics" => out.acoustics = l.flag(1, "значение")?,
"effects" => out.effects = Some((l.flag(1, "шейдер")?.unwrap_or(true), l.flag(2, "HD")?.unwrap_or(false))),
"seed" => {
let v = l.w.get(1).ok_or_else(|| l.bad("значение", "строка оборвана"))?;
@@ -132,6 +141,16 @@ impl Layout {
crate::wave::WaveConfig::default().check(&l.w[1..]).map_err(|e| l.bad("налёт", &e))?;
out.wave = Some(l.w[1..].iter().map(|s| s.to_string()).collect());
}
"doctrine" => {
// doctrine <system> <salvo> <reserve> <sector centre|-> <half|-> <guards> [x y z]...
let at = l.need(1, "комплекс", 0.0, MAX_UNITS as f64)? as usize;
let salvo = l.w.get(2).and_then(|v| sim::doctrine::Salvo::from_key(v)).ok_or_else(|| l.bad("залп", "eco, normal или heavy"))?;
let reserve = l.need(3, "резерв", 0.0, sim::doctrine::MAX_RESERVE as f64)? as u32;
let sector = if l.w.get(4) == Some(&"-") { None } else { Some((l.need(4, "центр сектора", -1.0e6, 1.0e6)?, l.need(5, "полуширина", 0.0, 7.0)?)) };
let n = l.need(6, "число объектов", 0.0, 64.0)? as usize;
let guards = (0..n).map(|k| l.xyz(7 + 3 * k, "охраняемый объект")).collect::<Result<Vec<_>, _>>()?;
out.doctrines.push((at, sim::doctrine::Doctrine { guards, sector, reserve, salvo }));
}
"roles" => {
let kind = l.kind(1)?;
out.roles.push((kind, l.need(2, "роли", 0.0, 255.0)? as u8));
@@ -194,6 +213,10 @@ mod tests {
assert_eq!(l.sams[3].comps.len(), 6);
assert_eq!((l.damage, l.far, l.effects, l.seed), (Some(false), Some(true), Some((true, false)), Some(123456789)));
assert_eq!(l.roles.len(), 1);
// Saves without strikes load as before; with them, the stock is read (and refused when wrong).
assert_eq!(l.strikes, None);
assert_eq!(Layout::parse("strikes 2 3500\n").unwrap().strikes, Some((2, 3500)));
assert!(Layout::parse("strikes -1 0\n").is_err() && Layout::parse("strikes x\n").is_err());
// What the build writes now reads back (a wave line from the menu's own text).
let w = crate::wave::WaveConfig::default().text();
assert!(Layout::parse(&format!("wave {w}\n")).is_ok(), "{w}");
+312 -19
View File
@@ -16,6 +16,7 @@ mod cinema;
mod engines;
mod commands;
mod layout;
mod map;
mod film;
mod monitor;
mod panel;
@@ -87,6 +88,8 @@ struct AirDef {
session: u64,
/// The world drawn beyond the render distance (replays and cameras see far off).
far: bool,
/// The acoustics asked for, until the game has them (its answer comes a tick later).
acoustics: Option<bool>,
/// The game's log of block changes (`render.world-log-start`): a replay shows the world as
/// it was at its moment, not the holes the raid left (None where the game has no such log).
block_log: Option<u32>,
@@ -119,6 +122,8 @@ struct AirDef {
model_generation: u32,
/// The instances handed to the game, kept between ticks so nothing is allocated for them.
by_id: Vec<ModelInstanceId>,
/// The command map (#48).
map: map::MapUi,
}
/// What `/airdef replay export` was asked for.
@@ -212,6 +217,9 @@ enum Page {
More,
/// The last raid's events and its replays.
Replays,
/// The systems with their doctrines, and one system's doctrine (by id) (#47).
Fleet,
Doctrine(u32),
}
const MENU_KINDS: u32 = 1;
@@ -236,6 +244,15 @@ const BTN_MAP: u32 = 40;
const BTN_FULL: u32 = 41;
const BTN_REEL: u32 = 42;
const BTN_FAR: u32 = 19;
const BTN_ACOUSTICS: u32 = 20;
const BTN_DOCTRINE: u32 = 21;
const FLEET_ROWS: u32 = 4;
const BTN_RESERVE: u32 = 50;
const BTN_SALVO: u32 = 51;
const BTN_SECTOR_DIR: u32 = 52;
const BTN_SECTOR_WIDTH: u32 = 53;
const BTN_GUARD: u32 = 54;
const BTN_DOC_RESET: u32 = 55;
const BTN_CLASS: u32 = 100;
const BTN_ALL: u32 = 120;
const BTN_DEFAULT: u32 = 121;
@@ -453,6 +470,7 @@ impl AirDef {
let id = 0x6200_0000 | (f.id & 0x00ff_ffff);
add(id, f.at + DVec3::Y * 1.2, (1.0, 0.5, 0.17), 70.0 * f.size * glow * flicker(f.id), 20.0 + 10.0 * f.size);
}
let mut spotted: Vec<u32> = Vec::new();
for s in &self.sim.sams {
if !s.alive() && s.burning() {
add(s.id + 15, s.pos + DVec3::Y * 1.5, (1.0, 0.5, 0.18), 80.0 * flicker(s.id), 30.0);
@@ -462,7 +480,9 @@ impl AirDef {
&& s.kind == SamKind::Light
&& let Some(t) = s.target.and_then(|t| self.sim.drones.iter().find(|d| d.id == t))
&& self.sim.lit.contains(&t.id)
&& !spotted.contains(&t.id)
{
spotted.push(t.id);
add(s.id + 14, t.pos, (0.95, 0.97, 1.0), 220.0, 30.0);
}
}
@@ -494,7 +514,7 @@ impl AirDef {
dy: b.dir.y as f32,
dz: b.dir.z as f32,
color: (0.95, 0.97, 1.0),
intensity: 90_000.0,
intensity: 12_000.0,
range: (b.free + 3.5).min(1024.0) as f32,
inner_angle: sim::lights::BEAM_INNER as f32,
outer_angle: sim::lights::BEAM_OUTER as f32,
@@ -503,6 +523,11 @@ impl AirDef {
.collect()
}
/// Whether the game's acoustics are on, or asked to be.
fn acoustics_on(&self) -> bool {
self.acoustics.unwrap_or_else(render::acoustics)
}
/// The effects preset: none, the post pass, or the post pass under the HD lighting.
fn set_effects(&mut self, shader: bool, hd: bool) {
self.sim.shader = shader;
@@ -802,9 +827,9 @@ impl AirDef {
}
fn draw_hud(&mut self, player: DVec3, yaw: f32) {
let air: Vec<&sim::Drone> = self.sim.drones.iter().filter(|d| d.alive()).collect();
let pic = self.sim.air_picture();
// The command post's grid and replays cover the screen: the other panels wait under them.
if self.grid || self.replay.is_some() || (air.is_empty() && self.sim.sams.is_empty() && self.sim.missiles.is_empty()) {
if self.grid || self.replay.is_some() || (pic.tracks.is_empty() && self.sim.sams.is_empty() && self.sim.missiles.is_empty()) {
if self.hud_shown {
hud::clear();
render::clear_corner();
@@ -813,7 +838,7 @@ impl AirDef {
return;
}
self.hud_shown = true;
hud::set_panel(Corner::TopRight, &panel::summary(&self.sim, &air, player));
hud::set_panel(Corner::TopRight, &panel::summary(&self.sim, &pic, player));
render::set_corner(render::Corner::BottomRight, radar::SIZE, radar::SIZE + 12.0, &radar::draw(&self.sim, player, yaw));
}
@@ -857,13 +882,22 @@ impl AirDef {
text += &format!("target {} {} {} {h}\n", t.x, t.y, t.z);
}
text += &format!("damage {}\n", self.sim.damage);
text += &format!("lastchance {}\n", self.sim.last_chance);
// A scenario to replay from the start begins with the full stock.
let (stock, progress) = if fresh { (sim::strike::TUNE.stock, 0) } else { (self.sim.strikes.stock, self.sim.strikes.progress) };
text += &format!("strikes {stock} {progress}\n");
if view {
text += &format!("far {}\n", self.far);
text += &format!("acoustics {}\n", self.acoustics_on());
text += &format!("effects {} {}\n", self.sim.shader, self.hd);
}
for (kind, roles) in &self.sim.roles {
text += &format!("roles {} {}\n", kind.key(), roles);
}
// Doctrines by the system's place in this file; the default ones aren't written.
for (i, s) in self.sim.sams.iter().enumerate().filter(|(_, s)| !s.doctrine.is_default()) {
text += &format!("doctrine {i} {}\n", s.doctrine.save_text());
}
text
}
@@ -883,7 +917,23 @@ impl AirDef {
}
fn load(&mut self) {
self.load_from(&self.state_key());
let key = self.state_key();
// A save from before worlds were told apart by seed (kept under the folder name alone) goes to the
// world that opens first under that name and is then removed, so a later world of the same name
// starts empty.
if storage::get(&key).is_none()
&& let Some(Some(id)) = &self.world
&& let Some((base, _)) = id.split_once('#')
{
let old = state_key_of(Some(base));
if storage::get(&old).is_some() {
self.load_from(&old);
let _ = storage::delete(&old);
self.dirty = true;
return;
}
}
self.load_from(&key);
}
fn load_from(&mut self, key: &str) {
@@ -908,6 +958,7 @@ impl AirDef {
/// Adds a checked layout to the scene: nothing here can fail.
fn commit_layout(&mut self, layout: layout::Layout) {
let base = self.sim.sams.len();
for r in layout.sams {
let id = self.sim.id();
let mut sam = Sam::new(id, r.kind, r.pos, r.heading);
@@ -945,6 +996,12 @@ impl AirDef {
if let Some(d) = layout.damage {
self.sim.damage = d;
}
if let Some(l) = layout.last_chance {
self.sim.last_chance = l;
}
if let Some((stock, progress)) = layout.strikes {
self.sim.strikes.restore(stock, progress);
}
// A scenario's: the random generator, and the wave menu's raid.
if let Some(v) = layout.seed {
self.sim.reseed(v);
@@ -953,6 +1010,10 @@ impl AirDef {
let w: Vec<&str> = w.iter().map(String::as_str).collect();
self.wave.parse(&w);
}
if let Some(a) = layout.acoustics {
self.acoustics = Some(a);
render::set_acoustics(a);
}
if let Some(f) = layout.far {
self.far = f;
}
@@ -962,6 +1023,11 @@ impl AirDef {
for (kind, r) in layout.roles {
self.sim.roles.insert(kind, r);
}
for (i, d) in layout.doctrines {
if let Some(s) = self.sim.sams.get_mut(base + i) {
s.doctrine = d;
}
}
}
fn give(&self) {
@@ -972,6 +1038,28 @@ impl AirDef {
}
}
/// The storage key of a world's state: a hash of its identity (`world_identity`).
fn state_key_of(world: Option<&str>) -> String {
match world {
Some(w) => {
// FNV-1a: storage keys are short, lowercase and plain.
let h = w.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3));
format!("state-{h:016x}")
}
None => "state".into(),
}
}
/// Which world is open: its folder (`game.world-id`) and, for a saved world, its seed, so a new world
/// with the name of an old one has a layout of its own.
fn world_identity() -> Option<String> {
let id = game::world_id()?;
Some(match game::world_seed() {
Some(seed) => format!("{id}#{seed:x}"),
None => id,
})
}
impl AirDef {
/// The EW stations: on if none is, else off.
fn toggle_ew(&mut self) {
@@ -988,14 +1076,7 @@ impl AirDef {
/// Where the open world's state is kept: a key of its own per world.
fn state_key(&self) -> String {
match self.world.as_ref().and_then(|w| w.as_ref()) {
Some(w) => {
// FNV-1a: storage keys are short, lowercase and plain.
let h = w.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3));
format!("state-{h:016x}")
}
None => "state".into(),
}
state_key_of(self.world.as_ref().and_then(|w| w.as_deref()))
}
/// Another world opened: the last one's state saved, a clean simulation with this one's loaded.
@@ -1013,6 +1094,7 @@ impl AirDef {
self.sim.shader = shader;
self.ground = Ground::default();
self.monitor = None;
self.map_close();
self.grid = false;
self.grid_ids.clear();
// The last world's transient state: its sounds were due on its own
@@ -1356,6 +1438,8 @@ impl AirDef {
widgets.push(Widget::List(ListWidget { id: MENU_KINDS, rows, selected: None }));
let _ = &self.monitor;
widgets.push(Widget::Button(ButtonWidget { id: BTN_GRID, label: "Командный пункт: все комплексы (J)".into() }));
let custom = self.sim.sams.iter().filter(|s| !s.doctrine.is_default()).count();
widgets.push(Widget::Button(ButtonWidget { id: BTN_DOCTRINE, label: format!("Доктрины: охрана, сектор, резерв (особых: {custom})…") }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_WAVE, label: "Налёт: состав и запуск…".into() }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_REPLAYS, label: "Повторы…".into() }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_MORE, label: "Огонь, ремонт, эффекты…".into() }));
@@ -1394,6 +1478,7 @@ impl AirDef {
widgets.push(Widget::Button(ButtonWidget { id: BTN_SHADER, label: format!("Эффекты: {fx}") }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_DAMAGE, label: format!("Разрушения: {}", onoff(self.sim.damage)) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_FAR, label: format!("Дальний ландшафт: {}", onoff(self.far)) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_ACOUSTICS, label: format!("Акустика (преграды, эхо): {}", onoff(self.acoustics_on())) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Назад".into() }));
}
Page::Wave => {
@@ -1418,6 +1503,37 @@ impl AirDef {
widgets.push(Widget::Button(ButtonWidget { id: BTN_LAUNCH, label: format!("▶ ПУСК ({})", w.total()) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Состав".into() }));
}
Page::Fleet => {
title = "Доктрины — клик по комплексу".to_string();
let rows = self
.fleet()
.iter()
.map(|&i| {
let s = &self.sim.sams[i];
format!("№{} {} · {}", i + 1, s.kind.name(), s.doctrine.text(&self.sim.targets))
})
.collect();
widgets.push(Widget::List(ListWidget { id: FLEET_ROWS, rows, selected: None }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Назад".into() }));
}
Page::Doctrine(id) => {
let Some(i) = self.sim.sams.iter().position(|s| s.id == id) else { return self.open_menu(Page::Fleet) };
let s = &self.sim.sams[i];
let d = &s.doctrine;
title = format!("№{} {} — доктрина (объяснение: /airdef why {})", i + 1, s.kind.name(), i + 1);
let guard = match d.guards.len() {
0 => "нет (все цели своих классов)".to_string(),
1 => sim::doctrine::object_name(&self.sim.targets, d.guards[0]),
n => format!("{n} объектов (клик — снять)"),
};
widgets.push(Widget::Button(ButtonWidget { id: BTN_GUARD, label: format!("Охраняемый объект: {guard}") }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_SECTOR_DIR, label: format!("Сектор: {}", d.sector_text()) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_SECTOR_WIDTH, label: format!("Ширина сектора: ±{:.0}°", d.sector.map_or(45.0, |(_, h)| h.to_degrees())) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_RESERVE, label: format!("Резерв ракет: {} (в магазине {})", d.reserve, s.missiles) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_SALVO, label: format!("Залп по цели: {}", d.salvo.name()) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_DOC_RESET, label: "Сбросить доктрину".into() }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Комплексы".into() }));
}
Page::Roles(kind) => {
title = format!("{} ({}) — по каким целям стрелять", kind.name(), panel::distance(kind.range()));
let _ = ItemWidget { id: 0, item: String::new(), name: String::new() };
@@ -1436,6 +1552,75 @@ impl AirDef {
ui::open_screen(&Screen { title, widgets });
}
/// The systems that have a doctrine to set: those with missiles (indices into the sim's list).
fn fleet(&self) -> Vec<usize> {
(0..self.sim.sams.len()).filter(|&i| self.sim.sams[i].kind.armed()).collect()
}
/// Changes one system's doctrine (the menu's buttons and `/airdef doctrine`).
fn edit_doctrine(&mut self, i: usize, f: impl FnOnce(&mut sim::doctrine::Doctrine)) {
if let Some(s) = self.sim.sams.get_mut(i) {
f(&mut s.doctrine);
// It chooses again under the new rules.
s.target = None;
s.gun_target = None;
self.dirty = true;
}
}
/// `/airdef doctrine [<n|all> reserve K | salvo eco|normal|heavy | sector C H | sector off | guard N[,M]|off | reset]` (#47).
fn doctrine_command(&mut self, w: &[&str]) -> Result<String, String> {
const USAGE: &str = "/airdef doctrine <номер|all> reserve <K> | salvo eco|normal|heavy | sector <центр°> <полуширина°> | sector off | guard <объект№>[,<№>] | guard off | reset";
let Some((who, what)) = w.split_first() else {
for i in self.fleet() {
let s = &self.sim.sams[i];
say(&format!("№{} {}: {}", i + 1, s.kind.name(), s.doctrine.text(&self.sim.targets)));
}
return Ok(format!("Изменить: {USAGE}"));
};
let which: Vec<usize> = if *who == "all" {
self.fleet()
} else {
let i = who.parse::<usize>().ok().and_then(|n| n.checked_sub(1)).filter(|&i| self.sim.sams.get(i).is_some_and(|s| s.kind.armed())).ok_or("Нет такого вооружённого комплекса")?;
vec![i]
};
let num = |v: Option<&&str>, what: &str, lo: f64, hi: f64| -> Result<f64, String> {
v.and_then(|v| v.parse::<f64>().ok()).filter(|x| x.is_finite() && (lo..=hi).contains(x)).ok_or(format!("{what}: число от {lo} до {hi}"))
};
let mut edit: Box<dyn Fn(&mut sim::doctrine::Doctrine)> = match what {
["reserve", k] => {
let k = num(Some(k), "резерв", 0.0, sim::doctrine::MAX_RESERVE as f64)? as u32;
Box::new(move |d| d.reserve = k)
}
["salvo", v] => {
let v = sim::doctrine::Salvo::from_key(v).ok_or("Залп: eco, normal или heavy")?;
Box::new(move |d| d.salvo = v)
}
["sector", "off"] => Box::new(|d| d.sector = None),
["sector", c, h] => {
let c = num(Some(c), "центр сектора", -720.0, 720.0)?.to_radians();
let h = num(Some(h), "полуширина сектора", 0.0, 180.0)?.to_radians();
Box::new(move |d| d.sector = Some((c, h)))
}
["guard", "off"] => Box::new(|d| d.guards.clear()),
["guard", list] => {
let mut at = Vec::new();
for n in list.split(',') {
let k = n.parse::<usize>().ok().and_then(|n| n.checked_sub(1)).and_then(|k| self.sim.targets.get(k)).ok_or(format!("Нет объекта «{n}» (отметьте его /airdef target here)"))?;
at.push(*k);
}
Box::new(move |d| d.guards = at.clone())
}
["reset"] => Box::new(|d| *d = sim::doctrine::Doctrine::default()),
_ => return Err(USAGE.into()),
};
for &i in &which {
self.edit_doctrine(i, &mut edit);
}
let first = which.first().map(|&i| self.sim.sams[i].doctrine.text(&self.sim.targets)).unwrap_or_default();
Ok(format!("Доктрина ({} компл.): {first}", which.len()))
}
fn set_roles(&mut self, kind: SamKind, mask: Option<u8>) {
let mask = match mask {
Some(m) => {
@@ -1483,6 +1668,7 @@ impl Mod for AirDef {
session: 0,
smoke: Default::default(),
far: true,
acoustics: None,
moving: None,
hd: false,
world: None,
@@ -1497,7 +1683,9 @@ impl Mod for AirDef {
model_ids: std::collections::HashMap::new(),
model_generation: u32::MAX,
by_id: Vec::new(),
map: map::MapUi::default(),
};
let _ = input::register_key("map", "Карта: командная, расстановка", "key.keyboard.m");
let _ = input::register_key("menu", "Меню AirDef", "key.keyboard.n");
let _ = input::register_key("monitor", "Монитор ПВО: следующий комплекс", "key.keyboard.k");
let _ = input::register_key("grid", "Командный пункт: все комплексы", "key.keyboard.j");
@@ -1525,13 +1713,14 @@ impl Mod for AirDef {
self.sim.engine_tick = tick;
let Some(p) = player() else { return };
// Each world keeps its own systems, objects and settings: loaded when it opens.
let world = game::world_id();
let world = world_identity();
if self.world.as_ref() != Some(&world) {
self.open_world(world);
}
for event in input::poll() {
match event {
Event::UseStart(u) => self.use_item(&u.item.id),
Event::KeyDown(k) if k == "map" => self.map_toggle(),
Event::KeyDown(k) if k == "menu" => {
if self.replay.is_some() {
self.stop_replay();
@@ -1574,10 +1763,11 @@ impl Mod for AirDef {
}
}
let now = self.sim.tick;
self.ground.answer(render::ground(), now);
// The blocks themselves (issues #42, #43): what changed, and the sections asked for last tick.
// The blocks themselves (issues #42, #43): what changed, and the sections asked for last tick. First, so
// that the answers below are judged by the world they come in.
let changes = render::world_changes();
self.ground.world.changed_rev(changes.epoch, changes.revision, changes.overflow, &changes.sections);
self.ground.answer(render::ground(), now);
let sections = render::sections()
.into_iter()
.map(|s| sim::world::SectionAnswer {
@@ -1617,6 +1807,11 @@ impl Mod for AirDef {
if !wanted.is_empty() {
render::query_ground(&wanted);
}
// The chunks the launch-site search is looking at: the game loads them for a few seconds (API 22).
let chunks = self.ground.take_chunks();
if !chunks.is_empty() {
render::load_chunks(&chunks);
}
let rays: Vec<render::Ray> = self
.ground
.world
@@ -1650,6 +1845,9 @@ impl Mod for AirDef {
}
}
self.emit(out);
self.map.sample(&self.sim);
// The map's events and shapes: at the tick's pace (a frame hook's budget is too small for it).
self.map_frame();
self.draw_hud(DVec3::new(p.x, p.y, p.z), p.yaw);
// The replay takes the screen; else the monitors and the cameras they show.
if let Some(r) = &self.replay {
@@ -1722,6 +1920,58 @@ impl Mod for AirDef {
}
return;
}
(Page::Main, BTN_DOCTRINE) => {
self.open_menu(Page::Fleet);
return;
}
(Page::Fleet, FLEET_ROWS) => {
if let Some(&i) = self.fleet().get(row as usize) {
self.open_menu(Page::Doctrine(self.sim.sams[i].id));
}
return;
}
(Page::Fleet, BTN_BACK) => {
self.open_menu(Page::Main);
return;
}
(Page::Doctrine(_), BTN_BACK) => {
self.open_menu(Page::Fleet);
return;
}
(Page::Doctrine(id), b) => {
let Some(i) = self.sim.sams.iter().position(|s| s.id == id) else { return };
let objects = self.sim.targets.clone();
match b {
BTN_RESERVE => self.edit_doctrine(i, |d| d.reserve = [0, 1, 2, 4, 8, 16].into_iter().find(|&r| r > d.reserve).unwrap_or(0)),
BTN_SALVO => self.edit_doctrine(i, |d| d.salvo = d.salvo.next()),
// Eight directions of 45 degrees, then off.
BTN_SECTOR_DIR => self.edit_doctrine(i, |d| {
let half = d.sector.map_or(45f64.to_radians(), |(_, h)| h);
d.sector = match d.sector {
None => Some((0.0, half)),
Some((c, _)) if (c.to_degrees().rem_euclid(360.0) / 45.0).round() < 7.0 => Some((c + 45f64.to_radians(), half)),
Some(_) => None,
};
}),
BTN_SECTOR_WIDTH => self.edit_doctrine(i, |d| {
if let Some((c, h)) = d.sector {
let deg = [30.0, 45.0, 60.0, 90.0, 135.0].into_iter().find(|&w| w > h.to_degrees() + 0.5).unwrap_or(30.0);
d.sector = Some((c, f64::to_radians(deg)));
}
}),
// None, then each object in turn, then none.
BTN_GUARD => self.edit_doctrine(i, |d| {
let at = d.guards.first().and_then(|g| objects.iter().position(|t| t.distance(*g) < 16.0));
d.guards = match (d.guards.len(), at) {
(0, _) => objects.first().copied().into_iter().collect(),
(1, Some(k)) => objects.get(k + 1).copied().into_iter().collect(),
_ => Vec::new(),
};
}),
BTN_DOC_RESET => self.edit_doctrine(i, |d| *d = sim::doctrine::Doctrine::default()),
_ => return,
}
}
(Page::More, BTN_HOLD) => self.sim.hold = !self.sim.hold,
(Page::Main, BTN_MONITOR) => self.cycle_monitor(),
(Page::Main, BTN_WAVE) => {
@@ -1828,6 +2078,12 @@ impl Mod for AirDef {
self.apply_far();
self.dirty = true;
}
(Page::More, BTN_ACOUSTICS) => {
let on = !self.acoustics_on();
self.acoustics = Some(on);
render::set_acoustics(on);
self.dirty = true;
}
(Page::More, BTN_DAMAGE) => {
self.sim.damage = !self.sim.damage;
self.dirty = true;
@@ -1886,12 +2142,14 @@ impl Mod for AirDef {
sam.roles = *r;
}
self.sim.sams.push(sam);
// Standing in the ground (a chunk that had not loaded as the player fell): it comes up to the surface.
self.sim.settle(sid);
self.dirty = true;
Some(format!("{} развёрнут", kind.name()))
}
["wave", kind, rest @ ..] => {
let count = rest.first().and_then(|v| v.parse().ok()).unwrap_or(5u32).clamp(1, 80);
let dist = rest.get(1).and_then(|v| v.parse().ok()).unwrap_or(260.0f64).clamp(60.0, 800.0);
let dist = rest.get(1).and_then(|v| v.parse().ok()).unwrap_or(260.0f64).clamp(wave::DIST_MIN, wave::DIST_MAX);
let p = player().ok_or("Нет игрока")?;
let target = self
.sim
@@ -1904,7 +2162,9 @@ impl Mod for AirDef {
let kind = DroneKind::from_key(kind).ok_or(
"Тип: shahed, geran3, lancet, gerbera, kh101, kalibr, kh22, zircon, iskander, kinzhal, oreshnik или mix",
)?;
self.sim.raid(kind, target, count, dist, &mut out);
// Through the menu's path (`order_raid`), so every drone leaves a launch site.
let order = sim::RaidOrder { kinds: vec![(kind, count)], approach: sim::Approach::Sector, dist, aim: sim::Aim::Objects };
self.sim.order_raid(&order, &[target], &mut out);
}
None
}
@@ -1936,6 +2196,20 @@ impl Mod for AirDef {
self.launch_wave(&mut out)?;
None
}
["sites"] => {
// The launch sites the radars have found, and the strike missiles (#101).
let p = player().ok_or("Нет игрока")?;
for l in self.sim.site_lines(DVec3::new(p.x, p.y, p.z)) {
say(&l);
}
None
}
["strike", n] => {
// A strike missile at known site n, from where the player stands.
let p = player().ok_or("Нет игрока")?;
let id = n.parse::<u32>().map_err(|_| "/airdef strike <номер ПУ из /airdef sites>")?;
Some(self.sim.order_strike(id, DVec3::new(p.x, p.y, p.z), &mut out)?)
}
["move", n] => {
// Unit n (as the summary numbers them) drives to where the player stands.
let p = player().ok_or("Нет игрока")?;
@@ -1993,12 +2267,17 @@ impl Mod for AirDef {
self.dirty = true;
Some(format!("Разрушения: {}", if self.sim.damage { "вкл" } else { "выкл" }))
}
["doctrine", rest @ ..] => Some(self.doctrine_command(rest)?),
["roles"] => {
for kind in SamKind::ARMED {
let r = self.sim.roles.get(&kind).copied().unwrap_or(kind.spec().roles);
let names: Vec<&str> = Class::ALL.iter().filter(|c| r & c.bit() != 0).map(|c| c.name()).collect();
say(&format!("{}: {}", kind.name(), if names.is_empty() { "ничего".into() } else { names.join(", ") }));
}
say(&format!(
"Последний шанс: {} (комплекс стреляет и по классу вне ролей, если цель вот-вот попадёт; /airdef lastchance on|off)",
if self.sim.last_chance { "вкл" } else { "выкл" }
));
Some("Классы: drone, jet, cruise, supersonic, ballistic (или all). /airdef roles <комплекс|all> <классы|default>".into())
}
["roles", who, what] => {
@@ -2025,6 +2304,15 @@ impl Mod for AirDef {
self.dirty = true;
Some("Роли ПВО обновлены".into())
}
["lastchance", v @ ("on" | "off")] => {
self.sim.last_chance = *v == "on";
self.dirty = true;
Some(if self.sim.last_chance {
"Последний шанс: вкл — комплекс стреляет и по классу вне ролей, если цель вот-вот попадёт".into()
} else {
"Последний шанс: выкл — комплексы стреляют только по своим ролям".into()
})
}
["hold", v @ ("on" | "off")] => {
self.sim.hold = *v == "on";
Some(if self.sim.hold { "ПВО: огонь запрещён".into() } else { "ПВО: огонь разрешён".into() })
@@ -2034,13 +2322,18 @@ impl Mod for AirDef {
Some(format!("Эффекты: {}", match *v { "off" => "выкл", "hd" => "HD", _ => "обычные" }))
}
["stats"] => Some(format!(
"В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}, разбились о препятствия: {}; {}",
"В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}, разбились о препятствия: {}; удары по ПУ: пущено {}, уничтожено {}, подавлено {}, промахов {}, сбито ПВО {}; {}",
self.sim.drones.iter().filter(|d| d.alive()).count(),
self.sim.missiles.len(),
self.sim.sams.len(),
self.sim.kills,
self.sim.hits,
self.sim.crashes,
self.sim.strikes.fired,
self.sim.strikes.destroyed,
self.sim.strikes.stunned,
self.sim.strikes.missed,
self.sim.strikes.shot_down,
self.ground.world.text()
)),
_ => return Err(help()),
File diff suppressed because it is too large Load Diff
+33 -25
View File
@@ -1,6 +1,6 @@
//! The summary panel (top right): the alert, what is in the air, each
//! air-defence system's state and the score.
use crate::sim::{Drone, DroneKind, Sim};
use crate::sim::{AirPicture, DroneKind, Sim};
use glam::DVec3;
use horizon_mod_sdk::hud::Line;
@@ -23,18 +23,21 @@ fn kmh(v: DVec3) -> f64 {
v.length() * 20.0 * 3.6
}
pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec<Line> {
/// The panel reads the air picture only (#93): what the network holds, with the age of a forecast. The sandbox's
/// `truth` adds one marked line of the real figures; it changes nothing else.
pub fn summary(sim: &Sim, pic: &AirPicture, player: DVec3) -> Vec<Line> {
let mut lines = Vec::new();
let line = |text: String, color: u32| Line { text, color };
if air.is_empty() {
let now = pic.now;
if pic.tracks.is_empty() {
lines.push(line("Небо чистое".into(), GREEN));
} else {
let blink = (sim.tick / 10) % 2 == 0;
lines.push(line(format!("ВОЗДУШНАЯ ТРЕВОГА — целей: {}", air.len()), if blink { RED } else { PINK }));
// By type.
lines.push(line(format!("ВОЗДУШНАЯ ТРЕВОГА — целей: {}", pic.tracks.len()), if blink { RED } else { PINK }));
// By type, as the radars classed them.
let mut by_type = String::new();
for kind in DroneKind::ALL {
let n = air.iter().filter(|d| d.kind == kind).count();
let n = pic.tracks.iter().filter(|t| t.kind == kind).count();
if n > 0 {
if !by_type.is_empty() {
by_type += ", ";
@@ -43,29 +46,34 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec<Line> {
}
}
lines.push(line(by_type, PINK));
// The nearest threat to the player.
if let Some(d) = air.iter().min_by(|a, b| a.pos.distance(player).total_cmp(&b.pos.distance(player))) {
// The nearest track to the player: where it was last measured (a forecast says so, with its age).
if let Some(t) = pic.nearest(player) {
let e = t.est(now);
let mark = if t.forecast(now) { format!(" ~{}с", t.age(now) / 20) } else { String::new() };
lines.push(line(
format!(
"Ближ.: {} {} выс.{:.0} {:.0}км/ч",
d.kind.spec().name,
distance(d.pos.distance(player)),
d.pos.y - player.y,
kmh(d.vel)
),
format!("Ближ.: {} {} выс.{:.0} {:.0}км/ч{mark}", t.kind.spec().name, distance(e.distance(player)), e.y - player.y, kmh(t.vel)),
PINK,
));
}
}
if sim.planning() {
lines.push(line("Противник планирует налёт…".into(), YELLOW));
if sim.truth {
let real = sim.drones.iter().filter(|d| d.alive()).count();
let unseen = sim.drones.iter().filter(|d| d.alive() && pic.get(d.id).is_none()).count();
let mut text = format!("ИСТИНА: в воздухе {real}, вне картины {unseen}, взлетает {}", sim.queue.len());
if sim.planning() {
text += ", противник планирует налёт";
}
lines.push(line(text, YELLOW));
}
if !sim.queue.is_empty() {
lines.push(line(format!("Ещё взлетает: {}", sim.queue.len()), PINK));
}
let hidden = air.iter().filter(|d| !sim.sams.is_empty() && !sim.tracked(d.id)).count();
if hidden > 0 {
lines.push(line(format!("Вне видимости РЛС: {hidden}"), GREY));
// The launch sites found, and the strike missiles (#101).
if !sim.strikes.known.is_empty() || !sim.strikes.flights.is_empty() {
let st = &sim.strikes;
let live = st.known.iter().filter(|k| !matches!(k.state, crate::sim::strike::SiteState::Destroyed(_))).count();
let mut text = format!("Известных ПУ: {live} · ударных ракет: {}", st.stock);
if let Some(f) = st.flights.iter().map(|f| f.arrive.saturating_sub(sim.tick)).min() {
text += &format!(" · в полёте {} (до цели {} с)", st.flights.len(), f / 20);
}
lines.push(line(text, YELLOW));
}
if !sim.target_health.is_empty() {
let list: Vec<String> = sim.target_health.iter().map(|h| format!("{h:.0}%")).collect();
@@ -93,8 +101,8 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec<Line> {
} else {
sam.status()
};
if let Some(t) = sam.gun_target.or(sam.target).and_then(|t| sim.drones.iter().find(|d| d.id == t)) {
text += &format!(" > {} {}", t.kind.spec().name, distance(t.pos.distance(sam.pos)));
if let Some(t) = sam.gun_target.or(sam.target).and_then(|t| pic.get(t)) {
text += &format!(" > {} {}", t.kind.spec().name, distance(t.est(now).distance(sam.pos)));
}
if sam.kills > 0 {
text += &format!(" ({})", sam.kills);
+63 -28
View File
@@ -107,37 +107,72 @@ pub fn draw(sim: &Sim, player: DVec3, yaw: f32) -> Vec<Shape> {
s.push(square(x, y, 1.0, 0xFFFFE060));
}
}
let mut far = 0;
// The air picture is what the systems' radars hold, as they last measured it (a track older than a second
// is a forecast, dimmer); with no system deployed, everything as it is.
let any = !sim.sams.is_empty();
let picture = sim.picture();
let now = sim.tick;
for d in sim.drones.iter().filter(|d| !any || !d.alive() || picture.contains_key(&d.id)) {
let track = picture.get(&d.id).filter(|_| any && d.alive());
let (pos, vel) = track.map_or((d.pos, d.vel), |t| (t.est(now), t.vel));
match scope(pos) {
Some((x, y, bearing)) => {
if d.alive() {
// Fresh: bright and red; a forecast: orange, fading as it ages towards the track's drop.
let (a, color) = match track {
Some(t) if t.forecast(now) => ((1.0 - t.age(now) as f64 / t.ttl.max(1) as f64 * 0.7).max(0.3), 0xFFB040),
Some(_) => (1.0, 0xFF4040),
None => (fade(bearing), 0xFF4040),
};
s.push(square(x, y, 1.8, alpha(color, a)));
// Its heading, a short tail.
let v = vel.normalize_or_zero();
s.push(quad(pt(x, y), pt(x + 0.6, y), pt(x - v.x as f32 * 6.0 + 0.6, y - v.z as f32 * 6.0), pt(x - v.x as f32 * 6.0, y - v.z as f32 * 6.0), alpha(color, a * 0.6)));
} else {
s.push(square(x, y, 1.2, alpha(0x909090, fade(bearing))));
// Launch sites the radars found: a diamond where the place is known (with the circle of its error), and a mark on
// the rim along the bearing when it is beyond the scope or only the bearing is known.
for k in &sim.strikes.known {
let Some(e) = &k.est else { continue };
let gone = matches!(k.state, crate::sim::strike::SiteState::Destroyed(_));
let color = if gone { 0xFF707070 } else { 0xFFFFA040 };
let inside = e.fix.then(|| scope(DVec3::new(e.pos.x, player.y, e.pos.y))).flatten();
if let Some((x, y, _)) = inside {
let q = 3.0;
s.push(quad(pt(x, y - q), pt(x + q, y), pt(x, y + q), pt(x - q, y), color));
let rad = (e.radius / RANGE) as f32 * r;
if rad > 3.5 {
for i in 0..SEGMENTS {
let (a0, a1) = (i as f32 / SEGMENTS as f32 * tau, (i + 1) as f32 / SEGMENTS as f32 * tau);
if i % 2 == 0 {
let p = |a: f32, rr: f32| pt(x + a.sin() * rr, y - a.cos() * rr);
s.push(quad(p(a0, rad - 0.4), p(a0, rad + 0.4), p(a1, rad + 0.4), p(a1, rad - 0.4), alpha(color, 0.5)));
}
}
}
None if d.alive() => far += 1,
None => {}
s.push(text(k.site.to_string(), x + 5.0, y - 9.0, color, false));
} else {
// On the rim, along the bearing (compass: north up, east right).
let a = e.bearing as f32;
let (x, y) = (cx + a.sin() * (r - 3.0), cy - a.cos() * (r - 3.0));
let q = if e.fix { 3.0 } else { 2.0 };
s.push(quad(pt(x, y - q), pt(x + q, y), pt(x, y + q), pt(x - q, y), color));
s.push(text(format!("{}{}", k.site, if e.fix { "" } else { "?" }), x - a.sin() * 8.0, y + a.cos() * -8.0 - 4.0, color, true));
}
// The true place, when asked for: a white dot beside the track, to see how far the picture is off.
if sim.truth && d.alive() && any {
}
// Strike missiles in flight, on the same footing: a small bright dot where the scope reaches, else nothing.
for f in &sim.strikes.flights {
let t = (sim.tick.saturating_sub(f.start)) as f64 / (f.arrive - f.start).max(1) as f64;
let p = f.from.lerp(DVec3::new(f.aim.x, f.from.y, f.aim.y), t.min(1.0));
if let Some((x, y, _)) = scope(p) {
s.push(square(x, y, 1.5, 0xFFFFFFA0));
}
}
let mut far = 0;
// The air picture is what the systems' radars hold, as they last measured it (a track older than a second
// is a forecast, dimmer); with no system deployed, the sky as it is. Nothing else is read (#93).
let pic = sim.air_picture();
let now = pic.now;
for t in &pic.tracks {
let (pos, vel) = (t.est(now), t.vel);
match scope(pos) {
Some((x, y, bearing)) => {
// Fresh: bright and red; a forecast: orange, fading as it ages towards the track's drop.
let (a, color) = if pic.unaided {
(fade(bearing), 0xFF4040)
} else if t.forecast(now) {
((1.0 - t.age(now) as f64 / t.ttl.max(1) as f64 * 0.7).max(0.3), 0xFFB040)
} else {
(1.0, 0xFF4040)
};
s.push(square(x, y, 1.8, alpha(color, a)));
// Its heading, a short tail.
let v = vel.normalize_or_zero();
s.push(quad(pt(x, y), pt(x + 0.6, y), pt(x - v.x as f32 * 6.0 + 0.6, y - v.z as f32 * 6.0), pt(x - v.x as f32 * 6.0, y - v.z as f32 * 6.0), alpha(color, a * 0.6)));
}
None => far += 1,
}
}
// The true places, when the sandbox asks for them: a white dot beside the track, to see how far the picture is off.
if sim.truth && !pic.unaided {
for d in sim.drones.iter().filter(|d| d.alive()) {
if let Some((x, y, _)) = scope(d.pos) {
s.push(square(x, y, 0.8, 0xFFFFFFFF));
}
+25 -2
View File
@@ -7,7 +7,7 @@
use crate::film::{self, Film};
use crate::panel::distance;
use crate::sim::Ground;
use crate::sim::record::{EventKind, Recording, STEP, What};
use crate::sim::record::{EventKind, Recording, STEP, StrikeResult, What};
use std::cell::Cell;
use crate::sim::{Class, DroneKind, Out, Sim};
use glam::DVec3;
@@ -137,6 +137,17 @@ fn outcome_note(outcome: crate::sim::outcome::Outcome) -> &'static str {
}
}
/// How a strike on a launch site ended, in words.
fn strike_word(result: StrikeResult) -> &'static str {
match result {
StrikeResult::Destroyed => "площадка уничтожена",
StrikeResult::Stunned => "площадка подавлена",
StrikeResult::Missed => "промах",
StrikeResult::Intercepted => "ракета сбита ПВО противника",
StrikeResult::Halted => "пуски прекращены (вне карты)",
}
}
/// What an event was, in a line.
pub fn title(rec: &Recording, i: usize) -> String {
let e = &rec.events[i];
@@ -151,6 +162,7 @@ pub fn title(rec: &Recording, i: usize) -> String {
EventKind::SamLost { kind, index, .. } => format!("{} №{} уничтожен", kind.name(), index + 1),
EventKind::Split { .. } => "«Орешник»: разделение боевых блоков".into(),
EventKind::Crash { kind, .. } => format!("«{}» разбивается о препятствие", kind.spec().name),
EventKind::Strike { site, result } => format!("Удар по ПУ №{site}: {}", strike_word(result)),
}
}
@@ -167,6 +179,7 @@ pub fn row(rec: &Recording, i: usize) -> String {
EventKind::SamLost { kind, index, .. } => format!("{}-{} уничтожен", kind.name(), index + 1),
EventKind::Split { .. } => "Орешник: разделение".into(),
EventKind::Crash { kind, .. } => format!("{}: о рельеф", kind.spec().name),
EventKind::Strike { site, result } => format!("ПУ {site}: {}", strike_word(result)),
};
format!("{} {what}", clock(e.at as f64))
}
@@ -199,6 +212,7 @@ pub fn interest(rec: &Recording, i: usize) -> f64 {
}
EventKind::SamLost { .. } => 12.0,
EventKind::Crash { power, .. } => power as f64,
EventKind::Strike { .. } => 0.0,
EventKind::Split { drone } => {
// The bus's split, not each block's.
let first = rec.events.iter().position(|o| matches!(o.kind, EventKind::Split { .. }));
@@ -216,6 +230,7 @@ fn clip(rec: &Recording, i: usize) -> Clip {
EventKind::SamLost { .. } => (80.0, 90.0),
EventKind::Split { .. } => (40.0, 170.0),
EventKind::Crash { .. } => (120.0, 60.0),
EventKind::Strike { .. } => (60.0, 80.0),
};
Clip { event: i, from: (at - before).max(0.0), to: (at + after).min(rec.len as f64) }
}
@@ -499,6 +514,12 @@ impl Player {
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][(power as usize).min(4)],
EventKind::SamLost { .. } => "airdef:blast.big",
EventKind::Split { .. } => "airdef:boom.sonic",
EventKind::Strike { result: StrikeResult::Halted, .. } => continue,
EventKind::Strike { result, .. } => ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][match result {
StrikeResult::Destroyed => 4,
StrikeResult::Intercepted => 0,
_ => 3,
}],
};
let gain = if let EventKind::Kill { kind, outcome, .. } = e.kind { crate::sim::outcome::look(kind, outcome).2 } else { 1.0 };
out.sound(event, heard(e.pos), gain, 1.0);
@@ -694,7 +715,7 @@ impl Player {
let look = rec.track(drone).and_then(|tr| tr.at(o, t)).filter(|_| t < te).unwrap_or(e.pos);
(eye, look, 50.0, 1)
}
EventKind::SamLost { .. } => (side(e.pos, o + DVec3::new(300.0, 0.0, 0.0), 45.0), e.pos, 50.0, 0),
EventKind::SamLost { .. } | EventKind::Strike { .. } => (side(e.pos, o + DVec3::new(300.0, 0.0, 0.0), 45.0), e.pos, 50.0, 0),
EventKind::Split { .. } => {
let ground = DVec3::new(e.pos.x, o.y, e.pos.z);
let eye = ground + DVec3::new(170.0, 8.0, 40.0);
@@ -953,6 +974,7 @@ impl Player {
}
EventKind::SamLost { .. } => s.push(fill(x - 4.0, y - 4.0, 8.0, 8.0, 0xFF200000)),
EventKind::Split { .. } => s.push(fill(x - 2.0, y - 2.0, 4.0, 4.0, 0xFFFF9020)),
EventKind::Strike { .. } => s.push(fill(x - 3.0, y - 3.0, 6.0, 6.0, 0xFFFFA040)),
}
}
// Header, legend, time line.
@@ -983,6 +1005,7 @@ impl Player {
EventKind::Crash { .. } => 0xFF903020,
EventKind::SamLost { .. } => 0xFFFF00FF,
EventKind::Split { .. } => 0xFFFF9020,
EventKind::Strike { .. } => 0xFFFFA040,
};
s.push(fill(x, ty - 2.0, 1.0, 7.0, c));
}
+1
View File
@@ -71,6 +71,7 @@ impl AirDef {
self.sim.targets.clear();
self.sim.target_health.clear();
self.sim.roles.clear();
self.sim.last_chance = true;
self.sim.kills = 0;
self.sim.hits = 0;
self.sim.launched = 0;
+579
View File
@@ -0,0 +1,579 @@
//! Balance measurements: a whole raid on flat land, flown headlessly against a cluster of systems.
//! The `#[ignore]`d tests print tables (`cargo test --release balance -- --ignored --nocapture`);
//! the others assert the cheap invariants. The tables are what the air defence's tuning is judged by.
use super::*;
/// The ten armed kinds in the order a cluster takes them (long-range heavy ones spread evenly).
const MIX: [SamKind; 10] = [
SamKind::Patriot,
SamKind::IrisT,
SamKind::S400,
SamKind::Pantsir,
SamKind::Nasams,
SamKind::Tor,
SamKind::S500,
SamKind::Buk,
SamKind::Gepard,
SamKind::P1sun,
];
/// What one drone went through, for saying why it got through.
#[derive(Default, Clone)]
struct Hist {
kind: Option<DroneKind>,
/// Ticks any system held a track of it.
seen: u32,
/// Ticks some loaded launcher with the class in its roles had it in range (`cover`), and held its track too (`reach`).
cover: u32,
reach: u32,
/// Distinct interceptors sent at it.
shots: std::collections::HashSet<u32>,
dead: bool,
last: (DVec3, DVec3),
/// Ticks to the aim when first covered, first tracked by a covering launcher, first shot at.
ttg: (f64, f64, f64),
}
#[derive(Default, Debug, Clone)]
pub(super) struct Result {
pub launched: u32,
pub kills: u32,
pub leaked: u32,
pub missiles: u32,
pub ticks: u64,
/// Leaks by cause.
pub unseen: u32,
pub no_cover: u32,
pub untracked: u32,
pub hits: u32,
pub crashes: u32,
/// Interceptors lost against the land, and ones that blew up short of a ridge.
pub blocked: u32,
pub scrubbed: u32,
pub unshot: u32,
pub missed: u32,
/// Missiles fired by the heavy systems (S-400, S-500, Patriot).
pub heavy: u32,
pub by_kind: Vec<(DroneKind, u32, u32)>,
pub tally: String,
/// Drones that vanished short of their aim without being shot down: kind, where, how far from the aim.
pub crashed: Vec<(DroneKind, DVec3, f64)>,
/// Chunk tickets the sim asked for.
pub tickets: usize,
pub scrubs: Vec<(MissileKind, u32, f64, f64, DVec3, f64, f64)>,
}
pub(super) struct Setup {
pub systems: usize,
pub smart: bool,
pub seed: u64,
pub size: u32,
pub radars: usize,
pub objects: usize,
/// Called on the sim before the raid (a test sets its switches).
pub tweak: fn(&mut Sim),
/// Hilly land instead of flat (`hills`).
pub hilly: bool,
/// Only a disc of this radius round the player (the origin) is loaded, as in the game; beyond it the land is not
/// there (`Void`) unless a chunk ticket was asked for (loaded after a delay, for a while).
pub disc: Option<f64>,
/// Mountains on top of the hills (ridges of up to 150 blocks, 600-900 blocks apart).
pub mountains: bool,
/// How far out the raid launches (the order's distance).
pub dist: f64,
/// Systems and objects sunk this far into the land (a player who stood inside it).
pub bury: f64,
/// How steep the hills are (1 is the usual), and whether a forest's canopy stands on them (the game's height map shows the leaves).
pub steep: f64,
pub trees: bool,
}
/// Rolling hills: ridges of 20-35 blocks over a plain at 64, wavelengths of 90-250 blocks.
pub(super) fn hills(x: i32, z: i32) -> i32 {
hills_k(x, z, 1.0)
}
/// `hills` with its ridges `k` times as steep (higher and closer together).
pub(super) fn hills_k(x: i32, z: i32, k: f64) -> i32 {
let (x, z) = (f64::from(x) * k, f64::from(z) * k);
let n = (x / 53.0 + (z / 91.0).sin() * 1.7).sin() * 0.5 + (z / 41.0 - (x / 77.0).cos()).sin() * 0.35 + ((x + z) / 130.0).sin() * 0.4 + ((x - 2.0 * z) / 29.0).sin() * 0.12;
64 + (n * 28.0 * k + 14.0).max(-4.0) as i32
}
/// Mountains: broad ridges over the hills, nothing within about 300 blocks of the origin.
pub(super) fn mountains(x: i32, z: i32) -> i32 {
let (x, z) = (f64::from(x), f64::from(z));
let r = (x * x + z * z).sqrt();
let ridge = ((x / 410.0 + 0.7).sin() * (z / 530.0 - 0.4).sin() + (x / 700.0 - z / 610.0).sin() * 0.6).max(0.0);
(ridge * 150.0 * ((r - 300.0) / 400.0).clamp(0.0, 1.0)) as i32
}
/// The game's view of the land: all of it, or a disc round the player and the chunks asked for.
pub(super) struct Loader {
pub hilly: bool,
pub mountains: bool,
pub disc: Option<f64>,
steep: f64,
trees: bool,
asked: std::cell::RefCell<HashMap<(i32, i32), (u64, u64)>>,
pub tickets: usize,
}
const TICKET_DELAY: u64 = 40;
const TICKET_LIFE: u64 = 400;
impl Loader {
pub fn new(s: &Setup) -> Self {
Loader { hilly: s.hilly, mountains: s.mountains, disc: s.disc, steep: s.steep, trees: s.trees, asked: Default::default(), tickets: 0 }
}
/// The land without the forest on it.
pub fn bare(&self, x: i32, z: i32) -> f64 {
f64::from(hills_k(x, z, self.steep) + if self.mountains { mountains(x, z) } else { 0 })
}
pub fn height(&self, x: i32, z: i32) -> i32 {
if !self.hilly {
return 64;
}
// A forest: a quarter of the 3x3 patches carry a canopy 8 blocks over the ground (the game's height map shows the leaves).
let canopy = if self.trees && ((x.div_euclid(3).wrapping_mul(73856093) ^ z.div_euclid(3).wrapping_mul(19349663)) as u32 >> 3) % 4 == 0 { 8 } else { 0 };
hills_k(x, z, self.steep) + canopy + if self.mountains { mountains(x, z) } else { 0 }
}
fn loaded(&self, c: (i32, i32), now: u64) -> Option<bool> {
let Some(r) = self.disc else { return Some(true) };
let (x, z) = (f64::from(c.0 * 16 + 8), f64::from(c.1 * 16 + 8));
if (x * x + z * z).sqrt() <= r {
return Some(true);
}
match self.asked.borrow().get(&c) {
Some(&(since, until)) if now < until => Some(now >= since + TICKET_DELAY),
_ => None,
}
}
pub fn serve(&mut self, ground: &mut Ground, now: u64) {
for c in ground.take_chunks() {
self.tickets += 1;
if std::env::var("NOTICKETS").is_err() {
let mut asked = self.asked.borrow_mut();
let e = asked.entry(c).or_insert((now, now));
if now >= e.1 {
*e = (now, now);
}
e.1 = now + TICKET_DELAY + TICKET_LIFE;
}
}
let cols = ground
.take_wanted()
.into_iter()
.map(|(x, z)| {
let y = match self.loaded((x >> 4, z >> 4), now) {
Some(true) => self.height(x, z),
Some(false) => PENDING,
None => -64,
};
(x, z, y)
})
.collect();
ground.answer(cols, now);
let secs = ground.world.take_wanted(now);
let view = Seen { l: self, now };
ground.world.serve(&view, secs, now);
}
}
struct Seen<'a> {
l: &'a Loader,
now: u64,
}
impl world::WorldSource for Seen<'_> {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
self.l.loaded((x >> 4, z >> 4), self.now).filter(|&l| l).map(|_| y < self.l.height(x, z))
}
}
impl Setup {
pub fn new(systems: usize, smart: bool, seed: u64) -> Self {
Setup { systems, smart, seed, size: 36, radars: (systems / 10).max(1), objects: 36, tweak: |_| {}, hilly: false, bury: 0.0, disc: None, mountains: false, dist: 500.0, steep: std::env::var("STEEP").ok().and_then(|v| v.parse().ok()).unwrap_or(1.0), trees: std::env::var("TREES").is_ok() }
}
}
/// The cluster: `objects` in a grid, systems of the mix scattered round them, radars among them.
pub(super) fn cluster(sim: &mut Sim, s: &Setup) {
let l = Loader::new(s);
let y = |x: f64, z: f64| if s.hilly { l.bare(x as i32, z as i32) - s.bury } else { 70.0 };
let mut rng = Rng::new(s.seed.wrapping_mul(977) + 5);
let side = (s.objects as f64).sqrt().ceil() as usize;
for k in 0..s.objects {
let (px, pz) = (((k % side) as f64 - side as f64 / 2.0) * 70.0, ((k / side) as f64 - side as f64 / 2.0) * 70.0);
let p = DVec3::new(px, if s.hilly { y(px, pz) + 6.0 + s.bury } else { 70.0 }, pz);
sim.targets.push(p);
sim.target_health.push(100.0);
}
let objects = sim.targets.clone();
for i in 0..s.systems {
let at = objects[(rng.f() * objects.len() as f64) as usize % objects.len()];
let a = rng.f() * TAU;
let r = 30.0 + rng.f() * 110.0;
let id = sim.id();
let (px, pz) = (at.x + a.sin() * r, at.z + a.cos() * r);
sim.sams.push(Sam::new(id, MIX[i % MIX.len()], DVec3::new(px, y(px, pz), pz), rng.f() * TAU));
}
for _ in 0..s.radars {
let a = rng.f() * TAU;
let r = rng.f() * 220.0;
let id = sim.id();
let (px, pz) = (a.sin() * r, a.cos() * r);
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(px, if s.hilly { y(px, pz) } else { 70.0 }, pz), 0.0));
}
}
/// Flies one raid to its end and counts.
pub(super) fn fly(s: &Setup) -> Result {
let mut sim = Sim::new(s.seed);
cluster(&mut sim, s);
(s.tweak)(&mut sim);
let mut ground = Ground::default();
let mut land = Loader::new(s);
let mut out = Out::default();
for _ in 0..200 {
land.serve(&mut ground, sim.tick);
sim.step(&mut ground, None, &mut out);
}
let mut order = crate::wave::WaveConfig::mix(s.size, s.dist);
// The Oreshnik's blocks are a raid of their own; this measures the ordinary mass raid.
order.kinds.retain(|k| k.0 != DroneKind::Oreshnik);
if s.smart {
order.aim = raid::Aim::Smart;
order.approach = raid::Approach::AllSides;
}
let targets = sim.targets.clone();
let fired0: u32 = sim.sams.iter().map(|m| m.fired).sum();
sim.order_raid(&order, &targets, &mut out);
let mut hist: HashMap<u32, Hist> = HashMap::new();
let mut result = Result::default();
let start = sim.tick;
let mut gone_unkilled: Vec<Hist> = Vec::new();
for _ in 0..9000 {
land.serve(&mut ground, sim.tick);
out = Out::default();
sim.step(&mut ground, None, &mut out);
let now = sim.tick;
for d in &sim.drones {
let h = hist.entry(d.id).or_default();
h.kind = Some(d.kind);
h.last = (d.pos, d.target);
if !d.alive() {
h.dead = true;
continue;
}
if sim.sams.iter().any(|m| m.seen.contains(&d.id)) {
h.seen += 1;
}
let class = d.kind.spec().class.bit();
let covers = |m: &&Sam| {
m.alive() && m.roles & class != 0 && m.missiles > 0 && m.kind.spec().missile.as_ref().is_some_and(|l| (l.min..l.max).contains(&m.pos.distance(d.pos)))
};
let ttg = why::arrival(d);
if h.cover == 0 && sim.sams.iter().filter(covers).next().is_some() {
h.ttg.0 = ttg;
}
if h.reach == 0 && sim.sams.iter().filter(covers).any(|m| m.seen.contains(&d.id)) {
h.ttg.1 = ttg;
}
h.cover += u32::from(sim.sams.iter().filter(covers).next().is_some());
h.reach += u32::from(sim.sams.iter().filter(covers).any(|m| m.seen.contains(&d.id)));
}
for m in &sim.missiles {
if let Some(h) = hist.get_mut(&m.target) {
if h.shots.is_empty() {
h.ttg.2 = h.last.0.distance(h.last.1) / h.kind.map_or(1.0, |k| k.spec().speed);
}
h.shots.insert(m.id);
}
}
// Drones gone from the sky that were never brought down arrived (or crashed).
let here: std::collections::HashSet<u32> = sim.drones.iter().map(|d| d.id).collect();
let queued: std::collections::HashSet<u32> = sim.queue.iter().map(|q| q.1.id).collect();
let vanished: Vec<u32> = hist.iter().filter(|(id, h)| !here.contains(id) && !queued.contains(id) && !h.dead && h.kind.is_some()).map(|(id, _)| *id).collect();
for id in vanished {
if let Some(h) = hist.remove(&id) {
// Gone from the sky near its aim: arrived. (A kill that detonates or breaks up removes the drone too.)
let k = h.kind.unwrap();
if !k.spec().decoy && h.last.0.distance(h.last.1) < 2.0 * k.spec().speed + 12.0 {
if std::env::var("LEAKS").is_ok() {
eprintln!(" leak {k:?} seen {} cover {} reach {} shots {} ttg cover/track/shot {:.0}/{:.0}/{:.0}", h.seen, h.cover, h.reach, h.shots.len(), h.ttg.0, h.ttg.1, h.ttg.2);
}
gone_unkilled.push(h);
}
}
}
// Dead ones that have landed are forgotten.
hist.retain(|id, h| !(h.dead && !here.contains(id)));
if now - start > 300 && sim.queue.is_empty() && !sim.planning() && !sim.drones.iter().any(|d| d.alive()) && sim.missiles.is_empty() {
break;
}
}
result.tickets = land.tickets;
result.scrubs = sim.scrubs.clone();
result.crashed = sim.crashed.iter().map(|c| (c.0, c.1, c.1.distance(c.2))).collect();
result.ticks = sim.tick - start;
result.launched = sim.launched;
result.kills = sim.kills;
result.missiles = sim.sams.iter().map(|m| m.fired).sum::<u32>() - fired0;
result.heavy = sim.sams.iter().filter(|m| matches!(m.kind, SamKind::S400 | SamKind::S500 | SamKind::Patriot)).map(|m| m.fired).sum();
result.hits = sim.hits;
result.crashes = sim.crashes;
result.blocked = sim.tally.blocked;
result.scrubbed = sim.tally.scrubbed;
result.leaked = sim.hits;
let mut kinds: HashMap<DroneKind, (u32, u32)> = HashMap::new();
for h in &gone_unkilled {
let k = h.kind.unwrap();
kinds.entry(k).or_default().0 += 1;
if h.seen == 0 {
result.unseen += 1;
} else if h.cover == 0 {
result.no_cover += 1;
} else if h.reach == 0 {
result.untracked += 1;
} else if h.shots.is_empty() {
result.unshot += 1;
} else {
result.missed += 1;
}
}
result.by_kind = kinds.into_iter().map(|(k, v)| (k, v.0, v.1)).collect();
result.by_kind.sort_by_key(|k| k.0 as u8);
result.tally = sim.tally.text();
result
}
pub(super) fn table(title: &str, smart: bool, size: u32, systems: &[usize], seeds: &[u64], tweak: fn(&mut Sim)) -> Vec<(usize, f64)> {
table_on(title, smart, size, systems, seeds, tweak, std::env::var("HILLS").is_ok())
}
pub(super) fn table_on(title: &str, smart: bool, size: u32, systems: &[usize], seeds: &[u64], tweak: fn(&mut Sim), hilly: bool) -> Vec<(usize, f64)> {
let mut means = Vec::new();
eprintln!("== {title} ({} raid{})", if smart { "smart" } else { "mix" }, if hilly { ", hills" } else { "" });
eprintln!("{:>4} {:>4} {:>5} {:>5} {:>5} {:>6} {:>6} | leaks: unseen nocover untrk unshot missed hits crash blkd scrub", "sams", "seed", "sent", "kills", "leak", "missil", "heavy");
for &n in systems {
let (mut total, mut missiles, mut heavy, mut clean, mut crashes, mut blocked) = (0, 0, 0, 0, 0, 0);
for &seed in seeds {
let mut s = Setup::new(n, smart, seed);
s.tweak = tweak;
s.size = size;
s.hilly = hilly;
let r = fly(&s);
total += r.leaked;
clean += u32::from(r.leaked == 0);
crashes += r.crashes;
blocked += r.blocked + r.scrubbed;
missiles += r.missiles;
heavy += r.heavy;
if std::env::var("ROWS").is_ok() { eprintln!("{n:>4} {seed:>4} {:>5} {:>5} {:>5} {:>6} {:>6} | {:>12} {:>7} {:>5} {:>6} {:>6} {:>4} {:>5} {:>4} {:>5} {:?}", r.launched, r.kills, r.leaked, r.missiles, r.heavy, r.unseen, r.no_cover, r.untracked, r.unshot, r.missed, r.hits, r.crashes, r.blocked, r.scrubbed, r.by_kind.iter().map(|k| format!("{:?}:{}", k.0, k.1)).collect::<Vec<_>>()); }
if seed == seeds[0] && std::env::var("ROWS").is_ok() {
eprintln!(" tally: {}", r.tally);
}
}
let mean = total as f64 / seeds.len() as f64;
let k = seeds.len() as f64;
eprintln!("{n:>4} mean leaks {mean:.1} (clean seeds {clean}/{}), missiles {:.0}, of them heavy {:.0}, drone crashes {:.1}, interceptors lost to land {:.1}", seeds.len(), missiles as f64 / k, heavy as f64 / k, crashes as f64 / k, blocked as f64 / k);
means.push((n, mean));
}
means
}
#[test]
#[ignore]
fn buried_table() {
for n in [20usize, 150] {
for seed in 1..=4u64 {
let mut s = Setup::new(n, false, seed);
s.hilly = true;
s.bury = 7.0;
let r = fly(&s);
eprintln!("buried {n} seed {seed}: sent {} kills {} leaked {} crashes {} blocked {} missiles {}", r.launched, r.kills, r.leaked, r.crashes, r.blocked, r.missiles);
}
}
}
#[test]
#[ignore]
fn hills_table() {
table_on("hills", false, 36, &[20, 50, 150], &[1, 2, 3, 4, 5, 6], |_| {}, true);
}
#[test]
#[ignore]
fn raid_table() {
for smart in [false, true] {
table("current", smart, 36, &[20, 50, 150], &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], |_| {});
}
}
/// A raid of 80 (about 100 drones): 20 systems clearly lose, 150 hold (a loose bound, one seed).
#[test]
fn a_small_cluster_loses_and_a_big_one_holds() {
let mut s = Setup::new(20, false, 2);
s.size = 80;
let small = fly(&s);
let mut s = Setup::new(150, false, 2);
s.size = 80;
let big = fly(&s);
eprintln!("20 systems: {small:?}\n150 systems: {big:?}");
assert!(small.launched >= 80, "the raid was not launched: {small:?}");
assert!(small.leaked >= 10, "20 systems should clearly lose, leaked {}", small.leaked);
assert!(big.leaked <= 5, "150 systems leaked {}", big.leaked);
}
/// The same on rolling hills: 150 systems still hold about as on flat land, drones do not fly into the land on
/// their own, and the interceptors do not routinely strike it (a few that blow up short of a ridge are allowed).
#[test]
fn on_hills_a_big_cluster_holds_and_nothing_routinely_hits_the_land() {
let mut s = Setup::new(150, false, 2);
s.size = 80;
s.hilly = true;
let big = fly(&s);
let mut s = Setup::new(20, false, 2);
s.size = 80;
s.hilly = true;
let small = fly(&s);
eprintln!("hills, 20 systems: {small:?}\nhills, 150 systems: {big:?}");
assert!(small.leaked >= 10, "20 systems should clearly lose on hills too, leaked {}", small.leaked);
assert!(big.leaked <= 6, "150 systems on hills leaked {}", big.leaked);
assert!(big.crashes <= 2, "drones flew into the hills: {}", big.crashes);
assert!(big.blocked * 20 <= big.missiles, "interceptors struck the land: {} of {}", big.blocked, big.missiles);
}
/// A raid from 2500 blocks over mountains, with only a disc round the player known (and chunks that load on request):
/// the flights do not fly into the land by themselves (a rare one that cannot climb a ridge in time is allowed).
#[test]
fn far_raids_over_mountains_do_not_crash_into_them() {
let (mut flown, mut crashed) = (0, 0);
for seed in [3u64, 4, 8] {
for smart in [false, true] {
let mut s = Setup::new(50, smart, seed);
s.hilly = true;
s.mountains = true;
s.dist = 2500.0;
s.disc = Some(300.0);
let r = fly(&s);
eprintln!("seed {seed} smart {smart}: {} crashed of {}: {:?}", r.crashed.len(), r.launched, r.crashed.iter().map(|c| c.0).collect::<Vec<_>>());
flown += r.launched;
crashed += r.crashed.len() as u32;
}
}
assert!(crashed * 40 <= flown, "{crashed} of {flown} flew into the land");
}
/// Steep forested hills: an interceptor that is fired is one the land lets through. The launch check asks the same
/// of a shot as the flight does (the margin over the land, the loft), so few blow up short of a ridge.
#[test]
fn on_steep_forested_hills_few_interceptors_are_scrubbed() {
let (mut fired, mut scrubbed) = (0, 0);
for seed in 1..=3u64 {
let mut s = Setup::new(15, false, seed);
s.radars = 2;
s.objects = 4;
s.hilly = true;
s.steep = 1.6;
s.trees = true;
let r = fly(&s);
fired += r.missiles;
scrubbed += r.scrubbed;
}
eprintln!("scrubbed {scrubbed} of {fired}");
assert!(scrubbed * 100 <= fired * 10, "{scrubbed} of {fired} interceptors blew up short of a ridge");
}
/// What each of the new rules adds, one at a time (the raid of 36 and a heavier one of 80).
#[test]
#[ignore]
fn rules_one_by_one() {
for size in [36, 80] {
for smart in [false, true] {
table(&format!("size {size}: roles only"), smart, size, &[20, 50, 150], &[1, 2, 3, 4, 5, 6], |s| {
s.last_chance = false;
s.far_shots = false;
});
table(&format!("size {size}: + last chance"), smart, size, &[20, 50, 150], &[1, 2, 3, 4, 5, 6], |s| s.far_shots = false);
table(&format!("size {size}: + last chance + long shots"), smart, size, &[20, 50, 150], &[1, 2, 3, 4, 5, 6], |_| {});
}
}
}
/// One raid in detail, for hunting a leak: `LEAKS=1 cargo test --release one_raid -- --ignored --nocapture`.
#[test]
#[ignore]
fn one_raid() {
let n: usize = std::env::var("SAMS").ok().and_then(|v| v.parse().ok()).unwrap_or(150);
let smart = std::env::var("SMART").is_ok();
let size: u32 = std::env::var("SIZE").ok().and_then(|v| v.parse().ok()).unwrap_or(36);
for seed in 1..=12 {
let mut s = Setup::new(n, smart, seed);
s.size = size;
let r = fly(&s);
eprintln!("seed {seed}: sent {} kills {} leaked {} missiles {} ticks {}", r.launched, r.kills, r.leaked, r.missiles, r.ticks);
}
}
/// Far launches over mountains where only a disc round the player is known: who crashes, where and how often.
/// `cargo test --release far_table -- --ignored --nocapture` (DISC=300 sets the radius, 0 for all known).
#[test]
#[ignore]
fn far_table() {
let disc: f64 = std::env::var("DISC").ok().and_then(|v| v.parse().ok()).unwrap_or(300.0);
let dist: f64 = std::env::var("DIST").ok().and_then(|v| v.parse().ok()).unwrap_or(2500.0);
let mut by_kind: HashMap<DroneKind, (u32, u32)> = HashMap::new();
let (mut launched, mut kills, mut leaked, mut tickets) = (0, 0, 0, 0);
for seed in std::env::var("SEED").ok().and_then(|v| v.parse::<u64>().ok()).map_or(1..=8u64, |v| v..=v) {
let mut s = Setup::new(50, std::env::var("SMART").is_ok(), seed);
s.hilly = true;
s.mountains = true;
s.dist = dist;
s.disc = (disc > 0.0).then_some(disc);
let r = fly(&s);
launched += r.launched;
kills += r.kills;
leaked += r.leaked;
tickets += r.tickets;
for c in &r.crashed {
by_kind.entry(c.0).or_default().0 += 1;
if std::env::var("ROWS").is_ok() {
let (x, z) = (c.1.x as i32, c.1.z as i32);
eprintln!(" seed {seed} {:?} crashed at {:.0},{:.0},{:.0} ({:.0} from origin, land {}), {:.0} from its aim", c.0, c.1.x, c.1.y, c.1.z, c.1.x.hypot(c.1.z), Loader::new(&s).height(x, z), c.2);
}
}
}
let mut v: Vec<_> = by_kind.into_iter().collect();
v.sort_by_key(|k| k.0 as u8);
eprintln!("disc {disc} dist {dist}: launched {launched} kills {kills} leaked {leaked} chunk tickets {tickets}; crashed by kind {:?}", v.iter().map(|k| (k.0.clone(), k.1.0)).collect::<Vec<_>>());
}
/// Interceptors that blew up short of a ridge (`STEEP=1.6`, `SAMS=15`, `HILLS=0` for flat): by kind, and what the target was doing.
#[test]
#[ignore]
fn scrub_table() {
let n: usize = std::env::var("SAMS").ok().and_then(|v| v.parse().ok()).unwrap_or(15);
let hilly = std::env::var("HILLS").map_or(true, |v| v != "0");
let (mut fired, mut scrubbed, mut kills, mut launched, mut leaked) = (0, 0, 0, 0, 0);
let mut by: HashMap<String, (u32, u32)> = HashMap::new();
let (mut near, mut low) = (0, 0);
for seed in 1..=8u64 {
let mut s = Setup::new(n, false, seed);
s.radars = 2;
s.objects = 4;
s.hilly = hilly;
let r = fly(&s);
fired += r.missiles;
scrubbed += r.scrubbed;
kills += r.kills;
launched += r.launched;
leaked += r.leaked;
for c in &r.scrubs {
by.entry(format!("{:?}", c.0)).or_default().0 += 1;
near += u32::from(c.2 < 150.0);
low += u32::from(c.3 < 30.0);
}
}
let mut v: Vec<_> = by.into_iter().collect();
v.sort();
eprintln!("sams {n} hilly {hilly}: launched {launched} kills {kills} leaked {leaked} missiles {fired} scrubbed {scrubbed} (target within 150: {near}, target under 30 over land: {low}) by kind {:?}", v.iter().map(|k| (k.0.clone(), k.1.0)).collect::<Vec<_>>());
}
+269
View File
@@ -0,0 +1,269 @@
//! Fire doctrine (#47): what a system is told about its job beyond the roles. Protected objects (it fires
//! only at what threatens them), a sector of responsibility, a reserve of missiles held back for what
//! matters, and how many interceptors it asks for on one target. The predicates are shared by `step_sams`
//! (through `why::basis`) and the diagnostic; the default doctrine blocks nothing and changes nothing.
//! Everything is judged on the system's track (its forecast), never on the drone's true place.
use super::*;
/// How many interceptors a system asks for on one target: the base shoot-look-shoot only, as now, or one more.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum Salvo {
Economy,
#[default]
Normal,
Heavy,
}
impl Salvo {
pub fn name(self) -> &'static str {
match self {
Salvo::Economy => "экономный",
Salvo::Normal => "обычный",
Salvo::Heavy => "усиленный",
}
}
pub fn key(self) -> &'static str {
match self {
Salvo::Economy => "eco",
Salvo::Normal => "normal",
Salvo::Heavy => "heavy",
}
}
pub fn from_key(s: &str) -> Option<Salvo> {
[Salvo::Economy, Salvo::Normal, Salvo::Heavy].into_iter().find(|v| v.key() == s)
}
pub fn next(self) -> Salvo {
match self {
Salvo::Economy => Salvo::Normal,
Salvo::Normal => Salvo::Heavy,
Salvo::Heavy => Salvo::Economy,
}
}
}
/// A system's doctrine. `Default` is the sandbox: no guards, no sector, no reserve, the usual salvo.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Doctrine {
/// Protected objects (their places): when any, the system fires only at what threatens one of them.
pub guards: Vec<DVec3>,
/// Sector of responsibility: centre (a yaw as `heading_of`, radians) and half-width.
pub sector: Option<(f64, f64)>,
/// Missiles held back: below this many, only what matters is shot at.
pub reserve: u32,
pub salvo: Salvo,
}
/// Where a track counts as threatening an object: within this many blocks of it, or flying at it.
pub const GUARD_R: f64 = 120.0;
/// Most missiles one can reserve.
pub const MAX_RESERVE: u32 = 64;
pub(super) const OUT_SECTOR: u8 = 1;
pub(super) const NO_THREAT: u8 = 2;
pub(super) const RESERVE: u8 = 4;
impl Doctrine {
pub fn is_default(&self) -> bool {
*self == Doctrine::default()
}
/// Whether the track is within the sector (judged from the system's place).
pub fn in_sector(&self, pos: DVec3, at: DVec3) -> bool {
self.sector.is_none_or(|(centre, half)| {
let to = at - pos;
to.x.hypot(to.z) < 30.0 || angle_diff(centre, heading_of(to)).abs() <= half
})
}
/// The first protected object the track threatens (its index in `guards`): near it, or flying towards it.
pub fn threatens(&self, track: &Track, now: u64) -> Option<usize> {
let at = track.est(now);
let slack = GUARD_R + track.uncertainty(now);
self.guards.iter().position(|g| {
let (dx, dz) = (g.x - at.x, g.z - at.z);
let near = dx.hypot(dz);
if near <= slack {
return true;
}
let (vx, vz) = (track.vel.x, track.vel.z);
let speed = vx.hypot(vz);
// Closing, and the line of flight passes the object within the slack.
speed > 1e-3 && vx * dx + vz * dz > 0.0 && (vx * dz - vz * dx).abs() / speed <= slack
})
}
/// Why the area (sector, protected objects) rules the track out; 0 is a pass.
pub fn area_blocks(&self, pos: DVec3, track: &Track, now: u64) -> u8 {
(if self.in_sector(pos, track.est(now)) { 0 } else { OUT_SECTOR }) | (if self.guards.is_empty() || self.threatens(track, now).is_some() { 0 } else { NO_THREAT })
}
/// What matters enough to spend the reserve: a ballistic missile, or anything about to hit.
pub fn matters(d: &Drone) -> bool {
d.kind.spec().class == Class::Ballistic || why::arrival(d) < why::URGENT
}
/// The reserve holds the launch back: the magazine is at the reserve and the target doesn't matter.
pub fn reserve_blocks(&self, missiles: u32, d: &Drone) -> u8 {
if self.reserve > 0 && missiles <= self.reserve && !Self::matters(d) { RESERVE } else { 0 }
}
/// Interceptors this doctrine asks for on `d`: `why::needed` by default, the base only when economical, one more when heavy.
pub fn needed(&self, d: &Drone) -> u32 {
let base = if d.kind.spec().class == Class::Ballistic { 2 } else { 1 };
match self.salvo {
Salvo::Economy => base,
Salvo::Normal => why::needed(d),
Salvo::Heavy => why::needed(d) + 1,
}
}
/// The sector in words (`90±45°`); degrees on the same scale as `/airdef doctrine ... sector`.
pub fn sector_text(&self) -> String {
match self.sector {
Some((c, h)) => format!("{:.0}±{:.0}°", c.to_degrees().rem_euclid(360.0), h.to_degrees()),
None => "круговой".to_string(),
}
}
/// The doctrine as the player reads it, empty for the default.
pub fn text(&self, targets: &[DVec3]) -> String {
if self.is_default() {
return "обычная (без ограничений)".to_string();
}
let guards = if self.guards.is_empty() {
"нет".to_string()
} else {
self.guards.iter().map(|g| object_name(targets, *g)).collect::<Vec<_>>().join(", ")
};
format!("охрана: {guards}; сектор: {}; резерв: {}; залп: {}", self.sector_text(), self.reserve, self.salvo.name())
}
/// One line of the save: `salvo reserve sector-centre sector-half guards x y z ...` (`-` for no sector).
pub fn save_text(&self) -> String {
let sector = self.sector.map_or("- -".to_string(), |(c, h)| format!("{c} {h}"));
let mut s = format!("{} {} {sector} {}", self.salvo.key(), self.reserve, self.guards.len());
for g in &self.guards {
s += &format!(" {} {} {}", g.x, g.y, g.z);
}
s
}
}
/// An object by number in the world's list ("объект №2"), else by place.
pub fn object_name(targets: &[DVec3], g: DVec3) -> String {
match targets.iter().position(|t| t.distance(g) < 16.0) {
Some(i) => format!("объект №{}", i + 1),
None => format!("точка {:.0} {:.0}", g.x, g.z),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn track(pos: DVec3, vel: DVec3) -> Track {
Track { id: 1, kind: DroneKind::Shahed, pos, vel, tick: 0, own_tick: 0, ttl: 100, hits: 3, locked: false }
}
#[test]
fn the_default_doctrine_blocks_nothing() {
let d = Doctrine::default();
let t = track(DVec3::new(500.0, 100.0, 0.0), DVec3::ZERO);
assert_eq!(d.area_blocks(DVec3::ZERO, &t, 0), 0);
assert!(d.is_default());
assert_eq!(Doctrine::default().save_text(), "normal 0 - - 0");
}
#[test]
fn a_sector_and_guards_rule_targets_out_by_their_tracks() {
let mut d = Doctrine::default();
// North is yaw 0 (towards -z): a sector of 45 degrees either side of north.
d.sector = Some((0.0, 45f64.to_radians()));
let north = track(DVec3::new(0.0, 100.0, -800.0), DVec3::ZERO);
let south = track(DVec3::new(0.0, 100.0, 800.0), DVec3::ZERO);
assert_eq!(d.area_blocks(DVec3::ZERO, &north, 0), 0);
assert_eq!(d.area_blocks(DVec3::ZERO, &south, 0), OUT_SECTOR);
d.sector = None;
d.guards = vec![DVec3::new(1000.0, 70.0, 0.0)];
// Flying at the object from afar threatens it; flying past, or away, doesn't.
let at = track(DVec3::new(-2000.0, 100.0, 10.0), DVec3::new(2.0, 0.0, 0.0));
let past = track(DVec3::new(-2000.0, 100.0, 900.0), DVec3::new(2.0, 0.0, 0.0));
let away = track(DVec3::new(-2000.0, 100.0, 0.0), DVec3::new(-2.0, 0.0, 0.0));
assert_eq!(d.area_blocks(DVec3::ZERO, &at, 0), 0);
assert_eq!(d.area_blocks(DVec3::ZERO, &past, 0), NO_THREAT);
assert_eq!(d.area_blocks(DVec3::ZERO, &away, 0), NO_THREAT);
}
#[test]
fn the_save_line_is_what_the_layout_reads() {
let mut d = Doctrine { salvo: Salvo::Economy, reserve: 3, ..Default::default() };
d.sector = Some((1.5, 0.5));
d.guards = vec![DVec3::new(1.0, 2.0, 3.0)];
let text = format!("sam {} 0 70 0 0\ndoctrine 0 {}\n", SamKind::Patriot.key(), d.save_text());
let l = crate::layout::Layout::parse(&text).unwrap();
assert_eq!(l.doctrines, vec![(0, d)]);
assert!(crate::layout::Layout::parse("doctrine 0 loud 1 - - 0\n").is_err());
assert!(crate::layout::Layout::parse("doctrine 0 eco 1 - - 2 1 2 3\n").is_err(), "guards cut short");
}
use super::sites::testland::{Land, serve};
/// One Patriot against one Shahed flying in from the north at its aim, the doctrine set before the raid:
/// the missiles it fired, the Shahed's ticks to its aim at the first launch, and the `why` lines at the end.
fn fire(doc: Doctrine) -> (u32, Option<f64>, Vec<String>) {
let mut sim = Sim::new(5);
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Buk, DVec3::new(0.0, 70.0, 0.0), 0.0));
sim.sams[0].roles = 0xFF;
sim.sams[0].doctrine = doc;
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, -600.0), DVec3::Z, DVec3::new(0.0, 70.0, -80.0));
let (mut ground, land, mut out) = (Ground::default(), Land(|_, _| 64), Out::default());
let mut first = None;
let mut lines: Vec<String> = Vec::new();
for _ in 0..900 {
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
if first.is_none() && sim.sams[0].fired > 0 {
first = sim.drones.first().map(why::arrival);
}
if sim.drones.iter().all(|d| !d.alive()) {
break;
}
// The explanation while the target is in the sky: all the lines it gave then.
if sim.tick % 10 == 0 {
lines.extend(sim.why(0).into_iter().map(|c| c.text));
}
}
(sim.sams[0].fired, first, lines)
}
// #47: the defaults fire as before; each setting changes the decision and `why` names it
#[test]
fn each_setting_changes_the_shot_and_why_says_which() {
let (base, first, w) = fire(Doctrine::default());
assert!(base > 0, "the baseline never fired: {w:?}");
let first = first.unwrap();
assert!(first > why::URGENT, "the baseline fires late: {first}");
let has = |text: &[String], what: &str| text.iter().any(|l| l.contains(what));
// The raid comes from the north (yaw 0): a sector round north holds it, one round south does not.
let sector = |c: f64| Doctrine { sector: Some((f64::to_radians(c), f64::to_radians(40.0))), ..Default::default() };
assert_eq!(fire(sector(0.0)).0, base, "a sector that holds the raid changes nothing");
let (n, _, why) = fire(sector(180.0));
assert_eq!(n, 0);
assert!(has(&why, "вне сектора"), "{why:?}");
// A reserve above the magazine holds the shot until the target is urgent.
let (n, late, _) = fire(Doctrine { reserve: 64, ..Default::default() });
assert!(n > 0 && late.unwrap() < why::URGENT, "{n} {late:?}");
// An object far off the raid's line is no one's to guard here; the one it flies at is.
let (n, _, why) = fire(Doctrine { guards: vec![DVec3::new(0.0, 70.0, 50000.0)], ..Default::default() });
assert_eq!(n, 0);
assert!(has(&why, "не угрожает"), "{why:?}");
let (n, _, why) = fire(Doctrine { guards: vec![DVec3::new(0.0, 70.0, -80.0)], ..Default::default() });
assert_eq!(n, base);
assert!(has(&why, "прикрывает"), "{why:?}");
// Economical asks for no more interceptors than the usual, heavy no fewer.
let (eco, _, _) = fire(Doctrine { salvo: Salvo::Economy, ..Default::default() });
let (heavy, _, _) = fire(Doctrine { salvo: Salvo::Heavy, ..Default::default() });
assert!(eco <= base && base <= heavy, "{eco} {base} {heavy}");
}
}
+6
View File
@@ -145,6 +145,12 @@ impl Sim {
out
}
/// Whether the enemy has this unit in its intelligence: a radar emits, so it is known; the rest only once
/// they gave themselves away (a launch, jamming) and are in `known`.
pub(super) fn enemy_knows(s: &Sam, known: &[(u32, DVec3)]) -> bool {
s.alive() && (s.kind == SamKind::Radar || known.iter().any(|k| k.0 == s.id))
}
/// During a smart raid: its drones not yet launched go for what just gave itself away (a jamming station
/// first, then systems that fired), and drones whose system target is gone pick another.
pub(super) fn adapt(&mut self) {
+15 -1
View File
@@ -15,6 +15,8 @@ pub const BEAM_INNER: f64 = 0.03;
pub const BEAM_OUTER: f64 = 0.075;
/// A target is lit once the axis is this close to it (inside the cone's soft edge).
const LIT_ANGLE: f64 = 0.05;
/// Most lamps on one target.
const MAX_LAMPS: u32 = 2;
/// Ticks between casts of the beam's axis against the land.
const CAST_EVERY: u64 = 4;
/// Guns' hits at night on an unlit target.
@@ -75,6 +77,10 @@ impl Sim {
self.flares = keep;
// What the network tracks, for the searchlights.
let tracked: Vec<u32> = self.sams.iter().flat_map(|s| s.seen.iter().copied()).collect();
// A lamp holds a target only while fewer than two others do; the sky is swept only while
// something flies in it.
let mut held: HashMap<u32, u32> = HashMap::new();
let anything = self.drones.iter().any(|d| d.alive());
let gun_targets: Vec<u32> = self.sams.iter().filter_map(|s| s.gun_target).collect();
for i in 0..self.sams.len() {
if self.sams[i].kind != SamKind::Light || !self.sams[i].alive() || self.sams[i].moving() {
@@ -93,12 +99,20 @@ impl Sim {
let target = self
.drones
.iter()
.filter(|d| d.alive() && tracked.contains(&d.id) && d.pos.distance(pos) < BEAM)
.filter(|d| d.alive() && tracked.contains(&d.id) && d.pos.distance(pos) < BEAM && held.get(&d.id).copied().unwrap_or(0) < MAX_LAMPS)
.min_by(|a, b| {
let key = |d: &Drone| d.pos.distance(pos) * if gun_targets.contains(&d.id) { 0.3 } else { 1.0 };
key(a).total_cmp(&key(b))
})
.map(|d| (d.id, d.pos));
if let Some((t, _)) = target {
*held.entry(t).or_insert(0) += 1;
} else if !anything {
let sam = &mut self.sams[i];
sam.target = None;
sam.beam_on = false;
continue;
}
let sam = &mut self.sams[i];
let (head, _) = sam.mount();
let (want_yaw, want_pitch, reach) = match target {
+5 -4
View File
@@ -54,6 +54,7 @@ impl Sim {
continue;
}
let at = truck.pos;
let truck_id = truck.id;
// Where it was going: hand over, or load up.
if let Some(job) = truck.job
&& let Some(j) = self.sams.iter().position(|s| s.id == job)
@@ -73,7 +74,7 @@ impl Sim {
// Stopped short of it (it drove off meanwhile): on to where it stands now.
let to = self.sams[j].pos;
let side = horizontal(at - to).normalize_or(DVec3::X) * (HANDOVER * 0.6);
self.order_move(t, to + side);
self.order_move_near(t, to + side, 3.5);
continue;
} else {
self.sams[t].job = None;
@@ -84,7 +85,7 @@ impl Sim {
let next = if self.sams[t].cargo > 0 {
self.sams
.iter()
.filter(|s| s.empty && s.alive() && !s.moving() && !taken.contains(&s.id))
.filter(|s| s.empty && s.alive() && !s.moving() && !taken.contains(&s.id) && !self.routes.out_of_reach(truck_id, s.id, now))
.min_by(|a, b| a.pos.distance(at).total_cmp(&b.pos.distance(at)))
.map(|s| (s.id, s.pos))
} else {
@@ -95,7 +96,7 @@ impl Sim {
.then(|| {
self.sams
.iter()
.filter(|d| d.kind == SamKind::Depot && d.alive() && d.cargo > 0)
.filter(|d| d.kind == SamKind::Depot && d.alive() && d.cargo > 0 && !self.routes.out_of_reach(truck_id, d.id, now))
.min_by(|a, b| a.pos.distance(at).total_cmp(&b.pos.distance(at)))
.map(|d| (d.id, d.pos))
})
@@ -106,7 +107,7 @@ impl Sim {
// Stop beside it, not on it.
let side = horizontal(at - to).normalize_or(DVec3::X) * (HANDOVER * 0.6);
if to.distance(at) > HANDOVER {
self.order_move(t, to + side);
self.order_move_near(t, to + side, 3.5);
}
}
}
+331 -26
View File
@@ -10,9 +10,11 @@ use glam::{DQuat, DVec3};
use std::collections::HashMap;
mod damage;
pub mod doctrine;
pub mod fires;
mod raid;
pub mod sites;
pub mod strike;
pub mod record;
mod sight;
mod terrain;
@@ -20,18 +22,21 @@ mod ew;
pub mod lights;
mod logistics;
mod orders;
mod route;
mod voices;
pub mod outcome;
mod units;
mod why;
pub mod world;
#[cfg(test)]
mod balance;
#[cfg(test)]
mod regress;
#[cfg(test)]
mod sensor_tests;
pub use ew::EW_RADIUS;
pub use damage::{Comps, Part, PartState};
pub use sight::{Ident, Track};
pub use sight::{AirPicture, Ident, LowSensor, Track, RIM_RAYS};
use damage::dice;
use outcome::{Impact, Outcome};
pub use units::{Family, SamKind};
@@ -229,14 +234,38 @@ pub struct Ground {
known: HashMap<(i32, i32), (i32, u64)>,
wanted: Vec<(i32, i32)>,
asked: std::collections::HashSet<(i32, i32)>,
/// Columns whose chunk the game is loading (it answered `PENDING`), and the tick it said so: not asked
/// again for a few ticks.
loading: HashMap<(i32, i32), u64>,
/// Chunks the search wants loaded (`render.load-chunks`), taken every tick.
chunks: Vec<(i32, i32)>,
/// The game has answered "loading" at least once: it loads chunks on request (a server doesn't).
pub loads: bool,
/// The tick of the last full clean-up (a big cache would otherwise be scanned on every answer).
swept: u64,
/// The tick of the last answer round: what the heights' ages are measured against.
now: u64,
/// The world's epoch when the last questions went out: an answer to them is of that world or dimension, or of none.
asked_epoch: Option<u32>,
/// The blocks themselves (issues #42, #43): what stops a flight.
pub world: world::WorldCache,
}
/// What `render.ground` answers where the chunk is not loaded (and never will be, unless asked for).
pub const NO_LAND: i32 = -64;
/// A height the game answered as a land height: none where it had no chunk. The one place that reads `NO_LAND`.
pub(crate) fn land_height(y: i32) -> Option<f64> {
(y > NO_LAND).then_some(f64::from(y))
}
/// What `render.ground` answers (`i32::MIN`) for a column whose chunk it is loading for the mod.
pub const PENDING: i32 = i32::MIN;
/// Ticks before a column that was loading is asked about again.
const LOAD_RECHECK: u64 = 6;
/// The chunks asked to load at once (the game holds 64).
const CHUNKS_MAX: usize = 48;
/// Ticks a strike drone may fly before it is lost: a Shahed at 0.6 blocks a tick needs 6700 for 4000 blocks.
const MAX_FLIGHT: u32 = 24_000;
/// The `when` of a height reported changed: due to be asked again.
const STALE: u64 = u64::MAX;
/// Ticks after which a land height used by the flights' look-ahead is asked again (#64), changes told of or not.
@@ -259,7 +288,7 @@ impl Ground {
if (when == STALE || now.saturating_sub(when) > 200) && self.wanted.len() < 64 && self.asked.insert(key) {
self.wanted.push(key);
}
(y > -64).then_some(f64::from(y))
land_height(y)
}
None => {
if self.wanted.len() < 256 && self.asked.insert(key) {
@@ -269,12 +298,37 @@ impl Ground {
}
}
}
/// The game's answers to the questions of `take_wanted`. Each is a height (`land_height`), "no land" or
/// "loading"; one to a question of another world or dimension than the one open now is dropped (it is
/// asked again when needed), not taken as this one's.
pub fn answer(&mut self, answers: Vec<(i32, i32, i32)>, now: u64) {
self.now = now;
let answers = match self.asked_epoch.take() {
Some(e) if e != 0 && e != self.world.epoch() => {
self.world.stats.late_dropped += answers.len() as u64;
Vec::new()
}
_ => answers,
};
for (x, z, y) in answers {
if y == PENDING {
self.loading.insert((x, z), now);
self.loads = true;
continue;
}
self.loading.remove(&(x, z));
self.known.insert((x, z), (y, now));
}
if self.loading.len() > 4096 {
self.loading.retain(|_, t| now.saturating_sub(*t) < 400);
}
self.age_heights();
// Blasts the game did not report in the blocks: the land there is looked at again.
for at in self.world.overdue_blasts(now) {
let how = if self.peek(at).is_some() { world::Settled::Unchanged } else { world::Settled::Unloaded };
self.world.note_settled(how);
self.forget(at, 8);
}
if self.known.len() > 300_000 && now.saturating_sub(self.swept) >= SWEEP_EVERY {
self.swept = now;
self.known.retain(|_, (_, when)| now.saturating_sub(*when) < 2000);
@@ -283,6 +337,13 @@ impl Ground {
/// Heights over blocks that changed (the block cache's notes of changes, chunk replacements and a new world)
/// are marked old (`when` = STALE): still used, asked again within the budget. #64.
fn age_heights(&mut self) {
// Another world or dimension: nothing known, asked or loading belongs to this one.
if std::mem::take(&mut self.world.epoch_reset) {
self.known.clear();
self.loading.clear();
self.wanted.clear();
self.asked.clear();
}
let all = std::mem::take(&mut self.world.changed_all);
let cols = std::mem::take(&mut self.world.changed_cols);
if !all && cols.is_empty() {
@@ -318,8 +379,43 @@ impl Ground {
fn due(&self, when: u64) -> bool {
when == STALE || self.now.saturating_sub(when) > HEIGHT_TTL
}
/// Asks the game to load the chunk (chunk x, z) for a while, if there is room; whether it did.
pub fn want_chunk(&mut self, c: (i32, i32)) -> bool {
if self.chunks.contains(&c) {
return true;
}
if self.chunks.len() >= CHUNKS_MAX {
return false;
}
self.chunks.push(c);
// What was answered as "no chunk" there is asked again: the chunk is being loaded now.
for x in c.0 * 16..c.0 * 16 + 16 {
for z in c.1 * 16..c.1 * 16 + 16 {
if self.known.get(&(x, z)).is_some_and(|v| land_height(v.0).is_none()) {
self.known.remove(&(x, z));
}
}
}
for x in (c.0 * 16..c.0 * 16 + 16).step_by(8) {
for z in (c.1 * 16..c.1 * 16 + 16).step_by(8) {
if self.known.get(&(x + 4, z + 4)).is_some_and(|v| land_height(v.0).is_none()) {
self.known.remove(&(x + 4, z + 4));
}
}
}
true
}
/// How many chunks are wanted so far this tick.
pub fn chunks_wanted(&self) -> usize {
self.chunks.len()
}
/// The chunks to ask the game to load this tick.
pub fn take_chunks(&mut self) -> Vec<(i32, i32)> {
std::mem::take(&mut self.chunks)
}
pub fn take_wanted(&mut self) -> Vec<(i32, i32)> {
self.asked.clear();
self.asked_epoch = Some(self.world.epoch());
std::mem::take(&mut self.wanted)
}
/// The first solid block on the way from `a` to `b` (the real blocks, not the height of the land).
@@ -524,6 +620,13 @@ pub struct DroneSpec {
pub ballistic: Option<f64>,
}
impl DroneSpec {
/// Its size, for how thick its smoke is.
pub fn trail_scale(&self) -> f32 {
self.scale as f32
}
}
impl DroneKind {
pub const ALL: [DroneKind; 13] = [
DroneKind::Shahed,
@@ -856,6 +959,40 @@ pub struct Drone {
err: DVec3,
/// Ticks held at the edge of blocks the game has not told of (#61); reset by a clear step.
hold: u16,
/// Its progress towards the aim: a flight that stops getting nearer runs out of fuel (see `Pace`).
pace: Pace,
}
/// Ticks a cruise flight may go without getting nearer its aim or passing a route point before it is out of fuel:
/// long enough for a detour round a radar, far short of `MAX_FLIGHT`. It stops a drone circling an aim it
/// cannot turn onto, or one that holds for an answer that never comes, from loitering over the map for ever.
pub const STALL_TICKS: u32 = 3600;
/// How much nearer counts as getting nearer (blocks).
const STALL_GAIN: f64 = 10.0;
#[derive(Clone, Copy, Debug)]
struct Pace {
/// The least distance to the aim so far, the age it was reached at, and the route points left then.
best: f64,
at: u32,
route: usize,
}
impl Default for Pace {
fn default() -> Self {
Pace { best: f64::INFINITY, at: 0, route: usize::MAX }
}
}
impl Pace {
/// One tick: true when the flight has gone `STALL_TICKS` without progress.
fn stalled(&mut self, age: u32, distance: f64, route: usize) -> bool {
if distance + STALL_GAIN < self.best || route < self.route {
*self = Pace { best: distance.min(self.best), at: age, route };
return false;
}
age.saturating_sub(self.at) > STALL_TICKS
}
}
/// A drone's terrain following: refreshed every few ticks, used every one.
@@ -865,11 +1002,14 @@ struct Nav {
need: f64,
/// The height from which a straight dive on its target clears the land (unknown: -inf).
dive: f64,
/// The land ahead is not known (a chunk the game does not have, or not yet): it keeps a transit height
/// above anything it could meet and comes down only once the land ahead is told of.
blind: bool,
}
impl Default for Nav {
fn default() -> Self {
Nav { need: f64::NEG_INFINITY, dive: f64::NEG_INFINITY }
Nav { need: f64::NEG_INFINITY, dive: f64::NEG_INFINITY, blind: false }
}
}
@@ -924,6 +1064,11 @@ struct MissileSpec {
}
impl MissileKind {
/// How thick its motor smoke is (its size); `None` for an electric one.
pub fn trail_scale(self) -> Option<f32> {
let s = self.spec();
(s.flame > 0.0).then_some(s.scale as f32)
}
/// The sound of its launch.
pub fn launch_sound(self) -> &'static str {
match self {
@@ -1199,6 +1344,11 @@ pub struct Sam {
pub bursts: u32,
cooldown: u32,
gun_cooldown: u32,
/// Ticks until the system may take another long shot on a big radar's cue (`why::basis`).
far_cooldown: u32,
/// Ticks it has held its present missile target without launching, and a target it gave up (id, until when).
held: u32,
shy: Option<(u32, u64)>,
pub reloading: u32,
/// What its missiles are after, and what its guns track.
pub target: Option<u32>,
@@ -1210,6 +1360,8 @@ pub struct Sam {
pub fired: u32,
/// The classes of target it engages (`Class::bit`s).
pub roles: u8,
/// Its fire doctrine (#47): protected objects, sector, reserve, salvo.
pub doctrine: doctrine::Doctrine,
/// 1 whole, 0 destroyed: a damaged system turns slower, sees less and hits worse.
pub health: f64,
/// Ticks its wreck still burns.
@@ -1270,6 +1422,9 @@ impl Sam {
bursts: spec.gun.as_ref().map_or(0, |g| g.bursts),
cooldown: 40,
gun_cooldown: 0,
far_cooldown: 0,
held: 0,
shy: None,
reloading: 0,
target: None,
gun_target: None,
@@ -1279,6 +1434,7 @@ impl Sam {
kills: 0,
fired: 0,
roles: spec.roles,
doctrine: doctrine::Doctrine::default(),
state: SamState::Search,
launched: 0,
health: 1.0,
@@ -1421,6 +1577,16 @@ pub struct Sim {
pub hits: u32,
/// Drones that struck the land or a building short of their aim.
pub crashes: u32,
/// Cruise flights that ran out of fuel for making no headway (`STALL_TICKS`).
pub stalled: u32,
/// Systems just deployed by hand and not yet checked for standing in the land: (id, until tick).
settling: Vec<(u32, u64)>,
/// What crashed, where, and its aim (balance measurements only).
#[cfg(test)]
pub crashed: Vec<(DroneKind, DVec3, DVec3)>,
/// Interceptors that blew up short of a ridge: kind, age, distance to the target, the target's height over the land under it (balance measurements only).
#[cfg(test)]
pub scrubs: Vec<(MissileKind, u32, f64, f64, DVec3, f64, f64)>,
pub launched: u32,
/// Explosions damage the land (primed TNT through commands).
pub damage: bool,
@@ -1432,6 +1598,10 @@ pub struct Sim {
pub shader: bool,
/// Air defence holds fire.
pub hold: bool,
/// Last chance (`/airdef lastchance`): a system fires at a class outside its roles when it is about to hit.
pub last_chance: bool,
/// The heavy systems take long shots on the big radar's cue (a switch for the balance tests).
pub far_shots: bool,
/// What the land hides round each system's radar, by system id.
horizons: HashMap<u32, sight::Horizon>,
/// Blasts to come: (tick, where, TNT, small) — a magazine cooking off.
@@ -1440,8 +1610,12 @@ pub struct Sim {
pub queue: Vec<(u64, Drone)>,
/// The raid's launch sites and their jobs (#100).
pub sites: sites::Sites,
/// Launch sites found by the radars, and the counter-strikes on them (#101).
pub strikes: strike::Strikes,
/// A raid being planned.
plan: Option<raid::Plan>,
/// The vehicles' ground routes, and the jobs the trucks found out of reach (#94).
routes: route::Routes,
/// Each marked object's health, 0 to 100 (`targets`' order).
pub target_health: Vec<f64>,
/// The raid in progress, for its report.
@@ -1499,17 +1673,27 @@ impl Sim {
kills: 0,
hits: 0,
crashes: 0,
stalled: 0,
settling: Vec::new(),
#[cfg(test)]
crashed: Vec::new(),
#[cfg(test)]
scrubs: Vec::new(),
launched: 0,
damage: true,
seed: seed.wrapping_mul(0x2545_F491_4F6C_DD1D) | 1,
truth: false,
shader: false,
hold: false,
last_chance: true,
far_shots: true,
horizons: HashMap::new(),
later: Vec::new(),
queue: Vec::new(),
sites: sites::Sites::default(),
strikes: strike::Strikes::new(),
plan: None,
routes: Default::default(),
target_health: Vec::new(),
wave: None,
rec: None,
@@ -1624,12 +1808,43 @@ impl Sim {
self.fires.extinguish();
}
/// A system just put down by the player is looked at for a few seconds: one standing inside the land (a player
/// who fell into a chunk that had not loaded) is brought up to the surface. Buried, its interceptors would
/// strike the dirt above it and the raid's aim at it would lie under the land. Only a spot whose block is known
/// solid and whose land height is known is moved, to that height.
pub fn settle(&mut self, id: u32) {
self.settling.push((id, self.tick + 100));
}
fn step_settling(&mut self, ground: &mut Ground) {
let now = self.tick;
let sams = &mut self.sams;
self.settling.retain(|&(id, until)| {
let Some(sam) = sams.iter_mut().find(|s| s.id == id) else { return false };
let p = sam.pos;
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
if ground.world.is_known_open(x, y, z, now) {
return false;
}
if ground.world.is_known_solid(x, y, z, now) {
if let Some(g) = ground.at(p, now) {
if g > p.y + 1.5 && g < p.y + 96.0 {
sam.pos.y = g;
}
return false;
}
}
now < until
});
}
/// One game tick.
pub fn step(&mut self, ground: &mut Ground, player: Option<DVec3>, out: &mut Out) {
self.tick += 1;
self.plan_step(Some(&mut *ground), out);
self.adapt();
self.release_queue(out);
self.step_settling(ground);
self.release_queue(ground, out);
self.step_drones(ground, player, out);
self.step_later(ground, player, out);
self.step_moves(ground, out);
@@ -1639,6 +1854,7 @@ impl Sim {
self.step_lights(ground, out);
self.step_sams(ground, out);
self.step_missiles(ground, out);
self.step_strikes(ground, player, out);
self.step_fx(ground, out);
self.draw_targets(out);
self.draw_sites(out);
@@ -1670,11 +1886,12 @@ impl Sim {
let from = d.pos;
ground.ahead(d.pos, d.vel, (120.0 / d.vel.length().max(0.05)).max(20.0), now);
// Near its aim it looks at the land there again: a platform or hilltop blown away
// meanwhile, it goes for what is left below, not for the air where it stood.
// meanwhile, it goes for what is left below, not for the air where it stood; an aim inside the land (a mark
// set from a spot the player stood in the ground) is the surface over it, else the strike counts as a crash.
if d.flight != Flight::Falling
&& horizontal(d.target - d.pos).length() < 250.0
&& let Some(g) = ground.at(d.target, now)
&& g < d.target.y - 1.5
&& (g < d.target.y - 1.5 || (g > d.target.y + 1.5 && !spec.decoy && spec.ballistic.is_none() && g < d.target.y + 96.0))
{
d.target.y = g;
}
@@ -1736,6 +1953,10 @@ impl Sim {
// Into the land or a building short of its aim: a crash, not a hit.
if landed && d.pos.distance(d.target) >= speed.max(2.0) + 6.0 {
crashes.push((d.id, d.kind, d.pos, spec.warhead));
#[cfg(test)]
{
self.crashed.push((d.kind, d.pos, d.target));
}
removed.push(d.id);
continue;
}
@@ -1798,6 +2019,15 @@ impl Sim {
}
continue;
}
// Out of fuel: no nearer its aim for a long time (it circles, or waits for what never comes): it falls
// where it is, a wreck like any other, and the raid is not held open by it.
if d.pace.stalled(d.age, d.pos.distance(d.target), d.route.len()) {
d.flight = Flight::Falling;
d.age = 0;
d.vel *= 0.35;
self.stalled += 1;
continue;
}
// An interceptor coming: drones drop to the treetops (radars lose them sooner).
if d.engaged > 0 && matches!(spec.class, Class::Drone | Class::Jet) {
d.low = true;
@@ -1837,7 +2067,9 @@ impl Sim {
lines.push(((heading + towards).normalize_or_zero(), reach));
}
}
d.nav.need = terrain::required_height(ground, d.pos, &lines, spec.clearance, &climb).unwrap_or(f64::NEG_INFINITY);
let (need, blind) = terrain::required_height(ground, d.pos, &lines, spec.clearance, &climb);
d.nav.need = need.unwrap_or(f64::NEG_INFINITY);
d.nav.blind = blind;
d.nav.dive = if to_target < dive_at * 3.0 {
terrain::dive_height(ground, d.pos, d.target, terrain::DIVE_MARGIN).unwrap_or(f64::NEG_INFINITY)
} else {
@@ -1870,13 +2102,18 @@ impl Sim {
} else {
let heading = horizontal(way - d.pos).normalize_or(horizontal(old).normalize_or(DVec3::NEG_Z));
// Cruise height over the target; as low as the land lets it when under the radars.
let base = if d.low { d.target.y + 4.0 } else { d.target.y + spec.cruise };
let base = if d.low && !d.nav.blind { d.target.y + 4.0 } else { d.target.y + spec.cruise };
let own = below.map_or(f64::NEG_INFINITY, |g| g + spec.clearance);
let need = d.nav.need.max(own);
let safe = if need.is_finite() { need + 2.0 } else { d.target.y + spec.clearance };
let height = base.max(safe).max(d.nav.dive + 1.0);
let mut height = base.max(safe).max(d.nav.dive + 1.0);
// Blind: it does not come down over land nobody has told of; it holds the height it has.
if d.nav.blind && d.low && to_target > 300.0 {
height = height.max(d.pos.y);
}
let full = climb.pitch.tan();
let urgent = need.is_finite() && d.pos.y < need;
// Blind (land ahead unknown): to the transit height at once, not at the gentle slope of a descent plan.
let urgent = (need.is_finite() && d.pos.y < need) || (d.nav.blind && d.pos.y < base - 2.0);
let slope = if urgent {
full
} else {
@@ -1907,12 +2144,16 @@ impl Sim {
}
let at_target = d.pos.distance(d.target) < speed.max(1.5);
let in_ground = sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.2, &sweep, now);
if at_target || in_ground || d.age > 8000 {
if at_target || in_ground || d.age > MAX_FLIGHT {
boom.push((d.pos, spec.warhead, spec.decoy));
// Into the land or a building short of its aim: a crash, not a hit.
if in_ground && d.pos.distance(d.target) >= speed.max(1.5) + 3.0 {
if !spec.decoy {
crashes.push((d.id, d.kind, d.pos, spec.warhead));
#[cfg(test)]
{
self.crashed.push((d.kind, d.pos, d.target));
}
}
removed.push(d.id);
continue;
@@ -1929,7 +2170,7 @@ impl Sim {
let back = d.pos - d.vel.normalize_or_zero() * 1.5 * spec.scale;
match spec.engine {
Engine::Prop(..) => {
if d.age % 2 == 0 {
if d.age % 10 == 0 {
out.particles("minecraft:smoke", back, 0.05, DVec3::ZERO, 0.01, 1);
}
}
@@ -2000,6 +2241,7 @@ impl Sim {
nav: Nav::default(),
err: DVec3::ZERO,
hold: 0,
pace: Pace::default(),
});
}
for d in &self.drones {
@@ -2093,6 +2335,7 @@ impl Sim {
));
}
ground.forget(pos, 8);
ground.world.expect_blast(pos, 8.0, self.tick);
}
}
@@ -2241,6 +2484,7 @@ impl Sim {
let sam = &mut self.sams[i];
sam.cooldown = sam.cooldown.saturating_sub(1);
sam.gun_cooldown = sam.gun_cooldown.saturating_sub(1);
sam.far_cooldown = sam.far_cooldown.saturating_sub(1);
sam.flash = sam.flash.saturating_sub(1);
sam.recoil *= 0.6;
if sam.radar_up() {
@@ -2287,18 +2531,32 @@ impl Sim {
// the forecast of the last measurement, never the true place.
let drone = |tr: &Track| drone_at.get(&tr.id).map(|&k| &self.drones[k]);
let claimed = |d: &Drone| why::claimed(d, &missile_on, had_target);
let needed = why::needed;
let doc = &me.doctrine;
let needed = |d: &Drone| doc.needed(d);
let opts = why::Opts { last_chance: self.last_chance, far_shots: self.far_shots };
let target = spec.missile.as_ref().map(|load| {
let ok = |tr: &Track, k: f64| {
drone(tr).is_some_and(|d| why::allowed(roles, tr, now, d) && open(tr.id) && why::in_reach(load.min, load.max, k, pos, tr.est(now)))
// A launcher with nothing to fire (reloading, empty, failed) claims no target: others take it.
if me.missiles == 0 || me.reloading > 0 || me.part(Part::Launcher) <= 0.0 {
return None;
}
let radars: Vec<&Sam> = if me.kind.far_shooter() && opts.far_shots { self.sams.iter().filter(|s| s.kind == SamKind::Radar).collect() } else { Vec::new() };
let cued = |id: u32| !radars.is_empty() && why::radar_cue(me, &radars, id, now);
let basis = |tr: &Track, k: f64, role_in_reach: bool| {
drone(tr).filter(|_| open(tr.id)).and_then(|d| why::basis(me, load, tr, d, now, claimed(d), k, opts, cued(tr.id), role_in_reach))
};
let keep = had_target.filter(|t| tracks.iter().any(|tr| tr.id == *t && ok(tr, 1.1)));
// A target held too long without a launch (the mount can't lay on it, the land) is let go,
// and not taken again for a while: another system gets it.
let shy = |id: u32| me.shy.is_some_and(|(s, until)| s == id && until > now);
let stuck = me.held > why::HOLD_MAX;
let keep = had_target.filter(|t| !stuck && tracks.iter().any(|tr| tr.id == *t && basis(tr, 1.1, false).is_some()));
keep.or_else(|| {
let free = |tr: &Track| drone(tr).is_some_and(|d| claimed(d) < needed(d));
// Long shots only while nothing of the system's roles is in its normal reach.
let role_in_reach = !radars.is_empty() && tracks.iter().any(|tr| free(tr) && basis(tr, 1.0, true) == Some(why::Basis::Role));
tracks
.iter()
.filter(|tr| ok(tr, 1.0))
.filter(|tr| free(tr) && !shy(tr.id) && basis(tr, 1.0, role_in_reach).is_some())
.filter_map(|tr| drone(tr).map(|d| (tr, d)))
.filter(|(_, d)| claimed(d) < needed(d))
.max_by(|a, b| a.1.threat_at(pos, a.0.est(now)).total_cmp(&b.1.threat_at(pos, b.0.est(now))))
.map(|(_, d)| d.id)
})
@@ -2311,7 +2569,7 @@ impl Sim {
tracks
.iter()
.filter_map(|tr| drone(tr).map(|d| (tr, d)))
.filter(|(tr, d)| why::gun_candidate(roles, tr, now, gun.track, pos, d))
.filter(|(tr, d)| why::gun_candidate(roles, doc, tr, now, gun.track, pos, d))
.map(|(tr, d)| {
let busy = 1.0 + 2.0 * on_it(d) + if d.engaged > 0 { 1.5 } else { 0.0 };
(tr.est(now).distance(pos) * busy, d.id)
@@ -2330,7 +2588,16 @@ impl Sim {
tracks_lost += 1;
}
retarget(&mut missile_on, had_target, target);
self.sams[i].target = target;
let sam = &mut self.sams[i];
if target.is_some() && target == had_target {
sam.held += 1;
} else {
if let (Some(h), true) = (had_target, sam.held > why::HOLD_MAX) {
sam.shy = Some((h, now + why::SHY_TICKS));
}
sam.held = 0;
}
sam.target = target;
}
if let Some(gun_target) = gun_target {
retarget(&mut guns_on, had_gun, gun_target);
@@ -2364,8 +2631,24 @@ impl Sim {
} else {
SamState::Search
};
// Back to the rest position, under the drive's limits like any other turn.
sam.aim_towards(sam.heading, Some(spec.rest_pitch));
// Nothing to fire at: the mount watches the nearest track of its classes (a reaction a turn
// shorter), else it goes back to the rest position, under the drive's limits like any other turn.
let (mount, _) = sam.mount();
let watch = sam
.tracks
.iter()
.filter(|tr| tr.age(now) <= sight::MISSILE_AGE && drone_at.get(&tr.id).is_some_and(|&k| roles & self.drones[k].kind.spec().class.bit() != 0 && self.drones[k].alive()))
.min_by(|a, b| a.est(now).distance(mount).total_cmp(&b.est(now).distance(mount)))
.map(|tr| why::aim_wants(mount, tr.est(now), tr.vel, spec.gun.as_ref().map_or(5.0, |g| g.shell)));
match (watch, spec.family) {
(Some((_, yaw, pitch)), Family::Trainable) => {
let _ = pitch;
sam.aim_towards(yaw, None)
}
(Some((_, yaw, _)), Family::Vertical) if matches!(spec.muzzle, Muzzle::Cells) => sam.aim_towards(yaw, None),
(Some((_, yaw, pitch)), Family::Gun | Family::Turret) => sam.aim_towards(yaw, Some(pitch)),
_ => sam.aim_towards(sam.heading, Some(spec.rest_pitch)),
}
continue;
};
let (mount, _) = sam.mount();
@@ -2496,6 +2779,10 @@ impl Sim {
retarget(&mut missile_on, Some(mt), None);
sam.target = None;
} else if ready && aligned {
// Past its normal reach: a long shot, and the next waits.
if sam.kind.far_shooter() && t.is_some_and(|t| t.distance(sam.pos) >= load.max) {
sam.far_cooldown = why::FAR_COOLDOWN;
}
launches.push((load.kind, muzzle, dir, mt, sam.id));
// Its missile is on it from now, so the systems after this one this tick see
// the target taken; this one's next launch looks for another.
@@ -2546,8 +2833,10 @@ impl Sim {
out.sound(event, muzzle, 1.0, 1.0);
} else {
out.particles("minecraft:flash", muzzle, 0.0, DVec3::ZERO, 0.0, 1);
out.particles("minecraft:cloud", muzzle - dir * 2.0, 1.4, DVec3::new(0.0, 0.02, 0.0), 0.06, 70);
out.particles("minecraft:campfire_cosy_smoke", muzzle - dir * 3.0, 1.2, DVec3::ZERO, 0.02, 20);
if spec.flame > 0.0 {
out.particles("minecraft:cloud", muzzle - dir * 2.0, 1.4, DVec3::new(0.0, 0.02, 0.0), 0.06, 70);
out.particles("minecraft:campfire_cosy_smoke", muzzle - dir * 3.0, 1.2, DVec3::ZERO, 0.02, 20);
}
let event = if kind == MissileKind::M57e6 { "airdef:launch.pantsir" } else { "airdef:launch.patriot" };
out.sound(event, muzzle, 1.0, 0.95 + self.vis.f() as f32 * 0.1);
}
@@ -2572,7 +2861,11 @@ impl Sim {
let mut done: Vec<(u32, Option<u32>, bool, u32)> = Vec::new(); // (missile, target, kill, sam)
let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32);
let (mut blocked, mut scrubbed) = (0u32, 0u32);
#[cfg(test)]
let mut scrub_log = Vec::new();
let sams_fit: HashMap<u32, f64> = self.sams.iter().map(|s| (s.id, 1.0 - 0.3 * (1.0 - s.health.clamp(0.0, 1.0)))).collect();
// Where each launcher stands and how far its missiles reach: a hit past that is less likely (`why::reach_fit`).
let launchers: HashMap<u32, (DVec3, f64)> = self.sams.iter().filter_map(|s| Some((s.id, (s.pos, s.kind.spec().missile.as_ref()?.max)))).collect();
// The data link (#66): the track the firing system holds of each missile's target, if it is up.
let links: HashMap<(u32, u32), Track> = self
.missiles
@@ -2630,9 +2923,19 @@ impl Sim {
let closing = (speed - vel.dot(los)).max(1.0);
let tgo = at.distance(m.pos) / closing;
// Over the land in the way: lofted as far as the missile can, else it blows up before the ridge.
let (want, fail) = terrain::steer(ground, m.kind, m.pos, speed, at + vel * tgo);
let (want, fail) = terrain::steer(ground, m.kind, m.pos, speed, at + vel * tgo, now);
if fail {
scrubbed += 1;
#[cfg(test)]
{
let over = target.and_then(|t| ground.at(t.pos, now).map(|g| t.pos.y - g)).unwrap_or(f64::NAN);
let dist = target.map_or(f64::NAN, |t| t.pos.distance(m.pos));
let mover = ground.at(m.pos, now).map_or(f64::NAN, |g| m.pos.y - g);
let from = launchers.get(&m.sam).map_or(0.0, |l| m.pos.distance(l.0));
if scrub_log.len() < 30 {
scrub_log.push((m.kind, m.age, dist, over, m.pos, mover, from));
}
}
done.push((m.id, target.map(|t| t.id), false, m.sam));
continue;
}
@@ -2680,7 +2983,7 @@ impl Sim {
if (rel0 + delta * k).length() < spec.fuse && world.map(|w| w.0).or(waiting).is_none_or(|w| k <= w) {
// A target faster than the interceptor is hard to hit.
let ratio = (spec.speed / t.vel.length().max(0.1)).min(1.0).powf(1.5);
let fit = sams_fit.get(&m.sam).copied().unwrap_or(1.0);
let fit = sams_fit.get(&m.sam).copied().unwrap_or(1.0) * launchers.get(&m.sam).map_or(1.0, |(at, max)| why::reach_fit(*max, at.distance(m.pos)));
let kill = self.rng.chance(spec.pk * fit * ratio / t.kind.spec().stealth.sqrt());
missed += u32::from(!kill);
done.push((m.id, Some(t.id), kill, m.sam));
@@ -2722,6 +3025,8 @@ impl Sim {
}
}
}
#[cfg(test)]
self.scrubs.extend(scrub_log);
self.note(|t| {
t.missed += missed;
t.expired += expired;
+70 -8
View File
@@ -38,11 +38,18 @@ impl Sam {
impl Sim {
/// Orders unit `i` to drive to `to`. False for one that can't (a depot, a wreck, failed running gear).
pub fn order_move(&mut self, i: usize, to: DVec3) -> bool {
self.order_move_near(i, to, 0.0)
}
/// The same, where arriving within `slack` blocks of `to` will do (a truck stops beside its job).
pub fn order_move_near(&mut self, i: usize, to: DVec3, slack: f64) -> bool {
let Some(sam) = self.sams.get_mut(i) else { return false };
if !sam.alive() || !sam.kind.mobile() || sam.drive_speed() <= 0.0 {
return false;
}
sam.dest = Some(to);
let route = route::Route::new(sam.pos, to, slack, sam.kind.climbs(), self.tick);
self.routes.map.insert(sam.id, route);
sam.target = None;
sam.gun_target = None;
if sam.state != SamState::Moving {
@@ -52,11 +59,17 @@ impl Sim {
true
}
/// Packing, driving and deploying, once a tick.
/// Packing, driving and deploying, once a tick. The way is searched on the ground the game has told of (a
/// few hundred cells a tick for all the vehicles); a vehicle stands while it waits, and stops with a word
/// when there is no way.
pub(super) fn step_moves(&mut self, ground: &mut Ground, out: &mut Out) {
let now = self.tick;
for sam in &mut self.sams {
let planners = self.routes.map.values().filter(|r| r.searching()).count().max(1);
let share = route::WORK_PER_TICK / planners;
let Sim { sams, routes, .. } = self;
for (n, sam) in sams.iter_mut().enumerate() {
if !sam.alive() {
routes.map.remove(&sam.id);
continue;
}
let spec = sam.kind.spec();
@@ -66,12 +79,16 @@ impl Sim {
}
match sam.state {
SamState::Packing => {
// The launcher comes down for the march.
// The launcher comes down for the march; the way is searched meanwhile.
sam.pitch += (0.0 - sam.pitch) * 0.06;
sam.yaw = slew(sam.yaw, sam.heading, 0.04);
sam.march = sam.march.saturating_sub(1);
if sam.march == 0 {
sam.state = SamState::Moving;
} else if let Some(r) = routes.map.get_mut(&sam.id)
&& r.searching()
{
let _ = r.drive(ground, now, sam.pos, share);
}
}
SamState::Moving => {
@@ -80,14 +97,43 @@ impl Sim {
sam.dest = None;
}
let Some(to) = sam.dest else {
routes.map.remove(&sam.id);
sam.state = SamState::Deploying;
sam.march = sam.kind.march_ticks();
continue;
};
let flat = horizontal(to - sam.pos);
if flat.length() < SPEED * 2.0 {
sam.pos = DVec3::new(to.x, sam.pos.y, to.z);
let route = routes.map.entry(sam.id).or_insert_with(|| route::Route::new(sam.pos, to, 0.0, sam.kind.climbs(), now));
let (go, last) = match route.drive(ground, now, sam.pos, share) {
route::Drive::Wait(why) => {
if route.waited(now) > 40 && !route.told {
route.told = true;
out.messages.push(format!("{} №{}: {}", sam.kind.name(), n + 1, why.text()));
}
continue;
}
route::Drive::Fail(why) => {
out.messages.push(format!("{} №{}: {}", sam.kind.name(), n + 1, why.text()));
// A truck leaves that job alone for a while, and takes another.
if let Some(job) = sam.job.take() {
routes.unreachable.insert((sam.id, job), now + route::UNREACHABLE_FOR);
}
routes.map.remove(&sam.id);
sam.dest = None;
sam.state = SamState::Deploying;
sam.march = sam.kind.march_ticks();
continue;
}
route::Drive::Go(p, last) => (p, last),
};
let flat = horizontal(go - sam.pos);
if last && flat.length() < SPEED * 2.0 {
let partial = route.partial;
sam.pos = DVec3::new(go.x, sam.pos.y, go.z);
sam.dest = None;
routes.map.remove(&sam.id);
if partial {
out.messages.push(format!("{} №{}: {}", sam.kind.name(), n + 1, route::Why::Partial.text()));
}
sam.state = SamState::Deploying;
sam.march = sam.kind.march_ticks();
out.sound("minecraft:block.piston.contract", sam.pos, 2.0, 0.6);
@@ -99,8 +145,24 @@ impl Sim {
let turning = angle_diff(sam.heading, heading_of(flat)).abs();
let step = SPEED * sam.drive_speed() * (1.0 - turning / std::f64::consts::PI).max(0.2);
let next = sam.pos + ahead * step;
let y = ground.at(next, now).unwrap_or(sam.pos.y);
sam.pos = DVec3::new(next.x, sam.pos.y + (y - sam.pos.y).clamp(-0.5, 0.5), next.z);
// The column ahead: a wall or a drop the grid missed turns the vehicle back to the planner.
let Some(y) = ground.at(next, now) else {
if route.wait_ahead() {
out.messages.push(format!("{} №{}: {}", sam.kind.name(), n + 1, route::Why::NoMap.text()));
routes.map.remove(&sam.id);
sam.dest = None;
sam.state = SamState::Deploying;
sam.march = sam.kind.march_ticks();
}
continue;
};
route.ahead_ok();
let rise = y - sam.pos.y;
if rise > route::STEP_UP || rise < -route::STEP_DOWN {
route.blocked_ahead(next, sam.pos);
continue;
}
sam.pos = DVec3::new(next.x, sam.pos.y + rise.clamp(-0.5, 0.5), next.z);
sam.yaw = sam.heading;
if now % 4 == 0 {
out.particles("minecraft:campfire_cosy_smoke", sam.pos - ahead * 2.0 * spec.scale, 0.3, DVec3::new(0.0, 0.03, 0.0), 0.01, 1);
+200 -55
View File
@@ -87,6 +87,8 @@ pub(super) struct Plan {
wants: Vec<sites::Want>,
sited: bool,
site_wait: u32,
/// The player has been told the enemy is still preparing.
said: bool,
smart: bool,
approach: Approach,
total: u32,
@@ -115,6 +117,9 @@ pub(super) struct WaveStats {
pub(super) const CELL: f64 = 16.0;
pub(super) const CELLS: i32 = 140;
/// The planner's work a tick (cells priced and opened): a raid takes a few ticks to plan, never a long one.
/// The longest a drone of a raid that arrives together waits to take off (ticks): a Shahed at 0.6 blocks a tick
/// takes 6700 of them to fly 4000 blocks, and the fastest missile must wait for it.
const LONGEST_WAIT: f64 = 12_000.0;
pub(super) const WORK_PER_TICK: usize = 40_000;
/// The planner's grid over a raid: its corner, its cells' size and how many a side.
@@ -232,6 +237,9 @@ impl PartialOrd for Open {
}
}
/// Ticks an air start waits for the land under it to be told of.
const AIR_START_WAIT: u32 = 120;
impl Sim {
/// Orders a raid on `objects` (at least one point). A smart raid is planned over the next ticks
/// (each route found in full, a few a tick) and launched when its plan is ready.
@@ -258,6 +266,9 @@ impl Sim {
let cell = CELL.max(2.0 * span / CELLS as f64);
let grid = Grid { min: centre - DVec3::new(span, 0.0, span), cell, size: (2.0 * span / cell) as i32 + 1 };
let mut items = Vec::new();
// A smart raid goes for the radars too (a few strikers each); the others pick as they always did.
let radar_jobs = if smart { self.radar_strikes(&order.kinds) } else { Vec::new() };
let mut index = 0usize;
for (group, &(kind, count)) in order.kinds.iter().enumerate() {
let spec = kind.spec();
for _ in 0..count {
@@ -268,7 +279,12 @@ impl Sim {
};
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 job = radar_jobs.get(index).copied().flatten();
index += 1;
let (target, at_sam) = match job {
Some((at, id)) => (at, Some(id)),
None => 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 };
@@ -298,6 +314,7 @@ impl Sim {
wants,
sited: false,
site_wait: 0,
said: false,
smart,
approach: order.approach,
total,
@@ -356,7 +373,8 @@ impl Sim {
}
});
plan.waited += 1;
if plan.unanswered.is_empty() || plan.waited >= 40 {
// The game answers 512 columns a tick: a big grid (a long range) gets the time its cells take.
if plan.unanswered.is_empty() || plan.waited >= 40 + (n * n) as u32 / 300 {
plan.mapped = true;
} else {
self.plan = Some(plan);
@@ -437,7 +455,7 @@ impl Sim {
if site != 0 {
self.sites.job(d.id, site);
}
self.queue.push((now + wait.clamp(0.0, 2400.0) as u64, d));
self.queue.push((now + wait.clamp(0.0, LONGEST_WAIT) as u64, d));
}
self.wave = Some(WaveStats {
kills: self.kills,
@@ -494,9 +512,35 @@ impl Sim {
nav: Nav::default(),
err,
hold: 0,
pace: Pace::default(),
}
}
/// The strikes a smart raid sends at the radars (they emit, so the enemy knows them whether or not they
/// fired): for the drones of `kinds` in order, the radar's place and id for those that go for one. Each radar
/// gets two or three, the fast strikers first, and all together no more than about a quarter of the raid.
pub(super) fn radar_strikes(&self, kinds: &[(DroneKind, u32)]) -> Vec<Option<(DVec3, u32)>> {
let mut radars: Vec<&Sam> = self.sams.iter().filter(|s| s.alive() && s.kind == SamKind::Radar).collect();
radars.sort_by_key(|s| s.id);
let flat: Vec<DroneKind> = kinds.iter().flat_map(|&(k, n)| std::iter::repeat_n(k, n as usize)).collect();
let mut jobs = vec![None; flat.len()];
if radars.is_empty() {
return jobs;
}
// Fast ones first (a stable sort keeps the raid's order among equals).
const RANK: [DroneKind; 8] =
[DroneKind::Kh22, DroneKind::Zircon, DroneKind::Iskander, DroneKind::Kinzhal, DroneKind::Kh101, DroneKind::Geran3, DroneKind::Lancet, DroneKind::Shahed];
let mut ranked: Vec<(usize, usize)> = flat.iter().enumerate().filter_map(|(i, k)| Some((RANK.iter().position(|r| r == k)?, i))).collect();
ranked.sort_by_key(|r| r.0);
let want = if flat.len() >= 24 { 3 } else { 2 };
let cap = (flat.len() / 4).max(1);
for (n, &(_, i)) in ranked.iter().take((radars.len() * want).min(cap)).enumerate() {
let radar = radars[n % radars.len()];
jobs[i] = Some((radar.pos, radar.id));
}
jobs
}
/// What a drone goes for: an object, or (by its kind and the raid's aim) a system. Returns the
/// point and the system's id.
fn choose_target(&mut self, kind: DroneKind, object: DVec3, aim: Aim) -> (DVec3, Option<u32>) {
@@ -517,17 +561,20 @@ impl Sim {
}
// Smart: each kind at what it is good against; what gave itself away first (a jamming
// station, a radar, a system that fired).
// The planner has only what the enemy knows (#93): the radars (they emit) and the units that gave
// themselves away; the rest of the player's layout is not in its hands.
let known = self.known_units();
let known_ew: Vec<&Sam> = alive.iter().copied().filter(|s| s.kind == SamKind::Ew && known.iter().any(|k| k.0 == s.id)).collect();
let alive: Vec<&Sam> = alive.iter().copied().filter(|s| Self::enemy_knows(s, &known)).collect();
let known_ew: Vec<&Sam> = alive.iter().copied().filter(|s| s.kind == SamKind::Ew).collect();
if matches!(kind, DroneKind::Lancet | DroneKind::Kh22) && !known_ew.is_empty() && roll < 0.6 {
let s = known_ew[(r * known_ew.len() as f64) as usize % known_ew.len()];
return (s.pos, Some(s.id));
}
// Short-range systems (guns, Tor, P1-SUN) for the Lancets; long-range ones and the radars
// that feed them for the fast missiles.
let guns: Vec<&Sam> = alive.iter().copied().filter(|s| s.kind.armed() && s.kind.range() <= 200.0).collect();
let guns: Vec<&Sam> = alive.iter().copied().filter(|s| s.kind.armed() && s.kind.range() <= 300.0).collect();
let long: Vec<&Sam> =
alive.iter().copied().filter(|s| s.kind.range() >= 300.0 || s.kind == SamKind::Radar).collect();
alive.iter().copied().filter(|s| s.kind.range() >= 450.0 || s.kind == SamKind::Radar).collect();
let choice = match kind {
DroneKind::Lancet if roll < 0.75 => pick(&guns, r).or_else(|| pick(&alive, r)),
DroneKind::Kh22 | DroneKind::Zircon if roll < 0.45 => pick(&long, r),
@@ -550,6 +597,7 @@ impl Sim {
let mut cost = 0;
let spec = kind.spec();
let height = spec.clearance.max(6.0);
let known_now = self.known_units();
let n = grid.size;
let mut prices = vec![1.0f32; (n * n) as usize];
// Known jamming stations: drones that navigate by satellite keep away.
@@ -567,7 +615,8 @@ impl Sim {
}
}
}
for s in self.sams.iter().filter(|s| s.alive() && s.roles & spec.class.bit() != 0) {
// Only the systems the enemy knows of are priced in (#93): a hidden one is flown into.
for s in self.sams.iter().filter(|s| s.alive() && s.roles & spec.class.bit() != 0 && Self::enemy_knows(s, &known_now)) {
let sp = s.kind.spec();
let (lo, hi) = sp.missile.as_ref().map_or((0.0, 0.0), |m| (m.min, m.max));
let gun = sp.gun.as_ref().map_or(0.0, |g| g.range);
@@ -609,8 +658,11 @@ impl Sim {
(prices, cost)
}
/// A planned raid's drones take off when their time comes.
pub(super) fn release_queue(&mut self, out: &mut Out) {
/// Ticks an air start waits for the land under it to be told of.
const AIR_START_WAIT: u32 = 120;
/// A planned raid's drones take off when their time comes.
pub(super) fn release_queue(&mut self, ground: &mut Ground, out: &mut Out) {
let now = self.tick;
if self.queue.iter().all(|q| q.0 > now) {
return;
@@ -618,10 +670,41 @@ impl Sim {
let (go, wait): (Vec<_>, Vec<_>) = std::mem::take(&mut self.queue).into_iter().partition(|q| q.0 <= now);
self.queue = wait;
let mut cancelled = 0;
for (t, d) in go {
for (t, mut d) in go {
// A site's job leaves its site one at a time, from its stock; a destroyed or empty site cancels it.
match self.site_slot(d.id) {
let from_site = self.sites.site_of(d.id);
// An air start (an off-map site's drone) was planned over land nobody had read. It waits (a second or
// two) for the land under it to be known, and begins above it if that is higher than the start: else it
// is born inside a hill and crashes at once.
let air = from_site.is_some_and(|id| self.sites.list.iter().any(|s| s.id == id && s.offmap));
if air {
let clear = if d.kind.spec().ballistic.is_some() { 10.0 } else { d.kind.spec().clearance };
match ground.at(d.pos, now) {
None if d.age < AIR_START_WAIT => {
// The game has no chunk there: it is asked to load the ones round the start (and kept a while).
let c = ((d.pos.x.floor() as i32) >> 4, (d.pos.z.floor() as i32) >> 4);
for (dx, dz) in [(0, 0), (1, 0), (-1, 0), (0, 1), (0, -1)] {
ground.want_chunk((c.0 + dx, c.1 + dz));
}
d.age += 1;
self.queue.push((t, d));
continue;
}
Some(g) if d.pos.y < g + clear => d.pos.y = g + clear,
_ => {}
}
d.age = 0;
}
let slot = self.site_slot(d.id);
if let (Some(site), sites::Slot::Go | sites::Slot::Air) = (from_site, &slot) {
self.scout_launch(d.id, site, d.kind, d.pos);
}
match slot {
// Free: not a site's job. Air: an off-map site's drone, already in flight position.
sites::Slot::Free => {}
// The air start was planned over land nobody had read; if it is read by now and the land there is
// higher than the start, it begins above it (else it is born inside a hill and crashes at once).
sites::Slot::Air => {}
sites::Slot::Busy => {
self.queue.push((t, d));
continue;
@@ -631,6 +714,14 @@ impl Sim {
continue;
}
sites::Slot::Go => {
// On a slope the pad's side or the muzzle's far end can stand in the land that rises there: the
// drone leaves from above the land at its own column, not from inside the hill.
if let Some(g) = ground.at(d.pos, now)
&& d.pos.y < g + 1.5
&& g < d.pos.y + 40.0
{
d.pos.y = g + 1.5;
}
let dir = d.vel.normalize_or_zero();
out.sound(d.kind.launch_sound(), d.pos, 1.0, 1.0);
out.particles("minecraft:flame", d.pos - dir * 1.5, 0.15, -dir * 0.1, 0.02, 4);
@@ -645,9 +736,8 @@ impl Sim {
}
}
/// 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).
/// Looks for the raid's launch sites. Returns whether they are settled (placed, or off the map: a side with
/// no suitable ground still launches, from the air at the ordered range; nothing is cancelled).
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();
@@ -655,43 +745,15 @@ impl Sim {
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) {
// The game loads the land of the places looked at; the player hears the enemy is preparing.
if !plan.said && plan.site_wait >= 40 {
plan.said = true;
out.messages.push("Противник готовит налёт: ищет площадки, местность подгружается".into());
}
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;
}
// A side that found nothing is not given up (no raid is cancelled for it): its site is off the map.
// 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);
@@ -703,12 +765,18 @@ impl Sim {
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();
for w in plan.wants.iter_mut().filter(|w| w.jobs > 0) {
let offmap = w.state != Search::Found;
let mut pos = if offmap {
// At the ordered range over the raid's centre height: nothing stands there.
plan.centre + DVec3::new(w.bearing.sin(), 0.0, w.bearing.cos()) * w.ordered
} else {
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 };
shortened |= !offmap && 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,
@@ -716,12 +784,13 @@ impl Sim {
bearing: w.bearing,
class: w.class,
pos,
range: w.range,
range: if offmap { w.ordered } else { w.range },
water: w.water,
stock: w.jobs,
planned: w.jobs,
alive: true,
next_free: 0,
offmap,
});
}
if shortened {
@@ -731,6 +800,11 @@ impl Sim {
for it in plan.items.iter_mut() {
let w = &plan.wants[it.want];
let Some(site) = self.sites.get(w.site) else { continue };
// Off the map: it starts where the plan put it, cruising.
if site.offmap {
it.d.sector = site.sector;
continue;
}
let from = site.muzzle(it.pad);
let spec = it.d.kind.spec();
it.from = from;
@@ -746,9 +820,16 @@ impl Sim {
}
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() }));
let mine = |s: &&sites::LaunchSite| plan.wants.iter().any(|w| w.site == s.id);
let n = self.sites.list.iter().filter(mine).filter(|s| !s.offmap).count();
let off = self.sites.list.iter().filter(mine).filter(|s| s.offmap).count();
let barges = self.sites.list.iter().filter(mine).filter(|s| s.water).count();
if n > 0 {
out.messages.push(format!("Пусковые площадки противника: {n}{}", if barges > 0 { format!(" (на баржах: {barges})") } else { String::new() }));
}
if off > 0 {
out.messages.push(format!("Площадок за пределами загруженной карты: {off}: оттуда пуск без площадки, с высоты полёта"));
}
// 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)) {
@@ -789,6 +870,13 @@ impl Sim {
if !why.is_empty() {
out.messages.push(format!("Неудачи ПВО: {why}"));
}
// What the counter-strikes did to the raid (#101): the launches that never happened.
let cut: u32 = self.strikes.cancelled.iter().map(|c| c.1).sum();
if cut > 0 {
let sites: Vec<String> = self.strikes.cancelled.iter().filter(|c| c.1 > 0).map(|c| format!("№{}", c.0)).collect();
out.messages.push(format!("Налёт сорван ударами по ПУ ({}): не взлетело {cut} из заявленных {}", sites.join(", "), w.size));
}
self.strikes.cancelled.clear();
self.wave = None;
}
}
@@ -848,6 +936,63 @@ mod tests {
eprintln!("4000 ticks in {:?}, kills {} hits {}", t.elapsed(), sim.kills, sim.hits);
assert!(sim.queue.is_empty() && !sim.drones.iter().any(|d| d.alive()), "the raid never ended");
}
/// How many of the live and queued drones are aimed at each point (the drone's own aim: its target less its navigation error).
fn aimed_at(sim: &Sim, p: DVec3) -> Vec<DroneKind> {
sim.drones.iter().chain(sim.queue.iter().map(|q| &q.1)).filter(|d| d.alive() && (d.target - d.err).distance(p) < 6.0).map(|d| d.kind).collect()
}
#[test]
fn a_smart_raid_goes_for_every_radar_and_the_enemys_adapting_does_not_turn_it_away() {
let mut sim = Sim::new(21);
let mut radars = Vec::new();
for (i, at) in [(-300.0, -200.0), (250.0, -250.0), (50.0, 300.0)].into_iter().enumerate() {
let id = sim.id();
let p = DVec3::new(at.0, 70.0, at.1);
sim.sams.push(Sam::new(id, SamKind::Radar, p, i as f64));
radars.push((id, p));
}
let ew = sim.id();
sim.sams.push(Sam::new(ew, SamKind::Ew, DVec3::new(0.0, 70.0, 0.0), 0.0));
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..20 {
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
}
let mut order = crate::wave::WaveConfig::mix(36, 500.0);
order.aim = Aim::Smart;
order.approach = Approach::AllSides;
let total: u32 = order.kinds.iter().map(|k| k.1).sum();
sim.order_raid(&order, &sim.targets.clone(), &mut out);
while sim.planning() {
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
}
let check = |sim: &Sim, when: &str| {
let mut all = 0;
for (i, (_, p)) in radars.iter().enumerate() {
let kinds = aimed_at(sim, *p);
all += kinds.len();
assert!(kinds.len() >= 2, "{when}: radar {i} has only {kinds:?} aimed at it");
}
all
};
let planned = check(&sim, "planned");
assert!(planned as u32 <= total / 4 + 6, "{planned} of {total} at the radars (the EW station and the rest may add a few)");
// The first strikers are the fast ones.
assert!(radars.iter().any(|(_, p)| aimed_at(&sim, *p).iter().any(|k| matches!(k, DroneKind::Kh22 | DroneKind::Zircon | DroneKind::Iskander | DroneKind::Kinzhal))));
// The enemy has learnt of the jamming station and re-aims what it can: not these.
sim.intel.revealed.push((ew, DVec3::new(0.0, 70.0, 0.0), sim.tick));
for _ in 0..160 {
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
}
check(&sim, "after adapting");
}
#[test]
fn systems_ready_together_send_one_missile_at_a_drone() {
let mut sim = Sim::new(3);
+34
View File
@@ -179,6 +179,22 @@ pub enum EventKind {
Split { drone: u32 },
/// A drone's warhead went off against the land or a building short of its aim.
Crash { drone: u32, kind: DroneKind, power: u32 },
/// A counter-strike on a launch site landed (#101): where it landed and how it ended. Launch sites themselves are
/// not drawn in a replay.
Strike { site: u32, result: StrikeResult },
}
/// How a strike on a launch site ended.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum StrikeResult {
Destroyed,
/// The site was stunned for a while.
Stunned,
Missed,
/// Shot down by the enemy's cover.
Intercepted,
/// Off the map: its launches ended, nothing to see.
Halted,
}
#[derive(Clone, Copy, Debug)]
@@ -336,6 +352,16 @@ pub fn kill_look(kind: DroneKind) -> (f32, u32, f32) {
(size, life, gain)
}
/// The fire a strike's end leaves (0: none): the wreck of a site burns long, a crater a while.
pub fn strike_power(result: StrikeResult) -> u32 {
match result {
StrikeResult::Destroyed => 3,
StrikeResult::Stunned => 2,
StrikeResult::Missed => 1,
StrikeResult::Intercepted | StrikeResult::Halted => 0,
}
}
/// A blast's look for an event: (fireball radius, ticks it burns). The live blasts use the same.
pub fn blast_spec(kind: EventKind) -> (f32, u32) {
const SIZE: [f32; 5] = [2.5, 4.5, 7.0, 10.0, 13.0];
@@ -351,6 +377,12 @@ pub fn blast_spec(kind: EventKind) -> (f32, u32) {
}
EventKind::SamLost { .. } => (8.0, 18 * 9),
EventKind::Split { .. } => (2.0, 7 * 9),
EventKind::Strike { result, .. } => match result {
StrikeResult::Destroyed => (SIZE[4], LIFE[4] * 9),
StrikeResult::Stunned | StrikeResult::Missed => (SIZE[3], LIFE[3] * 9),
StrikeResult::Intercepted => (SIZE[0], LIFE[0] * 9),
StrikeResult::Halted => (0.0, 0),
},
}
}
@@ -382,6 +414,7 @@ pub fn replay_fires(rec: &Recording, t: f64) -> Vec<ReplayFire> {
EventKind::Crash { power, .. } => power,
EventKind::Hit { power, .. } => power.max(1),
EventKind::SamLost { .. } => 2,
EventKind::Strike { result, .. } => strike_power(result),
_ => continue,
};
let (size, life) = (fires::size_for(power), fires::life_for(power));
@@ -518,6 +551,7 @@ impl Sim {
self.ignite(pos, power.max(1), host)
}
EventKind::SamLost { sam, .. } => self.ignite(pos, 2, Some(sam)),
EventKind::Strike { result, .. } if strike_power(result) > 0 => self.ignite(pos, strike_power(result), None),
_ => {}
}
if let Some(r) = &mut self.rec {
+212
View File
@@ -51,6 +51,10 @@ fn network_launcher_must_contribute_to_route_risk() {
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::P1sun,DVec3::new(0.,70.,0.),0.));
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::Radar,DVec3::new(0.,70.,0.),0.));
let grid=Grid{min:DVec3::new(-160.,0.,-160.),cell:16.,size:20};
// #93: the planner prices only what the enemy knows; the radar emits, the launcher is not known until it gives itself away.
let (prices,_)=sim.price_grid(&grid,DroneKind::Shahed);
assert!(prices.iter().all(|p|*p==1.),"an unknown launcher must not be priced");
sim.intel.revealed.push((sim.sams[0].id,DVec3::new(0.,70.,0.),0));
let (prices,_)=sim.price_grid(&grid,DroneKind::Shahed);
assert!(prices.iter().any(|p|*p>1.),"armed launcher with a network radar is invisible to route risk");
}
@@ -234,6 +238,8 @@ fn armed_strike_on_a_system_destroys_it_at_once() {
let mut destroyed = 0;
for seed in 1..=40 {
let mut sim = Sim::new(seed);
// The strike itself is under test: the Patriot does not shoot the Lancet down on a last chance.
sim.last_chance = false;
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Patriot, centre, 0.0));
let mut ground = Ground::default();
@@ -1592,3 +1598,209 @@ fn mass_strikes_keep_fires_effects_and_lights_bounded() {
assert!(sim.fires.is_empty(), "fires burn for ever");
let _ = commands;
}
/// A system put down inside the land (the player stood in the ground) is brought up to the surface, and its
/// interceptors then leave the launcher; one on the surface or in the open air is left where it is.
#[test]
fn a_system_deployed_inside_the_land_comes_up() {
let land = sites::testland::Land(|_, _| 63);
let mut sim = Sim::new(1);
let mut ground = Ground::default();
let mut out = Out::default();
let buried = sim.id();
sim.sams.push(Sam::new(buried, SamKind::Patriot, DVec3::new(20.0, 56.0, 17.0), 0.0));
sim.settle(buried);
let open = sim.id();
sim.sams.push(Sam::new(open, SamKind::S400, DVec3::new(60.0, 63.0, 17.0), 0.0));
sim.settle(open);
let air = sim.id();
sim.sams.push(Sam::new(air, SamKind::Tor, DVec3::new(90.0, 70.0, 17.0), 0.0));
sim.settle(air);
for _ in 0..60 {
sites::testland::step(&mut sim, &mut ground, &land, &mut out);
}
let y = |id| sim.sams.iter().find(|s| s.id == id).unwrap().pos.y;
assert_eq!(y(buried), 63.0, "a buried system comes up to the surface");
assert_eq!(y(open), 63.0);
assert_eq!(y(air), 70.0, "one in the open air is not moved");
}
/// An aim point inside the land (an object marked where the player stood in the ground) is the surface over it:
/// a drone that arrives is a hit, not a crash into the land short of its aim.
#[test]
fn a_drone_at_an_aim_under_the_land_hits() {
let (mut ground, land) = (Ground::default(), sites::testland::Land(|_, _| 63));
let mut sim = Sim::new(5);
sim.targets.push(DVec3::new(0.0, 56.0, 0.0));
sim.target_health.push(100.0);
let d = sim.make_drone(DroneKind::Geran3, DVec3::new(-400.0, 100.0, 0.0), DVec3::X, DVec3::new(0.0, 56.0, 0.0));
sim.drones.push(d);
let mut out = Out::default();
for _ in 0..1200 {
sites::testland::step(&mut sim, &mut ground, &land, &mut out);
if sim.drones.is_empty() {
break;
}
}
assert_eq!((sim.hits, sim.crashes), (1, 0), "hits {} crashes {}", sim.hits, sim.crashes);
}
/// Over land the game has not told of, a low-flying drone does not hug the height of its aim: it holds the transit
/// height (the cruise height over the aim) and comes down only once the land ahead is known.
#[test]
fn over_unknown_land_a_low_flight_holds_its_transit_height() {
let aim = DVec3::new(0.0, 64.0, 3000.0);
let fly = |known: bool| {
let mut sim = Sim::new(4);
let mut d = sim.make_drone(DroneKind::Kh101, DVec3::new(0.0, 70.0, 0.0), DVec3::Z, aim);
d.low = true;
d.route = vec![aim];
sim.drones.push(d);
let (mut ground, land) = (Ground::default(), sites::testland::Land(|_, _| 64));
let mut out = Out::default();
for _ in 0..300 {
if known {
sites::testland::step(&mut sim, &mut ground, &land, &mut out);
} else {
sim.step(&mut ground, None, &mut out);
}
}
sim.drones[0].pos.y
};
let spec = DroneKind::Kh101.spec();
let blind = fly(false);
let seen = fly(true);
assert!(blind > aim.y + spec.cruise - 8.0, "blind and low: {blind}");
assert!(seen < aim.y + 20.0, "over land it knows it keeps hugging it: {seen}");
}
/// The height map is a coarse upper bound (a forest's canopy shows as land): an interceptor under it, with the blocks
/// around it open, is not blown up for "no way over"; it is blown up only before blocks that are really there.
#[test]
fn an_interceptor_under_the_canopy_is_not_scrubbed_before_open_air() {
let blocks = sites::testland::Land(|_, _| 64);
let steer = |solid_to: i32| {
let mut ground = Ground::default();
let land = sites::testland::Land(move |_, _| solid_to);
let kind = MissileKind::M57e6;
let pos = DVec3::new(0.0, 66.0, 0.0);
let mut fail = false;
for t in 0..30 {
// The heights say 76 (the canopy); the blocks are solid only up to `solid_to`.
let cols = ground.take_wanted().into_iter().map(|(x, z)| (x, z, 76)).collect();
ground.answer(cols, t);
let secs = ground.world.take_wanted(t);
ground.world.serve(&land, secs, t);
fail = terrain::steer(&mut ground, kind, pos, kind.spec().speed, DVec3::new(0.0, 70.0, 200.0), t).1;
}
fail
};
let _ = blocks;
assert!(!steer(64), "open air under the canopy");
assert!(steer(80), "real blocks ahead");
}
/// A flight that is high over land nobody has told of does not come down towards the height of its aim.
#[test]
fn over_unknown_land_a_flight_does_not_descend() {
let aim = DVec3::new(0.0, 64.0, 3000.0);
let mut sim = Sim::new(4);
let mut d = sim.make_drone(DroneKind::Kh101, DVec3::new(0.0, 250.0, 0.0), DVec3::Z, aim);
d.low = true;
d.route = vec![aim];
sim.drones.push(d);
let (mut ground, mut out) = (Ground::default(), Out::default());
for _ in 0..150 {
sim.step(&mut ground, None, &mut out);
}
assert!(sim.drones[0].pos.y > 245.0, "it came down over unknown land: {}", sim.drones[0].pos.y);
}
/// #90: a cruise flight that stops getting nearer its aim (a turn circle wider than the distance, a hold that
/// never ends) falls out of the sky after `STALL_TICKS`, so a raid is not held open by it.
#[test]
fn a_flight_that_makes_no_headway_runs_out_of_fuel() {
let mut pace = Pace::default();
// Nearer and nearer: never stalled.
for age in 1..10_000u32 {
assert!(!pace.stalled(age, 5000.0 - f64::from(age) * 0.5, 1), "a flight under way stalled at {age}");
}
// Circling at 40 blocks from the aim.
let mut pace = Pace::default();
let mut ended = None;
for age in 1..10_000u32 {
if pace.stalled(age, 40.0 + f64::from(age % 7), 1) {
ended = Some(age);
break;
}
}
let ended = ended.expect("a circling flight never ran out of fuel");
assert!((STALL_TICKS..STALL_TICKS + 20).contains(&ended), "{ended}");
// Passing a route point is progress, however far off the aim the way goes.
let mut pace = Pace::default();
for age in 1..10_000u32 {
assert!(!pace.stalled(age, 3000.0, 10 - (age / 1000) as usize), "route points passed, still stalled at {age}");
}
}
/// #90: the same in the simulation: a drone that gets no nearer its aim (here the aim keeps its distance, as one
/// does that it cannot turn onto) ends, falling, within the stall time, and the raid is not held open by it.
#[test]
fn a_drone_that_never_arrives_ends_the_raid() {
let mut sim = Sim::new(4);
let mut d = sim.make_drone(DroneKind::Kh101, DVec3::new(0.0, 400.0, 0.0), DVec3::X, DVec3::new(3000.0, 400.0, 0.0));
d.low = false;
sim.drones.push(d);
let (mut ground, mut out) = (Ground::default(), Out::default());
let mut ended = None;
for t in 0..STALL_TICKS + 400 {
if let Some(d) = sim.drones.first_mut() {
d.target = d.pos + DVec3::X * 3000.0;
}
sim.step(&mut ground, None, &mut out);
if !sim.drones.iter().any(|d| d.alive()) {
ended = Some(t);
break;
}
}
let ended = ended.expect("still airborne after the stall time");
assert!(ended >= STALL_TICKS, "ended early: {ended}");
assert_eq!(sim.stalled, 1);
}
/// #90: an answer says which world it is of. Heights of another dimension are not this one's, and an answer to a
/// question asked before the world changed is dropped, not read as the new world's.
#[test]
fn heights_of_another_world_are_void_and_late_answers_are_dropped() {
let mut ground = Ground::default();
ground.world.changed_rev(1, 0, false, &[]);
ground.answer(vec![(4, 4, 70), (12, 4, NO_LAND)], 1);
assert_eq!(ground.land_state(DVec3::new(4.5, 80.0, 4.5)), Some(Some(70.0)));
assert_eq!(ground.land_state(DVec3::new(12.5, 80.0, 4.5)), Some(None), "no land is an answer too");
// A question goes out in world 1; the dimension changes before the answer comes.
let _ = ground.land(DVec3::new(100.5, 80.0, 100.5));
assert_eq!(ground.take_wanted().len(), 1);
ground.world.changed_rev(2, 0, false, &[]);
ground.answer(vec![(100, 100, 55)], 2);
assert_eq!(ground.world.stats.late_dropped, 1);
assert_eq!(ground.land_state(DVec3::new(4.5, 80.0, 4.5)), None, "the old dimension's height is gone, asked again");
assert_eq!(ground.land_state(DVec3::new(100.5, 80.0, 100.5)), None, "the late answer was not taken");
}
/// #90: a blast asked of the game in the blocks is confirmed by the game's own report of blocks changed there; one
/// that is not reported is told apart by whether the land was there.
#[test]
fn a_blast_in_the_blocks_waits_for_the_games_word() {
let mut ground = Ground::default();
ground.answer(vec![(4, 4, 70)], 1);
let near = DVec3::new(4.5, 70.0, 4.5);
let far = DVec3::new(900.5, 70.0, 900.5);
ground.world.expect_blast(near, 8.0, 1);
ground.world.expect_blast(near + DVec3::X * 200.0, 8.0, 1);
ground.world.expect_blast(far, 8.0, 1);
ground.world.changed_rev(1, 1, false, &[(0, 4, 0)]);
assert_eq!(ground.world.stats.blasts_confirmed, 1);
ground.answer(vec![], 1 + world::CONFIRM_TICKS);
let s = ground.world.stats;
assert_eq!((s.blasts_asked, s.blasts_confirmed, s.blasts_unchanged, s.blasts_unloaded), (3, 1, 0, 2));
}
+579
View File
@@ -0,0 +1,579 @@
//! Ground routes for the vehicles (#94): a march is no longer a straight line through the relief. A bounded
//! A* over a coarse grid of the land the game has told of finds a way that avoids cliffs, water and walls; it
//! is worked on a few hundred cells a tick, shared by all the vehicles, and it keeps its state between ticks.
//! Land the game has not answered yet is waited for (the search parks those edges and goes on elsewhere), and
//! after a while the vehicle goes as far as the known land; a place nobody can reach is refused with a reason.
//! While driving, the column ahead is checked once more (a wall the grid missed, a drop): the cell is marked bad
//! and the way is searched again.
use super::*;
use std::collections::{BinaryHeap, HashSet};
/// Cells opened a tick, all vehicles together.
pub(super) const WORK_PER_TICK: usize = 600;
/// Cells opened for one route before it settles for the nearest it got.
const MAX_NODES: usize = 24_000;
/// Ticks the search waits for land the game has not told of, before the vehicle goes as far as is known.
const WAIT_MAP: u64 = 300;
/// Ticks a driver waits for the column ahead.
const WAIT_AHEAD: u32 = 200;
/// Searches again, after a way was found blocked on the march, before giving up.
const REPLANS: u32 = 16;
/// The tallest step a vehicle climbs between two columns, and the deepest drop it takes.
pub(super) const STEP_UP: f64 = 1.5;
pub(super) const STEP_DOWN: f64 = 3.5;
/// Ticks a truck keeps away from a job it found unreachable.
pub(super) const UNREACHABLE_FOR: u64 = 3600;
/// Why a vehicle waits or stops.
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Why {
/// Waiting for the game's answers about the land.
Map,
/// The place itself can't be driven to (cliff, water, wall).
Goal,
/// No way there over the known land.
NoWay,
/// The way was found blocked on the march, again and again.
Stuck,
/// The map did not come.
NoMap,
/// It drove as far as the land is known.
Partial,
}
impl Why {
pub fn text(self) -> &'static str {
match self {
Why::Map => "ждёт карту местности",
Why::Goal => "туда не проехать: обрыв, вода или стена в точке назначения",
Why::NoWay => "прохода нет: путь перекрыт обрывами, водой или стенами",
Why::Stuck => "путь перекрыт на марше, объезда нет",
Why::NoMap => "карта местности не пришла, остался на месте",
Why::Partial => "дальше карта неизвестна: остановился на краю известного",
}
}
}
/// What the route says a vehicle does this tick.
pub(super) enum Drive {
/// Stand: for the land, or the planner.
Wait(Why),
/// Drive at this point; whether it is the last, and the route ends short of the order.
Go(DVec3, bool),
/// Stop here, and say why.
Fail(Why),
}
#[derive(Clone, Copy)]
struct Info {
h: f32,
ok: bool,
}
enum Eval {
Ok(f32),
Bad,
Pending,
}
#[derive(PartialEq)]
struct Open(f32, (i32, i32));
impl Eq for Open {}
impl Ord for Open {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
other.0.total_cmp(&self.0)
}
}
impl PartialOrd for Open {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
#[derive(PartialEq)]
enum Phase {
Search,
Follow,
}
/// One vehicle's way to its destination.
pub(super) struct Route {
dest: DVec3,
/// How near the destination counts (a truck stops beside its job); 0: the place itself.
slack: f64,
cell: i32,
slope: f64,
start: (i32, i32),
lo: (i32, i32),
hi: (i32, i32),
cells: HashMap<(i32, i32), Info>,
bad: HashSet<(i32, i32)>,
g: HashMap<(i32, i32), f32>,
came: HashMap<(i32, i32), (i32, i32)>,
open: BinaryHeap<Open>,
/// Edges waiting for a cell the game has not answered.
parked: Vec<((i32, i32), (i32, i32))>,
retry_at: u64,
stuck_since: Option<u64>,
/// The cell opened that is nearest the destination.
near: ((i32, i32), f64),
expanded: usize,
goal_checked: bool,
phase: Phase,
pts: Vec<DVec3>,
next: usize,
pub(super) partial: bool,
replans: u32,
ahead_wait: u32,
began: u64,
pub(super) told: bool,
failed: Option<Why>,
}
impl Route {
pub(super) fn new(pos: DVec3, dest: DVec3, slack: f64, slope: f64, now: u64) -> Self {
let dist = horizontal(dest - pos).length();
let cell = if dist <= 400.0 { 4 } else if dist <= 900.0 { 6 } else { 8 };
let mut r = Self {
dest,
slack,
cell,
slope,
start: (0, 0),
lo: (0, 0),
hi: (0, 0),
cells: HashMap::new(),
bad: HashSet::new(),
g: HashMap::new(),
came: HashMap::new(),
open: BinaryHeap::new(),
parked: Vec::new(),
retry_at: 0,
stuck_since: None,
near: ((0, 0), f64::MAX),
expanded: 0,
goal_checked: slack > 0.0,
phase: Phase::Search,
pts: Vec::new(),
next: 0,
partial: false,
replans: 0,
ahead_wait: 0,
began: now,
told: false,
failed: None,
};
r.restart(pos);
r
}
fn cell_of(&self, p: DVec3) -> (i32, i32) {
((p.x.floor() as i32).div_euclid(self.cell), (p.z.floor() as i32).div_euclid(self.cell))
}
fn centre(&self, c: (i32, i32)) -> DVec3 {
let k = f64::from(self.cell);
DVec3::new((f64::from(c.0) + 0.5) * k, 0.0, (f64::from(c.1) + 0.5) * k)
}
fn to_goal(&self, c: (i32, i32)) -> f64 {
horizontal(self.centre(c) - self.dest).length()
}
fn is_goal(&self, c: (i32, i32)) -> bool {
if self.slack > 0.0 { self.to_goal(c) <= self.slack } else { c == self.cell_of(self.dest) }
}
/// Searches afresh from `pos`, keeping the cells found bad.
fn restart(&mut self, pos: DVec3) {
self.start = self.cell_of(pos);
let goal = self.cell_of(self.dest);
let margin = (((self.start.0 - goal.0).abs().max((self.start.1 - goal.1).abs()) / 2).max(16)).min(64);
self.lo = (self.start.0.min(goal.0) - margin, self.start.1.min(goal.1) - margin);
self.hi = (self.start.0.max(goal.0) + margin, self.start.1.max(goal.1) + margin);
self.g.clear();
self.came.clear();
self.open.clear();
self.parked.clear();
self.pts.clear();
self.next = 0;
self.phase = Phase::Search;
self.stuck_since = None;
self.expanded = 0;
self.near = (self.start, f64::MAX);
self.partial = false;
self.cells.insert(self.start, Info { h: pos.y as f32, ok: true });
self.g.insert(self.start, 0.0);
let h = self.heur(self.start);
self.open.push(Open(h, self.start));
}
fn heur(&self, c: (i32, i32)) -> f32 {
(self.to_goal(c) * 1.1) as f32
}
fn eval(&mut self, ground: &mut Ground, now: u64, c: (i32, i32)) -> Eval {
if let Some(i) = self.cells.get(&c) {
return if i.ok { Eval::Ok(i.h) } else { Eval::Bad };
}
if self.bad.contains(&c) {
return Eval::Bad;
}
let p = self.centre(c);
let (x, z) = (p.x.floor() as i32, p.z.floor() as i32);
let verdict = match ground.column(x, z, 1024) {
None => return Eval::Pending,
Some(None) => Info { h: 0.0, ok: false },
Some(Some(h)) => {
// The height is the top of what blocks motion or holds a fluid: water has nothing solid under it.
let top = DVec3::new(f64::from(x) + 0.5, h, f64::from(z) + 0.5);
match ground.world.sweep(top + DVec3::Y * 0.5, top - DVec3::Y * 0.5, false, now) {
world::Sweep::Hit { .. } => Info { h: h as f32, ok: true },
world::Sweep::Clear => Info { h: h as f32, ok: false },
world::Sweep::Unknown { pending: true, .. } | world::Sweep::Wait { .. } => return Eval::Pending,
world::Sweep::Unknown { .. } => Info { h: h as f32, ok: false },
}
}
};
self.cells.insert(c, verdict);
if verdict.ok { Eval::Ok(verdict.h) } else { Eval::Bad }
}
/// Relaxes the edge from the opened cell `c` to `nb`; parks it when the land between isn't known yet.
fn relax(&mut self, ground: &mut Ground, now: u64, c: (i32, i32), nb: (i32, i32)) {
if nb.0 < self.lo.0 || nb.1 < self.lo.1 || nb.0 > self.hi.0 || nb.1 > self.hi.1 {
return;
}
let (di, dj) = (nb.0 - c.0, nb.1 - c.1);
let len = if di != 0 && dj != 0 { std::f64::consts::SQRT_2 } else { 1.0 };
let dist = len * f64::from(self.cell);
let Some(h0) = self.cells.get(&c).map(|i| f64::from(i.h)) else { return };
let h1 = match self.eval(ground, now, nb) {
Eval::Ok(h) => f64::from(h),
Eval::Bad => return,
Eval::Pending => return self.park(c, nb),
};
// A diagonal doesn't cut a corner of something in the way.
if di != 0 && dj != 0 {
for side in [(c.0 + di, c.1), (c.0, c.1 + dj)] {
match self.eval(ground, now, side) {
Eval::Ok(h) if (f64::from(h) - h0).abs() <= (self.slope * f64::from(self.cell)).max(STEP_UP) => {}
Eval::Pending => return self.park(c, nb),
_ => return,
}
}
}
// Between the centres: a wall one block thick or a ditch there is what the centres don't show.
let (a, b) = (self.centre(c), self.centre(nb));
let mid = (a + b) * 0.5;
let hm = match ground.column(mid.x.floor() as i32, mid.z.floor() as i32, 1024) {
None => return self.park(c, nb),
Some(None) => return,
Some(Some(h)) => h,
};
let half = dist * 0.5;
let allow = |l: f64| (self.slope * l).max(STEP_UP);
if (hm - h0).abs() > allow(half) || (h1 - hm).abs() > allow(half) || hm - h0 < -STEP_DOWN * (half / 2.0).max(1.0) {
return;
}
let dh = (h1 - h0).abs();
if dh > allow(dist) {
return;
}
let cost = (dist + 2.0 * dh) as f32;
let ng = self.g.get(&c).copied().unwrap_or(0.0) + cost;
if self.g.get(&nb).is_none_or(|&g| ng < g) {
self.g.insert(nb, ng);
self.came.insert(nb, c);
self.open.push(Open(ng + self.heur(nb), nb));
}
}
fn park(&mut self, c: (i32, i32), nb: (i32, i32)) {
if self.parked.len() < 4096 {
self.parked.push((c, nb));
}
}
/// Works on the search: at most `budget` cells.
fn search(&mut self, ground: &mut Ground, now: u64, mut budget: usize) {
if !self.goal_checked {
match self.eval(ground, now, self.cell_of(self.dest)) {
Eval::Bad => {
self.failed = Some(Why::Goal);
return;
}
Eval::Ok(_) => self.goal_checked = true,
Eval::Pending => {}
}
}
if now >= self.retry_at && !self.parked.is_empty() {
self.retry_at = now + 6;
let parked = std::mem::take(&mut self.parked);
for (k, &(c, nb)) in parked.iter().enumerate() {
// The game loads the chunk of the first few, if it loads on request.
if k < 6 {
let p = self.centre(nb);
ground.want_chunk(((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4));
}
self.relax(ground, now, c, nb);
}
}
let before = self.expanded;
while budget > 0 {
let Some(Open(f, c)) = self.open.pop() else { break };
let g = self.g.get(&c).copied().unwrap_or(f32::INFINITY);
if f > g + self.heur(c) + 1e-3 {
continue;
}
budget -= 1;
self.expanded += 1;
if self.is_goal(c) {
self.finish(c, false);
return;
}
let d = self.to_goal(c);
if d < self.near.1 {
self.near = (c, d);
}
for (di, dj) in [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)] {
self.relax(ground, now, c, (c.0 + di, c.1 + dj));
}
if self.expanded >= MAX_NODES {
self.settle(now);
return;
}
}
if self.expanded > before {
self.stuck_since = None;
}
if self.open.is_empty() {
if self.parked.is_empty() {
let goal = self.cell_of(self.dest);
self.failed = Some(if self.slack == 0.0 && !self.cells.get(&goal).is_none_or(|i| i.ok) { Why::Goal } else { Why::NoWay });
} else {
let since = *self.stuck_since.get_or_insert(now);
if now.saturating_sub(since) > WAIT_MAP {
self.settle(now);
}
}
} else {
self.stuck_since = None;
}
}
/// The land stays unknown (or the search is too big): the way to the nearest cell it got to.
fn settle(&mut self, _now: u64) {
let (c, d) = self.near;
let here = self.to_goal(self.start);
if c == self.start || here - d < f64::from(self.cell) * 2.0 {
self.failed = Some(Why::NoMap);
} else {
self.finish(c, true);
}
}
/// The way to cell `c`, as points with the straight stretches dropped.
fn finish(&mut self, c: (i32, i32), partial: bool) {
let mut cells = vec![c];
let mut k = c;
while let Some(&p) = self.came.get(&k) {
cells.push(p);
k = p;
if cells.len() > 100_000 {
break;
}
}
cells.reverse();
let mut pts: Vec<DVec3> = Vec::new();
for w in 1..cells.len() {
let dir = (cells[w].0 - cells[w - 1].0, cells[w].1 - cells[w - 1].1);
let last = w + 1 == cells.len();
let turns = !last && (cells[w + 1].0 - cells[w].0, cells[w + 1].1 - cells[w].1) != dir;
if last || turns {
pts.push(self.centre(cells[w]));
}
}
let end = if partial || self.slack > 0.0 { self.centre(c) } else { self.dest };
match pts.last_mut() {
Some(l) => *l = end,
None => pts.push(end),
}
self.pts = pts;
self.next = 0;
self.partial = partial;
self.phase = Phase::Follow;
}
/// The column ahead stopped the vehicle: mark it, and search again from here.
pub(super) fn blocked_ahead(&mut self, at: DVec3, pos: DVec3) {
let c = self.cell_of(at);
if c != self.cell_of(pos) {
self.bad.insert(c);
self.cells.insert(c, Info { h: 0.0, ok: false });
} else {
// In the cell itself: the stretch ahead, one cell on.
let d = horizontal(at - pos).normalize_or(DVec3::X) * f64::from(self.cell);
let c = self.cell_of(pos + d);
self.bad.insert(c);
self.cells.insert(c, Info { h: 0.0, ok: false });
}
self.replans += 1;
if self.replans > REPLANS {
self.failed = Some(Why::Stuck);
} else {
self.restart(pos);
}
}
/// Ticks the vehicle has waited for the land ahead; true when it has waited too long.
pub(super) fn wait_ahead(&mut self) -> bool {
self.ahead_wait += 1;
self.ahead_wait > WAIT_AHEAD
}
pub(super) fn ahead_ok(&mut self) {
self.ahead_wait = 0;
}
pub(super) fn waited(&self, now: u64) -> u64 {
now.saturating_sub(self.began)
}
/// What the vehicle at `pos` does: works the search on its share of the tick, then follows the way.
pub(super) fn drive(&mut self, ground: &mut Ground, now: u64, pos: DVec3, budget: usize) -> Drive {
if self.phase == Phase::Search && self.failed.is_none() {
self.search(ground, now, budget);
}
if let Some(why) = self.failed {
return Drive::Fail(why);
}
if self.phase == Phase::Search {
return Drive::Wait(Why::Map);
}
let reach = f64::from(self.cell) * 0.7;
while self.next + 1 < self.pts.len() && horizontal(self.pts[self.next] - pos).length() < reach {
self.next += 1;
}
Drive::Go(self.pts[self.next], self.next + 1 >= self.pts.len())
}
pub(super) fn searching(&self) -> bool {
self.phase == Phase::Search && self.failed.is_none()
}
}
/// The routes of the vehicles that drive, by system id, and the jobs a truck found out of reach.
#[derive(Default)]
pub(super) struct Routes {
pub map: HashMap<u32, Route>,
/// (truck id, the job's id) and until when the truck leaves it alone.
pub unreachable: HashMap<(u32, u32), u64>,
}
impl Routes {
pub(super) fn out_of_reach(&self, truck: u32, job: u32, now: u64) -> bool {
self.unreachable.get(&(truck, job)).is_some_and(|&t| t > now)
}
}
impl SamKind {
/// The steepest slope (rise a block) it drives: trucks the least.
pub(super) fn climbs(self) -> f64 {
match self.spec().family {
Family::Truck => 0.5,
_ => 0.6,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The land as the game would tell it: the height map, and under it the blocks that are solid.
struct Src<H: Fn(i32, i32) -> i32, B: Fn(i32, i32) -> i32> {
height: H,
floor: B,
}
impl<H: Fn(i32, i32) -> i32, B: Fn(i32, i32) -> i32> world::WorldSource for Src<H, B> {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
Some(y < (self.floor)(x, z))
}
}
fn answer<H: Fn(i32, i32) -> i32, B: Fn(i32, i32) -> i32>(ground: &mut Ground, src: &Src<H, B>, now: u64) {
let columns = ground.take_wanted().into_iter().map(|(x, z)| (x, z, (src.height)(x, z))).collect();
ground.answer(columns, now);
let sections = ground.world.take_wanted(now);
ground.world.serve(src, sections, now);
}
/// Drives a Patriot from the origin to `to` over land of the given heights (water: a height over a lower floor).
fn march<H: Fn(i32, i32) -> i32, B: Fn(i32, i32) -> i32>(height: H, floor: B, to: DVec3, ticks: u32) -> (Sim, Out, u32) {
let src = Src { height, floor };
let mut sim = Sim::new(3);
sim.damage = false;
let mut ground = Ground::default();
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.5, 64.0, 0.5), 0.0));
let mut out = Out::default();
for _ in 0..3 {
sim.tick += 1;
answer(&mut ground, &src, sim.tick);
}
assert!(sim.order_move(0, to));
let mut used = 0;
let mut worst = 0usize;
for _ in 0..ticks {
sim.tick += 1;
sim.step_moves(&mut ground, &mut out);
answer(&mut ground, &src, sim.tick);
used += 1;
let p = sim.sams[0].pos;
let (x, z) = (p.x.floor() as i32, p.z.floor() as i32);
assert!(((src.height)(x, z) as f64) <= p.y + 2.5, "inside the land at {p:?}");
assert!((src.floor)(x, z) >= (src.height)(x, z), "drove onto water at {p:?}");
worst = worst.max(sim.routes.map.values().filter(|r| r.searching()).count());
if !sim.sams[0].moving() {
break;
}
}
let _ = worst;
(sim, out, used)
}
// A wall across the way, with a gap far to one side: the vehicle goes round and arrives.
#[test]
fn a_wall_with_a_gap_is_driven_round() {
let wall = |x: i32, z: i32| if (40..44).contains(&x) && (-60..=40).contains(&z) { 90 } else { 64 };
let (sim, _, _) = march(wall, wall, DVec3::new(120.0, 64.0, 0.5), 6000);
let s = &sim.sams[0];
assert!(!s.moving() && s.pos.distance(DVec3::new(120.0, 64.0, 0.5)) < 3.0, "ended at {:?} {:?}", s.pos, s.state);
}
// A closed pocket: refused with a reason, no teleport and no drive into the wall.
#[test]
fn an_unreachable_place_is_refused() {
let walls = |x: i32, z: i32| if (60..64).contains(&x) && (-300..300).contains(&z) { 100 } else { 64 };
let (sim, out, _) = march(walls, walls, DVec3::new(120.0, 64.0, 0.5), 6000);
let s = &sim.sams[0];
assert!(!s.moving() && s.pos.x < 62.0, "{:?}", s.pos);
assert!(out.messages.iter().any(|m| m.contains("прохода нет") || m.contains("перекрыт") || m.contains("карта")), "{:?}", out.messages);
}
// A lake (height 64 over a floor of 50) is no road: it is gone round, and a destination in it is refused.
#[test]
fn a_lake_is_not_a_road() {
let floor = |x: i32, z: i32| if (50..90).contains(&x) && z.abs() < 40 { 50 } else { 64 };
let (sim, _, _) = march(|_, _| 64, floor, DVec3::new(130.0, 64.0, 0.5), 6000);
let s = &sim.sams[0];
assert!(!s.moving() && s.pos.distance(DVec3::new(130.0, 64.0, 0.5)) < 3.0, "{:?}", s.pos);
let (sim, out, _) = march(|_, _| 64, floor, DVec3::new(70.0, 64.0, 0.5), 3000);
assert!(!sim.sams[0].moving() && sim.sams[0].pos.x < 10.0, "{:?}", sim.sams[0].pos);
assert!(out.messages.iter().any(|m| m.contains("не проехать")), "{:?}", out.messages);
}
// Open ground: straight on, arrives.
#[test]
fn open_ground_is_driven_straight() {
let (sim, _, _) = march(|_, _| 64, |_, _| 64, DVec3::new(100.0, 64.0, 30.5), 1500);
let s = &sim.sams[0];
assert!(!s.moving() && s.pos.distance(DVec3::new(100.0, 64.0, 30.5)) < 3.0, "{:?}", s.pos);
}
}
+203 -1
View File
@@ -20,7 +20,7 @@ use super::*;
const RAYS: usize = 48;
const STEP: f64 = 16.0;
/// How far units share their tracks (the network's data links).
pub(super) const LINK: f64 = 1500.0;
pub(super) const LINK: f64 = 2250.0;
/// A launcher fires on a track no older than this (ticks); a gun needs its own radar's, newer than `GUN_AGE`.
pub(super) const MISSILE_AGE: u64 = 120;
pub(super) const GUN_AGE: u64 = 3;
@@ -93,6 +93,97 @@ pub(super) fn reach(sam: &Sam, kind: DroneKind, height: f64) -> f64 {
radar.min(horizon).max(NEAR)
}
/// The low flyer the command map shows the radars' coverage for: a Shahed over the land.
const LOW_KIND: DroneKind = DroneKind::Shahed;
/// A radar as the command map sees it: where its antenna is and how far it reaches a low flyer.
#[derive(Clone, Copy)]
pub struct LowSensor {
pub id: u32,
pub ant: DVec3,
/// The ground under the unit (a place whose land is unknown is taken as level with it).
pub base: f64,
pub reach: f64,
/// Its horizon is known: the land hides what it hides.
pub terrain: bool,
}
/// Rays of `Sim::low_rim`.
pub const RIM_RAYS: usize = RAYS;
impl Ground {
/// The land's height at `p` from what the game has already answered; asks for nothing.
pub fn peek(&self, p: DVec3) -> Option<f64> {
const LAT: i32 = super::terrain::LATTICE;
let k = (p.x.floor() as i32, p.z.floor() as i32);
let hit = self.known.get(&k).or_else(|| {
self.known.get(&(k.0.div_euclid(LAT) * LAT + LAT / 2, k.1.div_euclid(LAT) * LAT + LAT / 2))
});
hit.and_then(|&(y, _)| land_height(y))
}
}
impl Sim {
/// A radar for a unit of `kind` at `pos` as the map draws it; `id`: the unit it is (its parts and horizon), None for a ghost.
pub fn low_sensor(&self, kind: SamKind, pos: DVec3, id: Option<u32>) -> Option<LowSensor> {
let spec = kind.spec();
if spec.sensor <= 0.0 {
return None;
}
let fit = id.and_then(|i| self.sams.iter().find(|s| s.id == i)).map_or(1.0, |s| s.radar_fit());
let height = LOW_KIND.spec().clearance;
let radar = spec.sensor * fit * LOW_KIND.rcs().powf(0.25);
let reach = radar.min(HORIZON * (spec.mast.sqrt() + height.sqrt())).max(NEAR);
let terrain = id.is_some_and(|i| self.horizons.contains_key(&i));
Some(LowSensor { id: id.unwrap_or(u32::MAX), ant: pos + DVec3::Y * spec.mast, base: pos.y, reach, terrain })
}
/// The radars that are up now.
pub fn low_sensors(&self) -> Vec<LowSensor> {
self.sams.iter().filter(|s| s.radar_up()).filter_map(|s| self.low_sensor(s.kind, s.pos, Some(s.id))).collect()
}
/// Whether the radar sees a low flyer (over the land) at the column (`x`, `z`): in its reach and not behind the land.
pub fn low_sees(&self, ground: &Ground, s: &LowSensor, x: f64, z: f64) -> bool {
let (dx, dz) = (x - s.ant.x, z - s.ant.z);
let d2 = dx * dx + dz * dz;
if d2 > s.reach * s.reach {
return false;
}
if !s.terrain || d2 <= NEAR * NEAR {
return true;
}
let Some(h) = self.horizons.get(&s.id) else { return true };
let g = ground.peek(DVec3::new(x, 0.0, z)).unwrap_or(s.base);
!h.hides(s.ant, DVec3::new(x, g + LOW_KIND.spec().clearance, z))
}
/// Per ray round the radar, how far a low flyer is seen before the land first hides it (at most the reach).
pub fn low_rim(&self, ground: &Ground, s: &LowSensor) -> Vec<f32> {
let mut out = vec![s.reach as f32; RAYS];
let Some(h) = self.horizons.get(&s.id).filter(|_| s.terrain) else { return out };
let steps = ((s.reach / STEP) as usize).min(h.rays[0].len());
let height = LOW_KIND.spec().clearance;
for (r, o) in out.iter_mut().enumerate() {
let a = r as f64 / RAYS as f64 * TAU;
let (sx, sz) = (a.sin(), a.cos());
for k in 1..=steps {
let d = k as f64 * STEP;
if d <= NEAR {
continue;
}
let p = DVec3::new(s.ant.x + sx * d, 0.0, s.ant.z + sz * d);
let y = ground.peek(p).unwrap_or(s.base) + height - s.ant.y;
if ((y / d) as f32) < h.rays[r][k - 1] {
*o = (d - STEP).max(NEAR) as f32;
break;
}
}
}
out
}
}
/// How a kind's radar looks round.
pub(super) struct Scan {
/// Ticks for the beam to go round.
@@ -144,6 +235,8 @@ impl Ident {
#[derive(Clone, Copy, Debug)]
pub struct Track {
pub id: u32,
/// What the radar made of it when it measured (the class the picture shows).
pub kind: DroneKind,
/// Where it was and how it went when last measured (blocks, blocks a tick).
pub pos: DVec3,
pub vel: DVec3,
@@ -157,6 +250,24 @@ pub struct Track {
pub locked: bool,
}
/// What the player's side knows of the air: the network's tracks as last measured, never the drones themselves.
pub struct AirPicture {
pub now: u64,
pub tracks: Vec<Track>,
/// No system is deployed: the tracks are the sky as it is.
pub unaided: bool,
}
impl AirPicture {
pub fn get(&self, id: u32) -> Option<&Track> {
self.tracks.iter().find(|t| t.id == id)
}
/// The track nearest to a point, by its forecast place.
pub fn nearest(&self, to: DVec3) -> Option<&Track> {
self.tracks.iter().min_by(|a, b| a.est(self.now).distance(to).total_cmp(&b.est(self.now).distance(to)))
}
}
impl Track {
pub fn age(&self, now: u64) -> u64 {
now.saturating_sub(self.tick)
@@ -305,6 +416,7 @@ impl Sim {
let noise = DVec3::new(dice(seed, now, key, 1) - 0.5, (dice(seed, now, key, 2) - 0.5) * 0.5, dice(seed, now, key, 3) - 0.5) * 2.0 * sigma;
fresh.push(Track {
id: d.id,
kind: d.kind,
pos: d.pos + noise,
vel: d.vel,
tick: now,
@@ -356,6 +468,25 @@ impl Sim {
self.sams.iter().any(|s| s.seen.contains(&id))
}
/// The air picture as the player's panel and radar read it (the only thing they read).
pub fn air_picture(&self) -> AirPicture {
let now = self.tick;
// With no system deployed there is no radar to be blind: the sky as it is (the same fallback the screens
// always had); a network's picture is only what it holds.
let unaided = self.sams.is_empty();
let mut tracks: Vec<Track> = if unaided {
self.drones
.iter()
.filter(|d| d.alive())
.map(|d| Track { id: d.id, kind: d.kind, pos: d.pos, vel: d.vel, tick: now, own_tick: now, ttl: 1, hits: 3, locked: false })
.collect()
} else {
self.picture().into_values().collect()
};
tracks.sort_by_key(|t| t.id);
AirPicture { now, tracks, unaided }
}
/// The freshest track of each drone any system holds: the air picture.
pub fn picture(&self) -> HashMap<u32, Track> {
let mut best: HashMap<u32, Track> = HashMap::new();
@@ -367,3 +498,74 @@ impl Sim {
best
}
}
#[cfg(test)]
mod low_view_tests {
use super::*;
/// A radar on level land (70) with a hill east of it (140 high, x 128..160): the east ray is cut at the hill, the others are not.
fn world() -> (Sim, Ground, u32) {
let mut sim = Sim::new(3);
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Buk, DVec3::new(0.0, 70.0, 0.0), 0.0));
let mut ground = Ground::default();
let cols: Vec<(i32, i32, i32)> = (-60..60).map(|k| (k * 16, 0, if (128..160).contains(&(k * 16)) { 140 } else { 70 })).collect();
ground.answer(cols, 0);
let h = Horizon::build(antenna(&sim.sams[0]), sim.sams[0].kind.spec().sensor, &mut ground, 0);
sim.horizons.insert(id, h);
(sim, ground, id)
}
#[test]
fn the_land_cuts_the_coverage_edge_behind_a_hill() {
let (sim, ground, id) = world();
let ls = sim.low_sensor(SamKind::Buk, DVec3::new(0.0, 70.0, 0.0), Some(id)).unwrap();
assert!(ls.terrain && ls.reach > 200.0, "{}", ls.reach);
let rim = sim.low_rim(&ground, &ls);
let east = rim[RIM_RAYS / 4];
assert!((128.0..=176.0).contains(&east), "east edge {east}");
// Rays whose land is unknown hide nothing: the whole reach.
assert_eq!(rim[0], ls.reach as f32);
assert_eq!(rim[RIM_RAYS * 3 / 4], ls.reach as f32);
assert!(sim.low_sees(&ground, &ls, -200.0, 0.0));
assert!(sim.low_sees(&ground, &ls, 100.0, 0.0));
assert!(!sim.low_sees(&ground, &ls, 200.0, 0.0), "behind the hill");
assert!(!sim.low_sees(&ground, &ls, ls.reach + 50.0, 0.0), "out of reach");
// A ghost (no horizon) sees the whole circle.
let ghost = sim.low_sensor(SamKind::Buk, DVec3::new(0.0, 70.0, 0.0), None).unwrap();
assert!(sim.low_sees(&ground, &ghost, 200.0, 0.0));
}
// The map's tint asks `low_sees` for a cell of a coarse grid at every radar that reaches it, and the edges
// ask `low_rim`: with a Polygon's worth of systems both stay cheap (native time, a few ms; wasm is a few times that).
#[test]
fn coverage_of_128_systems_is_cheap() {
let (mut sim, mut ground, _) = world();
for i in 0..127 {
let id = sim.id();
let pos = DVec3::new((i % 16) as f64 * 40.0 - 300.0, 70.0, (i / 16) as f64 * 40.0 - 150.0);
sim.sams.push(Sam::new(id, SamKind::Buk, pos, 0.0));
let k = sim.sams.len() - 1;
let h = Horizon::build(antenna(&sim.sams[k]), sim.sams[k].kind.spec().sensor, &mut ground, 0);
sim.horizons.insert(id, h);
}
let sensors = sim.low_sensors();
assert_eq!(sensors.len(), 128);
let t = std::time::Instant::now();
let mut seen = 0;
for cy in 0..28 {
for cx in 0..36 {
let (x, z) = (cx as f64 * 30.0 - 500.0, cy as f64 * 30.0 - 400.0);
// Worst case for the early exit: a cell no radar sees is tested against all that reach it.
if sensors.iter().any(|s| sim.low_sees(&ground, s, x, z)) {
seen += 1;
}
}
}
let tint = t.elapsed();
let t = std::time::Instant::now();
let rims: usize = sensors.iter().take(3).map(|s| sim.low_rim(&ground, s).len()).sum();
eprintln!("tint of 1008 cells: {tint:?} ({seen} seen); 3 rims: {:?} ({rims})", t.elapsed());
assert!(seen > 0 && tint.as_millis() < 100, "{tint:?}");
}
}
+249 -26
View File
@@ -2,16 +2,23 @@
//! 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.
//! sites: each one leaves its site, one at a time, from the stock the site holds.
//!
//! The land beyond the render distance is not there until the game loads it, so the search asks for the chunks
//! of the places it is looking at (`render.load-chunks`, API 22: a few, never a ring; the game generates or reads
//! them on its workers and keeps them some seconds) and waits for them: a column the game is loading is neither
//! land nor sea, it is pending. Where nothing can be loaded (a server) or nothing suitable is found within
//! `SEARCH_TICKS`, the raid is never cancelled for it: the side's site is *off the map* (`offmap`): no kit is drawn,
//! nothing can be destroyed, and its drones start in the air at the ordered range, cruising over whatever is there.
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;
pub const SEARCH_TICKS: u32 = 400;
/// Candidates asked about ahead of the one being waited for.
const PREFETCH: usize = 3;
/// Prefetched candidates ask for their chunks only while fewer than this many are wanted.
const PREFETCH_CHUNKS: usize = 24;
/// 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)];
@@ -47,9 +54,9 @@ impl SiteClass {
/// Ticks between two launches from one site.
pub fn cadence(self) -> u64 {
match self {
SiteClass::Ramp => 3,
SiteClass::Launcher => 8,
SiteClass::Battery => 20,
SiteClass::Ramp => 15,
SiteClass::Launcher => 25,
SiteClass::Battery => 50,
}
}
fn slots(self) -> u32 {
@@ -123,6 +130,9 @@ pub struct LaunchSite {
pub planned: u32,
pub alive: bool,
pub next_free: u64,
/// Off the map: no land to stand on was found (or none can be loaded), so there is no kit; the drones
/// start in the air at `pos`'s range.
pub offmap: bool,
}
impl LaunchSite {
@@ -161,6 +171,8 @@ pub(super) enum Slot {
/// Not a site's job (a manual launch, an old save): off it goes.
Free,
Go,
/// An off-map site's drone: it starts in the air, without a launch.
Air,
/// The site launched a moment ago.
Busy,
/// The site is gone or empty: the job is cancelled.
@@ -215,8 +227,9 @@ impl Sites {
}
s.stock -= 1;
s.next_free = now + s.class.cadence();
let air = s.offmap;
self.jobs.swap_remove(j);
Slot::Go
if air { Slot::Air } else { Slot::Go }
}
}
@@ -260,6 +273,13 @@ pub(super) struct Want {
pub site: u32,
}
impl Want {
/// Whether the search has nothing to show for it: the site is off the map.
pub fn offmap(&self) -> bool {
self.state == Search::Failed
}
}
/// 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) {
@@ -269,16 +289,19 @@ pub(super) fn want_index(wants: &mut Vec<Want>, centre: DVec3, side: usize, bear
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.
// A long range retreats in steps of at most 100 blocks and tries wider rings, not a few huge gaps.
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) };
let min = if class == SiteClass::Battery { lo.min(from).max(80.0) } else { (from * 0.3).max(80.0).max(from - 600.0) };
(1..=6).map(|i| from - (from - min) * f64::from(i) / 6.0).collect()
};
let wide = (range / 500.0).clamp(1.0, 3.0);
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()
[0.0, 24.0, -24.0, 48.0, -48.0, 72.0, -72.0].iter().map(|d| (range + d * wide).max(KEEP_OUT)).collect()
};
let laterals: &[f64] = &[0.0, 20.0, -20.0];
// A battery may stand where another site of the side does (its range is its own): wider to the sides.
let laterals: &[f64] = if class == SiteClass::Battery { &[0.0, 20.0, -20.0, 60.0, -60.0] } else { &[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)) {
@@ -300,9 +323,25 @@ enum Verdict {
Good(DVec3, bool),
}
/// The chunks a site at `p` needs: its footprint and the way ahead, up to 14 blocks.
fn chunks_of(p: DVec3, inward: DVec3) -> Vec<(i32, i32)> {
let mut out: Vec<(i32, i32)> = Vec::with_capacity(6);
for (dx, dz) in FOOTPRINT {
for k in [0.0, 14.0] {
let c = (((p.x + dx + inward.x * k).floor() as i32) >> 4, ((p.z + dz + inward.z * k).floor() as i32) >> 4);
if !out.contains(&c) {
out.push(c);
}
}
}
out
}
/// 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 {
/// A column or chunk the game is still loading makes it `Pending`, and the chunks are asked for (unless this is
/// a look ahead at a later candidate, `ahead`, and many are wanted already).
fn judge(ground: &mut Ground, p: DVec3, inward: DVec3, keep: &[DVec3], now: u64, ahead: bool) -> 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;
}
@@ -318,6 +357,22 @@ fn judge(ground: &mut Ground, p: DVec3, inward: DVec3, keep: &[DVec3], now: u64)
}
}
}
// The way ahead must be loaded too before it is swept (an unknown stretch is answered "no chunk" and kept
// for a while): a column in the middle of each chunk it needs says so.
let chunks = chunks_of(p, inward);
if !pending {
for &(cx, cz) in &chunks {
pending |= ground.column(cx * 16 + 8, cz * 16 + 8, 256).is_none();
}
}
if pending {
if !ahead || ground.chunks_wanted() < PREFETCH_CHUNKS {
for c in chunks {
ground.want_chunk(c);
}
}
return Verdict::Pending;
}
if pending {
return Verdict::Pending;
}
@@ -334,7 +389,9 @@ fn judge(ground: &mut Ground, p: DVec3, inward: DVec3, keep: &[DVec3], now: u64)
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 { pending: true, .. } | world::Sweep::Wait { .. } => {
return Verdict::Pending;
}
world::Sweep::Unknown { .. } => return Verdict::Bad,
}
}
@@ -346,7 +403,9 @@ fn judge(ground: &mut Ground, p: DVec3, inward: DVec3, keep: &[DVec3], now: u64)
for s in [up, ahead] {
match s {
world::Sweep::Clear => {}
world::Sweep::Unknown { pending: true, .. } | world::Sweep::Wait { .. } => return Verdict::Pending,
world::Sweep::Unknown { pending: true, .. } | world::Sweep::Wait { .. } => {
return Verdict::Pending;
}
world::Sweep::Unknown { .. } | world::Sweep::Hit { .. } => return Verdict::Bad,
}
}
@@ -373,7 +432,7 @@ pub(super) fn search(wants: &mut [Want], ground: &mut Ground, centre: DVec3, kee
break;
}
let (cand, water) = w.cands[w.cursor];
match judge(ground, cand, inward, &keep, now) {
match judge(ground, cand, inward, &keep, now, false) {
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,
@@ -387,7 +446,7 @@ pub(super) fn search(wants: &mut [Want], ground: &mut Ground, centre: DVec3, kee
Verdict::Pending => {
for k in 1..=PREFETCH {
if let Some(&(c, _)) = w.cands.get(w.cursor + k) {
let _ = judge(ground, c, inward, &keep, now);
let _ = judge(ground, c, inward, &keep, now, true);
}
}
if last {
@@ -414,7 +473,18 @@ 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 };
// An off-map site is nowhere to be found: nothing to destroy.
self.end_site(id, false)
}
/// Ends a site's launches wherever it is, off the map too (a strike that would have hit it): the same as
/// destroying it, without anything to draw.
pub fn halt_site(&mut self, id: u32) -> u32 {
self.end_site(id, true)
}
fn end_site(&mut self, id: u32, offmap: bool) -> u32 {
let Some(s) = self.sites.list.iter_mut().find(|s| s.id == id && (offmap || !s.offmap)) else { return 0 };
s.alive = false;
s.stock = 0;
let ids: Vec<u32> = self.sites.jobs.iter().filter(|j| j.1 == id).map(|j| j.0).collect();
@@ -431,7 +501,7 @@ impl Sim {
/// 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) {
for s in self.sites.list.iter().filter(|s| s.alive && !s.offmap) {
let rot = DQuat::from_rotation_y(heading_of(s.inward()));
let sc = s.class.scale();
if s.water {
@@ -651,6 +721,7 @@ mod tests {
planned: 5,
alive: true,
next_free: 0,
offmap: false,
});
for i in 0..5 {
let site = sim.sites.get(id).unwrap().clone();
@@ -689,9 +760,16 @@ mod tests {
assert_eq!(sim.launched as usize + n, 14);
}
/// Runs the sim `ticks` without the game answering anything, and counts what left.
fn run_blind(sim: &mut Sim, out: &mut Out, ground: &mut Ground, ticks: u32) {
for _ in 0..ticks {
sim.step(ground, None, out);
}
}
#[test]
fn no_suitable_ground_means_a_clear_refusal() {
// Never answered: unknown ground is not suitable, and nothing is launched.
fn no_suitable_ground_means_the_raid_launches_off_the_map() {
// Never answered: unknown ground is not suitable, but nothing is cancelled: every side starts off the map.
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);
@@ -701,20 +779,165 @@ mod tests {
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.
assert_eq!(sim.queue.len() + sim.launched as usize, 14);
assert!(!sim.sites.list.is_empty() && sim.sites.list.iter().all(|s| s.offmap), "{:?}", sim.sites.list);
let said: Vec<_> = out.messages.iter().filter(|m| m.contains("за пределами")).collect();
assert_eq!(said.len(), 1, "{:?}", out.messages);
assert!(!out.messages.iter().any(|m| m.contains("отмен")), "{:?}", out.messages);
run_blind(&mut sim, &mut out, &mut ground, 400);
assert_eq!(sim.launched, 14, "{:?}", out.messages);
assert!(sim.queue.is_empty());
// No kit, and nothing to destroy.
let mut o2 = Out::default();
sim.draw_sites(&mut o2);
assert!(o2.models.is_empty());
let id = sim.sites.list[0].id;
assert_eq!(sim.destroy_site(id), 0);
// A cliff face everywhere (too steep to stand on) and a sea (no land) launch off the map as well.
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("площад")));
assert!(!sim.sites.list.is_empty() && sim.sites.list.iter().all(|s| s.offmap), "steep {steep}");
assert_eq!(sim.queue.len() + sim.launched as usize, 14, "steep {steep}");
assert!(!out.messages.iter().any(|m| m.contains("отмен")), "{:?}", out.messages);
}
}
/// An off-map site's drones start in the air at the height planned over land nobody had read; if the land
/// turns out higher there by the time they leave, they start above it instead of being born inside the hill.
#[test]
fn an_off_map_start_over_a_hill_begins_above_it() {
let (mut ground, mut out) = (Ground::default(), Out::default());
let mut sim = Sim::new(3);
sim.order_raid(&order(Approach::TwoSectors, 300.0), &[DVec3::new(0.0, 70.0, 0.0)], &mut out);
while sim.planning() {
sim.step(&mut ground, None, &mut out);
}
assert!(sim.sites.list.iter().all(|s| s.offmap));
let land = Land(|x, z| if x * x + z * z > 200 * 200 { 150 } else { 64 });
for _ in 0..500 {
step(&mut sim, &mut ground, &land, &mut out);
assert!(sim.drones.iter().all(|d| d.age > 3 || d.pos.y > 148.0 || d.flight == Flight::Ballistic), "a drone started inside the hill");
}
assert!(sim.launched > 0);
}
/// Chunks that load on request: a column of a chunk asked for answers `PENDING` until `delay` ticks
/// after the first request, and a chunk never asked for is not there (-64).
struct Lazy {
height: i32,
delay: u64,
asked: std::cell::RefCell<HashMap<(i32, i32), u64>>,
most: usize,
}
impl Lazy {
fn loaded(&self, c: (i32, i32), now: u64) -> Option<bool> {
self.asked.borrow().get(&c).map(|&t| now >= t + self.delay)
}
fn tick(&mut self, ground: &mut Ground, now: u64) {
let chunks = ground.take_chunks();
self.most = self.most.max(chunks.len());
for c in chunks {
self.asked.borrow_mut().entry(c).or_insert(now);
}
let cols = ground
.take_wanted()
.into_iter()
.map(|(x, z)| {
let y = match self.loaded((x >> 4, z >> 4), now) {
Some(true) => self.height,
Some(false) => PENDING,
None => -64,
};
(x, z, y)
})
.collect();
ground.answer(cols, now);
let secs = ground.world.take_wanted(now);
let view = Seen { lazy: self, now };
ground.world.serve(&view, secs, now);
}
}
struct Seen<'a> {
lazy: &'a Lazy,
now: u64,
}
impl world::WorldSource for Seen<'_> {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
self.lazy.loaded((x >> 4, z >> 4), self.now).filter(|&l| l).map(|_| y < self.lazy.height)
}
}
#[test]
fn a_column_pending_for_chunk_load_waits_then_finds_a_site() {
let (mut ground, mut out) = (Ground::default(), Out::default());
let mut lazy = Lazy { height: 64, delay: 50, asked: Default::default(), most: 0 };
let mut sim = Sim::new(21);
sim.order_raid(&order(Approach::TwoSectors, 1500.0), &[DVec3::new(0.0, 70.0, 0.0)], &mut out);
let mut n = 0;
while sim.planning() {
lazy.tick(&mut ground, sim.tick);
sim.step(&mut ground, None, &mut out);
n += 1;
assert!(n < SEARCH_TICKS + 5, "never settled");
}
assert!(n > 50, "settled in {n} ticks, before any chunk could be there");
assert!(sim.sites.list.iter().all(|s| !s.offmap), "{:?} {:?}", sim.sites.list, out.messages);
assert!(!sim.sites.list.is_empty());
assert!(lazy.most <= 48 && lazy.most > 0, "{} chunks wanted at once", lazy.most);
assert!(out.messages.iter().any(|m| m.contains("готовит")), "{:?}", out.messages);
assert!(!out.messages.iter().any(|m| m.contains("отмен") || m.contains("за пределами")), "{:?}", out.messages);
// Far ones stand where the raid was ordered (1500 out), not nearer.
assert!(sim.sites.list.iter().any(|s| s.range > 1400.0), "{:?}", sim.sites.list.iter().map(|s| s.range).collect::<Vec<_>>());
}
#[test]
fn a_game_that_never_loads_gets_the_same_launch_off_the_map() {
// The game answers "no chunk" (-64) for what was not asked about, and never loads what was (a server).
let (mut ground, mut out) = (Ground::default(), Out::default());
let mut sim = Sim::new(5);
sim.order_raid(&order(Approach::Sector, 2500.0), &[DVec3::new(0.0, 70.0, 0.0)], &mut out);
let mut n = 0;
while sim.planning() {
let _ = ground.take_chunks();
let cols = ground.take_wanted().into_iter().map(|(x, z)| (x, z, -64)).collect();
ground.answer(cols, sim.tick);
sim.step(&mut ground, None, &mut out);
n += 1;
assert!(n < SEARCH_TICKS + 5);
}
assert!(sim.sites.list.iter().all(|s| s.offmap));
assert_eq!(sim.queue.len() + sim.launched as usize, 14);
// Where they start: at the ordered range, not at the player.
assert!(sim.queue.iter().all(|q| horizontal(q.1.pos - DVec3::new(0.0, 70.0, 0.0)).length() > 2400.0));
}
#[test]
fn a_raid_ordered_4000_out_reaches_its_target() {
let (land, mut ground, mut out) = (flat(), Ground::default(), Out::default());
let mut sim = Sim::new(9);
let o = RaidOrder { kinds: vec![(DroneKind::Shahed, 2), (DroneKind::Kh101, 1), (DroneKind::Iskander, 1)], approach: Approach::Sector, dist: 4000.0, aim: Aim::Objects };
let target = DVec3::new(0.0, 70.0, 0.0);
sim.order_raid(&o, &[target], &mut out);
let mut closest = [f64::MAX; 4];
for _ in 0..16_000 {
step(&mut sim, &mut ground, &land, &mut out);
for d in sim.drones.iter().filter(|d| d.alive()) {
let k = match d.kind {
DroneKind::Shahed => 0,
DroneKind::Kh101 => 1,
_ => 2,
};
closest[k] = closest[k].min(horizontal(d.pos - target).length());
}
}
assert_eq!(sim.launched, 4, "{:?}", out.messages);
assert!(closest.iter().take(3).all(|&c| c < 120.0), "closest approaches {closest:?}");
assert!(sim.drones.iter().all(|d| !d.alive()), "still flying");
}
/// 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);
File diff suppressed because it is too large Load Diff
+27 -8
View File
@@ -10,7 +10,7 @@
use super::*;
/// Land heights are sampled at the centres of cells this wide.
const LATTICE: i32 = 8;
pub(super) const LATTICE: i32 = 8;
/// Land questions waiting at once: flights ask for more than a tick can send, and wait.
const ASK_MAX: usize = 192;
@@ -57,7 +57,7 @@ impl Ground {
if self.due(when) && self.wanted.len() < ASK_MAX / 2 && self.asked.insert(key) {
self.wanted.push(key);
}
if y > -64 { Rise::At(f64::from(y)) } else { Rise::Void }
land_height(y).map_or(Rise::Void, Rise::At)
}
None => {
if self.wanted.len() < ASK_MAX && self.asked.insert(key) {
@@ -76,10 +76,12 @@ impl Ground {
if self.due(when) && self.wanted.len() < cap / 2 && self.asked.insert((x, z)) {
self.wanted.push((x, z));
}
Some((y > -64).then_some(f64::from(y)))
Some(land_height(y))
}
None => {
if self.wanted.len() < cap && self.asked.insert((x, z)) {
// A chunk the game is loading is asked about again in a few ticks, not every one.
let loading = self.loading.get(&(x, z)).is_some_and(|&t| self.now.saturating_sub(t) < LOAD_RECHECK);
if !loading && self.wanted.len() < cap && self.asked.insert((x, z)) {
self.wanted.push((x, z));
}
None
@@ -166,6 +168,9 @@ pub(super) fn clamp_pitch(v: DVec3, lo: f64, hi: f64, level: DVec3) -> DVec3 {
horizontal(v).normalize_or(level) * pitch.cos() + DVec3::Y * pitch.sin()
}
/// Chunks a tick the flights' look-ahead may ask the game to load (the rest of the budget is the launch-site search's).
const CHUNK_BUDGET: usize = 46;
/// Distances ahead where the land is sampled.
const REACH: [f64; 21] = [
0.0, 8.0, 16.0, 24.0, 32.0, 44.0, 56.0, 72.0, 90.0, 112.0, 136.0, 164.0, 196.0, 232.0, 272.0, 316.0, 366.0, 424.0, 500.0, 600.0,
@@ -175,8 +180,10 @@ const REACH: [f64; 21] = [
/// The height a flight must be at *now* to clear the land along each of `lines` (a unit horizontal direction and
/// how far out to look) by `clear`, given what it can climb: the greatest over the samples of the land's height plus clearance
/// less the height it can gain on the way. `None` when nothing is known.
pub(super) fn required_height(ground: &mut Ground, pos: DVec3, lines: &[(DVec3, f64)], clear: f64, climb: &Climb) -> Option<f64> {
pub(super) fn required_height(ground: &mut Ground, pos: DVec3, lines: &[(DVec3, f64)], clear: f64, climb: &Climb) -> (Option<f64>, bool) {
let mut need: Option<f64> = None;
// Land nobody has told of within the first half of the look-ahead: the flight is blind there.
let mut blind = false;
for &(dir, len) in lines {
for &s in REACH.iter().chain(&[900.0]) {
if s > len {
@@ -185,10 +192,20 @@ pub(super) fn required_height(ground: &mut Ground, pos: DVec3, lines: &[(DVec3,
if let Some(g) = ground.land(pos + dir * s) {
let h = g + clear - climb.gain(s);
need = Some(need.map_or(h, |n: f64| n.max(h)));
} else {
if s <= len * 0.5 {
blind = true;
}
// Land the game has not told of ahead: its chunk is asked for (it loads in a couple of seconds, a flight
// is that far from it), so the pull-up starts from the first known column and not at the ridge.
if ground.chunks_wanted() < CHUNK_BUDGET {
let p = pos + dir * s;
ground.want_chunk(((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4));
}
}
}
}
need
(need, blind)
}
/// The least height to start from, over here, so that the straight line down to `target` stays `margin` over the
@@ -337,7 +354,7 @@ pub(super) const VERDICT_AGE: u64 = 20;
/// Which way an interceptor steers to keep clear of the land: `aim` is where it would go; the result is a direction
/// (unit) raised as far as its loft allows, and whether the land is in the way beyond that.
pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f64, aim: DVec3) -> (DVec3, bool) {
pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f64, aim: DVec3, now: u64) -> (DVec3, bool) {
let spec = kind.spec();
let want = (aim - pos).normalize_or(DVec3::Y);
let flat = horizontal(want);
@@ -355,10 +372,12 @@ pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f
// No way over, and the land close enough ahead that the next ticks will end in it: blow up before.
let mut fail = false;
if slope > cap + 0.02 && at < (speed * 6.0).max(40.0) {
// The heights are a coarse upper bound (the canopy of a forest, the roof of a building): it blows up only before
// blocks that are really there, not before what the height map says is higher than the missile.
fail = (1..=4).any(|k| {
let p = pos + raised * speed * f64::from(k);
ground.land(p).is_some_and(|g| g + 2.0 > p.y)
});
}) && matches!(ground.world.probe(pos, pos + raised * speed * 4.0, now), world::Sweep::Hit { .. });
}
(raised, fail)
}
+383
View File
@@ -0,0 +1,383 @@
//! Flights and the land. Strike drones follow the terrain with a look-ahead worth several seconds of flight
//! (what they can still climb is a question of their speed and turn rate), dive only down a clear line, and
//! interceptors keep a safety height over the land ahead, loft over a ridge when the line is blocked, and
//! blow up high when there is no way over. The launch predicates (`why`) ask the same questions of a
//! shot before it is fired, so a system doesn't spend missiles on targets behind a hill.
//!
//! The land comes from `Ground::land`: heights sampled on a coarse lattice, so many flights share columns and
//! the questions to the game stay few; what the heights miss (trees, buildings, overhangs) is caught by a
//! probe through the block cache.
use super::*;
/// Land heights are sampled at the centres of cells this wide.
pub(super) const LATTICE: i32 = 8;
/// Land questions waiting at once: flights ask for more than a tick can send, and wait.
const ASK_MAX: usize = 192;
/// What the game has said of the land at a place.
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) enum Rise {
/// Not yet: asked.
Pending,
/// There is no land to tell of (no chunk).
Void,
/// One above the highest solid block.
At(f64),
}
impl Ground {
/// One above the highest solid block at the lattice cell holding `p`, if known; asked for when it isn't.
/// Like `at` it asks again for a column that is old or was reported changed (#64), and it leaves room for the questions that matter more.
pub fn land(&mut self, p: DVec3) -> Option<f64> {
match self.rise(p) {
Rise::At(g) => Some(g),
_ => None,
}
}
/// For a planner: None while the game has not answered, Some(None) where it has no land to
/// tell of, else Some(Some(height)).
pub fn land_state(&mut self, p: DVec3) -> Option<Option<f64>> {
match self.rise(p) {
Rise::Pending => None,
Rise::Void => Some(None),
Rise::At(g) => Some(Some(g)),
}
}
/// The same, saying whether the game has not answered yet or has no land there to tell of.
pub(super) fn rise(&mut self, p: DVec3) -> Rise {
let key = (
(p.x.floor() as i32).div_euclid(LATTICE) * LATTICE + LATTICE / 2,
(p.z.floor() as i32).div_euclid(LATTICE) * LATTICE + LATTICE / 2,
);
match self.known.get(&key) {
Some(&(y, when)) => {
// Old or reported changed: still used, asked again with the questions to spare.
if self.due(when) && self.wanted.len() < ASK_MAX / 2 && self.asked.insert(key) {
self.wanted.push(key);
}
if y > -64 { Rise::At(f64::from(y)) } else { Rise::Void }
}
None => {
if self.wanted.len() < ASK_MAX && self.asked.insert(key) {
self.wanted.push(key);
}
Rise::Pending
}
}
}
/// The land's height at a column for the raid planner: `None` while the game hasn't answered, `Some(None)`
/// where it has no land to tell of. Asks, up to `cap` questions waiting.
pub(super) fn column(&mut self, x: i32, z: i32, cap: usize) -> Option<Option<f64>> {
match self.known.get(&(x, z)) {
Some(&(y, when)) => {
if self.due(when) && self.wanted.len() < cap / 2 && self.asked.insert((x, z)) {
self.wanted.push((x, z));
}
Some((y > -64).then_some(f64::from(y)))
}
None => {
// A chunk the game is loading is asked about again in a few ticks, not every one.
let loading = self.loading.get(&(x, z)).is_some_and(|&t| self.now.saturating_sub(t) < LOAD_RECHECK);
if !loading && self.wanted.len() < cap && self.asked.insert((x, z)) {
self.wanted.push((x, z));
}
None
}
}
}
}
/// What a flight can do about a rise ahead.
#[derive(Clone, Copy, Debug)]
pub(super) struct Climb {
/// Radius of its tightest pull-up (blocks).
pub radius: f64,
/// The steepest climb it holds (radians).
pub pitch: f64,
}
impl Climb {
/// How much height it can have gained by the time it has gone `s` blocks on, starting level.
pub fn gain(&self, s: f64) -> f64 {
let arc = self.radius * self.pitch.sin();
if s <= arc {
self.radius - (self.radius * self.radius - s * s).max(0.0).sqrt()
} else {
self.radius * (1.0 - self.pitch.cos()) + (s - arc) * self.pitch.tan()
}
}
}
impl DroneKind {
/// The steepest climb it holds in cruise (radians): cruise missiles are the shallowest.
pub(super) fn climb_pitch(self) -> f64 {
match self {
DroneKind::Kh101 | DroneKind::Kalibr => 0.45,
DroneKind::Lancet => 0.7,
DroneKind::Geran3 | DroneKind::Kh22 => 0.6,
_ => 0.5,
}
}
pub(super) fn climb(self) -> Climb {
let s = self.spec();
Climb { radius: (s.speed / s.turn.max(0.01) * 1.15).max(12.0), pitch: self.climb_pitch() }
}
/// Blocks it looks ahead: eight seconds of flight, at least a pull-up and a climb over a hill.
pub(super) fn look_len(self) -> f64 {
let s = self.spec();
(s.speed * 160.0).clamp(160.0, 900.0)
}
}
/// A flight's new heading: it turns in yaw towards `heading` and in pitch towards a climb of `slope`, together
/// at most `rate` radians a tick, so a turn on to a new course doesn't dip or climb by itself (a great-circle
/// turn between nearly opposite headings can go either way). With `climb` set the pitch comes first and the
/// yaw gets what is left of the rate.
pub(super) fn fly_towards(old: DVec3, heading: DVec3, slope: f64, rate: f64, climb: bool) -> DVec3 {
let (old_yaw, old_pitch) = (old.x.atan2(old.z), old.y.clamp(-1.0, 1.0).asin());
let want_yaw = heading.x.atan2(heading.z);
let mut yaw = angle_diff(old_yaw, want_yaw);
let mut pitch = slope.atan() - old_pitch;
if climb {
pitch = pitch.clamp(-rate, rate);
yaw = yaw.clamp(-(rate * rate - pitch * pitch).max(0.0).sqrt(), (rate * rate - pitch * pitch).max(0.0).sqrt());
} else {
let total = yaw.hypot(pitch);
if total > rate {
yaw *= rate / total;
pitch *= rate / total;
}
}
let (yaw, pitch) = (old_yaw + yaw, old_pitch + pitch);
DVec3::new(yaw.sin() * pitch.cos(), pitch.sin(), yaw.cos() * pitch.cos())
}
/// How far over the land a dive's line passes.
pub(super) const DIVE_MARGIN: f64 = 8.0;
/// `v` with its pitch held between `lo` and `hi` (radians); `level` is the way it faces if it points straight up or down.
pub(super) fn clamp_pitch(v: DVec3, lo: f64, hi: f64, level: DVec3) -> DVec3 {
let pitch = v.y.clamp(-1.0, 1.0).asin();
if (lo..=hi).contains(&pitch) {
return v;
}
let pitch = pitch.clamp(lo, hi);
horizontal(v).normalize_or(level) * pitch.cos() + DVec3::Y * pitch.sin()
}
/// Chunks a tick the flights' look-ahead may ask the game to load (the rest of the budget is the launch-site search's).
const CHUNK_BUDGET: usize = 46;
/// Distances ahead where the land is sampled.
const REACH: [f64; 21] = [
0.0, 8.0, 16.0, 24.0, 32.0, 44.0, 56.0, 72.0, 90.0, 112.0, 136.0, 164.0, 196.0, 232.0, 272.0, 316.0, 366.0, 424.0, 500.0, 600.0,
720.0,
];
/// The height a flight must be at *now* to clear the land along each of `lines` (a unit horizontal direction and
/// how far out to look) by `clear`, given what it can climb: the greatest over the samples of the land's height plus clearance
/// less the height it can gain on the way. `None` when nothing is known.
pub(super) fn required_height(ground: &mut Ground, pos: DVec3, lines: &[(DVec3, f64)], clear: f64, climb: &Climb) -> (Option<f64>, bool) {
let mut need: Option<f64> = None;
// Land nobody has told of within the first half of the look-ahead: the flight is blind there.
let mut blind = false;
for &(dir, len) in lines {
for &s in REACH.iter().chain(&[900.0]) {
if s > len {
break;
}
if let Some(g) = ground.land(pos + dir * s) {
let h = g + clear - climb.gain(s);
need = Some(need.map_or(h, |n: f64| n.max(h)));
} else {
if s <= len * 0.5 {
blind = true;
}
// Land the game has not told of ahead: its chunk is asked for (it loads in a couple of seconds, a flight
// is that far from it), so the pull-up starts from the first known column and not at the ridge.
if ground.chunks_wanted() < CHUNK_BUDGET {
let p = pos + dir * s;
ground.want_chunk(((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4));
}
}
}
}
(need, blind)
}
/// The least height to start from, over here, so that the straight line down to `target` stays `margin` over the
/// land (the last stretch before the aim is the target's own business). `None` when the land isn't known.
pub(super) fn dive_height(ground: &mut Ground, pos: DVec3, target: DVec3, margin: f64) -> Option<f64> {
let flat = horizontal(target - pos);
let total = flat.length();
if total < 20.0 {
return None;
}
let dir = flat / total;
let mut need: Option<f64> = None;
let n = ((total - 14.0) / 12.0).clamp(1.0, 40.0) as i32;
for k in 0..=n {
let s = (total - 14.0) * f64::from(k) / f64::from(n);
if let Some(g) = ground.land(pos + dir * s) {
let f = s / total;
let y = (g + margin - target.y * f) / (1.0 - f);
need = Some(need.map_or(y, |m: f64| m.max(y)));
}
}
need
}
/// The slope (rise a block) a flight at `from` must hold to clear the land by `margin` on its way towards
/// `to`, with a pull-up of `radius` for the climb to begin early, ignoring the last `skip` blocks before `to`.
/// Returns the slope and how far away the land that asks for it is; `None` when nothing is in the way or known.
pub(super) fn rope(ground: &mut Ground, from: DVec3, to: DVec3, margin: f64, radius: f64, (near, reach, skip): (f64, f64, f64), pending: &mut bool) -> Option<(f64, f64)> {
let flat = horizontal(to - from);
let total = flat.length();
if total < 1.0 {
return None;
}
let dir = flat / total;
let end = (total - skip).min(reach);
let mut best: Option<(f64, f64)> = None;
let mut s = near;
while s <= end {
match ground.rise(from + dir * s) {
Rise::At(g) => {
let slope = (g + margin - from.y) / (s - radius * 0.6).max(6.0);
if best.is_none_or(|b| slope > b.0) {
best = Some((slope, s));
}
}
Rise::Pending => *pending = true,
Rise::Void => {}
}
s += if s < 64.0 { 8.0 } else { 16.0 };
}
best
}
// ---------------------------------------------------------------- interceptors
impl MissileKind {
/// The steepest climb it flies to get over land (radians): long-range missiles loft, short-range ones don't.
pub(super) fn loft(self) -> f64 {
match self {
MissileKind::M77n6 => 1.25,
MissileKind::M48n6 => 1.1,
MissileKind::Pac3 | MissileKind::IrisT | MissileKind::Amraam => 0.9,
MissileKind::M9m317 => 0.8,
MissileKind::P1sun => 0.5,
MissileKind::M57e6 | MissileKind::M9m338 => 0.3,
}
}
/// Whether it lofts over a ridge to a target behind it.
pub(super) fn lofts(self) -> bool {
self.loft() >= 0.5
}
}
/// How high above the land an interceptor keeps.
pub(super) const MISSILE_MARGIN: f64 = 8.0;
/// What keeps an interceptor from being launched at a target for the land in the way.
pub(super) const MASKED: u8 = 1;
pub(super) const PATH_BLOCKED: u8 = 2;
/// The land under the line of fire has been asked for and not answered: the shot waits a tick or two.
pub(super) const WAIT: u8 = 4;
/// Ticks a shot waits for the land to be answered before it is judged on what is known.
pub(super) const WAIT_TICKS: u64 = 30;
/// Whether a shot from `muzzle` (launcher at `antenna`) at a target forecast at `est` moving at `vel` may be
/// fired for the land: the intercept point must not be behind a hill from the launcher, unless the missile
/// lofts, and the path the missile would fly must be clear.
pub(super) fn launch_blocks(ground: &mut Ground, kind: MissileKind, muzzle: DVec3, antenna: DVec3, est: DVec3, vel: DVec3) -> u8 {
let spec = kind.spec();
// Where it meets the target: the forecast carried on for the flight time.
let mut at = est;
for _ in 0..2 {
let tgo = muzzle.distance(at) / (spec.speed * 0.8);
at = est + vel * tgo;
}
let flat = horizontal(at - antenna);
let total = flat.length();
if total < 40.0 {
return 0;
}
let dir = flat / total;
// The line of sight from the launcher: any rise of the land over the line to the intercept point masks it.
let mut masked = false;
let mut waiting = false;
let mut s = 12.0;
while s < total - 12.0 {
match ground.rise(antenna + dir * s) {
Rise::At(g) if g + 1.0 > antenna.y + (at.y - antenna.y) * s / total => {
masked = true;
break;
}
Rise::Pending => waiting = true,
_ => {}
}
s += 16.0;
}
if !masked && waiting {
return WAIT;
}
if !masked {
return 0;
}
if !kind.lofts() {
return MASKED;
}
// A lofted path: climbs at the slope the highest land in the way asks for, and comes down on the target.
let radius = spec.speed / spec.turn.max(0.05);
let mut pending = false;
let path = rope(ground, muzzle, at, MISSILE_MARGIN, radius, (32.0, total, 24.0), &mut pending);
match path {
Some((slope, _)) if slope > kind.loft().tan() => PATH_BLOCKED,
_ if pending => WAIT,
_ => 0,
}
}
impl Sam {
/// What the land said of a shot at `target` when it was last judged, if lately.
pub(super) fn terrain_verdict(&self, target: u32, now: u64) -> Option<u8> {
self.terrain.get(&target).filter(|(when, _)| now.saturating_sub(*when) <= VERDICT_AGE).map(|(_, bits)| *bits)
}
}
/// Ticks a launch verdict (the land in the way) is kept before the shot is judged again.
pub(super) const VERDICT_AGE: u64 = 20;
/// Which way an interceptor steers to keep clear of the land: `aim` is where it would go; the result is a direction
/// (unit) raised as far as its loft allows, and whether the land is in the way beyond that.
pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f64, aim: DVec3, now: u64) -> (DVec3, bool) {
let spec = kind.spec();
let want = (aim - pos).normalize_or(DVec3::Y);
let flat = horizontal(want);
let flat_len = flat.length();
let reach = (speed * 18.0).clamp(60.0, 320.0);
let radius = speed / spec.turn.max(0.05);
let Some((slope, at)) = rope(ground, pos, aim, MISSILE_MARGIN, radius, (8.0, reach, 20.0), &mut false) else { return (want, false) };
let wanted = if flat_len > 1e-6 { want.y / flat_len } else { f64::INFINITY };
if slope <= wanted {
return (want, false);
}
let cap = kind.loft().tan();
let dir = flat.normalize_or(DVec3::X);
let raised = (dir + DVec3::Y * slope.min(cap)).normalize();
// No way over, and the land close enough ahead that the next ticks will end in it: blow up before.
let mut fail = false;
if slope > cap + 0.02 && at < (speed * 6.0).max(40.0) {
// The heights are a coarse upper bound (the canopy of a forest, the roof of a building): it blows up only before
// blocks that are really there, not before what the height map says is higher than the missile.
fail = (1..=4).any(|k| {
let p = pos + raised * speed * f64::from(k);
ground.land(p).is_some_and(|g| g + 2.0 > p.y)
}) && matches!(ground.world.probe(pos, pos + raised * speed * 4.0, now), world::Sweep::Hit { .. });
}
(raised, fail)
}
+21 -21
View File
@@ -192,7 +192,7 @@ impl SamKind {
const S: SamSpec = SamSpec {
name: "Patriot",
key: "patriot",
missile: Some(MissileLoad { kind: MissileKind::Pac3, magazine: 4, min: 10.0, max: 300.0, interval: 16 }),
missile: Some(MissileLoad { kind: MissileKind::Pac3, magazine: 4, min: 10.0, max: 450.0, interval: 16 }),
gun: None,
reload: 220,
scale: 1.3,
@@ -208,7 +208,7 @@ impl SamKind {
mount: (0.69, 1.2),
pivot: DVec3::new(0.0, -0.5, 1.375),
muzzle: Muzzle::Patriot,
sensor: 390.0,
sensor: 585.0,
mast: 5.0,
};
&S
@@ -218,7 +218,7 @@ impl SamKind {
name: "Гепард",
key: "gepard",
missile: None,
gun: Some(GunSpec { range: 90.0, track: 130.0, p: 0.3, bursts: 160, shell: 10.0, slew: (0.22, 0.16) }),
gun: Some(GunSpec { range: 135.0, track: 195.0, p: 0.3, bursts: 160, shell: 10.0, slew: (0.22, 0.16) }),
reload: 160,
scale: 1.25,
rest_pitch: 0.0,
@@ -234,7 +234,7 @@ impl SamKind {
mount: (1.55, 0.25),
pivot: DVec3::ZERO,
muzzle: Muzzle::None,
sensor: 170.0,
sensor: 255.0,
mast: 3.0,
};
&S
@@ -243,8 +243,8 @@ impl SamKind {
const S: SamSpec = SamSpec {
name: "Панцирь-С1",
key: "pantsir",
missile: Some(MissileLoad { kind: MissileKind::M57e6, magazine: 12, min: 25.0, max: 150.0, interval: 14 }),
gun: Some(GunSpec { range: 60.0, track: 90.0, p: 0.25, bursts: 140, shell: 9.0, slew: (0.25, 0.18) }),
missile: Some(MissileLoad { kind: MissileKind::M57e6, magazine: 12, min: 25.0, max: 225.0, interval: 14 }),
gun: Some(GunSpec { range: 90.0, track: 135.0, p: 0.25, bursts: 140, shell: 9.0, slew: (0.25, 0.18) }),
reload: 280,
scale: 1.3,
rest_pitch: 0.0,
@@ -260,7 +260,7 @@ impl SamKind {
mount: (1.35, 0.69),
pivot: DVec3::ZERO,
muzzle: Muzzle::Pantsir,
sensor: 195.0,
sensor: 292.0,
mast: 4.0,
};
&S
@@ -269,7 +269,7 @@ impl SamKind {
const S: SamSpec = SamSpec {
name: "С-400",
key: "s400",
missile: Some(MissileLoad { kind: MissileKind::M48n6, magazine: 4, min: 30.0, max: 480.0, interval: 20 }),
missile: Some(MissileLoad { kind: MissileKind::M48n6, magazine: 4, min: 30.0, max: 720.0, interval: 20 }),
gun: None,
reload: 420,
scale: 1.5,
@@ -286,7 +286,7 @@ impl SamKind {
mount: (0.7, 1.45),
pivot: TUBE_PIVOT,
muzzle: Muzzle::Grid(2, 2, 7.0),
sensor: 624.0,
sensor: 936.0,
mast: 5.0,
};
&S
@@ -295,7 +295,7 @@ impl SamKind {
const S: SamSpec = SamSpec {
name: "С-500",
key: "s500",
missile: Some(MissileLoad { kind: MissileKind::M77n6, magazine: 4, min: 40.0, max: 700.0, interval: 20 }),
missile: Some(MissileLoad { kind: MissileKind::M77n6, magazine: 4, min: 40.0, max: 1050.0, interval: 20 }),
gun: None,
reload: 520,
scale: 1.6,
@@ -312,7 +312,7 @@ impl SamKind {
mount: (0.7, 1.45),
pivot: TUBE_PIVOT,
muzzle: Muzzle::Grid(2, 1, 9.5),
sensor: 910.0,
sensor: 1365.0,
mast: 5.0,
};
&S
@@ -321,7 +321,7 @@ impl SamKind {
const S: SamSpec = SamSpec {
name: "IRIS-T SLM",
key: "irist",
missile: Some(MissileLoad { kind: MissileKind::IrisT, magazine: 8, min: 15.0, max: 400.0, interval: 10 }),
missile: Some(MissileLoad { kind: MissileKind::IrisT, magazine: 8, min: 15.0, max: 600.0, interval: 10 }),
gun: None,
reload: 300,
scale: 1.4,
@@ -338,7 +338,7 @@ impl SamKind {
mount: (0.7, 1.45),
pivot: TUBE_PIVOT,
muzzle: Muzzle::Grid(4, 2, 4.0),
sensor: 520.0,
sensor: 780.0,
mast: 5.0,
};
&S
@@ -347,7 +347,7 @@ impl SamKind {
const S: SamSpec = SamSpec {
name: "NASAMS",
key: "nasams",
missile: Some(MissileLoad { kind: MissileKind::Amraam, magazine: 6, min: 10.0, max: 300.0, interval: 14 }),
missile: Some(MissileLoad { kind: MissileKind::Amraam, magazine: 6, min: 10.0, max: 450.0, interval: 14 }),
gun: None,
reload: 260,
scale: 1.3,
@@ -364,7 +364,7 @@ impl SamKind {
mount: (0.62, 0.4),
pivot: TUBE_PIVOT,
muzzle: Muzzle::Grid(3, 2, 4.0),
sensor: 390.0,
sensor: 585.0,
mast: 5.0,
};
&S
@@ -373,7 +373,7 @@ impl SamKind {
const S: SamSpec = SamSpec {
name: "Бук-М2",
key: "buk",
missile: Some(MissileLoad { kind: MissileKind::M9m317, magazine: 4, min: 20.0, max: 450.0, interval: 18 }),
missile: Some(MissileLoad { kind: MissileKind::M9m317, magazine: 4, min: 20.0, max: 675.0, interval: 18 }),
gun: None,
reload: 400,
scale: 1.4,
@@ -389,7 +389,7 @@ impl SamKind {
mount: (1.55, 0.2),
pivot: DVec3::ZERO,
muzzle: Muzzle::Rails(&[-9.0, -3.0, 13.0, 19.0]),
sensor: 585.0,
sensor: 878.0,
mast: 4.0,
};
&S
@@ -398,7 +398,7 @@ impl SamKind {
const S: SamSpec = SamSpec {
name: "Тор-М2",
key: "tor",
missile: Some(MissileLoad { kind: MissileKind::M9m338, magazine: 8, min: 8.0, max: 160.0, interval: 8 }),
missile: Some(MissileLoad { kind: MissileKind::M9m338, magazine: 8, min: 8.0, max: 240.0, interval: 8 }),
gun: None,
reload: 300,
scale: 1.3,
@@ -409,7 +409,7 @@ impl SamKind {
mount: (1.5, 0.0),
pivot: DVec3::ZERO,
muzzle: Muzzle::Cells,
sensor: 210.0,
sensor: 315.0,
mast: 3.5,
};
&S
@@ -418,7 +418,7 @@ impl SamKind {
const S: SamSpec = SamSpec {
name: "P1-SUN",
key: "p1sun",
missile: Some(MissileLoad { kind: MissileKind::P1sun, magazine: 8, min: 5.0, max: 450.0, interval: 12 }),
missile: Some(MissileLoad { kind: MissileKind::P1sun, magazine: 8, min: 5.0, max: 675.0, interval: 12 }),
gun: None,
reload: 240,
scale: 1.1,
@@ -454,7 +454,7 @@ impl SamKind {
mount: (0.0, 0.0),
pivot: DVec3::ZERO,
muzzle: Muzzle::None,
sensor: 1700.0,
sensor: 2550.0,
mast: 16.0,
};
&S
+360 -25
View File
@@ -45,15 +45,125 @@ pub(super) fn claimed(d: &Drone, missile_on: &HashMap<u32, u32>, had_target: Opt
d.engaged + on
}
/// Ballistic missiles get a pair; anything else one.
/// Ticks until the target reaches its aim at its present speed.
pub(super) fn arrival(d: &Drone) -> f64 {
d.pos.distance(d.target) / d.vel.length().max(0.1)
}
/// Shoot-look-shoot that grows with urgency: ballistic missiles get a pair, anything else one; a target
/// this close to its aim (ticks) has one more interceptor allowed on it at once, closer still another, and in the last seconds a fourth.
pub(super) const URGENT: f64 = 240.0;
pub(super) const DESPERATE: f64 = 110.0;
pub(super) const FINAL: f64 = 60.0;
pub(super) fn needed(d: &Drone) -> u32 {
if d.kind.spec().class == Class::Ballistic { 2 } else { 1 }
let base = if d.kind.spec().class == Class::Ballistic { 2 } else { 1 };
let t = arrival(d);
base + u32::from(t < URGENT) + u32::from(t < DESPERATE) + u32::from(t < FINAL)
}
/// Ticks a launcher may hold a target without launching before it lets go, and how long it then keeps away from it.
pub(super) const HOLD_MAX: u32 = 24;
pub(super) const SHY_TICKS: u64 = 120;
// ---------------------------------------------------------------- the grounds to launch on
/// Last chance (`/airdef lastchance`): a target of a class the system does not engage that is about to
/// hit, with nobody's interceptor on it, is shot at anyway; a system told to hold fire (no roles) keeps to it.
pub(super) const LAST_CHANCE_TICKS: f64 = 200.0;
pub(super) fn last_chance(on: bool, roles: u8, d: &Drone, claimed: u32) -> bool {
on && roles != 0 && roles & d.kind.spec().class.bit() == 0 && claimed == 0 && arrival(d) < LAST_CHANCE_TICKS
}
/// The big radar's cue (heavy systems only): they may fire out to `FAR_K` × max at a target the radar holds.
pub(super) const FAR_K: f64 = 1.6;
/// Ticks between one system's long shots; the share of the magazine it keeps for them; the radar track's age.
pub(super) const FAR_COOLDOWN: u32 = 300;
pub(super) const FAR_RADAR_AGE: u64 = 150;
impl SamKind {
/// Whether the kind takes long shots on a big radar's data.
pub(super) fn far_shooter(self) -> bool {
matches!(self, SamKind::S400 | SamKind::S500 | SamKind::Patriot)
}
}
/// A live big radar in data-link reach of `sam` that measured `id` recently.
pub(super) fn radar_cue(sam: &Sam, radars: &[&Sam], id: u32, now: u64) -> bool {
radars.iter().any(|r| {
r.alive()
&& !r.moving()
&& r.radar_up()
&& r.pos.distance(sam.pos) <= r.link_range().min(sam.link_range())
&& r.track(id).is_some_and(|t| t.own_tick > 0 && t.age(now) <= FAR_RADAR_AGE)
})
}
/// The ground beyond `max`: a long shot is allowed (the system, its launcher and magazine, the flag, a cue).
pub(super) fn far_blocks(on: bool, sam: &Sam, load: &units::MissileLoad, cued: bool, in_reach_of_any: bool) -> u8 {
(if on && sam.kind.far_shooter() { 0 } else { FAR_OFF })
| (if cued { 0 } else { FAR_NO_CUE })
| (if sam.far_cooldown == 0 { 0 } else { FAR_WAIT })
| (if sam.missiles * 2 >= load.magazine { 0 } else { FAR_LOW })
| (if in_reach_of_any { FAR_BUSY } else { 0 })
}
pub(super) const FAR_OFF: u8 = 1;
pub(super) const FAR_NO_CUE: u8 = 2;
pub(super) const FAR_WAIT: u8 = 4;
pub(super) const FAR_LOW: u8 = 8;
pub(super) const FAR_BUSY: u8 = 16;
/// Why a system may launch at a target: its role, the last chance, or a long shot on the radar's cue.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum Basis {
Role,
LastChance,
FarShot,
}
/// What is switched on beyond the roles (the sim's settings).
#[derive(Clone, Copy)]
pub(super) struct Opts {
pub last_chance: bool,
pub far_shots: bool,
}
/// Whether, and on what basis, `sam` may launch at `d` on `track`: `k` stretches the reach (a held target
/// may stray); `claimed` is the interceptors others have on it; `cued` that a big radar holds it; and
/// `role_in_reach` that the system has a target of its roles in range anyway (no long shots then).
#[allow(clippy::too_many_arguments)]
pub(super) fn basis(sam: &Sam, load: &units::MissileLoad, track: &Track, d: &Drone, now: u64, claimed: u32, k: f64, opts: Opts, cued: bool, role_in_reach: bool) -> Option<Basis> {
let blocks = target_blocks(sam.roles, Some(track), now, d);
if blocks & !ROLE != 0 {
return None;
}
let est = track.est(now);
let reach = reach_blocks(load.min, load.max, k, sam.pos, est);
if blocks & ROLE == 0 {
// The doctrine (sector, protected objects, reserve) rules over the role's shots and the long ones; the last chance is past it.
if sam.doctrine.area_blocks(sam.pos, track, now) | sam.doctrine.reserve_blocks(sam.missiles, d) != 0 {
return None;
}
if reach == 0 {
return Some(Basis::Role);
}
let far = reach == TOO_FAR && reach_blocks(load.min, load.max, FAR_K, sam.pos, est) == 0;
return (far && far_blocks(opts.far_shots, sam, load, cued, role_in_reach) == 0).then_some(Basis::FarShot);
}
(reach == 0 && last_chance(opts.last_chance, sam.roles, d, claimed)).then_some(Basis::LastChance)
}
/// The kill chance of an interceptor that met its target `dist` from the launcher whose reach is `max`:
/// whole up to `max`, falling linearly to half at `FAR_K` × max.
pub(super) fn reach_fit(max: f64, dist: f64) -> f64 {
1.0 - 0.5 * ((dist - max) / (max * (FAR_K - 1.0))).clamp(0.0, 1.0)
}
/// A gun's candidates: measured by the system's own radar a moment ago and within the gun's tracking range
/// (by the track's forecast, not the target's true place).
pub(super) fn gun_candidate(roles: u8, track: &Track, now: u64, gun_track: f64, pos: DVec3, d: &Drone) -> bool {
allowed(roles, track, now, d) && track.own_within(now, sight::GUN_PICK_AGE) && track.est(now).distance(pos) < gun_track
pub(super) fn gun_candidate(roles: u8, doc: &doctrine::Doctrine, track: &Track, now: u64, gun_track: f64, pos: DVec3, d: &Drone) -> bool {
allowed(roles, track, now, d) && doc.area_blocks(pos, track, now) == 0 && track.own_within(now, sight::GUN_PICK_AGE) && track.est(now).distance(pos) < gun_track
}
/// How many systems have each drone as their missile target and as their gun target.
@@ -209,8 +319,12 @@ impl Sim {
let subject = sam.target.or(sam.gun_target).and_then(drone_at).or_else(|| {
self.drones
.iter()
.filter(|d| d.alive() && sam.roles & d.kind.spec().class.bit() != 0)
.max_by(|a, b| a.threat(sam.pos).total_cmp(&b.threat(sam.pos)))
.filter(|d| d.alive() && (sam.roles & d.kind.spec().class.bit() != 0 || last_chance(self.last_chance, sam.roles, d, claimed(d, &missile_on, sam.target))))
.max_by(|a, b| {
// What the doctrine lets the system shoot at comes first; then the most threatening.
let area = |d: &Drone| sam.track(d.id).is_none_or(|tr| sam.doctrine.area_blocks(sam.pos, tr, self.tick) == 0);
area(a).cmp(&area(b)).then(a.threat(sam.pos).total_cmp(&b.threat(sam.pos)))
})
});
let Some(t) = subject else {
v.push(check(false, format!("нет целей классов [{}]", roles_text(sam.roles))));
@@ -227,10 +341,44 @@ impl Sim {
// Where the system believes the target is: its track's forecast (the true place only when it has no track).
let est = track.map_or(t.pos, |tr| tr.est(now));
let evel = track.map_or(t.vel, |tr| tr.vel);
v.push(check(
blocks & ROLE == 0,
format!("роль: {} {} [{}]", t.kind.spec().class.name(), if blocks & ROLE == 0 { "в ролях" } else { "не в ролях" }, roles_text(sam.roles)),
));
let first_claim = claimed(t, &missile_on, sam.target);
let chance = blocks & ROLE != 0 && last_chance(self.last_chance, sam.roles, t, first_claim);
let role_text = if blocks & ROLE == 0 {
format!("роль: {} в ролях [{}]", t.kind.spec().class.name(), roles_text(sam.roles))
} else if chance {
format!(
"последний шанс: {} не в ролях [{}], но до цели {:.0} с, перехватчиков на ней нет",
t.kind.spec().class.name(),
roles_text(sam.roles),
arrival(t) / 20.0
)
} else {
format!("роль: {} не в ролях [{}]", t.kind.spec().class.name(), roles_text(sam.roles))
};
v.push(check(blocks & ROLE == 0 || chance, role_text));
// The doctrine: which of its settings let the shot through or stopped it. The last chance is past them.
let doc = &sam.doctrine;
let area = track.map_or(0, |tr| doc.area_blocks(sam.pos, tr, now));
let held_back = if spec.missile.is_some() { doc.reserve_blocks(sam.missiles, t) } else { 0 };
if !doc.is_default() {
v.push(check(true, format!("доктрина: {}", doc.text(&self.targets))));
}
if area & doctrine::OUT_SECTOR != 0 {
let bearing = heading_of(est - sam.pos).to_degrees().rem_euclid(360.0);
v.push(check(chance, format!("вне сектора {}: цель на {bearing:.0}°{}", doc.sector_text(), if chance { " — последний шанс сильнее" } else { "" })));
} else if doc.sector.is_some() {
v.push(check(true, format!("в секторе {}", doc.sector_text())));
}
if area & doctrine::NO_THREAT != 0 {
v.push(check(chance, format!("не угрожает охраняемым объектам ({}){}", doc.guards.iter().map(|g| doctrine::object_name(&self.targets, *g)).collect::<Vec<_>>().join(", "), if chance { " — последний шанс сильнее" } else { "" })));
} else if let Some(g) = track.and_then(|tr| doc.threatens(tr, now)) {
v.push(check(true, format!("прикрывает {}: цель идёт на него", doctrine::object_name(&self.targets, doc.guards[g]))));
}
if held_back != 0 {
v.push(check(chance, format!("сохраняет резерв {}: ракет {}, цель не срочная{}", doc.reserve, sam.missiles, if chance { " — последний шанс сильнее" } else { "" })));
} else if doc.reserve > 0 && sam.missiles <= doc.reserve {
v.push(check(true, format!("резерв {} открыт: цель срочная (до удара {:.0} с)", doc.reserve, arrival(t) / 20.0)));
}
let detect = match (track, sam.own.contains(&t.id)) {
(Some(tr), own) => {
let kind = if own { "своей РЛС" } else { "по сети (стволы не стреляют)" };
@@ -277,19 +425,33 @@ impl Sim {
let mut fires = false;
if let Some(load) = &spec.missile {
let reach = reach_blocks(load.min, load.max, 1.0, sam.pos, est);
v.push(check(
reach == 0,
format!(
"ракеты: дальность {}–{}, цель на {}{}",
crate::panel::distance(load.min),
crate::panel::distance(load.max),
crate::panel::distance(dist),
if reach & TOO_CLOSE != 0 { " — слишком близко" } else if reach & TOO_FAR != 0 { " — слишком далеко" } else { "" }
),
));
let free = claimed(t, &missile_on, sam.target);
let need = needed(t);
v.push(check(free < need, format!("цель занята перехватчиками: {free} из {need}{}", if free < need { "" } else { " — другие уже на ней" })));
// Beyond the normal reach, a heavy system may fire on a big radar's cue.
let radars: Vec<&Sam> = self.sams.iter().filter(|s| s.kind == SamKind::Radar).collect();
let cued = sam.kind.far_shooter() && radar_cue(sam, &radars, t.id, now);
let role_in_reach = self.drones.iter().any(|o| {
sam.track(o.id).is_some_and(|tr| allowed(sam.roles, tr, now, o) && in_reach(load.min, load.max, 1.0, sam.pos, tr.est(now)) && sam.doctrine.area_blocks(sam.pos, tr, now) | sam.doctrine.reserve_blocks(sam.missiles, o) == 0 && claimed(o, &missile_on, sam.target) < sam.doctrine.needed(o))
});
let far = reach == TOO_FAR && reach_blocks(load.min, load.max, FAR_K, sam.pos, est) == 0;
let far_ok = far && track.is_some_and(|tr| basis(sam, load, tr, t, now, 0, 1.0, Opts { last_chance: self.last_chance, far_shots: self.far_shots }, cued, role_in_reach) == Some(Basis::FarShot));
if far {
let fb = far_blocks(self.far_shots, sam, load, cued, role_in_reach);
let mut no = Vec::new();
for (bit, name) in [
(FAR_OFF, "не для этого комплекса"),
(FAR_NO_CUE, "нет свежего трека большой РЛС"),
(FAR_WAIT, "дальний пуск уже был недавно"),
(FAR_LOW, "меньше половины боезапаса"),
(FAR_BUSY, "есть цель в обычной дальности"),
] {
if fb & bit != 0 {
no.push(name);
}
}
v.push(check(far_ok, if far_ok { format!("дальний пуск по РЛС: цель на {} дальше обычных {}", crate::panel::distance(dist), crate::panel::distance(load.max)) } else { format!("дальний пуск по РЛС не разрешён: {}", no.join(", ")) }));
}
let free = first_claim;
let need = sam.doctrine.needed(t);
v.push(check(free < need, format!("цель занята перехватчиками: {free} из {need} (залп {}){}", doc.salvo.name(), if free < need { "" } else { " — другие уже на ней" })));
let mb = missile_blocks(sam);
let mut why = Vec::new();
if mb & COOLDOWN != 0 {
@@ -320,11 +482,13 @@ impl Sim {
},
));
}
fires |= reach == 0 && free < need && mb == 0 && aligned && blocks == 0 && land == 0;
let doc_ok = chance || area | held_back == 0;
let grounds = blocks & !ROLE == 0 && (blocks & ROLE == 0 || chance) && doc_ok && (reach == 0 || far_ok);
fires |= grounds && free < need && mb == 0 && aligned && land == 0;
}
if let Some(gun) = &spec.gun {
let on_it = guns_on.get(&t.id).copied().unwrap_or(0) - u32::from(sam.gun_target == Some(t.id));
let cand = track.is_some_and(|tr| gun_candidate(sam.roles, tr, now, gun.track, sam.pos, t));
let cand = track.is_some_and(|tr| gun_candidate(sam.roles, &sam.doctrine, tr, now, gun.track, sam.pos, t));
v.push(check(
cand,
format!(
@@ -379,4 +543,175 @@ mod tests {
sim.sams[0].roles = 0xFF;
assert!(sim.why(0).iter().any(|c| c.text.starts_with("роль")));
}
use super::sites::testland::{Land, serve};
fn setup(kind: SamKind) -> (Sim, Ground, Land<fn(i32, i32) -> i32>, Out) {
let mut sim = Sim::new(5);
let id = sim.id();
sim.sams.push(Sam::new(id, kind, DVec3::new(0.0, 70.0, 0.0), 0.0));
(sim, Ground::default(), Land(|_, _| 64), Out::default())
}
/// Steps `ticks` or until something is fired; returns the drone's ticks to its aim at the first launch.
fn until_fired(sim: &mut Sim, ground: &mut Ground, land: &Land<fn(i32, i32) -> i32>, out: &mut Out, ticks: u32) -> Option<f64> {
for _ in 0..ticks {
serve(ground, land, sim.tick);
sim.step(ground, None, out);
if sim.sams[0].fired > 0 {
return sim.drones.first().map(arrival);
}
}
None
}
/// A Shahed `blocks` short of its aim, which is `80` out from the system, coming in along z.
fn shahed_at(sim: &mut Sim, blocks: f64) {
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, -80.0 - blocks), DVec3::Z, DVec3::new(0.0, 70.0, -80.0));
}
#[test]
fn last_chance_fires_at_a_class_out_of_roles_only_when_it_is_about_to_hit() {
// The S-400 does not engage drones by default.
assert_eq!(SamKind::S400.spec().roles & Class::Drone.bit(), 0);
let (mut sim, mut ground, land, mut out) = setup(SamKind::S400);
shahed_at(&mut sim, 60.0);
let at = until_fired(&mut sim, &mut ground, &land, &mut out, 140).expect("the S-400 never fired on a Shahed about to hit");
assert!(at < LAST_CHANCE_TICKS, "fired with {at:.0} ticks to go");
// The diagnostic names why: the last chance.
let (mut sim, mut ground, land, mut out) = setup(SamKind::S400);
shahed_at(&mut sim, 100.0);
for _ in 0..30 {
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
}
let w = sim.why(0);
assert!(w.iter().any(|c| c.ok && c.text.starts_with("последний шанс")), "{:?}", w.iter().map(|c| &c.text).collect::<Vec<_>>());
}
#[test]
fn last_chance_waits_for_a_target_far_from_its_aim() {
let (mut sim, mut ground, land, mut out) = setup(SamKind::S400);
// In plain view 120 blocks off, but flying away from its aim, 480 blocks (800 ticks) ahead.
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, -120.0), DVec3::NEG_Z, DVec3::new(0.0, 70.0, -600.0));
assert!(until_fired(&mut sim, &mut ground, &land, &mut out, 150).is_none(), "fired at a Shahed far from its aim");
let w = sim.why(0);
// Still named as the role block it was: no target of its classes.
assert!(w.last().is_some_and(|c| !c.ok) && w.iter().any(|c| c.text.contains("нет целей классов")), "{:?}", w.iter().map(|c| &c.text).collect::<Vec<_>>());
assert!(!w.iter().any(|c| c.text.starts_with("последний шанс")));
}
#[test]
fn last_chance_is_off_when_switched_off_and_with_no_roles() {
for (off, roles) in [(true, 30u8), (false, 0u8)] {
let (mut sim, mut ground, land, mut out) = setup(SamKind::S400);
sim.last_chance = !off;
sim.sams[0].roles = roles;
shahed_at(&mut sim, 60.0);
assert!(until_fired(&mut sim, &mut ground, &land, &mut out, 140).is_none(), "fired with last chance off={off}, roles {roles}");
}
}
#[test]
fn last_chance_leaves_a_target_that_already_has_an_interceptor_on_it() {
let (mut sim, mut ground, land, mut out) = setup(SamKind::S400);
shahed_at(&mut sim, 60.0);
// Another system's interceptor is on it.
let id = sim.drones[0].id;
sim.drones[0].engaged = 1;
assert!(until_fired(&mut sim, &mut ground, &land, &mut out, 40).is_none(), "fired at {id} that is claimed");
}
/// A Kh101 `dist` from the origin on the far side, at height, coming in along z; a radar beside the system.
fn far_setup(radar: bool, dist: f64) -> (Sim, Ground, Land<fn(i32, i32) -> i32>, Out) {
let (mut sim, ground, land, out) = setup(SamKind::S400);
if radar {
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(40.0, 70.0, 0.0), 0.0));
}
sim.add_drone(DroneKind::Kh101, DVec3::new(0.0, 300.0, -dist), DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
(sim, ground, land, out)
}
/// The radar's measurement of every live drone, fresh this tick (as if its beam had just passed).
fn radar_sees(sim: &mut Sim) {
let now = sim.tick;
let tracks: Vec<Track> = sim
.drones
.iter()
.filter(|d| d.alive())
.map(|d| Track { id: d.id, kind: d.kind, pos: d.pos, vel: d.vel, tick: now, own_tick: now, ttl: 400, hits: 3, locked: false })
.collect();
for r in sim.sams.iter_mut().filter(|s| s.kind == SamKind::Radar) {
for t in &tracks {
r.tracks.retain(|o| o.id != t.id);
r.tracks.push(*t);
}
}
}
fn fire_far(sim: &mut Sim, ground: &mut Ground, land: &Land<fn(i32, i32) -> i32>, out: &mut Out, ticks: u32) -> u32 {
for _ in 0..ticks {
radar_sees(sim);
serve(ground, land, sim.tick);
sim.step(ground, None, out);
}
sim.sams[0].fired
}
#[test]
fn heavy_systems_shoot_far_on_a_radars_cue_but_not_without_one() {
let max = SamKind::S400.spec().missile.as_ref().unwrap().max;
let (mut sim, mut ground, land, mut out) = far_setup(true, max * 1.4);
assert!(fire_far(&mut sim, &mut ground, &land, &mut out, 80) >= 1, "no long shot at 1.4 x max with a radar");
let w = sim.why(0);
assert!(w.iter().any(|c| c.text.starts_with("дальний пуск по РЛС")), "{:?}", w.iter().map(|c| &c.text).collect::<Vec<_>>());
// No radar, no shot (the system's own radar may well see it: that is not a cue).
let (mut sim, mut ground, land, mut out) = far_setup(false, max * 1.4);
assert_eq!(fire_far(&mut sim, &mut ground, &land, &mut out, 80), 0, "a long shot with no radar");
// And a kind that is not a heavy one never does.
let (mut sim, mut ground, land, mut out) = far_setup(true, max * 1.4);
sim.far_shots = false;
assert_eq!(fire_far(&mut sim, &mut ground, &land, &mut out, 80), 0, "a long shot with them switched off");
}
#[test]
fn a_long_shot_waits_out_its_cooldown_and_for_nothing_closer() {
let max = SamKind::S400.spec().missile.as_ref().unwrap().max;
// Two far targets: one long shot, the second waits its 300 ticks.
let (mut sim, mut ground, land, mut out) = far_setup(true, max * 1.4);
sim.add_drone(DroneKind::Kh101, DVec3::new(0.0, 300.0, max * 1.4), DVec3::NEG_Z, DVec3::new(0.0, 70.0, 0.0));
assert_eq!(fire_far(&mut sim, &mut ground, &land, &mut out, 70), 1, "not exactly one long shot in the cooldown");
// A closer target in normal reach: the far one is not fired at while it stands.
let (mut sim, mut ground, land, mut out) = far_setup(true, max * 1.4);
let far = sim.drones[0].id;
sim.add_drone(DroneKind::Kh101, DVec3::new(300.0, 120.0, 0.0), DVec3::NEG_X, DVec3::new(0.0, 70.0, 0.0));
for _ in 0..40 {
radar_sees(&mut sim);
serve(&mut ground, &land, sim.tick);
sim.step(&mut ground, None, &mut out);
assert!(!sim.missiles.iter().any(|m| m.target == far), "a long shot while a closer target was in reach");
}
assert!(sim.sams[0].fired > 0, "the closer target was not fired at");
}
#[test]
fn the_kill_chance_falls_to_half_at_the_far_end() {
assert_eq!(reach_fit(100.0, 80.0), 1.0);
assert_eq!(reach_fit(100.0, 100.0), 1.0);
assert!((reach_fit(100.0, 130.0) - 0.75).abs() < 1e-9);
assert!((reach_fit(100.0, 160.0) - 0.5).abs() < 1e-9);
assert!((reach_fit(100.0, 400.0) - 0.5).abs() < 1e-9);
}
#[test]
fn every_interceptor_lives_long_enough_to_fly_its_reach() {
// Life × top speed must cover the far end of a heavy system's reach with room to chase; the rest their plain reach.
for kind in SamKind::ARMED {
let Some(load) = kind.spec().missile.as_ref() else { continue };
let m = load.kind.spec();
let reach = load.max * if kind.far_shooter() { FAR_K } else { 1.0 };
assert!(m.life as f64 * m.speed > reach * 1.2, "{}: {} x {} does not cover {reach}", kind.name(), m.life, m.speed);
}
}
}
+80 -2
View File
@@ -94,6 +94,13 @@ pub struct WorldStats {
/// Sections dropped or re-read for a chunk's unload or replacement, or for age (#62).
pub aged: u64,
pub columns_changed: u64,
/// Blasts asked in the blocks, and what became of them (`Settled`).
pub blasts_asked: u64,
pub blasts_confirmed: u64,
pub blasts_unchanged: u64,
pub blasts_unloaded: u64,
/// Answers dropped for being to a question of another world or dimension.
pub late_dropped: u64,
}
/// What the game's exact ray said of a flight's step, once it answered (#63).
@@ -182,9 +189,37 @@ pub struct WorldCache {
/// Columns (section x, z) where blocks changed since the land heights last looked (#64); `all`: too many to say.
pub(super) changed_cols: HashSet<(i32, i32)>,
pub(super) changed_all: bool,
/// Another world or dimension opened since the land heights last looked: they are all void.
pub(super) epoch_reset: bool,
/// Blasts the mod asked the game to make in the blocks (`Out.commands`), waiting for the game's word that
/// blocks changed there. The game's damage (the simulation's own) and the geometry the game confirms
/// are two things: this is the second (#90). Singleplayer: the client's world is the authority.
expected: Vec<Expected>,
pub stats: WorldStats,
}
/// A blast asked for: the sections it reaches (lowest, highest) and the tick it was asked.
struct Expected {
lo: Sec,
hi: Sec,
at: DVec3,
since: u64,
}
/// Ticks the game has to report a block changed after a blast was asked for (the fuse is 0; commands run 64 a tick).
pub const CONFIRM_TICKS: u64 = 100;
/// What became of a blast asked for in the blocks.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Settled {
/// The game reported blocks changed in the sections it reaches.
Confirmed,
/// The chunk was there and nothing changed: nothing to break, a rule or the server's rights said no.
Unchanged,
/// There was no chunk to change.
Unloaded,
}
fn sec_of(x: i32, y: i32, z: i32) -> Sec {
(x >> 4, y >> 4, z >> 4)
}
@@ -207,7 +242,7 @@ impl WorldCache {
pub fn text(&self) -> String {
let s = &self.stats;
format!(
"мир: {} секций ({} КБ), запросов {}, неизвестно {}, лучей {} (ударов {}, неизв. {}), клеток {}",
"мир: {} секций ({} КБ), запросов {}, неизвестно {}, лучей {} (ударов {}, неизв. {}), клеток {}; взрывы в блоках: {} (подтверждено {}, без перемен {}, нет чанка {}), чужих ответов {}",
self.len(),
self.bytes() / 1024,
s.sections_asked,
@@ -215,7 +250,12 @@ impl WorldCache {
s.sweeps,
s.hits,
s.unknown_sweeps,
s.cells
s.cells,
s.blasts_asked,
s.blasts_confirmed,
s.blasts_unchanged,
s.blasts_unloaded,
s.late_dropped
)
}
@@ -241,6 +281,8 @@ impl WorldCache {
if epoch != self.epoch {
self.rays = RayDesk { next: self.rays.next, ..RayDesk::default() };
if self.epoch != 0 {
self.epoch_reset = true;
self.expected.clear();
self.secs.clear();
self.urgent.clear();
self.ahead.clear();
@@ -264,6 +306,11 @@ impl WorldCache {
let set: HashSet<Sec> = sections.iter().copied().collect();
self.drop_rays(|b| set.contains(&sec_of(b[0], b[1], b[2])));
}
if !self.expected.is_empty() {
let before = self.expected.len();
self.expected.retain(|e| !sections.iter().any(|s| (e.lo.0..=e.hi.0).contains(&s.0) && (e.lo.1..=e.hi.1).contains(&s.1) && (e.lo.2..=e.hi.2).contains(&s.2)));
self.stats.blasts_confirmed += (before - self.expected.len()) as u64;
}
for s in sections {
self.note_column(s.0, s.2);
if let Some(e) = self.secs.get_mut(s) {
@@ -274,6 +321,37 @@ impl WorldCache {
self.stats.world_changes += sections.len() as u64;
}
/// The game's epoch as last told: another world or dimension opening changes it.
pub fn epoch(&self) -> u32 {
self.epoch
}
/// A blast of `radius` blocks was asked of the game at `at`: expect the game to report blocks changed there.
pub fn expect_blast(&mut self, at: DVec3, radius: f64, now: u64) {
let (lo, hi) = (at - DVec3::splat(radius), at + DVec3::splat(radius));
let sec = |p: DVec3| sec_of(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
if self.expected.len() < 256 {
self.expected.push(Expected { lo: sec(lo), hi: sec(hi), at, since: now });
}
self.stats.blasts_asked += 1;
}
/// The blasts that the game did not report in `CONFIRM_TICKS`, taken: where each was.
pub fn overdue_blasts(&mut self, now: u64) -> Vec<DVec3> {
let (late, keep): (Vec<_>, Vec<_>) = std::mem::take(&mut self.expected).into_iter().partition(|e| now.saturating_sub(e.since) >= CONFIRM_TICKS);
self.expected = keep;
late.into_iter().map(|e| e.at).collect()
}
/// What became of a blast that was not reported.
pub fn note_settled(&mut self, how: Settled) {
match how {
Settled::Confirmed => self.stats.blasts_confirmed += 1,
Settled::Unchanged => self.stats.blasts_unchanged += 1,
Settled::Unloaded => self.stats.blasts_unloaded += 1,
}
}
fn note_column(&mut self, sx: i32, sz: i32) {
if self.changed_cols.len() >= 4096 {
self.changed_all = true;
+80 -34
View File
@@ -99,30 +99,48 @@ fn blast_billows(out: &mut Vec<SmokeSegment>, blasts: &[crate::sim::Blast], tick
}
}
fn style_drone(s: &mut SmokeSegment, class: Class) {
let (r0, r1, dens, heat, soot, clear, life): (f32, f32, f32, f32, f32, f32, f32) = match class {
Class::Drone => (0.9, 5.0, 2.0, 0.0, 0.45, 3.0, 1200.0),
Class::Jet | Class::Cruise => (1.1, 7.0, 2.5, 0.15, 0.35, 4.0, 1300.0),
Class::Supersonic => (1.4, 9.0, 3.2, 0.5, 0.2, 5.0, 1300.0),
Class::Ballistic => (2.0, 13.0, 4.0, 0.7, 0.15, 7.0, 1500.0),
};
s.radius_start = r0;
s.radius_end = r1;
s.density = dens;
s.heat = heat;
s.soot = soot;
s.clear = clear;
s.life = life;
/// How one kind of trail looks. One rule by engine: a propeller leaves a faint short grey haze,
/// a jet or a cruise missile a thin grey line, a rocket (supersonic, ballistic, interceptor) a white
/// line as thick as the missile is big. No trail has heat: the flame at the nozzle is separate.
#[derive(Clone, Copy, Debug)]
struct Look {
r0: f32,
r1: f32,
density: f32,
soot: f32,
clear: f32,
life: f32,
}
fn style_missile(s: &mut SmokeSegment) {
s.radius_start = 0.6;
s.radius_end = 5.0;
s.density = 3.0;
s.heat = 0.8;
s.soot = 0.05;
s.clear = 3.0;
s.life = 1000.0;
/// A rocket's white line, thicker for a bigger missile (`scale`: Pantsir 0.85, S-500 1.7).
fn rocket(scale: f32, density: f32, life: f32) -> Look {
let scale = scale.min(2.0);
Look { r0: 0.25 * scale, r1: 1.2 + 1.6 * scale, density, soot: 0.02, clear: 0.8 + 1.2 * scale, life: life + 250.0 * scale }
}
fn drone_look(class: Class, scale: f32) -> Look {
match class {
Class::Drone => Look { r0: 0.4, r1: 1.8, density: 0.15, soot: 0.3, clear: 0.3, life: 250.0 },
Class::Jet => Look { r0: 0.5, r1: 2.5, density: 0.5, soot: 0.25, clear: 1.5, life: 500.0 },
Class::Cruise => Look { r0: 0.6, r1: 3.0, density: 0.6, soot: 0.3, clear: 2.0, life: 600.0 },
Class::Supersonic => rocket(scale, 0.9, 400.0),
Class::Ballistic => rocket(scale, 1.1, 500.0),
}
}
/// `None`: no motor smoke at all (the electric P1-SUN).
fn missile_look(kind: crate::sim::MissileKind) -> Option<Look> {
kind.trail_scale().map(|k| rocket(k, 0.8, 250.0))
}
fn style(s: &mut SmokeSegment, l: Look) {
s.radius_start = l.r0;
s.radius_end = l.r1;
s.density = l.density;
s.heat = 0.0;
s.soot = l.soot;
s.clear = l.clear;
s.life = l.life;
}
/// Points between a replay trail segment's ends (a segment every `TRAIL_GRID * STEP` ticks).
@@ -138,15 +156,15 @@ pub fn replay_smoke(rec: &crate::sim::record::Recording, t: f64) -> Vec<SmokeSeg
let mut out: Vec<SmokeSegment> = Vec::new();
let stride = (TRAIL_GRID as u32 * STEP) as f64;
for (n, tr) in rec.tracks.iter().enumerate() {
let (life, style): (f64, Box<dyn Fn(&mut SmokeSegment)>) = match tr.what {
let look = match tr.what {
What::Drone(kind) => {
let class = kind.spec().class;
let mut probe = seg(0, DVec3::ZERO, DVec3::ZERO, 0, 0);
style_drone(&mut probe, class);
(probe.life as f64, Box::new(move |s| style_drone(s, class)))
let spec = kind.spec();
Some(drone_look(spec.class, spec.trail_scale()))
}
What::Missile(..) => (1000.0, Box::new(style_missile)),
What::Missile(kind, _) => missile_look(kind),
};
let Some(look) = look else { continue };
let life = look.life as f64;
// The segments whose end point has been reached and whose life is not over.
let lo = (((t - life - tr.first as f64) / stride).floor().max(0.0)) as usize;
let hi = (((t - tr.first as f64) / stride).floor().max(0.0)) as usize;
@@ -157,7 +175,7 @@ pub fn replay_smoke(rec: &crate::sim::record::Recording, t: f64) -> Vec<SmokeSeg
continue;
}
let mut s = seg(0x2000_0000 | ((n as u32).wrapping_mul(7919) ^ g as u32) & 0x0fff_ffff, a, b, born, 1200);
style(&mut s);
style(&mut s, look);
if (tick as f64) < born as f64 + s.life as f64 {
out.push(s);
}
@@ -192,7 +210,7 @@ pub fn replay_smoke(rec: &crate::sim::record::Recording, t: f64) -> Vec<SmokeSeg
}
impl Smoke {
fn trail(&mut self, key: (u8, u32), at: DVec3, tick: u64, style: impl Fn(&mut SmokeSegment)) {
fn trail(&mut self, key: (u8, u32), at: DVec3, tick: u64, look: Look) {
let Some(&(from, when)) = self.last.get(&key) else {
self.last.insert(key, (at, tick));
return;
@@ -204,7 +222,7 @@ impl Smoke {
if at.distance(from) < 400.0 {
self.next = self.next.wrapping_add(1);
let mut s = seg(self.next, from, at, tick, 1200);
style(&mut s);
style(&mut s, look);
self.trails.push(Trail { seg: s });
}
self.last.insert(key, (at, tick));
@@ -219,11 +237,13 @@ impl Smoke {
}
self.last_tick = tick;
for d in &sim.drones {
let class = d.kind.spec().class;
self.trail((0, d.id), d.pos, tick, |s| style_drone(s, class));
let spec = d.kind.spec();
self.trail((0, d.id), d.pos, tick, drone_look(spec.class, spec.trail_scale()));
}
for m in &sim.missiles {
self.trail((1, m.id), m.pos, tick, style_missile);
if let Some(look) = missile_look(m.kind) {
self.trail((1, m.id), m.pos, tick, look);
}
}
// Forget what is gone.
if tick % 100 == 0 {
@@ -258,3 +278,29 @@ impl Smoke {
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sim::{DroneKind, MissileKind};
#[test]
fn trails_follow_one_rule() {
assert!(missile_look(MissileKind::P1sun).is_none());
let rocket_look = missile_look(MissileKind::M77n6).unwrap();
let pantsir = missile_look(MissileKind::M57e6).unwrap();
assert!(pantsir.r1 < rocket_look.r1);
let mut s = seg(0, DVec3::ZERO, DVec3::ZERO, 0, 0);
for class in [Class::Drone, Class::Jet, Class::Cruise, Class::Supersonic, Class::Ballistic] {
style(&mut s, drone_look(class, 1.0));
assert_eq!(s.heat, 0.0);
}
for k in [MissileKind::Pac3, MissileKind::M57e6, MissileKind::M77n6, MissileKind::Amraam] {
style(&mut s, missile_look(k).unwrap());
assert_eq!(s.heat, 0.0);
}
let spec = DroneKind::Shahed.spec();
let shahed = drone_look(Class::Drone, spec.trail_scale());
assert!(shahed.density * 4.0 < rocket_look.density);
}
}
+5 -2
View File
@@ -19,7 +19,10 @@ pub const KINDS: [DroneKind; 11] = [
/// What a click on a kind's row steps its number through.
const STEPS: [u32; 12] = [0, 1, 2, 3, 4, 6, 8, 10, 15, 20, 30, 40];
pub const DISTANCES: [f64; 4] = [200.0, 300.0, 500.0, 800.0];
pub const DISTANCES: [f64; 7] = [200.0, 300.0, 500.0, 800.0, 1500.0, 2500.0, 4000.0];
/// The nearest and farthest start a raid may be ordered at (blocks).
pub const DIST_MIN: f64 = 60.0;
pub const DIST_MAX: f64 = 4000.0;
pub struct Preset {
pub name: &'static str,
@@ -147,7 +150,7 @@ impl WaveConfig {
self.approach = *a;
}
if let Some(d) = w.get(2).and_then(|v| v.parse::<f64>().ok()) {
self.dist = d.clamp(60.0, 800.0);
self.dist = d.clamp(DIST_MIN, DIST_MAX);
}
if let Some(a) = w.get(3).and_then(|v| v.parse::<usize>().ok()).and_then(|i| Aim::ALL.get(i)) {
self.aim = *a;
+105 -5
View File
@@ -13,9 +13,9 @@
## Статус
**Стадия 1 (прямой звук) сделана**, включается `HORIZON_ACOUSTICS=1` (по умолчанию выключено, звук как был). Стадии 2-3
не начаты. Коммит SPR прежний, `453654ec`; атрибуция: `horizon/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md`.
GPL-файл один: `acoustics/spr_occlusion.rs`; остальное (движок `acoustics.rs`, склейка `game/acoustics.rs`, DSP в `audio.rs`) наше.
**Стадии 1 (прямой звук), 2 (отводы эха) и 3 (реверб) сделаны**, включается кнопкой мода или `HORIZON_ACOUSTICS=1` (по умолчанию выключено,
звук как был). Коммит SPR прежний, `453654ec`; атрибуция: `horizon/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md`.
GPL-файлы три: `acoustics/spr_occlusion.rs`, (стадия 2) `acoustics/spr_bounce.rs` и (стадия 3) `acoustics/spr_reverb.rs`; остальное (движок `acoustics.rs`, `acoustics/room.rs`, склейка `game/acoustics.rs`, DSP в `audio.rs` и `audio/reverb.rs`) наше.
Что вышло и чем отличается от плана выше:
@@ -45,9 +45,107 @@ GPL-файл один: `acoustics/spr_occlusion.rs`; остальное (дви
выстрел 1.5 с на 60 блоках, стена камня в 2 блока: ниже 300 Гц -5.2 дБ (SPR: gain 0.55), 300-1500 -7.4, 1500-5000 -19.7, выше 5000 -40.0 дБ.
Тесты: `cargo test -p horizon-client --lib acoustics` (формулы, неизвестное, щель, кеш, инвалидация, детерминизм, рабочий поток) и `audio`.
### Стадия 2: эхо (сделано)
Что игрок получает: взрыв в ущелье или между домами отвечает настоящим эхо от тех стен, что там есть, с задержкой по
настоящему пути; в чистом поле эха нет. Код: `acoustics/echo.rs` (наш, MIT), `acoustics/spr_bounce.rs` (GPL: спираль
Фибоначчи, `energyTowardsPlayer`, таблица отражательности), отводы в `audio.rs` (`Echo`), склейка `game/acoustics.rs`
(таблица отражательности по состояниям блоков).
- Метод: 32 луча от источника и 32 от слушателя (направления SPR, длина 300) находят плоскости стен (грани блоков
осевые). Для каждой плоскости мнимый источник (зеркальное отражение источника) даёт точный путь `|образ - слушатель|`;
отвод оставляем, если точка пересечения этой прямой с плоскостью лежит на твёрдом блоке (стена там правда есть) и пути
источник-стена и стена-слушатель свободны (окклюзия < 0.2). Это и есть «задержка по реальному пути»: сверка с формулой
до сэмпла.
- Задержка отвода = (путь - прямое расстояние) / 343 с: звук играет в момент прихода прямого (задержку распространения
держит мод). Уровень = `min(0.85, 2 * energyTowardsPlayer(refl)) * min(1, max(d,4)/путь)`, затем в микшере умножается на
громкость голоса и `distance_gain` длины пути; срез верхов по воздуху `0.994^(лишний путь)` (однополюсник на голос-отвод).
Окклюзия прямого звука на эхо не действует (оно приходит другим путём).
- Срез близких отражений: отвод ближе 30 мс к прямому звуку отбрасывается, до 50 мс плавно входит (иначе стена рядом с
игроком даёт гребёнку, а не эхо). Из найденных плоскостей берутся до 6 сильнейших не ближе 40 мс друг к другу.
- Кому: `deserves_echo` в `audio.rs`: не петля, не относительный, громкость >= 0.5, дальность (linear) >= 96, длина 0.1-12 с,
прошло меньше 70% звука; не более `ECHO_VOICES = 8` одновременно (самые громкие). Сотня двигателей эха не получает
(петли), сотня выстрелов: восемь самых громких.
- Планирование: счёт по клеткам, не по времени. Живьём трассировка идёт на том же рабочем потоке (в задании до 4 трасс и
240 тыс. клеток), кеш по блочным ячейкам источника и слушателя (центры ячеек, 256 записей), сброс по секциям, которых
касаются точки опоры отводов, и по эпохе; в съёмке трасса синхронная, 480 тыс. клеток на тик. Микшер получает отводы
числами (`take_echoes`), новый набор входит за ~50 мс (совпавший по задержке в пределах 3 мс отвод меняет только уровень).
Голос после конца сэмпла живёт ещё `max_delay` (хвост), потом останавливается.
- Измерено (release, M-серия): трасса `bench_traces`: ущелье 0.054 мс (7 тыс. клеток, 2 отвода), открытое поле 0.115 мс
(15 тыс. клеток, 0 отводов), комната 0.022 мс; всё на рабочем потоке, игровой поток платит только клонированием `Arc` и
отправкой. Микшер: 108 голосов (100 петель + 8 одноразовых) 0.213 мс -> 0.348 мс с 8 x 6 отводами (+0.135 мс, 1.3% буфера
512 кадров). Время игрового потока в `tick_acoustics` и эхо-трасс копится в `status` (`acoustics.tick_ms_*`,
`acoustics.echo.ms*`); в игре не снималось.
- Проверка без слуха: `audio::tests::export_echo_tracks` (настоящие `Acoustics` и `mix`, щелчок) +
`python3 tools/acoustics_check.py --echo-dir папка`: ущелье даёт пики 158.9 и 242.1 мс (ожидалось 158.9 и 242.1),
поле ничего, комната один пик 39.9 мс (боковые стены; торцы и потолок ближе 30 мс срезаны). Тесты: `--lib acoustics`
(ущелье, поле, комната, стена у слушателя, ширма, «стены нет там, где луч её нашёл», бюджет, детерминизм, кеш, инвалидация,
потолок голосов, рабочий поток) и `--lib audio` (отвод стоит на своём кадре до сэмпла, хвост, без щелчка, детерминизм).
Не слушали: громкость отводов (уровни из формул SPR и 1/r, не подстраивались), пороги `deserves_echo` (угаданы: не знаем,
какую громкость и дальность мод ставит выстрелам), срез 30/50 мс и разнос 40 мс на слух, срез верхов отвода. Первые 1-2 тика
эхо нового звука ещё нет (трасса на рабочем потоке): теряется только начало отводов с задержкой менее ~100 мс. Лучи стадии
видят стены только в 32 направлениях с каждого конца: узкая далёкая стена (меньше нескольких градусов) может не найтись.
Косые стены считаются ступеньками: на склоне горы отводов много, остаются сильнейшие с разносом 40 мс. Отводы идут только
в мире (реплей читает те же блоки), на съёмке не прогонялось в игре.
Не сделано / не проверено: запуск в игре и экспорт видео с настоящей сценой (склейка `game/acoustics.rs` — таблица состояний,
снимок чанков — компилируется, но в игре не прогонялась); влияние на кадр при налёте; жидкости; дверь как проём (считается
ячейкой); потолок памяти снимка. Стадии 2-3 (отводы эха, шины реверба) не начаты.
ячейкой); потолок памяти снимка.
### Стадия 3: реверб (сделано)
Что игрок получает: под крышей и в тоннеле звук звенит иначе, чем в поле; в чистом поле реверба нет. Код: `acoustics/room.rs`
(наш: замер пространства, уровень и спад, посылки), `acoustics/spr_reverb.rs` (GPL: четыре слота SPR, `0.7*0.85`, затухание посылок
с расстоянием), `audio/reverb.rs` (наш: DSP общей шины), посылки голосов и шина в `audio.rs` (`Channel.send`, `Mixer.reverb`),
склейка в `acoustics.rs` (`take_sends`).
- Замер пространства (`room::measure`): 48 лучей по сфере Фибоначчи, берутся те, что идут вбок и вверх (пол комнаты не делает), длина 64 блока
(столько видит и SPR). Вес попадания `1 - (d/64)^2`. Из них: **замкнутость** (сумма весов / число лучей, 0..1), **размер** (средняя
дальность стен) и **отражательность** (средняя по таблице SPR). Слушатель замеряется по своей клетке 1x1x1 (кеш 64, сброс по секциям в
кубе ±65 и по эпохе, на рабочем потоке; пока новый замер в пути, остаётся прежний); у звуков с эхом замеряется ещё и пространство
источника (в той же трассе эха, свой потолок 60 тыс. клеток). Пространство звука = слушатель 60% + источник 40% (без источника: слушатель).
- Уровень и спад (`wet_and_decay`): `wet = smoothstep(0.03, 0.4, замкнутость)`; `спад = замкнутость^2 * (0.4 + 0.1*min(размер,30)) *
(0.6 + 0.8*отражательность)`, в пределах 0.15-4.14 с (слоты SPR). Камень (1.5) звенит дольше шерсти (0.1). Ущелье с открытым верхом
почти не даёт реверба (его ответ - эхо стадии 2); комната и тоннель - да.
- Посылки: спад ложится между двумя соседними слотами SPR (0.15 / 0.55 / 1.68 / 4.14 с, по логарифму) равномерной по мощности
перекрёстной кривой (квадрат положения у длинного, иначе по Шрёдеру читается только он), каждый слот выравнен по своему `gain` из
таблицы SPR: громкость реверба не растёт с его длиной. Посылка голоса умножается на `distance_fade` SPR (`1 - d/(1.5*дальность)`).
- Шина (`audio/reverb.rs`): 4 слота, в каждом пред-задержка (late delay SPR), 2 allpass, 4 гребёнки с lowpass в петле (коэффициент из
`decay_hf_ratio` SPR на 5 кГц). Левое ухо берёт гребёнки 0 и 2, правое 1 и 3 (разные хвосты), моно = среднее всех четырёх. Голос
кладёт в шину сигнал после стены (окклюзии) и до панорамы; посылки голоса плавно (постоянная 0.3 с) идут к цели, так что вход в тоннель
и выход из него без щелчка. Шина считается и без голосов (хвост звучит после конца хлопка), слот без входа и хвоста не считается и
обнуляется (без денормалей), шина сбрасывается, когда затихла.
- Выключение (`set_acoustics(false)`): посылки идут к нулю за ~1-2 с, хвост доигрывает сам; `stop_all` (и `film_begin/film_end`) шину сбрасывают.
Съёмка: замер синхронный (счёт клеток), шина считается без случайности и от часов не зависит - дорожка та же при каждом прогоне
(`audio::tests::reverb_rendering_is_deterministic`).
- Измерено (release, M-серия, буфер 512 кадров = 10.67 мс): шина с четырьмя занятыми слотами 0.017 мс; 100 голосов 0.119 мс без реверба ->
+0.04 мс с реверб-посылками (25 или 100 голосов, около 0.4% буфера); замер пространства: комната 0.0075 мс (392 клетки), тоннель
0.0037, ущелье 0.033 (2208), поле 0.035 мс (2315); всё на рабочем потоке. Счётчики в `status`: `acoustics.reverb` (замеры, попадания
кеша, клетки, посылки, замкнутость и спад сейчас); в игре не снимались.
- Проверка без слуха: `audio::tests::export_reverb_tracks` (настоящие `Acoustics`, посылки, `mix`, щелчок в комнате 24x10x24, тоннеле 5x4,
ущелье, поле) + `python3 tools/acoustics_check.py --reverb-dir папка`. Порядок **поле < ущелье < тоннель < комната** (в плане было
«ущелье длиннее всех»; открытый верх ущелья этого не даёт):
| сцена | спад пространства, с | RT60 по посылкам, с | RT60 измерено, с | хвост/щелчок, дБ |
|---|---|---|---|---|
| поле | 0.15 | - | хвоста нет | нет |
| ущелье | 0.15 | 0.15 | 0.10 | -47.7 |
| тоннель | 0.79 | 0.97 | 0.87 | -11.7 |
| комната | 1.37 | 1.67 | 1.39 | -7.7 |
Колонка «по посылкам» - сумма экспонент слотов по Шрёдеру (`room::effective_decay`), измеренное лежит на 10-20% ниже (lowpass в петле
укорачивает). Каждый слот отдельно (импульс в одну шину, `reverb::tests::each_slot_decays_at_its_time`) читается в 0.5-1.15 от
номинала. Тесты: `--lib acoustics` (замеры комната/тоннель/ущелье/поле, порядок спадов, гладкость посылок, кеш, сброс, рабочий поток)
и `--lib audio` (хвост после конца звука и сброс шины, нулевая посылка - ровно прежний вывод, без щелчка при смене и выключении,
детерминизм, выключение и `film_begin`).
Не слушали: громкость реверба (`WET = 1.6` и уровни слотов не подстраивались на слух; в комнате хвост -8 дБ от щелчка по энергии), формулы
замкнутости/спада (пороги 0.03-0.4, веса, перекрёстная кривая - выбраны по замерам сцен, не по слуху), звучание гребёнок (металличность на
коротких слотах, пред-задержки), окраска левого и правого, 60/40 слушатель/источник, скорость скольжения посылок 0.3 с. Замкнутость
по 24 лучам вверх-вбок грубая: узкая щель в крыше может не найтись, деревья и листва (окклюзия 0) пространства не делают. Реверб не
получают относительные звуки (интерфейс, музыка) и звуки дальше 512 блоков; эхо-отводы в шину не идут. `gain_hf` слотов, `density`,
отражения слота SPR не перенесены.
## 1. Как SPR идёт от источника к результату
@@ -351,7 +449,7 @@ GPL-файл один: `acoustics/spr_occlusion.rs`; остальное (дви
классов не даёт скачка; сброс при `film_begin/film_end`; лимитер не срабатывает на нормальном налёте.
- Проверка без слуха: импульсный отклик шины из `film_mix` → RT60 по спаду (`tools/acoustics_check.py`) для четырёх
классов сцены (поле, ущелье, комната, тоннель) — таблица в отчёте; спад должен упорядочиться
поле < комната < тоннель < ущелье; налёт 100 источников: пиковый уровень, число срабатываний лимитера, CPU.
поле < ущелье < тоннель < комната (у ущелья открытый верх); налёт 100 источников: пиковый уровень, число срабатываний лимитера, CPU.
- Критерий: хвост после конца исходного хлопка длится ожидаемое RT60 и не продолжается после смены мира.
### Стадия 4 (по #96): движущиеся источники и интеграция с #95/cinema
@@ -370,3 +468,5 @@ GPL-файл один: `acoustics/spr_occlusion.rs`; остальное (дви
- Коммит из #96 (`76f680eb…`) не найден в недавней истории `master`; разбор сделан по `453654ec`.
- Мод-API `query-rays` с политикой `sound` для этого недостаточно (нет материала и полупрозрачных долей), поэтому
акустика в клиенте; мод только сообщает события (#95).
Включается кнопкой в меню мода (ещё → «Акустика»), сохраняется с миром. `HORIZON_ACOUSTICS=1` задаёт только начальное значение.
+9 -6
View File
@@ -19,12 +19,11 @@ AirDef работает на своей сборке (форке) Horizon: в н
## 2а. Готовая сборка из релиза (проще)
В репозитории → **Releases** → последний `build-N`. В каждом релизе есть `airdef.hmod` — сам мод (один для всех
ОС). Игру-форк сервер сборок не собирает сам: это тяжело для него. Архивы с игрой появляются в релизе, только если
их собрали вручную:
- `airdef-linux-x86_64.tar.gz` — Actions → **game** → Run workflow, указать тег релиза;
- `airdef-windows-x86_64.zip`, `airdef-macos-arm64.tar.gz` — Actions → **desktop** (нужны раннеры с метками
`windows-latest` / `macos-latest`).
В репозитории → **Releases**. Большие релизы `vX.Y.Z` содержат готовую игру для Windows,
`airdef-windows-x86_64.zip`: она собирается на сервере только по тегу версии (`git tag v0.3.0`, `git push origin v0.3.0`).
Сборки `build-N` выходят на каждый коммит, в них только `airdef.hmod`, сам мод (один для всех ОС).
Архив Linux собирается вручную: Actions → **game** → Run workflow с тегом `build-N`; macOS: Actions → **desktop**
(нужен раннер с меткой `macos-latest`).
Нет архива для твоей ОС — способ 2б: игра соберётся у тебя, а мод можно взять из релиза или собрать там же.
@@ -79,6 +78,9 @@ python3 tools/airdef.py play # или: python3 tools/airdef.py play --world "
Шаги по отдельности: `fork` (скачать Horizon и наложить патчи `horizon-patches/`), `game` (собрать игру),
`mod` (собрать `airdef.hmod`), `install` (положить мод в папку игры и включить), `play`.
`check` говорит прямо, совпадают ли форк, упаковщик мода, уровень API мода с игрой и ресурсы, и подсказывает,
что запустить, если нет (`python3 tools/airdef.py check`; Windows: `py tools\airdef.py check`). Версия Rust
фиксирована в `rust-toolchain.toml`: rustup сам поставит её и цель wasm32 при первой сборке.
## 3. В игре
@@ -86,6 +88,7 @@ python3 tools/airdef.py play # или: python3 tools/airdef.py play --world "
пересоберутся сами, если патчи изменились.
- Windows: нужна видеокарта с Vulkan. Экспорт фильмов (`/airdef replay export`) требует `ffmpeg` в PATH или `HORIZON_FFMPEG`.
- Шейдеры уровня шейдерпаков: `/airdef shader hd`. Для слабой видеокарты перед запуском: `$env:HORIZON_HD_SCALE="0.67"` (PowerShell).
- Преграды и эхо для звука: меню мода → «Акустика».
- Создай мир с читами (или включи их через «Открыть для сети»): мод выдаёт пульты и взрывы командами.
- `/airdef give` — пульты, **N** — командный пункт (роли ПВО), **K** — монитор комплекса,
`/airdef wave mix 20` — налёт. Подробнее — README.
+42
View File
@@ -0,0 +1,42 @@
# Карта репозитория
Что здесь что: исходники, сгенерированное, архив. Порядок сборки и установки — в `README.md` и `docs/INSTALL.md`.
## Исходники (правим руками)
| Путь | Что это |
|---|---|
| `airdef-mod/src/`, `airdef-mod/shaders/` | Мод на Rust (WASM) для форка Horizon |
| `airdef-mod/Cargo.toml`, `horizon-mod.toml` | Сборка мода |
| `airdef-mod/tests/`, `airdef-mod/examples/` | Тесты и примеры |
| `airdef-mod/NOTES-terrain-guidance.md` | Рабочие заметки по рельефу |
| `horizon/`, `HORIZON_COMMIT`, `horizon-patches/` | Форк Horizon и патчи к нему |
| `tools/airdef.py` | Сборка мода и установка в папку игры |
| `tools/gen_models.py`, `tools/gen_tex.py`, `tools/gen_sounds.py` | Генераторы контента (см. ниже) |
| `tools/acoustics_check.py`, `tools/hd_bench.py` | Проверочные скрипты |
| `docs/` | Документация (этот файл, `INSTALL.md`, `ACOUSTICS.md`, `HD-RENDERER.md`) |
| `install.sh`, `play.sh`, `dist/` | Установка и запуск |
| `run-home/`, `run-home-bench/`, `run-home-smoke/` | Тестовые игровые папки, не часть мода |
| `screenshots/` | Скриншоты |
## Сгенерировано (не правим руками)
`tools/gen_models.py` создаёт:
- `airdef-mod/assets/airdef/{blockstates,models,items,lang,textures}/`
- `airdef-mod/data/airdef/horizon/`
Список этих файлов с sha256 лежит в `airdef-mod/generated-manifest.txt`. Генератор пишет во временный каталог, проверяет набор (ссылки моделей, blockstate и текстур; JSON; PNG; координаты элементов; парность lang) и только потом переносит файлы. При ошибке проверки ничего не записывается. Файл, который правили вручную или который занимает путь генерируемого, останавливает запуск (`КОНФЛИКТ`).
Команды:
- `python3 tools/gen_models.py` — перегенерировать.
- `python3 tools/gen_models.py --check` — только сравнить, выход 1 при различиях.
`tools/gen_sounds.py` создаёт `airdef-mod/assets/airdef/sounds/*.ogg` и `sounds.json`. Этот генератор не использует manifest и не входит в проверку `gen_models.py`.
## Архив: `legacy/`
Первая версия мода: датапак на командах (`legacy/airdef-datapack/`) и контент-мод (`legacy/airdef-content/`) с генератором `legacy/gen_content.py`. Ничего из текущей сборки на него не ссылается: ни скрипты в `tools/`, ни `install.sh`, ни `Cargo.toml`, ни исходники мода. Не собирается и не устанавливается. Оставлен как история. Не редактировать и не удалять без отдельного решения.
`legacy/airdef-content/data/` сейчас не отслеживается git и не входит в эту карту.
@@ -1,7 +1,7 @@
From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 12:37:45 +0300
Subject: [PATCH 01/39] Mod API (AirDef fork): world models, client particles,
Subject: [PATCH 01/42] Mod API (AirDef fork): world models, client particles,
sounds, ground heights
render.set-models draws block models with an interpolated pose per id;
@@ -1,7 +1,7 @@
From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 12:39:45 +0300
Subject: [PATCH 02/39] cargo-horizon-mod: report particles and sounds
Subject: [PATCH 02/42] cargo-horizon-mod: report particles and sounds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
@@ -1,7 +1,7 @@
From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 13:33:10 +0300
Subject: [PATCH 03/39] Mod API (AirDef fork): full-screen post pass for mods
Subject: [PATCH 03/42] Mod API (AirDef fork): full-screen post pass for mods
render.set-post compiles a mod's WGSL after a prelude (camera matrices,
time, 256 vec4 of params, the world's colour and depth) and runs it after
@@ -1,7 +1,7 @@
From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 22:09:30 +0300
Subject: [PATCH 04/39] Mod models: draw far models as scaled impostors past
Subject: [PATCH 04/42] Mod models: draw far models as scaled impostors past
the far plane, cull at 4096
---
@@ -1,7 +1,7 @@
From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 22:31:00 +0300
Subject: [PATCH 05/39] Mod cameras: render.set-views draws the world from
Subject: [PATCH 05/42] Mod cameras: render.set-views draws the world from
other points into pictures that HUD view shapes show (API level 9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 23:08:21 +0300
Subject: [PATCH 06/39] Mods: more fuel per tick and per event (AirDef plans
Subject: [PATCH 06/42] Mods: more fuel per tick and per event (AirDef plans
raids)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 23:25:29 +0300
Subject: [PATCH 07/39] Mod cameras up to 1920x1080 (full-screen replays)
Subject: [PATCH 07/42] Mod cameras up to 1920x1080 (full-screen replays)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
@@ -1,7 +1,7 @@
From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 11:15:11 +0300
Subject: [PATCH 08/39] Mod cameras: distant terrain tiles, fog measured from
Subject: [PATCH 08/42] Mod cameras: distant terrain tiles, fog measured from
the camera, no impostors while one is shown
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 11:25:45 +0300
Subject: [PATCH 09/39] Mods: game.world-id, so mods keep state per world (API
Subject: [PATCH 09/42] Mods: game.world-id, so mods keep state per world (API
level 9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 12:01:08 +0300
Subject: [PATCH 10/39] cargo-horizon-mod: trial runs behind feature full, so a
Subject: [PATCH 10/42] cargo-horizon-mod: trial runs behind feature full, so a
packing-only tool skips wasmtime
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 13:18:43 +0300
Subject: [PATCH 11/39] HD lighting: G-buffer pass, per-pixel mod point lights
Subject: [PATCH 11/42] HD lighting: G-buffer pass, per-pixel mod point lights
in HDR, bloom and tone curve; render.set-lights and render.set-lighting (API
level 10)
@@ -1,7 +1,7 @@
From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 13:59:43 +0300
Subject: [PATCH 12/39] HD lighting stage 2: sun and moon shadow cascades, the
Subject: [PATCH 12/42] HD lighting stage 2: sun and moon shadow cascades, the
world relit (sun by face angle and shadow, sky and block light through the
lightmap), light shafts and halos, fog over it again, a light colour grade
@@ -1,7 +1,7 @@
From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 16:19:53 +0300
Subject: [PATCH 13/39] HD: bind groups cached until a resize or a new source,
Subject: [PATCH 13/42] HD: bind groups cached until a resize or a new source,
shadow passes draw only the entities that cast shadows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 16:25:44 +0300
Subject: [PATCH 14/39] HD: lights culled per 16x16 tile (surface and halo
Subject: [PATCH 14/42] HD: lights culled per 16x16 tile (surface and halo
lists built on the CPU), a naga test for the lighting shaders
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 16:43:36 +0300
Subject: [PATCH 15/39] HD: GPU time per pass in the F3 timing list,
Subject: [PATCH 15/42] HD: GPU time per pass in the F3 timing list,
half-resolution GTAO with a depth-aware blur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:02:02 +0300
Subject: [PATCH 16/39] HD: shadows draw only sun-facing quads and skip buried
Subject: [PATCH 16/42] HD: shadows draw only sun-facing quads and skip buried
sections, the far cascade every second frame; TAA (Halton jitter,
reprojection by depth, YCoCg variance clipping) before bloom
@@ -1,7 +1,7 @@
From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:02:02 +0300
Subject: [PATCH 17/39] Mods: model handles (render.model-id, model-generation,
Subject: [PATCH 17/42] Mods: model handles (render.model-id, model-generation,
set-models-by-id; API level 11) so mods send no model names each tick;
clear-post and set-lighting documented
@@ -1,7 +1,7 @@
From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:19:57 +0300
Subject: [PATCH 18/39] HD: temporal upscaling (HORIZON_HD_SCALE: world,
Subject: [PATCH 18/42] HD: temporal upscaling (HORIZON_HD_SCALE: world,
G-buffer and lighting at a lower size, rebuilt to the window in the TAA
pass); fix flickering shadows near the player (the near cascade lost its
sections on frames the far one was held, the held far matrix ignored the
@@ -1,7 +1,7 @@
From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:47:52 +0300
Subject: [PATCH 19/39] HD water: its own pass (colour, normal, depth), the
Subject: [PATCH 19/42] HD water: its own pass (colour, normal, depth), the
floor under it lit, a composite with world-space waves, refraction with depth
absorption in the biome colour, screen-space reflections, Fresnel and a
shadowed sun glint (HORIZON_HD_WATER=0 to switch off)
@@ -1,7 +1,7 @@
From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:56:04 +0300
Subject: [PATCH 20/39] HD water: no vanilla texture on the surface (the biome
Subject: [PATCH 20/42] HD water: no vanilla texture on the surface (the biome
colour only), a darker, deeper colour
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 18:38:19 +0300
Subject: [PATCH 21/39] HD sky and light: a physical atmosphere (Rayleigh, Mie,
Subject: [PATCH 21/42] HD sky and light: a physical atmosphere (Rayleigh, Mie,
ozone; constants and transmittance after Photon, MIT) for the sky, haze, fog
and water reflections; warmer sunlight from the atmosphere's transmittance, a
gentle sky-coloured ambient, soft PCSS-lite shadows near the player, a haze
@@ -1,7 +1,7 @@
From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 19:02:16 +0300
Subject: [PATCH 22/39] HD water: a darker blue, a little clearer, ripples a
Subject: [PATCH 22/42] HD water: a darker blue, a little clearer, ripples a
little higher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 21:41:48 +0300
Subject: [PATCH 23/39] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
Subject: [PATCH 23/42] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
tint, blue hour; MIT), volumetric clouds with ground shadows instead of
vanilla's in HD, volumetric fog with sun shafts and morning mist
(HORIZON_HD_CLOUDS=0, HORIZON_HD_VOLFOG=0)
@@ -1,7 +1,7 @@
From 24971b3c67d8c87673eb2e0d204cedf8a2f46d1c Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 21:43:22 +0300
Subject: [PATCH 24/39] Mods: world queries for simulations (render.query-rays,
Subject: [PATCH 24/42] Mods: world queries for simulations (render.query-rays,
query-sections, world-changes; API level 12): block-exact rays and section
occupancy by collision, sight or sound, answered next tick within budgets,
and a log of changed sections
@@ -1,7 +1,7 @@
From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 22:56:15 +0300
Subject: [PATCH 25/39] HD fixes: rain level from the world not from rain
Subject: [PATCH 25/42] HD fixes: rain level from the world not from rain
geometry (#41), TAA history on the jittered grid (#38), vanilla cloud tint
without a depth heuristic (#35), air fog once over water (#37), translucent
things in front of HD water (#36), cheaper sunrise fan (#39), halos in thick
@@ -1,7 +1,7 @@
From 21391afb26f49505d6cdaf454a6355cd4e747503 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 23:19:53 +0300
Subject: [PATCH 26/39] HD split into modules (gfx/hd/: one Rust file and one
Subject: [PATCH 26/42] HD split into modules (gfx/hd/: one Rust file and one
WGSL file per pass, a shared prelude), contracts for colour, depth, alpha,
time and coordinates written down, tests that the frame layout and bindings
match between Rust and WGSL (#45)
@@ -1,7 +1,7 @@
From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 23:22:19 +0300
Subject: [PATCH 27/39] HD clouds: noise scales that divide the camera's
Subject: [PATCH 27/42] HD clouds: noise scales that divide the camera's
65536-block wrap, so the clouds and their shadows no longer jump at x or z =
0
@@ -1,7 +1,7 @@
From 0fb59c782abf269fe555faa3241b31acf41e26d7 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:17:11 +0300
Subject: [PATCH 28/39] HD: volumetric smoke and missile trails with clear
Subject: [PATCH 28/42] HD: volumetric smoke and missile trails with clear
tunnels through fog and cloud (render.set-smoke, API level 14; #54); sun
speculars by material, wet surfaces and puddles in rain, wind in foliage
(HORIZON_HD_SPECULAR=0, HORIZON_HD_WIND=0, HORIZON_HD_SMOKE=0); HD shaders
@@ -1,7 +1,7 @@
From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:26:09 +0300
Subject: [PATCH 29/39] HD clouds composited by depth so terrain and vehicles
Subject: [PATCH 29/42] HD clouds composited by depth so terrain and vehicles
inside or behind the layer are hidden and those in front stay in front (#67);
world-anchored noise everywhere (cloud detail by height above the layer, fog,
smoke and puddle scales dividing the wrap) (#68); the replay's time is visual
@@ -1,7 +1,7 @@
From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:44:45 +0300
Subject: [PATCH 30/39] HD smoke: young trails drawn at least as wide as a grid
Subject: [PATCH 30/42] HD smoke: young trails drawn at least as wide as a grid
cell (density scaled to keep the mass), gentler bulges, so a trail is a
smooth ribbon and not a chain of puffs
@@ -1,7 +1,7 @@
From 35ffbaa26dd91855c8b93446646ba8c58448d3d0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 03:59:41 +0300
Subject: [PATCH 31/39] Mods: render.set-main-view (API level 15) renders the
Subject: [PATCH 31/42] Mods: render.set-main-view (API level 15) renders the
screen's world from a mod camera with the full pipeline, so replays get HD
and the post effects; small mod views get the post pass with their own camera
@@ -1,7 +1,7 @@
From 27e24527550942695164dd3a769a42a659e797ca Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 09:28:05 +0300
Subject: [PATCH 32/39] Spot lights for mods (render.set-spot-lights, API level
Subject: [PATCH 32/42] Spot lights for mods (render.set-spot-lights, API level
16): surfaces lit in the cone, a volumetric beam stopped by depth (#60); film
capture (render.capture-*, set-film-quality, API level 17): fixed-step clock,
frames piped to ffmpeg or PNGs, offline audio, no chat in the frames (#58);
@@ -1,7 +1,7 @@
From 615f5c3fa7c19a9895f8972a88333c6f250ad78a Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 14:30:39 +0300
Subject: [PATCH 33/39] HD clouds and fog polish: cloud panorama, cirrus,
Subject: [PATCH 33/42] HD clouds and fog polish: cloud panorama, cirrus,
terrain-aware fog, far smoke cascade; replay world log and film clock;
material overlays by sprite name
@@ -1,7 +1,7 @@
From 0254984fdcce53a4a0f2bff15f9be3b78c93842e Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 15:01:09 +0300
Subject: [PATCH 34/39] HD: coloured sun through glass and leaves, rain ripples
Subject: [PATCH 34/42] HD: coloured sun through glass and leaves, rain ripples
on water, film motion blur and depth of field
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 9645d3f1f47d8bb358d5630288bdd801bd874911 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 15:19:32 +0300
Subject: [PATCH 35/39] Film export: every frame in flight reaches the writer,
Subject: [PATCH 35/42] Film export: every frame in flight reaches the writer,
listener follows the main view, cloud wind on film frames, set-smoke reports
real activity
@@ -1,7 +1,7 @@
From 22d6835507d5627b0fe4a749b082b025b35e0262 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 15:43:11 +0300
Subject: [PATCH 36/39] Mod command trees: command.set-tree and
Subject: [PATCH 36/42] Mod command trees: command.set-tree and
set-suggestions, typed parse, colouring and completion from one tree (API 19)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 9be21a54fff055e78db245488c28a971a823fdba Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 16:10:23 +0300
Subject: [PATCH 37/39] Persistent mod sound sources: start, update, stop with
Subject: [PATCH 37/42] Persistent mod sound sources: start, update, stop with
generations, click-free voices, loop sections, voice budget and virtual loops
(API 20)
@@ -1,7 +1,7 @@
From e84cdc21a9fabc4518c18edbeacb2df24c85b983 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 19:07:37 +0300
Subject: [PATCH 38/39] HD clouds: rays from a far point, so the view bob's eye
Subject: [PATCH 38/42] HD clouds: rays from a far point, so the view bob's eye
offset does not bend them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 19f07ff4295766c7bee4bd1fbfc509b6a82d12d4 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 22:09:23 +0300
Subject: [PATCH 39/39] Acoustics stage 1: direct-path occlusion ported from
Subject: [PATCH 39/42] Acoustics stage 1: direct-path occlusion ported from
Sound Physics Remastered 453654e (GPL-3.0, separate files), off by default;
stereo output on surround devices; mixer sum in a fixed order (API 21)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,110 @@
From a6dcb0b5f73640e0324c8bec3508e8e396e713bd Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Sat, 31 Oct 2026 03:20:06 +0300
Subject: [PATCH 42/42] game.world-seed (API 25)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/game/mods.rs | 3 +++
crates/horizon-mod-sdk/wit/horizon.wit | 4 ++++
crates/horizon-mods/src/api_level.rs | 6 ++++--
crates/horizon-mods/src/runtime.rs | 5 +++++
docs/api/mods-reference.md | 1 +
5 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs
index 7f9714a18..e96744775 100644
--- a/crates/horizon-client/src/game/mods.rs
+++ b/crates/horizon-client/src/game/mods.rs
@@ -599,6 +599,9 @@ impl Game<'_> {
.is_some()
.then(|| self.mods.world_id.clone())
.flatten(),
+ world_seed: (self.player.is_some()
+ && self.mods.world_id.as_deref().is_some_and(|w| w.starts_with("world:")))
+ .then_some(self.level.seed),
}
}
/// The first block along the player's view within
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
index 805c2a515..774cc5851 100644
--- a/crates/horizon-mod-sdk/wit/horizon.wit
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
@@ -51,6 +51,10 @@ interface game {
/// world, `server:<address>` on a server; none on the title screen.
/// Mods keep their state apart by it.
world-id: func() -> option<string>;
+ /// The open saved world's seed (AirDef fork, API level 25): with the
+ /// folder name it tells a new world from an old one that had the same
+ /// name. None on the title screen and on a server.
+ world-seed: func() -> option<s64>;
/// A block along the player's view.
record hit {
/// The block hit.
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
index 579af9e25..3af36e7c1 100644
--- a/crates/horizon-mods/src/api_level.rs
+++ b/crates/horizon-mods/src/api_level.rs
@@ -7,13 +7,13 @@
//! version's level so the launcher can tell before it installs.
/// The newest level this Horizon serves.
-pub const LEVEL: u32 = 24;
+pub const LEVEL: u32 = 25;
/// Every function the game offers mods, as `interface.function`, with its
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
/// test fails until it is listed here).
-pub const FUNCTIONS: [(&str, u32); 90] = [
+pub const FUNCTIONS: [(&str, u32); 91] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -118,6 +118,8 @@ pub const FUNCTIONS: [(&str, u32); 90] = [
("render.set-canvas", 24),
("render.canvas-events", 24),
("render.canvas-open", 24),
+ // AirDef fork: the open world's seed, so a new world is not an old one of the same name.
+ ("game.world-seed", 25),
];
/// What the game's program carries so the launcher can read its level
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
index 81286975f..910986549 100644
--- a/crates/horizon-mods/src/runtime.rs
+++ b/crates/horizon-mods/src/runtime.rs
@@ -189,6 +189,8 @@ pub struct Snapshot {
pub creative: bool,
/// The open world (`game.world-id`, AirDef fork).
pub world: Option<String>,
+ /// The open saved world's seed (`game.world-seed`, AirDef fork).
+ pub world_seed: Option<i64>,
}
/// Capability `recipes`'s tables: `items` in the registry's order, which
@@ -1930,6 +1932,9 @@ impl game::Host for State {
fn world_id(&mut self) -> Option<String> {
self.snapshot.world.clone()
}
+ fn world_seed(&mut self) -> Option<i64> {
+ self.snapshot.world_seed
+ }
fn raycast(&mut self, distance: f32) -> Option<Hit> {
let (at, hit) = self.snapshot.view_hit.as_ref()?;
// NaN compares false: no hit.
diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md
index cf6e61440..3ab23b490 100644
--- a/docs/api/mods-reference.md
+++ b/docs/api/mods-reference.md
@@ -126,6 +126,7 @@ A block along the player's view.
| `get-player` | `func() -> option<player>` | None on the title screen. |
| `get-clock` | `func() -> option<clock>` | None on the title screen. |
| `world-id` | `func() -> option<string>` | Which world is open (AirDef fork): `world:<folder>` for a saved world, `server:<address>` on a server; none on the title screen. Mods keep their state apart by it. |
+| `world-seed` | `func() -> option<s64>` | The open saved world's seed (AirDef fork, API level 25): with the folder name it tells a new world from an old one that had the same name. None on the title screen and on a server. |
| `raycast` | `func(distance: f32) -> option<hit>` | The first block along the player's view (fluids pass), up to `distance` blocks (at most 1024; past the loaded chunks, across the distant terrain when LOD Distance is on), as of this tick. None when nothing is hit, and on the title screen. |
## `chat`
--
2.54.0 (Apple Git-157)
+5
View File
@@ -0,0 +1,5 @@
[toolchain]
# The version the fork and the mod are built with (tools/airdef.py): rustup reads this file for
# every folder under the repo, horizon/ and airdef-mod/ included. Bump it deliberately, with the fork.
channel = "1.99.1"
targets = ["wasm32-unknown-unknown"]
+130 -2
View File
@@ -9,6 +9,23 @@
Для каждой дорожки считается энергия левого канала в четырёх полосах (до 300 Гц, 300-1500, 1500-5000, выше
5000) и разница в дБ между дорожками. Преграда должна срезать верхи (выше 5 кГц не меньше чем на 15 дБ) и
оставить низы (до 300 Гц теряют не больше 8 дБ). Коды выхода: 0 — в порогах, 1 — нет, 2 — ошибка аргументов.
Режим `--echo` (стадия 2): в дорожке с одним щелчком ищутся пики огибающей после прямого звука, их задержки
(мс от прямого) сравниваются с ожидаемыми от геометрии:
python3 tools/acoustics_check.py --echo gorge.raw --expect 187.3,254.1 [--tol 1.0]
python3 tools/acoustics_check.py --echo field.raw --expect none
python3 tools/acoustics_check.py --echo-dir папка # gorge/field/room и expected.txt из export_echo_tracks
Дорожки пишет `audio::tests::export_echo_tracks` (HORIZON_ACOUSTICS_OUT=папка). Коды выхода те же.
Режим `--reverb-dir` (стадия 3): папка из `audio::tests::export_reverb_tracks` (щелчок в комнате, тоннеле, ущелье
и поле; `expected_reverb.txt`: предсказанный спад и уровень из замера пространства). По хвосту после прямого звука
считаются RT60 (обратное интегрирование Шрёдера, спад с -5 до -35 дБ, удвоенный) и энергия хвоста к энергии щелчка:
python3 tools/acoustics_check.py --reverb-dir папка [--rt-tol 0.35]
Проверяется: в поле хвоста нет (энергия хвоста меньше -90 дБ от щелчка), спад упорядочен поле < ущелье < тоннель <
комната, у комнаты и тоннеля измеренный RT60 в пределах `--rt-tol` от того, что дают их посылки в слоты (колонка
`RT60 expected`: сумма экспонент слотов, спад -5...-35 дБ; слой из двух слотов читается длиннее короткого).
Только стандартная библиотека.
"""
import argparse
@@ -53,19 +70,130 @@ def bands(path, rate, channels):
return out
def echo_peaks(path, rate, channels, min_gap_ms=10.0, rel=0.02):
"""Delays (ms from the first peak) of the peaks of the smoothed envelope of a track."""
raw = array.array("h")
with open(path, "rb") as f:
data = f.read()
raw.frombytes(data[: len(data) // 2 * 2])
frames = [sum(abs(raw[i + c]) for c in range(channels)) / 32768.0 for i in range(0, len(raw) - channels + 1, channels)]
win = max(1, rate // 1000) # 1 ms
env = []
acc = 0.0
for i, v in enumerate(frames):
acc += v
if i >= win:
acc -= frames[i - win]
env.append(acc / win)
first = max(range(min(len(env), rate // 20)), key=env.__getitem__) # the direct sound: loudest in the first 50 ms
top = env[first]
gap = int(min_gap_ms * rate / 1000)
peaks, i = [], first + int(0.02 * rate)
while i < len(env):
if env[i] >= rel * top:
j = max(range(i, min(len(env), i + gap)), key=env.__getitem__)
peaks.append((j - first) * 1000.0 / rate)
i = j + gap
else:
i += 1
return peaks
def check_echo(path, expect, tol, rate, channels):
peaks = echo_peaks(path, rate, channels)
print(f"{path}: peaks at {', '.join(f'{p:.1f}' for p in peaks) or 'none'} ms; expected {', '.join(f'{e:.1f}' for e in expect) or 'none'} ms")
ok = len(peaks) == len(expect) and all(abs(p - e) <= tol for p, e in zip(peaks, expect))
print(f" {'ok' if ok else 'FAIL'} (tolerance {tol} ms)")
return ok
def reverb_track(path, rate, channels, dry_ms=60.0):
"""(tail energy relative to the click in dB or None, RT60 in s or None) of a click's track."""
raw = array.array("h")
with open(path, "rb") as f:
data = f.read()
raw.frombytes(data[: len(data) // 2 * 2])
left = [s / 32768.0 for s in raw[0::channels]]
cut = int(dry_ms * rate / 1000)
dry = sum(v * v for v in left[:cut])
tail = left[cut:]
energy = sum(v * v for v in tail)
if dry <= 0 or energy <= 0 or 10 * math.log10(energy / dry) < -90:
return None, None
edc, acc = [0.0] * len(tail), 0.0
for i in range(len(tail) - 1, -1, -1):
acc += tail[i] * tail[i]
edc[i] = acc
level = lambda i: 10 * math.log10(max(edc[i], 1e-30) / edc[0])
a = next((i for i in range(len(tail)) if level(i) <= -5), None)
b = next((i for i in range(len(tail)) if level(i) <= -35), None)
rt = 2 * (b - a) / rate if a is not None and b is not None else None
return 10 * math.log10(energy / dry), rt
def check_reverb(folder, rate, channels, tol):
import os
want = {}
with open(os.path.join(folder, "expected_reverb.txt")) as f:
for line in f:
name, decay, wet, effective = line.split()
want[name] = (float(decay), float(wet), float(effective))
got = {}
print(f"{'scene':>8} {'decay of space':>16} {'RT60 expected':>14} {'measured RT60 s':>16} {'tail/click dB':>14}")
for name in ("field", "gorge", "tunnel", "room"):
level, rt = reverb_track(os.path.join(folder, name + ".raw"), rate, channels)
got[name] = (level, rt)
print(f"{name:>8} {want[name][0]:16.2f} {want[name][2]:14.2f} {('none' if rt is None else f'{rt:.2f}'):>16} {('none' if level is None else f'{level:.1f}'):>14}")
ok = got["field"][1] is None
print(f" field has no tail: {'ok' if ok else 'FAIL'}")
order = [got[n][1] for n in ("gorge", "tunnel", "room")]
ordered = all(v is not None for v in order) and order[0] < order[1] < order[2]
print(f" gorge < tunnel < room: {'ok' if ordered else 'FAIL'}")
close = True
for n in ("tunnel", "room"):
rt = got[n][1]
good = rt is not None and abs(rt - want[n][2]) <= tol * want[n][2]
close &= good
print(f" {n} within {tol:.0%} of the RT60 its sends make: {'ok' if good else 'FAIL'}")
return ok and ordered and close
def db(a, b):
return 10 * math.log10(max(a, 1e-20) / max(b, 1e-20))
def main():
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
p.add_argument("open")
p.add_argument("wall")
p.add_argument("open", nargs="?")
p.add_argument("wall", nargs="?")
p.add_argument("--echo", help="track with one click: check the delays of its echoes")
p.add_argument("--reverb-dir", help="folder written by export_reverb_tracks: check the decay of room, tunnel, gorge, field")
p.add_argument("--rt-tol", type=float, default=0.35, help="how far a measured RT60 may be from the predicted, as a fraction")
p.add_argument("--echo-dir", help="folder written by export_echo_tracks: check gorge, field and room")
p.add_argument("--expect", default="none", help="expected delays in ms, comma separated, or none")
p.add_argument("--tol", type=float, default=1.0, help="ms a peak may be off")
p.add_argument("--rate", type=int, default=48000)
p.add_argument("--channels", type=int, default=2)
p.add_argument("--min-high-cut", type=float, default=15.0, help="dB the band above 5 kHz must drop")
p.add_argument("--max-low-loss", type=float, default=8.0, help="dB the band below 300 Hz may drop")
a = p.parse_args()
if a.reverb_dir:
return 0 if check_reverb(a.reverb_dir, a.rate, a.channels, a.rt_tol) else 1
if a.echo_dir:
import os
ok = True
with open(os.path.join(a.echo_dir, "expected.txt")) as f:
for line in f:
name, _, vals = line.strip().partition(" ")
want = [float(v) for v in vals.split(",") if v]
# The ms in the room are for the walls the geometry says; only the first peak is compared there.
ok &= check_echo(os.path.join(a.echo_dir, name + ".raw"), want, a.tol, a.rate, a.channels)
return 0 if ok else 1
if a.echo:
want = [] if a.expect == "none" else [float(v) for v in a.expect.split(",")]
return 0 if check_echo(a.echo, want, a.tol, a.rate, a.channels) else 1
if not a.open or not a.wall:
p.error("give open.raw and wall.raw, or --echo / --echo-dir")
o, w = bands(a.open, a.rate, a.channels), bands(a.wall, a.rate, a.channels)
print(f"{'band, Hz':>10} {'open dB':>9} {'wall dB':>9} {'change dB':>10}")
ref = max(o)
+116 -1
View File
@@ -7,17 +7,21 @@
python3 tools/airdef.py install # положить мод в папку игры и включить
python3 tools/airdef.py play [аргументы игры] # запустить форк на папке игры
python3 tools/airdef.py all # всё по порядку: fork, game, mod, install
python3 tools/airdef.py check # совпадают ли форк, упаковщик, уровень API мода и ресурсы
Переменные окружения: HORIZON_HOME (папка игры), HORIZON_RESOURCES (подготовленные ресурсы
Minecraft 26.3), AIRDEF_GAME (готовый horizon-client вместо собранного).
"""
import glob
import hashlib
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
@@ -165,11 +169,122 @@ def play(args):
run([exe, "--resources", resources(), *args], cwd=home)
def fork_api():
"""Новейший уровень API форка и его таблица функций (horizon-mods/src/api_level.rs); None без форка."""
try:
text = (FORK / "crates" / "horizon-mods" / "src" / "api_level.rs").read_text(encoding="utf-8")
except OSError:
return None
try:
level = int(re.search(r"pub const LEVEL: u32 = (\d+);", text).group(1))
table = text[text.index("pub const FUNCTIONS"):]
table = table[:table.index("];")]
except (AttributeError, ValueError):
return None
return level, [(name, int(lvl)) for name, lvl in re.findall(r'\("([a-z.\-]+)", (\d+)\)', table)]
def needed_level(wasm: bytes, table) -> tuple:
"""Уровень, который нужен модулю: самая новая функция, которую он импортирует (как api_level::needed).
В компоненте имя функции записано как 0x00, длина, UTF-8 имя; по этим байтам и ищем."""
need = (0, None)
for name, level in table:
part = name.split(".", 1)[1].encode()
if level > need[0] and b"\x00" + bytes([len(part)]) + part in wasm:
need = (level, name)
return need
def game_level():
"""Уровень, который игра записала в себя (метка horizon-mod-api-level:NNNN;); None, если игры нет.
Игра без метки считается уровня 0, как в api_level::read_mark."""
try:
exe = built_game()
except SystemExit:
return None
if not exe.exists():
return None
found = re.search(rb"horizon-mod-api-level:(\d{4});", exe.read_bytes())
return int(found.group(1)) if found else 0
def check():
"""Говорит прямо, совпадают ли форк, упаковщик, уровень API мода с игрой и ресурсы. Код 0 — да, 1 — нет."""
commit = (ROOT / "HORIZON_COMMIT").read_text().strip()
patches = sorted((ROOT / "horizon-patches").glob("*.patch"))
stamp = fork_stamp(commit, patches)
problems = []
def line(name, ok, text):
print(f"[{'ok' if ok else 'нет'}] {name}: {text}")
if not ok:
problems.append(name)
stamp_file = FORK / ".git" / "airdef-stamp"
fork_ok = stamp_file.exists() and stamp_file.read_text().strip() == stamp
line("форк", fork_ok, f"коммит {commit[:12]}, патчей {len(patches)}" if fork_ok else
"horizon/ не собран из HORIZON_COMMIT и horizon-patches/: python3 tools/airdef.py fork")
tool = shutil.which("cargo-horizon-mod") or str(Path.home() / ".cargo" / "bin" / ("cargo-horizon-mod" + EXE))
packer_stamp = FORK / ".git" / "airdef-packer-stamp"
packer_ok = Path(tool).exists() and packer_stamp.exists() and packer_stamp.read_text().strip() == stamp
line("упаковщик", packer_ok, "собран из этого форка" if packer_ok else
"не установлен или собран из другого форка: python3 tools/airdef.py mod")
api = fork_api()
try:
mod_file = hmod()
except SystemExit:
mod_file = None
if api is None:
line("уровень API", False, "нет или не читается horizon/crates/horizon-mods/src/api_level.rs: python3 tools/airdef.py fork")
elif mod_file is None:
line("уровень API", False, "нет airdef.hmod: python3 tools/airdef.py mod")
else:
level, table = api
with zipfile.ZipFile(mod_file) as z:
wasm = z.read("mod.wasm") if "mod.wasm" in z.namelist() else b""
need, function = needed_level(wasm, table)
game = game_level()
text = f"мод нужен {need} ({function}), форк {level}, игра " + ("не собрана" if game is None else str(game))
if game is None:
line("уровень API", False, text + ": python3 tools/airdef.py game")
else:
# Сравнивается уровень, не код: одинаковый уровень не доказывает одинаковые патчи.
supported = need <= game and game == level
line("уровень API", supported, text if supported else
text + ": игра не поддерживает мод или собрана из другого форка")
try:
res = resources()
except SystemExit as e:
res = None
line("ресурсы", False, str(e.code))
if res is not None:
try:
preparer = (FORK / "crates" / "horizon-prepare" / "src" / "lib.rs").read_text(encoding="utf-8")
schema = int(re.search(r"pub const SCHEMA: i64 = (\d+);", preparer).group(1))
except (OSError, AttributeError):
schema = None
try:
manifest = json.loads((res / "horizon-resources.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
manifest = {}
ok = schema is not None and manifest.get("schema") == schema and manifest.get("minecraft") == "26.3"
line("ресурсы", ok, f"{res}, схема {schema}, Minecraft 26.3" if ok else
f"{res}: схема {manifest.get('schema')}, Minecraft {manifest.get('minecraft')}; нужны схема {schema} и Minecraft 26.3")
if problems:
sys.exit("Не совпадает: " + ", ".join(problems) + ".")
print("Всё совпадает: форк, упаковщик, уровень API мода и ресурсы.")
def main():
if len(sys.argv) < 2:
sys.exit(__doc__)
cmd, rest = sys.argv[1], sys.argv[2:]
steps = {"fork": fork, "game": game, "mod": mod, "install": install}
steps = {"fork": fork, "game": game, "mod": mod, "install": install, "check": check}
if cmd == "play":
play(rest)
elif cmd == "all":
+223 -9
View File
@@ -5,14 +5,22 @@
Поворачивает и масштабирует их движок форка (render.set-models), так что ракурсы больше не нужны.
Координаты элементов — в пределах -16..32 (иначе Horizon отбрасывает модель).
Запуск: python3 tools/gen_models.py
Запуск: python3 tools/gen_models.py [--check]
Скрипт пишет во временный каталог, проверяет набор (validate: ссылки моделей и текстур, JSON, PNG) и только потом
переносит результат в airdef-mod/; при ошибке проверки ничего не записывает. Список созданных им файлов
с sha256 лежит в airdef-mod/generated-manifest.txt (формат sha256sum). Удаляются и перезаписываются только файлы
из этого списка и только если их не правили вручную; чужой файл на пути генерируемого останавливает запуск.
--check ничего не пишет и выходит с кодом 1, если airdef-mod/ отличается от того, что дал бы запуск.
"""
import hashlib
import json
import math
import os
import sys
import random
import shutil
import tempfile
import zlib
from PIL import Image
@@ -1692,13 +1700,10 @@ def write_json(path, obj):
json.dump(obj, f, ensure_ascii=False, indent=1)
def main():
# Только своё: звуки (tools/gen_sounds.py) и sounds.json не трогаем.
for sub in ("blockstates", "items", "lang", "models", "textures"):
shutil.rmtree(os.path.join(ROOT, "assets", NS, sub), ignore_errors=True)
shutil.rmtree(os.path.join(ROOT, "data"), ignore_errors=True)
a = os.path.join(ROOT, "assets", NS)
d = os.path.join(ROOT, "data", NS, "horizon")
def generate(out):
# Пишет весь набор в каталог out (зеркало airdef-mod/). Звуки (tools/gen_sounds.py) и sounds.json не трогаем.
a = os.path.join(out, "assets", NS)
d = os.path.join(out, "data", NS, "horizon")
os.makedirs(os.path.join(a, "textures", "block"))
os.makedirs(os.path.join(a, "textures", "item"))
lang = {}
@@ -1738,7 +1743,216 @@ def main():
lang[f"item.{NS}.cam_{name}"] = title
write_json(os.path.join(a, "lang", "en_us.json"), lang)
write_json(os.path.join(a, "lang", "ru_ru.json"), lang)
print(f"{len(MODELS)} моделей, {len(ITEMS)} предметов, {len(TEXTURES)} текстур -> {ROOT}")
return len(MODELS), len(ITEMS), len(TEXTURES)
# ---------------------------------------------------------------- проверка набора до переноса
def check_png(path, rel):
try:
with Image.open(path) as im:
w, h = im.size
im.load()
except Exception as e: # битый файл или не PNG
return [f"{rel}: PNG не открывается: {e}"]
errs = []
if w != h:
errs.append(f"{rel}: текстура не квадратная, {w}x{h}")
if w < 8 or w > 256 or w & (w - 1):
errs.append(f"{rel}: размер {w}x{h}, ожидается степень двойки от 8 до 256")
return errs
def validate(stage):
# Проверяет набор в каталоге stage до того, как он попадёт в airdef-mod/. Возвращает список ошибок.
# Ссылки airdef:<путь> проверяем по набору; ссылки minecraft:... и без пространства имён — ванильные, их не проверяем.
errors = []
files, docs = set(), {}
for dirpath, _, names in os.walk(stage):
for name in sorted(names):
path = os.path.join(dirpath, name)
rel = os.path.relpath(path, stage).replace(os.sep, "/")
files.add(rel)
if name.endswith(".json"):
try:
with open(path, encoding="utf-8") as f:
docs[rel] = json.load(f)
except (OSError, ValueError) as e:
errors.append(f"{rel}: не разбирается как JSON: {e}")
elif name.endswith(".png"):
errors.extend(check_png(path, rel))
def check_ref(ref, kind, ext, owner):
if not isinstance(ref, str) or not ref.startswith(NS + ":"):
return
path = ref[len(NS) + 1:]
if not path or path.startswith("/") or ".." in path.split("/"):
errors.append(f"{owner}: недопустимая ссылка {ref}")
return
rel = f"assets/{NS}/{kind}/{path}{ext}"
if rel not in files:
errors.append(f"{owner}: ссылка {ref} не найдена ({rel})")
def pick(prefix):
return sorted(rel for rel in docs if rel.startswith(f"assets/{NS}/{prefix}"))
for rel in pick("models/"):
doc = docs[rel]
if not isinstance(doc, dict):
errors.append(f"{rel}: модель должна быть объектом JSON")
continue
if "parent" in doc:
check_ref(doc["parent"], "models", ".json", rel)
tex = doc.get("textures", {})
for key, val in tex.items():
if isinstance(val, str) and val.startswith("#"):
if val[1:] not in tex:
errors.append(f"{rel}: текстура {val} не объявлена")
else:
check_ref(val, "textures", ".png", rel)
for i, el in enumerate(doc.get("elements", [])):
for corner in ("from", "to"):
pos = el.get(corner, [])
if len(pos) != 3 or any(not -16 <= c <= 32 for c in pos):
errors.append(f"{rel}: элемент {i}, {corner} {pos} вне -16..32")
for face, fd in el.get("faces", {}).items():
tex_ref = fd.get("texture")
if tex_ref is None:
errors.append(f"{rel}: элемент {i}, грань {face} без texture")
elif tex_ref.startswith("#"):
if tex_ref[1:] not in tex:
errors.append(f"{rel}: элемент {i}, грань {face}: {tex_ref} не объявлена")
else:
check_ref(tex_ref, "textures", ".png", rel)
for rel in pick("blockstates/"):
for variant in docs[rel].get("variants", {}).values():
for m in (variant if isinstance(variant, list) else [variant]):
check_ref(m.get("model"), "models", ".json", rel)
for rel in pick("items/"):
check_ref(docs[rel].get("model", {}).get("model"), "models", ".json", rel)
# У каждого блока есть blockstate, и наоборот.
blocks = {rel[len(f"assets/{NS}/models/block/"):] for rel in pick("models/block/")}
states = {rel[len(f"assets/{NS}/blockstates/"):] for rel in pick("blockstates/")}
for name in sorted(blocks - states):
errors.append(f"models/block/{name}: нет blockstate")
for name in sorted(states - blocks):
errors.append(f"blockstates/{name}: нет модели блока")
en = docs.get(f"assets/{NS}/lang/en_us.json")
ru = docs.get(f"assets/{NS}/lang/ru_ru.json")
if en is None or ru is None:
errors.append("нет lang/en_us.json или lang/ru_ru.json")
elif set(en) != set(ru):
errors.append(f"lang: ключи en_us и ru_ru различаются: {sorted(set(en) ^ set(ru))[:5]}")
return errors
MANIFEST = os.path.join(ROOT, "generated-manifest.txt")
def file_sha(path):
with open(path, "rb") as f:
return hashlib.sha256(f.read()).hexdigest()
def read_manifest():
# Строки "<sha256> <путь от airdef-mod>", как у sha256sum. Только файлы, которые записал этот скрипт.
if not os.path.exists(MANIFEST):
return {}
entries = {}
with open(MANIFEST, encoding="utf-8") as f:
for line in f:
digest, rel = line.rstrip("\n").split(" ", 1)
if rel.startswith("/") or ".." in rel.split("/"):
sys.exit(f"{MANIFEST}: недопустимый путь {rel}")
entries[rel] = digest
return entries
def prune_empty(path):
d = os.path.dirname(path)
while d != ROOT and os.path.isdir(d) and not os.listdir(d):
os.rmdir(d)
d = os.path.dirname(d)
def main():
args = sys.argv[1:]
if any(arg != "--check" for arg in args):
sys.exit("Использование: python3 tools/gen_models.py [--check]")
check = "--check" in args
old = read_manifest()
with tempfile.TemporaryDirectory() as stage:
counts = generate(stage)
errors = validate(stage)
if errors:
print(f"ПРОВЕРКА НАБОРА НЕ ПРОЙДЕНА, ошибок: {len(errors)}. Ничего не записано в {ROOT}.")
for err in errors:
print(f" - {err}")
sys.exit(1)
new = {}
for dirpath, dirs, names in os.walk(stage):
dirs.sort()
for name in sorted(names):
path = os.path.join(dirpath, name)
new[os.path.relpath(path, stage).replace(os.sep, "/")] = file_sha(path)
# create: файла нет; update: файл записан генератором и с тех пор не менялся; same: уже совпадает;
# conflict: на пути лежит чужой или вручную правленный файл, поэтому ничего не пишем.
create, update, same, conflict = [], [], [], []
for rel in sorted(new):
target = os.path.join(ROOT, rel)
if not os.path.exists(target):
create.append(rel)
elif not os.path.isfile(target):
conflict.append(rel)
else:
cur = file_sha(target)
if cur == new[rel]:
same.append(rel)
elif rel in old and old[rel] == cur:
update.append(rel)
else:
conflict.append(rel)
# remove: генератор больше не создаёт файл и он не менялся; keep: не создаёт, но правлен вручную, оставляем.
remove, keep = [], []
for rel in sorted(old):
target = os.path.join(ROOT, rel)
if rel in new or not os.path.isfile(target):
continue
(remove if file_sha(target) == old[rel] else keep).append(rel)
if conflict:
for rel in conflict:
print(f"КОНФЛИКТ: {rel} занят файлом, которого нет в manifest (или он правлен вручную)")
sys.exit("Ничего не записано. Уберите или переименуйте эти файлы и запустите снова.")
manifest_text = "".join(f"{new[rel]} {rel}\n" for rel in sorted(new))
manifest_old = None
if os.path.exists(MANIFEST):
with open(MANIFEST, encoding="utf-8") as f:
manifest_old = f.read()
manifest_changed = manifest_text != manifest_old
for rel in keep:
print(f"ВНИМАНИЕ: {rel} больше не генерируется, но правлен вручную: оставлен, из manifest убран")
for label, rels in (("новый", create), ("обновить", update), ("удалить", remove)):
for rel in rels:
print(f"{label}: {rel}")
if manifest_changed:
print("manifest: обновить")
if check:
differs = create or update or remove or manifest_changed
print("различий нет" if not differs else "различия с генератором: см. выше")
sys.exit(1 if differs else 0)
for rel in create + update:
dst = os.path.join(ROOT, rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copyfile(os.path.join(stage, rel), dst)
for rel in remove:
path = os.path.join(ROOT, rel)
os.remove(path)
prune_empty(path)
if manifest_changed:
with open(MANIFEST, "w", encoding="utf-8", newline="\n") as f:
f.write(manifest_text)
models, items, textures = counts
print(f"{models} моделей, {items} предметов, {textures} текстур -> {ROOT}: "
f"создано {len(create)}, обновлено {len(update)}, удалено {len(remove)}, без изменений {len(same)}")
if __name__ == "__main__":