Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38e515dea9 | |||
| 57a5426e65 | |||
| ccc3e20902 | |||
| 5003376e70 |
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 СБ. Сбитый дрон падает с остатком скорости, и его боевая часть
|
||||
может сдетонировать на земле.
|
||||
|
||||
@@ -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
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
|
||||
+272
-6
@@ -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.
|
||||
@@ -192,6 +197,8 @@ struct PendingSound {
|
||||
const SOUND_SPEED: f64 = 17.15;
|
||||
/// Past this the far variant plays: muffled, more rolling echo.
|
||||
const FAR: f64 = 140.0;
|
||||
/// The same when the game's acoustics muffle (its tracing reaches 512 blocks).
|
||||
const ACOUSTIC_FAR: f64 = 480.0;
|
||||
|
||||
/// Impulsive sounds (blasts, launches, shots, shock waves) come late from afar and have far variants.
|
||||
fn travels(event: &str) -> bool {
|
||||
@@ -210,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;
|
||||
@@ -234,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;
|
||||
@@ -451,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);
|
||||
@@ -460,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);
|
||||
}
|
||||
}
|
||||
@@ -492,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,
|
||||
@@ -501,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;
|
||||
@@ -747,13 +774,16 @@ impl AirDef {
|
||||
let now = self.sim.tick;
|
||||
// Blasts play layered (cinema.rs), the nearest ones in full.
|
||||
let mut booms: Vec<(f64, usize, DVec3, f32, f32)> = Vec::new();
|
||||
// The game muffles by walls and distance itself (#96): the near variants play out to the
|
||||
// reach of its tracing, so nothing is muffled twice; past it the far ones still do.
|
||||
let far = if render::acoustics() { ACOUSTIC_FAR } else { FAR };
|
||||
for (event, at, volume, pitch) in out.sounds {
|
||||
let dist = at.distance(self.listener);
|
||||
if let Some(power) = cinema::blast_power(event) {
|
||||
booms.push((dist, power, at, volume, pitch));
|
||||
} else if travels(event) {
|
||||
// Seen first, heard later; from afar muffled.
|
||||
let name = if dist > FAR { format!("{event}_far") } else { event.to_string() };
|
||||
let name = if dist > far { format!("{event}_far") } else { event.to_string() };
|
||||
let delay = (dist / SOUND_SPEED) as u64;
|
||||
if delay == 0 {
|
||||
render::play_sound(&name, at.x, at.y, at.z, volume, pitch);
|
||||
@@ -766,7 +796,7 @@ impl AirDef {
|
||||
}
|
||||
booms.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
for (rank, (dist, power, at, volume, pitch)) in booms.into_iter().enumerate() {
|
||||
for l in cinema::layers(power, dist, FAR, volume, pitch, rank) {
|
||||
for l in cinema::layers(power, dist, far, volume, pitch, rank) {
|
||||
// Seen first, heard later: each voice after the sound has travelled.
|
||||
let delay = (dist / SOUND_SPEED) as u64 + l.delay;
|
||||
if delay == 0 {
|
||||
@@ -852,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
|
||||
}
|
||||
|
||||
@@ -903,6 +942,7 @@ impl AirDef {
|
||||
|
||||
/// Adds a checked layout to the scene: nothing here can fail.
|
||||
fn commit_layout(&mut self, layout: layout::Layout) {
|
||||
let base = self.sim.sams.len();
|
||||
for r in layout.sams {
|
||||
let id = self.sim.id();
|
||||
let mut sam = Sam::new(id, r.kind, r.pos, r.heading);
|
||||
@@ -940,6 +980,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);
|
||||
@@ -948,6 +994,10 @@ impl AirDef {
|
||||
let w: Vec<&str> = w.iter().map(String::as_str).collect();
|
||||
self.wave.parse(&w);
|
||||
}
|
||||
if let Some(a) = layout.acoustics {
|
||||
self.acoustics = Some(a);
|
||||
render::set_acoustics(a);
|
||||
}
|
||||
if let Some(f) = layout.far {
|
||||
self.far = f;
|
||||
}
|
||||
@@ -957,6 +1007,11 @@ impl AirDef {
|
||||
for (kind, r) in layout.roles {
|
||||
self.sim.roles.insert(kind, r);
|
||||
}
|
||||
for (i, d) in layout.doctrines {
|
||||
if let Some(s) = self.sim.sams.get_mut(base + i) {
|
||||
s.doctrine = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn give(&self) {
|
||||
@@ -1008,6 +1063,7 @@ impl AirDef {
|
||||
self.sim.shader = shader;
|
||||
self.ground = Ground::default();
|
||||
self.monitor = None;
|
||||
self.map_close();
|
||||
self.grid = false;
|
||||
self.grid_ids.clear();
|
||||
// The last world's transient state: its sounds were due on its own
|
||||
@@ -1351,6 +1407,8 @@ impl AirDef {
|
||||
widgets.push(Widget::List(ListWidget { id: MENU_KINDS, rows, selected: None }));
|
||||
let _ = &self.monitor;
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_GRID, label: "Командный пункт: все комплексы (J)".into() }));
|
||||
let custom = self.sim.sams.iter().filter(|s| !s.doctrine.is_default()).count();
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_DOCTRINE, label: format!("Доктрины: охрана, сектор, резерв (особых: {custom})…") }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_WAVE, label: "Налёт: состав и запуск…".into() }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_REPLAYS, label: "Повторы…".into() }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_MORE, label: "Огонь, ремонт, эффекты…".into() }));
|
||||
@@ -1389,6 +1447,7 @@ impl AirDef {
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_SHADER, label: format!("Эффекты: {fx}") }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_DAMAGE, label: format!("Разрушения: {}", onoff(self.sim.damage)) }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_FAR, label: format!("Дальний ландшафт: {}", onoff(self.far)) }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_ACOUSTICS, label: format!("Акустика (преграды, эхо): {}", onoff(self.acoustics_on())) }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Назад".into() }));
|
||||
}
|
||||
Page::Wave => {
|
||||
@@ -1413,6 +1472,37 @@ impl AirDef {
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_LAUNCH, label: format!("▶ ПУСК ({})", w.total()) }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Состав".into() }));
|
||||
}
|
||||
Page::Fleet => {
|
||||
title = "Доктрины — клик по комплексу".to_string();
|
||||
let rows = self
|
||||
.fleet()
|
||||
.iter()
|
||||
.map(|&i| {
|
||||
let s = &self.sim.sams[i];
|
||||
format!("№{} {} · {}", i + 1, s.kind.name(), s.doctrine.text(&self.sim.targets))
|
||||
})
|
||||
.collect();
|
||||
widgets.push(Widget::List(ListWidget { id: FLEET_ROWS, rows, selected: None }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Назад".into() }));
|
||||
}
|
||||
Page::Doctrine(id) => {
|
||||
let Some(i) = self.sim.sams.iter().position(|s| s.id == id) else { return self.open_menu(Page::Fleet) };
|
||||
let s = &self.sim.sams[i];
|
||||
let d = &s.doctrine;
|
||||
title = format!("№{} {} — доктрина (объяснение: /airdef why {})", i + 1, s.kind.name(), i + 1);
|
||||
let guard = match d.guards.len() {
|
||||
0 => "нет (все цели своих классов)".to_string(),
|
||||
1 => sim::doctrine::object_name(&self.sim.targets, d.guards[0]),
|
||||
n => format!("{n} объектов (клик — снять)"),
|
||||
};
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_GUARD, label: format!("Охраняемый объект: {guard}") }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_SECTOR_DIR, label: format!("Сектор: {}", d.sector_text()) }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_SECTOR_WIDTH, label: format!("Ширина сектора: ±{:.0}°", d.sector.map_or(45.0, |(_, h)| h.to_degrees())) }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_RESERVE, label: format!("Резерв ракет: {} (в магазине {})", d.reserve, s.missiles) }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_SALVO, label: format!("Залп по цели: {}", d.salvo.name()) }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_DOC_RESET, label: "Сбросить доктрину".into() }));
|
||||
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Комплексы".into() }));
|
||||
}
|
||||
Page::Roles(kind) => {
|
||||
title = format!("{} ({}) — по каким целям стрелять", kind.name(), panel::distance(kind.range()));
|
||||
let _ = ItemWidget { id: 0, item: String::new(), name: String::new() };
|
||||
@@ -1431,6 +1521,75 @@ impl AirDef {
|
||||
ui::open_screen(&Screen { title, widgets });
|
||||
}
|
||||
|
||||
/// The systems that have a doctrine to set: those with missiles (indices into the sim's list).
|
||||
fn fleet(&self) -> Vec<usize> {
|
||||
(0..self.sim.sams.len()).filter(|&i| self.sim.sams[i].kind.armed()).collect()
|
||||
}
|
||||
|
||||
/// Changes one system's doctrine (the menu's buttons and `/airdef doctrine`).
|
||||
fn edit_doctrine(&mut self, i: usize, f: impl FnOnce(&mut sim::doctrine::Doctrine)) {
|
||||
if let Some(s) = self.sim.sams.get_mut(i) {
|
||||
f(&mut s.doctrine);
|
||||
// It chooses again under the new rules.
|
||||
s.target = None;
|
||||
s.gun_target = None;
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// `/airdef doctrine [<n|all> reserve K | salvo eco|normal|heavy | sector C H | sector off | guard N[,M]|off | reset]` (#47).
|
||||
fn doctrine_command(&mut self, w: &[&str]) -> Result<String, String> {
|
||||
const USAGE: &str = "/airdef doctrine <номер|all> reserve <K> | salvo eco|normal|heavy | sector <центр°> <полуширина°> | sector off | guard <объект№>[,<№>] | guard off | reset";
|
||||
let Some((who, what)) = w.split_first() else {
|
||||
for i in self.fleet() {
|
||||
let s = &self.sim.sams[i];
|
||||
say(&format!("№{} {}: {}", i + 1, s.kind.name(), s.doctrine.text(&self.sim.targets)));
|
||||
}
|
||||
return Ok(format!("Изменить: {USAGE}"));
|
||||
};
|
||||
let which: Vec<usize> = if *who == "all" {
|
||||
self.fleet()
|
||||
} else {
|
||||
let i = who.parse::<usize>().ok().and_then(|n| n.checked_sub(1)).filter(|&i| self.sim.sams.get(i).is_some_and(|s| s.kind.armed())).ok_or("Нет такого вооружённого комплекса")?;
|
||||
vec![i]
|
||||
};
|
||||
let num = |v: Option<&&str>, what: &str, lo: f64, hi: f64| -> Result<f64, String> {
|
||||
v.and_then(|v| v.parse::<f64>().ok()).filter(|x| x.is_finite() && (lo..=hi).contains(x)).ok_or(format!("{what}: число от {lo} до {hi}"))
|
||||
};
|
||||
let mut edit: Box<dyn Fn(&mut sim::doctrine::Doctrine)> = match what {
|
||||
["reserve", k] => {
|
||||
let k = num(Some(k), "резерв", 0.0, sim::doctrine::MAX_RESERVE as f64)? as u32;
|
||||
Box::new(move |d| d.reserve = k)
|
||||
}
|
||||
["salvo", v] => {
|
||||
let v = sim::doctrine::Salvo::from_key(v).ok_or("Залп: eco, normal или heavy")?;
|
||||
Box::new(move |d| d.salvo = v)
|
||||
}
|
||||
["sector", "off"] => Box::new(|d| d.sector = None),
|
||||
["sector", c, h] => {
|
||||
let c = num(Some(c), "центр сектора", -720.0, 720.0)?.to_radians();
|
||||
let h = num(Some(h), "полуширина сектора", 0.0, 180.0)?.to_radians();
|
||||
Box::new(move |d| d.sector = Some((c, h)))
|
||||
}
|
||||
["guard", "off"] => Box::new(|d| d.guards.clear()),
|
||||
["guard", list] => {
|
||||
let mut at = Vec::new();
|
||||
for n in list.split(',') {
|
||||
let k = n.parse::<usize>().ok().and_then(|n| n.checked_sub(1)).and_then(|k| self.sim.targets.get(k)).ok_or(format!("Нет объекта «{n}» (отметьте его /airdef target here)"))?;
|
||||
at.push(*k);
|
||||
}
|
||||
Box::new(move |d| d.guards = at.clone())
|
||||
}
|
||||
["reset"] => Box::new(|d| *d = sim::doctrine::Doctrine::default()),
|
||||
_ => return Err(USAGE.into()),
|
||||
};
|
||||
for &i in &which {
|
||||
self.edit_doctrine(i, &mut edit);
|
||||
}
|
||||
let first = which.first().map(|&i| self.sim.sams[i].doctrine.text(&self.sim.targets)).unwrap_or_default();
|
||||
Ok(format!("Доктрина ({} компл.): {first}", which.len()))
|
||||
}
|
||||
|
||||
fn set_roles(&mut self, kind: SamKind, mask: Option<u8>) {
|
||||
let mask = match mask {
|
||||
Some(m) => {
|
||||
@@ -1478,6 +1637,7 @@ impl Mod for AirDef {
|
||||
session: 0,
|
||||
smoke: Default::default(),
|
||||
far: true,
|
||||
acoustics: None,
|
||||
moving: None,
|
||||
hd: false,
|
||||
world: None,
|
||||
@@ -1492,7 +1652,9 @@ impl Mod for AirDef {
|
||||
model_ids: std::collections::HashMap::new(),
|
||||
model_generation: u32::MAX,
|
||||
by_id: Vec::new(),
|
||||
map: map::MapUi::default(),
|
||||
};
|
||||
let _ = input::register_key("map", "Карта: командная, расстановка", "key.keyboard.m");
|
||||
let _ = input::register_key("menu", "Меню AirDef", "key.keyboard.n");
|
||||
let _ = input::register_key("monitor", "Монитор ПВО: следующий комплекс", "key.keyboard.k");
|
||||
let _ = input::register_key("grid", "Командный пункт: все комплексы", "key.keyboard.j");
|
||||
@@ -1527,6 +1689,7 @@ impl Mod for AirDef {
|
||||
for event in input::poll() {
|
||||
match event {
|
||||
Event::UseStart(u) => self.use_item(&u.item.id),
|
||||
Event::KeyDown(k) if k == "map" => self.map_toggle(),
|
||||
Event::KeyDown(k) if k == "menu" => {
|
||||
if self.replay.is_some() {
|
||||
self.stop_replay();
|
||||
@@ -1612,6 +1775,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
|
||||
@@ -1645,6 +1813,9 @@ impl Mod for AirDef {
|
||||
}
|
||||
}
|
||||
self.emit(out);
|
||||
self.map.sample(&self.sim);
|
||||
// The map's events and shapes: at the tick's pace (a frame hook's budget is too small for it).
|
||||
self.map_frame();
|
||||
self.draw_hud(DVec3::new(p.x, p.y, p.z), p.yaw);
|
||||
// The replay takes the screen; else the monitors and the cameras they show.
|
||||
if let Some(r) = &self.replay {
|
||||
@@ -1717,6 +1888,58 @@ impl Mod for AirDef {
|
||||
}
|
||||
return;
|
||||
}
|
||||
(Page::Main, BTN_DOCTRINE) => {
|
||||
self.open_menu(Page::Fleet);
|
||||
return;
|
||||
}
|
||||
(Page::Fleet, FLEET_ROWS) => {
|
||||
if let Some(&i) = self.fleet().get(row as usize) {
|
||||
self.open_menu(Page::Doctrine(self.sim.sams[i].id));
|
||||
}
|
||||
return;
|
||||
}
|
||||
(Page::Fleet, BTN_BACK) => {
|
||||
self.open_menu(Page::Main);
|
||||
return;
|
||||
}
|
||||
(Page::Doctrine(_), BTN_BACK) => {
|
||||
self.open_menu(Page::Fleet);
|
||||
return;
|
||||
}
|
||||
(Page::Doctrine(id), b) => {
|
||||
let Some(i) = self.sim.sams.iter().position(|s| s.id == id) else { return };
|
||||
let objects = self.sim.targets.clone();
|
||||
match b {
|
||||
BTN_RESERVE => self.edit_doctrine(i, |d| d.reserve = [0, 1, 2, 4, 8, 16].into_iter().find(|&r| r > d.reserve).unwrap_or(0)),
|
||||
BTN_SALVO => self.edit_doctrine(i, |d| d.salvo = d.salvo.next()),
|
||||
// Eight directions of 45 degrees, then off.
|
||||
BTN_SECTOR_DIR => self.edit_doctrine(i, |d| {
|
||||
let half = d.sector.map_or(45f64.to_radians(), |(_, h)| h);
|
||||
d.sector = match d.sector {
|
||||
None => Some((0.0, half)),
|
||||
Some((c, _)) if (c.to_degrees().rem_euclid(360.0) / 45.0).round() < 7.0 => Some((c + 45f64.to_radians(), half)),
|
||||
Some(_) => None,
|
||||
};
|
||||
}),
|
||||
BTN_SECTOR_WIDTH => self.edit_doctrine(i, |d| {
|
||||
if let Some((c, h)) = d.sector {
|
||||
let deg = [30.0, 45.0, 60.0, 90.0, 135.0].into_iter().find(|&w| w > h.to_degrees() + 0.5).unwrap_or(30.0);
|
||||
d.sector = Some((c, f64::to_radians(deg)));
|
||||
}
|
||||
}),
|
||||
// None, then each object in turn, then none.
|
||||
BTN_GUARD => self.edit_doctrine(i, |d| {
|
||||
let at = d.guards.first().and_then(|g| objects.iter().position(|t| t.distance(*g) < 16.0));
|
||||
d.guards = match (d.guards.len(), at) {
|
||||
(0, _) => objects.first().copied().into_iter().collect(),
|
||||
(1, Some(k)) => objects.get(k + 1).copied().into_iter().collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
}),
|
||||
BTN_DOC_RESET => self.edit_doctrine(i, |d| *d = sim::doctrine::Doctrine::default()),
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
(Page::More, BTN_HOLD) => self.sim.hold = !self.sim.hold,
|
||||
(Page::Main, BTN_MONITOR) => self.cycle_monitor(),
|
||||
(Page::Main, BTN_WAVE) => {
|
||||
@@ -1823,6 +2046,12 @@ impl Mod for AirDef {
|
||||
self.apply_far();
|
||||
self.dirty = true;
|
||||
}
|
||||
(Page::More, BTN_ACOUSTICS) => {
|
||||
let on = !self.acoustics_on();
|
||||
self.acoustics = Some(on);
|
||||
render::set_acoustics(on);
|
||||
self.dirty = true;
|
||||
}
|
||||
(Page::More, BTN_DAMAGE) => {
|
||||
self.sim.damage = !self.sim.damage;
|
||||
self.dirty = true;
|
||||
@@ -1881,12 +2110,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
|
||||
@@ -1899,7 +2130,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
|
||||
}
|
||||
@@ -1931,6 +2164,20 @@ impl Mod for AirDef {
|
||||
self.launch_wave(&mut out)?;
|
||||
None
|
||||
}
|
||||
["sites"] => {
|
||||
// The launch sites the radars have found, and the strike missiles (#101).
|
||||
let p = player().ok_or("Нет игрока")?;
|
||||
for l in self.sim.site_lines(DVec3::new(p.x, p.y, p.z)) {
|
||||
say(&l);
|
||||
}
|
||||
None
|
||||
}
|
||||
["strike", n] => {
|
||||
// A strike missile at known site n, from where the player stands.
|
||||
let p = player().ok_or("Нет игрока")?;
|
||||
let id = n.parse::<u32>().map_err(|_| "/airdef strike <номер ПУ из /airdef sites>")?;
|
||||
Some(self.sim.order_strike(id, DVec3::new(p.x, p.y, p.z), &mut out)?)
|
||||
}
|
||||
["move", n] => {
|
||||
// Unit n (as the summary numbers them) drives to where the player stands.
|
||||
let p = player().ok_or("Нет игрока")?;
|
||||
@@ -1988,12 +2235,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] => {
|
||||
@@ -2020,6 +2272,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() })
|
||||
@@ -2029,13 +2290,18 @@ impl Mod for AirDef {
|
||||
Some(format!("Эффекты: {}", match *v { "off" => "выкл", "hd" => "HD", _ => "обычные" }))
|
||||
}
|
||||
["stats"] => Some(format!(
|
||||
"В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}, разбились о препятствия: {}; {}",
|
||||
"В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}, разбились о препятствия: {}; удары по ПУ: пущено {}, уничтожено {}, подавлено {}, промахов {}, сбито ПВО {}; {}",
|
||||
self.sim.drones.iter().filter(|d| d.alive()).count(),
|
||||
self.sim.missiles.len(),
|
||||
self.sim.sams.len(),
|
||||
self.sim.kills,
|
||||
self.sim.hits,
|
||||
self.sim.crashes,
|
||||
self.sim.strikes.fired,
|
||||
self.sim.strikes.destroyed,
|
||||
self.sim.strikes.stunned,
|
||||
self.sim.strikes.missed,
|
||||
self.sim.strikes.shot_down,
|
||||
self.ground.world.text()
|
||||
)),
|
||||
_ => return Err(help()),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,16 @@ pub fn summary(sim: &Sim, air: &[&Drone], player: DVec3) -> Vec<Line> {
|
||||
if hidden > 0 {
|
||||
lines.push(line(format!("Вне видимости РЛС: {hidden}"), GREY));
|
||||
}
|
||||
// The launch sites found, and the strike missiles (#101).
|
||||
if !sim.strikes.known.is_empty() || !sim.strikes.flights.is_empty() {
|
||||
let st = &sim.strikes;
|
||||
let live = st.known.iter().filter(|k| !matches!(k.state, crate::sim::strike::SiteState::Destroyed(_))).count();
|
||||
let mut text = format!("Известных ПУ: {live} · ударных ракет: {}", st.stock);
|
||||
if let Some(f) = st.flights.iter().map(|f| f.arrive.saturating_sub(sim.tick)).min() {
|
||||
text += &format!(" · в полёте {} (до цели {} с)", st.flights.len(), f / 20);
|
||||
}
|
||||
lines.push(line(text, YELLOW));
|
||||
}
|
||||
if !sim.target_health.is_empty() {
|
||||
let list: Vec<String> = sim.target_health.iter().map(|h| format!("{h:.0}%")).collect();
|
||||
let worst = sim.target_health.iter().copied().fold(100.0, f64::min);
|
||||
|
||||
@@ -107,6 +107,44 @@ pub fn draw(sim: &Sim, player: DVec3, yaw: f32) -> Vec<Shape> {
|
||||
s.push(square(x, y, 1.0, 0xFFFFE060));
|
||||
}
|
||||
}
|
||||
// Launch sites the radars found: a diamond where the place is known (with the circle of its error), and a mark on
|
||||
// the rim along the bearing when it is beyond the scope or only the bearing is known.
|
||||
for k in &sim.strikes.known {
|
||||
let Some(e) = &k.est else { continue };
|
||||
let gone = matches!(k.state, crate::sim::strike::SiteState::Destroyed(_));
|
||||
let color = if gone { 0xFF707070 } else { 0xFFFFA040 };
|
||||
let inside = e.fix.then(|| scope(DVec3::new(e.pos.x, player.y, e.pos.y))).flatten();
|
||||
if let Some((x, y, _)) = inside {
|
||||
let q = 3.0;
|
||||
s.push(quad(pt(x, y - q), pt(x + q, y), pt(x, y + q), pt(x - q, y), color));
|
||||
let rad = (e.radius / RANGE) as f32 * r;
|
||||
if rad > 3.5 {
|
||||
for i in 0..SEGMENTS {
|
||||
let (a0, a1) = (i as f32 / SEGMENTS as f32 * tau, (i + 1) as f32 / SEGMENTS as f32 * tau);
|
||||
if i % 2 == 0 {
|
||||
let p = |a: f32, rr: f32| pt(x + a.sin() * rr, y - a.cos() * rr);
|
||||
s.push(quad(p(a0, rad - 0.4), p(a0, rad + 0.4), p(a1, rad + 0.4), p(a1, rad - 0.4), alpha(color, 0.5)));
|
||||
}
|
||||
}
|
||||
}
|
||||
s.push(text(k.site.to_string(), x + 5.0, y - 9.0, color, false));
|
||||
} else {
|
||||
// On the rim, along the bearing (compass: north up, east right).
|
||||
let a = e.bearing as f32;
|
||||
let (x, y) = (cx + a.sin() * (r - 3.0), cy - a.cos() * (r - 3.0));
|
||||
let q = if e.fix { 3.0 } else { 2.0 };
|
||||
s.push(quad(pt(x, y - q), pt(x + q, y), pt(x, y + q), pt(x - q, y), color));
|
||||
s.push(text(format!("{}{}", k.site, if e.fix { "" } else { "?" }), x - a.sin() * 8.0, y + a.cos() * -8.0 - 4.0, color, true));
|
||||
}
|
||||
}
|
||||
// Strike missiles in flight, on the same footing: a small bright dot where the scope reaches, else nothing.
|
||||
for f in &sim.strikes.flights {
|
||||
let t = (sim.tick.saturating_sub(f.start)) as f64 / (f.arrive - f.start).max(1) as f64;
|
||||
let p = f.from.lerp(DVec3::new(f.aim.x, f.from.y, f.aim.y), t.min(1.0));
|
||||
if let Some((x, y, _)) = scope(p) {
|
||||
s.push(square(x, y, 1.5, 0xFFFFFFA0));
|
||||
}
|
||||
}
|
||||
let mut far = 0;
|
||||
// The air picture is what the systems' radars hold, as they last measured it (a track older than a second
|
||||
// is a forecast, dimmer); with no system deployed, everything as it is.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
//! 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(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,
|
||||
}
|
||||
|
||||
/// Rolling hills: ridges of 20-35 blocks over a plain at 64, wavelengths of 90-250 blocks.
|
||||
pub(super) fn hills(x: i32, z: i32) -> i32 {
|
||||
let (x, z) = (f64::from(x), f64::from(z));
|
||||
let n = (x / 53.0 + (z / 91.0).sin() * 1.7).sin() * 0.5 + (z / 41.0 - (x / 77.0).cos()).sin() * 0.35 + ((x + z) / 130.0).sin() * 0.4 + ((x - 2.0 * z) / 29.0).sin() * 0.12;
|
||||
64 + (n * 28.0 + 14.0).max(-4.0) as i32
|
||||
}
|
||||
|
||||
fn ground_at(hilly: bool, x: f64, z: f64) -> f64 {
|
||||
if hilly { f64::from(hills(x as i32, z as i32)) } else { 64.0 }
|
||||
}
|
||||
|
||||
/// Mountains: broad ridges over the hills, nothing within about 300 blocks of the origin.
|
||||
pub(super) fn mountains(x: i32, z: i32) -> i32 {
|
||||
let (x, z) = (f64::from(x), f64::from(z));
|
||||
let r = (x * x + z * z).sqrt();
|
||||
let ridge = ((x / 410.0 + 0.7).sin() * (z / 530.0 - 0.4).sin() + (x / 700.0 - z / 610.0).sin() * 0.6).max(0.0);
|
||||
(ridge * 150.0 * ((r - 300.0) / 400.0).clamp(0.0, 1.0)) as i32
|
||||
}
|
||||
|
||||
/// The game's view of the land: all of it, or a disc round the player and the chunks asked for.
|
||||
pub(super) struct Loader {
|
||||
pub hilly: bool,
|
||||
pub mountains: bool,
|
||||
pub disc: Option<f64>,
|
||||
asked: std::cell::RefCell<HashMap<(i32, i32), (u64, u64)>>,
|
||||
pub tickets: usize,
|
||||
}
|
||||
const TICKET_DELAY: u64 = 40;
|
||||
const TICKET_LIFE: u64 = 400;
|
||||
|
||||
impl Loader {
|
||||
pub fn new(s: &Setup) -> Self {
|
||||
Loader { hilly: s.hilly, mountains: s.mountains, disc: s.disc, asked: Default::default(), tickets: 0 }
|
||||
}
|
||||
pub fn height(&self, x: i32, z: i32) -> i32 {
|
||||
if self.hilly { hills(x, z) + if self.mountains { mountains(x, z) } else { 0 } } else { 64 }
|
||||
}
|
||||
fn loaded(&self, c: (i32, i32), now: u64) -> Option<bool> {
|
||||
let Some(r) = self.disc else { return Some(true) };
|
||||
let (x, z) = (f64::from(c.0 * 16 + 8), f64::from(c.1 * 16 + 8));
|
||||
if (x * x + z * z).sqrt() <= r {
|
||||
return Some(true);
|
||||
}
|
||||
match self.asked.borrow().get(&c) {
|
||||
Some(&(since, until)) if now < until => Some(now >= since + TICKET_DELAY),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn serve(&mut self, ground: &mut Ground, now: u64) {
|
||||
for c in ground.take_chunks() {
|
||||
self.tickets += 1;
|
||||
if std::env::var("NOTICKETS").is_err() {
|
||||
let mut asked = self.asked.borrow_mut();
|
||||
let e = asked.entry(c).or_insert((now, now));
|
||||
if now >= e.1 {
|
||||
*e = (now, now);
|
||||
}
|
||||
e.1 = now + TICKET_DELAY + TICKET_LIFE;
|
||||
}
|
||||
}
|
||||
let cols = ground
|
||||
.take_wanted()
|
||||
.into_iter()
|
||||
.map(|(x, z)| {
|
||||
let y = match self.loaded((x >> 4, z >> 4), now) {
|
||||
Some(true) => self.height(x, z),
|
||||
Some(false) => PENDING,
|
||||
None => -64,
|
||||
};
|
||||
(x, z, y)
|
||||
})
|
||||
.collect();
|
||||
ground.answer(cols, now);
|
||||
let secs = ground.world.take_wanted(now);
|
||||
let view = Seen { l: self, now };
|
||||
ground.world.serve(&view, secs, now);
|
||||
}
|
||||
}
|
||||
struct Seen<'a> {
|
||||
l: &'a Loader,
|
||||
now: u64,
|
||||
}
|
||||
impl world::WorldSource for Seen<'_> {
|
||||
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
|
||||
self.l.loaded((x >> 4, z >> 4), self.now).filter(|&l| l).map(|_| y < self.l.height(x, z))
|
||||
}
|
||||
}
|
||||
|
||||
impl Setup {
|
||||
pub fn new(systems: usize, smart: bool, seed: u64) -> Self {
|
||||
Setup { systems, smart, seed, size: 36, radars: (systems / 10).max(1), objects: 36, tweak: |_| {}, hilly: false, bury: 0.0, disc: None, mountains: false, dist: 500.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// The cluster: `objects` in a grid, systems of the mix scattered round them, radars among them.
|
||||
pub(super) fn cluster(sim: &mut Sim, s: &Setup) {
|
||||
let y = |x: f64, z: f64| if s.hilly { ground_at(true, x, z) - s.bury } else { 70.0 };
|
||||
let mut rng = Rng::new(s.seed.wrapping_mul(977) + 5);
|
||||
let side = (s.objects as f64).sqrt().ceil() as usize;
|
||||
for k in 0..s.objects {
|
||||
let (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.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");
|
||||
}
|
||||
|
||||
/// 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 1..=8u64 {
|
||||
let mut s = Setup::new(50, std::env::var("SMART").is_ok(), seed);
|
||||
s.hilly = true;
|
||||
s.mountains = true;
|
||||
s.dist = dist;
|
||||
s.disc = (disc > 0.0).then_some(disc);
|
||||
let r = fly(&s);
|
||||
launched += r.launched;
|
||||
kills += r.kills;
|
||||
leaked += r.leaked;
|
||||
tickets += r.tickets;
|
||||
for c in &r.crashed {
|
||||
by_kind.entry(c.0).or_default().0 += 1;
|
||||
if std::env::var("ROWS").is_ok() {
|
||||
let (x, z) = (c.1.x as i32, c.1.z as i32);
|
||||
eprintln!(" seed {seed} {:?} crashed at {:.0},{:.0},{:.0} ({:.0} from origin, land {}), {:.0} from its aim", c.0, c.1.x, c.1.y, c.1.z, c.1.x.hypot(c.1.z), Loader::new(&s).height(x, z), c.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut v: Vec<_> = by_kind.into_iter().collect();
|
||||
v.sort_by_key(|k| k.0 as u8);
|
||||
eprintln!("disc {disc} dist {dist}: launched {launched} kills {kills} leaked {leaked} chunk tickets {tickets}; crashed by kind {:?}", v.iter().map(|k| (k.0, k.1.0)).collect::<Vec<_>>());
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Fire doctrine (#47): what a system is told about its job beyond the roles. Protected objects (it fires
|
||||
//! only at what threatens them), a sector of responsibility, a reserve of missiles held back for what
|
||||
//! matters, and how many interceptors it asks for on one target. The predicates are shared by `step_sams`
|
||||
//! (through `why::basis`) and the diagnostic; the default doctrine blocks nothing and changes nothing.
|
||||
//! Everything is judged on the system's track (its forecast), never on the drone's true place.
|
||||
use super::*;
|
||||
|
||||
/// How many interceptors a system asks for on one target: the base shoot-look-shoot only, as now, or one more.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub enum Salvo {
|
||||
Economy,
|
||||
#[default]
|
||||
Normal,
|
||||
Heavy,
|
||||
}
|
||||
|
||||
impl Salvo {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Salvo::Economy => "экономный",
|
||||
Salvo::Normal => "обычный",
|
||||
Salvo::Heavy => "усиленный",
|
||||
}
|
||||
}
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Salvo::Economy => "eco",
|
||||
Salvo::Normal => "normal",
|
||||
Salvo::Heavy => "heavy",
|
||||
}
|
||||
}
|
||||
pub fn from_key(s: &str) -> Option<Salvo> {
|
||||
[Salvo::Economy, Salvo::Normal, Salvo::Heavy].into_iter().find(|v| v.key() == s)
|
||||
}
|
||||
pub fn next(self) -> Salvo {
|
||||
match self {
|
||||
Salvo::Economy => Salvo::Normal,
|
||||
Salvo::Normal => Salvo::Heavy,
|
||||
Salvo::Heavy => Salvo::Economy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A system's doctrine. `Default` is the sandbox: no guards, no sector, no reserve, the usual salvo.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Doctrine {
|
||||
/// Protected objects (their places): when any, the system fires only at what threatens one of them.
|
||||
pub guards: Vec<DVec3>,
|
||||
/// Sector of responsibility: centre (a yaw as `heading_of`, radians) and half-width.
|
||||
pub sector: Option<(f64, f64)>,
|
||||
/// Missiles held back: below this many, only what matters is shot at.
|
||||
pub reserve: u32,
|
||||
pub salvo: Salvo,
|
||||
}
|
||||
|
||||
/// Where a track counts as threatening an object: within this many blocks of it, or flying at it.
|
||||
pub const GUARD_R: f64 = 120.0;
|
||||
/// Most missiles one can reserve.
|
||||
pub const MAX_RESERVE: u32 = 64;
|
||||
|
||||
pub(super) const OUT_SECTOR: u8 = 1;
|
||||
pub(super) const NO_THREAT: u8 = 2;
|
||||
pub(super) const RESERVE: u8 = 4;
|
||||
|
||||
impl Doctrine {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == Doctrine::default()
|
||||
}
|
||||
|
||||
/// Whether the track is within the sector (judged from the system's place).
|
||||
pub fn in_sector(&self, pos: DVec3, at: DVec3) -> bool {
|
||||
self.sector.is_none_or(|(centre, half)| {
|
||||
let to = at - pos;
|
||||
to.x.hypot(to.z) < 30.0 || angle_diff(centre, heading_of(to)).abs() <= half
|
||||
})
|
||||
}
|
||||
|
||||
/// The first protected object the track threatens (its index in `guards`): near it, or flying towards it.
|
||||
pub fn threatens(&self, track: &Track, now: u64) -> Option<usize> {
|
||||
let at = track.est(now);
|
||||
let slack = GUARD_R + track.uncertainty(now);
|
||||
self.guards.iter().position(|g| {
|
||||
let (dx, dz) = (g.x - at.x, g.z - at.z);
|
||||
let near = dx.hypot(dz);
|
||||
if near <= slack {
|
||||
return true;
|
||||
}
|
||||
let (vx, vz) = (track.vel.x, track.vel.z);
|
||||
let speed = vx.hypot(vz);
|
||||
// Closing, and the line of flight passes the object within the slack.
|
||||
speed > 1e-3 && vx * dx + vz * dz > 0.0 && (vx * dz - vz * dx).abs() / speed <= slack
|
||||
})
|
||||
}
|
||||
|
||||
/// Why the area (sector, protected objects) rules the track out; 0 is a pass.
|
||||
pub fn area_blocks(&self, pos: DVec3, track: &Track, now: u64) -> u8 {
|
||||
(if self.in_sector(pos, track.est(now)) { 0 } else { OUT_SECTOR }) | (if self.guards.is_empty() || self.threatens(track, now).is_some() { 0 } else { NO_THREAT })
|
||||
}
|
||||
|
||||
/// What matters enough to spend the reserve: a ballistic missile, or anything about to hit.
|
||||
pub fn matters(d: &Drone) -> bool {
|
||||
d.kind.spec().class == Class::Ballistic || why::arrival(d) < why::URGENT
|
||||
}
|
||||
|
||||
/// The reserve holds the launch back: the magazine is at the reserve and the target doesn't matter.
|
||||
pub fn reserve_blocks(&self, missiles: u32, d: &Drone) -> u8 {
|
||||
if self.reserve > 0 && missiles <= self.reserve && !Self::matters(d) { RESERVE } else { 0 }
|
||||
}
|
||||
|
||||
/// Interceptors this doctrine asks for on `d`: `why::needed` by default, the base only when economical, one more when heavy.
|
||||
pub fn needed(&self, d: &Drone) -> u32 {
|
||||
let base = if d.kind.spec().class == Class::Ballistic { 2 } else { 1 };
|
||||
match self.salvo {
|
||||
Salvo::Economy => base,
|
||||
Salvo::Normal => why::needed(d),
|
||||
Salvo::Heavy => why::needed(d) + 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The sector in words (`90±45°`); degrees on the same scale as `/airdef doctrine ... sector`.
|
||||
pub fn sector_text(&self) -> String {
|
||||
match self.sector {
|
||||
Some((c, h)) => format!("{:.0}±{:.0}°", c.to_degrees().rem_euclid(360.0), h.to_degrees()),
|
||||
None => "круговой".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The doctrine as the player reads it, empty for the default.
|
||||
pub fn text(&self, targets: &[DVec3]) -> String {
|
||||
if self.is_default() {
|
||||
return "обычная (без ограничений)".to_string();
|
||||
}
|
||||
let guards = if self.guards.is_empty() {
|
||||
"нет".to_string()
|
||||
} else {
|
||||
self.guards.iter().map(|g| object_name(targets, *g)).collect::<Vec<_>>().join(", ")
|
||||
};
|
||||
format!("охрана: {guards}; сектор: {}; резерв: {}; залп: {}", self.sector_text(), self.reserve, self.salvo.name())
|
||||
}
|
||||
|
||||
/// One line of the save: `salvo reserve sector-centre sector-half guards x y z ...` (`-` for no sector).
|
||||
pub fn save_text(&self) -> String {
|
||||
let sector = self.sector.map_or("- -".to_string(), |(c, h)| format!("{c} {h}"));
|
||||
let mut s = format!("{} {} {sector} {}", self.salvo.key(), self.reserve, self.guards.len());
|
||||
for g in &self.guards {
|
||||
s += &format!(" {} {} {}", g.x, g.y, g.z);
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// An object by number in the world's list ("объект №2"), else by place.
|
||||
pub fn object_name(targets: &[DVec3], g: DVec3) -> String {
|
||||
match targets.iter().position(|t| t.distance(g) < 16.0) {
|
||||
Some(i) => format!("объект №{}", i + 1),
|
||||
None => format!("точка {:.0} {:.0}", g.x, g.z),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn track(pos: DVec3, vel: DVec3) -> Track {
|
||||
Track { id: 1, pos, vel, tick: 0, own_tick: 0, ttl: 100, hits: 3, locked: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_doctrine_blocks_nothing() {
|
||||
let d = Doctrine::default();
|
||||
let t = track(DVec3::new(500.0, 100.0, 0.0), DVec3::ZERO);
|
||||
assert_eq!(d.area_blocks(DVec3::ZERO, &t, 0), 0);
|
||||
assert!(d.is_default());
|
||||
assert_eq!(Doctrine::default().save_text(), "normal 0 - - 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sector_and_guards_rule_targets_out_by_their_tracks() {
|
||||
let mut d = Doctrine::default();
|
||||
// North is yaw 0 (towards -z): a sector of 45 degrees either side of north.
|
||||
d.sector = Some((0.0, 45f64.to_radians()));
|
||||
let north = track(DVec3::new(0.0, 100.0, -800.0), DVec3::ZERO);
|
||||
let south = track(DVec3::new(0.0, 100.0, 800.0), DVec3::ZERO);
|
||||
assert_eq!(d.area_blocks(DVec3::ZERO, &north, 0), 0);
|
||||
assert_eq!(d.area_blocks(DVec3::ZERO, &south, 0), OUT_SECTOR);
|
||||
d.sector = None;
|
||||
d.guards = vec![DVec3::new(1000.0, 70.0, 0.0)];
|
||||
// Flying at the object from afar threatens it; flying past, or away, doesn't.
|
||||
let at = track(DVec3::new(-2000.0, 100.0, 10.0), DVec3::new(2.0, 0.0, 0.0));
|
||||
let past = track(DVec3::new(-2000.0, 100.0, 900.0), DVec3::new(2.0, 0.0, 0.0));
|
||||
let away = track(DVec3::new(-2000.0, 100.0, 0.0), DVec3::new(-2.0, 0.0, 0.0));
|
||||
assert_eq!(d.area_blocks(DVec3::ZERO, &at, 0), 0);
|
||||
assert_eq!(d.area_blocks(DVec3::ZERO, &past, 0), NO_THREAT);
|
||||
assert_eq!(d.area_blocks(DVec3::ZERO, &away, 0), NO_THREAT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_save_line_is_what_the_layout_reads() {
|
||||
let mut d = Doctrine { salvo: Salvo::Economy, reserve: 3, ..Default::default() };
|
||||
d.sector = Some((1.5, 0.5));
|
||||
d.guards = vec![DVec3::new(1.0, 2.0, 3.0)];
|
||||
let text = format!("sam {} 0 70 0 0\ndoctrine 0 {}\n", SamKind::Patriot.key(), d.save_text());
|
||||
let l = crate::layout::Layout::parse(&text).unwrap();
|
||||
assert_eq!(l.doctrines, vec![(0, d)]);
|
||||
assert!(crate::layout::Layout::parse("doctrine 0 loud 1 - - 0\n").is_err());
|
||||
assert!(crate::layout::Layout::parse("doctrine 0 eco 1 - - 2 1 2 3\n").is_err(), "guards cut short");
|
||||
}
|
||||
|
||||
use super::sites::testland::{Land, serve};
|
||||
|
||||
/// One Patriot against one Shahed flying in from the north at its aim, the doctrine set before the raid:
|
||||
/// the missiles it fired, the Shahed's ticks to its aim at the first launch, and the `why` lines at the end.
|
||||
fn fire(doc: Doctrine) -> (u32, Option<f64>, Vec<String>) {
|
||||
let mut sim = Sim::new(5);
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Buk, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
sim.sams[0].roles = 0xFF;
|
||||
sim.sams[0].doctrine = doc;
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, -600.0), DVec3::Z, DVec3::new(0.0, 70.0, -80.0));
|
||||
let (mut ground, land, mut out) = (Ground::default(), Land(|_, _| 64), Out::default());
|
||||
let mut first = None;
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for _ in 0..900 {
|
||||
serve(&mut ground, &land, sim.tick);
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
if first.is_none() && sim.sams[0].fired > 0 {
|
||||
first = sim.drones.first().map(why::arrival);
|
||||
}
|
||||
if sim.drones.iter().all(|d| !d.alive()) {
|
||||
break;
|
||||
}
|
||||
// The explanation while the target is in the sky: all the lines it gave then.
|
||||
if sim.tick % 10 == 0 {
|
||||
lines.extend(sim.why(0).into_iter().map(|c| c.text));
|
||||
}
|
||||
}
|
||||
(sim.sams[0].fired, first, lines)
|
||||
}
|
||||
|
||||
// #47: the defaults fire as before; each setting changes the decision and `why` names it
|
||||
#[test]
|
||||
fn each_setting_changes_the_shot_and_why_says_which() {
|
||||
let (base, first, w) = fire(Doctrine::default());
|
||||
assert!(base > 0, "the baseline never fired: {w:?}");
|
||||
let first = first.unwrap();
|
||||
assert!(first > why::URGENT, "the baseline fires late: {first}");
|
||||
let has = |text: &[String], what: &str| text.iter().any(|l| l.contains(what));
|
||||
// The raid comes from the north (yaw 0): a sector round north holds it, one round south does not.
|
||||
let sector = |c: f64| Doctrine { sector: Some((f64::to_radians(c), f64::to_radians(40.0))), ..Default::default() };
|
||||
assert_eq!(fire(sector(0.0)).0, base, "a sector that holds the raid changes nothing");
|
||||
let (n, _, why) = fire(sector(180.0));
|
||||
assert_eq!(n, 0);
|
||||
assert!(has(&why, "вне сектора"), "{why:?}");
|
||||
// A reserve above the magazine holds the shot until the target is urgent.
|
||||
let (n, late, _) = fire(Doctrine { reserve: 64, ..Default::default() });
|
||||
assert!(n > 0 && late.unwrap() < why::URGENT, "{n} {late:?}");
|
||||
// An object far off the raid's line is no one's to guard here; the one it flies at is.
|
||||
let (n, _, why) = fire(Doctrine { guards: vec![DVec3::new(0.0, 70.0, 50000.0)], ..Default::default() });
|
||||
assert_eq!(n, 0);
|
||||
assert!(has(&why, "не угрожает"), "{why:?}");
|
||||
let (n, _, why) = fire(Doctrine { guards: vec![DVec3::new(0.0, 70.0, -80.0)], ..Default::default() });
|
||||
assert_eq!(n, base);
|
||||
assert!(has(&why, "прикрывает"), "{why:?}");
|
||||
// Economical asks for no more interceptors than the usual, heavy no fewer.
|
||||
let (eco, _, _) = fire(Doctrine { salvo: Salvo::Economy, ..Default::default() });
|
||||
let (heavy, _, _) = fire(Doctrine { salvo: Salvo::Heavy, ..Default::default() });
|
||||
assert!(eco <= base && base <= heavy, "{eco} {base} {heavy}");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+224
-23
@@ -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::{Ident, LowSensor, Track, RIM_RAYS};
|
||||
use damage::dice;
|
||||
use outcome::{Impact, Outcome};
|
||||
pub use units::{Family, SamKind};
|
||||
@@ -229,6 +234,13 @@ 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.
|
||||
@@ -237,6 +249,14 @@ pub struct Ground {
|
||||
pub world: world::WorldCache,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -272,8 +292,17 @@ impl Ground {
|
||||
pub fn answer(&mut self, answers: Vec<(i32, i32, i32)>, now: u64) {
|
||||
self.now = now;
|
||||
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();
|
||||
if self.known.len() > 300_000 && now.saturating_sub(self.swept) >= SWEEP_EVERY {
|
||||
self.swept = now;
|
||||
@@ -318,6 +347,40 @@ 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| v.0 <= -64) {
|
||||
self.known.remove(&(x, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
for x in (c.0 * 16..c.0 * 16 + 16).step_by(8) {
|
||||
for z in (c.1 * 16..c.1 * 16 + 16).step_by(8) {
|
||||
if self.known.get(&(x + 4, z + 4)).is_some_and(|v| v.0 <= -64) {
|
||||
self.known.remove(&(x + 4, z + 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// How many chunks are wanted so far this tick.
|
||||
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();
|
||||
std::mem::take(&mut self.wanted)
|
||||
@@ -524,6 +587,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,
|
||||
@@ -865,11 +935,14 @@ struct Nav {
|
||||
need: f64,
|
||||
/// The height from which a straight dive on its target clears the land (unknown: -inf).
|
||||
dive: f64,
|
||||
/// The land ahead is not known (a chunk the game does not have, or not yet): it keeps a transit height
|
||||
/// above anything it could meet and comes down only once the land ahead is told of.
|
||||
blind: bool,
|
||||
}
|
||||
|
||||
impl Default for Nav {
|
||||
fn default() -> Self {
|
||||
Nav { need: f64::NEG_INFINITY, dive: f64::NEG_INFINITY }
|
||||
Nav { need: f64::NEG_INFINITY, dive: f64::NEG_INFINITY, blind: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -924,6 +997,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 +1277,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 +1293,8 @@ pub struct Sam {
|
||||
pub fired: u32,
|
||||
/// The classes of target it engages (`Class::bit`s).
|
||||
pub roles: u8,
|
||||
/// Its fire doctrine (#47): protected objects, sector, reserve, salvo.
|
||||
pub doctrine: doctrine::Doctrine,
|
||||
/// 1 whole, 0 destroyed: a damaged system turns slower, sees less and hits worse.
|
||||
pub health: f64,
|
||||
/// Ticks its wreck still burns.
|
||||
@@ -1270,6 +1355,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 +1367,7 @@ impl Sam {
|
||||
kills: 0,
|
||||
fired: 0,
|
||||
roles: spec.roles,
|
||||
doctrine: doctrine::Doctrine::default(),
|
||||
state: SamState::Search,
|
||||
launched: 0,
|
||||
health: 1.0,
|
||||
@@ -1421,6 +1510,11 @@ pub struct Sim {
|
||||
pub hits: u32,
|
||||
/// Drones that struck the land or a building short of their aim.
|
||||
pub crashes: u32,
|
||||
/// Systems just deployed by hand and not yet checked for standing in the land: (id, until tick).
|
||||
settling: Vec<(u32, u64)>,
|
||||
/// What crashed, where, and its aim (balance measurements only).
|
||||
#[cfg(test)]
|
||||
pub crashed: Vec<(DroneKind, DVec3, DVec3)>,
|
||||
pub launched: u32,
|
||||
/// Explosions damage the land (primed TNT through commands).
|
||||
pub damage: bool,
|
||||
@@ -1432,6 +1526,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 +1538,12 @@ pub struct Sim {
|
||||
pub queue: Vec<(u64, Drone)>,
|
||||
/// The raid's launch sites and their jobs (#100).
|
||||
pub sites: sites::Sites,
|
||||
/// Launch sites found by the radars, and the counter-strikes on them (#101).
|
||||
pub strikes: strike::Strikes,
|
||||
/// A raid being planned.
|
||||
plan: Option<raid::Plan>,
|
||||
/// The vehicles' ground routes, and the jobs the trucks found out of reach (#94).
|
||||
routes: route::Routes,
|
||||
/// Each marked object's health, 0 to 100 (`targets`' order).
|
||||
pub target_health: Vec<f64>,
|
||||
/// The raid in progress, for its report.
|
||||
@@ -1499,17 +1601,24 @@ impl Sim {
|
||||
kills: 0,
|
||||
hits: 0,
|
||||
crashes: 0,
|
||||
settling: Vec::new(),
|
||||
#[cfg(test)]
|
||||
crashed: Vec::new(),
|
||||
launched: 0,
|
||||
damage: true,
|
||||
seed: seed.wrapping_mul(0x2545_F491_4F6C_DD1D) | 1,
|
||||
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 +1733,43 @@ impl Sim {
|
||||
self.fires.extinguish();
|
||||
}
|
||||
|
||||
/// A system just put down by the player is looked at for a few seconds: one standing inside the land (a player
|
||||
/// who fell into a chunk that had not loaded) is brought up to the surface. Buried, its interceptors would
|
||||
/// strike the dirt above it and the raid's aim at it would lie under the land. Only a spot whose block is known
|
||||
/// solid and whose land height is known is moved, to that height.
|
||||
pub fn settle(&mut self, id: u32) {
|
||||
self.settling.push((id, self.tick + 100));
|
||||
}
|
||||
|
||||
fn step_settling(&mut self, ground: &mut Ground) {
|
||||
let now = self.tick;
|
||||
let sams = &mut self.sams;
|
||||
self.settling.retain(|&(id, until)| {
|
||||
let Some(sam) = sams.iter_mut().find(|s| s.id == id) else { return false };
|
||||
let p = sam.pos;
|
||||
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
if ground.world.is_known_open(x, y, z, now) {
|
||||
return false;
|
||||
}
|
||||
if ground.world.is_known_solid(x, y, z, now) {
|
||||
if let Some(g) = ground.at(p, now) {
|
||||
if g > p.y + 1.5 && g < p.y + 96.0 {
|
||||
sam.pos.y = g;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
now < until
|
||||
});
|
||||
}
|
||||
|
||||
/// One game tick.
|
||||
pub fn step(&mut self, ground: &mut Ground, player: Option<DVec3>, out: &mut Out) {
|
||||
self.tick += 1;
|
||||
self.plan_step(Some(&mut *ground), out);
|
||||
self.adapt();
|
||||
self.release_queue(out);
|
||||
self.step_settling(ground);
|
||||
self.release_queue(ground, out);
|
||||
self.step_drones(ground, player, out);
|
||||
self.step_later(ground, player, out);
|
||||
self.step_moves(ground, out);
|
||||
@@ -1639,6 +1779,7 @@ impl Sim {
|
||||
self.step_lights(ground, out);
|
||||
self.step_sams(ground, out);
|
||||
self.step_missiles(ground, out);
|
||||
self.step_strikes(ground, player, out);
|
||||
self.step_fx(ground, out);
|
||||
self.draw_targets(out);
|
||||
self.draw_sites(out);
|
||||
@@ -1670,11 +1811,12 @@ impl Sim {
|
||||
let from = d.pos;
|
||||
ground.ahead(d.pos, d.vel, (120.0 / d.vel.length().max(0.05)).max(20.0), now);
|
||||
// Near its aim it looks at the land there again: a platform or hilltop blown away
|
||||
// meanwhile, it goes for what is left below, not for the air where it stood.
|
||||
// meanwhile, it goes for what is left below, not for the air where it stood; an aim inside the land (a mark
|
||||
// set from a spot the player stood in the ground) is the surface over it, else the strike counts as a crash.
|
||||
if d.flight != Flight::Falling
|
||||
&& horizontal(d.target - d.pos).length() < 250.0
|
||||
&& let Some(g) = ground.at(d.target, now)
|
||||
&& g < d.target.y - 1.5
|
||||
&& (g < d.target.y - 1.5 || (g > d.target.y + 1.5 && !spec.decoy && spec.ballistic.is_none() && g < d.target.y + 96.0))
|
||||
{
|
||||
d.target.y = g;
|
||||
}
|
||||
@@ -1736,6 +1878,10 @@ impl Sim {
|
||||
// Into the land or a building short of its aim: a crash, not a hit.
|
||||
if landed && d.pos.distance(d.target) >= speed.max(2.0) + 6.0 {
|
||||
crashes.push((d.id, d.kind, d.pos, spec.warhead));
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.crashed.push((d.kind, d.pos, d.target));
|
||||
}
|
||||
removed.push(d.id);
|
||||
continue;
|
||||
}
|
||||
@@ -1837,7 +1983,9 @@ impl Sim {
|
||||
lines.push(((heading + towards).normalize_or_zero(), reach));
|
||||
}
|
||||
}
|
||||
d.nav.need = terrain::required_height(ground, d.pos, &lines, spec.clearance, &climb).unwrap_or(f64::NEG_INFINITY);
|
||||
let (need, blind) = terrain::required_height(ground, d.pos, &lines, spec.clearance, &climb);
|
||||
d.nav.need = need.unwrap_or(f64::NEG_INFINITY);
|
||||
d.nav.blind = blind;
|
||||
d.nav.dive = if to_target < dive_at * 3.0 {
|
||||
terrain::dive_height(ground, d.pos, d.target, terrain::DIVE_MARGIN).unwrap_or(f64::NEG_INFINITY)
|
||||
} else {
|
||||
@@ -1870,13 +2018,14 @@ impl Sim {
|
||||
} else {
|
||||
let heading = horizontal(way - d.pos).normalize_or(horizontal(old).normalize_or(DVec3::NEG_Z));
|
||||
// Cruise height over the target; as low as the land lets it when under the radars.
|
||||
let base = if d.low { d.target.y + 4.0 } else { d.target.y + spec.cruise };
|
||||
let base = if d.low && !d.nav.blind { d.target.y + 4.0 } else { d.target.y + spec.cruise };
|
||||
let own = below.map_or(f64::NEG_INFINITY, |g| g + spec.clearance);
|
||||
let need = d.nav.need.max(own);
|
||||
let safe = if need.is_finite() { need + 2.0 } else { d.target.y + spec.clearance };
|
||||
let height = base.max(safe).max(d.nav.dive + 1.0);
|
||||
let full = climb.pitch.tan();
|
||||
let urgent = need.is_finite() && d.pos.y < need;
|
||||
// Blind (land ahead unknown): to the transit height at once, not at the gentle slope of a descent plan.
|
||||
let urgent = (need.is_finite() && d.pos.y < need) || (d.nav.blind && d.pos.y < base - 2.0);
|
||||
let slope = if urgent {
|
||||
full
|
||||
} else {
|
||||
@@ -1907,12 +2056,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 +2082,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);
|
||||
}
|
||||
}
|
||||
@@ -2241,6 +2394,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 +2441,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 +2479,7 @@ impl Sim {
|
||||
tracks
|
||||
.iter()
|
||||
.filter_map(|tr| drone(tr).map(|d| (tr, d)))
|
||||
.filter(|(tr, d)| why::gun_candidate(roles, tr, now, gun.track, pos, d))
|
||||
.filter(|(tr, d)| why::gun_candidate(roles, doc, tr, now, gun.track, pos, d))
|
||||
.map(|(tr, d)| {
|
||||
let busy = 1.0 + 2.0 * on_it(d) + if d.engaged > 0 { 1.5 } else { 0.0 };
|
||||
(tr.est(now).distance(pos) * busy, d.id)
|
||||
@@ -2330,7 +2498,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 +2541,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 +2689,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 +2743,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);
|
||||
}
|
||||
@@ -2573,6 +2772,8 @@ impl Sim {
|
||||
let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32);
|
||||
let (mut blocked, mut scrubbed) = (0u32, 0u32);
|
||||
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
|
||||
@@ -2680,7 +2881,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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
+192
-53
@@ -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,
|
||||
@@ -497,6 +515,31 @@ impl Sim {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
@@ -525,9 +568,9 @@ impl Sim {
|
||||
}
|
||||
// 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),
|
||||
@@ -609,8 +652,11 @@ impl Sim {
|
||||
(prices, cost)
|
||||
}
|
||||
|
||||
/// A planned raid's drones take off when their time comes.
|
||||
pub(super) fn release_queue(&mut self, out: &mut Out) {
|
||||
/// Ticks an air start waits for the land under it to be told of.
|
||||
const AIR_START_WAIT: u32 = 120;
|
||||
|
||||
/// A planned raid's drones take off when their time comes.
|
||||
pub(super) fn release_queue(&mut self, ground: &mut Ground, out: &mut Out) {
|
||||
let now = self.tick;
|
||||
if self.queue.iter().all(|q| q.0 > now) {
|
||||
return;
|
||||
@@ -618,10 +664,41 @@ impl Sim {
|
||||
let (go, wait): (Vec<_>, Vec<_>) = std::mem::take(&mut self.queue).into_iter().partition(|q| q.0 <= now);
|
||||
self.queue = wait;
|
||||
let mut cancelled = 0;
|
||||
for (t, d) in go {
|
||||
for (t, mut d) in go {
|
||||
// A site's job leaves its site one at a time, from its stock; a destroyed or empty site cancels it.
|
||||
match self.site_slot(d.id) {
|
||||
let from_site = self.sites.site_of(d.id);
|
||||
// An air start (an off-map site's drone) was planned over land nobody had read. It waits (a second or
|
||||
// two) for the land under it to be known, and begins above it if that is higher than the start: else it
|
||||
// is born inside a hill and crashes at once.
|
||||
let air = from_site.is_some_and(|id| self.sites.list.iter().any(|s| s.id == id && s.offmap));
|
||||
if air {
|
||||
let clear = if d.kind.spec().ballistic.is_some() { 10.0 } else { d.kind.spec().clearance };
|
||||
match ground.at(d.pos, now) {
|
||||
None if d.age < AIR_START_WAIT => {
|
||||
// The game has no chunk there: it is asked to load the ones round the start (and kept a while).
|
||||
let c = ((d.pos.x.floor() as i32) >> 4, (d.pos.z.floor() as i32) >> 4);
|
||||
for (dx, dz) in [(0, 0), (1, 0), (-1, 0), (0, 1), (0, -1)] {
|
||||
ground.want_chunk((c.0 + dx, c.1 + dz));
|
||||
}
|
||||
d.age += 1;
|
||||
self.queue.push((t, d));
|
||||
continue;
|
||||
}
|
||||
Some(g) if d.pos.y < g + clear => d.pos.y = g + clear,
|
||||
_ => {}
|
||||
}
|
||||
d.age = 0;
|
||||
}
|
||||
let slot = self.site_slot(d.id);
|
||||
if let (Some(site), sites::Slot::Go | sites::Slot::Air) = (from_site, &slot) {
|
||||
self.scout_launch(d.id, site, d.kind, d.pos);
|
||||
}
|
||||
match slot {
|
||||
// Free: not a site's job. Air: an off-map site's drone, already in flight position.
|
||||
sites::Slot::Free => {}
|
||||
// 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 +708,14 @@ impl Sim {
|
||||
continue;
|
||||
}
|
||||
sites::Slot::Go => {
|
||||
// On a slope the pad's side or the muzzle's far end can stand in the land that rises there: the
|
||||
// drone leaves from above the land at its own column, not from inside the hill.
|
||||
if let Some(g) = ground.at(d.pos, now)
|
||||
&& d.pos.y < g + 1.5
|
||||
&& g < d.pos.y + 40.0
|
||||
{
|
||||
d.pos.y = g + 1.5;
|
||||
}
|
||||
let dir = d.vel.normalize_or_zero();
|
||||
out.sound(d.kind.launch_sound(), d.pos, 1.0, 1.0);
|
||||
out.particles("minecraft:flame", d.pos - dir * 1.5, 0.15, -dir * 0.1, 0.02, 4);
|
||||
@@ -645,9 +730,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 +739,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 +759,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 +778,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 +794,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 +814,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 +864,13 @@ impl Sim {
|
||||
if !why.is_empty() {
|
||||
out.messages.push(format!("Неудачи ПВО: {why}"));
|
||||
}
|
||||
// What the counter-strikes did to the raid (#101): the launches that never happened.
|
||||
let cut: u32 = self.strikes.cancelled.iter().map(|c| c.1).sum();
|
||||
if cut > 0 {
|
||||
let sites: Vec<String> = self.strikes.cancelled.iter().filter(|c| c.1 > 0).map(|c| format!("№{}", c.0)).collect();
|
||||
out.messages.push(format!("Налёт сорван ударами по ПУ ({}): не взлетело {cut} из заявленных {}", sites.join(", "), w.size));
|
||||
}
|
||||
self.strikes.cancelled.clear();
|
||||
self.wave = None;
|
||||
}
|
||||
}
|
||||
@@ -848,6 +930,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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -234,6 +234,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 +1594,78 @@ fn mass_strikes_keep_fires_effects_and_lights_bounded() {
|
||||
assert!(sim.fires.is_empty(), "fires burn for ever");
|
||||
let _ = commands;
|
||||
}
|
||||
|
||||
/// A system put down inside the land (the player stood in the ground) is brought up to the surface, and its
|
||||
/// interceptors then leave the launcher; one on the surface or in the open air is left where it is.
|
||||
#[test]
|
||||
fn a_system_deployed_inside_the_land_comes_up() {
|
||||
let land = sites::testland::Land(|_, _| 63);
|
||||
let mut sim = Sim::new(1);
|
||||
let mut ground = Ground::default();
|
||||
let mut out = Out::default();
|
||||
let buried = sim.id();
|
||||
sim.sams.push(Sam::new(buried, SamKind::Patriot, DVec3::new(20.0, 56.0, 17.0), 0.0));
|
||||
sim.settle(buried);
|
||||
let open = sim.id();
|
||||
sim.sams.push(Sam::new(open, SamKind::S400, DVec3::new(60.0, 63.0, 17.0), 0.0));
|
||||
sim.settle(open);
|
||||
let air = sim.id();
|
||||
sim.sams.push(Sam::new(air, SamKind::Tor, DVec3::new(90.0, 70.0, 17.0), 0.0));
|
||||
sim.settle(air);
|
||||
for _ in 0..60 {
|
||||
sites::testland::step(&mut sim, &mut ground, &land, &mut out);
|
||||
}
|
||||
let y = |id| sim.sams.iter().find(|s| s.id == id).unwrap().pos.y;
|
||||
assert_eq!(y(buried), 63.0, "a buried system comes up to the surface");
|
||||
assert_eq!(y(open), 63.0);
|
||||
assert_eq!(y(air), 70.0, "one in the open air is not moved");
|
||||
}
|
||||
|
||||
/// An aim point inside the land (an object marked where the player stood in the ground) is the surface over it:
|
||||
/// a drone that arrives is a hit, not a crash into the land short of its aim.
|
||||
#[test]
|
||||
fn a_drone_at_an_aim_under_the_land_hits() {
|
||||
let (mut ground, land) = (Ground::default(), sites::testland::Land(|_, _| 63));
|
||||
let mut sim = Sim::new(5);
|
||||
sim.targets.push(DVec3::new(0.0, 56.0, 0.0));
|
||||
sim.target_health.push(100.0);
|
||||
let d = sim.make_drone(DroneKind::Geran3, DVec3::new(-400.0, 100.0, 0.0), DVec3::X, DVec3::new(0.0, 56.0, 0.0));
|
||||
sim.drones.push(d);
|
||||
let mut out = Out::default();
|
||||
for _ in 0..1200 {
|
||||
sites::testland::step(&mut sim, &mut ground, &land, &mut out);
|
||||
if sim.drones.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!((sim.hits, sim.crashes), (1, 0), "hits {} crashes {}", sim.hits, sim.crashes);
|
||||
}
|
||||
|
||||
/// Over land the game has not told of, a low-flying drone does not hug the height of its aim: it holds the transit
|
||||
/// height (the cruise height over the aim) and comes down only once the land ahead is known.
|
||||
#[test]
|
||||
fn over_unknown_land_a_low_flight_holds_its_transit_height() {
|
||||
let aim = DVec3::new(0.0, 64.0, 3000.0);
|
||||
let fly = |known: bool| {
|
||||
let mut sim = Sim::new(4);
|
||||
let mut d = sim.make_drone(DroneKind::Kh101, DVec3::new(0.0, 70.0, 0.0), DVec3::Z, aim);
|
||||
d.low = true;
|
||||
d.route = vec![aim];
|
||||
sim.drones.push(d);
|
||||
let (mut ground, land) = (Ground::default(), sites::testland::Land(|_, _| 64));
|
||||
let mut out = Out::default();
|
||||
for _ in 0..300 {
|
||||
if known {
|
||||
sites::testland::step(&mut sim, &mut ground, &land, &mut out);
|
||||
} else {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
}
|
||||
sim.drones[0].pos.y
|
||||
};
|
||||
let spec = DroneKind::Kh101.spec();
|
||||
let blind = fly(false);
|
||||
let seen = fly(true);
|
||||
assert!(blind > aim.y + spec.cruise - 8.0, "blind and low: {blind}");
|
||||
assert!(seen < aim.y + 20.0, "over land it knows it keeps hugging it: {seen}");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+163
-1
@@ -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, _)| (y > -64).then_some(f64::from(y)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
/// A radar for a unit of `kind` at `pos` as the map draws it; `id`: the unit it is (its parts and horizon), None for a ghost.
|
||||
pub fn low_sensor(&self, kind: SamKind, pos: DVec3, id: Option<u32>) -> Option<LowSensor> {
|
||||
let spec = kind.spec();
|
||||
if spec.sensor <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let fit = id.and_then(|i| self.sams.iter().find(|s| s.id == i)).map_or(1.0, |s| s.radar_fit());
|
||||
let height = LOW_KIND.spec().clearance;
|
||||
let radar = spec.sensor * fit * LOW_KIND.rcs().powf(0.25);
|
||||
let reach = radar.min(HORIZON * (spec.mast.sqrt() + height.sqrt())).max(NEAR);
|
||||
let terrain = id.is_some_and(|i| self.horizons.contains_key(&i));
|
||||
Some(LowSensor { id: id.unwrap_or(u32::MAX), ant: pos + DVec3::Y * spec.mast, base: pos.y, reach, terrain })
|
||||
}
|
||||
|
||||
/// The radars that are up now.
|
||||
pub fn low_sensors(&self) -> Vec<LowSensor> {
|
||||
self.sams.iter().filter(|s| s.radar_up()).filter_map(|s| self.low_sensor(s.kind, s.pos, Some(s.id))).collect()
|
||||
}
|
||||
|
||||
/// Whether the radar sees a low flyer (over the land) at the column (`x`, `z`): in its reach and not behind the land.
|
||||
pub fn low_sees(&self, ground: &Ground, s: &LowSensor, x: f64, z: f64) -> bool {
|
||||
let (dx, dz) = (x - s.ant.x, z - s.ant.z);
|
||||
let d2 = dx * dx + dz * dz;
|
||||
if d2 > s.reach * s.reach {
|
||||
return false;
|
||||
}
|
||||
if !s.terrain || d2 <= NEAR * NEAR {
|
||||
return true;
|
||||
}
|
||||
let Some(h) = self.horizons.get(&s.id) else { return true };
|
||||
let g = ground.peek(DVec3::new(x, 0.0, z)).unwrap_or(s.base);
|
||||
!h.hides(s.ant, DVec3::new(x, g + LOW_KIND.spec().clearance, z))
|
||||
}
|
||||
|
||||
/// Per ray round the radar, how far a low flyer is seen before the land first hides it (at most the reach).
|
||||
pub fn low_rim(&self, ground: &Ground, s: &LowSensor) -> Vec<f32> {
|
||||
let mut out = vec![s.reach as f32; RAYS];
|
||||
let Some(h) = self.horizons.get(&s.id).filter(|_| s.terrain) else { return out };
|
||||
let steps = ((s.reach / STEP) as usize).min(h.rays[0].len());
|
||||
let height = LOW_KIND.spec().clearance;
|
||||
for (r, o) in out.iter_mut().enumerate() {
|
||||
let a = r as f64 / RAYS as f64 * TAU;
|
||||
let (sx, sz) = (a.sin(), a.cos());
|
||||
for k in 1..=steps {
|
||||
let d = k as f64 * STEP;
|
||||
if d <= NEAR {
|
||||
continue;
|
||||
}
|
||||
let p = DVec3::new(s.ant.x + sx * d, 0.0, s.ant.z + sz * d);
|
||||
let y = ground.peek(p).unwrap_or(s.base) + height - s.ant.y;
|
||||
if ((y / d) as f32) < h.rays[r][k - 1] {
|
||||
*o = (d - STEP).max(NEAR) as f32;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// How a kind's radar looks round.
|
||||
pub(super) struct Scan {
|
||||
/// Ticks for the beam to go round.
|
||||
@@ -367,3 +458,74 @@ impl Sim {
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod low_view_tests {
|
||||
use super::*;
|
||||
|
||||
/// A radar on level land (70) with a hill east of it (140 high, x 128..160): the east ray is cut at the hill, the others are not.
|
||||
fn world() -> (Sim, Ground, u32) {
|
||||
let mut sim = Sim::new(3);
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Buk, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
let mut ground = Ground::default();
|
||||
let cols: Vec<(i32, i32, i32)> = (-60..60).map(|k| (k * 16, 0, if (128..160).contains(&(k * 16)) { 140 } else { 70 })).collect();
|
||||
ground.answer(cols, 0);
|
||||
let h = Horizon::build(antenna(&sim.sams[0]), sim.sams[0].kind.spec().sensor, &mut ground, 0);
|
||||
sim.horizons.insert(id, h);
|
||||
(sim, ground, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_land_cuts_the_coverage_edge_behind_a_hill() {
|
||||
let (sim, ground, id) = world();
|
||||
let ls = sim.low_sensor(SamKind::Buk, DVec3::new(0.0, 70.0, 0.0), Some(id)).unwrap();
|
||||
assert!(ls.terrain && ls.reach > 200.0, "{}", ls.reach);
|
||||
let rim = sim.low_rim(&ground, &ls);
|
||||
let east = rim[RIM_RAYS / 4];
|
||||
assert!((128.0..=176.0).contains(&east), "east edge {east}");
|
||||
// Rays whose land is unknown hide nothing: the whole reach.
|
||||
assert_eq!(rim[0], ls.reach as f32);
|
||||
assert_eq!(rim[RIM_RAYS * 3 / 4], ls.reach as f32);
|
||||
assert!(sim.low_sees(&ground, &ls, -200.0, 0.0));
|
||||
assert!(sim.low_sees(&ground, &ls, 100.0, 0.0));
|
||||
assert!(!sim.low_sees(&ground, &ls, 200.0, 0.0), "behind the hill");
|
||||
assert!(!sim.low_sees(&ground, &ls, ls.reach + 50.0, 0.0), "out of reach");
|
||||
// A ghost (no horizon) sees the whole circle.
|
||||
let ghost = sim.low_sensor(SamKind::Buk, DVec3::new(0.0, 70.0, 0.0), None).unwrap();
|
||||
assert!(sim.low_sees(&ground, &ghost, 200.0, 0.0));
|
||||
}
|
||||
|
||||
// The map's tint asks `low_sees` for a cell of a coarse grid at every radar that reaches it, and the edges
|
||||
// ask `low_rim`: with a Polygon's worth of systems both stay cheap (native time, a few ms; wasm is a few times that).
|
||||
#[test]
|
||||
fn coverage_of_128_systems_is_cheap() {
|
||||
let (mut sim, mut ground, _) = world();
|
||||
for i in 0..127 {
|
||||
let id = sim.id();
|
||||
let pos = DVec3::new((i % 16) as f64 * 40.0 - 300.0, 70.0, (i / 16) as f64 * 40.0 - 150.0);
|
||||
sim.sams.push(Sam::new(id, SamKind::Buk, pos, 0.0));
|
||||
let k = sim.sams.len() - 1;
|
||||
let h = Horizon::build(antenna(&sim.sams[k]), sim.sams[k].kind.spec().sensor, &mut ground, 0);
|
||||
sim.horizons.insert(id, h);
|
||||
}
|
||||
let sensors = sim.low_sensors();
|
||||
assert_eq!(sensors.len(), 128);
|
||||
let t = std::time::Instant::now();
|
||||
let mut seen = 0;
|
||||
for cy in 0..28 {
|
||||
for cx in 0..36 {
|
||||
let (x, z) = (cx as f64 * 30.0 - 500.0, cy as f64 * 30.0 - 400.0);
|
||||
// Worst case for the early exit: a cell no radar sees is tested against all that reach it.
|
||||
if sensors.iter().any(|s| sim.low_sees(&ground, s, x, z)) {
|
||||
seen += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let tint = t.elapsed();
|
||||
let t = std::time::Instant::now();
|
||||
let rims: usize = sensors.iter().take(3).map(|s| sim.low_rim(&ground, s).len()).sum();
|
||||
eprintln!("tint of 1008 cells: {tint:?} ({seen} seen); 3 rims: {:?} ({rims})", t.elapsed());
|
||||
assert!(seen > 0 && tint.as_millis() < 100, "{tint:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+249
-26
@@ -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
@@ -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;
|
||||
|
||||
@@ -79,7 +79,9 @@ impl Ground {
|
||||
Some((y > -64).then_some(f64::from(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
|
||||
|
||||
+21
-21
@@ -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
@@ -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, 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
-34
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+139
-1
@@ -11,6 +11,142 @@
|
||||
- Коммит из текста #96 (`76f680eb…`) в последних 200 коммитах `master` не нашёлся. Выводы привязаны к
|
||||
`453654ec`.
|
||||
|
||||
## Статус
|
||||
|
||||
**Стадии 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`) наше.
|
||||
|
||||
Что вышло и чем отличается от плана выше:
|
||||
|
||||
- Фильтр голоса: не однополюсный lowpass, а **полка** (`audio::Occlusion`): ниже 1 кГц звук проходит с `gain`, выше с
|
||||
`gain * cutoff`, два полюса, сглаживание 80 мс на буфер. Однополюсник резал бы и басы толстой стены; нужно «глухо», а не «тихо».
|
||||
- Формулы SPR: `cutoff = exp(-occ*3)`, `gain = cutoff^0.1`, 9 путей со сдвигом 0.35 (для 16 самых громких; остальные 1 путь),
|
||||
блок источника не считается, неизвестный чанк = воздух, не полный блок x0.25. Луч — обход вокселей (свой), а не clip по формам:
|
||||
забор, дверь считаются ячейкой целиком (x0.25). Жидкости пока не глушат.
|
||||
- Дальность 512 блоков (SPR: ~64), счёт клеток, не времени: 40 тыс. клеток на тик встроенно (новый звук, 1 путь, чтобы взрыв
|
||||
не начинался «ярким»), 400 тыс. на задание рабочего потока (9 путей, одно задание за раз); при съёмке всё встроенно, 600 тыс.
|
||||
клеток на тик. Порядок: по громкости, затем по id; сумма голосов в `mix` теперь тоже по id (раньше порядок HashMap делал дорожку
|
||||
недетерминированной при нескольких голосах).
|
||||
- Кеш по ячейкам 2x2x2 источника и слушателя, 4096 записей; сброс по секциям `World.sound_changes` (как `mod_changes`),
|
||||
по эпохе мира и при переполнении; результат задания, начатого до изменения в его коробке, отбрасывается.
|
||||
- Воздух: `0.994^расстояние` на верха (EFX по умолчанию, не проверено по OpenAL Soft) — иначе при `acoustics=on` пропадала бы
|
||||
дальность, которую раньше давали `_far`-сэмплы.
|
||||
- Мод: API 21, `render.acoustics()`; при включённой акустике мод играет ближние варианты до 480 блоков (`ACOUSTIC_FAR`), дальше
|
||||
`_far`. Задержку распространения мод держит сам (не трогали).
|
||||
- Реплей: мир, из которого читает акустика, — тот самый `World`, где реплей показывает блоки на нужный момент (журнал
|
||||
изменений, `set-world-time-view`); перемотка пишет блоки через `put`/`set_block`, а они попадают в `sound_changes`. Значит
|
||||
стена, которой уже нет, в реплее не глушит, а которая была — глушит. Проверено чтением кода, не запуском.
|
||||
|
||||
Измерено (release, M-серия, `audio::tests::bench_one_hundred_voices_with_and_without_occlusion`, 100 голосов, буфер 512 кадров
|
||||
10.67 мс): 0.110 мс без акустики, 0.327 мс с полкой на всех (+0.217 мс, около 2% буфера); оценка 0.5-1 мс выше была завышена.
|
||||
|
||||
Проверка без слуха (`audio::tests::export_wall_tracks` через настоящие `Acoustics` и `mix`, затем `tools/acoustics_check.py`),
|
||||
выстрел 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` — таблица состояний,
|
||||
снимок чанков — компилируется, но в игре не прогонялась); влияние на кадр при налёте; жидкости; дверь как проём (считается
|
||||
ячейкой); потолок памяти снимка.
|
||||
|
||||
### Стадия 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 идёт от источника к результату
|
||||
|
||||
Сигнал: Minecraft вызывает `Channel.play` → `mixin/SourceMixin.play` → `SoundPhysics.onPlaySound` →
|
||||
@@ -313,7 +449,7 @@
|
||||
классов не даёт скачка; сброс при `film_begin/film_end`; лимитер не срабатывает на нормальном налёте.
|
||||
- Проверка без слуха: импульсный отклик шины из `film_mix` → RT60 по спаду (`tools/acoustics_check.py`) для четырёх
|
||||
классов сцены (поле, ущелье, комната, тоннель) — таблица в отчёте; спад должен упорядочиться
|
||||
поле < комната < тоннель < ущелье; налёт 100 источников: пиковый уровень, число срабатываний лимитера, CPU.
|
||||
поле < ущелье < тоннель < комната (у ущелья открытый верх); налёт 100 источников: пиковый уровень, число срабатываний лимитера, CPU.
|
||||
- Критерий: хвост после конца исходного хлопка длится ожидаемое RT60 и не продолжается после смены мира.
|
||||
|
||||
### Стадия 4 (по #96): движущиеся источники и интеграция с #95/cinema
|
||||
@@ -332,3 +468,5 @@
|
||||
- Коммит из #96 (`76f680eb…`) не найден в недавней истории `master`; разбор сделан по `453654ec`.
|
||||
- Мод-API `query-rays` с политикой `sound` для этого недостаточно (нет материала и полупрозрачных долей), поэтому
|
||||
акустика в клиенте; мод только сообщает события (#95).
|
||||
|
||||
Включается кнопкой в меню мода (ещё → «Акустика»), сохраняется с миром. `HORIZON_ACOUSTICS=1` задаёт только начальное значение.
|
||||
|
||||
+9
-6
@@ -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.
|
||||
|
||||
@@ -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/38] Mod API (AirDef fork): world models, client particles,
|
||||
Subject: [PATCH 01/41] Mod API (AirDef fork): world models, client particles,
|
||||
sounds, ground heights
|
||||
|
||||
render.set-models draws block models with an interpolated pose per id;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 12:39:45 +0300
|
||||
Subject: [PATCH 02/38] cargo-horizon-mod: report particles and sounds
|
||||
Subject: [PATCH 02/41] cargo-horizon-mod: report particles and sounds
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 13:33:10 +0300
|
||||
Subject: [PATCH 03/38] Mod API (AirDef fork): full-screen post pass for mods
|
||||
Subject: [PATCH 03/41] Mod API (AirDef fork): full-screen post pass for mods
|
||||
|
||||
render.set-post compiles a mod's WGSL after a prelude (camera matrices,
|
||||
time, 256 vec4 of params, the world's colour and depth) and runs it after
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 22:09:30 +0300
|
||||
Subject: [PATCH 04/38] Mod models: draw far models as scaled impostors past
|
||||
Subject: [PATCH 04/41] Mod models: draw far models as scaled impostors past
|
||||
the far plane, cull at 4096
|
||||
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 22:31:00 +0300
|
||||
Subject: [PATCH 05/38] Mod cameras: render.set-views draws the world from
|
||||
Subject: [PATCH 05/41] Mod cameras: render.set-views draws the world from
|
||||
other points into pictures that HUD view shapes show (API level 9)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 23:08:21 +0300
|
||||
Subject: [PATCH 06/38] Mods: more fuel per tick and per event (AirDef plans
|
||||
Subject: [PATCH 06/41] Mods: more fuel per tick and per event (AirDef plans
|
||||
raids)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 23:25:29 +0300
|
||||
Subject: [PATCH 07/38] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
Subject: [PATCH 07/41] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 11:15:11 +0300
|
||||
Subject: [PATCH 08/38] Mod cameras: distant terrain tiles, fog measured from
|
||||
Subject: [PATCH 08/41] Mod cameras: distant terrain tiles, fog measured from
|
||||
the camera, no impostors while one is shown
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 11:25:45 +0300
|
||||
Subject: [PATCH 09/38] Mods: game.world-id, so mods keep state per world (API
|
||||
Subject: [PATCH 09/41] Mods: game.world-id, so mods keep state per world (API
|
||||
level 9)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 12:01:08 +0300
|
||||
Subject: [PATCH 10/38] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
Subject: [PATCH 10/41] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
packing-only tool skips wasmtime
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 13:18:43 +0300
|
||||
Subject: [PATCH 11/38] HD lighting: G-buffer pass, per-pixel mod point lights
|
||||
Subject: [PATCH 11/41] HD lighting: G-buffer pass, per-pixel mod point lights
|
||||
in HDR, bloom and tone curve; render.set-lights and render.set-lighting (API
|
||||
level 10)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 13:59:43 +0300
|
||||
Subject: [PATCH 12/38] HD lighting stage 2: sun and moon shadow cascades, the
|
||||
Subject: [PATCH 12/41] HD lighting stage 2: sun and moon shadow cascades, the
|
||||
world relit (sun by face angle and shadow, sky and block light through the
|
||||
lightmap), light shafts and halos, fog over it again, a light colour grade
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:19:53 +0300
|
||||
Subject: [PATCH 13/38] HD: bind groups cached until a resize or a new source,
|
||||
Subject: [PATCH 13/41] HD: bind groups cached until a resize or a new source,
|
||||
shadow passes draw only the entities that cast shadows
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:25:44 +0300
|
||||
Subject: [PATCH 14/38] HD: lights culled per 16x16 tile (surface and halo
|
||||
Subject: [PATCH 14/41] HD: lights culled per 16x16 tile (surface and halo
|
||||
lists built on the CPU), a naga test for the lighting shaders
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:43:36 +0300
|
||||
Subject: [PATCH 15/38] HD: GPU time per pass in the F3 timing list,
|
||||
Subject: [PATCH 15/41] HD: GPU time per pass in the F3 timing list,
|
||||
half-resolution GTAO with a depth-aware blur
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:02:02 +0300
|
||||
Subject: [PATCH 16/38] HD: shadows draw only sun-facing quads and skip buried
|
||||
Subject: [PATCH 16/41] HD: shadows draw only sun-facing quads and skip buried
|
||||
sections, the far cascade every second frame; TAA (Halton jitter,
|
||||
reprojection by depth, YCoCg variance clipping) before bloom
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:02:02 +0300
|
||||
Subject: [PATCH 17/38] Mods: model handles (render.model-id, model-generation,
|
||||
Subject: [PATCH 17/41] Mods: model handles (render.model-id, model-generation,
|
||||
set-models-by-id; API level 11) so mods send no model names each tick;
|
||||
clear-post and set-lighting documented
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:19:57 +0300
|
||||
Subject: [PATCH 18/38] HD: temporal upscaling (HORIZON_HD_SCALE: world,
|
||||
Subject: [PATCH 18/41] HD: temporal upscaling (HORIZON_HD_SCALE: world,
|
||||
G-buffer and lighting at a lower size, rebuilt to the window in the TAA
|
||||
pass); fix flickering shadows near the player (the near cascade lost its
|
||||
sections on frames the far one was held, the held far matrix ignored the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:47:52 +0300
|
||||
Subject: [PATCH 19/38] HD water: its own pass (colour, normal, depth), the
|
||||
Subject: [PATCH 19/41] HD water: its own pass (colour, normal, depth), the
|
||||
floor under it lit, a composite with world-space waves, refraction with depth
|
||||
absorption in the biome colour, screen-space reflections, Fresnel and a
|
||||
shadowed sun glint (HORIZON_HD_WATER=0 to switch off)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:56:04 +0300
|
||||
Subject: [PATCH 20/38] HD water: no vanilla texture on the surface (the biome
|
||||
Subject: [PATCH 20/41] HD water: no vanilla texture on the surface (the biome
|
||||
colour only), a darker, deeper colour
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 18:38:19 +0300
|
||||
Subject: [PATCH 21/38] HD sky and light: a physical atmosphere (Rayleigh, Mie,
|
||||
Subject: [PATCH 21/41] HD sky and light: a physical atmosphere (Rayleigh, Mie,
|
||||
ozone; constants and transmittance after Photon, MIT) for the sky, haze, fog
|
||||
and water reflections; warmer sunlight from the atmosphere's transmittance, a
|
||||
gentle sky-coloured ambient, soft PCSS-lite shadows near the player, a haze
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 19:02:16 +0300
|
||||
Subject: [PATCH 22/38] HD water: a darker blue, a little clearer, ripples a
|
||||
Subject: [PATCH 22/41] HD water: a darker blue, a little clearer, ripples a
|
||||
little higher
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 21:41:48 +0300
|
||||
Subject: [PATCH 23/38] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
|
||||
Subject: [PATCH 23/41] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
|
||||
tint, blue hour; MIT), volumetric clouds with ground shadows instead of
|
||||
vanilla's in HD, volumetric fog with sun shafts and morning mist
|
||||
(HORIZON_HD_CLOUDS=0, HORIZON_HD_VOLFOG=0)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 24971b3c67d8c87673eb2e0d204cedf8a2f46d1c Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 21:43:22 +0300
|
||||
Subject: [PATCH 24/38] Mods: world queries for simulations (render.query-rays,
|
||||
Subject: [PATCH 24/41] Mods: world queries for simulations (render.query-rays,
|
||||
query-sections, world-changes; API level 12): block-exact rays and section
|
||||
occupancy by collision, sight or sound, answered next tick within budgets,
|
||||
and a log of changed sections
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 22:56:15 +0300
|
||||
Subject: [PATCH 25/38] HD fixes: rain level from the world not from rain
|
||||
Subject: [PATCH 25/41] HD fixes: rain level from the world not from rain
|
||||
geometry (#41), TAA history on the jittered grid (#38), vanilla cloud tint
|
||||
without a depth heuristic (#35), air fog once over water (#37), translucent
|
||||
things in front of HD water (#36), cheaper sunrise fan (#39), halos in thick
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 21391afb26f49505d6cdaf454a6355cd4e747503 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 23:19:53 +0300
|
||||
Subject: [PATCH 26/38] HD split into modules (gfx/hd/: one Rust file and one
|
||||
Subject: [PATCH 26/41] HD split into modules (gfx/hd/: one Rust file and one
|
||||
WGSL file per pass, a shared prelude), contracts for colour, depth, alpha,
|
||||
time and coordinates written down, tests that the frame layout and bindings
|
||||
match between Rust and WGSL (#45)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 23:22:19 +0300
|
||||
Subject: [PATCH 27/38] HD clouds: noise scales that divide the camera's
|
||||
Subject: [PATCH 27/41] HD clouds: noise scales that divide the camera's
|
||||
65536-block wrap, so the clouds and their shadows no longer jump at x or z =
|
||||
0
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0fb59c782abf269fe555faa3241b31acf41e26d7 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:17:11 +0300
|
||||
Subject: [PATCH 28/38] HD: volumetric smoke and missile trails with clear
|
||||
Subject: [PATCH 28/41] HD: volumetric smoke and missile trails with clear
|
||||
tunnels through fog and cloud (render.set-smoke, API level 14; #54); sun
|
||||
speculars by material, wet surfaces and puddles in rain, wind in foliage
|
||||
(HORIZON_HD_SPECULAR=0, HORIZON_HD_WIND=0, HORIZON_HD_SMOKE=0); HD shaders
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:26:09 +0300
|
||||
Subject: [PATCH 29/38] HD clouds composited by depth so terrain and vehicles
|
||||
Subject: [PATCH 29/41] HD clouds composited by depth so terrain and vehicles
|
||||
inside or behind the layer are hidden and those in front stay in front (#67);
|
||||
world-anchored noise everywhere (cloud detail by height above the layer, fog,
|
||||
smoke and puddle scales dividing the wrap) (#68); the replay's time is visual
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:44:45 +0300
|
||||
Subject: [PATCH 30/38] HD smoke: young trails drawn at least as wide as a grid
|
||||
Subject: [PATCH 30/41] HD smoke: young trails drawn at least as wide as a grid
|
||||
cell (density scaled to keep the mass), gentler bulges, so a trail is a
|
||||
smooth ribbon and not a chain of puffs
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 35ffbaa26dd91855c8b93446646ba8c58448d3d0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:59:41 +0300
|
||||
Subject: [PATCH 31/38] Mods: render.set-main-view (API level 15) renders the
|
||||
Subject: [PATCH 31/41] Mods: render.set-main-view (API level 15) renders the
|
||||
screen's world from a mod camera with the full pipeline, so replays get HD
|
||||
and the post effects; small mod views get the post pass with their own camera
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 27e24527550942695164dd3a769a42a659e797ca Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 09:28:05 +0300
|
||||
Subject: [PATCH 32/38] Spot lights for mods (render.set-spot-lights, API level
|
||||
Subject: [PATCH 32/41] Spot lights for mods (render.set-spot-lights, API level
|
||||
16): surfaces lit in the cone, a volumetric beam stopped by depth (#60); film
|
||||
capture (render.capture-*, set-film-quality, API level 17): fixed-step clock,
|
||||
frames piped to ffmpeg or PNGs, offline audio, no chat in the frames (#58);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 615f5c3fa7c19a9895f8972a88333c6f250ad78a Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 14:30:39 +0300
|
||||
Subject: [PATCH 33/38] HD clouds and fog polish: cloud panorama, cirrus,
|
||||
Subject: [PATCH 33/41] HD clouds and fog polish: cloud panorama, cirrus,
|
||||
terrain-aware fog, far smoke cascade; replay world log and film clock;
|
||||
material overlays by sprite name
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0254984fdcce53a4a0f2bff15f9be3b78c93842e Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 15:01:09 +0300
|
||||
Subject: [PATCH 34/38] HD: coloured sun through glass and leaves, rain ripples
|
||||
Subject: [PATCH 34/41] HD: coloured sun through glass and leaves, rain ripples
|
||||
on water, film motion blur and depth of field
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9645d3f1f47d8bb358d5630288bdd801bd874911 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 15:19:32 +0300
|
||||
Subject: [PATCH 35/38] Film export: every frame in flight reaches the writer,
|
||||
Subject: [PATCH 35/41] Film export: every frame in flight reaches the writer,
|
||||
listener follows the main view, cloud wind on film frames, set-smoke reports
|
||||
real activity
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 22d6835507d5627b0fe4a749b082b025b35e0262 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 15:43:11 +0300
|
||||
Subject: [PATCH 36/38] Mod command trees: command.set-tree and
|
||||
Subject: [PATCH 36/41] Mod command trees: command.set-tree and
|
||||
set-suggestions, typed parse, colouring and completion from one tree (API 19)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9be21a54fff055e78db245488c28a971a823fdba Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 16:10:23 +0300
|
||||
Subject: [PATCH 37/38] Persistent mod sound sources: start, update, stop with
|
||||
Subject: [PATCH 37/41] Persistent mod sound sources: start, update, stop with
|
||||
generations, click-free voices, loop sections, voice budget and virtual loops
|
||||
(API 20)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From e84cdc21a9fabc4518c18edbeacb2df24c85b983 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 19:07:37 +0300
|
||||
Subject: [PATCH 38/38] HD clouds: rays from a far point, so the view bob's eye
|
||||
Subject: [PATCH 38/41] HD clouds: rays from a far point, so the view bob's eye
|
||||
offset does not bend them
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Проверка акустики без слуха (#96): энергия по полосам в дорожке с преградой и без неё.
|
||||
|
||||
python3 tools/acoustics_check.py open.raw wall.raw # s16le, 48000 Гц, стерео
|
||||
python3 tools/acoustics_check.py open.raw wall.raw --rate 48000 --channels 2
|
||||
|
||||
Дорожки берутся из `Audio::film_mix` (экспорт видео пишет `<имя>.audio.raw`) или из теста
|
||||
`cargo test -p horizon-client --lib audio::tests::export_wall_tracks -- --ignored` (HORIZON_ACOUSTICS_OUT=папка).
|
||||
Для каждой дорожки считается энергия левого канала в четырёх полосах (до 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
|
||||
import array
|
||||
import cmath
|
||||
import math
|
||||
import sys
|
||||
|
||||
BANDS = [("<300", 0, 300), ("300-1500", 300, 1500), ("1500-5000", 1500, 5000), (">5000", 5000, 1e9)]
|
||||
|
||||
|
||||
def fft(a):
|
||||
n = len(a)
|
||||
if n == 1:
|
||||
return a
|
||||
even, odd = fft(a[0::2]), fft(a[1::2])
|
||||
t = [cmath.exp(-2j * math.pi * k / n) * odd[k] for k in range(n // 2)]
|
||||
return [even[k] + t[k] for k in range(n // 2)] + [even[k] - t[k] for k in range(n // 2)]
|
||||
|
||||
|
||||
def bands(path, rate, channels):
|
||||
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]]
|
||||
n = 1
|
||||
while n * 2 <= len(left):
|
||||
n *= 2
|
||||
n = min(n, 1 << 16)
|
||||
if n < 1024:
|
||||
sys.exit(f"{path}: too short ({len(left)} frames)")
|
||||
# A Hann window over the loudest n frames' start: the burst is at the start of the track.
|
||||
w = [left[i] * (0.5 - 0.5 * math.cos(2 * math.pi * i / (n - 1))) for i in range(n)]
|
||||
spec = fft([complex(v) for v in w])
|
||||
out = [0.0] * len(BANDS)
|
||||
for k in range(1, n // 2):
|
||||
hz = k * rate / n
|
||||
for i, (_, lo, hi) in enumerate(BANDS):
|
||||
if lo <= hz < hi:
|
||||
out[i] += abs(spec[k]) ** 2
|
||||
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", 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)
|
||||
for (name, _, _), x, y in zip(BANDS, o, w):
|
||||
print(f"{name:>10} {db(x, ref):9.1f} {db(y, ref):9.1f} {db(y, x):10.1f}")
|
||||
low, high = db(w[0], o[0]), db(w[-1], o[-1])
|
||||
ok = high <= -a.min_high_cut and low >= -a.max_low_loss
|
||||
print(f"highs {high:.1f} dB (need <= -{a.min_high_cut}), lows {low:.1f} dB (need >= -{a.max_low_loss}): {'ok' if ok else 'FAIL'}")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+116
-1
@@ -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
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user