Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccc3e20902 |
@@ -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,12 +150,13 @@ 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 roles` — показать, по каким классам целей работает каждый комплекс; `/airdef roles <patriot|gepard|pantsir|s400|s500|all> <классы|default>` — задать (классы через запятую: `drone`, `jet`, `cruise`, `supersonic`, `ballistic` или `all`), например `/airdef roles s400 cruise,ballistic`;
|
||||
- `/airdef deploy <patriot|gepard|pantsir|s400|s500>` — поставить комплекс в 8 блоках перед собой;
|
||||
- `/airdef hold on|off` — запретить или разрешить ПВО огонь, удобно для съёмки ударов;
|
||||
@@ -192,25 +194,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
|
||||
@@ -68,7 +68,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 +77,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(
|
||||
@@ -148,8 +149,8 @@ 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",
|
||||
"deploy patriot", "deploy light", "deploy s400", "wave mix", "wave shahed", "wave shahed 20", "wave kh101 80 800", "wave shahed 20 4000",
|
||||
"wave oreshnik 1 60", "damage on", "damage off", "truth on", "shader hd", "shader off", "shader on", "hold on", "lastchance on", "lastchance off",
|
||||
"roles all default", "roles patriot drone,jet", "roles pantsir all", "roles tor ballistic",
|
||||
] {
|
||||
ok(line);
|
||||
@@ -174,7 +175,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);
|
||||
@@ -218,10 +219,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", "stats"] {
|
||||
assert!(top.contains(&w.to_string()), "{w} in {top:?}");
|
||||
}
|
||||
assert_eq!(top.len(), 24);
|
||||
assert_eq!(top.len(), 25);
|
||||
assert_eq!(complete("rep", &[]), ["repair", "replay"]);
|
||||
assert_eq!(complete("replay ", &[]), ["export", "film", "full", "map", "reel", "stop"]);
|
||||
assert_eq!(complete("replay film ", &[]), ["all", "best"]);
|
||||
|
||||
@@ -30,6 +30,7 @@ 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>,
|
||||
@@ -122,6 +123,7 @@ impl Layout {
|
||||
out.targets.push((pos, hp));
|
||||
}
|
||||
"damage" => out.damage = l.flag(1, "значение")?,
|
||||
"lastchance" => out.last_chance = l.flag(1, "значение")?,
|
||||
"far" => out.far = l.flag(1, "значение")?,
|
||||
"effects" => out.effects = Some((l.flag(1, "шейдер")?.unwrap_or(true), l.flag(2, "HD")?.unwrap_or(false))),
|
||||
"seed" => {
|
||||
|
||||
+30
-3
@@ -453,6 +453,7 @@ impl AirDef {
|
||||
let id = 0x6200_0000 | (f.id & 0x00ff_ffff);
|
||||
add(id, f.at + DVec3::Y * 1.2, (1.0, 0.5, 0.17), 70.0 * f.size * glow * flicker(f.id), 20.0 + 10.0 * f.size);
|
||||
}
|
||||
let mut spotted: Vec<u32> = Vec::new();
|
||||
for s in &self.sim.sams {
|
||||
if !s.alive() && s.burning() {
|
||||
add(s.id + 15, s.pos + DVec3::Y * 1.5, (1.0, 0.5, 0.18), 80.0 * flicker(s.id), 30.0);
|
||||
@@ -462,7 +463,9 @@ impl AirDef {
|
||||
&& s.kind == SamKind::Light
|
||||
&& let Some(t) = s.target.and_then(|t| self.sim.drones.iter().find(|d| d.id == t))
|
||||
&& self.sim.lit.contains(&t.id)
|
||||
&& !spotted.contains(&t.id)
|
||||
{
|
||||
spotted.push(t.id);
|
||||
add(s.id + 14, t.pos, (0.95, 0.97, 1.0), 220.0, 30.0);
|
||||
}
|
||||
}
|
||||
@@ -494,7 +497,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,
|
||||
@@ -857,6 +860,7 @@ 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);
|
||||
if view {
|
||||
text += &format!("far {}\n", self.far);
|
||||
text += &format!("effects {} {}\n", self.sim.shader, self.hd);
|
||||
@@ -945,6 +949,9 @@ impl AirDef {
|
||||
if let Some(d) = layout.damage {
|
||||
self.sim.damage = d;
|
||||
}
|
||||
if let Some(l) = layout.last_chance {
|
||||
self.sim.last_chance = l;
|
||||
}
|
||||
// A scenario's: the random generator, and the wave menu's raid.
|
||||
if let Some(v) = layout.seed {
|
||||
self.sim.reseed(v);
|
||||
@@ -1617,6 +1624,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
|
||||
@@ -1891,7 +1903,7 @@ impl Mod for AirDef {
|
||||
}
|
||||
["wave", kind, rest @ ..] => {
|
||||
let count = rest.first().and_then(|v| v.parse().ok()).unwrap_or(5u32).clamp(1, 80);
|
||||
let dist = rest.get(1).and_then(|v| v.parse().ok()).unwrap_or(260.0f64).clamp(60.0, 800.0);
|
||||
let dist = rest.get(1).and_then(|v| v.parse().ok()).unwrap_or(260.0f64).clamp(wave::DIST_MIN, wave::DIST_MAX);
|
||||
let p = player().ok_or("Нет игрока")?;
|
||||
let target = self
|
||||
.sim
|
||||
@@ -1904,7 +1916,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
|
||||
}
|
||||
@@ -1999,6 +2013,10 @@ impl Mod for AirDef {
|
||||
let names: Vec<&str> = Class::ALL.iter().filter(|c| r & c.bit() != 0).map(|c| c.name()).collect();
|
||||
say(&format!("{}: {}", kind.name(), if names.is_empty() { "ничего".into() } else { names.join(", ") }));
|
||||
}
|
||||
say(&format!(
|
||||
"Последний шанс: {} (комплекс стреляет и по классу вне ролей, если цель вот-вот попадёт; /airdef lastchance on|off)",
|
||||
if self.sim.last_chance { "вкл" } else { "выкл" }
|
||||
));
|
||||
Some("Классы: drone, jet, cruise, supersonic, ballistic (или all). /airdef roles <комплекс|all> <классы|default>".into())
|
||||
}
|
||||
["roles", who, what] => {
|
||||
@@ -2025,6 +2043,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() })
|
||||
|
||||
@@ -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,297 @@
|
||||
//! Balance measurements: a whole raid on flat land, flown headlessly against a cluster of systems.
|
||||
//! The `#[ignore]`d tests print tables (`cargo test --release balance -- --ignored --nocapture`);
|
||||
//! the others assert the cheap invariants. The tables are what the air defence's tuning is judged by.
|
||||
use super::sites::testland::{Land, serve};
|
||||
use super::*;
|
||||
|
||||
/// The ten armed kinds in the order a cluster takes them (long-range heavy ones spread evenly).
|
||||
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,
|
||||
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,
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
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: |_| {} }
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 mut rng = Rng::new(s.seed.wrapping_mul(977) + 5);
|
||||
let side = (s.objects as f64).sqrt().ceil() as usize;
|
||||
for k in 0..s.objects {
|
||||
let p = DVec3::new(((k % side) as f64 - side as f64 / 2.0) * 70.0, 70.0, ((k / side) as f64 - side as f64 / 2.0) * 70.0);
|
||||
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();
|
||||
sim.sams.push(Sam::new(id, MIX[i % MIX.len()], at + DVec3::new(a.sin() * r, 0.0, a.cos() * r), rng.f() * TAU));
|
||||
}
|
||||
for _ in 0..s.radars {
|
||||
let a = rng.f() * TAU;
|
||||
let r = rng.f() * 220.0;
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(a.sin() * r, 70.0, a.cos() * r), 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 land = Land(|_, _| 64);
|
||||
let mut out = Out::default();
|
||||
for _ in 0..200 {
|
||||
serve(&mut ground, &land, sim.tick);
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
let mut order = crate::wave::WaveConfig::mix(s.size, 500.0);
|
||||
// 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 {
|
||||
serve(&mut ground, &land, 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.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.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)> {
|
||||
let mut means = Vec::new();
|
||||
eprintln!("== {title} ({} raid)", if smart { "smart" } else { "mix" });
|
||||
eprintln!("{:>4} {:>4} {:>5} {:>5} {:>5} {:>6} {:>6} | leaks: unseen nocover untrk unshot missed hits crash", "sams", "seed", "sent", "kills", "leak", "missil", "heavy");
|
||||
for &n in systems {
|
||||
let (mut total, mut missiles, mut heavy, mut clean) = (0, 0, 0, 0);
|
||||
for &seed in seeds {
|
||||
let mut s = Setup::new(n, smart, seed);
|
||||
s.tweak = tweak;
|
||||
s.size = size;
|
||||
let r = fly(&s);
|
||||
total += r.leaked;
|
||||
clean += u32::from(r.leaked == 0);
|
||||
missiles += r.missiles;
|
||||
heavy += r.heavy;
|
||||
if std::env::var("ROWS").is_ok() { eprintln!("{n:>4} {seed:>4} {:>5} {:>5} {:>5} {:>6} {:>6} | {:>12} {:>7} {:>5} {:>6} {:>6} {:>4} {:>5} {:?}", r.launched, r.kills, r.leaked, r.missiles, r.heavy, r.unseen, r.no_cover, r.untracked, r.unshot, r.missed, r.hits, r.crashes, r.by_kind.iter().map(|k| format!("{:?}:{}", k.0, k.1)).collect::<Vec<_>>()); }
|
||||
if seed == seeds[0] && std::env::var("ROWS").is_ok() {
|
||||
eprintln!(" tally: {}", r.tally);
|
||||
}
|
||||
}
|
||||
let mean = total as f64 / seeds.len() as f64;
|
||||
let k = seeds.len() as f64;
|
||||
eprintln!("{n:>4} mean leaks {mean:.1} (clean seeds {clean}/{}), missiles {:.0}, of them heavy {:.0}", seeds.len(), missiles as f64 / k, heavy as f64 / k);
|
||||
means.push((n, mean));
|
||||
}
|
||||
means
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+131
-13
@@ -26,6 +26,8 @@ mod units;
|
||||
mod why;
|
||||
pub mod world;
|
||||
#[cfg(test)]
|
||||
mod balance;
|
||||
#[cfg(test)]
|
||||
mod regress;
|
||||
#[cfg(test)]
|
||||
mod sensor_tests;
|
||||
@@ -229,6 +231,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 +246,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 +289,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 +344,25 @@ 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);
|
||||
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 +569,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,
|
||||
@@ -924,6 +976,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 +1256,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>,
|
||||
@@ -1270,6 +1332,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,
|
||||
@@ -1432,6 +1497,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.
|
||||
@@ -1505,6 +1574,8 @@ impl Sim {
|
||||
truth: false,
|
||||
shader: false,
|
||||
hold: false,
|
||||
last_chance: true,
|
||||
far_shots: true,
|
||||
horizons: HashMap::new(),
|
||||
later: Vec::new(),
|
||||
queue: Vec::new(),
|
||||
@@ -1907,7 +1978,7 @@ 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 {
|
||||
@@ -1929,7 +2000,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 +2312,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() {
|
||||
@@ -2288,17 +2360,30 @@ impl Sim {
|
||||
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 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)
|
||||
})
|
||||
@@ -2330,7 +2415,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 +2458,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 +2606,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 +2660,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 +2689,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 +2798,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));
|
||||
|
||||
+138
-50
@@ -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.
|
||||
@@ -258,6 +263,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 +276,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 +311,7 @@ impl Sim {
|
||||
wants,
|
||||
sited: false,
|
||||
site_wait: 0,
|
||||
said: false,
|
||||
smart,
|
||||
approach: order.approach,
|
||||
total,
|
||||
@@ -356,7 +370,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 +452,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 +512,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 +565,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),
|
||||
@@ -621,7 +661,8 @@ impl Sim {
|
||||
for (t, d) in go {
|
||||
// A site's job leaves its site one at a time, from its stock; a destroyed or empty site cancels it.
|
||||
match self.site_slot(d.id) {
|
||||
sites::Slot::Free => {}
|
||||
// Free: not a site's job. Air: an off-map site's drone, already in flight position.
|
||||
sites::Slot::Free | sites::Slot::Air => {}
|
||||
sites::Slot::Busy => {
|
||||
self.queue.push((t, d));
|
||||
continue;
|
||||
@@ -645,9 +686,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 +695,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 +715,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 +734,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 +750,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 +770,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)) {
|
||||
@@ -848,6 +879,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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
+217
-23
@@ -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)];
|
||||
@@ -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,8 @@ 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.
|
||||
let Some(s) = self.sites.list.iter_mut().find(|s| s.id == id && !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 +491,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 +711,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 +750,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 +769,146 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
@@ -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
|
||||
|
||||
+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
|
||||
|
||||
+322
-19
@@ -45,9 +45,115 @@ 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 {
|
||||
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
|
||||
@@ -209,7 +315,7 @@ 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)
|
||||
.filter(|d| d.alive() && (sam.roles & d.kind.spec().class.bit() != 0 || last_chance(self.last_chance, sam.roles, d, claimed(d, &missile_on, sam.target))))
|
||||
.max_by(|a, b| a.threat(sam.pos).total_cmp(&b.threat(sam.pos)))
|
||||
});
|
||||
let Some(t) = subject else {
|
||||
@@ -227,10 +333,21 @@ 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));
|
||||
let detect = match (track, sam.own.contains(&t.id)) {
|
||||
(Some(tr), own) => {
|
||||
let kind = if own { "своей РЛС" } else { "по сети (стволы не стреляют)" };
|
||||
@@ -277,17 +394,31 @@ 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);
|
||||
// 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)) && claimed(o, &missile_on, sam.target) < 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 = needed(t);
|
||||
v.push(check(free < need, format!("цель занята перехватчиками: {free} из {need}{}", if free < need { "" } else { " — другие уже на ней" })));
|
||||
let mb = missile_blocks(sam);
|
||||
@@ -320,7 +451,8 @@ impl Sim {
|
||||
},
|
||||
));
|
||||
}
|
||||
fires |= reach == 0 && free < need && mb == 0 && aligned && blocks == 0 && land == 0;
|
||||
let grounds = blocks & !ROLE == 0 && (blocks & ROLE == 0 || chance) && (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));
|
||||
@@ -379,4 +511,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;
|
||||
|
||||
+48
-4
@@ -13,9 +13,9 @@
|
||||
|
||||
## Статус
|
||||
|
||||
**Стадия 1 (прямой звук) сделана**, включается `HORIZON_ACOUSTICS=1` (по умолчанию выключено, звук как был). Стадии 2-3
|
||||
не начаты. Коммит SPR прежний, `453654ec`; атрибуция: `horizon/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md`.
|
||||
GPL-файл один: `acoustics/spr_occlusion.rs`; остальное (движок `acoustics.rs`, склейка `game/acoustics.rs`, DSP в `audio.rs`) наше.
|
||||
**Стадия 1 (прямой звук) и стадия 2 (отводы эха) сделаны**, включается `HORIZON_ACOUSTICS=1` (по умолчанию выключено,
|
||||
звук как был). Стадия 3 (реверб) не начата. Коммит SPR прежний, `453654ec`; атрибуция: `horizon/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md`.
|
||||
GPL-файлы два: `acoustics/spr_occlusion.rs` и (стадия 2) `acoustics/spr_bounce.rs`; остальное (движок `acoustics.rs`, склейка `game/acoustics.rs`, DSP в `audio.rs`) наше.
|
||||
|
||||
Что вышло и чем отличается от плана выше:
|
||||
|
||||
@@ -45,9 +45,53 @@ GPL-файл один: `acoustics/spr_occlusion.rs`; остальное (дви
|
||||
выстрел 1.5 с на 60 блоках, стена камня в 2 блока: ниже 300 Гц -5.2 дБ (SPR: gain 0.55), 300-1500 -7.4, 1500-5000 -19.7, выше 5000 -40.0 дБ.
|
||||
Тесты: `cargo test -p horizon-client --lib acoustics` (формулы, неизвестное, щель, кеш, инвалидация, детерминизм, рабочий поток) и `audio`.
|
||||
|
||||
### Стадия 2: эхо (сделано)
|
||||
|
||||
Что игрок получает: взрыв в ущелье или между домами отвечает настоящим эхо от тех стен, что там есть, с задержкой по
|
||||
настоящему пути; в чистом поле эха нет. Код: `acoustics/echo.rs` (наш, MIT), `acoustics/spr_bounce.rs` (GPL: спираль
|
||||
Фибоначчи, `energyTowardsPlayer`, таблица отражательности), отводы в `audio.rs` (`Echo`), склейка `game/acoustics.rs`
|
||||
(таблица отражательности по состояниям блоков).
|
||||
|
||||
- Метод: 32 луча от источника и 32 от слушателя (направления SPR, длина 300) находят плоскости стен (грани блоков
|
||||
осевые). Для каждой плоскости мнимый источник (зеркальное отражение источника) даёт точный путь `|образ - слушатель|`;
|
||||
отвод оставляем, если точка пересечения этой прямой с плоскостью лежит на твёрдом блоке (стена там правда есть) и пути
|
||||
источник-стена и стена-слушатель свободны (окклюзия < 0.2). Это и есть «задержка по реальному пути»: сверка с формулой
|
||||
до сэмпла.
|
||||
- Задержка отвода = (путь - прямое расстояние) / 343 с: звук играет в момент прихода прямого (задержку распространения
|
||||
держит мод). Уровень = `min(0.85, 2 * energyTowardsPlayer(refl)) * min(1, max(d,4)/путь)`, затем в микшере умножается на
|
||||
громкость голоса и `distance_gain` длины пути; срез верхов по воздуху `0.994^(лишний путь)` (однополюсник на голос-отвод).
|
||||
Окклюзия прямого звука на эхо не действует (оно приходит другим путём).
|
||||
- Срез близких отражений: отвод ближе 30 мс к прямому звуку отбрасывается, до 50 мс плавно входит (иначе стена рядом с
|
||||
игроком даёт гребёнку, а не эхо). Из найденных плоскостей берутся до 6 сильнейших не ближе 40 мс друг к другу.
|
||||
- Кому: `deserves_echo` в `audio.rs`: не петля, не относительный, громкость >= 0.5, дальность (linear) >= 96, длина 0.1-12 с,
|
||||
прошло меньше 70% звука; не более `ECHO_VOICES = 8` одновременно (самые громкие). Сотня двигателей эха не получает
|
||||
(петли), сотня выстрелов: восемь самых громких.
|
||||
- Планирование: счёт по клеткам, не по времени. Живьём трассировка идёт на том же рабочем потоке (в задании до 4 трасс и
|
||||
240 тыс. клеток), кеш по блочным ячейкам источника и слушателя (центры ячеек, 256 записей), сброс по секциям, которых
|
||||
касаются точки опоры отводов, и по эпохе; в съёмке трасса синхронная, 480 тыс. клеток на тик. Микшер получает отводы
|
||||
числами (`take_echoes`), новый набор входит за ~50 мс (совпавший по задержке в пределах 3 мс отвод меняет только уровень).
|
||||
Голос после конца сэмпла живёт ещё `max_delay` (хвост), потом останавливается.
|
||||
- Измерено (release, M-серия): трасса `bench_traces`: ущелье 0.054 мс (7 тыс. клеток, 2 отвода), открытое поле 0.115 мс
|
||||
(15 тыс. клеток, 0 отводов), комната 0.022 мс; всё на рабочем потоке, игровой поток платит только клонированием `Arc` и
|
||||
отправкой. Микшер: 108 голосов (100 петель + 8 одноразовых) 0.213 мс -> 0.348 мс с 8 x 6 отводами (+0.135 мс, 1.3% буфера
|
||||
512 кадров). Время игрового потока в `tick_acoustics` и эхо-трасс копится в `status` (`acoustics.tick_ms_*`,
|
||||
`acoustics.echo.ms*`); в игре не снималось.
|
||||
- Проверка без слуха: `audio::tests::export_echo_tracks` (настоящие `Acoustics` и `mix`, щелчок) +
|
||||
`python3 tools/acoustics_check.py --echo-dir папка`: ущелье даёт пики 158.9 и 242.1 мс (ожидалось 158.9 и 242.1),
|
||||
поле ничего, комната один пик 39.9 мс (боковые стены; торцы и потолок ближе 30 мс срезаны). Тесты: `--lib acoustics`
|
||||
(ущелье, поле, комната, стена у слушателя, ширма, «стены нет там, где луч её нашёл», бюджет, детерминизм, кеш, инвалидация,
|
||||
потолок голосов, рабочий поток) и `--lib audio` (отвод стоит на своём кадре до сэмпла, хвост, без щелчка, детерминизм).
|
||||
|
||||
Не слушали: громкость отводов (уровни из формул SPR и 1/r, не подстраивались), пороги `deserves_echo` (угаданы: не знаем,
|
||||
какую громкость и дальность мод ставит выстрелам), срез 30/50 мс и разнос 40 мс на слух, срез верхов отвода. Первые 1-2 тика
|
||||
эхо нового звука ещё нет (трасса на рабочем потоке): теряется только начало отводов с задержкой менее ~100 мс. Лучи стадии
|
||||
видят стены только в 32 направлениях с каждого конца: узкая далёкая стена (меньше нескольких градусов) может не найтись.
|
||||
Косые стены считаются ступеньками: на склоне горы отводов много, остаются сильнейшие с разносом 40 мс. Отводы идут только
|
||||
в мире (реплей читает те же блоки), на съёмке не прогонялось в игре.
|
||||
|
||||
Не сделано / не проверено: запуск в игре и экспорт видео с настоящей сценой (склейка `game/acoustics.rs` — таблица состояний,
|
||||
снимок чанков — компилируется, но в игре не прогонялась); влияние на кадр при налёте; жидкости; дверь как проём (считается
|
||||
ячейкой); потолок памяти снимка. Стадии 2-3 (отводы эха, шины реверба) не начаты.
|
||||
ячейкой); потолок памяти снимка. Стадия 3 (шины реверба) не начата.
|
||||
|
||||
## 1. Как SPR идёт от источника к результату
|
||||
|
||||
|
||||
+8
-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. В игре
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 12:37:45 +0300
|
||||
Subject: [PATCH 01/39] Mod API (AirDef fork): world models, client particles,
|
||||
Subject: [PATCH 01/40] Mod API (AirDef fork): world models, client particles,
|
||||
sounds, ground heights
|
||||
|
||||
render.set-models draws block models with an interpolated pose per id;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 12:39:45 +0300
|
||||
Subject: [PATCH 02/39] cargo-horizon-mod: report particles and sounds
|
||||
Subject: [PATCH 02/40] cargo-horizon-mod: report particles and sounds
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 13:33:10 +0300
|
||||
Subject: [PATCH 03/39] Mod API (AirDef fork): full-screen post pass for mods
|
||||
Subject: [PATCH 03/40] Mod API (AirDef fork): full-screen post pass for mods
|
||||
|
||||
render.set-post compiles a mod's WGSL after a prelude (camera matrices,
|
||||
time, 256 vec4 of params, the world's colour and depth) and runs it after
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 22:09:30 +0300
|
||||
Subject: [PATCH 04/39] Mod models: draw far models as scaled impostors past
|
||||
Subject: [PATCH 04/40] Mod models: draw far models as scaled impostors past
|
||||
the far plane, cull at 4096
|
||||
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 22:31:00 +0300
|
||||
Subject: [PATCH 05/39] Mod cameras: render.set-views draws the world from
|
||||
Subject: [PATCH 05/40] Mod cameras: render.set-views draws the world from
|
||||
other points into pictures that HUD view shapes show (API level 9)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 23:08:21 +0300
|
||||
Subject: [PATCH 06/39] Mods: more fuel per tick and per event (AirDef plans
|
||||
Subject: [PATCH 06/40] Mods: more fuel per tick and per event (AirDef plans
|
||||
raids)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 23:25:29 +0300
|
||||
Subject: [PATCH 07/39] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
Subject: [PATCH 07/40] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 11:15:11 +0300
|
||||
Subject: [PATCH 08/39] Mod cameras: distant terrain tiles, fog measured from
|
||||
Subject: [PATCH 08/40] Mod cameras: distant terrain tiles, fog measured from
|
||||
the camera, no impostors while one is shown
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 11:25:45 +0300
|
||||
Subject: [PATCH 09/39] Mods: game.world-id, so mods keep state per world (API
|
||||
Subject: [PATCH 09/40] Mods: game.world-id, so mods keep state per world (API
|
||||
level 9)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 12:01:08 +0300
|
||||
Subject: [PATCH 10/39] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
Subject: [PATCH 10/40] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
packing-only tool skips wasmtime
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 13:18:43 +0300
|
||||
Subject: [PATCH 11/39] HD lighting: G-buffer pass, per-pixel mod point lights
|
||||
Subject: [PATCH 11/40] HD lighting: G-buffer pass, per-pixel mod point lights
|
||||
in HDR, bloom and tone curve; render.set-lights and render.set-lighting (API
|
||||
level 10)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 13:59:43 +0300
|
||||
Subject: [PATCH 12/39] HD lighting stage 2: sun and moon shadow cascades, the
|
||||
Subject: [PATCH 12/40] HD lighting stage 2: sun and moon shadow cascades, the
|
||||
world relit (sun by face angle and shadow, sky and block light through the
|
||||
lightmap), light shafts and halos, fog over it again, a light colour grade
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:19:53 +0300
|
||||
Subject: [PATCH 13/39] HD: bind groups cached until a resize or a new source,
|
||||
Subject: [PATCH 13/40] HD: bind groups cached until a resize or a new source,
|
||||
shadow passes draw only the entities that cast shadows
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:25:44 +0300
|
||||
Subject: [PATCH 14/39] HD: lights culled per 16x16 tile (surface and halo
|
||||
Subject: [PATCH 14/40] HD: lights culled per 16x16 tile (surface and halo
|
||||
lists built on the CPU), a naga test for the lighting shaders
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:43:36 +0300
|
||||
Subject: [PATCH 15/39] HD: GPU time per pass in the F3 timing list,
|
||||
Subject: [PATCH 15/40] HD: GPU time per pass in the F3 timing list,
|
||||
half-resolution GTAO with a depth-aware blur
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:02:02 +0300
|
||||
Subject: [PATCH 16/39] HD: shadows draw only sun-facing quads and skip buried
|
||||
Subject: [PATCH 16/40] HD: shadows draw only sun-facing quads and skip buried
|
||||
sections, the far cascade every second frame; TAA (Halton jitter,
|
||||
reprojection by depth, YCoCg variance clipping) before bloom
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:02:02 +0300
|
||||
Subject: [PATCH 17/39] Mods: model handles (render.model-id, model-generation,
|
||||
Subject: [PATCH 17/40] Mods: model handles (render.model-id, model-generation,
|
||||
set-models-by-id; API level 11) so mods send no model names each tick;
|
||||
clear-post and set-lighting documented
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:19:57 +0300
|
||||
Subject: [PATCH 18/39] HD: temporal upscaling (HORIZON_HD_SCALE: world,
|
||||
Subject: [PATCH 18/40] HD: temporal upscaling (HORIZON_HD_SCALE: world,
|
||||
G-buffer and lighting at a lower size, rebuilt to the window in the TAA
|
||||
pass); fix flickering shadows near the player (the near cascade lost its
|
||||
sections on frames the far one was held, the held far matrix ignored the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:47:52 +0300
|
||||
Subject: [PATCH 19/39] HD water: its own pass (colour, normal, depth), the
|
||||
Subject: [PATCH 19/40] HD water: its own pass (colour, normal, depth), the
|
||||
floor under it lit, a composite with world-space waves, refraction with depth
|
||||
absorption in the biome colour, screen-space reflections, Fresnel and a
|
||||
shadowed sun glint (HORIZON_HD_WATER=0 to switch off)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:56:04 +0300
|
||||
Subject: [PATCH 20/39] HD water: no vanilla texture on the surface (the biome
|
||||
Subject: [PATCH 20/40] HD water: no vanilla texture on the surface (the biome
|
||||
colour only), a darker, deeper colour
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 18:38:19 +0300
|
||||
Subject: [PATCH 21/39] HD sky and light: a physical atmosphere (Rayleigh, Mie,
|
||||
Subject: [PATCH 21/40] HD sky and light: a physical atmosphere (Rayleigh, Mie,
|
||||
ozone; constants and transmittance after Photon, MIT) for the sky, haze, fog
|
||||
and water reflections; warmer sunlight from the atmosphere's transmittance, a
|
||||
gentle sky-coloured ambient, soft PCSS-lite shadows near the player, a haze
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 19:02:16 +0300
|
||||
Subject: [PATCH 22/39] HD water: a darker blue, a little clearer, ripples a
|
||||
Subject: [PATCH 22/40] HD water: a darker blue, a little clearer, ripples a
|
||||
little higher
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 21:41:48 +0300
|
||||
Subject: [PATCH 23/39] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
|
||||
Subject: [PATCH 23/40] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
|
||||
tint, blue hour; MIT), volumetric clouds with ground shadows instead of
|
||||
vanilla's in HD, volumetric fog with sun shafts and morning mist
|
||||
(HORIZON_HD_CLOUDS=0, HORIZON_HD_VOLFOG=0)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 24971b3c67d8c87673eb2e0d204cedf8a2f46d1c Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 21:43:22 +0300
|
||||
Subject: [PATCH 24/39] Mods: world queries for simulations (render.query-rays,
|
||||
Subject: [PATCH 24/40] Mods: world queries for simulations (render.query-rays,
|
||||
query-sections, world-changes; API level 12): block-exact rays and section
|
||||
occupancy by collision, sight or sound, answered next tick within budgets,
|
||||
and a log of changed sections
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 22:56:15 +0300
|
||||
Subject: [PATCH 25/39] HD fixes: rain level from the world not from rain
|
||||
Subject: [PATCH 25/40] HD fixes: rain level from the world not from rain
|
||||
geometry (#41), TAA history on the jittered grid (#38), vanilla cloud tint
|
||||
without a depth heuristic (#35), air fog once over water (#37), translucent
|
||||
things in front of HD water (#36), cheaper sunrise fan (#39), halos in thick
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 21391afb26f49505d6cdaf454a6355cd4e747503 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 23:19:53 +0300
|
||||
Subject: [PATCH 26/39] HD split into modules (gfx/hd/: one Rust file and one
|
||||
Subject: [PATCH 26/40] HD split into modules (gfx/hd/: one Rust file and one
|
||||
WGSL file per pass, a shared prelude), contracts for colour, depth, alpha,
|
||||
time and coordinates written down, tests that the frame layout and bindings
|
||||
match between Rust and WGSL (#45)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 23:22:19 +0300
|
||||
Subject: [PATCH 27/39] HD clouds: noise scales that divide the camera's
|
||||
Subject: [PATCH 27/40] HD clouds: noise scales that divide the camera's
|
||||
65536-block wrap, so the clouds and their shadows no longer jump at x or z =
|
||||
0
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0fb59c782abf269fe555faa3241b31acf41e26d7 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:17:11 +0300
|
||||
Subject: [PATCH 28/39] HD: volumetric smoke and missile trails with clear
|
||||
Subject: [PATCH 28/40] HD: volumetric smoke and missile trails with clear
|
||||
tunnels through fog and cloud (render.set-smoke, API level 14; #54); sun
|
||||
speculars by material, wet surfaces and puddles in rain, wind in foliage
|
||||
(HORIZON_HD_SPECULAR=0, HORIZON_HD_WIND=0, HORIZON_HD_SMOKE=0); HD shaders
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:26:09 +0300
|
||||
Subject: [PATCH 29/39] HD clouds composited by depth so terrain and vehicles
|
||||
Subject: [PATCH 29/40] HD clouds composited by depth so terrain and vehicles
|
||||
inside or behind the layer are hidden and those in front stay in front (#67);
|
||||
world-anchored noise everywhere (cloud detail by height above the layer, fog,
|
||||
smoke and puddle scales dividing the wrap) (#68); the replay's time is visual
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:44:45 +0300
|
||||
Subject: [PATCH 30/39] HD smoke: young trails drawn at least as wide as a grid
|
||||
Subject: [PATCH 30/40] HD smoke: young trails drawn at least as wide as a grid
|
||||
cell (density scaled to keep the mass), gentler bulges, so a trail is a
|
||||
smooth ribbon and not a chain of puffs
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 35ffbaa26dd91855c8b93446646ba8c58448d3d0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:59:41 +0300
|
||||
Subject: [PATCH 31/39] Mods: render.set-main-view (API level 15) renders the
|
||||
Subject: [PATCH 31/40] Mods: render.set-main-view (API level 15) renders the
|
||||
screen's world from a mod camera with the full pipeline, so replays get HD
|
||||
and the post effects; small mod views get the post pass with their own camera
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 27e24527550942695164dd3a769a42a659e797ca Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 09:28:05 +0300
|
||||
Subject: [PATCH 32/39] Spot lights for mods (render.set-spot-lights, API level
|
||||
Subject: [PATCH 32/40] Spot lights for mods (render.set-spot-lights, API level
|
||||
16): surfaces lit in the cone, a volumetric beam stopped by depth (#60); film
|
||||
capture (render.capture-*, set-film-quality, API level 17): fixed-step clock,
|
||||
frames piped to ffmpeg or PNGs, offline audio, no chat in the frames (#58);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 615f5c3fa7c19a9895f8972a88333c6f250ad78a Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 14:30:39 +0300
|
||||
Subject: [PATCH 33/39] HD clouds and fog polish: cloud panorama, cirrus,
|
||||
Subject: [PATCH 33/40] HD clouds and fog polish: cloud panorama, cirrus,
|
||||
terrain-aware fog, far smoke cascade; replay world log and film clock;
|
||||
material overlays by sprite name
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0254984fdcce53a4a0f2bff15f9be3b78c93842e Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 15:01:09 +0300
|
||||
Subject: [PATCH 34/39] HD: coloured sun through glass and leaves, rain ripples
|
||||
Subject: [PATCH 34/40] HD: coloured sun through glass and leaves, rain ripples
|
||||
on water, film motion blur and depth of field
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9645d3f1f47d8bb358d5630288bdd801bd874911 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 15:19:32 +0300
|
||||
Subject: [PATCH 35/39] Film export: every frame in flight reaches the writer,
|
||||
Subject: [PATCH 35/40] Film export: every frame in flight reaches the writer,
|
||||
listener follows the main view, cloud wind on film frames, set-smoke reports
|
||||
real activity
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 22d6835507d5627b0fe4a749b082b025b35e0262 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 15:43:11 +0300
|
||||
Subject: [PATCH 36/39] Mod command trees: command.set-tree and
|
||||
Subject: [PATCH 36/40] Mod command trees: command.set-tree and
|
||||
set-suggestions, typed parse, colouring and completion from one tree (API 19)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9be21a54fff055e78db245488c28a971a823fdba Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 16:10:23 +0300
|
||||
Subject: [PATCH 37/39] Persistent mod sound sources: start, update, stop with
|
||||
Subject: [PATCH 37/40] Persistent mod sound sources: start, update, stop with
|
||||
generations, click-free voices, loop sections, voice budget and virtual loops
|
||||
(API 20)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From e84cdc21a9fabc4518c18edbeacb2df24c85b983 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 19:07:37 +0300
|
||||
Subject: [PATCH 38/39] HD clouds: rays from a far point, so the view bob's eye
|
||||
Subject: [PATCH 38/40] HD clouds: rays from a far point, so the view bob's eye
|
||||
offset does not bend them
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 19f07ff4295766c7bee4bd1fbfc509b6a82d12d4 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 22:09:23 +0300
|
||||
Subject: [PATCH 39/39] Acoustics stage 1: direct-path occlusion ported from
|
||||
Subject: [PATCH 39/40] Acoustics stage 1: direct-path occlusion ported from
|
||||
Sound Physics Remastered 453654e (GPL-3.0, separate files), off by default;
|
||||
stereo output on surround devices; mixer sum in a fixed order (API 21)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
@@ -9,6 +9,14 @@
|
||||
Для каждой дорожки считается энергия левого канала в четырёх полосах (до 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=папка). Коды выхода те же.
|
||||
Только стандартная библиотека.
|
||||
"""
|
||||
import argparse
|
||||
@@ -53,19 +61,75 @@ def bands(path, rate, channels):
|
||||
return out
|
||||
|
||||
|
||||
def echo_peaks(path, rate, channels, min_gap_ms=10.0, rel=0.02):
|
||||
"""Delays (ms from the first peak) of the peaks of the smoothed envelope of a track."""
|
||||
raw = array.array("h")
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
raw.frombytes(data[: len(data) // 2 * 2])
|
||||
frames = [sum(abs(raw[i + c]) for c in range(channels)) / 32768.0 for i in range(0, len(raw) - channels + 1, channels)]
|
||||
win = max(1, rate // 1000) # 1 ms
|
||||
env = []
|
||||
acc = 0.0
|
||||
for i, v in enumerate(frames):
|
||||
acc += v
|
||||
if i >= win:
|
||||
acc -= frames[i - win]
|
||||
env.append(acc / win)
|
||||
first = max(range(min(len(env), rate // 20)), key=env.__getitem__) # the direct sound: loudest in the first 50 ms
|
||||
top = env[first]
|
||||
gap = int(min_gap_ms * rate / 1000)
|
||||
peaks, i = [], first + int(0.02 * rate)
|
||||
while i < len(env):
|
||||
if env[i] >= rel * top:
|
||||
j = max(range(i, min(len(env), i + gap)), key=env.__getitem__)
|
||||
peaks.append((j - first) * 1000.0 / rate)
|
||||
i = j + gap
|
||||
else:
|
||||
i += 1
|
||||
return peaks
|
||||
|
||||
|
||||
def check_echo(path, expect, tol, rate, channels):
|
||||
peaks = echo_peaks(path, rate, channels)
|
||||
print(f"{path}: peaks at {', '.join(f'{p:.1f}' for p in peaks) or 'none'} ms; expected {', '.join(f'{e:.1f}' for e in expect) or 'none'} ms")
|
||||
ok = len(peaks) == len(expect) and all(abs(p - e) <= tol for p, e in zip(peaks, expect))
|
||||
print(f" {'ok' if ok else 'FAIL'} (tolerance {tol} ms)")
|
||||
return ok
|
||||
|
||||
|
||||
def db(a, b):
|
||||
return 10 * math.log10(max(a, 1e-20) / max(b, 1e-20))
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||
p.add_argument("open")
|
||||
p.add_argument("wall")
|
||||
p.add_argument("open", nargs="?")
|
||||
p.add_argument("wall", nargs="?")
|
||||
p.add_argument("--echo", help="track with one click: check the delays of its echoes")
|
||||
p.add_argument("--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.echo_dir:
|
||||
import os
|
||||
ok = True
|
||||
with open(os.path.join(a.echo_dir, "expected.txt")) as f:
|
||||
for line in f:
|
||||
name, _, vals = line.strip().partition(" ")
|
||||
want = [float(v) for v in vals.split(",") if v]
|
||||
# The ms in the room are for the walls the geometry says; only the first peak is compared there.
|
||||
ok &= check_echo(os.path.join(a.echo_dir, name + ".raw"), want, a.tol, a.rate, a.channels)
|
||||
return 0 if ok else 1
|
||||
if a.echo:
|
||||
want = [] if a.expect == "none" else [float(v) for v in a.expect.split(",")]
|
||||
return 0 if check_echo(a.echo, want, a.tol, a.rate, a.channels) else 1
|
||||
if not a.open or not a.wall:
|
||||
p.error("give open.raw and wall.raw, or --echo / --echo-dir")
|
||||
o, w = bands(a.open, a.rate, a.channels), bands(a.wall, a.rate, a.channels)
|
||||
print(f"{'band, Hz':>10} {'open dB':>9} {'wall dB':>9} {'change dB':>10}")
|
||||
ref = max(o)
|
||||
|
||||
+116
-1
@@ -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":
|
||||
|
||||
+117
-9
@@ -5,14 +5,21 @@
|
||||
Поворачивает и масштабирует их движок форка (render.set-models), так что ракурсы больше не нужны.
|
||||
Координаты элементов — в пределах -16..32 (иначе Horizon отбрасывает модель).
|
||||
|
||||
Запуск: python3 tools/gen_models.py
|
||||
Запуск: python3 tools/gen_models.py [--check]
|
||||
|
||||
Скрипт пишет во временный каталог и только потом переносит результат в 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 +1699,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 +1742,111 @@ 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)
|
||||
|
||||
|
||||
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)
|
||||
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