2 Commits
Author SHA1 Message Date
threenebula23 40c08c16ae docs: procedural VHS, no shader or screen texture
CI / tests (push) Successful in 32s
2026-10-25 18:05:36 +03:00
threenebula23 d8e3c1bb49 fix(ui): make VHS procedural and non-blocking 2026-10-25 18:01:00 +03:00
98 changed files with 7769 additions and 24544 deletions

No files matched your search

+44 -89
View File
@@ -1,4 +1,4 @@
# AI_REPORT — PEAK HOUR 97 (v2.0)
# AI_REPORT — PEAK HOUR 97
## 1. Инструменты и модели
@@ -13,111 +13,66 @@ test-writer, explorer, feis-regulator, oracle):
| **MiMo-V2.6-Flash** | аудио: SFX, ambient, фон |
| дефолтные модели субагентов | поиск, тесты, ревью |
MR-ревью — агент **oracle** (opencode-go/deepseek-v4.1-flash): dry-run
MR-ревью — агент **oracle** (`opencode-go/deepseek-v4.1-flash`): dry-run
74–90 с, комментарий в MR, без мержа.
## 2. Что изменилось в v2.0
## 2. Где применялся ИИ
- **Экономика:** старт 50 $, SLA 60/60, 1 слот, канал 10, буфер 6, таймер
запроса 30 с; кап SLA +35 за уровень до 200; сетка 5×4 → 8×6.
- **Магазин:** 8 лотов, ровно 3 оффера, рероллы 40/80/120, замки, веса/pity;
полное дерево — 13 100 $.
- **Файлы:** полимино 1/2/3/4 клетки, поворот, подпись в занятой клетке,
стол 4×6, резервации приёма, авто-возврат стола.
- **Мини-игры:** 11 игр (7 отправки + 4 атаки), без таймеров, seeded RNG,
экран результата с отложенным finished.
- **Атаки:** 20 за забег, 4 вида, 4 ступени, пауза часа, щит и эффекты
провала.
- **Save:** VERSION 2, единый источник позиций, миграция v1.
- **Документация:** GDD/DECISIONS/README/ITCH_PAGE переписаны под фактические
числа и поведение; старые решения помечены, а не удалены.
- **Код:** `GameController`, `HourDirector`, `AttackDirector`, `Shop`,
`SaveManager`, UI, мини-игры.
- **UI v2:** Kaleidos, маскот **БАЙТ**, туториал.
- **Ambient:** 4 WAV (`audio/amb`, PSU/fan/room/seek), генератор
`generate_ambient.py`, autoload после первого жеста, mute/пауза/громкость.
- **VHS:** процедурный `vhs_overlay.gd` (`RefCounted`) в `MainMenu._draw`;
скан-линии/зерно/виньетка, без shader/screen texture, ввод не блокирует,
текст резкий; только меню, чекбокс в настройках, HEADLESS без рендера.
- **Баланс/мета:** SLA, 10 апгрейдов, таймеры, канал, мета 3/5/9.
- **Аудит:** calm-фон, Esc итогов, уничтожение, тосты/fullscreen.
- **Тесты/доки/звук:** unit/fuzz/интеграция/полный забег; `docs/`;
18 SFX + `hum.wav` на Python.
## 3. Где применялся ИИ
- **Код:** HourDirector, FileRegistry, Economy, Shop, AttackDirector,
SaveManager, UI и мини-игры.
- **UI v2:** Kaleidos, маскот **БАЙТ**, туториал, VHS главного меню.
- **Ambient:** 4 WAV (audio/amb), генератор generate_ambient.py, autoload
после первого жеста, mute/пауза/громкость.
- **Баланс:** цели дохода, дерево магазина, симулятор на 240 сидах.
- **Аудит:** B4 (дыры в выборе мини-игры), H5 (лимит папок/цена),
M8 (масштаб денег атак), M11 (геометрия wires T4), M16 (soft-lock
«нет денег/нет места»).
- **Тесты/доки/звук:** unit/fuzz/интеграция/полный забег; docs/;
18 SFX + hum.wav на Python.
## 4. Приём: «враждебный критик»
## 3. Приём: «враждебный критик»
Промпт просил быть враждебным критиком: «найди блокеры, из-за которых
прототип нельзя собрать; не хвали». Для v2 главный найденный дефект —
**B4**: выбор мини-игры по S−1 давал дыры и недостижимые строки, потому что
при максимум 3 файлах существуют только 19 сумм из 28. Исправление:
прототип нельзя собрать; не хвали». Нашлось **6 блокеров**. Один из них:
выбор мини-игры по самому сложному типу ломал смешанные запросы. Заменили на
средний коэффициент:
ACHIEVABLE_SUMS = [1..14, 16, 17, 18, 20, 24]
rank = index of S in ACHIEVABLE_SUMS
tier = clamp(1 + floor(rank * 4 / 19), 1, 4)
id = order[rank % 7]
```
difficulty = sum(coeff(required)) / n
coeff = {text: 1.0, sound: 2.0, photo: 3.0, video: 4.0}
```
Все 7 игр и все 4 ступени достижимы; S=24 → video/T4. Старый пример S−1 из
спецификации отменён. Дополнительно закрыты M11 (низ wires T4 ≤ 320),
M16 (бесплатное уничтожение неприкреплённого файла) и H5 (лимит 6 папок /
100 $).
Это изменило §8.4 и §10 GDD и решение D17; пороги `<1.5 / <2.5 / <3.5`
зафиксированы в `data/balance.json` и проверяются в `test_minigame.gd`.
## 5. Что сделано без ИИ
## 4. Что сделано без ИИ
Идея, логлайн, исходная спецификация, ключевые решения (1997 год, 9 часов,
4 атаки, экономика, тон 90-х) и финальная приёмка — человеком, автором
проекта. ИИ не придумывал игру: он помогал формулировать, писать код,
считать баланс и искать противоречия.
## 6. Ассеты и лицензии
## 5. Ассеты и лицензии
- **Handjet** и **Press Start 2P** — SIL OFL 1.1, тексты в
art/fonts/OFL-Handjet.txt и OFL-PressStart2P.txt.
- Pixelify Sans в теме не используется (нет глифов О/П); файл и OFL
оставлены в art/fonts/.
- UI, иконки, маскот **БАЙТ** и icon.svg — команда проекта, MIT.
- SFX, ambient и hum.wav — скрипты проекта, MIT/CC0.
- Код — MIT (LICENSE); OFL-шрифты совместимы с MIT при сохранении лицензий.
Подробности — в ASSETS.md.
`art/fonts/OFL-Handjet.txt` и `OFL-PressStart2P.txt`.
- Pixelify Sans в теме не используется (нет глифов `О`/`П`); файл и OFL
оставлены в `art/fonts/`.
- UI, иконки и `icon.svg` — команда проекта, MIT.
- SFX, ambient и `hum.wav` — скрипты проекта, MIT/CC0.
- Код — MIT (`LICENSE`); OFL-шрифты совместимы с MIT при сохранении
лицензий. Подробности — в `ASSETS.md`.
## 7. Проверки v2.0
## 6. Проверки и ограничения
По прогону v2 (Windows, Godot 4.7.2):
- **22/22 PASS** (21 тест + parse). Core 543, hour 133, shop 116, minigame
51, slots 34, audio 37; fuzz 3249, integration 231, attacks 173,
controller 143.
- `test_full_playthrough` — 294 на сидах `1/42/777`: 3/3 victory, 9/9,
SLA 200; main 350, toast 14, fullscreen 31, ui 135, ambient 74.
- Web-экспорт — exit 0; Chromium **1920×1080**: меню → «Новый забег» →
подготовка, JS-ошибок нет.
- **25 тестовых наборов + parse-check — итого 26/26 PASS**.
Полный прогон:
powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1
(Linux/macOS — bash scripts/run_all_tests.sh).
- Windows-раннер сам ищет Godot; путь переопределяется GODOT_BIN или
-GodotBin.
- **Симулятор баланса:** python tests/sim_balance.py, 240 сидов, 9 часов,
exit 0, вердикт **PASS**: все часы в ±20 %, аналитические капы не ниже
целей. Средний итог — **8 184 $**; средний расход магазина — **5 771 $**
(**44,1 %** дерева 13 100 $), остаток после H9 — **2 463 $**.
- **Web-пресет** export_presets.cfg существует: thread_support=false,
экспорт в build/web/index.html, просмотр только по HTTP.
- Повторный запуск тестов в сессии правки документации не выполнялся:
shell в этом окружении недоступен. Числа выше — из прогона v2 и
tests/SIM_BALANCE_REPORT.md.
## 8. Известные отклонения
- ПРОПУСТИТЬ даёт q=0; quality = 0.5, то есть половина quality-множителя,
а не нулевая выплата. Осознанно.
- speed выплаты фиксируется на старте отправки, пока таймер был заморожен
мини-игрой; быстрый игрок видит ≈1.0.
- Лимит папок 6, новая папка 100 $ (review H5).
- Бесплатное уничтожение любого неприкреплённого файла в подготовке
(review M16).
- wires T4: box 34, pitch 40, верх 56 (review M11).
- Деньги атак масштабируются по часу max(0.75, hour/3) (review M8).
- Расход магазина 44,1 % **ниже** старой цели 55–65 %: доход H9 частично
остаётся неистраченным, забег заканчивается. Это не «полный выкуп к H8–H9».
## 9. Ограничения
Туториал — маскот и подсказки; web-save — IndexedDB; плейтест, громкость и
масштаб — за человеком; remote push ещё не выполнен. Мета-ключ `search`
остаётся в данных, но UI-поиск не подключён. Скриншоты в docs/screenshots/
стоит пересобрать под v2 перед публикацией страницы.
Ограничения: туториал — маскот и подсказки; web-save — IndexedDB; плейтест,
громкость и масштаб — за человеком; remote push ещё не выполнен.
+61 -90
View File
@@ -2,57 +2,56 @@
> Час пик. Одна смена. Ни одного свободного места.
1997 год. Ночной диспетчер узла файлового обмена: принимайте пакеты,
раскладывайте файлы-полимино по папкам, собирайте контейнеры запросов,
проходите мини-игры и держите SLA. UI — «Kaleidos»: тёплая бумага, тонкие
контуры и бирюзовый акцент.
Ночной диспетчер узла файлового обмена, 1997 год. Принимайте пакеты,
раскладывайте файлы, собирайте запросы клиентов, держите канал и SLA — в
интерфейсе, вдохновлённом Kaleidos: тёплая бумага, тонкие контуры и
бирюзовый акцент.
**Жанр:** менеджмент / тайм-менеджмент, retro UI.
**Платформа:** Godot **4.7.2** → **Web (HTML5, без threads)**, RU-only.
**Окно:** 1920×1080, логический viewport 1280×720, fractional scale.
**Забег:** 9 игровых часов по 5 минут (≈45 минут) + подготовка между часами.
## Статус v2.0
## Статус v1.2
Играбельный веб-прототип полного цикла: **меню → подготовка → час → итоги
часа → … → итоги забега**. Актуальная спецификация — [GDD v2.0](docs/GDD.md),
решения — [DECISIONS](docs/DECISIONS.md) (D29–D44).
часа → … → итоги забега**. Девять часов проходят на сидах `1`, `42`, `777`
без потери часов; SLA после магазина — 200.
Реализовано:
- **9 часов**, старт **50 $**, **SLA 60/60** (кап растёт +35 за уровень
`lot_sla` до 200), 1 слот (макс. 5), канал 10 (макс. 46), буфер 6 (макс. 12),
таймер запроса 30 с (макс. 55), таймер пакета 20 с.
- **4 типа файлов-полимино:** текст 1, звук 2, фото 3, видео 4 клетки;
поворот `R`/ПКМ во время drag; сетка папки 5×4 → 8×6.
- **Стол сортировки 4×6** в подготовке; файлы со стола нельзя прикрепить,
на старте часа они автоматически возвращаются в папки (если места нет —
час не начинается).
- **8 лотов магазина**; ровно 3 предложения, рероллы 40/80/120 (макс. 3 за
подготовку), замок отдельного предложения. Магазин — с H2 и только в
подготовке. Полное дерево — 13 100 $.
- **7 мини-игр отправки** (`dots`, `sound`, `wires`, `text`, `video`, `photo`,
`maze`) и **4 мини-игры атак** (`junk`, `ddos`, `phishing`, `ransomware`);
без таймеров, с экраном `q%`/вердикт и `ПРОДОЛЖИТЬ`.
- **Явная отправка** `ОТПРАВИТЬ` (автостарта нет) и переключатель **TCP/UDP**:
TCP +15 % к выплате, UDP ×0.8 ко времени. Файлы расходуются при успешной
отправке и сохраняются при отмене/таймауте.
- **20 атак за забег** (`0,1,1,2,2,3,3,4,4`), 4 вида, 4 ступени; атака — это
мини-игра, которая ставит час на паузу. Щит «Заземление» гасит атаку
автоматически (50 % награды, без штрафа).
- **Save VERSION 2**: позиции только в `folder.files`/`table`, метаданные в
`files`; v1-сейвы мигрируют без потери файлов и без уменьшения сеток;
офферы/рероллы/замки и стол сохраняются.
- Мета-награды после 3/5/9 часов, маскот **БАЙТ**, UI v2 «Kaleidos»,
ambient машинного зала, VHS только в главном меню.
- 4 типа файлов (text/sound/photo/video), буфер приёма, папки и секции.
- **10 апгрейдов** магазина, включая `SLA +24`; магазин открывается с часа 2
и скрыт во время часа.
- TCP-слоты и полоска соединения: три попадания; промах закрывает панель
без автоповтора.
- 4 атаки, но **час 1 проходит без атак**; junk/ddos — с часа 2,
phishing — с 3, ransomware — с 4.
- **SLA 128 / 200** на старте; потеря пакета: text 2 / sound 4 / photo 8 /
video 16.
- Мета-награды **постепенно по часам**: поиск после 3-го часа, щит после
5-го, `complete_run` после 9-го; итоги показывают «ОТКРЫТО: …», прогресс
живёт между забегами. `minigame_advanced` — резерв под будущий контент.
- Поиск по имени в текущей папке после открытия; заголовок «N ИЗ M ОБЪЕКТОВ».
- Автообновление папки/дерева, уникальные имена папок (`Имя`, `Имя 2`),
удаление пустых папок, attached-галочки, скролл приёма до 8 пакетов.
- «Уничтожить содержимое» папки удаляет файлы с подтверждением и −5 SLA;
пустая папка удаляется без штрафа.
- «Спокойный фон» — плоская бумага без dither; Esc на итогах забега ведёт
в главное меню.
- UI v2 «Kaleidos», маскот **БАЙТ** и короткий туториал первого часа.
- Ambient машинного зала: 4 WAV, автозапуск после первого жеста, mute/пауза/
громкость; генератор `audio/generate_ambient.py`.
- Процедурный VHS только в главном меню (настройки → «ЭКРАН»): скан-линии,
зерно, виньетка и полоса; рисуется кодом, не блокирует ввод, текст резкий.
Игровой UI остаётся чистым, без CRT на весь интерфейс.
- Handjet + Press Start 2P, 18 SFX + фоновый `hum`.
Ограничения:
- Туториал — маскот и несколько подсказок, без пошагового сценария.
- Сохранение в Web идёт через IndexedDB (`user://save.json`); проверяйте
продолжение после перезагрузки вкладки.
- Средний расход магазина в симуляторе — **44,1 %** дерева (ниже старой
цели 55–65 %); это известное ограничение баланса, а не «полный выкуп».
- Remote push ещё не выполнен; сборка для itch.io готовится локально.
## Управление
@@ -61,24 +60,20 @@
| --- | --- |
| Выбор, меню, перетаскивание | ЛКМ |
| Контекстное меню | ПКМ |
| Поворот фигуры во время drag | `R` или ПКМ |
| Пакет из «Приёма» → папка | drag&drop |
| Файл из папки → карточка запроса | drag&drop |
| Файл ↔ стол сортировки | drag&drop |
| Отправка запроса | кнопка `ОТПРАВИТЬ` |
| TCP/UDP | переключатель на карточке запроса до отправки |
| Мини-игра: действие | ЛКМ / `Space` / стрелки (по игре) |
| Пропустить мини-игру | `ПРОПУСТИТЬ` + подтверждение |
| Результат мини-игры | `ПРОДОЛЖИТЬ` или `Esc` |
| Магазин: закрыть | `ЗАКРЫТЬ` или `Esc` |
| Пакет из «Приёма» → папка; файл → карточка запроса | drag&drop |
| Поиск по имени в текущей папке | после открытия «Поиска» |
| Мини-игра соединения: три попадания | ЛКМ или `Space` |
| Мини-игра подготовки: отправить | `Enter` или кнопка |
| Пауза | `Esc` |
| Панель соединения: отмена (слот свободен, запрос остаётся) | `Esc` |
## Запуск
Нужен Godot **4.7.2**. Команды выполняются из корня репозитория; ниже
`GODOT` — путь к бинарю Godot.
Нужен Godot 4.7.2. В этом окружении:
```bash
GODOT=/home/eq/techlb/tools/godot/Godot_v4.7.2-stable_linux.x86_64
# один раз после клона: импорт шрифтов и темы
"$GODOT" --headless --path . --import
@@ -89,13 +84,6 @@
"$GODOT" -e --path .
```
В Windows удобно задать путь один раз:
```powershell
$env:GODOT_BIN = "D:\Apps\godot\Godot_v4.7.2-stable_win64_console.exe"
& $env:GODOT_BIN --path .
```
## Сборка Web
```bash
@@ -109,31 +97,20 @@ mkdir -p build/web
просмотр — только по HTTP:
```bash
python -m http.server 8000 --directory build/web
python3 -m http.server 8000 --directory build/web
# http://localhost:8000
```
Нужны export templates 4.7.2 (`web_nothreads_release.zip`). Каталог шаблонов
должен называться `4.7.2.stable`.
Нужны export templates 4.7.2 (`web_nothreads_release.zip`).
## Тесты
Полный прогон — **25 тестовых наборов + parse-check (итого 26/26 PASS)**:
```powershell
# Windows
powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1
```
Полный прогон (21 набор + parse-check):
```bash
# Linux / macOS
bash scripts/run_all_tests.sh
```
Windows-раннер сам ищет Godot (сначала `GODOT_BIN`, затем стандартный путь,
затем `godot4`/`godot` из `PATH`); путь можно передать явно:
`-GodotBin <path>`.
Точечно:
```bash
@@ -143,38 +120,32 @@ Windows-раннер сам ищет Godot (сначала `GODOT_BIN`, зате
"$GODOT" --headless --path . --script scripts/ui/ui_smoke.gd
```
Баланс (240 сидов, отчёт `tests/SIM_BALANCE_REPORT.md`):
```bash
python tests/sim_balance.py
```
Фактический итог v2: **26/26 PASS** (25 наборов + parse-check), симулятор
завершается с кодом 0 и вердиктом `PASS`. Подробности — в [AI_REPORT](AI_REPORT.md).
Фактический итог: **22/22 PASS** (21 набор + parse-check); ключевые
счётчики — `test_main_prep` 92/0, `ui_smoke` 135/0, `test_ambient` 74/0.
Подробности — в
[AI_REPORT](AI_REPORT.md).
## Структура
```
Game-JAM/
peak-hour/
├─ project.godot # 1920×1080 / 1280×720, GL Compatibility
├─ export_presets.cfg # Web (no threads)
├─ scenes/ # Main + UI/мини-игры
├─ scenes/ # Main + dev-превью
├─ scripts/
│ ├─ main.gd # UI, ввод, модалки, drag&drop
│ ├─ game_controller.gd # цикл menu → prep → hour → results
│ ├─ run_all_tests.ps1/.sh # полный headless-прогон
│ ├─ run_all_tests.sh # полный headless-прогон
│ ├─ autoload/ # Game, Data, Audio, Rng, Ambient
│ ├─ core/ # HourDirector, FileRegistry, SlotManager, Economy
│ ├─ entities/ # файлы, папки, секции, пакеты, запросы, слоты
│ ├─ systems/ # Shop, AttackDirector, SaveManager
│ ├─ ui/ # Theme90, FileGrid, SortingTable, ShopModal, VHS
│ └─ minigames/ # MiniGame, factory, send/, attack/
│ ├─ entities/ # файлы, пакеты, запросы, папки, слоты
│ ├─ systems/ # атаки, магазин, сохранения
│ ├─ ui/ # Theme90 v2, модалки, маскот, VHS-оверлей
│ └─ minigames/ # ConnectionGame, MiniGame
├─ data/ # balance.json, strings_ru.json, names_ru.json
├─ art/fonts/ # Handjet, Press Start 2P + OFL
├─ audio/ # sfx/, music/, amb/, generate_*py
├─ shaders/ # vhs.gdshader (только главное меню)
├─ tests/ # headless-наборы, sim_balance.py
├─ docs/ # GDD, DECISIONS, UI_REFRESH, скриншоты
├─ tests/ # headless-наборы
├─ docs/ # GDD, DECISIONS, UI_REFRESH, screenshots
└─ build/ # артефакты экспорта (gitignored)
```
@@ -182,18 +153,18 @@ Game-JAM/
- Godot **4.7.2** (GL Compatibility).
- Для Web: export templates 4.7.2, `web_nothreads_release.zip`.
- Python 3 — для баланса и пересборки SFX/ambient.
- Python 3 — только для пересборки SFX.
- Никаких секретов и внешних сервисов.
## Документация
- [GDD](docs/GDD.md) — правила и числа v2.0.
- [DECISIONS](docs/DECISIONS.md) — решения D01–D44 (актуальны D29–D44).
- [GDD](docs/GDD.md) — правила и числа v1.2.
- [DECISIONS](docs/DECISIONS.md) — решения D01–D25.
- [AI_REPORT](AI_REPORT.md) — модели, приёмы и проверки.
- [ASSETS](ASSETS.md) — шрифты, звук, лицензии и воспроизведение.
- [UI_REFRESH](docs/UI_REFRESH.md) — UI v2 «Kaleidos».
- [IMPLEMENTATION](docs/IMPLEMENTATION.md) и [TEST_PLAN](docs/TEST_PLAN.md) —
исторические контракты v1 и матрица тестов.
контракты и матрица тестов.
- [ITCH_PAGE](docs/ITCH_PAGE.md) — текст страницы itch.io.
- [screenshots](docs/screenshots/) — кадры меню, подготовки, часа и атак.
+205 -619
View File
@@ -1,640 +1,123 @@
{
"_meta": {
"file": "balance.json",
"version": "2.0",
"rules": "PEAK_HOUR_V2_SHOP_FILES_MINIGAMES.md §2, §3, §6, §8 + Appendix A",
"note": "v2: 8 lot families, 3 offers, rerolls, dense send-minigame selection. Legacy sections (difficulty/spawn/progression/timers/connections/channel) kept for the not-yet-rewritten HourDirector. New code reads constants/hours/grid/shop_rules/shop/minigames/attacks/payout/sla."
"version": "1.1",
"rules": "docs/GDD.md (компактная сводка) + docs/DECISIONS.md (D01–D17)",
"note": "null = параметр не задан в постановке, уточнить до кода (см. _todo). Никаких выдуманных чисел.",
"_todo": [
"attacks.junk.spawn_probability — не задан в спецификации (явно null)",
"income_target_by_hour: часы 4,6,7,8 — интерполяция, уточнить",
"difficulty.h2 packet_timer/request_timer: формула даёт 26.325/50.15 (19.5×1.35 и 29.5×1.7 — у запросов отдельный grace)"
]
},
"constants": {
"hour_seconds": 300,
"run_hours": 9,
"money_start": 50,
"sla_start": 60,
"sla_base": 60,
"sla_per_level": 35,
"sla_hard_cap": 200,
"sla_regen_per_hour": 15,
"slots_start": 1,
"slots_max": 5,
"channel_capacity": 10,
"channel_per_level": 6,
"channel_capacity_max": 46,
"buffer_base": 6,
"buffer_per_level": 1,
"buffer_max": 12,
"request_timer": 30,
"request_timer_per_level": 5,
"request_timer_max": 55,
"packet_timer": 20,
"folder_cols": 5,
"folder_rows": 4,
"folders_start": 2,
"folders_max": 6,
"folder_new_cost": 100,
"max_sections": 1,
"section_start": "C",
"sla_start": 128,
"sla_max": 200,
"money_start": 150,
"buffer_base": 4,
"buffer_per_upgrade": 2,
"buffer_max": 8,
"slots_start": 2,
"slots_max": 4,
"max_sections": 6,
"files_per_folder": 16,
"folders_per_section": 12,
"files_per_folder": 48,
"send_window_visible_cards": 4,
"send_scroll_card_height_px": 56,
"required_name_char_limit": 12,
"file_cooldown_after_send_sec": 0,
"consume_files_on_send": true,
"required_per_request": { "min": 1, "max": 3 },
"spawn_window_ratio": 0.75,
"spawn_jitter": 0.2,
"buffer_per_upgrade": 1
},
"economy": {
"start_stats": {
"money": 50,
"sla": 60,
"sla_cap": 60,
"slots": 1,
"channel_capacity": 10,
"buffer": 6,
"request_timer": 30,
"packet_timer": 20,
"folder_grid": [5, 4],
"money_mult": 1.0,
"speed_mult": 1.0
},
"formulas": {
"max_sla": "clamp(60 + 35 * sla_level, 60, 200)",
"sla_regen": "sla = min(sla_cap, sla + 15) в начале каждого часа",
"slots": "1 + slot_level (max 5)",
"channel_capacity": "10 + 6 * channel_level",
"buffer": "min(12, 6 + channel_level)",
"request_timer": "min(55, 30 + 5 * timer_level)",
"packet_timer": "20 (не покупается)",
"speed": "pow(0.92, speed_level)",
"money_mult": "1 + 0.05 * money_level",
"folder_dims": "[[5,4],[6,4],[6,5],[7,5],[7,6],[8,6]][folder_level]",
"transfer_time": "base_time_sec * pow(0.92, speed_level) * max(1.0, load / capacity)",
"protocol_time": "UDP ×0.8, TCP ×1.0 (только отправка)"
}
"file_cooldown_after_send_sec": 20,
"required_per_request": { "min": 1, "max": 3 }
},
"types": {
"text": {
"name_ru": "Текст",
"ext": "txt",
"icon": "text",
"size_kb": 64,
"cells": 1,
"base_time_sec": 3,
"ext": ".txt",
"icon": "▤",
"recv_time": 1.0,
"send_time": 1.0,
"recv_fee": 2,
"base_pay": 20,
"recv_fee": 1,
"weight": 1,
"minigame_coeff": 1,
"sla_loss": 2,
"shapes": ["O1"]
"weight": 6,
"minigame_coeff": 1.0,
"sla_loss": 2
},
"sound": {
"name_ru": "Звук",
"ext": "mp3",
"icon": "sound",
"size_kb": 3072,
"cells": 2,
"base_time_sec": 5,
"base_pay": 34,
"recv_fee": 2,
"weight": 2,
"minigame_coeff": 2,
"sla_loss": 4,
"shapes": ["I2H", "I2V"]
"ext": ".wav",
"icon": "♪",
"recv_time": 1.5,
"send_time": 2.0,
"recv_fee": 3,
"base_pay": 35,
"weight": 10,
"minigame_coeff": 2.0,
"sla_loss": 4
},
"photo": {
"name_ru": "Фото",
"ext": "jpg",
"icon": "photo",
"size_kb": 5120,
"cells": 3,
"base_time_sec": 8,
"base_pay": 52,
"recv_fee": 4,
"weight": 4,
"minigame_coeff": 4,
"sla_loss": 8,
"shapes": ["I3H", "I3V", "L3a", "L3b", "L3c", "L3d"]
"ext": ".jpg",
"icon": "▣",
"recv_time": 2.0,
"send_time": 3.0,
"recv_fee": 5,
"base_pay": 55,
"weight": 14,
"minigame_coeff": 3.0,
"sla_loss": 8
},
"video": {
"name_ru": "Видео",
"ext": "mp4",
"icon": "video",
"size_kb": 20480,
"cells": 4,
"base_time_sec": 12,
"base_pay": 80,
"recv_fee": 6,
"weight": 8,
"minigame_coeff": 8,
"sla_loss": 16,
"shapes": ["I4H", "I4V", "O4", "T4a", "T4b", "T4c", "T4d", "L4a", "L4b", "L4c", "L4d", "J4a", "J4b", "J4c", "J4d", "S4", "Z4"]
}
},
"hours": [
{
"hour": 1,
"files": 4,
"request_cap": 2,
"active_requests": 1,
"attacks": 0,
"income_target": 65,
"recv_interval": 12,
"send_interval": 22,
"type_weights": { "text": 0.7, "sound": 0.3, "photo": 0.0, "video": 0.0 },
"request_mix": { "1": 0.7, "2": 0.3, "3": 0.0 }
},
{
"hour": 2,
"files": 8,
"request_cap": 5,
"active_requests": 2,
"attacks": 1,
"income_target": 220,
"recv_interval": 11,
"send_interval": 20,
"type_weights": { "text": 0.55, "sound": 0.3, "photo": 0.15, "video": 0.0 },
"request_mix": { "1": 0.6, "2": 0.35, "3": 0.05 }
},
{
"hour": 3,
"files": 12,
"request_cap": 5,
"active_requests": 2,
"attacks": 1,
"income_target": 380,
"recv_interval": 10,
"send_interval": 18,
"type_weights": { "text": 0.4, "sound": 0.3, "photo": 0.22, "video": 0.08 },
"request_mix": { "1": 0.5, "2": 0.4, "3": 0.1 }
},
{
"hour": 4,
"files": 16,
"request_cap": 6,
"active_requests": 3,
"attacks": 2,
"income_target": 560,
"recv_interval": 9,
"send_interval": 17,
"type_weights": { "text": 0.3, "sound": 0.25, "photo": 0.28, "video": 0.17 },
"request_mix": { "1": 0.5, "2": 0.4, "3": 0.1 }
},
{
"hour": 5,
"files": 20,
"request_cap": 6,
"active_requests": 3,
"attacks": 2,
"income_target": 760,
"recv_interval": 8,
"send_interval": 15,
"type_weights": { "text": 0.22, "sound": 0.2, "photo": 0.32, "video": 0.26 },
"request_mix": { "1": 0.4, "2": 0.45, "3": 0.15 }
},
{
"hour": 6,
"files": 20,
"request_cap": 7,
"active_requests": 3,
"attacks": 3,
"income_target": 980,
"recv_interval": 7.5,
"send_interval": 14,
"type_weights": { "text": 0.15, "sound": 0.15, "photo": 0.35, "video": 0.35 },
"request_mix": { "1": 0.4, "2": 0.45, "3": 0.15 }
},
{
"hour": 7,
"files": 20,
"request_cap": 7,
"active_requests": 4,
"attacks": 3,
"income_target": 1200,
"recv_interval": 7,
"send_interval": 13,
"type_weights": { "text": 0.1, "sound": 0.12, "photo": 0.35, "video": 0.43 },
"request_mix": { "1": 0.3, "2": 0.5, "3": 0.2 }
},
{
"hour": 8,
"files": 20,
"request_cap": 7,
"active_requests": 4,
"attacks": 4,
"income_target": 1450,
"recv_interval": 6.5,
"send_interval": 12,
"type_weights": { "text": 0.06, "sound": 0.08, "photo": 0.32, "video": 0.54 },
"request_mix": { "1": 0.3, "2": 0.5, "3": 0.2 }
},
{
"hour": 9,
"files": 20,
"request_cap": 8,
"active_requests": 4,
"attacks": 4,
"income_target": 1700,
"recv_interval": 6,
"send_interval": 12,
"type_weights": { "text": 0.04, "sound": 0.04, "photo": 0.3, "video": 0.62 },
"request_mix": { "1": 0.3, "2": 0.5, "3": 0.2 }
}
],
"payout": {
"speed_base": 0.6,
"speed_bonus": 0.4,
"quality_base": 0.5,
"quality_bonus": 0.5,
"multi_step": 0.15,
"protocol_mult": { "TCP": 1.15, "UDP": 1.0 },
"udp_time_mult": 0.8,
"streak_step": 0.02,
"streak_cap": 10,
"money_mult_step": 0.05,
"formula": "payout = int(round(float(base_sum) * speed * quality * multi * protocol * streak * money_mult))",
"speed_formula": "speed = 0.6 + 0.4 * clamp(time_left / timer_total, 0, 1)",
"quality_formula": "quality = 0.5 + 0.5 * q",
"multi_formula": "multi = 1 + 0.15 * (n - 1)",
"streak_formula": "streak = 1 + 0.02 * min(10, streak)",
"money_mult_formula": "money_mult = 1 + 0.05 * money_level",
"note": "Только float-арифметика: никаких int/int и никакого ^ для степени."
},
"sla": {
"start": 60,
"regen_per_hour": 15,
"defeat_at_zero": true,
"timeout_fine_base": 10,
"timeout_fine_per_hour": 2,
"timeout_fine_formula": "-(10 + 2*hour) SLA",
"wrong_attach_fine": -2,
"lost_file_fine": -5,
"buffer_overflow_fine": -5,
"reject_money": -5,
"reject_sla": -5,
"folder_destroyed_fine": -5,
"sla_lose": "SLA <= 0 во время часа = поражение забега"
},
"grid": {
"gap": 6,
"cell_min": 56,
"cell_max": 104,
"view_w": 558,
"view_h": 470,
"folder_cols": 5,
"folder_rows": 4,
"folder_dims": [[5, 4], [6, 4], [6, 5], [7, 5], [7, 6], [8, 6]],
"table_cols": 4,
"table_rows": 6,
"cell_formula": "cell = clamp(floor(min((view_w - gap*(cols-1))/cols, (view_h - gap*(rows-1))/rows)), 56, 104)",
"shapes": {
"O1": { "cells": [[0, 0]], "w": 1, "h": 1 },
"I2H": { "cells": [[0, 0], [1, 0]], "w": 2, "h": 1 },
"I2V": { "cells": [[0, 0], [0, 1]], "w": 1, "h": 2 },
"I3H": { "cells": [[0, 0], [1, 0], [2, 0]], "w": 3, "h": 1 },
"I3V": { "cells": [[0, 0], [0, 1], [0, 2]], "w": 1, "h": 3 },
"L3a": { "cells": [[0, 0], [0, 1], [1, 1]], "w": 2, "h": 2 },
"L3b": { "cells": [[0, 0], [1, 0], [0, 1]], "w": 2, "h": 2 },
"L3c": { "cells": [[1, 0], [0, 1], [1, 1]], "w": 2, "h": 2 },
"L3d": { "cells": [[0, 0], [1, 0], [1, 1]], "w": 2, "h": 2 },
"I4H": { "cells": [[0, 0], [1, 0], [2, 0], [3, 0]], "w": 4, "h": 1 },
"I4V": { "cells": [[0, 0], [0, 1], [0, 2], [0, 3]], "w": 1, "h": 4 },
"O4": { "cells": [[0, 0], [1, 0], [0, 1], [1, 1]], "w": 2, "h": 2 },
"T4a": { "cells": [[0, 0], [1, 0], [2, 0], [1, 1]], "w": 3, "h": 2 },
"T4b": { "cells": [[0, 1], [1, 1], [2, 1], [1, 0]], "w": 3, "h": 2 },
"T4c": { "cells": [[0, 0], [0, 1], [1, 1], [0, 2]], "w": 2, "h": 3 },
"T4d": { "cells": [[1, 0], [1, 1], [0, 1], [1, 2]], "w": 2, "h": 3 },
"L4a": { "cells": [[0, 0], [0, 1], [0, 2], [1, 2]], "w": 2, "h": 3 },
"L4b": { "cells": [[0, 0], [1, 0], [2, 0], [0, 1]], "w": 3, "h": 2 },
"L4c": { "cells": [[0, 0], [1, 0], [1, 1], [1, 2]], "w": 2, "h": 3 },
"L4d": { "cells": [[2, 0], [0, 1], [1, 1], [2, 1]], "w": 3, "h": 2 },
"J4a": { "cells": [[1, 0], [1, 1], [1, 2], [0, 2]], "w": 2, "h": 3 },
"J4b": { "cells": [[0, 0], [1, 0], [2, 0], [2, 1]], "w": 3, "h": 2 },
"J4c": { "cells": [[0, 0], [0, 1], [1, 1], [2, 1]], "w": 3, "h": 2 },
"J4d": { "cells": [[0, 0], [0, 1], [0, 2], [1, 0]], "w": 2, "h": 3 },
"S4": { "cells": [[1, 0], [2, 0], [0, 1], [1, 1]], "w": 3, "h": 2 },
"Z4": { "cells": [[0, 0], [1, 0], [1, 1], [2, 1]], "w": 3, "h": 2 }
},
"type_shapes": {
"text": ["O1"],
"sound": ["I2H", "I2V"],
"photo": ["I3H", "I3V", "L3a", "L3b", "L3c", "L3d"],
"video": ["I4H", "I4V", "O4", "T4a", "T4b", "T4c", "T4d", "L4a", "L4b", "L4c", "L4d", "J4a", "J4b", "J4c", "J4d", "S4", "Z4"]
}
},
"shop_rules": {
"offers": 3,
"unlock_hour": 2,
"reroll_costs": [40, 80, 120],
"max_rerolls": 3,
"last_offer_weight": 0.5,
"pity_hours": 2,
"pity_weight": 1.5,
"note": "Веса берутся из shop[].weight_by_hour (9 значений, индекс = hour-1). Фазы H2-3/H4-6/H7-9 — таблица §3.1 — источник правды."
},
"shop": [
{
"id": "lot_slots",
"name_ru": "Порты коммутатора",
"desc_ru": "Ещё один порт: приём и отправка могут идти параллельно.",
"effect": "connection_slots",
"value": 1,
"max_level": 4,
"prices": [120, 300, 650, 1200],
"level_names": ["Второй порт", "Третий порт", "Четвёртый порт", "Магистральный порт"],
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8]
},
{
"id": "lot_speed",
"name_ru": "Оптоволокно",
"desc_ru": "Скорость приёма и отправки +8% за уровень (умножается).",
"effect": "transfer_speed",
"value": 0.08,
"max_level": 6,
"prices": [90, 130, 180, 250, 340, 450],
"level_names": ["Витая пара", "Экранированная пара", "Оптоволокно", "Гигабитная линия", "Магистраль", "Тёмное волокно"],
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8]
},
{
"id": "lot_sla",
"name_ru": "Резервный ИБП",
"desc_ru": "Держит узел живым: кап SLA +35 и мгновенное восстановление +35.",
"effect": "sla_cap",
"value": 35,
"max_level": 4,
"prices": [120, 260, 480, 800],
"level_names": ["Резервный ИБП", "Горячий резерв", "Двойное питание", "Автономный контур"],
"weight_by_hour": [0.8, 0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
},
{
"id": "lot_shield",
"name_ru": "Заземление",
"desc_ru": "Автоматически гасит атаку: 50% награды, без штрафа.",
"effect": "shield_charge",
"value": 1,
"max_level": 3,
"prices": [150, 400, 800],
"level_names": ["Заземление", "Громоотвод", "Защитный контур"],
"weight_by_hour": [0.3, 0.3, 0.3, 0.8, 0.8, 0.8, 1.0, 1.0, 1.0]
},
{
"id": "lot_money",
"name_ru": "Корпоративный контракт",
"desc_ru": "Оплата за контейнеры +5% за уровень.",
"effect": "money_mult",
"value": 0.05,
"max_level": 8,
"prices": [80, 110, 150, 200, 260, 340, 440, 560],
"level_names": ["Тарифный план", "Корпоративный контракт", "SLA-пакет", "Госзаказ", "Экспортный контракт", "Франшиза", "Монополия", "Золотая жила"],
"weight_by_hour": [0.6, 0.6, 0.6, 1.0, 1.0, 1.0, 1.2, 1.2, 1.2]
},
{
"id": "lot_folder",
"name_ru": "Складской стеллаж",
"desc_ru": "Больше места под фигуры: 20 → 24 клетки на первом уровне.",
"effect": "folder_cells",
"value": 1,
"max_level": 5,
"prices": [80, 180, 360, 650, 1000],
"level_names": ["Стеллаж", "Секция стеллажа", "Складская линия", "Автоматизированный склад", "Ангар"],
"weight_by_hour": [1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.8, 0.8, 0.8]
},
{
"id": "lot_channel",
"name_ru": "Пропускная способность",
"desc_ru": "Канал +6 и буфер приёма +1 за уровень.",
"effect": "channel_capacity",
"value": 6,
"buffer_value": 1,
"max_level": 6,
"prices": [70, 100, 140, 190, 250, 330],
"level_names": ["Канал 56k", "ISDN", "ADSL", "Выделенная линия", "Оптика", "Магистраль"],
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.9, 0.9, 0.9]
},
{
"id": "lot_timer",
"name_ru": "Регламент обработки",
"desc_ru": "Больше времени на поиск файлов и прикрепление: 30 → 35 с.",
"effect": "request_timer",
"value": 5,
"max_level": 5,
"prices": [70, 110, 160, 230, 320],
"level_names": ["Регламент", "Продление сессии", "Гибкий график", "Приоритетная обработка", "Выделенный менеджер"],
"weight_by_hour": [1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.7, 0.7, 0.7]
}
],
"minigames": {
"selection": {
"order": ["dots", "sound", "wires", "text", "video", "photo", "maze"],
"achievable_sums": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 20, 24],
"max_index": 27,
"formula": "S=sum(coeff); rank=index of S in achievable_sums (or largest listed <= S); tier=clamp(1+floor(rank*4.0/count),1,4); id=order[rank%7]",
"note": "Ровно 3 файла максимум: суммы 15/19/21/22/23 недостижимы, поэтому используется плотный список достижимых сумм (B4). Все 7 игр и все 4 ступени достижимы."
},
"games": {
"dots": {
"name_ru": "Одна линия",
"kind": "send",
"tiers": [
{ "grid": [3, 3], "obstacles": 0, "undo_penalty": 0.05 },
{ "grid": [4, 3], "obstacles": 2, "undo_penalty": 0.04 },
{ "grid": [4, 4], "obstacles": 4, "undo_penalty": 0.04 },
{ "grid": [5, 4], "obstacles": 6, "undo_penalty": 0.03 }
]
},
"sound": {
"name_ru": "Уровни",
"kind": "send",
"tiers": [
{ "channels": 3, "tolerance": 0.16, "drift": 0.0, "drift_hz": 0.0 },
{ "channels": 4, "tolerance": 0.11, "drift": 0.0, "drift_hz": 0.0 },
{ "channels": 5, "tolerance": 0.08, "drift": 0.02, "drift_hz": 0.15 },
{ "channels": 6, "tolerance": 0.06, "drift": 0.03, "drift_hz": 0.25 }
]
},
"wires": {
"name_ru": "Провода",
"kind": "send",
"tiers": [
{ "wires": 3, "sockets": 3, "box": 36, "pitch": 42, "empty_sockets": 0, "lookalike_pairs": 0 },
{ "wires": 4, "sockets": 4, "box": 36, "pitch": 42, "empty_sockets": 0, "lookalike_pairs": 2 },
{ "wires": 5, "sockets": 6, "box": 36, "pitch": 42, "empty_sockets": 1, "lookalike_pairs": 1 },
{ "wires": 6, "sockets": 6, "box": 34, "pitch": 40, "empty_sockets": 0, "lookalike_pairs": 2 }
]
},
"text": {
"name_ru": "Чужое слово",
"kind": "send",
"tiers": [
{ "words": 11, "aliens": 1, "lines": 2, "alien_kind": "foreign_language" },
{ "words": 15, "aliens": 2, "lines": 2, "alien_kind": "foreign_language+typos" },
{ "words": 19, "aliens": 3, "lines": 3, "alien_kind": "foreign_language+typos+semantic" },
{ "words": 24, "aliens": 4, "lines": 3, "alien_kind": "foreign_language+typos+semantic+archaic" }
]
},
"video": {
"name_ru": "Раскадровка",
"kind": "send",
"tiers": [
{ "frames": 3, "slots": 3, "decoys": 0, "mirror": false },
{ "frames": 4, "slots": 4, "decoys": 0, "mirror": false },
{ "frames": 5, "slots": 4, "decoys": 1, "mirror": false },
{ "frames": 6, "slots": 5, "decoys": 2, "mirror": true }
]
},
"photo": {
"name_ru": "Кадр",
"kind": "send",
"tiers": [
{ "speed": 90, "frame_w": 220, "frame_h": 150, "shots": 3, "retarget_min": 0.8, "retarget_max": 1.2 },
{ "speed": 140, "frame_w": 180, "frame_h": 120, "shots": 4, "retarget_min": 0.6, "retarget_max": 1.0 },
{ "speed": 200, "frame_w": 150, "frame_h": 100, "shots": 5, "retarget_min": 0.5, "retarget_max": 0.8 },
{ "speed": 260, "frame_w": 120, "frame_h": 80, "shots": 6, "retarget_min": 0.35, "retarget_max": 0.65 }
]
},
"maze": {
"name_ru": "Лабиринт",
"kind": "send",
"tiers": [
{ "cols": 9, "rows": 4, "width": 360, "height": 160, "speed": 420, "collision_penalty": 0.05, "dead_end_penalty": 0.02 },
{ "cols": 11, "rows": 4, "width": 440, "height": 160, "speed": 470, "collision_penalty": 0.05, "dead_end_penalty": 0.02 },
{ "cols": 13, "rows": 4, "width": 494, "height": 152, "speed": 520, "collision_penalty": 0.04, "dead_end_penalty": 0.02 },
{ "cols": 15, "rows": 5, "width": 520, "height": 180, "speed": 560, "collision_penalty": 0.04, "dead_end_penalty": 0.02 }
]
},
"junk": {
"name_ru": "Фильтр",
"kind": "attack",
"tiers": [
{ "cards": 9, "junk": 2, "hints": 1, "threshold": 0.6 },
{ "cards": 10, "junk": 3, "hints": 2, "threshold": 0.65 },
{ "cards": 11, "junk": 4, "hints": 2, "threshold": 0.7 },
{ "cards": 12, "junk": 5, "hints": 3, "threshold": 0.75 }
]
},
"ddos": {
"name_ru": "Щит",
"kind": "attack",
"tiers": [
{ "packets": 8, "speed": 110, "shield_width": 260, "interval": 0.55, "threshold": 0.6 },
{ "packets": 10, "speed": 140, "shield_width": 220, "interval": 0.45, "threshold": 0.65 },
{ "packets": 12, "speed": 170, "shield_width": 180, "interval": 0.35, "threshold": 0.7 },
{ "packets": 14, "speed": 200, "shield_width": 150, "interval": 0.28, "threshold": 0.75 }
]
},
"phishing": {
"name_ru": "Паспорт",
"kind": "attack",
"tiers": [
{ "clients": 3, "checks": ["name"], "threshold": 0.75 },
{ "clients": 4, "checks": ["name", "department"], "threshold": 0.75 },
{ "clients": 5, "checks": ["name", "department", "id"], "threshold": 0.8 },
{ "clients": 6, "checks": ["name", "department", "id", "photo", "expiry"], "threshold": 0.8 }
]
},
"ransomware": {
"name_ru": "Карантин",
"kind": "attack",
"tiers": [
{ "cards": 12, "exe": 1, "threshold": 0.5, "masking": "extension_visible" },
{ "cards": 16, "exe": 2, "threshold": 0.55, "masking": "extension_small" },
{ "cards": 20, "exe": 2, "threshold": 0.6, "masking": "document_icon" },
{ "cards": 24, "exe": 3, "threshold": 0.65, "masking": "similar_names" }
]
}
}
},
"attacks": {
"schedule": [0, 1, 1, 2, 2, 3, 3, 4, 4],
"tier_formula": "clamp(1 + floor((hour - 2) / 2.0), 1, 4)",
"spawn_window": { "start_sec": 60, "end_sec": 240 },
"shield_reward_scale": 0.5,
"money_scale": 1.0,
"kinds": {
"junk": {
"name_ru": "Мусорный пакет",
"minigame": "junk",
"weight_by_hour": [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
"tiers": [
{ "reward_money": 5, "reward_sla": 1, "fail_money": -10, "fail_sla": -5, "threshold": 0.6 },
{ "reward_money": 10, "reward_sla": 2, "fail_money": -15, "fail_sla": -7, "threshold": 0.65 },
{ "reward_money": 15, "reward_sla": 3, "fail_money": -20, "fail_sla": -9, "threshold": 0.7 },
{ "reward_money": 20, "reward_sla": 4, "fail_money": -25, "fail_sla": -12, "threshold": 0.75 }
]
},
"ddos": {
"name_ru": "DDoS",
"minigame": "ddos",
"weight_by_hour": [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
"tiers": [
{ "reward_money": 30, "reward_sla": 2, "fail_money": -10, "fail_sla": -15, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.6 },
{ "reward_money": 40, "reward_sla": 3, "fail_money": -15, "fail_sla": -20, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.65 },
{ "reward_money": 50, "reward_sla": 4, "fail_money": -20, "fail_sla": -25, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.7 },
{ "reward_money": 60, "reward_sla": 5, "fail_money": -25, "fail_sla": -30, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.75 }
]
},
"phishing": {
"name_ru": "Фишинг",
"minigame": "phishing",
"weight_by_hour": [0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
"tiers": [
{ "reward_money": 20, "reward_sla": 2, "fail_money": -40, "fail_sla": -10, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.75 },
{ "reward_money": 30, "reward_sla": 3, "fail_money": -50, "fail_sla": -12, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.75 },
{ "reward_money": 40, "reward_sla": 4, "fail_money": -60, "fail_sla": -15, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.8 },
{ "reward_money": 50, "reward_sla": 5, "fail_money": -70, "fail_sla": -18, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.8 }
]
},
"ransomware": {
"name_ru": "Шифровальщик",
"minigame": "ransomware",
"weight_by_hour": [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0],
"tiers": [
{ "reward_money": 25, "reward_sla": 2, "fail_money": -30, "fail_sla": -10, "folder_block_sec": 15, "threshold": 0.5 },
{ "reward_money": 35, "reward_sla": 3, "fail_money": -40, "fail_sla": -12, "folder_block_sec": 15, "threshold": 0.55 },
{ "reward_money": 45, "reward_sla": 4, "fail_money": -50, "fail_sla": -15, "folder_block_sec": 15, "threshold": 0.6 },
{ "reward_money": 55, "reward_sla": 5, "fail_money": -60, "fail_sla": -18, "folder_block_sec": 15, "threshold": 0.65 }
]
}
"ext": ".mp4",
"icon": "▶",
"recv_time": 3.0,
"send_time": 5.0,
"recv_fee": 8,
"base_pay": 90,
"weight": 22,
"minigame_coeff": 4.0,
"sla_loss": 16
}
},
"difficulty": [
{ "hour": 1, "recv_interval": 12, "send_interval": 22, "max_requests": 2, "buffer": 4, "packet_timer": 20, "request_timer": 30, "attacks": 0 },
{ "hour": 2, "recv_interval": 11, "send_interval": 20, "max_requests": 5, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 1 },
{ "hour": 3, "recv_interval": 10, "send_interval": 18, "max_requests": 7, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 1 },
{ "hour": 4, "recv_interval": 9, "send_interval": 17, "max_requests": 9, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 2 },
{ "hour": 5, "recv_interval": 8, "send_interval": 15, "max_requests": 10, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 2 },
{ "hour": 6, "recv_interval": 7.5, "send_interval": 14, "max_requests": 11, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 3 },
{ "hour": 7, "recv_interval": 7, "send_interval": 13, "max_requests": 11, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 3 },
{ "hour": 8, "recv_interval": 6.5, "send_interval": 12, "max_requests": 12, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 4 },
{ "hour": 9, "recv_interval": 6, "send_interval": 12, "max_requests": 12, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 4 }
{ "hour": 1, "recv_interval": 12, "send_interval": 22, "max_requests": 2, "buffer": 4, "packet_timer": 30, "request_timer": 60, "attacks": 0 },
{ "hour": 2, "recv_interval": 11, "send_interval": 20, "max_requests": 2, "buffer": 4, "packet_timer": 26.325, "request_timer": 50.15, "attacks": 1 },
{ "hour": 3, "recv_interval": 10, "send_interval": 18, "max_requests": 3, "buffer": 5, "packet_timer": 22.8, "request_timer": 40.6, "attacks": 1 },
{ "hour": 4, "recv_interval": 9, "send_interval": 17, "max_requests": 3, "buffer": 5, "packet_timer": 18.5, "request_timer": 28.5, "attacks": 2 },
{ "hour": 5, "recv_interval": 8, "send_interval": 15, "max_requests": 4, "buffer": 6, "packet_timer": 18, "request_timer": 28, "attacks": 2 },
{ "hour": 6, "recv_interval": 7.5, "send_interval": 14, "max_requests": 4, "buffer": 6, "packet_timer": 17.5, "request_timer": 27.5, "attacks": 3 },
{ "hour": 7, "recv_interval": 7, "send_interval": 13, "max_requests": 4, "buffer": 6, "packet_timer": 17, "request_timer": 27, "attacks": 3 },
{ "hour": 8, "recv_interval": 6.5, "send_interval": 12, "max_requests": 4, "buffer": 7, "packet_timer": 16.5, "request_timer": 26.5, "attacks": 4 },
{ "hour": 9, "recv_interval": 6, "send_interval": 12, "max_requests": 4, "buffer": 7, "packet_timer": 16, "request_timer": 26, "attacks": 4 }
],
"difficulty_rules": {
"table_end_hour": 9,
"after_end_hour": "значения часа 9",
"spawn_speedup_after_end": 0.0,
"table_end_hour": 8,
"after_end_hour": "значения часа 8; +5% к скорости спавна каждые 2 часа",
"spawn_speedup_after_end": 0.05,
"spawn_speedup_period_hours": 2,
"type_availability": "веса типов — hours[].type_weights (единственный источник правды); H1 только text/sound, photo с H2, video с H3",
"timer_grace": "v2: таймеры не масштабируются по часу (packet 20, request 30+5*level)"
"type_availability": "час 1 — text; час 2 — text и sound; час 3 — +photo; час 4+ — все 4 (D23); атаки — с часа 2 (attack_junk)",
"timer_grace": "packet_timer: grace h1 1.5, h2 1.35, h3 1.2, h4+ 1.0 (Economy.TIMER_GRACE). request_timer: отдельный grace h1 2.0, h2 1.7, h3 1.4, h4+ 1.0 (Economy.REQUEST_TIMER_GRACE). Формула: (base − 0.5*(h−1))*grace + 4*level. h2: packet 19.5*1.35=26.325, request 29.5*1.7=50.15."
},
"progression": {
"unlocks": {
"shop": { "run_hour": 2, "label": "Магазин" },
"search": { "meta": "survive_hour_3", "label": "Поиск" },
"shield": { "meta": "survive_hour_5", "label": "Щит" },
"minigame_text": { "run_hour": 1 },
"minigame_sound": { "run_hour": 2 },
"minigame_photo": { "run_hour": 3 },
"minigame_video": { "run_hour": 4 },
"attack_junk": { "run_hour": 2 },
"attack_ddos": { "run_hour": 2 },
"attack_phishing": { "run_hour": 3 },
"attack_ransomware": { "run_hour": 4 }
"shop": {"run_hour": 2, "label": "Магазин"},
"search": {"meta": "survive_hour_3", "label": "Поиск"},
"shield": {"meta": "survive_hour_5", "label": "Щит"},
"minigame_text": {"run_hour": 1},
"minigame_sound": {"run_hour": 2},
"minigame_photo": {"run_hour": 3},
"minigame_video": {"run_hour": 4},
"minigame_advanced": {"meta": "complete_run", "label": "Продвинутые мини-игры"},
"attack_junk": {"run_hour": 2},
"attack_ddos": {"run_hour": 2},
"attack_phishing": {"run_hour": 3},
"attack_ransomware": {"run_hour": 4}
},
"meta_rewards": {
"survive_hour_3": { "min_hours": 3, "unlocks": ["search"] },
"survive_hour_5": { "min_hours": 5, "unlocks": ["shield"] },
"complete_run": { "min_hours": 9, "unlocks": ["minigame_advanced"] }
"survive_hour_3": {"min_hours": 3, "unlocks": ["search"]},
"survive_hour_5": {"min_hours": 5, "unlocks": ["shield"]},
"complete_run": {"min_hours": 9, "unlocks": ["minigame_advanced"]}
}
},
"timers": {
@@ -644,20 +127,34 @@
"minigame_timeout": null,
"delete_long_press_sec": 0.5
},
"spawn": {
"type_pool_by_hour": { "1": ["text", "sound"], "2": ["text", "sound", "photo"], "3+": ["text", "sound", "photo", "video"] },
"malicious_ratio": 0.0,
"required_count_by_hour": {
"1": { "1": 0.7, "2": 0.3, "3": 0.0 },
"2": { "1": 0.6, "2": 0.35, "3": 0.05 },
"3+": { "1": 0.5, "2": 0.4, "3": 0.1 }
},
"required_size_distribution": { "1": 0.5, "2": 0.4, "3": 0.1 },
"required_count_note": "legacy; v2 читает hours[].request_mix",
"attack_schedule": "attacks.schedule (0/1/1/2/2/3/3/4/4), окно 60–240 с часа"
"sla": {
"recv_bonus": 0.5,
"send_bonus": 1.0,
"reflect_bonus": 1.0,
"timeout_fine_base": 10,
"timeout_fine_per_hour": 2,
"timeout_fine_formula": "-(10 + 2*hour) — таймаут и подобные штрафы (D12)",
"wrong_attach_fine": -2,
"lost_file_fine": -5,
"buffer_overflow_fine": -5,
"sla_lose": "SLA <= 0"
},
"payout": {
"base_pay": "см. types.*.base_pay (значения в types)",
"recv_fee": "см. types.*.recv_fee (значения в types)",
"speed_formula": "speed = 0.6 + 0.4*(time_left / timer_total)",
"quality_formula": "quality = 0.5 + 0.5*mini_quality",
"multi_formula": "multi = 1 + 0.1*(n - 1)",
"protocol_mult": { "TCP": 1.15, "UDP": 1.0 },
"streak_formula": "streak = 1 + 0.02*min(10, streak)",
"income_target_by_hour": {
"hours": [1, 2, 3, 5, 9],
"approx": [500, 800, 1200, 1800, 2200],
"note": "ожидаемо ±10%; часы 4,6,7,8 — интерполяция, уточнить"
}
},
"connections": {
"W_formula": "legacy (полоска соединения удалена в v2)",
"W_formula": "W = clamp(0.30 - 0.005*(hour-1) - 0.005*successes + 0.05*zone_upgrade_level, 0.18, 0.36)",
"W_base": 0.3,
"W_hour_coef": -0.005,
"W_success_coef": -0.005,
@@ -672,14 +169,103 @@
"CHANNEL_BG_PER_HOUR": 0,
"SLOT_LOAD": 0,
"BG_note": "фоновый шум и занятые слоты НЕ грузят канал; load = только активные передачи (loading/sending)",
"CHANNEL_CAPACITY": 10,
"capacity_per_upgrade": 6,
"capacity_upgrade_max_level": 6,
"formula": "load = sum(weight(active_transfers)); factor = max(1.0, load / capacity); actual_time = base_time_sec * pow(0.92, speed_level) * factor"
"CHANNEL_CAPACITY": 80,
"capacity_per_upgrade": 25,
"capacity_upgrade_max_level": 4,
"formula": "load = sum(weight(active_transfers)); factor = max(1.0, load / CHANNEL_CAPACITY); actual_time = base_time * factor. idle load = 0"
},
"attacks": {
"junk": {
"name_ru": "Мусорный пакет",
"spawn_probability": null,
"duration_sec": 15,
"tell": "серая иконка ?, размер 0 Б, нет таймера",
"effect": "авто-занос через duration_sec, занимает место в приёме; уничтожить можно как malicious в любой фазе (D02)",
"reward_money": 5,
"reward_sla": 1,
"fail_money": -10,
"fail_sla": -5,
"filter_disabled_sec": 10
},
"ddos": {
"name_ru": "DDoS",
"cards_min": 8,
"cards_max": 12,
"spawn_window_sec": 2.0,
"shield_hold_sec": 1.5,
"tell": "8–12 идентичных карточек без типа и таймера",
"effect": "волна карточек; отбивается удержанием щита",
"reward_money": 30,
"reward_sla": 2,
"fail_sla": -15,
"channel_debuff": 0.4,
"channel_debuff_sec": 15,
"packets_lost": 2,
"recv_disabled_sec": 5
},
"phishing": {
"name_ru": "Фишинг",
"reward_money": 20,
"reward_sla": 2,
"fail_money": -40,
"fail_sla": -10,
"file_lock_sec": 60,
"send_disabled_sec": 5,
"timeout_money": -20,
"timeout_sla": -5,
"timeout_lock_sec": 30,
"tell": "client_id формата id_XXXX с неверным символом (например id_7F3К — кириллическая К), серый аватар",
"effect": "фальшивый запрос/пакет"
},
"ransomware": {
"name_ru": "Шифровальщик",
"reward_money": 25,
"reward_sla": 2,
"folder_block_delay_sec": 3,
"auto_unlock_sec": 30,
"auto_unlock_money": -30,
"auto_unlock_sla": -10,
"tell": "расширение .exe, красный квадрат 4×4",
"effect": "файл становится locked (lock_until), кликабелен для уничтожения (D08)"
}
},
"shop": [
{ "id": "shop_slot3", "name_ru": "Третий слот", "effect": "connection_slots", "value": 1, "price": 250, "max_level": 1 },
{ "id": "shop_slot4", "name_ru": "Четвёртый слот", "effect": "connection_slots", "value": 1, "price": 450, "max_level": 1 },
{ "id": "shop_buffer", "name_ru": "Буфер +2", "effect": "buffer_slots", "value": 2, "price": 200, "max_level": 2 },
{ "id": "shop_timer", "name_ru": "Таймер пакетов +4 с", "effect": "packet_timer", "value": 4, "unit": "сек (аддитивно)", "price": 350, "max_level": 3 },
{ "id": "shop_zone", "name_ru": "Зона +5%", "effect": "zone_width", "value": 0.05, "unit": "доля W (аддитивно в формуле D04)", "price": 300, "max_level": 3 },
{ "id": "shop_channel", "name_ru": "Канал +25", "effect": "channel_capacity", "value": 25, "price": 250, "max_level": 4 },
{ "id": "shop_section", "name_ru": "Новая секция", "effect": "sections", "value": 1, "price": 300, "max_level": 5 },
{ "id": "shop_search", "name_ru": "Поиск", "effect": "search_highlight", "value": 1, "price": 250, "max_level": 1 },
{ "id": "shop_shield", "name_ru": "Щит", "effect": "shield", "value": 1, "price": 400, "max_level": 1 },
{ "id": "shop_sla", "name_ru": "SLA +24", "effect": "sla_add", "value": 24, "price": 300, "max_level": 3 }
],
"shop_reserve": {
"items": ["scanner", "autosort"],
"note": "резерв — в прототип НЕ входят (D13); позиций в shop НЕТ"
},
"spawn": {
"type_pool_by_hour": {
"1": ["text"],
"2": ["text", "sound"],
"3+": ["text", "sound", "photo", "video"]
},
"malicious_ratio": 0.0,
"required_count_by_hour": {
"1": { "1": 1.0 },
"2": { "1": 0.75, "2": 0.25 },
"3+": { "1": 0.6, "2": 0.3, "3": 0.1 }
},
"required_size_distribution": { "1": 0.6, "2": 0.3, "3": 0.1 },
"required_count_note": "required_count_by_hour — распределение по часу; required_size_distribution — fallback/час 3+",
"attack_schedule": "с часа 2, далее по difficulty.attacks, окно 60–240 с часа, не во время мини-игры"
},
"minigame_selection": {
"coeff": { "text": 1, "sound": 2, "photo": 4, "video": 8 },
"formula": "см. minigames.selection.formula (плотный список достижимых сумм, B4)",
"coeff": { "text": 1.0, "sound": 2.0, "photo": 3.0, "video": 4.0 },
"difficulty_formula": "sum(coeff(required)) / n",
"thresholds": { "text": "< 1.5", "sound": "< 2.5", "photo": "< 3.5", "else": "video" },
"call": "MiniGame.start(type, difficulty)",
"quality_stub": 1.0
}
}
+1 -74
View File
@@ -72,10 +72,6 @@
"retry_no_effect": "Качество прежнее — улучшить нельзя",
"delete_prep_only": "Обычные файлы удаляются только в подготовке",
"money_short": "Не хватает средств",
"folder_need_money": "НУЖНО {cost} $",
"folder_limit": "ДОСТИГНУТ ЛИМИТ ПАПОК",
"delete_file_confirm": "Удалить файл?",
"delete_file_detail": "Файл «{name}» будет уничтожен без штрафа SLA.",
"save_restored": "Прогресс смена восстановлен"
},
"shop": {
@@ -91,76 +87,7 @@
"shop_channel": "Ёмкость канала +25",
"shop_section": "Новая секция хранения",
"shop_search": "Поиск нужных файлов",
"shop_shield": "Щит (отражения атак)",
"title_offers": "МАГАЗИН · 3 ПРЕДЛОЖЕНИЯ",
"subtitle": "Новые предложения после каждого часа",
"reroll": "ОБНОВИТЬ АССОРТИМЕНТ · %d $",
"reroll_limit": "ЛИМИТ ОБНОВЛЕНИЙ",
"level": "УРОВЕНЬ %d/%d",
"now": "СЕЙЧАС",
"after": "ПОСЛЕ",
"not_enough": "НЕ ХВАТАЕТ %d $",
"maxed": "МАКСИМУМ",
"all_bought": "ВСЁ КУПЛЕНО",
"lock": "ЗАКРЕПИТЬ",
"unlock": "ОТКРЕПИТЬ",
"lot_slots": "Порты коммутатора",
"lot_speed": "Оптоволокно",
"lot_sla": "Резервный ИБП",
"lot_shield": "Заземление",
"lot_money": "Корпоративный контракт",
"lot_folder": "Складской стеллаж",
"lot_channel": "Пропускная способность",
"lot_timer": "Регламент обработки"
},
"sort": {
"title": "СТОЛ СОРТИРОВКИ",
"hint": "Перетащите файлы, чтобы разложить по папкам",
"full": "СТОЛ ЗАПОЛНЕН",
"return_blocked": "На столе остались файлы, места нет",
"empty": "Здесь можно разложить файлы после часа пика"
},
"grid": {
"cells": "%d / %d клеток",
"free": "свободно %d клеток",
"busy": "занято %d из %d · свободно %d",
"empty_folder": "ПУСТО · перетащите файл или пакет",
"pack": "УПАКОВАТЬ"
},
"packet": {
"shape": "ЗАЙМЁТ %d КЛЕТОК",
"no_space": "НЕТ МЕСТА В ПАПКЕ"
},
"request": {
"minigame": "МИНИ-ИГРА: %s · T%d",
"difficulty": "СЛОЖНОСТЬ: %d",
"waiting_slot": "ОЖИДАНИЕ СЛОТА"
},
"minigame": {
"quality": "КАЧЕСТВО %d%%",
"surrender": "СДАТЬСЯ",
"skip": "ПРОПУСТИТЬ",
"no_timer": "БЕЗ ТАЙМЕРА",
"continue": "ПРОДОЛЖИТЬ",
"clean": "ЧИСТО",
"normal": "НОРМАЛЬНО",
"weak": "СЛАБО",
"dots": "Одна линия",
"sound": "Уровни",
"wires": "Провода",
"text": "Чужое слово",
"video": "Раскадровка",
"photo": "Кадр",
"maze": "Лабиринт",
"junk": "Фильтр",
"ddos": "Щит",
"phishing": "Паспорт",
"ransomware": "Карантин"
},
"hour": {
"next_offers": "3 НОВЫХ ЛОТА В МАГАЗИНЕ",
"sla_regen": "SLA восстановлен: +%d",
"all_slots_busy": "ВСЕ КАНАЛЫ ЗАНЯТЫ"
"shop_shield": "Щит (отражения атак)"
},
"attack": {
"junk": {
+11 -286
View File
@@ -1,30 +1,15 @@
# DECISIONS — решения v2.0
# DECISIONS — решения v1.2
> Каждое решение: ID, что решили, что меняется в GDD. Дедлайн
> 26.10.2026 23:59 (Minsk). Источник правил — `docs/GDD.md` (v2.0).
>
> **v2.0 (25.10.2026):** актуальны решения **D29–D44**. Решения D01–D28
> сохранены как история; конфликтующие с v2.0 помечены пометкой
> «**Статус v2.0: устарело**» с указанием замены. Ничего не удалено.
>
> **Сдвиг ID:** диапазон D26–D28 уже занят решениями v1.2 (аудит, VHS,
> ambient), поэтому 16 новых решений v2.0 идут **D29–D44** — это эквивалент
> запрошенного диапазона D26–D41 со сдвигом +3.
>
> **Статус решений v1.x в v2.0 (кратко):** D01→D32, D02→D33/D44,
> D03→D34/D38, D04→D33, D05→D29, D06→D32, D07→D38/D39, D08→D38,
> D09→D32, D10→D34, D12→D29/D40/D42, D13→D36/D37, D16→D43,
> D17→D34/D35, D19→D29/D36, D20→D29, D21→D29, D22→D33, D23→D29/D38,
> D25→D43. D11, D14, D15, D18, D24 (кроме магазина), D27, D28 остаются
> в силе.
> 26.10.2026 23:59 (Minsk). Источник правил — `docs/GDD.md`.
> **v1.2 (25.10.2026):** D01–D17 сохранены; добавлены D18–D25. Если D19–D24
> меняют числа старых решений, это указано в примечании к D12/D13/D16.
## D01 — карточка запроса
**Решение:** в карточке запроса показывать имя требуемого файла
(до 12 символов) и его папку, а не только иконку типа. `required` —
конкретные id файлов, не фильтры по типу.
**GDD:** §7 (нужные файлы — по имени+папке, привязка по id).
**Статус v2.0: устарело.** В v2 `required` — это 1–3 слота контейнера, файлы
выбирает игрок; конкретные id не зашиты (D32, GDD §7).
## D02 — расход/удаление файлов и склад
**Решение:** файлы **не расходуются** при отправке, но после успешной
@@ -34,10 +19,6 @@
поражением, но пройденные 30+ часов засчитываются; в итоговом
отчёте это указывается явно.
**GDD:** §3, §5, §16.
**Статус v2.0: устарело.** Файлы **расходуются** при успешной отправке
(cooldown 0); при отмене/таймауте сохраняются (D33). «Конец забега по
складу» не используется: поражение только по SLA (D40, GDD §16). Удаление
неприкреплённого файла в подготовке — без штрафа (D44).
## D03 — заморозка таймеров модалками
**Решение:** пока открыта модалка мини-игры или панель соединения —
@@ -45,9 +26,6 @@
активного запроса продолжает идти; при полном буфере новые пакеты
не спавнятся.
**GDD:** §6, §8, §8.4.
**Статус v2.0: частично устарело.** Мини-игра отправки не ставит час на
паузу: она морозит спавн/загрузку, спавн запросов и таймер своего запроса;
другие таймеры идут. Мини-игра атаки ставит на паузу весь час (D34/D38).
## D04 — полоска соединения
**Решение:** `W = clamp(0.30 − 0.005×(hour−1) − 0.005×successes
@@ -55,8 +33,6 @@
440/340/280 px/с; окна попадания ~130–200 мс; апгрейд зоны применяется
внутри формулы, cap 0.36.
**GDD:** §11.
**Статус v2.0: устарело.** Полоска соединения удалена. TCP/UDP — переключатель
оплаты/скорости: TCP ×1.15 к выплате, UDP ×0.8 ко времени (D33, GDD §11).
## D05 — один источник истины
**Решение:** буфер = `4 + 2×buffer_upgrades` (max 8); зона — только
@@ -64,30 +40,18 @@
(`balance.json → difficulty`); в окне «Отправка» максимум 4 видимых
карточки; при 5-м запросе — скролл и высота карточек 56 px.
**GDD:** §6, §7, §11.
**Статус v2.0: устарело в числах.** Буфер = 6 + уровень `lot_channel`, max 12;
`request_cap` берётся из `hours[]` (2/5/5/6/6/7/7/7/8), а не из legacy
`difficulty`. Правило «4 карточки, дальше скролл, высота 56 px» остаётся
(D29, GDD §6–7).
## D06 — файл в нескольких запросах
**Решение:** файл может входить в `required` нескольких запросов, но
`attached` — только в одном; второй запрос показывает файл серым.
При `failed` файла все запросы, где он `required`, отменяются со штрафом.
**GDD:** §7.
**Статус v2.0: частично устарело.** Файл по-прежнему прикрепляется только к
одному запросу; но `required` больше не содержит конкретных id, поэтому
правило «все запросы, где он `required`» не применяется. При `locked`/
`failed`/`malicious` файл просто открепляется (D32).
## D07 — атаки вне буфера и единая таблица штрафов
**Решение:** атаки не занимают буфер и не вытесняются ими; отдельный
слой карточек атак. Единая таблица урона: мусор −5 SLA, DDoS −15,
фишинг −10, шифровальщик −10 (вместо глобального −15).
**GDD:** §12.
**Статус v2.0: устарело.** Карточек/оверлеев атак больше нет: каждая атака —
мини-игра, которая ставит час на паузу. Урон/награда — по
`attacks.kinds.*.tiers`, плюс эффекты DDoS/фишинга/шифровальщика
(D38/D39, GDD §12.1).
## D08 — видимые теллы
**Решение:** у пакета показывать расширение файла; у шифровальщика —
@@ -95,8 +59,6 @@
символом (например `id_7F3К`, кириллическая К) и серый аватар.
`locked`/malicious файл остаётся кликабельным для уничтожения.
**GDD:** §20.
**Статус v2.0: устарело.** Зловредных пакетов/фишинговых запросов и их
теллов нет; атаки вынесены в отдельные мини-игры (D38, GDD §12.1, §20).
## D09 — состояния-флаги
**Решение:** состояния — флаги, а не один enum:
@@ -106,18 +68,12 @@
Таблица переходов при истечении таймера (загрузка/соединение/мини-игра)
— в `docs/IMPLEMENTATION.md`.
**GDD:** §17.
**Статус v2.0: обновлено.** Добавлены `table` и `sending`; `connected` —
legacy и всегда `true` (полоски нет). Резервации приёма живут в папке, а не
в запросе (D31/D32, GDD §17).
## D10 — поведение Esc
**Решение:** в час — пауза; в мини-игре — no-op; в панели соединения —
отмена (слот освобождается, запрос остаётся); закрытие модалок — только
кнопкой или таймаутом.
**GDD:** §18.
**Статус v2.0: частично устарело.** Панели соединения нет. В мини-игре
отправки `Esc` во время игры — no-op; в мини-игре атаки — двухшаговое
подтверждение `СДАТЬСЯ`; на экране результата — `ПРОДОЛЖИТЬ` (D34, GDD §18).
## D11 — устойчивость к лагам
**Решение:** `delta = min(delta, 0.1)`; авто-пауза при
@@ -133,10 +89,6 @@ legacy и всегда `true` (полоски нет). Резервации пр
значения, но +5 % к скорости спавна каждые 2 часа.
**GDD:** §12, §13, §23.
**v1.2:** старт SLA — 128/200; штраф за потерю пакета — по типу (D19).
**Статус v2.0: устарело.** Доход — цели 65/220/380/560/760/980/1200/1450/
1700; старт SLA 60/60; пассивных +0.5/+1 нет, есть +15 в начале часа и
награды атак; «полный выкуп к H8–H9» не подтверждён (44,1 % дерева).
Штраф `−(10+2×hour)` сохранён (D29/D40/D42, GDD §12–13).
## D13 — магазин
**Решение:** 9 позиций: 3-й слот 250, 4-й слот 450, буфер +2 — 200×2,
@@ -145,11 +97,9 @@ legacy и всегда `true` (полоски нет). Резервации пр
резерв (вырезаны из прототипа).
**GDD:** §14.
**v1.2:** добавлена 10-я позиция «SLA +24» (300, max 3); открытия — D23.
**Статус v2.0: устарело.** 8 лотов, 3 оффера, рероллы и замки; зоны/секции/
поиска в магазине нет. Полное дерево — 13 100 $ (D36/D37, GDD §14).
## D14 — фон
**Решение:** шейдер фона — статичный градиент + слабый шум 320×180,
**Решение:** фон — статичный градиент + слабый шум 320×180,
без полноэкранного fbm; CRT-эффект убрать.
**GDD:** §21.
**v1.2:** фон — тёплый бумажный дизеринг/панели, см. D18.
@@ -170,9 +120,6 @@ Press Start 2P остаётся только для крупных заголо
**Решение:** порядок P0: данные/реестр → слоты → приём/отправка → UI.
**GDD:** §23.
**v1.2:** календарь открытий — D23; в часе 1 атак нет.
**Статус v2.0: исторический.** Актуальная структура — `main.gd` +
`game_controller.gd` + `core/` + `minigames/`; порядок работ и проверки —
D43, GDD §23.
## D17 — quality/retry
**Решение:** заглушка качества возвращает 1.0; кнопка «Переделать»
@@ -183,9 +130,6 @@ D43, GDD §23.
`difficulty = sum(coeff)/n`, пороги `<1.5 text, <2.5 sound,
<3.5 photo, иначе video`; `MiniGame.start(type, difficulty)`.
**GDD:** §4, §8.4, §10.
**Статус v2.0: устарело.** Заглушки качества и кнопки «Переделать» нет:
`q` даёт мини-игра. Выбор игры — по плотному списку достижимых сумм и
коэффициентам 1/2/4/8 (D34/D35, GDD §8.4, §10).
## D18 — UI v2 «Kaleidos»
@@ -205,9 +149,6 @@ D43, GDD §23.
10-я позиция **«SLA +24»** — 300, max 3 уровня. Остальные 9 позиций D13
сохраняются; итого 10.
**GDD:** §4, §12, §14.
**Статус v2.0: устарело.** Старт SLA 60/60; лота «SLA +24» нет, кап растёт
на +35 за уровень `lot_sla` до 200. Потеря пакета по типу сохранена
(D29/D40, GDD §12).
## D20 — таймеры и grace
@@ -218,8 +159,6 @@ h2 26.325/39.825, h3 22.8/34.8, h4 18.5/28.5, h5 18/28, далее
плавно до h9 16/26. Апгрейд «Таймер пакетов +4 с» даёт чистые +4 с
поверх grace.
**GDD:** §8.
**Статус v2.0: устарело.** Grace и per-hour scaling отменены: пакет 20 с,
запрос 30 + 5×уровень (max 55) (D29, GDD §8).
## D21 — канал v1.2
@@ -228,8 +167,6 @@ h2 26.325/39.825, h3 22.8/34.8, h4 18.5/28.5, h5 18/28, далее
слоты не грузят; idle = 0 %. `capacity = 80 + 25×channel_level`,
`factor = max(1.0, load / capacity)`, `actual_time = base_time × factor`.
**GDD:** §9.
**Статус v2.0: устарело в числах.** `capacity = 10 + 6×level` (10…46);
формула нагрузки и idle=0 сохранены (D29, GDD §9).
## D22 — TCP-соединение
@@ -238,8 +175,6 @@ h2 26.325/39.825, h3 22.8/34.8, h4 18.5/28.5, h5 18/28, далее
после успешного прохождения; при промахе слот освобождается, запрос
остаётся. `Esc` — отмена с тем же результатом.
**GDD:** §11, §17.
**Статус v2.0: устарело.** Полоски/панели соединения нет; `connected` всегда
`true`. TCP/UDP — переключатель выплаты/времени (D33, GDD §11).
## D23 — открытия и мета
@@ -253,11 +188,6 @@ h2 26.325/39.825, h3 22.8/34.8, h4 18.5/28.5, h5 18/28, далее
(`difficulty.attacks = 0,1,1,2,2,3,3,4,4`). `minigame_advanced` — резерв
под будущие мини-игры, контента пока нет.
**GDD:** §3, §10, §15, §23.
**Статус v2.0: обновлено.** Магазин с H2 и мета 3/5/9 сохранены. Типы H1
text/sound, photo с H2, video с H3. Атаки: junk/ddos с H2, phishing с H4,
ransomware с H5 (по `weight_by_hour`); `minigame_advanced` по-прежнему
резерв (D29/D38, GDD §3, §14). Мета-ключ `search` остаётся в данных, но
UI-поиск в v2 не подключён — как готовая фича не документируется.
## D24 — UX и читаемость
@@ -272,8 +202,6 @@ UI-поиск в v2 не подключён — как готовая фича
Маскот **БАЙТ** ведёт короткий туториал первого часа. Esc на итогах забега
ведёт в главное меню (не soft-lock).
**GDD:** §18, §19, §21.
**Статус v2.0: частично.** Поле поиска не подключено (см. D23), остальное —
имена, уничтожение папки, магазин без крестика, calm-фон — в силе.
## D25 — проверки и MR-ревью
@@ -283,9 +211,6 @@ OpenCode-ревью агентом `oracle` на модели
`opencode-go/deepseek-v4.1-flash`: dry-run 74–90 с, комментарий с
Blockers/High/Medium/Test gaps/Verdict; бот не мержит.
**GDD:** §23, §25.
**Статус v2.0: устарело в числах.** Актуальный прогон — Windows/Linux
раннеры, **25 тестовых наборов + parse-check (26/26 PASS)**; баланс —
`python tests/sim_balance.py` (D43, GDD §23).
## D26 — фиксы аудита v1.2
@@ -302,17 +227,15 @@ Blockers/High/Medium/Test gaps/Verdict; бот не мержит.
на слот запускает соединение, тост покупки с названием, честный статус
сейва, fallback `SLA +24`.
- `minigame_advanced` — зарезервированный unlock, контента пока нет.
**Статус v2.0: частично устарело.** Пункты про TCP-прогресс, drag на слот и
fallback `SLA +24` неактуальны: соединения и лота SLA+24 нет. Остальное
(calm-фон, Esc, уничтожение папки, мета, H1 без атак) сохранено.
## D27 — VHS главного меню
**Решение:** VHS-слой только на главном меню: `shaders/vhs.gdshader` +
`scripts/ui/vhs_overlay.gd`, screen texture 1920, скан-линии 2 физ. px,
слабая хроматика, зерно, виньетка и полоса. Галочка «VHS-эффект» — в
настройках, группа «ЭКРАН»; HEADLESS не рендерит. Карточка/кнопки меню под
фильтром, модалки — нет; это не CRT на весь UI.
**Решение:** VHS-слой только на главном меню: процедурный
`scripts/ui/vhs_overlay.gd` (`extends RefCounted`), рисуется в
`MainMenu._draw` до текста. Скан-линии, слабая хроматика, зерно, виньетка и
полоса; без shader/screen texture, ввод не блокирует, текст резкий.
Галочка «VHS-эффект» — в настройках, группа «ЭКРАН»; HEADLESS не рендерит.
Карточка/кнопки меню под фильтром, модалки — нет; это не CRT на весь UI.
**GDD:** §21, §25.
## D28 — ambient machine room
@@ -322,201 +245,3 @@ fallback `SLA +24` неактуальны: соединения и лота SLA+
`scripts/autoload/ambient.gd`. Стартует после первого жеста (web autoplay),
поддерживает mute, паузу и громкость музыки. `test_ambient` — 74/0.
**GDD:** §22, §25.
---
# Решения v2.0 (D29–D44)
## D29 — стартовые статы и формулы
**Решение:** забег начинается с **50 $** и **SLA 60/60**. Кап SLA —
`60 + 35×sla_level`, жёсткий максимум **200**. Слоты — `1 + slot_level`
(1…5), канал — `10 + 6×channel_level` (10…46, он же даёт буфер
`6 + уровень`, 6…12), таймер запроса — `30 + 5×timer_level` (30…55),
таймер пакета — фиксированные **20 с**. Сетка папки —
`[5×4, 6×4, 6×5, 7×5, 7×6, 8×6]`. На старте 2 папки, максимум 6, новая —
**100 $**. В начале часа SLA +15 и заряды щита восстанавливаются.
**GDD:** §4, §6, §8, §9, §12, §14.
**Заменяет:** числа D05, D12, D19, D20, D21.
## D30 — сетки папок и файлы-полимино
**Решение:** каждая папка — клеточная сетка; файл — полимино: текст 1,
звук 2, фото 3, видео 4 клетки. Формы — `grid.shapes` в `data/balance.json`.
Поворот — `R` или ПКМ во время drag (0–3). Точное размещение проверяет
пересечения и границы; auto-fit идёт first-fit по строкам/столбцам. Подпись
рисуется в **верхней левой занятой** клетке, а не в пустом углу bbox
(S/Z-формы). Перемещать `attached`/`locked`/`failed`/`malicious` нельзя.
**GDD:** §5, §20.
**Заменяет:** плоские «слоты файлов» v1.
## D31 — приём, резервации и стол сортировки
**Решение:** приём резервирует клетки фигуры в папке на время загрузки;
отмена/таймаут освобождает резервацию. Полный буфер замораживает спавн, но
не сжигает бюджет часа. Стол сортировки — отдельная сетка **4×6**, доступен
в подготовке. Файл на столе нельзя прикрепить к запросу. На старте часа
стол автоматически возвращает файлы в папки; если хотя бы один не влез —
час не начинается, игрок освобождает место. Позиции на столе входят в сейв.
**GDD:** §3, §5–7, §15.
**Обновляет:** D03, D05.
## D32 — запросы и прикрепление
**Решение:** запрос требует 1–3 файла (слоты контейнера), конкретные id не
зашиты — игрок сам выбирает файлы. Файл прикреплён только к одному запросу;
со стола не прикрепляется. При `locked`/`failed`/`malicious` файл
открепляется, контейнер нужно собрать снова. Таймер запроса замирает в его
мини-игре, в очереди out-слота и во время передачи; остальные таймеры идут.
`ОТКЛОНИТЬ` — −5 $/−5 SLA.
**GDD:** §7, §8, §17.
**Заменяет:** D01, D06; обновляет D09.
## D33 — отправка, расход файлов и TCP/UDP
**Решение:** отправка — только по явной кнопке `ОТПРАВИТЬ`; прикрепление и
закрытие мини-игры автостарт не запускают. TCP/UDP переключаются до старта:
TCP — выплата ×1.15, время ×1.0; UDP — время ×0.8, выплата ×1.0. После
мини-игры запрос занимает out-слот или встаёт в очередь «ОЖИДАНИЕ СЛОТА»
(таймер заморожен), пока слот не освободится. При успешной отправке файлы
**расходуются**; при отмене/таймауте/конце часа сохраняются. Полоски
соединения нет; `connected` — legacy.
**GDD:** §7, §11, §17.
**Заменяет:** D02 (расход), D04, D22.
## D34 — мини-игры: состав и контракт
**Решение:** 11 игр — 7 отправки (`dots`, `sound`, `wires`, `text`, `video`,
`photo`, `maze`) и 4 атаки (`junk`, `ddos`, `phishing`, `ransomware`). Общий
контракт: без таймеров, конечная цель, локальный seeded RNG, `q ∈ 0…1`,
экран результата `q%`/вердикт и `ПРОДОЛЖИТЬ`; `finished` эмитится только
после `ПРОДОЛЖИТЬ` в модальном режиме (награда не применяется раньше).
`СДАТЬСЯ` (атаки) / `ПРОПУСТИТЬ` (отправки) с подтверждением даёт `q=0`.
Формулы `q` — GDD §10.
`Esc` во время игры отправки — no-op, в игре атаки — двухшаговое `СДАТЬСЯ`;
на результате — продолжить.
**GDD:** §10, §18.
**Заменяет:** D17 (заглушка q), D08 (теллы).
**Примечание:** строка `minigame.surrender` (`СДАТЬСЯ`) используется
мини-играми атак; у мини-игр отправки кнопка — `ПРОПУСТИТЬ`.
## D35 — выбор мини-игры отправки (B4)
**Решение:** коэффициенты `text 1 / sound 2 / photo 4 / video 8`;
`S = sum(coeff)` по 1–3 файлам. `rank` — позиция S в плотном списке
`[1…14,16,17,18,20,24]` (или ближайшая меньшая), `tier = clamp(1 +
floor(rank×4/19), 1, 4)`, `id = order[rank % 7]`, где order = `dots, sound,
wires, text, video, photo, maze`. Суммы 15/19/21/22/23 недостижимы, поэтому
28-элементный массив не перечисляется. Все 7 игр и 4 ступени достижимы;
`S=24 → video/T4` (старый пример S−1 отменён).
**GDD:** §8.4, §10.
**Заменяет:** D17 (средний коэффициент/пороги).
## D36 — магазин: 8 лотов и 3 оффера
**Решение:** 8 лотов (`lot_slots`, `lot_speed`, `lot_sla`, `lot_shield`,
`lot_money`, `lot_folder`, `lot_channel`, `lot_timer`), ровно **3**
предложения в подготовке. Магазин — только в подготовке с H2, в H1 и во
время часа недоступен. Максимальные лоты не предлагаются. Сумма всех
уровней — **13 100 $**. Крестика нет: `ЗАКРЫТЬ`/`Esc`.
**GDD:** §3, §14.
**Заменяет:** D13, D19 (10 позиций).
## D37 — рероллы, замки, веса и сохранение офферов
**Решение:** реролл стоит **40/80/120 $**, максимум **3** за подготовку;
замок отдельного предложения сохраняется при реролле. Вес =
`weight_by_hour[hour−1] × 0.5` (был в прошлой выдаче) `× 1.5` (pity, ≥2
часов без предложения). По возможности в выдаче есть хотя бы один
доступный лот. Офферы, рероллы, `last_seen` и замки сохраняются в сейве и
восстанавливаются при загрузке; счётчик/замки сбрасываются на новой
подготовке.
**GDD:** §14, §15.
**Обновляет:** D23 (открытия), D24 (магазин без крестика).
## D38 — атаки v2
**Решение:** 20 атак за забег: `[0,1,1,2,2,3,3,4,4]` для H1–H9, время —
случайное в окне **60–240 с**. Виды: `junk`, `ddos`, `phishing`,
`ransomware`; доступность по `weight_by_hour` (junk/ddos с H2, phishing с
H4, ransomware с H5). Ступень: H2–3 T1, H4–5 T2, H6–7 T3, H8–9 T4
(`clamp(1+floor((hour−2)/2),1,4)`). Каждая атака — мини-игра, которая
ставит на паузу **весь час**; карточек/пакетов/запросов атак нет.
**GDD:** §3, §10, §12.1.
**Заменяет:** D07, D08, D19 (часть).
## D39 — щит, награды и последствия провала
**Решение:** заряды щита = уровень `lot_shield` (макс. 3), восстанавливаются
в начале часа. Заряд тратится **до** открытия мини-игры: атака считается
отражённой, награда **50 %**, штрафа нет. Деньги наград/штрафов
масштабируются по часу: `max(0.75, hour/3) × attacks.money_scale`. Провал:
DDoS — канал −40 % (factor ×1.4) на 15 с; фишинг — случайный файл
заблокирован на 60 с и отправка недоступна 5 с; ransomware — случайная
папка заблокирована на 15 с. SLA/деньги — из `attacks.kinds.*.tiers`.
**GDD:** §9, §12, §12.1.
**Заменяет:** D07 (таблица урона).
## D40 — SLA, реген и поражение
**Решение:** старт 60/60; в начале часа +15 (не выше капа), кап растёт на
+35 за `lot_sla` до 200. Потеря пакета — 2/4/8/16 по типу; таймаут запроса
и провал запросов в конце часа — `−(10+2×hour)`; отклонение — −5/−5;
уничтожение папки — −5 SLA. Пассивных +0.5/+1 нет. **SLA ≤ 0 во время часа
= поражение**; «конца забега по складу» нет.
**GDD:** §12, §16.
**Заменяет:** D12 (SLA-прирост), D19 (старт), D02 (склад).
## D41 — save v2 и миграция
**Решение:** `SaveManager.VERSION = 2`, поддерживаются 1 и 2. Позиции
хранятся **только** в `folder.files[]` и `table[]`; в `files{}` — метаданные.
Сейв включает деньги/SLA/час/фазу/streak/stats/upgrades/мету/RNG/реестр/
офферы/рероллы/замки/`last_seen`/заряды щита/час SLA-регена. Запись
атомарная (`save.tmp` → rename), в Web — `force_fs_sync()`. v1 мигрирует
без уменьшения сеток и без потери файлов; лишние файлы уходят на стол или в
`registry`.
**GDD:** §15, §17.
**Заменяет:** старую схему слотов v1.
## D42 — баланс v2
**Решение:** цели дохода H1–H9 — **65/220/380/560/760/980/1200/1450/
1700 $**, `request_cap` — 2/5/5/6/6/7/7/7/8. Симулятор `tests/sim_balance.py`
на **240 сидах** даёт средний итог **8 184 $**, все часы в **±20 %**,
аналитические капы не ниже целей. Средний расход магазина — **5 771 $**,
это **44,1 %** дерева 13 100 $; после H9 остаётся в среднем 2 463 $.
Цель 55–65 % **не достигнута** и не декларируется: доход H9 частично
остаётся неистраченным, забег заканчивается. Это принято как честное
ограничение текущей политики покупок, а не как «полный выкуп».
**GDD:** §13, §14.
## D43 — проверки, запуск и Web
**Решение:** единый прогон:
`powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1`
(Windows) или `bash scripts/run_all_tests.sh` (Linux/macOS). Наборы —
**25 тестовых наборов + parse-check (26/26 PASS)**; все проходят.
Баланс — `python tests/sim_balance.py` (exit 0, отчёт
`tests/SIM_BALANCE_REPORT.md`). Windows-раннер сам ищет Godot и позволяет
переопределить путь через `GODOT_BIN` или `-GodotBin`. Web-пресет
`export_presets.cfg` существует: `thread_support=false`, экспорт в
`build/web/index.html`, просмотр только по HTTP.
**GDD:** §23, §24.
**Заменяет:** D25 (числа/платформы).
## D44 — известные отклонения и упрощения
**Решение:** фиксируем осознанно:
- `ПРОПУСТИТЬ` → `q=0` → `quality = 0.5`, то есть половина quality-множителя,
а не нулевая выплата;
- `speed` выплаты использует `time_left/timer_total` на **старте отправки**,
пока таймер был заморожен мини-игрой; быстрый игрок видит ≈1.0;
- лимит папок **6**, новая папка **100 $** (review H5);
- любой не прикреплённый файл можно уничтожить в подготовке бесплатно —
защита от soft-lock «нет денег/нет места» (review M16);
- `wires` T4: `box 34`, `pitch 40`, верх `56`, чтобы низ был ≤320 (review M11);
- деньги атак масштабируются по часу `max(0.75, hour/3)` (review M8).
**GDD:** §26.
**Не является обещанием:** это не баги «на потом», а принятые рамки
джем-прототипа; менять только вместе с балансом и тестами.
+198 -460
View File
@@ -1,514 +1,252 @@
# PEAK HOUR — GDD (v2.0)
# PEAK HOUR — GDD (компактная сводка v1.2)
> **Статус:** актуальная спецификация **v2.0** (25.10.2026). Источник чисел —
> `data/balance.json` (version 2.0) и код `scripts/core`, `scripts/systems`.
> Актуальные решения — **D29–D44** в `docs/DECISIONS.md`; D01–D28 сохранены
> как история и в спорных местах помечены как устаревшие.
> Godot **4.7.2** → **Web (HTML5, no threads)**, RU-only, окно **1920×1080**,
> **Статус:** компактная сводка правил и чисел из постановки задачи +
> решений `DECISIONS.md` (ссылки **Dxx**). Версия **v1.2** (25.10.2026)
> добавляет решения D18–D25 и не отменяет D01–D17, если ниже не сказано иное.
> Godot 4.7.2 → HTML5 (Web, no threads), RU-only, окно **1920×1080**,
> логический viewport **1280×720** (`canvas_items/keep/fractional`),
> 4 человека, джем FEIS, дедлайн 26.10.2026 23:59 Minsk.
## v2.0 — что изменилось
- **Экономика перезапущена:** старт **50 $**, SLA **60/60** (кап +35 за уровень
`lot_sla`, максимум 200), 1 слот (макс. 5), канал 10 (макс. 46), буфер 6
(макс. 12), таймер запроса 30 с (макс. 55), таймер пакета 20 с, сетка папки
**5×4** (макс. **8×6**), 2 папки на старте (макс. 6, новая — 100 $).
- **Магазин v2:** 8 лотов, ровно 3 предложения, рероллы 40/80/120 (макс. 3 за
подготовку), замок отдельного предложения, веса и pity. Полное дерево
стоит **13 100 $**. Магазин появляется только в подготовке с H2.
- **Файлы — полимино:** текст 1 клетка, звук 2, фото 3, видео 4; поворот
`R`/ПКМ во время drag; подпись — в верхней левой **занятой** клетке.
- **Мини-игры:** 11 игр (7 отправки + 4 атаки), без таймеров, с конечной целью
и локальным сидом; экран результата `q%`/вердикт и `ПРОДОЛЖИТЬ`.
- **Отправка:** явная кнопка `ОТПРАВИТЬ` (автостарта нет), переключатель
TCP/UDP; файлы **расходуются** при успешной отправке.
- **Атаки:** 20 за забег по расписанию `0,1,1,2,2,3,3,4,4`; каждая атака —
мини-игра, которая ставит **весь час** на паузу.
- **Save VERSION 2:** позиции хранятся только в `folder.files`/`table`,
метаданные — в `files`; v1-сейвы мигрируют без потери файлов и без
уменьшения сеток.
- **Баланс:** цели дохода H1–H9 = **65/220/380/560/760/980/1200/1450/1700 $**;
симулятор на 240 сидах даёт средний итог **8 184 $**, все часы в ±20 %,
аналитические капы проходят. Средний расход магазина — **44,1 %** дерева
(ниже старой цели 55–65 %; это зафиксировано честно, см. §13).
## 1–2. Обзор и платформа
Менеджер приёма/отправки файлов: дежурный узла раскладывает пакеты по
папкам-сеткам, собирает контейнеры запросов, проходит мини-игры и отражает
атаки за одну смену.
Ограничения: окно 1920×1080, логический viewport 1280×720 (`canvas_items`,
`keep`, `fractional`), nearest, RU-only, Web no-threads (без `Thread` и
блокирующего IO), шрифт **Handjet** (D15). UI v2 «Kaleidos»: тёплая бумага,
1 px контуры, мягкие тени и бирюзовый акцент (D18). Игровой UI без CRT;
VHS-слой — только в главном меню (D27).
Менеджер приёма/отправки файлов: дежурный узла обрабатывает пакеты,
запросы клиентов, канал и атаки в течение одной смены. Ограничения:
окно 1920×1080, логический viewport 1280×720 (`canvas_items`, `keep`,
`fractional`), nearest, RU-only, Web no-threads (без `Thread`/блокирующего IO),
шрифт **Handjet** (D15). UI v2 — «Kaleidos»: тёплая бумага, 1 px контуры,
мягкие тени и бирюзовый акцент (D18).
## 3. Структура смены
Забег конечный, **9 игровых часов по 300 с**:
```
menu → prep(H1) → hour(H1) → hour result → prep(H2) → … → hour(H9)
→ hour result → run result
```
- **Подготовка:** таймеров нет. Доступны папки, стол сортировки 4×6, новая
папка (100 $, максимум 6) и магазин (только с H2).
- **Час:** 300 с; спавн файлов идёт в первые **75 %** часа (225 с), атаки —
в окно **60–240 с**. В конце часа активные запросы проваливаются по
таймауту (штраф `−(10 + 2×hour)`), файлы при этом сохраняются; пакеты
снимаются без дополнительного штрафа; открытая мини-игра отправки
принудительно закрывается без отправки.
- **Старт часа:** SLA **+15** (не выше капа), заряды щита восстанавливаются,
файлы со стола автоматически возвращаются в папки. Если хотя бы одному
файлу не хватило места, час **не начинается** — игрок освобождает место.
- **Победа:** отработаны все 9 часов. **Поражение:** SLA ≤ 0 во время часа.
Отдельного «конца забега по складу» в v2 нет.
Забег конечный, **9 игровых часов**: `Prep (подготовка) → Hour → итоги
часа`; после 9-го — **победа** и итоги забега. Досрочное поражение —
SLA ≤ 0 (сразу итоги забега). «Конец забега по складу» — страховочный
механизм (см. §16), тоже итоги забега. Магазин открывается с часа 2
(в подготовке после часа 1) и скрыт во время часа (D23).
## 4. Файлы и типы
4 типа файлов: текст (`text`), звук (`sound`), фото (`photo`), видео
(`video`). Каждый с полями `name_ru, ext, icon, recv_time, send_time,
recv_fee, base_pay, weight, minigame_coeff, sla_loss` — значения в
`data/balance.json`.
Перед отправкой запроса качество определяется заглушкой `quality = 1.0`;
кнопка «Переделать» не даёт бонусов (D17). «Самый сложный тип» заменён
средним коэффициентом (см. выбор мини-игры, п. 8.4/10). Потеря пакета
бьёт по SLA по типу: **text 2, sound 4, photo 8, video 16** (D19).
4 типа файлов. Каждый файл — полимино; число клеток равно `cells`:
| Тип | Клетки | Формы | `base_time_sec` | `base_pay` | `recv_fee` | `weight` | `sla_loss` |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| `text` (текст) | 1 | 1 | 3 | 20 | 1 | 1 | 2 |
| `sound` (звук) | 2 | 2 | 5 | 34 | 2 | 2 | 4 |
| `photo` (фото) | 3 | 6 | 8 | 52 | 4 | 4 | 8 |
| `video` (видео) | 4 | 17 | 12 | 80 | 6 | 8 | 16 |
- Имена уникальны в пределах забега, генерируются из `data/names_ru.json`.
- `weight` входит в нагрузку канала; `sla_loss` — штраф за потерю пакета.
- Пакет в приёме показывает тип/расширение и фигуру; зловредных пакетов и
«файлов-ловушек» в v2 нет — атаки вынесены в мини-игры (§12.1).
- Качество отправки больше не заглушка: его даёт мини-игра (§10).
## 5. Папки, сетка и секции
- Одна секция **C**. На старте **2 папки** (`Разное`, `Входящие`), игрок может
создать новую за **100 $**, всего до **6** папок (review H5). Имена
уникальны внутри секции (`Имя`, `Имя 2`).
- Каждая папка — клеточная сетка. Размер общий для всех папок и растёт лотом
`lot_folder`:
| Уровень | 0 | 1 | 2 | 3 | 4 | 5 |
| --- | --- | --- | --- | --- | --- | --- |
| Сетка | 5×4 | 6×4 | 6×5 | 7×5 | 7×6 | 8×6 |
| Клеток | 20 | 24 | 30 | 35 | 42 | 48 |
- Файл занимает несколько клеток по форме; пересечения запрещены. При
перемещении доступен точный drop или auto-fit (first-fit по строкам, затем
столбцам; текущий поворот, затем 0–3).
- Поворот фигуры во время drag — **`R`** или **ПКМ** (циклически 0→1→2→3).
- Подпись файла рисуется в **верхней левой занятой** клетке фигуры (важно для
S/Z-образных форм, где пустой угол bbox не является клеткой файла).
- Файл в `attached`/`locked`/`failed`/`malicious` нельзя перемещать.
- «Уничтожить содержимое» папки удаляет все её файлы после подтверждения и
снимает **5 SLA**; пустая папка — без штрафа. Обычный не прикреплённый файл
можно уничтожить в подготовке без штрафа (review M16).
- Загрузка сейва не уменьшает сетку: при нехватке места папка растёт до
ближайшего размера из таблицы, иначе файл уходит на стол или в `registry`
без потери.
## 5. Папки и секции
Файлы лежат в папках (список папок по умолчанию — `names_ru.json`),
папки сгруппированы в секции; секции докупаются в магазине (D13).
Обычные файлы удаляются только в подготовке (долгое удержание/ПКМ);
зловредные — в любой фазе (D02). «Уничтожить содержимое» папки реально
удаляет файлы после подтверждения и снимает 5 SLA; пустая папка — без штрафа
(D26).
## 6. Буфер приёма
- Буфер = **6 + уровень `lot_channel`**, максимум **12**.
- Полный буфер **замораживает спавн**, но не сжигает бюджет файлов часа:
файл появится позже, когда место освободится.
- Бюджет файлов — `hours[].files` (4…20) плюс перенос недоспанного бюджета с
прошлого часа, но не больше **20** файлов за час. Интервал спавна —
`225 с / бюджет` с джиттером ±20 %.
- Пакет ждёт в буфере **20 с** (фиксировано, не покупается). После переноса в
папку начинается загрузка: `base_time_sec × 0.92^speed_level × channel_factor`.
Пока идёт загрузка, таймер пакета не тикает.
- Папка резервирует клетки фигуры **во время приёма**; отмена/таймаут
освобождает резервацию. Это исключает двойную выдачу места.
Буфер = **4 + 2×уровня апгрейда буфера**, максимум **8** (D05).
Пакет тянут по таймеру в папку; таймеры пакетов в буфере идут только
когда нет модалок (D03).
## 7. Запросы
Карточка запроса показывает **имя требуемого файла (до 12 симв.) и
папку**, а не только иконку типа; `required` — конкретные id (D01).
Файл может числиться в `required` нескольких запросов, но `attached` —
только одном; второй запрос видит его серым; при `failed` файла все
запросы, где он `required`, отменяются со штрафом (D06).
В окне «Отправка» максимум 4 видимых карточки; 5-й и далее — скролл,
карточки высотой 56 px (D05).
- Запрос требует **1–3 файла** (веса — `hours[].request_mix`). В v2 `required`
— это слоты контейнера, а не заранее выбранные id: игрок сам решает, какие
файлы положить.
- Файл может быть прикреплён только к одному запросу. Прикрепить файл со
стола нельзя — сначала верните его в папку.
- Если файл стал `locked`/`failed`/`malicious`, он автоматически открепляется
от запроса; контейнер снова нужно собрать.
- Таймер запроса: **30 + 5×timer_level**, максимум **55 с**. Он замирает,
пока открыта мини-игра этого запроса, пока запрос ждёт out-слот и пока идёт
передача. Время часа при этом продолжается.
- `ОТКЛОНИТЬ` — штраф **−5 $ и −5 SLA**, запрос удаляется, файлы остаются.
- Максимум запросов и одновременно активных запросов — из `hours[]`
(`request_cap` / `active_requests`), см. §13.
- В окне отправки видны 4 карточки, дальше — скролл; высота карточки 56 px.
## 8. Meter/фазы и таймеры
Все фазы (приём, отправка, соединение, мини-игра, атаки) — на едином
`Clock`/`HourDirector.tick(delta)`; пауза флагами (см. IMPLEMENTATION.md).
Полный буфер останавливает спавн приёма; открытые модалки/панели —
останавливают спавн приёма и таймеры пакетов, но таймер активного
запроса продолжается (D03). `delta = min(delta, 0.1)`, авто-пауза при
потере фокуса окна, макс FPS 60 (D11).
## 8. Таймеры и фазы
Таймеры пакетов/запросов с grace первых часов: `h1 1.5`, `h2 1.35`,
`h3 1.2`, `h4+ 1.0`; формула `(base − 0.5×(h−1))×grace + 4×timer_level`
(D20). Итоговые значения:
| Таймер | Значение | Примечание |
| Час | Пакет, с | Запрос, с |
| --- | --- | --- |
| Пакет | 20 с | фиксировано; загрузка считается отдельно |
| Запрос | 30 + 5×уровень, макс. 55 с | без per-hour grace |
| Час | 300 с | 9 часов на забег |
| Мини-игра | нет | конечная цель, не время |
| Атака | 60–240 с от начала часа | окно спавна |
- `delta = min(delta, 0.1)`, `Engine.max_fps = 60`, авто-пауза при потере
фокуса окна (D11).
- **Мини-игра отправки** замораживает спавн пакетов, загрузку, спавн запросов
и таймер своего запроса; час и таймеры других запросов продолжают идти.
- **Мини-игра атаки** ставит на паузу весь час (все таймеры), пауза
выставляется до показа окна.
- Модалки подготовки (магазин, пауза, настройки) не тикают час, потому что
час в этой фазе не идёт.
### 8.4. Мини-игры запросов: выбор
Коэффициенты типов: `text 1`, `sound 2`, `photo 4`, `video 8`.
`S = sum(coeff)` по фактически прикреплённым файлам (1–3 файла). Выбор — по
**плотному списку достижимых сумм**, а не по `S−1`:
| 1 | 30 | 45 |
| 2 | 26.325 | 39.825 |
| 3 | 22.8 | 34.8 |
| 4 | 18.5 | 28.5 |
| 5–9 | 18 → 16 | 28 → 26 |
### 8.4. Мини-игры запросов (правило выбора)
```
ACHIEVABLE_SUMS = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,16,17,18,20,24]
rank = индекс S в списке (или ближайшей меньшей достижимой)
tier = clamp(1 + floor(rank * 4 / 19), 1, 4)
id = [dots, sound, wires, text, video, photo, maze][rank % 7]
coeff = {text:1.0, sound:2.0, photo:3.0, video:4.0}
difficulty = sum(coeff(required)) / n # средний коэффициент
пороги: <1.5 → text, <2.5 → sound, <3.5 → photo, иначе → video
MiniGame.start(type, difficulty)
```
Суммы 15/19/21/22/23 недостижимы при максимум 3 файлах, поэтому старый
28-элементный массив не перечисляется буквально. Это фикс **B4**: в
отображаемой таблице нет «дыр» и недостижимых строк, все 7 игр и все 4
ступени достижимы. Пример: `S=24 → rank 18 → video / T4` (старый пример
`S−1` из спецификации отменён).
| rank | S | tier | игра |
| ---: | ---: | ---: | --- |
| 0 | 1 | T1 | dots |
| 1 | 2 | T1 | sound |
| 2 | 3 | T1 | wires |
| 3 | 4 | T1 | text |
| 4 | 5 | T1 | video |
| 5 | 6 | T2 | photo |
| 6 | 7 | T2 | maze |
| 7 | 8 | T2 | dots |
| 8 | 9 | T2 | sound |
| 9 | 10 | T2 | wires |
| 10 | 11 | T3 | text |
| 11 | 12 | T3 | video |
| 12 | 13 | T3 | photo |
| 13 | 14 | T3 | maze |
| 14 | 16 | T3 | dots |
| 15 | 17 | T4 | sound |
| 16 | 18 | T4 | wires |
| 17 | 20 | T4 | text |
| 18 | 24 | T4 | video |
## 9. Канал
Один общий ресурс для приёма и отправки. Фон и занятые слоты канал **не
грузят**: `idle load = 0`, в нагрузку входят только активные передачи
(`packet.loading` и `request.sending`).
Один общий ресурс для отправок. Фон и занятые слоты канал **не грузят**:
`idle load = 0`, в нагрузку входят только активные передачи (`loading`/
`sending`). Формула (D21):
```
load = Σ weight(active_transfers)
capacity = 10 + 6 × channel_level # 10 … 46
factor = max(1.0, load / capacity)
time = base_time_sec × 0.92^speed_level × factor
load = Σ weight(active_transfers)
factor = max(1.0, load / CHANNEL_CAPACITY)
CHANNEL_CAPACITY = 80 + 25×channel_level
actual_time = base_time × factor
```
- `lot_channel` даёт **+6 канала и +1 буфера** за уровень, максимум 6.
- TCP не меняет время; UDP — `×0.8` (см. §11).
- Провал DDoS временно умножает `factor` на **1.4** (канал −40 %) на 15 с.
Апгрейд «Канал +25» (магазин, ×4, D13/D21) увеличивает capacity.
Полные параметры — `data/balance.json → channel`.
## 10. Мини-игры
## 10. Мини-игры (детали)
Мини-игра — модалка; тип определяется правилом п. 8.4 (коэффициенты
text 1.0 / sound 2.0 / photo 3.0 / video 4.0, средняя `difficulty`,
пороги <1.5/<2.5/<3.5/иначе). `MiniGame.start(type, difficulty)`.
Типы: text — h1, sound — h2, photo/video — h3+; продвинутые мини-игры —
мета `complete_run`, контента пока нет (D23). `Esc` в мини-игре — no-op (D10).
Всего **11** игр: 7 отправки и 4 атаки. Общие правила:
- **Нет таймеров** и поражения по времени; игра заканчивается действием
игрока или кнопкой отказа.
- Локальный seeded RNG: сид выдаёт `HourDirector`/`AttackDirector`, состояние
не зависит от глобального `randi()`.
- Экран результата: **`q%` + вердикт** (`ЧИСТО` ≥75 %, `НОРМАЛЬНО` ≥45 %,
`СЛАБО` <45 %) и кнопка **`ПРОДОЛЖИТЬ`**. Сигнал `finished` эмитится
только после `ПРОДОЛЖИТЬ` (в модальном режиме), поэтому награда/штраф не
применяются, пока игрок не закрыл результат.
- Кнопка отказа с подтверждением: в мини-играх отправки — `ПРОПУСТИТЬ`,
в мини-играх атак — `СДАТЬСЯ`; она даёт `q = 0`.
- `Esc` во время игры отправки — no-op; в игре атаки — `СДАТЬСЯ`
с двухшаговым подтверждением. На экране результата `Esc` = `ПРОДОЛЖИТЬ`.
| Игра | Тип | Цель | `q` |
| --- | --- | --- | --- |
| `dots` Одна линия | send | пройти все клетки без повторов | `1 − undo_penalty×undos − 0.1×invalid` |
| `sound` Уровни | send | выставить каналы в целевые значения | среднее `1 − abs(v−target)/(2×tol)` |
| `wires` Провода | send | соединить провода с гнёздами | `(connected − 0.25×wrong) / total` |
| `text` Чужое слово | send | найти все чужие слова | `(found − false_positive) / aliens` |
| `video` Раскадровка | send | разложить кадры по слотам | `(correct − 0.5×wrong − decoys) / slots` |
| `photo` Кадр | send | сделать все снимки | `captures / shots` |
| `maze` Лабиринт | send | дойти до выхода | `1 − 0.04×collisions − 0.02×dead_ends` |
| `junk` Фильтр | attack | убрать весь мусор | `(removed − false_positive) / total_junk` |
| `ddos` Щит | attack | заблокировать все пакеты | `blocked / total_packets` |
| `phishing` Паспорт | attack | проверить всех клиентов | `correct / total_clients` |
| `ransomware` Карантин | attack | найти все `.exe` | `(found_exe − 0.5×wrong) / total_exe` |
Все формулы клампятся в `0…1`. Параметры ступеней (сетки, допуски, число
карточек, пороги) — `data/balance.json → minigames.games`.
Для `wires` T4 используется геометрия `box 34`, `pitch 40`, верх `56`, чтобы
нижняя граница была `≤ 320` (review M11); T1–T3 — `36/42`.
## 11. Протоколы и явная отправка
Полоски соединения и «трёх попаданий» в v2 **нет**. TCP/UDP — это выбор
экономики/скорости, а не мини-игра.
- Переключатель TCP/UDP доступен до старта отправки; во время мини-игры,
очереди или передачи менять протокол нельзя.
- **TCP:** выплата `×1.15`, время передачи `×1.0`.
- **UDP:** время передачи `×0.8`, выплата `×1.0`.
- Отправка начинается только по кнопке **`ОТПРАВИТЬ`**. Прикрепление файлов
автостарт не запускает.
- После мини-игры запрос занимает свободный **out-слот**; если слота нет —
встаёт в очередь `ОЖИДАНИЕ СЛОТА` (таймер запроса заморожен) и стартует,
когда слот освободится. Это продолжение уже начатой отправки, а не
автоматический старт нового запроса.
- Слоты общие для приёма и отправки: `1 + slot_level`, максимум 5.
- Выплата:
```
payout = round(base_sum × speed × quality × multi × protocol × streak × money_mult)
speed = 0.6 + 0.4 × clamp(time_left / timer_total, 0, 1) # на старте отправки
quality = 0.5 + 0.5 × q
multi = 1 + 0.15 × (n − 1)
protocol = 1.15 для TCP, 1.0 для UDP
streak = 1 + 0.02 × min(10, streak)
money_mult = 1 + 0.05 × money_level
```
`recv_fee` начисляется при приёме и не умножается на эти коэффициенты.
`base_sum` — сумма `base_pay` прикреплённых файлов.
## 11. Полоска соединения
`W = clamp(0.30 − 0.005×(hour−1) − 0.005×successes + 0.05×zone_upgrade_level, 0.18, 0.36)`;
скорости палочек 440/340/280 px/с; окна попадания ~130–200 мс;
апгрейд зоны учтён в формуле с кап 0.36 (D04). `W` — единственный
источник правды по зоне; других формул зоны нет (D05).
`Esc` в панели соединения — отмена: слот освобождается, запрос остаётся (D10).
Промах закрывает полоску: автоповтора нет, нужно снова нажать
«Соединиться». `connected` выставляется только после успешного прохождения
полоски (D22).
## 12. SLA и штрафы
Старт забега: **SLA 128 / 200** (D19). Прирост: приём +0.5, отправка +1,
отражённая атака +1 (D12). Потеря пакета — по типу: **text 2, sound 4,
photo 8, video 16** (D19). Отдельная таблица штрафов атак (D07): мусор −5,
DDoS −15, фишинг −10, шифровальщик −10. Таймаут и подобные штрафы
масштабируются: −(10 + 2×hour) (D12).
- Старт забега: **SLA 60 / 60**. Кап = `60 + 35×sla_level`, максимум **200**;
покупка уровня сразу добавляет +35 к текущему SLA.
- В начале каждого часа: **+15 SLA** (не выше капа), затем восстанавливаются
заряды щита.
- Потеря пакета: `text 2`, `sound 4`, `photo 8`, `video 16` SLA.
- Таймаут запроса и провал запросов в конце часа: `−(10 + 2×hour)` SLA.
- Отклонение запроса: `−5 $`, `−5 SLA`.
- Уничтожение содержимого папки: `−5 SLA` (пустая папка — 0).
- Провал атаки: SLA/деньги по таблице `attacks.kinds.*.tiers` и эффекты §12.1.
- **SLA ≤ 0 во время часа = поражение забега.** Других способов проиграть нет.
## 12.1. Атаки
- Расписание на забег: `[0,1,1,2,2,3,3,4,4]` для H1–H9, всего **20** атак;
время — случайное в окне **60–240 с** от начала часа.
- Виды: `junk` (мусор), `ddos`, `phishing`, `ransomware`. Доступность по
`weight_by_hour`: junk/ddos с H2, phishing с H4, ransomware с H5.
- Ступень: H2–3 → T1, H4–5 → T2, H6–7 → T3, H8–9 → T4
(`clamp(1 + floor((hour−2)/2), 1, 4)`).
- Каждая атака — мини-игра (§10); на время неё **весь час** на паузе.
Карточек, пакетов, запросов и файлов атака не создаёт.
- **Щит «Заземление»:** заряды = уровень `lot_shield` (макс. 3),
восстанавливаются в начале часа. Заряд тратится автоматически **до**
открытия мини-игры: атака считается отражённой, награда **50 %**, штрафа
нет.
- Успех/провал дают деньги и SLA из `attacks.kinds.*.tiers`; порог качества
(`threshold`) свой у каждого вида/ступени. Деньги масштабируются:
`max(0.75, hour/3) × attacks.money_scale`.
- Провал даёт эффект:
- `ddos` — канал −40 % (`factor ×1.4`) на **15 с**;
- `phishing` — случайный файл заблокирован на **60 с**, отправка
недоступна **5 с**;
- `ransomware` — случайная папка заблокирована на **15 с**;
- `junk` — только деньги/SLA.
- Кнопка `СДАТЬСЯ` (как и `Esc` с подтверждением) в мини-игре атаки даёт
`q=0`; если порог не пройден, атака считается пропущенной со всеми эффектами.
## 13. Экономика и баланс
Стартовый капитал — **50 $**. Цели дохода по часам (`hours[].income_target`):
| Час | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| Доход, $ | 65 | 220 | 380 | 560 | 760 | 980 | 1 200 | 1 450 | 1 700 |
| `request_cap` | 2 | 5 | 5 | 6 | 6 | 7 | 7 | 7 | 8 |
| `active_requests` | 1 | 2 | 2 | 3 | 3 | 3 | 4 | 4 | 4 |
Доход складывается из выплат за отправки, `recv_fee` за принятые файлы и
денежных наград за отражённые атаки. Магазин — основной сток денег.
Проверка `tests/sim_balance.py` (240 сидов, 9 часов, `dt=0.25`, `q=0.85`, TCP):
- средний итог **8 184 $** (payout 7 060 + `recv_fee` 445 + атаки 679);
- все часы внутри **±20 %** от цели, аналитические капы не ниже целей;
- средний расход магазина — **5 771 $**, это **44,1 %** дерева 13 100 $;
после H9 в среднем остаётся **2 463 $**.
- 44,1 % **ниже** старой цели 55–65 %. Причина: доход H9 частично остаётся
неистраченным, а забег заканчивается; политика симулятора докупает лоты в
финальной подготовке, но не успевает выкупить дерево. Это зафиксированное
отклонение, а не «полный выкуп к H8–H9».
## 13. Экономика
Ожидаемый доход/час ≈ 500 / 800 / 1200 / 1800 / 2200 для часов
1 / 2 / 3 / 5 / 9 (D12). Цены магазина подняты так, что полный выкуп
реалистичен к часу 8–9. Таблица сложности останавливается на часе 8;
дальше те же значения +5 % к скорости спавна каждые 2 часа (D12).
## 14. Магазин
Магазин открывается **только в подготовке** и только **с H2** (после H1).
Во время часа он скрыт. Крестика нет: закрытие — `ЗАКРЫТЬ` или `Esc`.
- Ровно **3 предложения** за подготовку; максимальные лоты не предлагаются.
- Реролл: **40 / 80 / 120 $**, максимум **3** за подготовку.
- Замок отдельного предложения сохраняется при реролле; замки и счётчик
рероллов сбрасываются на следующей подготовке.
- Вес лота: `weight_by_hour[hour−1] × 0.5` (если был в прошлых предложениях)
`× 1.5` (pity, если не предлагался ≥2 часов). Если возможно, в выдаче
есть хотя бы один доступный по деньгам лот.
- Офферы, рероллы, `last_seen` и замки сохраняются в сейве.
| Лот | Эффект | Уровни | Цены |
| --- | --- | ---: | --- |
| `lot_slots` Порты коммутатора | +1 слот | 4 | 120 / 300 / 650 / 1 200 |
| `lot_speed` Оптоволокно | время передачи `×0.92` за уровень | 6 | 90 / 130 / 180 / 250 / 340 / 450 |
| `lot_sla` Резервный ИБП | кап +35 и сразу +35 SLA | 4 | 120 / 260 / 480 / 800 |
| `lot_shield` Заземление | +1 заряд щита | 3 | 150 / 400 / 800 |
| `lot_money` Корпоративный контракт | выплата +5 % за уровень | 8 | 80 / 110 / 150 / 200 / 260 / 340 / 440 / 560 |
| `lot_folder` Складской стеллаж | следующая сетка папки | 5 | 80 / 180 / 360 / 650 / 1 000 |
| `lot_channel` Пропускная способность | канал +6, буфер +1 | 6 | 70 / 100 / 140 / 190 / 250 / 330 |
| `lot_timer` Регламент обработки | таймер запроса +5 с | 5 | 70 / 110 / 160 / 230 / 320 |
Сумма всех уровней — **13 100 $**.
**10 позиций** (D13 + D19): 3-й слот (250), 4-й слот (450), буфер +2
(200, ×2), таймер пакетов +4 с (350, ×3), зона +5 % (300, ×3), канал +25
(250, ×4), секция (300, ×5), поиск (250), щит (400), **SLA +24 (300, ×3)**.
Сканер и автосортировка — резерв (в прототип не входят). Открытия (D23):
магазин — с часа 2; поиск — мета `survive_hour_3`; щит — мета
`survive_hour_5`; в час магазин скрыт. Крестика у магазина нет: закрытие —
«Закрыть» или `Esc`.
## 15. Save
- `SaveManager`, `user://save.json`, **VERSION 2**; поддерживаются версии
**1 и 2**. Запись атомарная: `save.tmp` → rename, затем проверка чтением;
в Web вызывается `force_fs_sync()` (IndexedDB).
- **Единый источник позиций:** `folder.files[]` (id, shape_id, origin,
rotation) и `table[]` (file_id, origin, rotation). В `files{}` хранятся
только метаданные (тип, имя, location, attached_request, lock/cooldown,
shape_id и т.д.), без дублирования origin/rotation.
- В сейв входят деньги, SLA, час, фаза, streak, stats, upgrades, мета-открытия,
`run_meta_awards`, seed/state RNG, реестр, офферы/рероллы/час офферов/
`last_seen`/замки, заряды щита и час последнего SLA-регена.
- Миграция v1: позиции восстанавливаются first-fit, папки не уменьшаются
(`resize_folders` только растёт, при нехватке — ближайший больший размер),
лишние файлы уходят на стол или в `registry` — файлы не теряются.
Сохранение между часами (`SaveManager`, без секретов, локально). В Web —
IndexedDB (`user://save.json`). Мета-открытия (`meta_unlocks`) сохраняются
между забегами: `survive_hour_3` → поиск, `survive_hour_5` → щит,
`complete_run` → продвинутые мини-игры (D23). Награды выдаются постепенно:
после 3-го, 5-го и 9-го часа; `Game.run_meta_awards` копит открытия забега,
итоги показывают «ОТКРЫТО: …».
## 16. Окончание забега
- **Победа:** отработаны 9 часов, затем итоги забега.
- **Поражение:** SLA ≤ 0 во время часа.
- «Конец забега по складу» из v1 не используется: склад не является
отдельным условием поражения, а переполнение решается сеткой/столом.
- На итогах показываются часы, заработок, SLA, счёт и `ОТКРЫТО: …` для
мета-наград. `Esc` ведёт в главное меню.
Победа — отработаны все 9 часов, затем итоги забега. Поражение —
SLA ≤ 0 (смена не отработана), экран итогов. Переполнение склада за
9 часов недостижимо; механизм «конец забега по складу» остаётся как
страховка (не основной конец): поражение, но пройденные 30+ часов
засчитываются — отметка в итоговом отчёте (D02).
## 17. Состояния
Флаги, а не enum (D09):
- `FileItem.location` ∈ registry/folder/attached/locked/malicious/failed,
`attached_request`, `malicious_kind`, `lock_until`;
- `Request.slot_reserved`, `Request.connected`, `Request.minigame_active`.
Таблица переходов по таймерам (потеря таймера в приёме/соединении/мини-игре)
— `docs/IMPLEMENTATION.md`.
Флаги, а не один enum:
- `FileItem.location` ∈ `registry` / `folder` / `table` / `attached` /
`locked` / `malicious` / `failed`;
- `attached_request`, `malicious_kind`, `lock_until`, `cooldown_until`
(`cooldown` в v2 равен 0, файлы расходуются), `shape_id`, `origin`,
`rotation`;
- у запроса: `sending`, `slot_reserved`, `minigame_active`, `quality`,
`send_sum`, `minigame_id`, `minigame_tier`; `required_count`/`queued` —
метаданные. `connected` оставлен legacy-полем и всегда `true`: полоски нет.
- у пакета: `waiting` / `loading`; у папки — `reserved` (резервации приёма).
## 18. Esc и паузирование
- В час — пауза часа.
- В мини-игре отправки во время игры — no-op; в мини-игре атаки —
двухшаговое подтверждение `СДАТЬСЯ`. На экране результата — `ПРОДОЛЖИТЬ`.
- В магазине — закрыть.
- Панели соединения больше нет; отменять нечего.
- На итогах забега — в главное меню (не soft-lock).
## 18. Esc и паузирование (D10)
Час — пауза; мини-игра — no-op; панель соединения — отмена (слот
свободен, запрос остаётся); закрытие модалок — кнопкой/таймаутом. Магазин
закрывается «Закрыть» или `Esc`, без крестика (D24). Esc на итогах забега
ведёт в главное меню (D26).
## 19. Тексты UI
Все строки игрока берутся из `data/strings_ru.json`, ключи `btn.*`,
`msg.*`, `ui.*`, `shop.*`, `attack.*` (+ итаковые/панные/подсказки).
Нет оскорблений, русский язык, никакого мата.
Строки игрока — из `data/strings_ru.json`, ключи `btn.*`, `msg.*`, `ui.*`,
`shop.*`, `attack.*`, `minigame.*`. Русский язык, без оскорблений и мата.
Fallback-строки в коде совпадают по смыслу с JSON.
## 20. Читаемость
- Пакет показывает тип, расширение и фигуру; при drag виден ghost и
количество клеток.
- Файлы в папке/на столе различаются цветом типа и подписью в занятой клетке.
- Запрос показывает имя клиента, `client_id`, протокол, число слотов,
таймер и кнопки `ОТПРАВИТЬ`/`ОТКЛОНИТЬ`.
- У атаки нет «карточки с теллом»: игрок сразу видит мини-игру и её цель.
- В подготовке правая колонка — стол сортировки; в час — окна `ПРИЁМ` и
`ОТПРАВКА`.
## 20. Tells и читаемость
Видимые теллы (D08): пакет показывает **расширение**; шифровальщик —
красный квадрат 4×4; фишинг — client_id в формате `id_XXXX` с неверным
символом (например `id_7F3К`, кириллическая К) и серым аватаром.
`locked`/malicious файл остаётся кликабельным (уничтожение).
## 21. Арт и шрифты
- Основной шрифт — **Handjet** (OFL 1.1, полная кириллица; вариативный
`wght 100–900`, `ELGR 1–2`, `ELSH 0–16`; средняя ширина ≈0.36 em).
- Press Start 2P — только крупные заголовки/логотип.
- Pixelify Sans не используется (нет глифов `О`, `П`).
- Допустимые кегли: **10 / 12 / 16 / 24**.
- UI v2 «Kaleidos»: бумага `#E9E6DD`, 1 px контуры, тени `#00000018`,
скругления 3–4 px, акцент `#2F7F8F`; спокойный фон — плоская бумага.
- Маскот **БАЙТ** ведёт короткий туториал первого часа.
- VHS — только главное меню: `shaders/vhs.gdshader` +
`scripts/ui/vhs_overlay.gd`; чекбокс в настройках, HEADLESS не рендерит.
`wght 100–900, ELGR 1–2, ELSH 0–16`; средняя ширина ≈0.36 em). (D15)
- Press Start 2P — ТОЛЬКО крупные заголовки/логотип (слишком широкий:
17 символов не влезают в карточку 132 px).
- Pixelify Sans — **удалён** из темы (неполная кириллица: нет `О`, `П`).
- Допустимые кегли: **10 / 12 / 16 / 24** (Handjet узкий, 10 разрешён).
- UI v2 «Kaleidos»: тёплая бумага `#E9E6DD`, 1 px контуры, мягкие тени
`#00000018`, скругления 3–4 px, бирюзовый акцент `#2F7F8F`; меньше
декоративных подписей. «Спокойный фон» — плоская `PAPER` без dither
(D18, D24, D26).
- Маскот **БАЙТ** ведёт короткий туториал первого часа (D24).
- VHS-эффект **только на главном меню**: процедурный слой
`scripts/ui/vhs_overlay.gd` (`extends RefCounted`), рисуется в
`MainMenu._draw` до текста; скан-линии 2 физ. px, слабая хроматика, зерно,
виньетка, полоса. Без shader/screen texture, ввод не блокирует, текст
резкий. Чекбокс «VHS-эффект» в настройках, HEADLESS не рендерит.
Игровой UI без CRT; это не полноэкранный CRT (D14, D27).
- Фон: тёплый бумажный дизеринг и светлые панели, без полноэкранного fbm;
CRT убрать (D14, D18).
## 22. Аудио
18 SFX (интерфейс, приём/отправка, соединение, мини-игры, атаки, экономика,
итоги) + фоновый `hum.wav` (loop-friendly, 3 с); регулируемая громкость.
Ambient машинного зала — 4 WAV (`audio/amb`, PSU/fan/room/seek), генератор
`audio/generate_ambient.py`, autoload `Ambient` стартует после первого жеста
(web autoplay), поддерживает mute/паузу/громкость музыки (D28).
18 SFX (интерфейс, приём/отправка, мини-игры, атаки, экономика, итоги) +
фоновый `hum.wav`. Ambient машинного зала — 4 WAV в `audio/amb`
(PSU/fan/room/seek), генератор `audio/generate_ambient.py`; autoload `Ambient`
стартует после первого жеста (web autoplay), поддерживает mute/паузу/громкость.
## 23. Порядок разработки и структура
Актуальный порядок: данные/реестр → сетки и фигуры → приём/отправка →
мини-игры → магазин/экономика → атаки → UI/полировка → Web.
```
scripts/
├─ main.gd # UI, ввод, модалки, drag&drop
├─ game_controller.gd # фазы menu → prep → hour → results
├─ autoload/ # Game, Data, Audio, Rng, Ambient
├─ core/ # HourDirector, FileRegistry, SlotManager, Economy
├─ entities/ # файлы, папки, секции, пакеты, запросы, слоты
├─ systems/ # Shop, AttackDirector, SaveManager
├─ ui/ # Theme90, FileGrid, SortingTable, ShopModal, …
└─ minigames/ # MiniGame, factory, send/, attack/
```
## 23. Порядок разработки
P0 (D16, уточнено D23): данные/реестр → слоты → приём/отправка → UI.
Типы: text h1, sound h2, photo/video h3+. **Час 1 — без атак**; junk/ddos —
h2, phishing — h3, ransomware — h4 (`difficulty.attacks = 0,1,1,2,2,3,3,4,4`).
Магазин — h2. Дальше: UI v2 «Kaleidos», прогрессивная мета, маскот-туториал,
полировка, Web-сборка.
## 24. Приёмка (definition of done)
Полный забег из 9 часов, 4 типа файлов, 4 атаки, **10 позиций магазина**,
правило выбора мини-игры и формула зоны исчерпываются через data,
RU-тексты, save/load и мета-открытия, HTML5-сборка работает на itch.io.
Полный забег из 9 часов; 4 типа файлов-полимино; 7 мини-игр отправки и 4
атаки; 8 лотов магазина с 3 офферами/рероллами/замками; явная отправка с
TCP/UDP; save v2 с миграцией v1; RU-тексты; HTML5-сборка работает на itch.io.
## 25. v1.2 — delta (D18–D28)
## 25. Решения v2.0
Кратко; полные формулировки — `docs/DECISIONS.md`:
- **D29** стартовые статы и формулы; **D30** сетки/полимино;
**D31** приём и стол; **D32** запросы и прикрепление; **D33** отправка и
протоколы; **D34** мини-игры; **D35** выбор B4; **D36** 8 лотов;
**D37** рероллы/замки/веса; **D38** атаки; **D39** щит и последствия;
**D40** SLA/поражение; **D41** save v2; **D42** баланс; **D43** тесты;
**D44** известные отклонения.
- Устаревшие числа v1 (SLA 128/200, 10 позиций, буфер 4–8, канал 80+25,
grace-таймеры, полоска соединения, карточки атак) заменены и помечены в
DECISIONS.
## 26. Известные отклонения v2.0
- `ПРОПУСТИТЬ`/`СДАТЬСЯ` дают `q = 0`; `quality = 0.5 + 0.5×0 = 0.5`, то есть
половину quality-множителя, а не нулевую выплату. Это осознанно.
- `speed` в выплате использует `time_left / timer_total`, зафиксированное
**на старте отправки**, пока таймер запроса был заморожен мини-игрой.
Быстрый игрок видит `speed ≈ 1.0`; это принятое упрощение.
- Лимит папок — **6**, новая папка — **100 $** (review H5).
- Любой не прикреплённый файл можно уничтожить в подготовке бесплатно —
защита от soft-lock «нет денег и нет места» (review M16).
- `wires` T4: `box 34`, `pitch 40`, верх `56` (review M11).
- Денежные награды/штрафы атак масштабируются по часу:
`max(0.75, hour/3) × attacks.money_scale` (review M8).
- **D18 — UI v2 «Kaleidos»:** тёплая бумага, 1 px контуры, мягкие тени,
скругления, бирюзовый акцент; окно 1920×1080, логический viewport
1280×720, fractional scale; меньше декоративного шума.
- **D19 — SLA/экономика:** старт 128/200; потеря пакета `text 2 / sound 4 /
photo 8 / video 16`; в магазине 10-я позиция `SLA +24` (300, ×3).
- **D20 — таймеры:** grace `h1 1.5 / h2 1.35 / h3 1.2 / h4+ 1.0`;
`(base − 0.5×(h−1))×grace + 4×timer_level`; итоговая таблица — §8.
- **D21 — канал:** `load = Σ weight` только активных передач; idle 0 %;
`capacity = 80 + 25×level`; фон и слоты не грузят.
- **D22 — TCP:** промах закрывает полоску, автоповтора нет; повторное
«Соединиться»; `connected` только после успеха.
- **D23 — открытия/мета:** магазин с часа 2; поиск/щит/`complete_run` —
после 3/5/9 часов; `run_meta_awards` + «ОТКРЫТО» в итогах; типы text h1,
sound h2, photo/video h3+; **час 1 без атак**, junk/ddos h2, phishing h3,
ransomware h4; `minigame_advanced` — резерв без контента.
- **D24 — UX:** уникальные имена папок, удаление пустых папок,
автообновление, attached-галочки, магазин после часа без крестика,
настройки на главном экране, маскот **БАЙТ**, спокойный фон = плоская
бумага, реальное уничтожение содержимого (−5 SLA), Esc на итогах → меню.
- **D25 — проверки/ревью:** `scripts/run_all_tests.sh` — 21 набор +
parse-check, **22/22 PASS**; MR-ревью OpenCode `oracle`
(`opencode-go/deepseek-v4.1-flash`, 74–90 с на dry-run).
- **D26 — фиксы аудита:** calm-фон без серого `#C0C0C0`, Esc на итогах →
меню без soft-lock, реальное уничтожение папки, прогрессивная мета,
час 1 без атак, честный резерв `minigame_advanced`.
- **D27 — VHS главного меню:** процедурный `vhs_overlay.gd`
(`extends RefCounted`) в `MainMenu._draw`; скан-линии, хроматика, зерно,
виньетка, полоса. Без shader/screen texture, ввод не блокирует, текст
резкий; только меню, чекбокс, HEADLESS без рендера. Не CRT на весь UI.
- **D28 — ambient machine room:** 4 WAV в `audio/amb`, генератор
`generate_ambient.py`, autoload после первого жеста, mute/пауза/громкость;
тест `test_ambient` — 74/0.
+41 -60
View File
@@ -10,54 +10,38 @@
## Короткое описание
1997 год. Ночная смена в узле файлового обмена. Принимайте пакеты,
раскладывайте файлы по папкам, отправляйте контейнеры клиентам, держите
SLA — и не дайте атакам положить канал.
отправляйте файлы клиентам, держите SLA — и не дайте атакам положить канал.
## Описание
Вы — дежурный оператор узла 7. В 03:42 на столе мигает приём: пакеты
приходят быстрее, чем вы успеваете разложить их по папкам. Каждый файл —
фигура из клеток: текст занимает одну, видео — четыре. Фигуру можно
повернуть и плотно уложить в сетку папки; на столе сортировки всегда есть
место, чтобы разобраться с завалом.
приходят быстрее, чем вы успеваете разложить их по папкам. Клиенты присылают
запросы на конкретные файлы — имя и папку видно в карточке, так что искать
приходится по всему диску. Перетащите пакет в папку, найдите нужный файл,
прикрепите его к запросу, установите соединение и отправьте. В окне
соединения три бегунка — три попадания по очереди, и канал ваш. Промах
закрывает панель: придётся нажать «Соединиться» снова.
Клиенты присылают запросы на 1–3 файла. Соберите контейнер, выберите
протокол — TCP платит больше, UDP быстрее — и нажмите «ОТПРАВИТЬ».
Отправка не абстрактная: перед ней открывается мини-игра. Всего их
одиннадцать: семь для отправки и четыре для атак. В них нет таймеров —
только конечная цель и качество выполнения, от которого зависит выплата.
Результат показывает проценты и вердикт, а награда применяется после
«ПРОДОЛЖИТЬ».
Смена длится девять игровых часов по пять минут. На старте **SLA 60/60**;
в начале часа восстанавливается 15 SLA. Потерянный пакет бьёт по типу
файла: текст 2, звук 4, фото 8, видео 16. Первый час проходит без атак;
дальше в узел лезут мусор, DDoS, фишинг и шифровальщик. Каждая атака — это
отдельная мини-игра, которая ставит час на паузу: отбейте её, и получите
деньги и SLA; провалите — получите штраф и временный дебафф. «Заземление»
гасит атаку автоматически, но приносит только половину награды.
Магазин открывается после первого часа: **8 улучшений** — порты, оптика,
ИБП, заземление, контракты, стеллажи, канал и регламент. Каждую подготовку
предлагаются три лота; ассортимент можно обновить за 40/80/120 $ или
закрепить нужное предложение. Полное дерево стоит 13 100 $ — выкупить всё
за один забег не получится, придётся выбирать.
Дожить до конца смены — победа; уронить SLA — поражение. Сохранение
работает между часами, включая офферы магазина и стол сортировки.
Смена длится девять игровых часов по пять минут. На старте **SLA 128/200**,
а каждый потерянный пакет бьёт по типу файла: текст 2, звук 4, фото 8,
видео 16. Первый час проходит без атак; дальше в узел лезут мусор, DDoS,
фишинг и шифровальщик — их нужно вовремя заметить и уничтожить. Магазин
открывается после первого часа: **10 улучшений** от слотов и буфера до зоны,
канала, щита и запаса SLA. Поиск и щит открываются мета-прогрессом после
3-го и 5-го часа, продвинутые мини-игры — после 9-го (контент пока
в разработке). Дожить до конца смены — победа; уронить SLA — поражение.
Интерфейс вдохновлён Kaleidos: тёплая бумага, тонкие 1 px контуры, мягкие
тени и бирюзовый акцент. В первом часе рядом подсказывает маскот **БАЙТ**.
В главном меню — лёгкий VHS-эффект, который отключается в настройках; в игре
UI остаётся чистым. Фон — ambient машинного зала, включается после первого
жеста и регулируется вместе с музыкой.
В главном меню — лёгкий процедурный VHS-эффект (скан-линии, зерно, виньетка),
который отключается в настройках; в игре UI остаётся чистым. Фон — ambient
машинного зала, включается после первого жеста и регулируется вместе с музыкой.
UI рисуется кодом, без внешних текстур; Handjet держит плотный русский
текст, Press Start 2P — логотип и цифры. Звуки синтезированы скриптом
проекта. Собрано на Godot 4.7.2 для Web без threads: игра запускается в
браузере без COOP/COEP. Это играбельный джем-прототип полного цикла: меню,
подготовка, девять часов, итоги, магазин, пауза, настройки, атаки,
сохранение и мета-открытия.
проекта. Собрано на Godot 4.7.2 для Web без threads:
игра запускается в браузере без COOP/COEP. Это играбельный джем-прототип
полного цикла: меню, подготовка, девять часов, итоги, магазин, пауза,
настройки, атаки, сохранение и мета-открытия.
## Управление
@@ -65,16 +49,13 @@ UI рисуется кодом, без внешних текстур; Handjet д
| --- | --- |
| Выбор, меню, перетаскивание | ЛКМ |
| Контекстное меню | ПКМ |
| Поворот фигуры во время drag | R или ПКМ |
| Пакет из «Приёма» → папка | drag&drop |
| Файл из папки → карточка запроса | drag&drop |
| Файл ↔ стол сортировки | drag&drop |
| Отправка контейнера | кнопка «ОТПРАВИТЬ» |
| TCP / UDP | переключатель на карточке запроса |
| Мини-игра: действие | ЛКМ / Space / стрелки |
| Пропустить мини-игру | «ПРОПУСТИТЬ» + подтверждение |
| Результат мини-игры | «ПРОДОЛЖИТЬ» или Esc |
| Пауза | Esc |
| Поиск по имени в текущей папке | после открытия «Поиска» |
| Мини-игра соединения: три попадания | ЛКМ или `Space` |
| Мини-игра подготовки: отправить | `Enter` |
| Пауза | `Esc` |
| Панель соединения: отмена | `Esc` |
## Сделано на FEIS Game Jam
@@ -90,7 +71,7 @@ FEIS Game Jam, 2026. Команда — 4 человека. Godot 4.7.2, GDScrip
## Ассеты и лицензии
Шрифты **Handjet** и **Press Start 2P** — SIL OFL 1.1. UI, иконки, маскот
**БАЙТ** и icon.svg нарисованы кодом/командой — MIT. SFX и hum.wav
**БАЙТ** и `icon.svg` нарисованы кодом/командой — MIT. SFX и `hum.wav`
сгенерированы скриптом проекта — MIT/CC0. Код — MIT. Полный список и
инструкции по воспроизведению: [ASSETS.md](https://git.194-146-38-78.sslip.io/admin/Game-JAM/-/blob/main/ASSETS.md).
@@ -108,19 +89,19 @@ FEIS Game Jam, 2026. Команда — 4 человека. Godot 4.7.2, GDScrip
![Подготовка](screenshots/prep.png)
2. **Подготовка.** «Подготовка: сетка папок, стол сортировки 4×6, SLA 60/60; магазин откроется после первого часа».
2. **Подготовка.** «Подготовка: склад и папки, SLA 128/200, магазин откроется после первого часа».
![Час пик](screenshots/hour.png)
3. **Час пик.** «Час пик: пакеты-фигуры в приёме и запросы с таймерами, протоколами и слотами».
3. **Час пик.** «Час пик: 3 пакета в приёме и 2 запроса с таймерами/протоколами/слотами».
![Атаки](screenshots/attacks.png)
4. **Атаки.** «Мини-игра атаки: час на паузе, качество и вердикт после действия».
4. **Атаки.** «DDoS-баннер в приёме и фишинговый запрос с ???».
![Магазин](screenshots/shop.png)
5. **Магазин.** «Магазин после часа: три предложения, реролл и замок, без крестика».
5. **Магазин.** «Магазин после часа: доступные улучшения, SLA +24, без крестика».
![Настройки](screenshots/settings.png)
@@ -128,20 +109,20 @@ FEIS Game Jam, 2026. Команда — 4 человека. Godot 4.7.2, GDScrip
## Теги
godot game jam management simulation retro 90s pixel art singleplayer short web
`godot` `game jam` `management` `simulation` `retro` `90s` `pixel art`
`singleplayer` `short` `web`
---
## Перед публикацией (служебное, в текст страницы не вставлять)
- [ ] Пересобрать build/web из текущего кода и загрузить в itch.io:
HTML5, 1920×1080, без fullscreen-lock.
- [ ] Пересобрать скриншоты под v2 (сетки, стол, мини-игры, 3 оффера) и
загрузить 6 кадров из docs/screenshots/: обязательные — main_menu.png,
hour.png, attacks.png; дополнительно — prep.png, shop.png, settings.png;
cover.png (630×500) — обложка страницы.
- [ ] Сверить ссылку на репозиторий и ветку (main); при другой ветке
заменить main на фактическую.
- [ ] Пересобрать `build/web` из текущего кода и загрузить в itch.io:
**HTML5**, 1920×1080, без fullscreen-lock.
- [ ] Загрузить 6 кадров из `docs/screenshots/`: 3 обязательных — `main_menu.png`,
`hour.png`, `attacks.png`; дополнительно — `prep.png`, `shop.png`,
`settings.png`; `cover.png` (630×500) — обложка страницы.
- [ ] Сверить ссылку на репозиторий и ветку (`main`); при другой ветке
заменить `main` на фактическую.
- [ ] Прогнать полный забег вручную на itch.io, проверить сохранение после
перезагрузки вкладки.
- [ ] Отправить страницу и сборку в джем.
+26 -26
View File
@@ -39,7 +39,7 @@ error #C25B4A · success #3F8F5E · warn #C58A2E · shadow #00000018
| `scroll_bar.gd` | тонкая дорожка, teal только при hover; скрыт без прокрутки |
| `shop_modal.gd` | 10 позиций (включая `SLA +24`), фильтр `Data.available_shop()`, без крестика; «Закрыть»/`Esc` |
| `main_menu.gd` | логотип, слоган, 4 кнопки, 3 шага смены, сводка; VHS-фон только здесь |
| `vhs_overlay.gd` | нативный CRT/VHS-слой меню: скан-линии, зерно, виньетка, полоса |
| `vhs_overlay.gd` | процедурный CRT/VHS-слой меню (RefCounted): скан-линии, зерно, виньетка, полоса |
| `tutorial_mascot.gd` | маскот **БАЙТ** 64–96 px + облачко, не ловит мышь |
| `main.gd` | сборка экрана, магазин после часа, поиск, автообновление, скролл приёма |
@@ -68,39 +68,39 @@ error #C25B4A · success #3F8F5E · warn #C58A2E · shadow #00000018
## VHS-эффект главного меню
Тонкий CRT/VHS-слой есть **только на главном меню** (`shaders/vhs.gdshader` +
`scripts/ui/vhs_overlay.gd`). Полноэкранный `ColorRect` с canvas_item-шейдером
читает готовый кадр через `hint_screen_texture`.
Тонкий CRT/VHS-слой есть **только на главном меню** (`scripts/ui/vhs_overlay.gd`).
Это `RefCounted`, а не Node/Control: меню само рисует слой в `MainMenu._draw()`.
Ключевое — **native-res**: эффект считается в физических пикселях окна
(`FRAGCOORD`/`SCREEN_PIXEL_SIZE`). При 1920×1080 скан-линии идут ровно каждые
2 px, без блоков и banding; логика UI при этом остаётся 1280×720
(`stretch/mode = canvas_items`), но VHS не растягивает 1280-текстуру.
Порядок отрисовки:
- screen texture сэмплится с линейной фильтрацией 1:1. Identity-проверка
при 1920×1080: diff в области текста = 0, то есть картинка не мылится;
- хроматика ≤0.0003 UV (~0.6 px у края кадра), зелёный канал не сдвигается —
буквы не двоятся;
- полоса меняет только яркость и не двигает изображение, поэтому текст
под ней не «плывёт».
1. бумага;
2. VHS по фону — скан-линии, зерно, виньетка, полоса;
3. карточка, правая панель и декоративные фигуры;
4. мягкий VHS-проход по карточке и панели (0.72 силы, без виньетки);
5. текст и кнопки.
Параметры (`VhsOverlay` / шейдер):
Поэтому слой не перекрывает меню, не перехватывает мышь и не может затереть
кадр фоном подготовки. Кнопки — дети `MainMenu`, они рисуются поверх.
Screen texture (`hint_screen_texture`) не используется: интерфейс не сэмплится
и остаётся резким.
Параметры (`VhsOverlay`):
| Параметр | Значение | Смысл |
| --- | --- | --- |
| `intensity` | **0.40** (макс. 0.55) | общая сила слоя |
| `scanline_step` | 2 физ. px | тонкие ровные скан-линии |
| `scanline_strength` | 0.06 | затемнение ≈2.4% при intensity 0.40 |
| `aberration` | 0.0003 UV | мягкая хроматика, без двоения |
| `grain` | 0.025, 2 Гц | слабое зерно, без мерцания |
| `vignette` | 0.11 | затемнение к краям кадра |
| `band` | 0.04, ~18 с на проход | редкая полоса, только яркость |
| `intensity` | **0.42** (макс. 0.55) | общая сила слоя |
| `SCANLINE_STEP` | 2 лог. px | шаг скан-линий, 1 px затемнения |
| `SCANLINE_STRENGTH` | 0.07 | затемнение линий |
| проход по карточке | ×0.72 | мягче фона, текст рисуется поверх |
| зерно | тайл 128×128, 2 Гц | амплитуда ≤0.05, без мерцания |
| виньетка | 0.13 | затемнение к краям кадра |
| полоса | ~18 с на проход | мягкая тёмная, со светлой кромкой и дрожанием ±4 px |
Слой — последний ребёнок `MainMenu`, `mouse_filter = IGNORE`,
`process_mode = ALWAYS`. Модалки (настройки, подтверждение) — отдельные
соседи в `modal_root`, рисуются поверх и **не** фильтруются.
Скан-линии рисуются в канве меню (логические 1280×720) и масштабируются
вместе с UI: на 1920×1080 шаг ≈3 физических px. Это осознанный компромисс —
эффект не читает экранную текстуру, поэтому не бывает мыла и затирания UI.
В HEADLESS материал не создаётся, слой скрыт — тесты не тормозят.
В HEADLESS текстуры не создаются и слой не рисуется — тесты не тормозят.
**Как отключить:**
+1 -1
View File
@@ -8,7 +8,7 @@ config_version=5
config/name="PEAK HOUR 97"
config/description="Час пик. Одна смена. Ни одного свободного места."
config/version="2.0.0"
config/version="0.1.0"
run/main_scene="res://scenes/Main.tscn"
run/max_fps=60
config/features=PackedStringArray("4.7", "GL Compatibility")
+67 -379
View File
@@ -1,155 +1,88 @@
extends Node
## Data — доступ к JSON-данным из res://data (autoload).
##
## v2: новые секции balance.json (constants/hours/grid/shop_rules/shop/
## minigames/attacks/payout/sla). Отсутствующие ключи заменяются безопасными
## дефолтами с push_warning; игра не должна падать из-за данных.
##
## Legacy-API (tr, type_def, difficulty_for, shop_item, available_shop,
## folder_name) сохранён для ещё не переписанных систем.
## Файлы данных сейчас заготовки, поэтому отсутствующие ключи заменяются
## безопасными дефолтами с push_warning; игра не должна падать из-за данных.
const DATA_DIR := "res://data"
const DEFAULT_TYPES := {
"text": {
"name_ru": "Текст", "ext": "txt", "icon": "text",
"size_kb": 64,
"cells": 1, "base_time_sec": 3, "recv_fee": 1,
"base_pay": 20, "weight": 1, "minigame_coeff": 1, "sla_loss": 2,
"shapes": ["O1"],
"recv_time": 2.0, "send_time": 1.5, "recv_fee": 1,
"base_pay": 80, "weight": 8, "minigame_coeff": 1.0, "size_kb": 64,
"sla_loss": 2,
},
"sound": {
"name_ru": "Звук", "ext": "mp3", "icon": "sound",
"size_kb": 3072,
"cells": 2, "base_time_sec": 5, "recv_fee": 2,
"base_pay": 34, "weight": 2, "minigame_coeff": 2, "sla_loss": 4,
"shapes": ["I2H", "I2V"],
"recv_time": 3.5, "send_time": 2.5, "recv_fee": 2,
"base_pay": 140, "weight": 12, "minigame_coeff": 2.0, "size_kb": 3072,
"sla_loss": 4,
},
"photo": {
"name_ru": "Фото", "ext": "jpg", "icon": "photo",
"size_kb": 5120,
"cells": 3, "base_time_sec": 8, "recv_fee": 4,
"base_pay": 52, "weight": 4, "minigame_coeff": 4, "sla_loss": 8,
"shapes": ["I3H", "I3V", "L3a", "L3b", "L3c", "L3d"],
"recv_time": 5.0, "send_time": 3.5, "recv_fee": 3,
"base_pay": 220, "weight": 18, "minigame_coeff": 3.0, "size_kb": 5120,
"sla_loss": 8,
},
"video": {
"name_ru": "Видео", "ext": "mp4", "icon": "video",
"size_kb": 20480,
"cells": 4, "base_time_sec": 12, "recv_fee": 6,
"base_pay": 80, "weight": 8, "minigame_coeff": 8, "sla_loss": 16,
"shapes": ["I4H", "I4V", "O4", "T4a", "T4b", "T4c", "T4d",
"L4a", "L4b", "L4c", "L4d", "J4a", "J4b", "J4c", "J4d", "S4", "Z4"],
"recv_time": 8.0, "send_time": 6.0, "recv_fee": 4,
"base_pay": 380, "weight": 22, "minigame_coeff": 4.0, "size_kb": 20480,
"sla_loss": 16,
},
}
## Таблица сложности v1 — оставлена для legacy difficulty_for().
## Таблица сложности — до часа 8 (D12); дальше используется последняя строка.
const DEFAULT_DIFFICULTY := [
{"hour": 1, "max_requests": 2, "spawn_sec": 7.0},
{"hour": 2, "max_requests": 5, "spawn_sec": 6.5},
{"hour": 3, "max_requests": 7, "spawn_sec": 6.0},
{"hour": 4, "max_requests": 9, "spawn_sec": 5.5},
{"hour": 5, "max_requests": 10, "spawn_sec": 5.0},
{"hour": 6, "max_requests": 11, "spawn_sec": 4.5},
{"hour": 7, "max_requests": 11, "spawn_sec": 4.2},
{"hour": 8, "max_requests": 12, "spawn_sec": 4.0},
{"hour": 2, "max_requests": 3, "spawn_sec": 6.5},
{"hour": 3, "max_requests": 3, "spawn_sec": 6.0},
{"hour": 4, "max_requests": 4, "spawn_sec": 5.5},
{"hour": 5, "max_requests": 4, "spawn_sec": 5.0},
{"hour": 6, "max_requests": 5, "spawn_sec": 4.5},
{"hour": 7, "max_requests": 5, "spawn_sec": 4.2},
{"hour": 8, "max_requests": 6, "spawn_sec": 4.0},
]
const DEFAULT_SEND_ORDER := ["dots", "sound", "wires", "text", "video", "photo", "maze"]
const DEFAULT_ACHIEVABLE_SUMS := [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 20, 24]
const DEFAULT_HOUR_FILES := [4, 8, 12, 16, 20, 20, 20, 20, 20]
const DEFAULT_REQUEST_CAP := [2, 5, 7, 9, 10, 11, 11, 12, 12]
const DEFAULT_ACTIVE_REQUESTS := [1, 2, 2, 3, 3, 3, 4, 4, 4]
const DEFAULT_ATTACKS := [0, 1, 1, 2, 2, 3, 3, 4, 4]
const DEFAULT_INCOME_TARGET := [90, 220, 380, 560, 760, 980, 1200, 1450, 1700]
const DEFAULT_RECV_INTERVAL := [12, 11, 10, 9, 8, 7.5, 7, 6.5, 6]
const DEFAULT_SEND_INTERVAL := [22, 20, 18, 17, 15, 14, 13, 12, 12]
## Порядок: text, sound, photo, video (§2.5).
const DEFAULT_TYPE_WEIGHTS := [
[0.70, 0.30, 0.00, 0.00],
[0.55, 0.30, 0.15, 0.00],
[0.40, 0.30, 0.22, 0.08],
[0.30, 0.25, 0.28, 0.17],
[0.22, 0.20, 0.32, 0.26],
[0.15, 0.15, 0.35, 0.35],
[0.10, 0.12, 0.35, 0.43],
[0.06, 0.08, 0.32, 0.54],
[0.04, 0.04, 0.30, 0.62],
]
## Порядок: 1 файл, 2 файла, 3 файла (§2.6).
const DEFAULT_REQUEST_MIX := [
[0.70, 0.30, 0.00],
[0.60, 0.35, 0.05],
[0.50, 0.40, 0.10],
[0.50, 0.40, 0.10],
[0.40, 0.45, 0.15],
[0.40, 0.45, 0.15],
[0.30, 0.50, 0.20],
[0.30, 0.50, 0.20],
[0.30, 0.50, 0.20],
const DEFAULT_SHOP := [
{"id": "slot_3", "name": "3-й слот", "cost": 250, "max_level": 1},
{"id": "slot_4", "name": "4-й слот", "cost": 450, "max_level": 1},
{"id": "buffer", "name": "Буфер +2", "cost": 200, "max_level": 2},
{"id": "packet_timer", "name": "Таймер пакетов +4 с", "cost": 350, "max_level": 3},
{"id": "zone", "name": "Зона +5 %", "cost": 300, "max_level": 3},
{"id": "channel", "name": "Канал +25", "cost": 250, "max_level": 4},
{"id": "section", "name": "Секция", "cost": 300, "max_level": 5},
{"id": "search", "name": "Поиск", "cost": 250, "max_level": 1},
{"id": "shield", "name": "Щит", "cost": 400, "max_level": 1},
{"id": "shop_sla", "name": "SLA +24", "effect": "sla_add", "value": 24, "cost": 300, "max_level": 3},
]
const DEFAULT_LOT_IDS := [
"lot_slots", "lot_speed", "lot_sla", "lot_shield",
"lot_money", "lot_folder", "lot_channel", "lot_timer",
]
const DEFAULT_LOT_NAMES := [
"Порты коммутатора", "Оптоволокно", "Резервный ИБП", "Заземление",
"Корпоративный контракт", "Складской стеллаж", "Пропускная способность",
"Регламент обработки",
]
const DEFAULT_LOT_EFFECTS := [
"connection_slots", "transfer_speed", "sla_cap", "shield_charge",
"money_mult", "folder_cells", "channel_capacity", "request_timer",
]
const DEFAULT_LOT_VALUES := [1, 0.08, 35, 1, 0.05, 1, 6, 5]
const DEFAULT_LOT_MAX := [4, 6, 4, 3, 8, 5, 6, 5]
const DEFAULT_LOT_PRICES := [
[120, 300, 650, 1200],
[90, 130, 180, 250, 340, 450],
[120, 260, 480, 800],
[150, 400, 800],
[80, 110, 150, 200, 260, 340, 440, 560],
[80, 180, 360, 650, 1000],
[70, 100, 140, 190, 250, 330],
[70, 110, 160, 230, 320],
]
const DEFAULT_LOT_WEIGHTS := [
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8],
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8],
[0.8, 0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
[0.3, 0.3, 0.3, 0.8, 0.8, 0.8, 1.0, 1.0, 1.0],
[0.6, 0.6, 0.6, 1.0, 1.0, 1.0, 1.2, 1.2, 1.2],
[1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.8, 0.8, 0.8],
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.9, 0.9, 0.9],
[1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.7, 0.7, 0.7],
]
const DEFAULT_SHOP_RULES := {
"offers": 3,
"unlock_hour": 2,
"reroll_costs": [40, 80, 120],
"max_rerolls": 3,
"last_offer_weight": 0.5,
"pity_hours": 2,
"pity_weight": 1.5,
## Прогрессия/открытия (дублирует data/balance.json → progression).
const DEFAULT_PROGRESSION := {
"unlocks": {
"shop": {"run_hour": 2, "label": "Магазин"},
"search": {"meta": "survive_hour_3", "label": "Поиск"},
"shield": {"meta": "survive_hour_5", "label": "Щит"},
"minigame_text": {"run_hour": 1},
"minigame_sound": {"run_hour": 2},
"minigame_photo": {"run_hour": 3},
"minigame_video": {"run_hour": 4},
"minigame_advanced": {"meta": "complete_run", "label": "Продвинутые мини-игры"},
"attack_junk": {"run_hour": 2},
"attack_ddos": {"run_hour": 2},
"attack_phishing": {"run_hour": 3},
"attack_ransomware": {"run_hour": 4},
},
"meta_rewards": {
"survive_hour_3": {"min_hours": 3, "unlocks": ["search"]},
"survive_hour_5": {"min_hours": 5, "unlocks": ["shield"]},
"complete_run": {"min_hours": 9, "unlocks": ["minigame_advanced"]},
},
}
const DEFAULT_GRID_DIMS := [[5, 4], [6, 4], [6, 5], [7, 5], [7, 6], [8, 6]]
var balance: Dictionary = {}
var constants: Dictionary = {}
var economy: Dictionary = {}
var types: Dictionary = {}
var hours: Array = []
var grid: Dictionary = {}
var shop_lots: Array = []
var rules: Dictionary = {}
var minigames: Dictionary = {}
var attacks: Dictionary = {}
var payout_rules: Dictionary = {}
var sla_rules: Dictionary = {}
var difficulty: Array = []
var shop: Array = []
var names: Dictionary = {}
@@ -167,27 +100,9 @@ func _ready() -> void:
func load_all() -> void:
_cache.clear()
balance = load_json("balance.json")
constants = _as_dict(balance.get("constants", null))
economy = _as_dict(balance.get("economy", null))
types = _merge_types(balance.get("types", null))
hours = _load_hours(balance.get("hours", null))
grid = _as_dict(balance.get("grid", null))
if grid.is_empty():
push_warning("Data: balance.json → grid отсутствует, используются дефолты")
grid = {"folder_cols": 5, "folder_rows": 4, "gap": 6,
"cell_min": 56, "cell_max": 104, "table_cols": 4, "table_rows": 6,
"folder_dims": DEFAULT_GRID_DIMS.duplicate(true), "shapes": {}}
shop_lots = _load_shop_lots(balance.get("shop", null))
shop = shop_lots
rules = _load_rules(balance.get("shop_rules", null))
minigames = _as_dict(balance.get("minigames", null))
if minigames.is_empty():
push_warning("Data: balance.json → minigames отсутствует")
minigames = {"games": {}, "selection": {}}
attacks = _as_dict(balance.get("attacks", null))
payout_rules = _as_dict(balance.get("payout", null))
sla_rules = _as_dict(balance.get("sla", null))
difficulty = _load_difficulty(balance.get("difficulty", null))
shop = _load_shop(balance.get("shop", null))
names = _load_names(load_json("names_ru.json"))
strings = load_json("strings_ru.json")
var progression = balance.get("progression", null)
@@ -267,7 +182,7 @@ func type_def(type: String) -> Dictionary:
return DEFAULT_TYPES["text"].duplicate(true)
## Строка таблицы сложности; для hour > max — последняя строка (legacy).
## Строка таблицы сложности; для hour > max — последняя строка.
func difficulty_for(hour: int) -> Dictionary:
var h := maxi(1, int(hour))
if difficulty.is_empty():
@@ -289,140 +204,6 @@ func difficulty_for(hour: int) -> Dictionary:
return last.duplicate(true)
# --- v2: часы --------------------------------------------------------------
## Строка бюджета часа (files/request_cap/active_requests/attacks/
## income_target/type_weights/request_mix). hour > 9 → последняя строка.
func hour_def(hour: int) -> Dictionary:
var h := maxi(1, int(hour))
if hours.is_empty():
push_warning("Data: balance.json → hours отсутствует, дефолт часа %d" % h)
return _default_hour_row(h)
var prev: Dictionary = hours[0]
for row in hours:
if not (row is Dictionary):
continue
var row_hour := int(row.get("hour", 0))
if row_hour == h:
return row.duplicate(true)
if row_hour < h:
prev = row
elif row_hour > h:
return prev.duplicate(true)
return (hours[hours.size() - 1] as Dictionary).duplicate(true)
func hour_count() -> int:
return maxi(1, hours.size())
# --- v2: магазин -----------------------------------------------------------
func lot_defs() -> Array:
return shop_lots.duplicate(true)
func lot_def(id: String) -> Dictionary:
for lot in shop_lots:
if lot is Dictionary and str(lot.get("id", "")) == str(id):
return lot.duplicate(true)
push_warning("Data: нет лота '%s'" % str(id))
return {}
func shop_rules() -> Dictionary:
return rules.duplicate(true)
# --- v2: мини-игры ---------------------------------------------------------
func minigame_def(id: String) -> Dictionary:
var games = minigames.get("games", null)
if games is Dictionary:
var def = (games as Dictionary).get(str(id), null)
if def is Dictionary:
return (def as Dictionary).duplicate(true)
push_warning("Data: нет мини-игры '%s'" % str(id))
return {}
func minigame_selection() -> Dictionary:
var out: Dictionary = {}
var raw = minigames.get("selection", null)
if raw is Dictionary:
out = (raw as Dictionary).duplicate(true)
if not (out.get("order", null) is Array) or (out.get("order", null) as Array).is_empty():
out["order"] = DEFAULT_SEND_ORDER.duplicate()
if not (out.get("achievable_sums", null) is Array) or (out.get("achievable_sums", null) as Array).is_empty():
out["achievable_sums"] = DEFAULT_ACHIEVABLE_SUMS.duplicate()
out["max_index"] = int(out.get("max_index", 27))
var coeff: Dictionary = {}
for key in ["text", "sound", "photo", "video"]:
coeff[key] = int(type_def(key).get("minigame_coeff", DEFAULT_TYPES[key].get("minigame_coeff", 1)))
out["coeff"] = coeff
return out
## Legacy-ступень для индекса 0..27 (idx/7 + 1).
func minigame_tier(index: int) -> int:
return clampi(1 + int(floor(float(maxi(0, index)) / 7.0)), 1, 4)
# --- v2: атаки -------------------------------------------------------------
func attack_def(id: String) -> Dictionary:
var kinds = attacks.get("kinds", null)
if kinds is Dictionary:
var def = (kinds as Dictionary).get(str(id), null)
if def is Dictionary:
return (def as Dictionary).duplicate(true)
push_warning("Data: нет атаки '%s'" % str(id))
return {}
func attack_tier(hour: int) -> int:
return clampi(1 + int(floor(float(maxi(1, int(hour)) - 2) / 2.0)), 1, 4)
func attack_schedule() -> Array:
var raw = attacks.get("schedule", null)
if raw is Array:
return (raw as Array).duplicate()
return DEFAULT_ATTACKS.duplicate()
# --- v2: сетка -------------------------------------------------------------
func grid_rules() -> Dictionary:
return grid.duplicate(true)
func shape_def(id: String) -> Dictionary:
var shapes = grid.get("shapes", null)
if shapes is Dictionary:
var def = (shapes as Dictionary).get(str(id), null)
if def is Dictionary:
return (def as Dictionary).duplicate(true)
push_warning("Data: нет фигуры '%s'" % str(id))
return {}
func shapes() -> Dictionary:
var raw = grid.get("shapes", null)
if raw is Dictionary:
return (raw as Dictionary).duplicate(true)
return {}
func folder_dims_table() -> Array:
var raw = grid.get("folder_dims", null)
if raw is Array and not (raw as Array).is_empty():
return (raw as Array).duplicate(true)
return DEFAULT_GRID_DIMS.duplicate(true)
# --- legacy shop API -------------------------------------------------------
func shop_item(id: String) -> Dictionary:
for item in shop:
if item is Dictionary and str(item.get("id", "")) == str(id):
@@ -432,6 +213,7 @@ func shop_item(id: String) -> Dictionary:
## Ключ открытия для позиции магазина. "" — позиция доступна всегда.
## shop_sla продаётся вне прогрессии; поиск/щит открываются мета-наградами.
func shop_unlock_key(id: String) -> String:
match str(id):
"shop_sla":
@@ -446,10 +228,7 @@ func shop_unlock_key(id: String) -> String:
## Открыта ли позиция магазина по текущему состоянию Game (run_hour/meta).
func shop_item_unlocked(item: Dictionary) -> bool:
var id := str(item.get("id", ""))
if id.begins_with("lot_"):
return true
var key := shop_unlock_key(id)
var key := shop_unlock_key(str(item.get("id", "")))
if key.is_empty():
return true
var game = _game_node()
@@ -458,7 +237,7 @@ func shop_item_unlocked(item: Dictionary) -> bool:
return true
## Каталог магазина без закрытых позиций.
## Каталог магазина без закрытых позиций (shop_sla виден всегда).
func available_shop() -> Array:
var out: Array = []
for item in shop:
@@ -482,13 +261,6 @@ func folder_name() -> String:
return str(values[randi_range(0, values.size() - 1)])
## Значение из constants с fallback (для других систем).
func const_value(key: String, fallback_value = null):
if constants.has(key):
return constants[key]
return fallback_value
# --- служебное -------------------------------------------------------------
func _game_node():
@@ -498,31 +270,13 @@ func _game_node():
return null
func _as_dict(raw) -> Dictionary:
if raw is Dictionary:
return (raw as Dictionary).duplicate(true)
return {}
func _load_unlocks(progression) -> Dictionary:
if progression is Dictionary:
var raw = progression.get("unlocks", null)
if raw is Dictionary and not raw.is_empty():
return raw.duplicate(true)
push_warning("Data: balance.json → progression.unlocks отсутствует, используются дефолты")
return {
"shop": {"run_hour": 2, "label": "Магазин"},
"search": {"meta": "survive_hour_3", "label": "Поиск"},
"shield": {"meta": "survive_hour_5", "label": "Щит"},
"minigame_text": {"run_hour": 1},
"minigame_sound": {"run_hour": 2},
"minigame_photo": {"run_hour": 3},
"minigame_video": {"run_hour": 4},
"attack_junk": {"run_hour": 2},
"attack_ddos": {"run_hour": 2},
"attack_phishing": {"run_hour": 3},
"attack_ransomware": {"run_hour": 4},
}
return DEFAULT_PROGRESSION["unlocks"].duplicate(true)
func _load_meta_rewards(progression) -> Dictionary:
@@ -531,12 +285,7 @@ func _load_meta_rewards(progression) -> Dictionary:
if raw is Dictionary and not raw.is_empty():
return raw.duplicate(true)
push_warning("Data: balance.json → progression.meta_rewards отсутствует, используются дефолты")
return {
"survive_hour_3": {"min_hours": 3, "unlocks": ["search"]},
"survive_hour_5": {"min_hours": 5, "unlocks": ["shield"]},
"complete_run": {"min_hours": 9, "unlocks": ["minigame_advanced"]},
}
return DEFAULT_PROGRESSION["meta_rewards"].duplicate(true)
func _merge_types(raw) -> Dictionary:
var out: Dictionary = DEFAULT_TYPES.duplicate(true)
@@ -570,83 +319,22 @@ func _load_difficulty(raw) -> Array:
return DEFAULT_DIFFICULTY.duplicate(true)
func _load_hours(raw) -> Array:
if raw is Array and not (raw as Array).is_empty():
var out: Array = []
for row in raw:
if row is Dictionary:
out.append(row.duplicate(true))
if not out.is_empty():
out.sort_custom(func(a, b): return int(a.get("hour", 0)) < int(b.get("hour", 0)))
return out
push_warning("Data: balance.json → hours отсутствует, используются дефолты")
var fallback: Array = []
for i in range(9):
fallback.append(_default_hour_row(i + 1))
return fallback
func _default_hour_row(hour: int) -> Dictionary:
var idx := clampi(hour - 1, 0, 8)
var type_keys := ["text", "sound", "photo", "video"]
var weights: Dictionary = {}
for i in range(type_keys.size()):
weights[type_keys[i]] = DEFAULT_TYPE_WEIGHTS[idx][i]
return {
"hour": maxi(1, hour),
"files": DEFAULT_HOUR_FILES[idx],
"request_cap": DEFAULT_REQUEST_CAP[idx],
"active_requests": DEFAULT_ACTIVE_REQUESTS[idx],
"attacks": DEFAULT_ATTACKS[idx],
"income_target": DEFAULT_INCOME_TARGET[idx],
"recv_interval": DEFAULT_RECV_INTERVAL[idx],
"send_interval": DEFAULT_SEND_INTERVAL[idx],
"type_weights": weights,
"request_mix": {
"1": DEFAULT_REQUEST_MIX[idx][0],
"2": DEFAULT_REQUEST_MIX[idx][1],
"3": DEFAULT_REQUEST_MIX[idx][2],
},
}
func _load_shop_lots(raw) -> Array:
if raw is Array and not (raw as Array).is_empty():
func _load_shop(raw) -> Array:
if raw is Array and not raw.is_empty():
var out: Array = []
for item in raw:
if item is Dictionary and not str(item.get("id", "")).is_empty():
out.append(item.duplicate(true))
if not out.is_empty():
return out
push_warning("Data: balance.json → shop отсутствует, используются 8 дефолтных лотов")
var fallback: Array = []
for i in range(DEFAULT_LOT_IDS.size()):
fallback.append({
"id": DEFAULT_LOT_IDS[i],
"name_ru": DEFAULT_LOT_NAMES[i],
"desc_ru": "",
"effect": DEFAULT_LOT_EFFECTS[i],
"value": DEFAULT_LOT_VALUES[i],
"max_level": DEFAULT_LOT_MAX[i],
"prices": DEFAULT_LOT_PRICES[i].duplicate(),
"level_names": [],
"weight_by_hour": DEFAULT_LOT_WEIGHTS[i].duplicate(),
})
return fallback
func _load_rules(raw) -> Dictionary:
var out: Dictionary = DEFAULT_SHOP_RULES.duplicate(true)
if raw is Dictionary and not raw.is_empty():
for key in raw.keys():
out[str(key)] = raw[key]
return out
push_warning("Data: balance.json → shop_rules отсутствует, используются дефолты")
return out
push_warning("Data: balance.json → shop отсутствует, используется дефолт")
return DEFAULT_SHOP.duplicate(true)
func _load_names(raw) -> Dictionary:
if raw is Dictionary and not raw.is_empty():
# Сохраняем все ключи файла (adjectives/nouns/organizations/folders/...),
# а не только жёстко заданный набор.
return raw.duplicate(true)
push_warning("Data: names_ru.json пуст, используются пустые пулы имён")
return {
+28 -331
View File
@@ -1,10 +1,6 @@
extends Node
## Game — верхнеуровневое состояние забега (autoload).
##
## v2-экономика: старт 50 $, SLA 60/60, 1 слот, сетка 5×4, канал 10,
## буфер 6, таймер запроса 30 с. Уровни лотов хранятся в Game.upgrades
## одновременно под id лота и под агрегированным ключом эффекта.
##
## Не знает о сценах и UI: только данные, переходы фаз и сигналы.
## Фазы: menu → prep → hour → ... → results.
@@ -15,41 +11,26 @@ signal phase_changed(p)
signal run_ended(reason)
signal toast(text)
const START_MONEY := 50
const START_SLA := 60.0
const START_MONEY := 150
const START_SLA := 128.0
const HOURS_PER_RUN := 9
## Жёсткий кап SLA (main.gd показывает его в статус-баре). Рабочий кап —
## sla_cap(): 60 + 35 * sla_level, не выше MAX_SLA.
const MAX_SLA := 200.0
const REGISTRY_SCRIPT := preload("res://scripts/core/file_registry.gd")
const SAVE_SCRIPT := preload("res://scripts/systems/save_system.gd")
const SHOP_SCRIPT := preload("res://scripts/systems/shop.gd")
## effect → агрегированный ключ уровня в Game.upgrades.
const LOT_EFFECT_KEYS := {
"connection_slots": "slot_level",
"transfer_speed": "speed_level",
"sla_cap": "sla_level",
"shield_charge": "shield_level",
"money_mult": "money_level",
"folder_cells": "folder_level",
"channel_capacity": "channel_level",
"request_timer": "timer_level",
}
const FALLBACK_LOT_DIMS := [[5, 4], [6, 4], [6, 5], [7, 5], [7, 6], [8, 6]]
## Резерв, если Data ещё не готов (id → лот минимально необходимых полей).
## Резерв, если Data ещё не готов (id → товар).
const FALLBACK_SHOP := {
"lot_slots": {"id": "lot_slots", "name_ru": "Порты коммутатора", "effect": "connection_slots", "max_level": 4, "prices": [120, 300, 650, 1200]},
"lot_speed": {"id": "lot_speed", "name_ru": "Оптоволокно", "effect": "transfer_speed", "max_level": 6, "prices": [90, 130, 180, 250, 340, 450]},
"lot_sla": {"id": "lot_sla", "name_ru": "Резервный ИБП", "effect": "sla_cap", "max_level": 4, "prices": [120, 260, 480, 800]},
"lot_shield": {"id": "lot_shield", "name_ru": "Заземление", "effect": "shield_charge", "max_level": 3, "prices": [150, 400, 800]},
"lot_money": {"id": "lot_money", "name_ru": "Корпоративный контракт", "effect": "money_mult", "max_level": 8, "prices": [80, 110, 150, 200, 260, 340, 440, 560]},
"lot_folder": {"id": "lot_folder", "name_ru": "Складской стеллаж", "effect": "folder_cells", "max_level": 5, "prices": [80, 180, 360, 650, 1000]},
"lot_channel": {"id": "lot_channel", "name_ru": "Пропускная способность", "effect": "channel_capacity", "max_level": 6, "prices": [70, 100, 140, 190, 250, 330]},
"lot_timer": {"id": "lot_timer", "name_ru": "Регламент обработки", "effect": "request_timer", "max_level": 5, "prices": [70, 110, 160, 230, 320]},
"shop_slot3": {"id": "shop_slot3", "name_ru": "Третий слот", "price": 250, "max_level": 1},
"shop_slot4": {"id": "shop_slot4", "name_ru": "Четвёртый слот", "price": 450, "max_level": 1},
"shop_buffer": {"id": "shop_buffer", "name_ru": "Буфер +2", "price": 200, "max_level": 2},
"shop_timer": {"id": "shop_timer", "name_ru": "Таймер пакетов +4 с", "price": 350, "max_level": 3},
"shop_zone": {"id": "shop_zone", "name_ru": "Зона +5%", "price": 300, "max_level": 3},
"shop_channel": {"id": "shop_channel", "name_ru": "Канал +25", "price": 250, "max_level": 4},
"shop_section": {"id": "shop_section", "name_ru": "Новая секция", "price": 300, "max_level": 5},
"shop_search": {"id": "shop_search", "name_ru": "Поиск", "price": 250, "max_level": 1},
"shop_shield": {"id": "shop_shield", "name_ru": "Щит", "price": 400, "max_level": 1},
"shop_sla": {"id": "shop_sla", "name_ru": "SLA +24", "price": 300, "max_level": 3},
}
var money: int = START_MONEY
@@ -63,19 +44,6 @@ var upgrades: Dictionary = {}
var meta_unlocks: Dictionary = {}
## Награды, выданные в текущем забеге (для итогового экрана); чистится новым забегом.
var run_meta_awards: Array[String] = []
## v2-магазин: 3 id, счётчик рероллов, час генерации, pity-история и замки.
var shop_offers: Array[String] = []
var shop_rerolls: int = 0
var shop_offer_hour: int = 0
var shop_last_seen: Dictionary = {}
var shop_locked: Array[String] = []
## Заряды «Заземления»: восстанавливаются в начале часа.
var shield_charges: int = 0
## Час, за который SLA-реген уже выдан (идемпотентность вызова).
var _sla_regen_hour: int = 0
## FileRegistry; без type hint, чтобы работать без прогретого class cache.
var registry = null
var rng_seed: int = 0
@@ -110,8 +78,6 @@ func start_hour() -> bool:
return false
if registry == null:
registry = _make_registry()
regen_sla_for_hour()
regen_shield_for_hour()
phase = &"hour"
phase_changed.emit(phase)
toast.emit(_tr("msg.hour_start", "Час %d" % hour))
@@ -133,9 +99,6 @@ func end_hour() -> bool:
var finished := hour
hour += 1
phase = &"prep"
# Рероллы живут одну подготовку.
shop_rerolls = 0
shop_locked.clear()
hour_changed.emit(hour)
phase_changed.emit(phase)
toast.emit(_tr("msg.hour_end", "Час %d завершён" % finished))
@@ -165,7 +128,7 @@ func add_money(delta: int, reason: String = "") -> void:
func add_sla(delta: float, reason: String = "") -> void:
sla = clampf(sla + float(delta), 0.0, sla_cap())
sla = clampf(sla + float(delta), 0.0, MAX_SLA)
if not reason.is_empty():
stats["last_reason"] = reason
sla_changed.emit(sla)
@@ -173,144 +136,10 @@ func add_sla(delta: float, reason: String = "") -> void:
_finish_run("sla")
## +15 SLA в начале часа (не выше динамического капа). Возвращает новый SLA.
## Идемпотентно в пределах одного часа: повторный вызов не добавляет SLA.
func regen_sla_for_hour() -> float:
if _sla_regen_hour == hour:
return sla
_sla_regen_hour = hour
var regen := _const("sla_regen_per_hour", 15.0)
sla = clampf(sla + regen, 0.0, sla_cap())
sla_changed.emit(sla)
return sla
## Заряды щита восстанавливаются до максимума в начале часа.
func regen_shield_for_hour() -> int:
shield_charges = shield_max()
return shield_charges
func add_stat(key: String, delta: int = 1) -> void:
stats[key] = int(stats.get(key, 0)) + int(delta)
# --- v2: уровни лотов и производные статы ----------------------------------
## Уровень по id лота (lot_*) или по эффекту/агрегату
## (connection_slots / slot_level / ...).
func level(effect_or_lot: String) -> int:
var key := str(effect_or_lot)
if key.is_empty():
return 0
if key.begins_with("lot_"):
if upgrades.has(key):
return int(upgrades[key])
var def := _lot_def(key)
var effect := str(def.get("effect", ""))
var agg := str(LOT_EFFECT_KEYS.get(effect, ""))
if not agg.is_empty():
return int(upgrades.get(agg, 0))
return 0
if upgrades.has(key):
return int(upgrades[key])
if LOT_EFFECT_KEYS.has(key):
return int(upgrades.get(str(LOT_EFFECT_KEYS[key]), 0))
return 0
## Записать уровень лота в оба ключа и сразу применить мгновенные эффекты
## (SLA-кап/щит). Остальные статы читаются геттерами динамически.
func set_lot_level(id: String, new_level: int) -> int:
var def := _lot_def(id)
if def.is_empty():
return 0
if not (upgrades is Dictionary):
upgrades = {}
var max_level := maxi(1, int(def.get("max_level", 1)))
var old_level := level(id)
var lvl := clampi(int(new_level), 0, max_level)
upgrades[id] = lvl
var effect := str(def.get("effect", ""))
var agg := str(LOT_EFFECT_KEYS.get(effect, ""))
if not agg.is_empty():
upgrades[agg] = lvl
if effect == "sla_cap" and lvl > old_level:
# Кап уже вырос (sla_cap() читает новый уровень), добавляем +35 сразу.
var gain := _lot_value(def, 35.0) * float(lvl - old_level)
sla = clampf(sla + gain, 0.0, sla_cap())
sla_changed.emit(sla)
elif effect == "shield_charge" and lvl > old_level:
shield_charges = mini(shield_max(), shield_charges + (lvl - old_level))
if effect == "folder_cells" and lvl != old_level and registry != null \
and registry.has_method("resize_folders"):
# Лот растит уже существующие папки, а не только будущие.
registry.resize_folders(folder_dims())
return lvl
func sla_cap() -> float:
var base := _const("sla_base", START_SLA)
var step := _const("sla_per_level", 35.0)
var hard := _const("sla_hard_cap", MAX_SLA)
return clampf(base + step * float(level("sla_level")), base, hard)
func money_mult() -> float:
var step := _const("money_mult_step", 0.05)
return 1.0 + step * float(level("money_level"))
## pow(0.92, speed_level) — множитель времени передачи.
func speed_mult() -> float:
return pow(0.92, float(maxi(0, level("speed_level"))))
func folder_dims() -> Vector2i:
var table := _folder_dims_table()
if table.is_empty():
table = FALLBACK_LOT_DIMS
var idx := clampi(level("folder_level"), 0, table.size() - 1)
var pair = table[idx]
if pair is Array and (pair as Array).size() >= 2:
return Vector2i(int(pair[0]), int(pair[1]))
return Vector2i(5, 4)
func connection_slots() -> int:
var base := int(_const("slots_start", 1.0))
var cap := int(_const("slots_max", 5.0))
return clampi(base + level("slot_level"), base, cap)
func channel_capacity() -> float:
var base := _const("channel_capacity", 10.0)
var step := _const("channel_per_level", 6.0)
return base + step * float(level("channel_level"))
func buffer_limit() -> int:
var base := int(_const("buffer_base", 6.0))
var step := int(_const("buffer_per_level", 1.0))
var cap := int(_const("buffer_max", 12.0))
return clampi(base + step * level("channel_level"), base, cap)
func request_timer() -> float:
var base := _const("request_timer", 30.0)
var step := _const("request_timer_per_level", 5.0)
var cap := _const("request_timer_max", 55.0)
return clampf(base + step * float(level("timer_level")), base, cap)
func packet_timer() -> float:
return _const("packet_timer", 20.0)
func shield_max() -> int:
return maxi(0, level("shield_level"))
# --- прогрессия/открытия ---------------------------------------------------
## Открыта ли фича по data/balance.json → progression.unlocks.
@@ -361,23 +190,23 @@ func award_meta_for_run(hours: int) -> Array[String]:
return awarded
# --- магазин (legacy-фасад) ------------------------------------------------
# --- магазин (заглушка эффектов) ------------------------------------------
## Покупка по id лота (legacy-путь; UI v2 использует Shop.buy).
## Покупка проверяет деньги и max_level, но не применяет эффект (P1).
func buy_upgrade(id: String) -> bool:
var item := _shop_item(id)
if item.is_empty():
return false
var lvl := level(id)
var level := int(upgrades.get(id, 0))
var max_level := int(item.get("max_level", 1))
if max_level > 0 and lvl >= max_level:
if max_level > 0 and level >= max_level:
return false
var cost := _item_cost(item, lvl)
var cost := _item_cost(item, level)
if money < cost:
toast.emit(_tr("msg.money_short", "Недостаточно денег"))
return false
money -= cost
set_lot_level(id, lvl + 1)
upgrades[id] = level + 1
money_changed.emit(money)
toast.emit(_tr("msg.upgrade_bought", "Куплено: %s") % _item_name(item, id))
return true
@@ -405,13 +234,6 @@ func save_game() -> bool:
"rng_seed": rng_seed,
"rng_state": rng_state,
"registry": registry.to_dict(),
"shop_offers": shop_offers.duplicate(),
"shop_rerolls": shop_rerolls,
"shop_offer_hour": shop_offer_hour,
"shop_last_seen": shop_last_seen.duplicate(true),
"shop_locked": shop_locked.duplicate(),
"shield_charges": shield_charges,
"sla_regen_hour": _sla_regen_hour,
}
return SAVE_SCRIPT.save(payload)
@@ -430,12 +252,12 @@ func load_game() -> bool:
return false
registry = reg
upgrades = _dict_of(data.get("upgrades", {}))
money = maxi(0, int(data.get("money", START_MONEY)))
sla = clampf(float(data.get("sla", START_SLA)), 0.0, MAX_SLA)
hour = clampi(int(data.get("hour", 1)), 1, HOURS_PER_RUN)
phase = StringName(str(data.get("phase", "menu")))
streak = maxi(0, int(data.get("streak", 0)))
sla = clampf(float(data.get("sla", START_SLA)), 0.0, sla_cap())
upgrades = _dict_of(data.get("upgrades", {}))
meta_unlocks = _dict_of(data.get("meta", {}))
run_meta_awards = _string_array_of(data.get("run_meta_awards", []))
stats = _default_stats()
@@ -444,10 +266,6 @@ func load_game() -> bool:
for key in raw_stats.keys():
stats[key] = raw_stats[key]
rng_seed = int(data.get("rng_seed", 0))
shield_charges = clampi(int(data.get("shield_charges", 0)), 0, shield_max())
_sla_regen_hour = int(data.get("sla_regen_hour", 0))
_restore_shop_state(data)
var rng = _rng_node()
if rng != null and rng.has_method("restore_state"):
@@ -469,13 +287,6 @@ func _reset_fields(keep_meta: bool = true) -> void:
streak = 0
stats = _default_stats()
upgrades = {}
shop_offers.clear()
shop_rerolls = 0
shop_offer_hour = 0
shop_last_seen.clear()
shop_locked.clear()
shield_charges = 0
_sla_regen_hour = 0
if not keep_meta:
meta_unlocks = {}
registry = _make_registry()
@@ -511,130 +322,24 @@ func _default_stats() -> Dictionary:
}
## Восстановить офферы/рероллы/last_seen/замки; выкинуть неизвестные и
## максимальные лоты; при необходимости догенерировать до 3.
func _restore_shop_state(data: Dictionary) -> void:
shop_offers.clear()
for raw_id in _string_array_of(data.get("shop_offers", [])):
var id := str(raw_id)
if not id.begins_with("lot_"):
continue
var def := _lot_def(id)
if def.is_empty():
continue
if level(id) >= maxi(1, int(def.get("max_level", 1))):
continue
if shop_offers.has(id):
continue
shop_offers.append(id)
shop_rerolls = clampi(int(data.get("shop_rerolls", 0)), 0, _max_rerolls())
shop_offer_hour = int(data.get("shop_offer_hour", 0))
shop_last_seen = _int_dict_of(data.get("shop_last_seen", {}))
shop_locked.clear()
for raw_id in _string_array_of(data.get("shop_locked", [])):
var id := str(raw_id)
if shop_offers.has(id) and not shop_locked.has(id):
shop_locked.append(id)
if hour <= 1:
return
var target := int(_shop_rules().get("offers", 3))
if shop_offers.size() >= target and shop_offer_hour > 0:
return
var lots := _lot_defs()
if lots.is_empty():
return
var selected := SHOP_SCRIPT.select_offers(
hour, lots, upgrades, money, [], shop_last_seen,
shop_offers.duplicate(), target)
shop_offers.clear()
for id in selected:
shop_offers.append(str(id))
if shop_offer_hour <= 0:
shop_offer_hour = hour
for id in shop_offers:
shop_last_seen[str(id)] = hour
func _lot_def(id: String) -> Dictionary:
var data = _data_node()
if data != null and data.has_method("lot_def"):
var def = data.lot_def(id)
if def is Dictionary and not (def as Dictionary).is_empty():
return def
var fallback = FALLBACK_SHOP.get(str(id), null)
if fallback is Dictionary:
return (fallback as Dictionary).duplicate(true)
return {}
func _lot_defs() -> Array:
var data = _data_node()
if data != null and data.has_method("lot_defs"):
var lots = data.lot_defs()
if lots is Array and not (lots as Array).is_empty():
return lots
var out: Array = []
for id in FALLBACK_SHOP.keys():
out.append((FALLBACK_SHOP[id] as Dictionary).duplicate(true))
return out
func _shop_rules() -> Dictionary:
var data = _data_node()
if data != null and data.has_method("shop_rules"):
var rules = data.shop_rules()
if rules is Dictionary and not (rules as Dictionary).is_empty():
return rules
return {"offers": 3, "reroll_costs": [40, 80, 120], "max_rerolls": 3}
func _max_rerolls() -> int:
return maxi(0, int(_shop_rules().get("max_rerolls", 3)))
func _folder_dims_table() -> Array:
var data = _data_node()
if data != null and data.has_method("folder_dims_table"):
var table = data.folder_dims_table()
if table is Array and not (table as Array).is_empty():
return table
return FALLBACK_LOT_DIMS.duplicate(true)
func _const(key: String, fallback_value: float) -> float:
var data = _data_node()
if data != null and data.get("constants") is Dictionary:
var consts: Dictionary = data.constants
if consts.has(key):
return float(consts[key])
return float(fallback_value)
func _lot_value(def: Dictionary, fallback_value: float) -> float:
if def.has("value"):
return float(def.get("value"))
return float(fallback_value)
func _shop_item(id: String) -> Dictionary:
var data = _data_node()
if data != null and data.has_method("shop_item"):
var item = data.shop_item(id)
if item is Dictionary and not (item as Dictionary).is_empty():
if item is Dictionary and not item.is_empty():
return item
var fallback = FALLBACK_SHOP.get(id, null)
if fallback is Dictionary:
return (fallback as Dictionary).duplicate(true)
return fallback.duplicate(true)
return {}
func _item_cost(item: Dictionary, lvl: int) -> int:
func _item_cost(item: Dictionary, level: int) -> int:
var prices = item.get("prices", null)
if prices is Array and not (prices as Array).is_empty():
if prices is Array and not prices.is_empty():
var arr: Array = prices
if lvl < arr.size():
return maxi(0, int(arr[lvl]))
if level < arr.size():
return maxi(0, int(arr[level]))
return maxi(0, int(arr[arr.size() - 1]))
if item.has("price"):
return maxi(0, int(item.get("price", 0)))
@@ -682,14 +387,6 @@ func _dict_of(value) -> Dictionary:
return {}
func _int_dict_of(value) -> Dictionary:
var out: Dictionary = {}
if value is Dictionary:
for key in value.keys():
out[str(key)] = int(value[key])
return out
func _string_array_of(value) -> Array[String]:
var out: Array[String] = []
if value is Array:
+54 -132
View File
@@ -1,55 +1,48 @@
class_name Economy
extends RefCounted
## Чистая экономика и тайминги v2. Все методы статические, без состояния
## Чистая экономика и тайминги (D12). Все методы статические, без состояния
## и без обращений к сценам — удобно тестировать headless.
##
## ВАЖНО: только float-арифметика в формулах выплаты (никаких int/int) и
## только pow() для степеней (никакого ^).
## Канал: load = Σ weight(активных передач). Фон и занятые слоты не грузят.
## Фон/слоты НЕ входят в load (уточнение п.11): канал грузят только
## активные передачи. Константы оставлены нулевыми для совместимости.
const CHANNEL_BG_BASE := 0.0
const CHANNEL_BG_PER_HOUR := 0.0
const SLOT_LOAD := 0.0
const CHANNEL_CAPACITY_BASE := 10.0
const CHANNEL_CAPACITY_STEP := 6.0
const CHANNEL_CAPACITY_BASE := 80.0
const CHANNEL_CAPACITY_STEP := 25.0
const PACKET_TIMER_BASE := 20.0
const REQUEST_TIMER_BASE := 30.0
const REQUEST_TIMER_STEP := 5.0
const REQUEST_TIMER_MAX := 55.0
const TIMER_PER_HOUR := 0.5
const TIMER_UPGRADE_STEP := 4.0
const PACKET_TIMER_MIN := 12.0
const REQUEST_TIMER_MIN := 20.0
const TIMER_MAX := 999.0
const BUFFER_BASE := 6
const BUFFER_PER_LEVEL := 1
const BUFFER_MAX := 12
## Grace-множитель первых часов (п.10): больше времени на старте.
## h1 1.5, h2 1.35, h3 1.2, h4+ 1.0 — для таймера пакетов.
const TIMER_GRACE := {1: 1.5, 2: 1.35, 3: 1.2}
## Множитель времени передачи за уровень «Оптоволокна».
const SPEED_STEP := 0.92
## Отдельный grace для таймера запросов: в начале игроку нужно больше времени,
## чтобы собрать файлы. h1 2.0, h2 1.7, h3 1.4, h4+ 1.0.
## h1: 30×2.0 = 60, h2: 29.5×1.7 = 50.15, h3: 29×1.4 = 40.6.
const REQUEST_TIMER_GRACE := {1: 2.0, 2: 1.7, 3: 1.4}
## Коэффициенты мини-игр: text 1, sound 2, photo 4, video 8.
const MINIGAME_COEFF := {
"text": 1,
"sound": 2,
"photo": 4,
"video": 8,
"text": 1.0,
"sound": 2.0,
"photo": 3.0,
"video": 4.0,
}
const SEND_ORDER := ["dots", "sound", "wires", "text", "video", "photo", "maze"]
## Достижимые суммы коэффициентов максимум для 3 файлов (B4).
## Суммы 15/19/21/22/23 недостижимы, поэтому используется плотный список:
## все 7 игр и все 4 ступени становятся достижимыми.
const ACHIEVABLE_SUMS := [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 20, 24]
const PAYOUT_SPEED_BASE := 0.6
const PAYOUT_SPEED_BONUS := 0.4
const PAYOUT_QUALITY_BASE := 0.5
const PAYOUT_QUALITY_BONUS := 0.5
const PAYOUT_MULTI_STEP := 0.15
const PAYOUT_MULTI_STEP := 0.1
const PAYOUT_TCP_MULT := 1.15
const PAYOUT_UDP_TIME_MULT := 0.8
const PAYOUT_STREAK_STEP := 0.02
const PAYOUT_STREAK_CAP := 10
const PAYOUT_MONEY_STEP := 0.05
# --- канал -----------------------------------------------------------------
@@ -65,7 +58,7 @@ static func channel_load(_hour: int, _used_slots: int, active_weights: Array, _u
static func channel_capacity(upgrades: Dictionary = {}) -> float:
var level := _level(upgrades, ["channel", "channel_level", "channel_lvl", "lot_channel", "channel_capacity"])
var level := _level(upgrades, ["channel", "channel_level", "channel_lvl", "shop_channel", "channel_capacity"])
return CHANNEL_CAPACITY_BASE + CHANNEL_CAPACITY_STEP * float(level)
@@ -82,82 +75,54 @@ static func channel_factor(load: float, capacity_or_upgrades = null) -> float:
return maxf(1.0, maxf(0.0, load) / capacity)
static func buffer_limit(upgrades: Dictionary = {}) -> int:
var level := _level(upgrades, ["channel", "channel_level", "buffer", "buffer_level", "lot_channel"])
return clampi(BUFFER_BASE + BUFFER_PER_LEVEL * level, BUFFER_BASE, BUFFER_MAX)
## Базовое время приёма/отправки умножается на фактор канала.
static func recv_time(type_def: Dictionary, factor: float, _upgrades: Dictionary = {}) -> float:
return maxf(0.0, float(type_def.get("recv_time", 0.0))) * maxf(0.0, factor)
## pow(0.92, speed_level): 0 → 1.0, 6 → ≈0.606.
static func speed_mult(speed_level: int) -> float:
return pow(SPEED_STEP, float(maxi(0, int(speed_level))))
## Базовое время типа: v2-поле base_time_sec с fallback на legacy.
static func type_base_time(type_def: Dictionary) -> float:
if type_def.has("base_time_sec"):
return maxf(0.0, float(type_def.get("base_time_sec", 0.0)))
if type_def.has("recv_time"):
return maxf(0.0, float(type_def.get("recv_time", 0.0)))
return 0.0
## Время приёма: base_time_sec × pow(0.92, speed_level) × channel_factor.
## У приёма нет протокольного множителя.
static func recv_transfer_time(type_def: Dictionary, channel_factor_value: float = 1.0, speed_level: int = 0) -> float:
return type_base_time(type_def) * speed_mult(speed_level) * maxf(0.0, channel_factor_value)
## Время отправки: то же + UDP ×0.8 (TCP ×1.0).
static func send_transfer_time(type_def: Dictionary, channel_factor_value: float = 1.0, speed_level: int = 0, protocol = "tcp") -> float:
var mult := 1.0
if str(protocol).to_lower() == "udp":
mult = PAYOUT_UDP_TIME_MULT
return type_base_time(type_def) * speed_mult(speed_level) * maxf(0.0, channel_factor_value) * mult
## Legacy-обёртки: factor — channel_factor, upgrades даёт speed_level.
static func recv_time(type_def: Dictionary, factor: float, upgrades: Dictionary = {}) -> float:
var level := _level(upgrades, ["speed", "speed_level", "transfer_speed", "lot_speed"])
return recv_transfer_time(type_def, factor, level)
static func send_time(type_def: Dictionary, factor: float, upgrades: Dictionary = {}) -> float:
var level := _level(upgrades, ["speed", "speed_level", "transfer_speed", "lot_speed"])
return send_transfer_time(type_def, factor, level, "tcp")
static func send_time(type_def: Dictionary, factor: float, _upgrades: Dictionary = {}) -> float:
return maxf(0.0, float(type_def.get("send_time", 0.0))) * maxf(0.0, factor)
# --- таймеры ---------------------------------------------------------------
## v2: таймер пакета фиксирован — 20 с, не покупается и не зависит от часа.
static func packet_timer(_hour: int = 1, _upgrades: Dictionary = {}) -> float:
return PACKET_TIMER_BASE
## Формула (п.10): (base − 0.5*(h−1)) * grace(h) + 4*level.
## Grace умножает только базовую часть; апгрейд таймера даёт чистые +4 с.
static func packet_timer(hour: int, upgrades: Dictionary = {}) -> float:
var h := maxi(1, hour)
var level := _level(upgrades, ["timer", "timer_level", "packet_timer", "packet_timer_level", "shop_timer"])
var base := PACKET_TIMER_BASE - TIMER_PER_HOUR * float(h - 1)
var value := base * timer_grace(h) + TIMER_UPGRADE_STEP * float(level)
return clampf(value, PACKET_TIMER_MIN, TIMER_MAX)
## v2: 30 + 5 × timer_level, не выше 55 с.
static func request_timer(_hour: int = 1, upgrades: Dictionary = {}) -> float:
var level := _level(upgrades, ["timer", "timer_level", "request_timer", "request_timer_level", "lot_timer"])
var value := REQUEST_TIMER_BASE + REQUEST_TIMER_STEP * float(level)
return clampf(value, REQUEST_TIMER_BASE, REQUEST_TIMER_MAX)
static func request_timer(hour: int, upgrades: Dictionary = {}) -> float:
var h := maxi(1, hour)
var level := _level(upgrades, ["timer", "timer_level", "request_timer", "request_timer_level", "shop_timer"])
var base := REQUEST_TIMER_BASE - TIMER_PER_HOUR * float(h - 1)
var value := base * request_timer_grace(h) + TIMER_UPGRADE_STEP * float(level)
return clampf(value, REQUEST_TIMER_MIN, TIMER_MAX)
## Legacy-хуки: в v2 grace не используется.
static func timer_grace(_hour: int) -> float:
return 1.0
## Grace-множитель таймеров по часу: h1 1.5, h2 1.35, h3 1.2, h4+ 1.0.
static func timer_grace(hour: int) -> float:
return float(TIMER_GRACE.get(maxi(1, hour), 1.0))
static func request_timer_grace(_hour: int) -> float:
return 1.0
## Grace-множитель таймера запросов: h1 2.0, h2 1.7, h3 1.4, h4+ 1.0.
static func request_timer_grace(hour: int) -> float:
return float(REQUEST_TIMER_GRACE.get(maxi(1, hour), 1.0))
# --- мини-игры -------------------------------------------------------------
## Средний коэффициент сложности (legacy-эвристика).
## Средний коэффициент сложности. Пустой/неизвестный тип трактуем как text.
static func minigame_difficulty(types: Array) -> float:
if types.is_empty():
return 1.0
var total := 0.0
for type in types:
total += float(MINIGAME_COEFF.get(str(type), 1))
total += float(MINIGAME_COEFF.get(str(type), 1.0))
return total / float(types.size())
@@ -172,44 +137,8 @@ static func minigame_type(types: Array) -> String:
return "video"
## Выбор мини-игры отправки по сумме коэффициентов (B4).
##
## rank — позиция суммы S в плотном списке достижимых сумм (для
## недостижимой S — позиция ближайшей меньшей достижимой).
## tier = clamp(1 + floor(rank * 4.0 / count), 1, 4) — все 4 ступени
## достижимы. game = order[rank % 7] — все 7 игр достижимы.
##
## Примечание: из-за плотного списка S=24 даёт rank 18 → "video"/T4
## (а не "wires", как в старой формуле idx = S-1).
static func send_minigame_for(types: Array) -> Dictionary:
var total := 0
for type in types:
total += int(MINIGAME_COEFF.get(str(type), 1))
var count := ACHIEVABLE_SUMS.size()
var rank := 0
for i in range(count):
if int(ACHIEVABLE_SUMS[i]) <= total:
rank = i
else:
break
var tier := clampi(1 + int(floor(float(rank) * 4.0 / float(count))), 1, 4)
return {
"id": str(SEND_ORDER[rank % SEND_ORDER.size()]),
"tier": tier,
"index": rank,
"sum": total,
}
## Ступень атаки: H2-3 → T1, H4-5 → T2, H6-7 → T3, H8-9 → T4.
static func attack_tier(hour: int) -> int:
return clampi(1 + int(floor(float(maxi(1, int(hour)) - 2) / 2.0)), 1, 4)
# --- выплата ---------------------------------------------------------------
## payout = round(base_sum × speed × quality × multi × protocol × streak ×
## money_mult). money_level берётся из upgrades.
static func payout(
base_sum: float,
n: int,
@@ -218,29 +147,22 @@ static func payout(
mini_quality: float,
protocol,
streak: int,
upgrades: Dictionary = {}
_upgrades: Dictionary = {}
) -> int:
if n <= 0:
return 0
var time_ratio := 1.0
if timer_total > 0.0:
time_ratio = clampf(float(time_left) / float(timer_total), 0.0, 1.0)
time_ratio = clampf(time_left / timer_total, 0.0, 1.0)
var speed := PAYOUT_SPEED_BASE + PAYOUT_SPEED_BONUS * time_ratio
var quality := PAYOUT_QUALITY_BASE + PAYOUT_QUALITY_BONUS * clampf(float(mini_quality), 0.0, 1.0)
var quality := PAYOUT_QUALITY_BASE + PAYOUT_QUALITY_BONUS * clampf(mini_quality, 0.0, 1.0)
var multi := 1.0 + PAYOUT_MULTI_STEP * float(n - 1)
var protocol_mult := PAYOUT_TCP_MULT if str(protocol).to_lower() == "tcp" else 1.0
var streak_mult := 1.0 + PAYOUT_STREAK_STEP * float(clampi(streak, 0, PAYOUT_STREAK_CAP))
var money_level := _level(upgrades, ["money", "money_level", "money_mult", "lot_money"])
var money_mult := 1.0 + PAYOUT_MONEY_STEP * float(money_level)
var value := maxf(0.0, float(base_sum)) * speed * quality * multi * protocol_mult * streak_mult * money_mult
var value := maxf(0.0, base_sum) * speed * quality * multi * protocol_mult * streak_mult
return int(round(value))
static func money_mult(upgrades: Dictionary = {}) -> float:
var level := _level(upgrades, ["money", "money_level", "money_mult", "lot_money"])
return 1.0 + PAYOUT_MONEY_STEP * float(level)
## Итоговый счёт: часы × 1000 + заработанные деньги + SLA × 10.
static func score(hours: int, money_earned_total: int, sla: float) -> int:
var safe_hours := maxi(0, hours)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -1,6 +1,6 @@
class_name SlotManager
extends RefCounted
## Слоты соединений v2. Всего 1 + slot_level, но не больше 5.
## Слоты соединений. Всего 2 + slot_level, но не больше 4 (D13/D16).
##
## Слоты общие для приёма и отправки; направление хранится для UI и канала.
## manager не тикает сам: прогресс обновляет HourDirector.
@@ -8,8 +8,8 @@ extends RefCounted
signal changed
const SLOT_SCRIPT := preload("res://scripts/entities/slot.gd")
const BASE_SLOTS := 1
const MAX_SLOTS := 5
const BASE_SLOTS := 2
const MAX_SLOTS := 4
var slots: Array = []
var _fallback_level: int = 0
@@ -192,11 +192,11 @@ func _slot_level() -> int:
if game == null:
return _fallback_level
var upgrades: Dictionary = game.upgrades if game.upgrades is Dictionary else {}
# v2: агрегированный уровень, который пишет Shop/Game (lot_slots → slot_level).
for key in ["slot_level", "connection_slots", "lot_slots"]:
# Сначала агрегированный уровень, который пишет Shop.
for key in ["slot_level", "connection_slots", "slots"]:
if upgrades.has(key):
return int(upgrades[key])
# Совместимость с v1-покупками по id (если такие остались в старом сейве).
# Совместимость с покупками по id (Game.buy_upgrade старого образца).
var level := 0
for key in ["shop_slot3", "shop_slot4"]:
level += int(upgrades.get(key, 0))
+3 -58
View File
@@ -6,10 +6,6 @@ extends RefCounted
## `attached_request` — к какому запросу прикреплён, `lock_until` /
## `cooldown_until` — таймеры (unix-время).
##
## v2: файл — полимино на сетке папки. `shape_id` + `rotation` задают форму,
## `origin` — верхняя левая клетка bounding box, `shape_cells` — производное
## поле (пересчитывается из shape_id/rotation, в save не пишется).
##
## ВАЖНО: проект запускается без прогретого global class cache, поэтому
## между своими скриптами используем `preload`, а не имя класса. Внутри
## этого файла допустим `new()` (не `FileItem.new()`).
@@ -17,7 +13,6 @@ extends RefCounted
const LOCATIONS: Array[String] = [
"registry", # получен, ещё не разложен по папке
"folder", # лежит в папке
"table", # лежит на столе сортировки (не в папке, нельзя attach)
"attached", # прикреплён к запросу (остаётся в папке)
"locked", # заблокирован атакой/шифровальщиком
"malicious", # вредоносный, можно уничтожить
@@ -37,15 +32,6 @@ var cooldown_until: float = 0.0
var size_kb: int = 0
var created_at: float = 0.0
## Форма на сетке (v2).
var shape_id: String = "O1"
## Производное от shape_id/rotation; в save не входит.
var shape_cells: Array = []
## Верхняя левая клетка bounding box в сетке папки/стола.
var origin: Vector2i = Vector2i.ZERO
## 0..3, по часовой стрелке.
var rotation: int = 0
## Куда файл вернётся после снятия lock (служебное поле, в save пишется).
var _location_before_lock: StringName = &"folder"
@@ -90,21 +76,8 @@ func can_attach(now: float = -1.0) -> bool:
return is_available(now)
func is_on_table() -> bool:
return location == &"table"
func cell_count() -> int:
if not shape_cells.is_empty():
return shape_cells.size()
return 1
## Позиция не входит в метаданные save v2 (единый источник — folder.files /
## table), поэтому по умолчанию include_placement=true нужен только для
## прямого roundtrip FileItem.
func to_dict(include_placement: bool = true) -> Dictionary:
var out := {
func to_dict() -> Dictionary:
return {
"id": id,
"name": name,
"ext": ext,
@@ -118,12 +91,7 @@ func to_dict(include_placement: bool = true) -> Dictionary:
"size_kb": size_kb,
"created_at": created_at,
"location_before_lock": String(_location_before_lock),
"shape_id": shape_id,
}
if include_placement:
out["origin"] = [origin.x, origin.y]
out["rotation"] = rotation
return out
static func from_dict(d: Dictionary):
@@ -144,35 +112,12 @@ static func from_dict(d: Dictionary):
f.size_kb = int(d.get("size_kb", 0))
f.created_at = float(d.get("created_at", 0.0))
var prev := StringName(str(d.get("location_before_lock", "folder")))
if prev != &"registry" and prev != &"folder" and prev != &"table":
if prev != &"registry":
prev = &"folder"
f._location_before_lock = prev
f.shape_id = str(d.get("shape_id", "O1"))
if f.shape_id.is_empty():
f.shape_id = "O1"
f.rotation = posmod(int(d.get("rotation", 0)), 4)
f.origin = _vec_from(d.get("origin", [0, 0]))
var raw_cells = d.get("shape_cells", [])
if raw_cells is Array:
for cell in raw_cells:
f.shape_cells.append(_vec_from(cell))
# Инварианты: attached без запроса и failed/malicious не бывают прикреплены.
if f.location == &"attached" and f.attached_request.is_empty():
f.location = &"folder" if not f.folder_id.is_empty() else &"registry"
if f.location == &"failed" or f.location == &"malicious":
f.attached_request = ""
if f.location == &"table":
f.attached_request = ""
return f
static func _vec_from(value) -> Vector2i:
if value is Vector2i:
return value
if value is Vector2:
return Vector2i(int(value.x), int(value.y))
if value is Array and value.size() >= 2:
return Vector2i(int(value[0]), int(value[1]))
if value is Dictionary:
return Vector2i(int(value.get("x", 0)), int(value.get("y", 0)))
return Vector2i.ZERO
+8 -81
View File
@@ -1,86 +1,26 @@
class_name Folder
extends RefCounted
## Папка секции. Хранит id файлов; сами файлы — в FileRegistry.
## Папка секции. Хранит только id файлов; сами файлы — в FileRegistry.
##
## v2: папка — клеточная сетка `cols` x `rows`. `cells` — служебная карта
## Vector2i -> owner_id (id файла или id пакета для резервации). В save
## `cells` не пишется: позиции восстанавливаются из placements в FileRegistry.
##
## `reserved` — резервации пакетов: packet_id -> {shape_id, origin, rotation}.
## Клетки резервации тоже лежат в `cells`, поэтому cell_occupied() возвращает
## true и для файлов, и для резерваций.
## `reserved` — счётчик слотов, зарезервированных под ещё не завершённые
## загрузки. В save не пишется: после перезапуска незавершённых загрузок нет.
var id: String = ""
var section: String = ""
var name: String = ""
var cols: int = 5
var rows: int = 4
var file_ids: Array[String] = []
## Служебное: резервации приёма (в save пишутся отдельным списком).
var reserved: Dictionary = {}
## Служебное: занятость клеток (не сохраняется).
var cells: Dictionary = {}
## Legacy-совместимость со старым reserve_slot()/release_slot() (v1 API).
var legacy_slots: int = 0
## Служебное: зарезервированные слоты (в файл сохранения не попадает).
var reserved: int = 0
func count() -> int:
return file_ids.size()
func total_cells() -> int:
return maxi(0, cols) * maxi(0, rows)
func cell_occupied(c: int, r: int) -> bool:
return cells.has(Vector2i(c, r))
func owner_at(c: int, r: int) -> String:
return str(cells.get(Vector2i(c, r), ""))
func is_reserved_cell(c: int, r: int) -> bool:
var owner := owner_at(c, r)
return not owner.is_empty() and reserved.has(owner)
func has_free_cell() -> bool:
return cells.size() < total_cells()
func occupied_cell_count() -> int:
return cells.size()
func free_cell_count() -> int:
return maxi(0, total_cells() - cells.size())
func reserved_count() -> int:
return reserved.size()
## Legacy: раньше ёмкость измерялась слотами (16 файлов).
## Свободно ли место с учётом резерваций.
func has_free_slot(capacity: int = 16) -> bool:
return count() + reserved.size() + legacy_slots < capacity
## Служебный API для FileRegistry: занять клетку владельцем.
func set_cell(cell: Vector2i, owner_id: String) -> void:
cells[cell] = owner_id
## Служебный API: освободить клетки, принадлежащие owner_id.
func clear_owner(owner_id: String) -> void:
for cell in cells.keys():
if str(cells[cell]) == owner_id:
cells.erase(cell)
func clear_cells() -> void:
cells.clear()
return count() + reserved < capacity
func to_dict() -> Dictionary:
@@ -88,8 +28,6 @@ func to_dict() -> Dictionary:
"id": id,
"section": section,
"name": name,
"cols": cols,
"rows": rows,
"file_ids": file_ids.duplicate(),
}
@@ -99,20 +37,9 @@ static func from_dict(d: Dictionary):
f.id = str(d.get("id", ""))
f.section = str(d.get("section", ""))
f.name = str(d.get("name", ""))
f.cols = maxi(0, int(d.get("cols", 0)))
f.rows = maxi(0, int(d.get("rows", 0)))
var raw = d.get("file_ids", [])
if raw is Array:
for value in raw:
f.file_ids.append(str(value))
# v2: placements могут лежать в files — id подтянет FileRegistry.
var placements = d.get("files", [])
if placements is Array:
for entry in placements:
if entry is Dictionary:
var fid := str(entry.get("id", ""))
if not fid.is_empty() and not f.file_ids.has(fid):
f.file_ids.append(fid)
f.reserved = {}
f.cells = {}
f.reserved = 0
return f
-20
View File
@@ -28,12 +28,6 @@ var is_attack: bool = false
var attack_kind: String = ""
var size_kb: int = 0
## Форма, которую пакет займёт в папке. Выбирается при спавне, чтобы показать
## превью ещё до приёма. `shape_cells` — производное (не сохраняется).
var shape_id: String = "O1"
var rotation: int = 0
var shape_cells: Array = []
## Служебные поля (не входят в минимальный контракт, но нужны директору).
var lost_left: float = 0.0
var loading_duration: float = 0.0
@@ -98,8 +92,6 @@ func to_dict() -> Dictionary:
"loading_duration": loading_duration,
"order": order,
"attack_id": attack_id,
"shape_id": shape_id,
"rotation": rotation,
}
@@ -123,18 +115,6 @@ static func from_dict(d: Dictionary):
p.loading_duration = float(d.get("loading_duration", 0.0))
p.order = int(d.get("order", 0))
p.attack_id = str(d.get("attack_id", ""))
p.shape_id = str(d.get("shape_id", "O1"))
if p.shape_id.is_empty():
p.shape_id = "O1"
p.rotation = posmod(int(d.get("rotation", 0)), 4)
p.shape_cells = []
var raw_cells = d.get("shape_cells", [])
if raw_cells is Array:
for cell in raw_cells:
if cell is Vector2i:
p.shape_cells.append(cell)
elif cell is Array and cell.size() >= 2:
p.shape_cells.append(Vector2i(int(cell[0]), int(cell[1])))
return p
-12
View File
@@ -32,12 +32,6 @@ var required_types: Array[String] = []
var send_duration: float = 0.0
var send_base_sum: float = 0.0
## v2: выбор мини-игры отправки. Сумма коэффициентов типов, выбранная игра и
## её ступень. Заполняются HourDirector/Economy, в save опциональны.
var send_sum: int = 0
var minigame_id: String = ""
var minigame_tier: int = 0
## Готов ли запрос к отправке: все required прикреплены, TCP подключён.
func is_ready() -> bool:
@@ -101,9 +95,6 @@ func to_dict() -> Dictionary:
"required_types": required_types.duplicate(),
"send_duration": send_duration,
"send_base_sum": send_base_sum,
"send_sum": send_sum,
"minigame_id": minigame_id,
"minigame_tier": minigame_tier,
}
@@ -130,9 +121,6 @@ static func from_dict(d: Dictionary):
r.required_types = _string_array(d.get("required_types", []))
r.send_duration = float(d.get("send_duration", 0.0))
r.send_base_sum = float(d.get("send_base_sum", 0.0))
r.send_sum = int(d.get("send_sum", 0))
r.minigame_id = str(d.get("minigame_id", ""))
r.minigame_tier = int(d.get("minigame_tier", 0))
return r
+10 -57
View File
@@ -21,9 +21,6 @@ signal shop_changed
signal toast(text: String)
signal show_connection(game)
signal show_minigame(game)
signal minigame_closed(req_id: String)
signal show_attack_minigame(game, kind: String)
signal attack_resolved(kind: String, success: bool, tier: int)
## Дополнительно: проброс изменения слотов (удобно для UI во время часа).
signal slot_changed
@@ -107,12 +104,6 @@ func _enter_prep() -> void:
modal_open = false
if game != null:
game.phase = PHASE_PREP
# Магазин: 3 лота после каждого часа, начиная с подготовки перед H2.
# Во время часа офферы не генерируются и магазин не открывается.
if int(game.hour) > 1 and int(game.shop_offer_hour) != int(game.hour):
if shop != null:
shop.refresh(int(game.hour), int(game.money))
shop_changed.emit()
# Автосейв на входе в подготовку: прогресс не теряется между часами.
game.save_game()
phase_changed.emit(phase)
@@ -128,19 +119,11 @@ func start_hour() -> bool:
# Час начинается только из подготовки (menu допустим, если Game уже в prep).
if phase != PHASE_PREP and not (phase == PHASE_MENU and game.phase == PHASE_PREP):
return false
# M4/M18: стол сортировки возвращает файлы до старта часа. Если места нет —
# час не начинается (никакого soft-lock/потери файлов).
if game.registry != null and game.registry.has_method("auto_return_table"):
if not bool(game.registry.auto_return_table()):
var message := "На столе остались файлы, места нет"
toast.emit(message)
return false
if not bool(game.start_hour()):
return false
hour_director.start_hour(int(game.hour))
hour_director.set_paused(false)
hour_director.set_modal_open(false)
hour_director.set_attack_paused(false)
phase = PHASE_HOUR
paused = false
modal_open = false
@@ -242,14 +225,13 @@ func prep_data() -> Dictionary:
for file in registry.get_folder_files(str(folder.id)):
folder_files.append(_file_entry(file))
files_map[str(folder.id)] = folder_files
capacity_total += folder.total_cells()
free_slots += folder.free_cell_count()
capacity_total += FOLDER_CAPACITY
free_slots += maxi(0, FOLDER_CAPACITY - folder.count() - int(folder.reserved))
folders_data.append({
"id": str(folder.id),
"name": str(folder.name),
"count": folder.occupied_cell_count(),
"capacity": folder.total_cells(),
"files": folder.count(),
"count": folder.count(),
"capacity": FOLDER_CAPACITY,
})
sections_data.append({
"id": str(section.id),
@@ -306,11 +288,9 @@ func hour_data() -> Dictionary:
for id in hour_director.active_requests.keys():
var request = hour_director.active_requests[id]
var req_entry: Dictionary = request.to_dict()
req_entry["ready"] = bool(hour_director.is_request_ready(str(id)))
req_entry["ready"] = request.is_ready()
req_entry["time_ratio"] = request.time_ratio()
req_entry["required_count"] = int(hour_director.request_required_count(str(id)))
req_entry["missing"] = maxi(0, int(req_entry["required_count"]) - request.attached.size())
req_entry["queued"] = bool(hour_director.is_request_queued(str(id)))
req_entry["missing"] = request.missing_count()
requests.append(req_entry)
var used := 0
@@ -343,28 +323,20 @@ func hour_data() -> Dictionary:
return {"packets": packets, "requests": requests, "slots": slots_data, "status": status}
## 3 текущих предложения магазина (v2). Пустой массив в H1/во время часа.
## Каталог магазина с текущими уровнями/ценами — для модалки магазина.
func shop_data() -> Array:
_bind()
var out: Array = []
if shop == null or game == null:
if shop == null:
return out
if int(game.hour) <= 1 and game.shop_offers.is_empty():
return out
for raw_id in shop.offers():
var id := str(raw_id)
var item: Dictionary = shop.get_item(id)
if item.is_empty():
continue
for item in shop.items():
var id := str(item.get("id", ""))
var item_name := str(item.get("name_ru", ""))
if item_name.is_empty():
item_name = str(item.get("name", id))
out.append({
"id": id,
"name": item_name,
"desc": str(item.get("desc_ru", "")),
"effect": str(item.get("effect", "")),
"value": float(item.get("value", 0.0)),
"price": int(shop.price(id)),
"level": int(shop.level(id)),
"max_level": int(shop.max_level(id)),
@@ -409,21 +381,6 @@ func _on_show_minigame(game_node) -> void:
show_minigame.emit(game_node)
func _on_minigame_closed(req_id: String) -> void:
modal_open = false
minigame_closed.emit(req_id)
func _on_show_attack_minigame(game_node, kind: String) -> void:
modal_open = true
show_attack_minigame.emit(game_node, kind)
func _on_attack_resolved(kind: String, success: bool, tier: int) -> void:
modal_open = false
attack_resolved.emit(kind, success, tier)
func _on_slot_changed() -> void:
slot_changed.emit()
@@ -474,9 +431,6 @@ func _bind() -> void:
hour_director.toast.connect(_on_hour_toast)
hour_director.show_connection.connect(_on_show_connection)
hour_director.show_minigame.connect(_on_show_minigame)
hour_director.minigame_closed.connect(_on_minigame_closed)
hour_director.show_attack_minigame.connect(_on_show_attack_minigame)
hour_director.attack_resolved.connect(_on_attack_resolved)
hour_director.slot_changed.connect(_on_slot_changed)
if game != null and not game.run_ended.is_connected(_on_game_run_ended):
game.run_ended.connect(_on_game_run_ended)
@@ -486,7 +440,6 @@ func _bind() -> void:
func _reset_director_state() -> void:
if hour_director == null:
return
hour_director.reset_carry()
hour_director.start_hour(1)
hour_director.running = false
hour_director.paused = false
+620 -659
View File
File diff suppressed because it is too large. Load diff
-384
View File
@@ -1,384 +0,0 @@
class_name DdosGame
extends MiniGame
## Атака «Щит» (ddos). Конечная волна красных пакетов падает сверху.
## Щит следует за мышью по X (кнопку держать не нужно) и двигается стрелками.
##
## Движение только dt-based: p.y += speed * delta, без покадрового damping.
## Раунд заканчивается сам, когда каждый пакет волны отбит или пробил низ.
## Качество: q = blocked / total_packets (все пакеты волны входят в total).
const GAME_ID := "ddos"
const TITLE_RU := "ЩИТ"
const FIELD := Rect2(18.0, 58.0, 604.0, 246.0)
const PACKET_W := 18.0
const PACKET_H := 20.0
const SHIELD_H := 16.0
const SHIELD_Y := 270.0
const FIELD_PAD := 10.0
const SHIELD_PAD := 8.0
const MIN_SPACING := 12.0
const TIERS := {
1: {"packets": 8, "speed": 110.0, "shield_width": 260.0, "interval": 0.55, "threshold": 0.60},
2: {"packets": 10, "speed": 140.0, "shield_width": 220.0, "interval": 0.45, "threshold": 0.65},
3: {"packets": 12, "speed": 170.0, "shield_width": 180.0, "interval": 0.35, "threshold": 0.70},
4: {"packets": 14, "speed": 200.0, "shield_width": 150.0, "interval": 0.28, "threshold": 0.75},
}
var _wave: Dictionary = {}
var _packets: Array = []
var _events: Array = []
var _spawned: int = 0
var _blocked: int = 0
var _breached: int = 0
var _elapsed: float = 0.0
var _shield_center: float = 320.0
var _flash: float = 0.0
var _round_done: bool = false
func _own_game_id() -> String:
return GAME_ID
func title() -> String:
return TITLE_RU
func quality() -> float:
if _finished:
return quality_value
return ddos_quality(_blocked, int(_wave.get("total", 0)))
func _setup() -> void:
_wave = generate_wave(tier, seed_value)
_packets.clear()
_events.clear()
_spawned = 0
_blocked = 0
_breached = 0
_elapsed = 0.0
_shield_center = float(_wave.get("start_shield_x", 320.0))
_flash = 0.0
_round_done = false
# --- параметры -------------------------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var base: Dictionary = (TIERS.get(t, TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier(GAME_ID, t)
if row.is_empty():
return base
for key in ["packets"]:
if row.has(key):
base[key] = int(row[key])
for key in ["speed", "shield_width", "interval", "threshold"]:
if row.has(key):
base[key] = float(row[key])
return base
# --- генерация -------------------------------------------------------------
static func generate_wave(p_tier: int, seed: int) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var p := tier_params(t)
var total := clampi(int(p.get("packets", 8)), 4, 24)
var base_speed := maxf(40.0, float(p.get("speed", 110.0)))
var shield_width := clampf(float(p.get("shield_width", 260.0)), 80.0, FIELD.size.x)
var interval := maxf(0.10, float(p.get("interval", 0.55)))
var rng := RandomNumberGenerator.new()
rng.seed = seed
var left := FIELD.position.x + FIELD_PAD
var right := FIELD.end.x - FIELD_PAD - PACKET_W
var band := (right - left) / float(total)
var bands: Array = []
for i in range(total):
bands.append(i)
_shuffle(bands, rng)
var packets: Array = []
var spawn_at := 0.35
for i in range(total):
var band_index := int(bands[i])
var band_left := left + band * float(band_index)
var inner := maxf(1.0, band - 12.0 - PACKET_W)
var x := band_left + 6.0 + rng.randf() * inner
x = clampf(x, left, right)
var speed := base_speed * rng.randf_range(0.90, 1.10)
if i > 0:
spawn_at += interval * rng.randf_range(0.85, 1.15)
packets.append({
"id": i,
"x": x,
"speed": speed,
"spawn_at": spawn_at,
})
var min_center := FIELD.position.x + shield_width * 0.5 + SHIELD_PAD
var max_center := FIELD.end.x - shield_width * 0.5 - SHIELD_PAD
var start_shield := rng.randf_range(min_center, max_center)
return {
"tier": t,
"packets": packets,
"total": total,
"speed": base_speed,
"shield_width": shield_width,
"interval": interval,
"threshold": float(p.get("threshold", 0.6)),
"start_shield_x": start_shield,
"field": FIELD,
"shield_y": SHIELD_Y,
"packet_w": PACKET_W,
"packet_h": PACKET_H,
}
static func validate_wave(data: Dictionary) -> bool:
var packets: Array = data.get("packets", [])
var total := int(data.get("total", 0))
if total <= 0 or packets.size() != total:
return false
var shield_width := float(data.get("shield_width", 0.0))
if shield_width <= 0.0 or shield_width > FIELD.size.x:
return false
var base_speed := float(data.get("speed", 0.0))
var last_spawn := -1.0
var xs: Array = []
for packet in packets:
var p: Dictionary = packet
var x := float(p.get("x", -1000.0))
var speed := float(p.get("speed", -1.0))
var spawn_at := float(p.get("spawn_at", -1.0))
if is_nan(x) or is_inf(x) or is_nan(speed) or is_inf(speed):
return false
if is_nan(spawn_at) or is_inf(spawn_at) or spawn_at < 0.0 or spawn_at > 30.0:
return false
if x < FIELD.position.x + FIELD_PAD - 0.5 or x > FIELD.end.x - FIELD_PAD - PACKET_W + 0.5:
return false
if speed < base_speed * 0.89 or speed > base_speed * 1.11:
return false
if spawn_at <= last_spawn:
return false
last_spawn = spawn_at
xs.append(x)
# Щит такой ширины может физически перекрыть пакет.
var center := x + PACKET_W * 0.5
var min_center := FIELD.position.x + shield_width * 0.5 + SHIELD_PAD
var max_center := FIELD.end.x - shield_width * 0.5 - SHIELD_PAD
var reachable := clampf(center, min_center, max_center)
if absf(reachable - center) > shield_width * 0.5 - PACKET_W * 0.5 + 0.5:
return false
xs.sort()
for i in range(1, xs.size()):
if float(xs[i]) - float(xs[i - 1]) < MIN_SPACING - 0.01:
return false
return true
static func ddos_quality(blocked: int, total_packets: int) -> float:
if total_packets <= 0:
return 0.0
return clampf(float(blocked) / float(total_packets), 0.0, 1.0)
# --- игра ------------------------------------------------------------------
func _shield_rect() -> Rect2:
var shield_width := float(_wave.get("shield_width", 260.0))
var min_center := FIELD.position.x + shield_width * 0.5 + SHIELD_PAD
var max_center := FIELD.end.x - shield_width * 0.5 - SHIELD_PAD
_shield_center = clampf(_shield_center, min_center, max_center)
return Rect2(_shield_center - shield_width * 0.5, SHIELD_Y, shield_width, SHIELD_H)
func _spawn_packet(packet: Dictionary) -> void:
_packets.append({
"id": int(packet.get("id", 0)),
"x": float(packet.get("x", 0.0)),
"y": FIELD.position.y - PACKET_H,
"speed": float(packet.get("speed", 100.0)),
"resolved": false,
})
func _add_event(pos: Vector2, text: String, color: Color) -> void:
_events.append({"pos": pos, "text": text, "color": color, "ttl": 0.7})
func _process(delta: float) -> void:
if _finished or _round_done:
return
_elapsed += delta
if _flash > 0.0:
_flash = maxf(0.0, _flash - delta)
var wave_packets: Array = _wave.get("packets", [])
while _spawned < wave_packets.size() \
and _elapsed >= float((wave_packets[_spawned] as Dictionary).get("spawn_at", 0.0)):
_spawn_packet(wave_packets[_spawned])
_spawned += 1
var shield := _shield_rect()
var active: Array = []
for entry in _packets:
var p: Dictionary = entry
var prev_bottom := float(p["y"]) + PACKET_H
p["y"] = float(p["y"]) + float(p["speed"]) * delta
var bottom := float(p["y"]) + PACKET_H
var px := float(p["x"])
if not bool(p["resolved"]) and prev_bottom < SHIELD_Y and bottom >= SHIELD_Y:
if px + PACKET_W >= shield.position.x and px <= shield.end.x:
p["resolved"] = true
_blocked += 1
_add_event(Vector2(px + PACKET_W * 0.5, SHIELD_Y - 10.0), "ОТБИТО",
Theme90.SUCCESS)
_play_sfx(&"stamp")
if not bool(p["resolved"]) and bottom >= FIELD.end.y:
p["resolved"] = true
_breached += 1
_flash = 0.22
_add_event(Vector2(px + PACKET_W * 0.5, FIELD.end.y - 12.0), "ПРОБОЙ",
Theme90.ERROR)
_play_sfx(&"bad")
if not bool(p["resolved"]):
active.append(p)
_packets = active
for event in _events:
(event as Dictionary)["ttl"] = float((event as Dictionary)["ttl"]) - delta
(event as Dictionary)["pos"] = (event as Dictionary)["pos"] + Vector2(0.0, -14.0 * delta)
_events = _events.filter(func(e): return float(e["ttl"]) > 0.0)
if _spawned >= wave_packets.size() and _packets.is_empty():
_round_done = true
var total := int(_wave.get("total", wave_packets.size()))
var q := ddos_quality(_blocked, total)
_result_detail = _with_context("ОТБИТО %d ИЗ %d · ПРОБОЙ %d" % [
_blocked, total, _breached])
_finish(q)
queue_redraw()
func _with_context(text: String) -> String:
var parts: Array = []
var reward := str(context.get("reward_text", ""))
var penalty := str(context.get("penalty_text", ""))
if not reward.is_empty():
parts.append(reward)
if not penalty.is_empty():
parts.append(penalty)
parts.append(text)
return " · ".join(parts)
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if _finished or _round_done:
return
_shield_center = local.x
_queue_shield_redraw()
func _on_pointer_pressed(local: Vector2, event: InputEvent) -> bool:
if _finished or _round_done:
return false
_on_pointer_moved(local, event)
return true
func _on_key_pressed(keycode: int, event: InputEvent) -> bool:
if _finished or _round_done:
return false
var step := 8.0 if event is InputEventKey and (event as InputEventKey).shift_pressed else 26.0
match keycode:
KEY_LEFT, KEY_A:
_shield_center -= step
_queue_shield_redraw()
return true
KEY_RIGHT, KEY_D:
_shield_center += step
_queue_shield_redraw()
return true
return false
func _queue_shield_redraw() -> void:
_shield_rect()
queue_redraw()
# --- отрисовка -------------------------------------------------------------
func _draw_game() -> void:
var total := int(_wave.get("total", 0))
draw_rect(FIELD, Color(0.075, 0.110, 0.120, 1.0))
draw_rect(FIELD, Color(1, 1, 1, 0.10), false, 1.0)
var grid := Color(1, 1, 1, 0.055)
var gx := FIELD.position.x + 28.0
while gx < FIELD.end.x:
draw_line(Vector2(gx, FIELD.position.y), Vector2(gx, FIELD.end.y), grid, 1.0)
gx += 32.0
var gy := FIELD.position.y + 26.0
while gy < FIELD.end.y:
draw_line(Vector2(FIELD.position.x, gy), Vector2(FIELD.end.x, gy), grid, 1.0)
gy += 26.0
_draw_label("ОТБИТО %d / %d" % [_blocked, total], Vector2(24.0, 44.0), 12,
Theme90.INK, 220.0)
_draw_label("ПРОБОЙ %d" % _breached, Vector2(420.0, 44.0), 12,
Theme90.ERROR if _breached > 0 else Theme90.SHADE, 200.0,
HORIZONTAL_ALIGNMENT_RIGHT)
if _flash > 0.0:
draw_rect(FIELD, Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b,
0.10 + 0.18 * (_flash / 0.22)))
for entry in _packets:
var p: Dictionary = entry
var rect := Rect2(float(p["x"]), float(p["y"]), PACKET_W, PACKET_H)
draw_style_box(Theme90.box(Theme90.ERROR, Theme90.ERROR.darkened(0.25), 2, 1,
Theme90.SHADOW), rect)
Theme90.text_vcenter(self, Theme90.ui(), rect, "!", 12, Theme90.WHITE,
-1, HORIZONTAL_ALIGNMENT_CENTER)
draw_line(Vector2(rect.position.x + 4.0, rect.position.y + 4.0),
Vector2(rect.end.x - 4.0, rect.end.y - 4.0), Color(1, 1, 1, 0.32), 1.0)
var shield := _shield_rect()
draw_style_box(Theme90.box(Theme90.ACCENT, Theme90.NAVY_DEEP, 3, 1, Theme90.SHADOW), shield)
Theme90.draw_hatch(self, shield.grow(-2.0), Color(1, 1, 1, 0.14), 7.0)
Theme90.text_vcenter(self, Theme90.ui(), shield, "ЩИТ", 10, Theme90.WHITE,
-1, HORIZONTAL_ALIGNMENT_CENTER)
for event in _events:
var e: Dictionary = event
var alpha := clampf(float(e["ttl"]) / 0.55, 0.0, 1.0)
var pos: Vector2 = e["pos"]
var col: Color = e["color"]
Theme90.text(self, Theme90.ui(), pos - Vector2(24.0, 0.0), str(e["text"]), 10,
Color(col.r, col.g, col.b, alpha), 48.0, HORIZONTAL_ALIGNMENT_CENTER)
func _draw_footer_hint() -> void:
_draw_label("ЩИТ СЛЕДУЕТ ЗА МЫШЬЮ ПО X · СТРЕЛКИ ← → ТОЖЕ", Vector2(18.0, 350.0),
10, Theme90.SHADE, 604.0)
# --- служебное -------------------------------------------------------------
static func _shuffle(list: Array, rng: RandomNumberGenerator) -> void:
for i in range(list.size() - 1, 0, -1):
var j := rng.randi_range(0, i)
var tmp = list[i]
list[i] = list[j]
list[j] = tmp
-1
View File
@@ -1 +0,0 @@
uid://djv0odpjkyh8p
-653
View File
@@ -1,653 +0,0 @@
class_name JunkGame
extends MiniGame
## Атака «Фильтр» (junk). Статическая сетка карточек: игрок помечает мусор
## в карантин, повторный клик снимает пометку, ПРОВЕРИТЬ фиксирует выбор.
##
## Мусор выглядит почти как обычный файл: аномалия спрятана в метаданных
## (размер, расширение/тип, дубль имени, дата, CRC, непечатаемый символ).
## Нормальные карточки полностью согласованы. Точное число мусора не
## показывается — только «ОТМЕЧЕНО: N».
##
## Здесь нет таймера и нет поражения по времени. Качество:
## q = clamp((removed_junk - false_positive) / total_junk, 0, 1).
const GAME_ID := "junk"
const TITLE_RU := "ФИЛЬТР"
const BAD_CHAR := "▯"
const COLS := 3
const CARD_W := 190.0
const CARD_GAP := 8.0
const GRID_TOP := 58.0
const GRID_BOTTOM := 306.0
const VERIFY_RECT := Rect2(466.0, 320.0, 156.0, 28.0)
const TIERS := {
1: {"cards": 9, "junk": 2, "hints": 1, "threshold": 0.60},
2: {"cards": 10, "junk": 3, "hints": 2, "threshold": 0.65},
3: {"cards": 11, "junk": 4, "hints": 2, "threshold": 0.70},
4: {"cards": 12, "junk": 5, "hints": 3, "threshold": 0.75},
}
const TYPE_DEFS := [
{"kind": "text", "label": "ТЕКСТ", "exts": ["TXT", "DOC", "RTF"]},
{"kind": "sound", "label": "ЗВУК", "exts": ["WAV", "MP3"]},
{"kind": "photo", "label": "ФОТО", "exts": ["JPG", "PNG", "BMP"]},
{"kind": "video", "label": "ВИДЕО", "exts": ["AVI", "MP4"]},
]
const NAMES := [
"отчёт", "смета", "письмо", "схема", "прайс", "акт", "реестр", "план",
"курс", "заявка", "договор", "опись", "факс", "сводка", "накладная",
"ведомость", "протокол", "фото", "звук", "клип",
]
const ANOMALY_DEFS := [
{"id": "zero_size", "family": "size"},
{"id": "absurd_size", "family": "size"},
{"id": "wrong_ext", "family": "ext"},
{"id": "duplicate_name", "family": "name"},
{"id": "bad_date", "family": "date"},
{"id": "missing_crc", "family": "crc"},
{"id": "bad_char", "family": "name"},
]
const ANOMALY_HINTS := {
"zero_size": "размер 0 Б — это мусор",
"absurd_size": "абсурдный размер файла",
"wrong_ext": "расширение не сходится с типом",
"duplicate_name": "два файла с одинаковым именем",
"invalid_date": "несуществующая дата",
"future_date": "дата из будущего",
"missing_crc": "нет контрольной суммы",
"bad_char": "в имени непечатаемый символ",
}
const WRONG_EXTS := {
"text": ["WAV", "JPG", "AVI", "EXE"],
"sound": ["TXT", "JPG", "AVI", "EXE"],
"photo": ["TXT", "WAV", "AVI", "EXE"],
"video": ["TXT", "WAV", "JPG", "EXE"],
}
var _data: Dictionary = {}
var _cards: Array = []
var _marked: Dictionary = {}
var _submitted: bool = false
var _hover: int = -1
func _own_game_id() -> String:
return GAME_ID
func title() -> String:
return TITLE_RU
func quality() -> float:
if _finished:
return quality_value
var counts := count_marks(_cards, _marked)
return junk_quality(int(counts["removed_junk"]), int(counts["false_positive"]),
int(_data.get("total_junk", 0)))
func _setup() -> void:
_data = generate_junk(tier, seed_value)
_cards = _data.get("cards", [])
_marked.clear()
_submitted = false
_hover = -1
# --- параметры -------------------------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var base: Dictionary = (TIERS.get(t, TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier(GAME_ID, t)
if row.is_empty():
return base
for key in ["cards", "junk", "hints"]:
if row.has(key):
base[key] = int(row[key])
if row.has("threshold"):
base["threshold"] = float(row["threshold"])
return base
# --- генерация -------------------------------------------------------------
static func generate_junk(p_tier: int, seed: int) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var p := tier_params(t)
var total := clampi(int(p.get("cards", 9)), 4, 24)
var junk_count := clampi(int(p.get("junk", 2)), 1, total - 1)
var hint_count := clampi(int(p.get("hints", 1)), 0, 3)
var rng := RandomNumberGenerator.new()
rng.seed = seed
var names: Array = NAMES.duplicate()
_shuffle(names, rng)
var cards: Array = []
for i in range(total):
var td: Dictionary = TYPE_DEFS[rng.randi_range(0, TYPE_DEFS.size() - 1)]
var exts: Array = td["exts"]
var ext := str(exts[rng.randi_range(0, exts.size() - 1)])
var raw_name := str(names[i % names.size()])
if i >= names.size():
raw_name = "%s_%d" % [raw_name, i]
var size_kb := rng.randi_range(2, 480)
cards.append({
"id": i,
"name": raw_name,
"kind": str(td["kind"]),
"type_label": str(td["label"]),
"ext": ext,
"size_kb": size_kb,
"size_text": "%d КБ" % size_kb,
"date": _random_date(rng),
"crc": "%04X" % rng.randi_range(0, 65535),
"junk": false,
"anomalies": [],
})
var order: Array = []
for i in range(total):
order.append(i)
_shuffle(order, rng)
var junk_indices: Array = order.slice(0, junk_count)
var normal_indices: Array = []
for i in range(total):
if not junk_indices.has(i):
normal_indices.append(i)
for pos in range(junk_indices.size()):
var card: Dictionary = cards[int(junk_indices[pos])]
card["junk"] = true
var anomaly_ids := _pick_anomalies(t, pos, rng)
var applied: Array = []
for anomaly_id in anomaly_ids:
var result := _apply_anomaly(card, str(anomaly_id), rng, cards, normal_indices)
if not str(result).is_empty():
applied.append(str(result))
if applied.is_empty():
card["size_kb"] = 0
card["size_text"] = "0 Б"
applied.append("zero_size")
card["anomalies"] = applied
var hints := _collect_hints(cards, hint_count)
return {
"tier": t,
"cards": cards,
"total_cards": total,
"total_junk": junk_count,
"hint_count": hint_count,
"hints": hints,
"threshold": float(p.get("threshold", 0.6)),
}
static func _pick_anomalies(p_tier: int, junk_pos: int, rng: RandomNumberGenerator) -> Array:
var pool: Array = ANOMALY_DEFS.duplicate(true)
_shuffle(pool, rng)
var picked: Array = []
var families: Dictionary = {}
if p_tier == 1 and junk_pos == 0:
picked.append("zero_size")
families["size"] = true
elif p_tier <= 2 and junk_pos == 0 and rng.randf() < 0.5:
picked.append("zero_size")
families["size"] = true
for entry in pool:
if picked.size() >= 2:
break
var family := str((entry as Dictionary)["family"])
var anomaly_id := str((entry as Dictionary)["id"])
if families.has(family):
continue
picked.append(anomaly_id)
families[family] = true
if picked.is_empty():
picked.append("zero_size")
return picked
static func _apply_anomaly(card: Dictionary, anomaly_id: String, rng: RandomNumberGenerator,
cards: Array, normal_indices: Array) -> String:
match anomaly_id:
"zero_size":
card["size_kb"] = 0
card["size_text"] = "0 Б"
return "zero_size"
"absurd_size":
card["size_kb"] = 999999
card["size_text"] = "999 999 КБ"
return "absurd_size"
"wrong_ext":
var kind_name := str(card.get("kind", "text"))
var options: Array = WRONG_EXTS.get(kind_name, ["EXE"])
var alt := str(options[rng.randi_range(0, options.size() - 1)])
if _ext_matches_kind(kind_name, alt):
alt = "EXE"
card["ext"] = alt
return "wrong_ext"
"duplicate_name":
if normal_indices.is_empty() or str(card.get("name", "")).contains(BAD_CHAR):
return ""
var victim: Dictionary = cards[int(normal_indices[rng.randi_range(0,
normal_indices.size() - 1)])]
card["name"] = str(victim.get("name", ""))
return "duplicate_name"
"bad_date":
if rng.randf() < 0.5:
card["date"] = "31.02.97"
return "invalid_date"
card["date"] = "12.06.31"
return "future_date"
"missing_crc":
card["crc"] = "—"
return "missing_crc"
"bad_char":
var name := str(card.get("name", ""))
if name.length() >= 2:
card["name"] = name.substr(0, 1) + BAD_CHAR + name.substr(2)
else:
card["name"] = BAD_CHAR + name
return "bad_char"
return ""
static func _collect_hints(cards: Array, limit: int) -> Array:
if limit <= 0:
return []
var order := ["zero_size", "absurd_size", "wrong_ext", "duplicate_name",
"invalid_date", "future_date", "missing_crc", "bad_char"]
var seen: Dictionary = {}
for key in order:
for card in cards:
if not bool((card as Dictionary).get("junk", false)):
continue
var anomalies: Array = (card as Dictionary).get("anomalies", [])
if anomalies.has(key):
seen[key] = true
var hints: Array = []
for key in order:
if seen.has(key) and hints.size() < limit:
hints.append(str(ANOMALY_HINTS.get(key, "")))
return hints
# --- проверка --------------------------------------------------------------
static func _ext_matches_kind(kind: String, ext: String) -> bool:
var e := ext.strip_edges().to_upper()
for td in TYPE_DEFS:
if str((td as Dictionary)["kind"]) != kind:
continue
var exts: Array = (td as Dictionary)["exts"]
for allowed in exts:
if e == str(allowed):
return true
return false
static func _valid_date(value: String) -> bool:
var parts := value.split(".")
if parts.size() != 3:
return false
if not parts[0].is_valid_int() or not parts[1].is_valid_int() \
or not parts[2].is_valid_int():
return false
var day := int(parts[0])
var month := int(parts[1])
var year := int(parts[2])
if month < 1 or month > 12:
return false
if day < 1 or day > _days_in_month(month):
return false
return year >= 0 and year <= 99
static func _future_date(value: String) -> bool:
var parts := value.split(".")
if parts.size() != 3 or not parts[2].is_valid_int():
return false
# Игра идёт в 1996–1997; двухзначные 00–89 читаются как 2000-е.
var year := int(parts[2])
return year > 97 or year < 90
static func _days_in_month(month: int) -> int:
match month:
2:
return 28
4, 6, 9, 11:
return 30
return 31
static func _random_date(rng: RandomNumberGenerator) -> String:
var day := rng.randi_range(1, 28)
var month := rng.randi_range(1, 12)
var year := 96 + rng.randi_range(0, 1)
return "%02d.%02d.%02d" % [day, month, year]
## Все видимые аномалии карточки. Дубль имени — отношение между карточками,
## поэтому передаётся counts имён.
static func detected_anomalies(card: Dictionary, name_counts: Dictionary) -> Array:
var out: Array = []
var size_kb := int(card.get("size_kb", 0))
if size_kb == 0:
out.append("zero_size")
elif size_kb >= 999999:
out.append("absurd_size")
if not _ext_matches_kind(str(card.get("kind", "")), str(card.get("ext", ""))):
out.append("wrong_ext")
if str(card.get("crc", "")) == "—":
out.append("missing_crc")
var date := str(card.get("date", ""))
if not _valid_date(date):
out.append("invalid_date")
elif _future_date(date):
out.append("future_date")
if str(card.get("name", "")).contains(BAD_CHAR):
out.append("bad_char")
if int(name_counts.get(str(card.get("name", "")), 0)) > 1:
out.append("duplicate_name")
return out
static func validate_junk(data: Dictionary) -> bool:
var cards: Array = data.get("cards", [])
var p := tier_params(int(data.get("tier", 1)))
if cards.size() != int(data.get("total_cards", -1)) or cards.is_empty():
return false
if int(data.get("total_junk", -1)) != int(p.get("junk", -1)):
return false
var name_counts: Dictionary = {}
for card in cards:
var name := str((card as Dictionary).get("name", ""))
name_counts[name] = int(name_counts.get(name, 0)) + 1
var junk_seen := 0
for card in cards:
var c: Dictionary = card
var detected := detected_anomalies(c, name_counts)
if bool(c.get("junk", false)):
junk_seen += 1
if detected.is_empty():
return false
# Дубль имени сам по себе неоднозначен: у мусорной карточки должен
# быть ещё хотя бы один независимый признак.
var independent := 0
for anomaly in detected:
if str(anomaly) != "duplicate_name":
independent += 1
if independent == 0:
return false
else:
if int(c.get("size_kb", 0)) <= 0 or int(c.get("size_kb", 0)) > 999998:
return false
if not _ext_matches_kind(str(c.get("kind", "")), str(c.get("ext", ""))):
return false
if str(c.get("crc", "")) == "—" or str(c.get("crc", "")).length() != 4:
return false
if not _valid_date(str(c.get("date", ""))) or _future_date(str(c.get("date", ""))):
return false
if str(c.get("name", "")).contains(BAD_CHAR):
return false
if junk_seen != int(data.get("total_junk", -1)):
return false
return true
static func junk_quality(removed_junk: int, false_positive: int, total_junk: int) -> float:
if total_junk <= 0:
return 0.0
return clampf((float(removed_junk) - float(false_positive)) / float(total_junk),
0.0, 1.0)
static func count_marks(cards: Array, marked: Dictionary) -> Dictionary:
var removed := 0
var false_positive := 0
var missed := 0
for card in cards:
var c: Dictionary = card
var is_marked := bool(marked.get(int(c.get("id", -1)), false))
if bool(c.get("junk", false)):
if is_marked:
removed += 1
else:
missed += 1
elif is_marked:
false_positive += 1
return {
"removed_junk": removed,
"false_positive": false_positive,
"missed_junk": missed,
"marked": int(marked.size()),
}
# --- ввод ------------------------------------------------------------------
func _card_index_at(local: Vector2) -> int:
for i in range(_cards.size()):
if _card_rect(i).has_point(local):
return i
return -1
func _toggle(index: int) -> void:
if index < 0 or index >= _cards.size():
return
var id := int((_cards[index] as Dictionary).get("id", index))
if _marked.has(id):
_marked.erase(id)
else:
_marked[id] = true
_play_sfx(&"tick")
queue_redraw()
func _submit() -> void:
if _submitted or _finished:
return
var counts := count_marks(_cards, _marked)
var q := junk_quality(int(counts["removed_junk"]), int(counts["false_positive"]),
int(_data.get("total_junk", 0)))
_submitted = true
_result_detail = _with_context("СНЯТО %d ИЗ %d · ЛОЖНЫХ %d · ПРОПУЩЕНО %d" % [
int(counts["removed_junk"]), int(_data.get("total_junk", 0)),
int(counts["false_positive"]), int(counts["missed_junk"])])
_finish(q)
func _with_context(text: String) -> String:
var parts: Array = []
var reward := str(context.get("reward_text", ""))
var penalty := str(context.get("penalty_text", ""))
if not reward.is_empty():
parts.append(reward)
if not penalty.is_empty():
parts.append(penalty)
parts.append(text)
return " · ".join(parts)
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if _submitted or _finished:
return false
if VERIFY_RECT.has_point(local):
_submit()
return true
var index := _card_index_at(local)
if index >= 0:
_toggle(index)
return true
return false
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if _submitted or _finished:
return
var next := _card_index_at(local)
if next != _hover:
_hover = next
queue_redraw()
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if _submitted or _finished:
return false
if keycode in [KEY_ENTER, KEY_KP_ENTER, KEY_SPACE]:
_submit()
return true
return false
# --- отрисовка -------------------------------------------------------------
func _card_rect(index: int) -> Rect2:
var rows := maxi(1, int(ceil(float(_cards.size()) / float(COLS))))
var gap := CARD_GAP
var avail_h := GRID_BOTTOM - GRID_TOP
var card_h := minf(70.0, (avail_h - gap * float(rows - 1)) / float(rows))
var total_w := CARD_W * float(COLS) + gap * float(COLS - 1)
var x0 := 18.0 + (604.0 - total_w) * 0.5
var col := index % COLS
var row := index / COLS
return Rect2(x0 + float(col) * (CARD_W + gap), GRID_TOP + float(row) * (card_h + gap),
CARD_W, card_h)
func _draw_game() -> void:
var font := Theme90.ui()
var hints: Array = _data.get("hints", [])
var marked_count := _marked.size()
_draw_label("ОТМЕЧЕНО: %d" % marked_count, Vector2(24.0, 44.0), 12,
Theme90.INK, 180.0)
var hint_text := "· ".join(hints) if not hints.is_empty() else "СНИМАЙТЕ ТОЛЬКО МУСОР"
_draw_label(Theme90.fit(font, hint_text, 10, 360.0), Vector2(226.0, 46.0), 10,
Theme90.SHADE, 380.0, HORIZONTAL_ALIGNMENT_RIGHT)
for i in range(_cards.size()):
_draw_card(i)
_draw_action_button(VERIFY_RECT, "ПРОВЕРИТЬ", not _submitted, true)
func _draw_card(index: int) -> void:
var card: Dictionary = _cards[index]
var rect := _card_rect(index)
var marked := _marked.has(int(card.get("id", -1)))
var junk := bool(card.get("junk", false))
var fill := Theme90.PAPER_HI
var border := Theme90.BORDER
var border_w := 1
var status := ""
if _submitted:
if junk and marked:
fill = Theme90.SUCCESS.lightened(0.78)
border = Theme90.SUCCESS
border_w = 2
status = "СНЯТ"
elif junk and not marked:
fill = Theme90.WARN.lightened(0.78)
border = Theme90.WARN
border_w = 2
status = "ПРОПУЩЕН"
elif not junk and marked:
fill = Theme90.ERROR.lightened(0.80)
border = Theme90.ERROR
border_w = 2
status = "ЛОЖНО"
elif not junk:
border = Theme90.BORDER_SOFT
elif marked:
fill = Theme90.ERROR.lightened(0.90)
border = Theme90.ERROR
border_w = 2
status = "В КАРАНТИН"
elif index == _hover:
border = Theme90.ACCENT
border_w = 2
draw_style_box(Theme90.box(fill, border, 3, border_w), rect)
var kind := str(card.get("kind", "text"))
var ext := str(card.get("ext", ""))
var cat := Theme90.ext_category(ext)
var icon_col := Theme90.ext_color(cat)
if cat.is_empty():
match kind:
"sound":
icon_col = Theme90.WARN
"photo":
icon_col = Theme90.SUCCESS
"video":
icon_col = Theme90.ERROR
_:
icon_col = Theme90.ACCENT
Theme90.draw_doc_icon(self, Rect2(rect.position + Vector2(7.0, 7.0), Vector2(28.0, 28.0)),
kind, icon_col, ext)
var tall := rect.size.y >= 64.0
var name_y := rect.position.y + (8.0 if tall else 5.0)
var meta_y := rect.position.y + (27.0 if tall else 21.0)
var sub_y := rect.position.y + (44.0 if tall else 36.0)
var text_x := rect.position.x + 42.0
var text_w := rect.size.x - 50.0
var name := Theme90.fit(Theme90.ui(), str(card.get("name", "")), 12, text_w)
Theme90.text(self, Theme90.ui(), Vector2(text_x, name_y), name, 12, Theme90.INK, text_w)
Theme90.text(self, Theme90.ui(), Vector2(text_x, meta_y),
"%s · %s" % [ext, str(card.get("size_text", ""))], 10, Theme90.DARK, text_w)
Theme90.text(self, Theme90.ui(), Vector2(text_x, sub_y),
"%s · CRC %s" % [str(card.get("date", "")), str(card.get("crc", ""))],
10, Theme90.SHADE, text_w)
var badge := status
if badge.is_empty():
badge = str(card.get("type_label", ""))
var badge_col := Theme90.SHADE
if _submitted and status == "СНЯТ":
badge_col = Theme90.SUCCESS
elif _submitted and status == "ЛОЖНО":
badge_col = Theme90.ERROR
elif status == "ПРОПУЩЕН":
badge_col = Theme90.WARN
elif status == "В КАРАНТИН":
badge_col = Theme90.ERROR
Theme90.text(self, Theme90.ui(), Vector2(rect.position.x + 112.0, name_y),
Theme90.fit(Theme90.ui(), badge, 10, rect.size.x - 118.0), 10, badge_col,
rect.size.x - 118.0, HORIZONTAL_ALIGNMENT_RIGHT)
func _draw_footer_hint() -> void:
var hints: Array = _data.get("hints", [])
if hints.is_empty():
return
_draw_label("ПОДСКАЗКИ: " + " · ".join(hints), Vector2(18.0, 350.0), 10,
Theme90.SHADE, 604.0)
# --- служебное -------------------------------------------------------------
static func _shuffle(list: Array, rng: RandomNumberGenerator) -> void:
for i in range(list.size() - 1, 0, -1):
var j := rng.randi_range(0, i)
var tmp = list[i]
list[i] = list[j]
list[j] = tmp
-1
View File
@@ -1 +0,0 @@
uid://dmrrnmmspoyhy
-608
View File
@@ -1,608 +0,0 @@
class_name PhishingGame
extends MiniGame
## Атака «Паспорт» (phishing). Очередь клиентов: две «бумаги» — заявка и
## пропуск. Игрок сверяет поля и жмёт ВЫДАТЬ или ОТКАТАТЬ.
##
## У каждого клиента ровно один правильный ответ. Подделка отличается ровно
## одним проверяемым полем (имя / отдел / ID / срок действия), причём ID
## использует кириллическую похожую букву. Отмена решения невозможна,
## таймера нет; после решения — короткая печать 0.12 с.
##
## Качество: q = correct / total_clients.
const GAME_ID := "phishing"
const TITLE_RU := "ПАСПОРТ"
const ISSUE_RECT := Rect2(142.0, 274.0, 166.0, 30.0)
const DENY_RECT := Rect2(332.0, 274.0, 166.0, 30.0)
const FILE_RECT := Rect2(24.0, 62.0, 286.0, 206.0)
const PASS_RECT := Rect2(330.0, 62.0, 286.0, 206.0)
const STAMP_TTL := 0.12
const TIERS := {
1: {"clients": 3, "checks": ["name"], "threshold": 0.75},
2: {"clients": 4, "checks": ["name", "department"], "threshold": 0.75},
3: {"clients": 5, "checks": ["name", "department", "id"], "threshold": 0.80},
4: {"clients": 6, "checks": ["name", "department", "id", "photo", "expiry"],
"threshold": 0.80},
}
const PEOPLE := [
{"surname": "Иванов", "first": "Пётр", "female": false},
{"surname": "Смирнова", "first": "Анна", "female": true},
{"surname": "Кузнецов", "first": "Илья", "female": false},
{"surname": "Попова", "first": "Мария", "female": true},
{"surname": "Соколов", "first": "Артём", "female": false},
{"surname": "Лебедева", "first": "Ольга", "female": true},
{"surname": "Новиков", "first": "Денис", "female": false},
{"surname": "Морозова", "first": "Вера", "female": true},
{"surname": "Волков", "first": "Сергей", "female": false},
{"surname": "Зайцева", "first": "Нина", "female": true},
{"surname": "Павлов", "first": "Максим", "female": false},
{"surname": "Орлова", "first": "Елена", "female": true},
{"surname": "Фёдоров", "first": "Олег", "female": false},
{"surname": "Егорова", "first": "Ирина", "female": true},
{"surname": "Белов", "first": "Антон", "female": false},
{"surname": "Гусева", "first": "Дарья", "female": true},
{"surname": "Тихонов", "first": "Роман", "female": false},
{"surname": "Киселёва", "first": "Света", "female": true},
{"surname": "Борисов", "first": "Кирилл", "female": false},
{"surname": "Андреева", "first": "Таня", "female": true},
]
const DEPARTMENTS := [
"БУХГАЛТЕРИЯ", "ОТДЕЛ КАДРОВ", "ЛОГИСТИКА", "ПРОДАЖИ", "ИТ-ОТДЕЛ",
"ЮРОТДЕЛ", "СКЛАД", "ДИСПЕТЧЕРСКАЯ", "АРХИВ", "СНАБЖЕНИЕ",
"КЛИЕНТСКИЙ ОТДЕЛ", "ТРАНСПОРТ",
]
## ASCII → кириллический двойник. ID всегда содержит буквы из ключей.
const LOOKALIKE := {
"A": "А", "B": "В", "C": "С", "E": "Е", "H": "Н", "K": "К",
"M": "М", "O": "О", "P": "Р", "T": "Т", "X": "Х", "Y": "У",
}
const ID_LETTERS := "ABEKMHOPCTX"
const CHECK_LABELS := {
"name": "ИМЯ",
"dept": "ОТДЕЛ",
"id": "ID",
"expiry": "СРОК",
"photo": "ФОТО",
}
var _clients: Array = []
var _index: int = 0
var _correct: int = 0
var _wrong: int = 0
var _stamp_ttl: float = 0.0
var _last_ok: bool = false
var _last_choice: String = ""
var _hover: String = ""
func _own_game_id() -> String:
return GAME_ID
func title() -> String:
return TITLE_RU
func quality() -> float:
if _finished:
return quality_value
return phishing_quality(_correct, _clients.size())
func _setup() -> void:
_clients = generate_clients(tier, seed_value)
_index = 0
_correct = 0
_wrong = 0
_stamp_ttl = 0.0
_last_ok = false
_last_choice = ""
_hover = ""
mouse_default_cursor_shape = Control.CURSOR_ARROW
# --- параметры -------------------------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var base: Dictionary = (TIERS.get(t, TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier(GAME_ID, t)
if row.is_empty():
base["checks"] = _normalized_checks(base.get("checks", []))
return base
if row.has("clients"):
base["clients"] = int(row["clients"])
if row.has("checks"):
base["checks"] = _normalized_checks(row["checks"])
else:
base["checks"] = _normalized_checks(base.get("checks", []))
if row.has("threshold"):
base["threshold"] = float(row["threshold"])
return base
static func _normalized_checks(raw) -> Array:
var out: Array = []
if raw is Array:
for entry in raw:
match str(entry).to_lower():
"name":
if not out.has("name"):
out.append("name")
"department", "dept":
if not out.has("dept"):
out.append("dept")
"id":
if not out.has("id"):
out.append("id")
"expiry", "expires", "date":
if not out.has("expiry"):
out.append("expiry")
"photo", "avatar":
if not out.has("photo"):
out.append("photo")
if out.is_empty():
out = ["name"]
return out
# --- генерация -------------------------------------------------------------
static func generate_clients(p_tier: int, seed: int) -> Array:
var t := clampi(p_tier, 1, 4)
var p := tier_params(t)
var total := clampi(int(p.get("clients", 3)), 2, 12)
var clients: Array = []
for i in range(total):
clients.append(generate_client(t, seed, i, -1))
var valid_count := 0
var fake_count := 0
for client in clients:
if bool((client as Dictionary).get("valid", true)):
valid_count += 1
else:
fake_count += 1
if fake_count == 0 and total >= 2:
clients[0] = generate_client(t, seed, 0, 1)
elif valid_count == 0 and total >= 2:
clients[0] = generate_client(t, seed, 0, 0)
return clients
## force_fake: -1 — случайно, 0 — честный, 1 — подделка.
static func generate_client(p_tier: int, seed: int, index: int = 0,
force_fake: int = -1) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var p := tier_params(t)
var checks: Array = p.get("checks", ["name"])
var rng := RandomNumberGenerator.new()
rng.seed = seed + index * 104729 + 17
var person: Dictionary = PEOPLE[rng.randi_range(0, PEOPLE.size() - 1)]
var dept := str(DEPARTMENTS[rng.randi_range(0, DEPARTMENTS.size() - 1)])
var id_value := _make_id(rng)
var expiry := _random_expiry(rng)
var photo := {
"jacket": rng.randi_range(0, 3),
"hair": rng.randi_range(0, 3),
"skin": rng.randi_range(0, 2),
"female": bool(person.get("female", false)),
}
var full_name := "%s %s" % [str(person.get("surname", "")), str(person.get("first", ""))]
var file_data := {
"name": full_name,
"dept": dept,
"id": id_value,
"expiry": expiry,
"photo": photo.duplicate(true),
}
var pass_data := {
"name": full_name,
"dept": dept,
"id": id_value,
"expiry": expiry,
"photo": photo.duplicate(true),
}
var make_fake := false
if force_fake == 1:
make_fake = true
elif force_fake == 0:
make_fake = false
else:
make_fake = rng.randf() < 0.5
var mismatch := ""
if make_fake:
var kinds: Array = []
for check in checks:
if check in ["name", "dept", "id", "expiry"]:
kinds.append(str(check))
if kinds.is_empty():
kinds = ["name"]
var kind := str(kinds[rng.randi_range(0, kinds.size() - 1)])
_apply_fake(pass_data, kind, rng)
mismatch = kind
return {
"id": index,
"file": file_data,
"pass": pass_data,
"mismatch": mismatch,
"valid": mismatch.is_empty(),
"expected": "deny" if not mismatch.is_empty() else "issue",
"checks": checks.duplicate(),
}
static func _apply_fake(pass_data: Dictionary, kind: String, rng: RandomNumberGenerator) -> void:
match kind:
"name":
var full := str(pass_data.get("name", ""))
var space := full.find(" ")
if space > 0:
pass_data["name"] = _surname_variant(full.substr(0, space)) + full.substr(space)
else:
pass_data["name"] = _surname_variant(full)
"dept":
var current := str(pass_data.get("dept", ""))
var current_index := DEPARTMENTS.find(current)
if current_index < 0:
current_index = 0
var offset := rng.randi_range(1, DEPARTMENTS.size() - 1)
pass_data["dept"] = str(DEPARTMENTS[(current_index + offset) % DEPARTMENTS.size()])
"id":
pass_data["id"] = _lookalike_id(str(pass_data.get("id", "")))
"expiry":
pass_data["expiry"] = _shift_expiry(str(pass_data.get("expiry", "")))
static func _surname_variant(surname: String) -> String:
if surname.ends_with("ов") or surname.ends_with("ев") or surname.ends_with("ин"):
return surname + "а"
if surname.ends_with("а"):
return surname.substr(0, surname.length() - 1)
return surname + "а"
static func _make_id(rng: RandomNumberGenerator) -> String:
var out := "id_"
out += ID_LETTERS[rng.randi_range(0, ID_LETTERS.length() - 1)]
out += str(rng.randi_range(0, 9))
out += str(rng.randi_range(0, 9))
out += ID_LETTERS[rng.randi_range(0, ID_LETTERS.length() - 1)]
return out
static func _lookalike_id(id_value: String) -> String:
for i in range(id_value.length()):
var ch := id_value.substr(i, 1)
if LOOKALIKE.has(ch):
return id_value.substr(0, i) + str(LOOKALIKE[ch]) + id_value.substr(i + 1)
return id_value + "К"
static func _random_expiry(rng: RandomNumberGenerator) -> String:
var day := rng.randi_range(1, 28)
var month := rng.randi_range(1, 12)
var year := 97 + rng.randi_range(0, 1)
return "%02d.%02d.%02d" % [day, month, year]
static func _shift_expiry(value: String) -> String:
var parts := value.split(".")
if parts.size() != 3:
return "01.01.98"
var day := clampi(int(parts[0]), 1, 28)
var month := clampi(int(parts[1]), 1, 12)
var year := int(parts[2])
if year >= 98:
year = 97
else:
year += 1
return "%02d.%02d.%02d" % [day, month, year]
# --- проверка --------------------------------------------------------------
static func _same_photo(a: Dictionary, b: Dictionary) -> bool:
return int(a.get("jacket", -1)) == int(b.get("jacket", -2)) \
and int(a.get("hair", -1)) == int(b.get("hair", -2)) \
and int(a.get("skin", -1)) == int(b.get("skin", -2)) \
and bool(a.get("female", false)) == bool(b.get("female", true))
static func client_diff_fields(client: Dictionary) -> Array:
var out: Array = []
var file_data: Dictionary = client.get("file", {})
var pass_data: Dictionary = client.get("pass", {})
for field in ["name", "dept", "id", "expiry"]:
if str(file_data.get(field, "")) != str(pass_data.get(field, "")):
out.append(field)
if not _same_photo(file_data.get("photo", {}), pass_data.get("photo", {})):
out.append("photo")
return out
static func validate_client(client: Dictionary, p_tier: int) -> bool:
var checks: Array = tier_params(p_tier).get("checks", ["name"])
var diffs := client_diff_fields(client)
if diffs.size() > 1:
return false
var valid_now := diffs.is_empty()
if valid_now != bool(client.get("valid", false)):
return false
if str(client.get("expected", "")) != ("issue" if valid_now else "deny"):
return false
if not valid_now:
if not checks.has(str(diffs[0])):
return false
if str(client.get("mismatch", "")) != str(diffs[0]):
return false
else:
if not str(client.get("mismatch", "")).is_empty():
return false
return true
static func validate_clients(clients: Array, p_tier: int) -> bool:
if clients.is_empty():
return false
for client in clients:
if not validate_client(client, p_tier):
return false
return true
static func phishing_quality(correct: int, total_clients: int) -> float:
if total_clients <= 0:
return 0.0
return clampf(float(correct) / float(total_clients), 0.0, 1.0)
# --- игра ------------------------------------------------------------------
func _current_client() -> Dictionary:
if _index < 0 or _index >= _clients.size():
return {}
return _clients[_index]
func _decide(choice: String) -> void:
if _finished or _stamp_ttl > 0.0:
return
var client := _current_client()
if client.is_empty():
return
var expected := "issue" if bool(client.get("valid", false)) else "deny"
var ok := choice == expected
if ok:
_correct += 1
else:
_wrong += 1
_last_ok = ok
_last_choice = choice
_stamp_ttl = STAMP_TTL
_hover = ""
mouse_default_cursor_shape = Control.CURSOR_ARROW
_play_sfx(&"ok" if ok else &"bad")
queue_redraw()
func _advance() -> void:
_index += 1
_last_ok = false
_last_choice = ""
if _index >= _clients.size():
var q := phishing_quality(_correct, _clients.size())
_result_detail = _with_context("ВЕРНО %d ИЗ %d · ОШИБОК %d" % [
_correct, _clients.size(), _wrong])
_finish(q)
else:
queue_redraw()
func _with_context(text: String) -> String:
var parts: Array = []
var reward := str(context.get("reward_text", ""))
var penalty := str(context.get("penalty_text", ""))
if not reward.is_empty():
parts.append(reward)
if not penalty.is_empty():
parts.append(penalty)
parts.append(text)
return " · ".join(parts)
func _process(delta: float) -> void:
if _finished:
return
if _stamp_ttl <= 0.0:
return
_stamp_ttl = maxf(0.0, _stamp_ttl - delta)
if _stamp_ttl <= 0.0:
_advance()
queue_redraw()
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if _finished or _stamp_ttl > 0.0:
return false
if ISSUE_RECT.has_point(local):
_decide("issue")
return true
if DENY_RECT.has_point(local):
_decide("deny")
return true
return false
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if _finished:
return
var next := ""
if _stamp_ttl <= 0.0:
if ISSUE_RECT.has_point(local):
next = "issue"
elif DENY_RECT.has_point(local):
next = "deny"
if next != _hover:
_hover = next
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if not next.is_empty() \
else Control.CURSOR_ARROW
queue_redraw()
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if _finished or _stamp_ttl > 0.0:
return false
match keycode:
KEY_1, KEY_A:
_decide("issue")
return true
KEY_2, KEY_D:
_decide("deny")
return true
return false
# --- отрисовка -------------------------------------------------------------
func _draw_game() -> void:
var total := _clients.size()
var shown := mini(_index + 1, total)
_draw_label("КЛИЕНТ %d / %d" % [shown, total], Vector2(24.0, 44.0), 12,
Theme90.INK, 220.0)
_draw_label("ВЕРНО %d · ОШИБОК %d" % [_correct, _wrong], Vector2(380.0, 44.0),
12, Theme90.ERROR if _wrong > 0 else Theme90.SHADE, 230.0,
HORIZONTAL_ALIGNMENT_RIGHT)
var client := _current_client()
if client.is_empty():
_draw_label("ОЧЕРЕДЬ ОБРАБОТАНА", Vector2(24.0, 140.0), 24, Theme90.NAVY_DEEP, 604.0,
HORIZONTAL_ALIGNMENT_CENTER)
return
_draw_file(FILE_RECT, client)
_draw_pass(PASS_RECT, client)
_draw_action_button(ISSUE_RECT, "ВЫДАТЬ", true, true)
_draw_action_button(DENY_RECT, "ОТКАТАТЬ", true, false)
if _hover == "issue":
draw_rect(ISSUE_RECT.grow(2.0), Theme90.ACCENT, false, 2.0)
elif _hover == "deny":
draw_rect(DENY_RECT.grow(2.0), Theme90.ACCENT, false, 2.0)
if _stamp_ttl > 0.0:
_draw_stamp()
func _draw_file(rect: Rect2, client: Dictionary) -> void:
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 4, 1, Theme90.SHADOW), rect)
_draw_paper_header(rect, "ЗАЯВКА")
var data: Dictionary = client.get("file", {})
var x := rect.position.x + 14.0
var w := rect.size.x - 90.0
_draw_avatar(Rect2(rect.end.x - 68.0, rect.position.y + 30.0, 54.0, 66.0),
data.get("photo", {}))
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 30.0), "ВЛАДЕЛЕЦ",
10, Theme90.NAVY_DEEP, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 42.0),
Theme90.fit(Theme90.ui(), str(data.get("name", "")), 16, w), 16, Theme90.INK, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 72.0), "ОТДЕЛ",
10, Theme90.NAVY_DEEP, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 84.0),
Theme90.fit(Theme90.ui(), str(data.get("dept", "")), 12, w), 12, Theme90.DARK, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 110.0), "ID",
10, Theme90.NAVY_DEEP, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 122.0),
str(data.get("id", "")), 12, Theme90.DARK, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 148.0), "ДЕЙСТВУЕТ ДО",
10, Theme90.NAVY_DEEP, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 160.0),
str(data.get("expiry", "")), 12, Theme90.DARK, w)
func _draw_pass(rect: Rect2, client: Dictionary) -> void:
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 4, 1, Theme90.SHADOW), rect)
_draw_paper_header(rect, "ПРОПУСК")
var data: Dictionary = client.get("pass", {})
_draw_avatar(Rect2(rect.position.x + 14.0, rect.position.y + 32.0, 58.0, 72.0),
data.get("photo", {}))
var x := rect.position.x + 84.0
var w := rect.size.x - 98.0
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 36.0),
Theme90.fit(Theme90.ui(), str(data.get("name", "")), 16, w), 16, Theme90.INK, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 68.0),
Theme90.fit(Theme90.ui(), str(data.get("dept", "")), 12, w), 12, Theme90.DARK, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 94.0),
str(data.get("id", "")), 12, Theme90.DARK, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 120.0),
"СРОК " + str(data.get("expiry", "")), 12, Theme90.DARK, w)
var checks: Array = client.get("checks", [])
var check_text := "ПРОВЕРКА: "
var labels: Array = []
for check in checks:
labels.append(str(CHECK_LABELS.get(str(check), str(check))))
check_text += " · ".join(labels)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 150.0),
Theme90.fit(Theme90.ui(), check_text, 10, w), 10, Theme90.SHADE, w)
func _draw_paper_header(rect: Rect2, text: String) -> void:
var header := Rect2(rect.position, Vector2(rect.size.x, 20.0))
draw_style_box(Theme90.top_box(Theme90.NAVY, 4), header)
draw_rect(Rect2(header.position.x, header.end.y - 1.0, header.size.x, 1.0),
Theme90.NAVY_DEEP)
Theme90.text(self, Theme90.ui(), Vector2(header.position.x + 10.0,
header.position.y + 5.0), text, 10, Theme90.WHITE, header.size.x - 20.0)
func _draw_avatar(rect: Rect2, photo: Dictionary) -> void:
draw_style_box(Theme90.box(Theme90.ACCENT_PALE, Theme90.BORDER, 2, 1), rect)
var jackets := [Theme90.NAVY, Theme90.ACCENT, Color(0.43, 0.48, 0.37), Color(0.48, 0.41, 0.36)]
var hairs := [Color(0.23, 0.21, 0.18), Color(0.35, 0.29, 0.23), Color(0.42, 0.38, 0.35),
Color(0.18, 0.23, 0.27)]
var skins := [Color(0.85, 0.76, 0.65), Color(0.79, 0.68, 0.56), Color(0.88, 0.80, 0.71)]
var jacket: Color = jackets[clampi(int(photo.get("jacket", 0)), 0, jackets.size() - 1)]
var hair: Color = hairs[clampi(int(photo.get("hair", 0)), 0, hairs.size() - 1)]
var skin: Color = skins[clampi(int(photo.get("skin", 0)), 0, skins.size() - 1)]
var cx := rect.get_center().x
var bottom := rect.end.y - 3.0
draw_colored_polygon(PackedVector2Array([
Vector2(cx - 22.0, bottom), Vector2(cx - 14.0, bottom - 24.0),
Vector2(cx + 14.0, bottom - 24.0), Vector2(cx + 22.0, bottom)]), jacket)
draw_rect(Rect2(cx - 4.0, bottom - 38.0, 8.0, 14.0), skin)
draw_circle(Vector2(cx, bottom - 44.0), 11.0, skin)
draw_circle(Vector2(cx, bottom - 46.0), 11.0, hair)
draw_rect(Rect2(cx - 10.0, bottom - 46.0, 20.0, 8.0), skin)
if bool(photo.get("female", false)):
draw_rect(Rect2(cx - 12.0, bottom - 42.0, 4.0, 12.0), hair)
draw_rect(Rect2(cx + 8.0, bottom - 42.0, 4.0, 12.0), hair)
func _draw_stamp() -> void:
var label := "ВЕРНО" if _last_ok else "ПРОМАХ"
var color := Theme90.SUCCESS if _last_ok else Theme90.ERROR
var rect := Rect2(250.0, 148.0, 140.0, 36.0)
draw_style_box(Theme90.box(Theme90.PAPER_HI, color, 4, 2, Theme90.SHADOW), rect)
Theme90.text_vcenter(self, Theme90.ui(), rect, label, 16, color, -1,
HORIZONTAL_ALIGNMENT_CENTER)
func _draw_footer_hint() -> void:
if _finished:
return
var checks: Array = tier_params(tier).get("checks", ["name"])
var labels: Array = []
for check in checks:
labels.append(str(CHECK_LABELS.get(str(check), str(check))))
_draw_label("СВЕРЬТЕ: " + " · ".join(labels), Vector2(18.0, 350.0), 10,
Theme90.SHADE, 604.0)
-1
View File
@@ -1 +0,0 @@
uid://lj8d55h1pplr
-529
View File
@@ -1,529 +0,0 @@
class_name RansomwareGame
extends MiniGame
## Атака «Карантин» (ransomware). Сетка файлов, среди которых спрятаны
## исполняемые (.exe/.scr/.bat) с документными именами.
##
## Клик — «ИЗОЛИРОВАТЬ», повторный клик снимает пометку, ПРОВЕРИТЬ фиксирует.
## На T3/T4 расширение скрыто до наведения на карточку; на T4 есть ловушки,
## в имени которых есть «exe», но расширение doc/xls.
##
## Качество: q = clamp((found_exe - 0.5 * wrong_clicks) / total_exe, 0, 1).
const GAME_ID := "ransomware"
const TITLE_RU := "КАРАНТИН"
const VERIFY_RECT := Rect2(466.0, 320.0, 156.0, 28.0)
const GRID_TOP := 58.0
const GRID_BOTTOM := 306.0
const GRID_GAP := 8.0
const TIERS := {
1: {"cards": 12, "exe": 1, "threshold": 0.50, "masking": "extension_visible"},
2: {"cards": 16, "exe": 2, "threshold": 0.55, "masking": "extension_small"},
3: {"cards": 20, "exe": 2, "threshold": 0.60, "masking": "document_icon"},
4: {"cards": 24, "exe": 3, "threshold": 0.65, "masking": "similar_names"},
}
const EXEC_EXTS := ["EXE", "SCR", "BAT"]
const CLEAN_EXTS := ["DOC", "XLS", "TXT", "JPG", "BMP", "ZIP", "WAV", "AVI", "PDF"]
const BASE_NAMES := [
"отчёт", "смета", "письмо", "фото", "схема", "архив", "звук", "клип",
"опись", "прайс", "счёт", "курс", "факс", "лог", "план", "акт", "заявка",
"реестр", "накладная", "договор", "таблица", "список", "карта", "резерв",
"сводка", "ведомость", "протокол", "этикетка",
]
var _data: Dictionary = {}
var _cards: Array = []
var _marked: Dictionary = {}
var _submitted: bool = false
var _hover: int = -1
func _own_game_id() -> String:
return GAME_ID
func title() -> String:
return TITLE_RU
func quality() -> float:
if _finished:
return quality_value
var counts := count_marks(_cards, _marked)
return ransomware_quality(int(counts["found_exe"]), int(counts["wrong_clicks"]),
int(_data.get("total_exe", 0)))
func _setup() -> void:
_data = generate_cards(tier, seed_value)
_cards = _data.get("cards", [])
_marked.clear()
_submitted = false
_hover = -1
# --- параметры -------------------------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var base: Dictionary = (TIERS.get(t, TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier(GAME_ID, t)
if row.is_empty():
return base
for key in ["cards", "exe"]:
if row.has(key):
base[key] = int(row[key])
if row.has("threshold"):
base["threshold"] = float(row["threshold"])
if row.has("masking"):
base["masking"] = str(row["masking"])
return base
static func masking_for(p_tier: int) -> String:
return str(tier_params(p_tier).get("masking", "extension_visible"))
# --- генерация -------------------------------------------------------------
static func generate_cards(p_tier: int, seed: int) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var p := tier_params(t)
var total := clampi(int(p.get("cards", 12)), 8, 30)
var exe_count := clampi(int(p.get("exe", 1)), 1, total - 2)
var trap_count := 2 if t == 4 else 0
if total - exe_count - trap_count < 2:
trap_count = 0
var rng := RandomNumberGenerator.new()
rng.seed = seed
var names: Array = BASE_NAMES.duplicate()
_shuffle(names, rng)
var cursor := 0
var execs: Array = []
for i in range(exe_count):
var ext := str(EXEC_EXTS[rng.randi_range(0, EXEC_EXTS.size() - 1)])
execs.append({
"name": str(names[cursor % names.size()]),
"ext": ext,
"size_kb": rng.randi_range(18, 46),
"date": _random_date(rng),
"executable": true,
"target": false,
"trap": false,
})
cursor += 1
var traps: Array = []
for i in range(trap_count):
var base_name := str(names[cursor % names.size()])
cursor += 1
var trap_name := ("exe_" + base_name) if i % 2 == 0 else (base_name + "_exe")
traps.append({
"name": trap_name,
"ext": "DOC" if i % 2 == 0 else "XLS",
"size_kb": rng.randi_range(2, 480),
"date": _random_date(rng),
"executable": false,
"target": false,
"trap": true,
})
var clean_count := total - execs.size() - traps.size()
var cleans: Array = []
for i in range(clean_count):
var ext := str(CLEAN_EXTS[rng.randi_range(0, CLEAN_EXTS.size() - 1)])
cleans.append({
"name": str(names[cursor % names.size()]),
"ext": ext,
"size_kb": rng.randi_range(2, 480),
"date": _random_date(rng),
"executable": false,
"target": false,
"trap": false,
})
cursor += 1
# Ровно одна «настоящая» цель; остальные исполняемые — тоже угрозы,
# но флаг target не дублируется. Ловушки целью не бывают никогда.
var target_index := rng.randi_range(0, execs.size() - 1)
(execs[target_index] as Dictionary)["target"] = true
# Хотя бы один чистый файл совпадает по размеру с исполняемым, чтобы
# размер сам по себе не выдавал цель.
if not cleans.is_empty():
(cleans[0] as Dictionary)["size_kb"] = int((execs[0] as Dictionary)["size_kb"])
var descriptors: Array = []
descriptors.append_array(execs)
descriptors.append_array(traps)
descriptors.append_array(cleans)
_shuffle(descriptors, rng)
var cards: Array = []
for i in range(descriptors.size()):
var d: Dictionary = descriptors[i]
var size_kb := int(d.get("size_kb", 2))
cards.append({
"id": i,
"name": str(d.get("name", "")),
"ext": str(d.get("ext", "")),
"size_kb": size_kb,
"size_text": "%d КБ" % size_kb,
"date": str(d.get("date", "01.01.97")),
"executable": bool(d.get("executable", false)),
"target": bool(d.get("target", false)),
"trap": bool(d.get("trap", false)),
})
return {
"tier": t,
"cards": cards,
"total_cards": total,
"total_exe": exe_count,
"trap_count": trap_count,
"threshold": float(p.get("threshold", 0.5)),
"masking": str(p.get("masking", "extension_visible")),
}
static func _random_date(rng: RandomNumberGenerator) -> String:
var day := rng.randi_range(1, 28)
var month := rng.randi_range(1, 12)
var year := 96 + rng.randi_range(0, 1)
return "%02d.%02d.%02d" % [day, month, year]
# --- проверка --------------------------------------------------------------
static func validate_cards(data: Dictionary) -> bool:
var t := clampi(int(data.get("tier", 1)), 1, 4)
var p := tier_params(t)
var cards: Array = data.get("cards", [])
var total := int(data.get("total_cards", -1))
if cards.size() != total or total != int(p.get("cards", -1)):
return false
if int(data.get("total_exe", -1)) != int(p.get("exe", -1)):
return false
var exec_count := 0
var target_count := 0
var trap_count := 0
var names: Dictionary = {}
for card in cards:
var c: Dictionary = card
var name_key := str(c.get("name", ""))
if names.has(name_key):
return false
names[name_key] = true
var ext := str(c.get("ext", "")).to_upper()
if bool(c.get("trap", false)):
trap_count += 1
if bool(c.get("executable", false)) or bool(c.get("target", false)):
return false
if t != 4:
return false
if not name_key.to_lower().contains("exe"):
return false
if ext != "DOC" and ext != "XLS":
return false
if bool(c.get("executable", false)):
exec_count += 1
if not EXEC_EXTS.has(ext):
return false
if bool(c.get("trap", false)):
return false
else:
if EXEC_EXTS.has(ext):
return false
if not bool(c.get("trap", false)) and name_key.to_lower().contains("exe"):
return false
if bool(c.get("target", false)):
target_count += 1
if not bool(c.get("executable", false)):
return false
if exec_count < 1 or exec_count != int(p.get("exe", 0)):
return false
if target_count > 1:
return false
if t <= 2 and target_count != 1:
return false
if trap_count != int(data.get("trap_count", 0)):
return false
if t != 4 and trap_count != 0:
return false
return true
static func ransomware_quality(found_exe: int, wrong_clicks: int, total_exe: int) -> float:
if total_exe <= 0:
return 0.0
return clampf((float(found_exe) - 0.5 * float(wrong_clicks)) / float(total_exe),
0.0, 1.0)
static func count_marks(cards: Array, marked: Dictionary) -> Dictionary:
var found := 0
var wrong := 0
var missed := 0
for card in cards:
var c: Dictionary = card
var is_marked := bool(marked.get(int(c.get("id", -1)), false))
if bool(c.get("executable", false)):
if is_marked:
found += 1
else:
missed += 1
elif is_marked:
wrong += 1
return {"found_exe": found, "wrong_clicks": wrong, "missed_exe": missed,
"marked": int(marked.size())}
# --- игра ------------------------------------------------------------------
func _card_rects() -> Array:
var n := _cards.size()
if n <= 0:
return []
var cols := 4
if n > 20:
cols = 6
elif n > 16:
cols = 5
var rows := int(ceil(float(n) / float(cols)))
var card_w := (604.0 - GRID_GAP * float(cols - 1)) / float(cols)
var avail_h := GRID_BOTTOM - GRID_TOP
var card_h := minf(64.0, (avail_h - GRID_GAP * float(rows - 1)) / float(rows))
var rects: Array = []
for i in range(n):
var col := i % cols
var row := i / cols
rects.append(Rect2(18.0 + float(col) * (card_w + GRID_GAP),
GRID_TOP + float(row) * (card_h + GRID_GAP), card_w, card_h))
return rects
func _card_index_at(local: Vector2) -> int:
var rects := _card_rects()
for i in range(rects.size()):
if (rects[i] as Rect2).has_point(local):
return i
return -1
func _ext_visible(card: Dictionary, index: int) -> bool:
var masking := str(_data.get("masking", "extension_visible"))
if masking == "extension_visible" or masking == "extension_small":
return true
return index == _hover
func _toggle(index: int) -> void:
if index < 0 or index >= _cards.size():
return
var id := int((_cards[index] as Dictionary).get("id", index))
if _marked.has(id):
_marked.erase(id)
else:
_marked[id] = true
_play_sfx(&"tick")
queue_redraw()
func _submit() -> void:
if _submitted or _finished:
return
var counts := count_marks(_cards, _marked)
var q := ransomware_quality(int(counts["found_exe"]), int(counts["wrong_clicks"]),
int(_data.get("total_exe", 0)))
_submitted = true
_result_detail = _with_context("НАЙДЕНО %d ИЗ %d · ЛОЖНЫХ %d · ПРОПУЩЕНО %d" % [
int(counts["found_exe"]), int(_data.get("total_exe", 0)),
int(counts["wrong_clicks"]), int(counts["missed_exe"])])
_finish(q)
func _with_context(text: String) -> String:
var parts: Array = []
var reward := str(context.get("reward_text", ""))
var penalty := str(context.get("penalty_text", ""))
if not reward.is_empty():
parts.append(reward)
if not penalty.is_empty():
parts.append(penalty)
parts.append(text)
return " · ".join(parts)
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if _submitted or _finished:
return false
if VERIFY_RECT.has_point(local):
_submit()
return true
var index := _card_index_at(local)
if index >= 0:
_toggle(index)
return true
return false
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if _submitted or _finished:
return
var next := _card_index_at(local)
if next != _hover:
_hover = next
queue_redraw()
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if _submitted or _finished:
return false
if keycode in [KEY_ENTER, KEY_KP_ENTER, KEY_SPACE]:
_submit()
return true
return false
# --- отрисовка -------------------------------------------------------------
func _draw_game() -> void:
_draw_label("ОТМЕЧЕНО: %d" % _marked.size(), Vector2(24.0, 44.0), 12,
Theme90.INK, 220.0)
var masking := str(_data.get("masking", "extension_visible"))
var mode_text := "РАСШИРЕНИЕ ВИДНО"
if masking == "document_icon":
mode_text = "НАВЕДИТЕ НА ФАЙЛ: РАСШИРЕНИЕ В ТУЛТИПЕ"
elif masking == "similar_names":
mode_text = "ОСТОРОЖНО: ИМЕНА С «EXE» МОГУТ БЫТЬ ЛОВУШКОЙ"
elif masking == "extension_small":
mode_text = "РАСШИРЕНИЕ МЕЛКИМ ШРИФТОМ"
_draw_label(mode_text, Vector2(230.0, 44.0), 10, Theme90.SHADE, 382.0,
HORIZONTAL_ALIGNMENT_RIGHT)
var rects := _card_rects()
for i in range(_cards.size()):
_draw_card(i, rects[i] if i < rects.size() else Rect2())
_draw_action_button(VERIFY_RECT, "ПРОВЕРИТЬ", not _submitted, true)
func _draw_card(index: int, rect: Rect2) -> void:
if rect.size.x <= 0.0:
return
var card: Dictionary = _cards[index]
var marked := _marked.has(int(card.get("id", -1)))
var executable := bool(card.get("executable", false))
var trap := bool(card.get("trap", false))
var fill := Theme90.PAPER_HI
var border := Theme90.BORDER
var border_w := 1
var status := ""
if _submitted:
if executable and marked:
fill = Theme90.SUCCESS.lightened(0.78)
border = Theme90.SUCCESS
border_w = 2
status = "ИЗОЛИРОВАН"
elif executable and not marked:
fill = Theme90.WARN.lightened(0.78)
border = Theme90.WARN
border_w = 2
status = "ПРОПУЩЕН"
elif not executable and marked:
fill = Theme90.ERROR.lightened(0.80)
border = Theme90.ERROR
border_w = 2
status = "ЛОЖНО"
elif trap:
border = Theme90.BORDER_SOFT
elif marked:
fill = Theme90.ERROR.lightened(0.90)
border = Theme90.ERROR
border_w = 2
status = "ИЗОЛИРОВАТЬ"
elif index == _hover:
border = Theme90.ACCENT
border_w = 2
draw_style_box(Theme90.box(fill, border, 3, border_w), rect)
var ext := str(card.get("ext", ""))
var show_ext := _ext_visible(card, index)
var icon_ext := ext if show_ext else ""
var icon_kind := "text"
if show_ext:
icon_kind = _kind_for_ext(ext)
var icon_col := Theme90.ext_color(Theme90.ext_category(ext))
if not show_ext:
icon_col = Theme90.NAVY
var icon_size := 24.0 if rect.size.x >= 120.0 else 20.0
Theme90.draw_doc_icon(self, Rect2(rect.position + Vector2(6.0, 7.0),
Vector2(icon_size, icon_size)), icon_kind, icon_col, icon_ext)
var name_size := 12 if rect.size.x >= 130.0 else 10
var text_x := rect.position.x + icon_size + 12.0
var text_w := rect.size.x - icon_size - 18.0
Theme90.text(self, Theme90.ui(), Vector2(text_x, rect.position.y + 6.0),
Theme90.fit(Theme90.ui(), str(card.get("name", "")), name_size, text_w),
name_size, Theme90.INK, text_w)
var meta := str(card.get("size_text", ""))
if show_ext:
meta = "%s · %s" % [ext, meta]
Theme90.text(self, Theme90.ui(), Vector2(text_x, rect.position.y + 23.0),
Theme90.fit(Theme90.ui(), meta, 10, text_w), 10, Theme90.DARK, text_w)
var bottom_text := str(card.get("date", ""))
var bottom_col := Theme90.SHADE
if not status.is_empty():
bottom_text = status
bottom_col = Theme90.ERROR
if _submitted and status == "ИЗОЛИРОВАН":
bottom_col = Theme90.SUCCESS
elif status == "ПРОПУЩЕН":
bottom_col = Theme90.WARN
Theme90.text(self, Theme90.ui(), Vector2(rect.position.x + 6.0,
rect.position.y + rect.size.y - 14.0),
Theme90.fit(Theme90.ui(), bottom_text, 10, rect.size.x - 12.0), 10,
bottom_col, rect.size.x - 12.0)
func _kind_for_ext(ext: String) -> String:
match Theme90.ext_category(ext):
"audio":
return "sound"
"image":
return "photo"
"video":
return "video"
return "text"
func _draw_footer_hint() -> void:
var masking := str(_data.get("masking", "extension_visible"))
var text := "ИЩИТЕ ИСПОЛНЯЕМЫЕ ФАЙЛЫ · КЛИК — ИЗОЛИРОВАТЬ"
if masking == "document_icon":
text = "ИКОНКА ДОКУМЕНТА НИЧЕГО НЕ ЗНАЧИТ · НАВЕДИТЕ НА ФАЙЛ"
elif masking == "similar_names":
text = "«EXE» В ИМЕНИ — ЕЩЁ НЕ ПРИГОВОР · ПРОВЕРЬТЕ РАСШИРЕНИЕ"
_draw_label(text, Vector2(18.0, 350.0), 10, Theme90.SHADE, 604.0)
# --- служебное -------------------------------------------------------------
static func _shuffle(list: Array, rng: RandomNumberGenerator) -> void:
for i in range(list.size() - 1, 0, -1):
var j := rng.randi_range(0, i)
var tmp = list[i]
list[i] = list[j]
list[j] = tmp
@@ -1 +0,0 @@
uid://bvm5eshivri3g
+120 -411
View File
@@ -1,66 +1,35 @@
class_name MiniGame
extends Control
## Базовая мини-игра v2: без таймера, локальный сид, quality 0..1.
## Заглушка мини-игры запроса (D17).
##
## Окно 640×360. Общая шапка, кнопка «ПРОПУСТИТЬ» с двухкликовым
## подтверждением и экран результата. Конкретные игры наследуются от этого
## класса и переопределяют _setup(), _draw_game(), обработчики ввода.
##
## Важно: здесь нет узлов-таймеров и нет поражения по времени. Игра
## завершается только действием игрока (конечная цель) или пропуском.
## Окно 640×360, заголовок по типу файла, полоса заполняется за 2 с,
## кнопки «Отправить» / «Переделать». quality всегда 1.0; «Переделать»
## лишь перезапускает полосу и не меняет качество. Esc — no-op (D10).
## Рисуется кодом, без UI-компонентов.
signal finished(quality: float)
const WINDOW_SIZE := Vector2(640.0, 360.0)
const HEADER_H := 30.0
const FOOTER_Y := 320.0
const CONTENT_RECT := Rect2(18.0, 40.0, 604.0, 270.0)
const ACTION_RECT := Rect2(466.0, 320.0, 156.0, 28.0)
const SKIP_RECT := Rect2(18.0, 320.0, 148.0, 28.0)
const CONTINUE_RECT := Rect2(245.0, 252.0, 150.0, 32.0)
const DURATION := 2.0
const QUALITY_STUB := 1.0
const SEND_IDS: Array[String] = [
"dots", "sound", "wires", "text", "video", "photo", "maze",
]
const GAME_TITLES := {
"dots": "ОДНА ЛИНИЯ",
"sound": "УРОВНИ",
"wires": "ПРОВОДА",
"text": "ЧУЖОЕ СЛОВО",
"video": "РАСКАДРОВКА",
"photo": "КАДР",
"maze": "ЛАБИРИНТ",
}
const LEGACY_TYPES := {
"text": "text",
"sound": "sound",
"photo": "photo",
"video": "video",
"connection": "wires",
"connect": "wires",
const SEND_RECT := Rect2(150.0, 282.0, 150.0, 34.0)
const REDO_RECT := Rect2(340.0, 282.0, 150.0, 34.0)
const BAR_RECT := Rect2(100.0, 210.0, 440.0, 22.0)
const TYPE_NAMES := {
"text": "ТЕКСТ",
"sound": "ЗВУК",
"photo": "ФОТО",
"video": "ВИДЕО",
}
## Общие поля контракта.
var game_id: String = ""
var tier: int = 1
var rng := RandomNumberGenerator.new()
var context: Dictionary = {}
var quality_value: float = 0.0
var running: bool = false
var game_type: String = "text"
var difficulty: float = 1.0
var seed_value: int = 0
## true для мини-игр атак: кнопка отказа — СДАТЬСЯ, Esc взводит/подтверждает
## сдачу (§5.1/§5.5). Мини-игры отправки оставляют ПРОПУСТИТЬ и Esc — no-op.
var is_attack: bool = false
var progress: float = 0.0
var running: bool = false
var _finished: bool = false
var _skip_armed: bool = false
var _result_detail: String = ""
## D2 (§5.5): если true, _finish() только показывает экран результата и НЕ
## эмитит finished. Сигнал уходит по клику «ПРОДОЛЖИТЬ» (Main включает режим
## для модальных мини-игр). unit-тесты без Main работают с default=false.
var defer_finish_signal: bool = false
var _pending_finish: bool = false
var _emitted: bool = false
func _init() -> void:
@@ -68,289 +37,154 @@ func _init() -> void:
size = WINDOW_SIZE
custom_minimum_size = WINDOW_SIZE
mouse_filter = Control.MOUSE_FILTER_STOP
focus_mode = Control.FOCUS_CLICK
visible = false
set_process(true)
set_process(false)
set_process_input(true)
func _ready() -> void:
_center_on_screen()
var viewport := get_viewport()
if viewport != null and not viewport.size_changed.is_connected(_center_on_screen):
viewport.size_changed.connect(_center_on_screen)
get_viewport().size_changed.connect(_center_on_screen)
## Совместимая сигнатура со старым main/hour_director:
## start(type_or_id, difficulty, seed, context).
func start(type_or_id: String, p_difficulty: float = 1.0, p_seed: int = 0,
p_context: Dictionary = {}) -> void:
var requested := str(type_or_id).to_lower()
var resolved := resolve_game_id(requested)
if resolved.is_empty():
resolved = _own_game_id()
if resolved.is_empty():
resolved = requested if not requested.is_empty() else "dots"
game_id = resolved
func start(type: String, p_difficulty: float = 1.0) -> void:
game_type = str(type)
if not TYPE_NAMES.has(game_type):
game_type = "text"
difficulty = maxf(0.0, float(p_difficulty))
# Старый вызов передавал 2.5 и т.п.; фабрика передаёт 1..4.
tier = clampi(int(round(difficulty)), 1, 4)
if difficulty >= 1.0 and difficulty <= 4.0 and is_equal_approx(difficulty, roundf(difficulty)):
tier = clampi(int(difficulty), 1, 4)
context = p_context.duplicate(true)
seed_value = p_seed
if seed_value == 0:
seed_value = int(Time.get_ticks_usec()) ^ int(hash("%s:%d:%d" % [game_id, tier, Time.get_ticks_msec()]))
rng = RandomNumberGenerator.new()
rng.seed = seed_value
_emitent_reset()
_setup()
_emitted = false
running = true
progress = 0.0
visible = true
_skip_armed = false
_result_detail = ""
set_process(true)
set_process_input(true)
_center_on_screen()
queue_redraw()
## Сброс состояния при повторном start() на том же узле.
func _emitent_reset() -> void:
_finished = false
quality_value = 0.0
_skip_armed = false
_result_detail = ""
_pending_finish = false
## Переопределяется наследником: генерация поля/состояния.
func _setup() -> void:
pass
## База не анимируется; наследники (sound/photo/maze) переопределяют.
func _process(_delta: float) -> void:
pass
## Идентификатор игры самого класса (нужен для прямого new().start()).
func _own_game_id() -> String:
return ""
func title() -> String:
return str(GAME_TITLES.get(game_id, "МИНИ-ИГРА"))
func tier_label() -> String:
return "T%d" % tier
## Подпись кнопки отказа: атаки сдаются, отправки пропускаются.
func skip_label() -> String:
return "СДАТЬСЯ" if is_attack else "ПРОПУСТИТЬ"
func quality() -> float:
return quality_value
return QUALITY_STUB
func is_finished() -> bool:
return _finished
## Правило выбора типа по среднему коэффициенту (GDD §8.4 / D17).
static func select_type(types: Array) -> String:
var total := 0.0
if types.is_empty():
return "text"
for type in types:
total += _coeff(str(type))
var difficulty := total / float(types.size())
if difficulty < 1.5:
return "text"
if difficulty < 2.5:
return "sound"
if difficulty < 3.5:
return "photo"
return "video"
func verdict_for(q: float) -> String:
if q >= 0.75:
return "ЧИСТО"
if q >= 0.45:
return "НОРМАЛЬНО"
return "СЛАБО"
static func type_label(type: String) -> String:
return str(TYPE_NAMES.get(str(type), "ТЕКСТ"))
func result_text() -> String:
return "КАЧЕСТВО %d%% · %s" % [roundi(quality_value * 100.0),
verdict_for(quality_value)]
static func _coeff(type: String) -> float:
match type:
"sound":
return 2.0
"photo":
return 3.0
"video":
return 4.0
return 1.0
## Завершение игры. Эмитит ровно один раз, клампит q в 0..1.
func _finish(q: float) -> void:
if _finished:
func _process(delta: float) -> void:
if not running:
return
quality_value = clampf(float(q), 0.0, 1.0)
_finished = true
running = false
set_process(false)
_skip_armed = false
if _result_detail.is_empty():
_result_detail = _default_result_detail()
_play_sfx(&"minigame_win" if quality_value >= 0.45 else &"minigame_fail")
if defer_finish_signal:
# Экран результата уже нарисован; сигнал ждёт «ПРОДОЛЖИТЬ».
_pending_finish = true
else:
finished.emit(quality_value)
queue_redraw()
if progress < DURATION:
progress = minf(DURATION, progress + minf(maxf(delta, 0.0), 0.1))
queue_redraw()
## Эмитит отложенный finished ровно один раз (если _finish уже случился).
func _emit_finished_once() -> void:
if not _pending_finish:
return
_pending_finish = false
finished.emit(quality_value)
func _default_result_detail() -> String:
var reward := str(context.get("reward_text", ""))
var penalty := str(context.get("penalty_text", ""))
if not reward.is_empty():
return reward
return penalty
# --- ввод ------------------------------------------------------------------
func _input(event: InputEvent) -> void:
if event is InputEventKey:
if not event.pressed or event.echo:
return
if _finished:
if event.keycode in [KEY_ENTER, KEY_KP_ENTER, KEY_SPACE, KEY_ESCAPE]:
_mark_input_handled()
_continue_clicked()
return
if not running:
return
if event is InputEventKey and event.pressed and not event.echo:
if event.keycode == KEY_ESCAPE:
# В мини-играх отправки Esc — no-op (D10), но пауза часа его не ловит.
# В мини-играх атак Esc — двухшаговое подтверждение сдачи.
# D10: Esc в мини-игре — no-op, но событие не отдаём паузе часа.
_mark_input_handled()
if is_attack:
_on_skip_clicked()
return
if _on_key_pressed(event.keycode, event):
if event.keycode == KEY_ENTER or event.keycode == KEY_KP_ENTER:
_mark_input_handled()
return
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
var local: Vector2 = event.position - global_position
if _finished:
if event.pressed and _result_continue_rect().has_point(local):
_mark_input_handled()
_continue_clicked()
return
if event.pressed:
if SKIP_RECT.has_point(local):
_mark_input_handled()
_on_skip_clicked()
return
if _on_pointer_pressed(local, event):
_mark_input_handled()
else:
_on_pointer_released(local, event)
return
if event is InputEventMouseMotion:
if _finished:
_send()
return
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
var local: Vector2 = event.position - global_position
_on_pointer_moved(local, event)
if SEND_RECT.has_point(local) and progress >= DURATION:
_mark_input_handled()
_send()
elif REDO_RECT.has_point(local):
_mark_input_handled()
_restart_bar()
func _on_pointer_pressed(_local: Vector2, _event: InputEvent) -> bool:
return false
func _on_pointer_released(_local: Vector2, _event: InputEvent) -> void:
pass
func _on_pointer_moved(_local: Vector2, _event: InputEvent) -> void:
pass
func _on_key_pressed(_keycode: int, _event: InputEvent) -> bool:
return false
func _on_skip_clicked() -> void:
if not _skip_armed:
_skip_armed = true
_play_sfx(&"tick")
queue_redraw()
func _send() -> void:
if _emitted:
return
_finish(0.0)
func _continue_clicked() -> void:
# Модальный режим: сначала отдаём результат директору, затем освобождаем ноду.
_emit_finished_once()
_emitted = true
running = false
set_process(false)
set_process_input(false)
_play_sfx(&"minigame_win")
finished.emit(QUALITY_STUB)
queue_free()
func _mark_input_handled() -> void:
var viewport := get_viewport()
if viewport != null:
viewport.set_input_as_handled()
func _restart_bar() -> void:
# D17: «Переделать» перезапускает заглушку, quality не меняется.
progress = 0.0
queue_redraw()
# --- отрисовка -------------------------------------------------------------
func _draw() -> void:
var font := Theme90.ui()
var full := Rect2(Vector2.ZERO, size)
var full := Rect2(Vector2.ZERO, WINDOW_SIZE)
Theme90.draw_bevel(self, full, false, Theme90.BEVEL, Theme90.PAPER)
var tr := Rect2(1.0, 1.0, size.x - 2.0, HEADER_H - 2.0)
# Светлый титлбар с teal-акцентом.
var tr := Rect2(1.0, 1.0, WINDOW_SIZE.x - 2.0, 28.0)
draw_style_box(Theme90.top_box(Theme90.PAPER_HI, 4), tr)
draw_rect(Rect2(tr.position.x + 1.0, tr.end.y, tr.size.x - 2.0, 1.0),
Theme90.HAIRLINE)
draw_circle(Vector2(tr.position.x + 14.0, tr.position.y + 14.0), 3.0,
Theme90.ACCENT)
Theme90.text(self, font, Vector2(tr.position.x + 24.0, tr.position.y + 7.0),
title(), 16, Theme90.NAVY_DEEP, tr.size.x - 210.0)
draw_circle(Vector2(tr.position.x + 14.0, tr.position.y + 14.0), 3.0, Theme90.ACCENT)
var title := "МИНИ-ИГРА: %s" % type_label(game_type)
Theme90.text(self, font, Vector2(tr.position.x + 25.0, tr.position.y + 7.0),
title, 14, Theme90.NAVY, tr.size.x - 40.0)
var badge := Rect2(tr.end.x - 196.0, tr.position.y + 7.0, 42.0, 16.0)
draw_style_box(Theme90.box(Theme90.ACCENT_PALE, Theme90.BORDER_SOFT, 2, 1), badge)
Theme90.text_vcenter(self, font, badge, tier_label(), 10, Theme90.NAVY,
-1, HORIZONTAL_ALIGNMENT_CENTER)
Theme90.text(self, font, Vector2(tr.end.x - 146.0, tr.position.y + 8.0),
"БЕЗ ТАЙМЕРА", 10, Theme90.SHADE, 130.0, HORIZONTAL_ALIGNMENT_RIGHT)
Theme90.text(self, font, Vector2(40.0, 64.0), "ЗАГЛУШКА",
20, Theme90.INK)
Theme90.text(self, font, Vector2(40.0, 94.0),
"Сложность: %.2f · quality = %.1f" % [difficulty, QUALITY_STUB],
12, Theme90.SHADE)
Theme90.text(self, font, Vector2(40.0, 118.0),
"Подготовьте файл к отправке. Esc не закрывает окно.",
12, Theme90.DARK)
_draw_game()
_draw_skip_button()
_draw_footer_hint()
if _finished:
_draw_result_overlay()
# Полоса 2 с: тонкая дорожка + мягкая заливка.
Theme90.text(self, font, Vector2(BAR_RECT.position.x, BAR_RECT.position.y - 20.0),
"ОБРАБОТКА %.1f / %.1f с" % [progress, DURATION], 11, Theme90.SHADE)
Theme90.draw_sunken(self, BAR_RECT)
var ratio := clampf(progress / DURATION, 0.0, 1.0)
Theme90.draw_meter(self, BAR_RECT.grow(-1.0), ratio, Theme90.SUCCESS,
Theme90.PAPER_LO)
# Компактные кнопки в стиле v2.
_draw_button(SEND_RECT, "Отправить", ratio >= 1.0, true)
_draw_button(REDO_RECT, "Переделать", true, false)
## Переопределяется наследником. Рисовать в CONTENT_RECT.
func _draw_game() -> void:
pass
## Необязательная подпись над футером/в футере.
func _draw_footer_hint() -> void:
pass
func _draw_skip_button() -> void:
var face := Theme90.WARN if _skip_armed else Theme90.PAPER_HI
var fg := Theme90.WHITE if _skip_armed else Theme90.DARK
var border := Theme90.WARN.darkened(0.18) if _skip_armed else Theme90.BORDER
draw_style_box(Theme90.box(face, border, 3, 1,
Theme90.SHADOW if not _skip_armed else Color(0, 0, 0, 0)), SKIP_RECT)
var label := skip_label()
if _skip_armed:
label = "ТОЧНО СДАТЬСЯ?" if is_attack else "ТОЧНО ПРОПУСТИТЬ?"
Theme90.text_vcenter(self, Theme90.ui(), SKIP_RECT, label, 10, fg,
-1, HORIZONTAL_ALIGNMENT_CENTER)
func _draw_action_button(rect: Rect2, label: String, enabled: bool = true,
primary: bool = true) -> void:
func _draw_button(rect: Rect2, label: String, enabled: bool, primary: bool = false) -> void:
var font := Theme90.ui()
var face := Theme90.PAPER_HI
var fg := Theme90.INK
var border := Theme90.BORDER
@@ -364,130 +198,8 @@ func _draw_action_button(rect: Rect2, label: String, enabled: bool = true,
border = Theme90.BORDER_SOFT
draw_style_box(Theme90.box(face, border, 3, 1,
Theme90.SHADOW if enabled else Color(0, 0, 0, 0)), rect)
Theme90.text_vcenter(self, Theme90.ui(), rect, label, 12, fg,
-1, HORIZONTAL_ALIGNMENT_CENTER)
func _draw_secondary_button(rect: Rect2, label: String) -> void:
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 3, 1), rect)
Theme90.text_vcenter(self, Theme90.ui(), rect, label, 10, Theme90.DARK,
-1, HORIZONTAL_ALIGNMENT_CENTER)
func _draw_modal_panel(rect: Rect2, fill: Color = Theme90.PAPER_HI) -> void:
draw_style_box(Theme90.box(fill, Theme90.BORDER, 4, 1, Theme90.SHADOW), rect)
func _draw_label(text: String, pos: Vector2, size_px: int = 12,
color: Color = Theme90.INK, width: float = -1.0,
align: int = HORIZONTAL_ALIGNMENT_LEFT) -> void:
Theme90.text(self, Theme90.ui(), pos, text, size_px, color, width, align)
func _draw_centered(text: String, rect: Rect2, size_px: int = 12,
color: Color = Theme90.INK) -> void:
Theme90.text_vcenter(self, Theme90.ui(), rect, text, size_px, color,
-1, HORIZONTAL_ALIGNMENT_CENTER)
func _draw_bar(rect: Rect2, ratio: float, color: Color) -> void:
Theme90.draw_sunken(self, rect)
Theme90.draw_meter(self, rect.grow(-1.0), clampf(ratio, 0.0, 1.0), color,
Theme90.PAPER_LO)
func _result_continue_rect() -> Rect2:
var panel := _result_panel_rect()
return Rect2(panel.position.x + (panel.size.x - CONTINUE_RECT.size.x) * 0.5,
panel.position.y + panel.size.y - CONTINUE_RECT.size.y - 16.0,
CONTINUE_RECT.size.x, CONTINUE_RECT.size.y)
func _result_panel_rect() -> Rect2:
return Rect2((size.x - 380.0) * 0.5, (size.y - 196.0) * 0.5, 380.0, 196.0)
func _draw_result_overlay() -> void:
draw_rect(Rect2(Vector2.ZERO, size), Color(0.08, 0.10, 0.10, 0.58))
var panel := _result_panel_rect()
_draw_modal_panel(panel)
Theme90.text(self, Theme90.logo(), Vector2(panel.position.x, panel.position.y + 18.0),
"РЕЗУЛЬТАТ", 16, Theme90.NAVY_DEEP, panel.size.x,
Theme90.text_vcenter(self, font, rect, label, 12, fg, -1,
HORIZONTAL_ALIGNMENT_CENTER)
var q_text := "%d%%" % roundi(quality_value * 100.0)
Theme90.text(self, Theme90.logo(), Vector2(panel.position.x, panel.position.y + 50.0),
q_text, 24, Theme90.INK, panel.size.x, HORIZONTAL_ALIGNMENT_CENTER)
var verdict := verdict_for(quality_value)
var vcol := Theme90.SUCCESS if quality_value >= 0.75 else (
Theme90.WARN if quality_value >= 0.45 else Theme90.ERROR)
Theme90.text(self, Theme90.ui(), Vector2(panel.position.x, panel.position.y + 104.0),
verdict, 24, vcol, panel.size.x, HORIZONTAL_ALIGNMENT_CENTER)
if not _result_detail.is_empty():
Theme90.text(self, Theme90.ui(), Vector2(panel.position.x + 18.0,
panel.position.y + 142.0), Theme90.fit(Theme90.ui(), _result_detail, 10,
panel.size.x - 36.0), 10, Theme90.SHADE, panel.size.x - 36.0,
HORIZONTAL_ALIGNMENT_CENTER)
var cont := _result_continue_rect()
_draw_action_button(cont, "ПРОДОЛЖИТЬ", true, true)
# --- служебное -------------------------------------------------------------
static func resolve_game_id(value: String) -> String:
var v := value.strip_edges().to_lower()
if SEND_IDS.has(v):
return v
return str(LEGACY_TYPES.get(v, ""))
## Безопасное чтение data/minigame_def(id) с fallback на balance.json.
## Возвращает копию словаря; при отсутствии данных — пустой словарь.
static func data_minigame_def(id: String) -> Dictionary:
var loop := Engine.get_main_loop()
if not (loop is SceneTree):
return {}
var root := (loop as SceneTree).root
var data = root.get_node_or_null("Data")
if data == null:
return {}
if data.has_method("minigame_def"):
var value = data.call("minigame_def", id)
if value is Dictionary:
return (value as Dictionary).duplicate(true)
var balance = data.get("balance")
if balance is Dictionary:
var minigames = (balance as Dictionary).get("minigames", null)
if minigames is Dictionary:
var games = (minigames as Dictionary).get("games", null)
if games is Dictionary and (games as Dictionary).get(id) is Dictionary:
return ((games as Dictionary).get(id) as Dictionary).duplicate(true)
return {}
## Строка tier из Data.minigame_def(id). Поддерживает tiers как Array
## (текущий формат balance.json) и как Dictionary (старый/альтернативный).
static func data_minigame_tier(id: String, p_tier: int) -> Dictionary:
var def := data_minigame_def(id)
if def.is_empty():
return {}
var t := clampi(p_tier, 1, 4)
var tiers = def.get("tiers", null)
var row: Dictionary = {}
if tiers is Array:
var arr := tiers as Array
if t - 1 < arr.size() and arr[t - 1] is Dictionary:
row = (arr[t - 1] as Dictionary).duplicate(true)
elif tiers is Dictionary:
var map := tiers as Dictionary
var value = map.get(str(t), map.get(t, null))
if value is Dictionary:
row = (value as Dictionary).duplicate(true)
if row.is_empty():
# Плоская схема: параметры прямо в def (без tiers).
for key in def.keys():
if str(key) != "tiers":
row[key] = def[key]
return row
func _center_on_screen() -> void:
@@ -500,6 +212,12 @@ func _center_on_screen() -> void:
position.y = maxf(0.0, position.y)
func _mark_input_handled() -> void:
var viewport := get_viewport()
if viewport != null:
viewport.set_input_as_handled()
func _play_sfx(id: StringName) -> void:
var audio = _autoload("Audio")
if audio != null and audio.has_method("play_sfx"):
@@ -511,12 +229,3 @@ func _autoload(name: String):
if loop is SceneTree:
return (loop as SceneTree).root.get_node_or_null(name)
return null
## Удобный static-хелпер для игр, которым нужен общий вердикт.
static func verdict(q: float) -> String:
if q >= 0.75:
return "ЧИСТО"
if q >= 0.45:
return "НОРМАЛЬНО"
return "СЛАБО"
-131
View File
@@ -1,131 +0,0 @@
class_name MiniGameFactory
extends RefCounted
## Фабрика мини-игр v2 и выбор игры для отправки.
##
## create_send() загружает res://scripts/minigames/send/<id>.gd.
## create_attack() — заготовка: возвращает null, если скрипта атаки ещё нет.
## send_for_types() сначала спрашивает Economy.send_minigame_for(), если этот
## метод уже реализован другим агентом, иначе использует локальную формулу.
const SEND_IDS: Array[String] = [
"dots", "sound", "wires", "text", "video", "photo", "maze",
]
const ATTACK_IDS: Array[String] = [
"junk", "ddos", "phishing", "ransomware",
]
const COEFF := {
"text": 1,
"sound": 2,
"photo": 4,
"video": 8,
}
const SEND_DIR := "res://scripts/minigames/send"
const ATTACK_DIR := "res://scripts/minigames/attack"
const ECONOMY_PATH := "res://scripts/core/economy.gd"
static func create_send(game_id: String, tier: int, seed: int,
context: Dictionary = {}) -> MiniGame:
var id := str(game_id).strip_edges().to_lower()
if not SEND_IDS.has(id):
id = MiniGame.resolve_game_id(id)
if not SEND_IDS.has(id):
return null
var path := "%s/%s.gd" % [SEND_DIR, id]
if not ResourceLoader.exists(path):
return null
var script = load(path)
if script == null:
return null
var node = script.new()
if node == null or not (node is MiniGame):
if node != null:
node.free()
return null
node.start(id, clampi(tier, 1, 4), seed, context)
return node
## Атаки добавляются отдельным агентом. Пока скрипта нет — null.
static func create_attack(kind: String, tier: int, seed: int,
context: Dictionary = {}):
var id := str(kind).strip_edges().to_lower()
if id.is_empty():
return null
var path := "%s/%s.gd" % [ATTACK_DIR, id]
if not ResourceLoader.exists(path):
return null
var script = load(path)
if script == null:
return null
var node = script.new()
if node == null or not (node is MiniGame):
if node != null:
node.free()
return null
node.start(id, clampi(tier, 1, 4), seed, context)
return node
static func is_send_id(game_id: String) -> bool:
return SEND_IDS.has(str(game_id).strip_edges().to_lower())
static func send_ids() -> Array[String]:
return SEND_IDS.duplicate()
static func tier_for_hour(hour: int) -> int:
return clampi(1 + int(floor(float(maxi(1, hour) - 2) / 2.0)), 1, 4)
## Единая формула выбора: idx = S-1, tier = floor(idx/7)+1,
## игра = SEND_ORDER[idx % 7].
static func selection_for_sum(total: int) -> Dictionary:
var sum := maxi(total, 0)
var idx := clampi(sum - 1, 0, 27)
return {
"id": SEND_IDS[idx % 7],
"tier": int(idx / 7) + 1,
"index": idx,
"sum": sum,
}
static func send_for_types(types: Array) -> Dictionary:
var delegated := _economy_selection(types)
if not delegated.is_empty():
if not delegated.has("id") and delegated.has("game"):
delegated["id"] = str(delegated["game"])
if delegated.has("id"):
var id := str(delegated["id"])
if SEND_IDS.has(id):
if not delegated.has("tier"):
delegated["tier"] = 1
return delegated
var sum := 0
for type in types:
sum += int(COEFF.get(str(type).to_lower(), 1))
return selection_for_sum(sum)
static func _economy_selection(types: Array) -> Dictionary:
if not ResourceLoader.exists(ECONOMY_PATH):
return {}
var script = load(ECONOMY_PATH)
if script == null:
return {}
var has_method := false
for method in script.get_script_method_list():
if str(method.get("name", "")) == "send_minigame_for":
has_method = true
break
if not has_method:
return {}
var instance = script.new()
if instance == null:
return {}
var result = instance.call("send_minigame_for", types)
if result is Dictionary:
return (result as Dictionary).duplicate(true)
return {}
@@ -1 +0,0 @@
uid://brr3sl85uqcxa
-468
View File
@@ -1,468 +0,0 @@
extends MiniGame
## «Одна линия» (dots). Гамильтонов путь по свободным клеткам.
##
## FIX B3: сначала выбираются свободные клетки и препятствия, затем ищется
## путь только по свободным клеткам. Если случайная раскладка нерешаема —
## новый локальный RNG и ограниченное число попыток; последний рубеж — змейка.
const TIERS := {
1: {"cols": 3, "rows": 3, "obstacles": 0, "undo_penalty": 0.05},
2: {"cols": 4, "rows": 3, "obstacles": 2, "undo_penalty": 0.04},
3: {"cols": 4, "rows": 4, "obstacles": 4, "undo_penalty": 0.04},
4: {"cols": 5, "rows": 4, "obstacles": 6, "undo_penalty": 0.03},
}
const MAX_BOARD_ATTEMPTS := 16
const MAX_START_TRIES := 8
const DFS_GUARD := 120000
const RESET_RECT := Rect2(466.0, 320.0, 156.0, 28.0)
var board: Dictionary = {}
var _path: Array = []
var _undos: int = 0
var _invalid: int = 0
var _cell_rects: Array[Rect2] = []
var _cell_ids: Array = []
var _stride: float = 60.0
var _port_rect: Rect2 = Rect2()
func _own_game_id() -> String:
return "dots"
func _setup() -> void:
board = generate_tier(tier, seed_value)
_path.clear()
_undos = 0
_invalid = 0
_build_geometry()
# --- параметры -------------------------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIERS.get(clampi(p_tier, 1, 4), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("dots", p_tier)
if row.is_empty():
return base
var grid = row.get("grid", null)
if grid is Array and (grid as Array).size() >= 2:
base["cols"] = int((grid as Array)[0])
base["rows"] = int((grid as Array)[1])
for key in ["cols", "rows", "obstacles"]:
if row.has(key):
base[key] = int(row[key])
if row.has("undo_penalty"):
base["undo_penalty"] = float(row["undo_penalty"])
return base
# --- генерация/валидация ---------------------------------------------------
static func generate_tier(p_tier: int, seed: int) -> Dictionary:
var p := tier_params(p_tier)
return generate(int(p["cols"]), int(p["rows"]), int(p["obstacles"]), seed)
## Чистая генерация. Возвращает разложение по int-id (row * cols + col).
static func generate(cols: int, rows: int, obstacles: int, seed: int) -> Dictionary:
var c := maxi(2, cols)
var r := maxi(2, rows)
var cells := c * r
var obs := clampi(obstacles, 0, cells - 4)
var free_count := cells - obs
var ids: Array = []
for i in range(cells):
ids.append(i)
var fallback_used := false
for attempt in range(MAX_BOARD_ATTEMPTS):
var rng := RandomNumberGenerator.new()
rng.seed = seed + 7919 * (attempt + 1)
var shuffled := ids.duplicate()
_shuffle(shuffled, rng)
var blocked: Array = shuffled.slice(0, obs)
var free: Array = shuffled.slice(obs)
if free.size() != free_count:
continue
if not _free_connected(free, c, r):
continue
if not _parity_ok(free, c):
continue
var starts := free.duplicate()
_shuffle(starts, rng)
var tries := mini(MAX_START_TRIES, starts.size())
for si in range(tries):
var start := int(starts[si])
var visited := {start: true}
var path: Array = [start]
var guard: Array = [0]
if _ham_dfs(start, free, c, r, visited, path, free_count, guard, rng):
if _path_valid(path, free, c, r):
return _make_board(c, r, free, blocked, path, attempt + 1, false)
# Случайный, но гарантированно валидный откат: полный гамильтонов путь
# по всей сетке, затем первые free_count клеток — свободные, хвост — стены.
for fb_attempt in range(4):
var rng3 := RandomNumberGenerator.new()
rng3.seed = seed + 15485863 + fb_attempt * 101
var all_ids: Array = []
for i in range(cells):
all_ids.append(i)
var starts3 := all_ids.duplicate()
_shuffle(starts3, rng3)
var start3 := int(starts3[0])
var visited3 := {start3: true}
var full_path: Array = [start3]
var guard3: Array = [0]
if _ham_dfs(start3, all_ids, c, r, visited3, full_path, cells, guard3, rng3):
var free3: Array = full_path.slice(0, free_count)
var blocked3: Array = full_path.slice(free_count, cells)
return _make_board(c, r, free3, blocked3, free3.duplicate(),
MAX_BOARD_ATTEMPTS + fb_attempt + 1, true)
# Последний рубеж: змейка по всей сетке; свободные — её префикс.
fallback_used = true
var rng2 := RandomNumberGenerator.new()
rng2.seed = seed + 104729
var snake := _snake_ids(c, r)
var free2: Array = snake.slice(0, free_count)
var blocked2: Array = snake.slice(free_count, snake.size())
_shuffle(blocked2, rng2)
return _make_board(c, r, free2, blocked2, free2.duplicate(), MAX_BOARD_ATTEMPTS,
fallback_used)
static func _make_board(c: int, r: int, free: Array, blocked: Array, path: Array,
attempts: int, fallback_used: bool) -> Dictionary:
var path_cells: Array = []
for id in path:
path_cells.append(Vector2i(int(id) % c, int(id) / c))
var sorted_blocked := blocked.duplicate()
sorted_blocked.sort()
var sorted_free := free.duplicate()
sorted_free.sort()
return {
"cols": c,
"rows": r,
"obstacles": sorted_blocked.size(),
"free_count": sorted_free.size(),
"blocked": sorted_blocked,
"free": sorted_free,
"path": path.duplicate(),
"path_cells": path_cells,
"start": int(path[0]),
"finish": int(path[path.size() - 1]),
"attempts": attempts,
"fallback": fallback_used,
}
static func _free_connected(free: Array, c: int, r: int) -> bool:
if free.is_empty():
return false
var free_map := {}
for id in free:
free_map[int(id)] = true
var seen := {int(free[0]): true}
var queue: Array = [int(free[0])]
while not queue.is_empty():
var id := int(queue.pop_front())
for n in _neighbors(id, c, r):
if free_map.has(n) and not seen.has(n):
seen[n] = true
queue.append(n)
return seen.size() == free.size()
static func _parity_ok(free: Array, c: int) -> bool:
var even := 0
var odd := 0
for id in free:
var x := int(id) % c
var y := int(id) / c
if (x + y) % 2 == 0:
even += 1
else:
odd += 1
return absi(even - odd) <= 1
static func _ham_dfs(current: int, free: Array, c: int, r: int, visited: Dictionary,
path: Array, total: int, guard: Array, rng: RandomNumberGenerator) -> bool:
guard[0] = int(guard[0]) + 1
if int(guard[0]) > DFS_GUARD:
return false
if path.size() == total:
return true
var free_map := {}
for id in free:
free_map[int(id)] = true
var near: Array = []
for n in _neighbors(current, c, r):
if free_map.has(n) and not visited.has(n):
near.append(n)
_shuffle(near, rng)
for n in near:
visited[n] = true
path.append(n)
if _ham_dfs(int(n), free, c, r, visited, path, total, guard, rng):
return true
path.pop_back()
visited.erase(n)
return false
static func _path_valid(path: Array, free: Array, c: int, r: int) -> bool:
if path.size() != free.size() or path.is_empty():
return false
var free_map := {}
for id in free:
free_map[int(id)] = true
var seen := {}
for i in range(path.size()):
var id := int(path[i])
if not free_map.has(id) or seen.has(id):
return false
seen[id] = true
if i > 0 and not _adjacent(int(path[i - 1]), id, c):
return false
return true
static func validate_board(board_data: Dictionary) -> bool:
if not board_data.has("cols") or not board_data.has("rows"):
return false
var c := int(board_data["cols"])
var r := int(board_data["rows"])
var free: Array = board_data.get("free", [])
var blocked: Array = board_data.get("blocked", [])
var path: Array = board_data.get("path", [])
if free.size() + blocked.size() != c * r:
return false
if int(board_data.get("obstacles", blocked.size())) != blocked.size():
return false
if int(board_data.get("free_count", free.size())) != free.size():
return false
if not _path_valid(path, free, c, r):
return false
if int(board_data.get("start", -1)) != int(path[0]):
return false
if int(board_data.get("finish", -1)) != int(path[path.size() - 1]):
return false
return true
static func _neighbors(id: int, c: int, r: int) -> Array:
var x := id % c
var y := id / c
var out: Array = []
if y > 0:
out.append(id - c)
if x < c - 1:
out.append(id + 1)
if y < r - 1:
out.append(id + c)
if x > 0:
out.append(id - 1)
return out
static func _adjacent(a: int, b: int, c: int) -> bool:
var ax := a % c
var ay := a / c
var bx := b % c
var by := b / c
return absi(ax - bx) + absi(ay - by) == 1
static func _shuffle(list: Array, p_rng: RandomNumberGenerator) -> void:
for i in range(list.size() - 1, 0, -1):
var j := p_rng.randi_range(0, i)
var tmp = list[i]
list[i] = list[j]
list[j] = tmp
static func _snake_ids(c: int, r: int) -> Array:
var out: Array = []
for y in range(r):
for i in range(c):
var x := i if y % 2 == 0 else c - 1 - i
out.append(y * c + x)
return out
static func quality_for(p_undos: int, p_invalid: int, p_tier: int) -> float:
var p := tier_params(p_tier)
var penalty := float(p.get("undo_penalty", 0.04))
return clampf(1.0 - penalty * float(maxi(0, p_undos))
- 0.1 * float(maxi(0, p_invalid)), 0.0, 1.0)
# --- геометрия/игра --------------------------------------------------------
func _build_geometry() -> void:
var cols := int(board.get("cols", 3))
var rows := int(board.get("rows", 3))
var area := Rect2(20.0, 54.0, 600.0, 256.0)
_stride = minf(area.size.x / float(cols), area.size.y / float(rows))
_stride = clampf(floorf(_stride), 46.0, 78.0)
var total_w := _stride * float(cols)
var total_h := _stride * float(rows)
_port_rect = Rect2(area.position.x + (area.size.x - total_w) * 0.5,
area.position.y + (area.size.y - total_h) * 0.5, total_w, total_h)
_cell_rects.clear()
_cell_ids.clear()
for y in range(rows):
for x in range(cols):
var pad := 5.0
_cell_rects.append(Rect2(
Vector2(_port_rect.position.x + x * _stride,
_port_rect.position.y + y * _stride) + Vector2(pad, pad),
Vector2(_stride - pad * 2.0, _stride - pad * 2.0)))
_cell_ids.append(y * cols + x)
func _cell_at(local: Vector2) -> int:
for i in range(_cell_rects.size()):
if _cell_rects[i].grow(4.0).has_point(local):
return int(_cell_ids[i])
return -1
func _blocked_map() -> Dictionary:
var out := {}
for id in board.get("blocked", []):
out[int(id)] = true
return out
func _free_map() -> Dictionary:
var out := {}
for id in board.get("free", []):
out[int(id)] = true
return out
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if RESET_RECT.has_point(local):
_reset_line()
return true
var id := _cell_at(local)
if id < 0:
return false
if _path.is_empty():
if id == int(board.get("start", -1)):
_path = [id]
_play_sfx(&"tick")
else:
_invalid += 1
queue_redraw()
return true
var free := _free_map()
var last := int(_path[_path.size() - 1])
if _path.size() >= 2 and id == int(_path[_path.size() - 2]):
_path.pop_back()
_undos += 1
_play_sfx(&"tick")
queue_redraw()
return true
if id == last:
return true
if not free.has(id) or _path.has(id):
_invalid += 1
queue_redraw()
return true
var cols := int(board.get("cols", 3))
if not _adjacent(last, id, cols):
_invalid += 1
queue_redraw()
return true
if id == int(board.get("finish", -1)) and _path.size() < int(board.get("free_count", 0)) - 1:
# Финиш можно только последним: иначе линия не покроет все порты.
_invalid += 1
queue_redraw()
return true
_path.append(id)
_play_sfx(&"tick")
if _path.size() == int(board.get("free_count", 0)) and id == int(board.get("finish", -1)):
_finish(quality_for(_undos, _invalid, tier))
queue_redraw()
return true
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if keycode == KEY_R:
_reset_line()
return true
return false
func _reset_line() -> void:
if not _path.is_empty():
_undos += 1
_path.clear()
_play_sfx(&"tick")
queue_redraw()
func _draw_game() -> void:
var font := Theme90.ui()
var cols := int(board.get("cols", 3))
var blocked := _blocked_map()
var free := _free_map()
var path_set := {}
for id in _path:
path_set[int(id)] = true
_draw_label("ЛИНИЯ %d / %d" % [_path.size(), int(board.get("free_count", 0))],
Vector2(24.0, 46.0), 10, Theme90.NAVY, 220.0)
_draw_label("ПРЕПЯТСТВИЙ %d · ОТКАТОВ %d · ОШИБОК %d" % [
int(board.get("obstacles", 0)), _undos, _invalid],
Vector2(300.0, 46.0), 10, Theme90.SHADE, 300.0, HORIZONTAL_ALIGNMENT_RIGHT)
draw_style_box(Theme90.box(Theme90.PAPER_LO, Theme90.BORDER_SOFT, 4, 1),
Rect2(_port_rect.position - Vector2(8, 8), _port_rect.size + Vector2(16, 16)))
for i in range(_cell_ids.size()):
var id := int(_cell_ids[i])
var rect := _cell_rects[i]
if blocked.has(id):
draw_style_box(Theme90.fill_box(Theme90.DARK.darkened(0.08), 3), rect)
Theme90.draw_hatch(self, rect.grow(-2.0), Color(1, 1, 1, 0.10), 5.0)
continue
var fill := Theme90.PAPER_HI
if path_set.has(id):
fill = Theme90.ACCENT_PALE
draw_style_box(Theme90.box(fill, Theme90.BORDER, 3, 1), rect)
var center := rect.get_center()
draw_circle(center, minf(rect.size.x, rect.size.y) * 0.22, Theme90.ACCENT_LIGHT)
draw_circle(center, minf(rect.size.x, rect.size.y) * 0.13, Theme90.ACCENT)
if id == int(board.get("start", -1)):
Theme90.text_vcenter(self, font, rect, "С", 16, Theme90.WHITE,
-1, HORIZONTAL_ALIGNMENT_CENTER)
elif id == int(board.get("finish", -1)):
draw_circle(center, minf(rect.size.x, rect.size.y) * 0.30, Theme90.SUCCESS)
Theme90.text_vcenter(self, font, rect, "Ф", 16, Theme90.WHITE,
-1, HORIZONTAL_ALIGNMENT_CENTER)
for i in range(1, _path.size()):
var a := _cell_center(int(_path[i - 1]))
var b := _cell_center(int(_path[i]))
draw_line(a, b, Theme90.ACCENT, 4.0, true)
if i == _path.size() - 1:
draw_circle(b, 4.0, Theme90.NAVY_DEEP)
if _path.is_empty():
_draw_label("НАЧНИТЕ С «С» · КЛИК ПО ПРЕДЫДУЩЕМУ УЗЛУ — ОТКАТ · R — СБРОС",
Vector2(24.0, 344.0), 10, Theme90.SHADE, 430.0)
_draw_secondary_button(RESET_RECT, "СБРОС (R)")
func _cell_center(id: int) -> Vector2:
for i in range(_cell_ids.size()):
if int(_cell_ids[i]) == id:
return _cell_rects[i].get_center()
return _port_rect.get_center()
-1
View File
@@ -1 +0,0 @@
uid://co766vx8eagfo
-447
View File
@@ -1,447 +0,0 @@
extends MiniGame
## «Лабиринт» (maze). Recursive backtracker + BFS-валидация.
## Пакет следует за мышью с максимальной скоростью, скользит по осям.
## Качество: 1 - 0.04*collisions - 0.02*dead_ends (без нижнего порога 0.4).
const TIERS := {
1: {"cols": 9, "rows": 4, "field_w": 360.0, "field_h": 160.0,
"corridor": 28.0, "speed": 420.0},
2: {"cols": 11, "rows": 4, "field_w": 440.0, "field_h": 160.0,
"corridor": 24.0, "speed": 470.0},
3: {"cols": 13, "rows": 4, "field_w": 494.0, "field_h": 152.0,
"corridor": 20.0, "speed": 520.0},
4: {"cols": 15, "rows": 5, "field_w": 520.0, "field_h": 180.0,
"corridor": 18.0, "speed": 560.0},
}
const HITBOX := 9.0
const PACKET := 12.0
const LOOP_CHANCE := 0.12
const COLLISION_COOLDOWN := 0.25
var maze: Dictionary = {}
var params: Dictionary = {}
var _walls: Array[Rect2] = []
var _field: Rect2 = Rect2()
var _pitch: float = 20.0
var _corridor: float = 20.0
var _solution: Array = []
var _sol_index: Dictionary = {}
var _dead_ends: Dictionary = {}
var _packet: Vector2 = Vector2.ZERO
var _target: Vector2 = Vector2.ZERO
var _entrance: Vector2 = Vector2.ZERO
var _exit: Vector2 = Vector2.ZERO
var _collisions: int = 0
var _dead_end_hits: int = 0
var _visited_dead: Dictionary = {}
var _collision_cd: float = 0.0
var _max_sol: int = 0
var _exited: bool = false
func _own_game_id() -> String:
return "maze"
func _setup() -> void:
params = tier_params(tier)
maze = generate_tier(tier, seed_value)
_pitch = float(params["field_w"]) / float(maze["cols"])
_corridor = float(params["corridor"])
_field = Rect2((640.0 - float(params["field_w"])) * 0.5,
70.0 + (180.0 - float(params["field_h"])) * 0.5,
float(params["field_w"]), float(params["field_h"]))
_walls = build_walls(maze["cells"], int(maze["cols"]), int(maze["rows"]),
int(maze["ent_row"]), int(maze["exit_row"]), _field, _corridor)
_solution = maze.get("solution", [])
_sol_index.clear()
for i in range(_solution.size()):
_sol_index[int(_solution[i])] = i
_entrance = Vector2(_field.position.x + _pitch * 0.5,
_field.position.y + (float(maze["ent_row"]) + 0.5) * _pitch)
_exit = Vector2(_field.end.x - _pitch * 0.5,
_field.position.y + (float(maze["exit_row"]) + 0.5) * _pitch)
_packet = _entrance
_target = _entrance
_collisions = 0
_dead_end_hits = 0
_visited_dead.clear()
_collision_cd = 0.0
_max_sol = 0
_exited = false
_dead_ends = find_dead_ends(maze["cells"], int(maze["cols"]), int(maze["rows"]),
int(maze["ent_row"]), int(maze["exit_row"]))
# --- параметры/генерация ---------------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIERS.get(clampi(p_tier, 1, 4), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("maze", p_tier)
if row.is_empty():
return base
for key in ["cols", "rows"]:
if row.has(key):
base[key] = int(row[key])
if row.has("width"):
base["field_w"] = float(row["width"])
if row.has("height"):
base["field_h"] = float(row["height"])
for key in ["field_w", "field_h", "corridor", "speed"]:
if row.has(key):
base[key] = float(row[key])
if row.has("speed"):
base["speed"] = float(row["speed"])
return base
static func generate_tier(p_tier: int, seed: int) -> Dictionary:
var p := tier_params(p_tier)
var temp := RandomNumberGenerator.new()
temp.seed = seed
var ent_row := temp.randi_range(0, int(p["rows"]) - 1)
var exit_row := temp.randi_range(0, int(p["rows"]) - 1)
return generate(int(p["cols"]), int(p["rows"]), seed, ent_row, exit_row)
static func generate(cols: int, rows: int, seed: int, ent_row: int = -1,
exit_row: int = -1) -> Dictionary:
var c := maxi(3, cols)
var r := maxi(2, rows)
var rng := RandomNumberGenerator.new()
rng.seed = seed
var ent := clampi(ent_row if ent_row >= 0 else rng.randi_range(0, r - 1), 0, r - 1)
var ex := clampi(exit_row if exit_row >= 0 else rng.randi_range(0, r - 1), 0, r - 1)
var cells: Array = []
for y in range(r):
var row: Array = []
for x in range(c):
row.append({"n": true, "e": true, "s": true, "w": true, "v": false})
cells.append(row)
var stack: Array = [Vector2i(0, ent)]
cells[ent][0]["v"] = true
var visited := 1
while visited < c * r:
var top: Vector2i = stack[stack.size() - 1]
var near: Array = []
if top.y > 0 and not bool(cells[top.y - 1][top.x]["v"]):
near.append([top.x, top.y - 1, "n", "s"])
if top.x < c - 1 and not bool(cells[top.y][top.x + 1]["v"]):
near.append([top.x + 1, top.y, "e", "w"])
if top.y < r - 1 and not bool(cells[top.y + 1][top.x]["v"]):
near.append([top.x, top.y + 1, "s", "n"])
if top.x > 0 and not bool(cells[top.y][top.x - 1]["v"]):
near.append([top.x - 1, top.y, "w", "e"])
if near.is_empty():
stack.pop_back()
continue
var pick: Array = near[rng.randi_range(0, near.size() - 1)]
cells[top.y][top.x][str(pick[2])] = false
cells[int(pick[1])][int(pick[0])][str(pick[3])] = false
cells[int(pick[1])][int(pick[0])]["v"] = true
visited += 1
stack.append(Vector2i(int(pick[0]), int(pick[1])))
# Петли: аккуратно убираем часть внутренних стен (10–20%).
for y in range(r):
for x in range(c):
if x < c - 1 and bool(cells[y][x]["e"]) and rng.randf() < LOOP_CHANCE:
cells[y][x]["e"] = false
cells[y][x + 1]["w"] = false
if y < r - 1 and bool(cells[y][x]["s"]) and rng.randf() < LOOP_CHANCE:
cells[y][x]["s"] = false
cells[y + 1][x]["n"] = false
# Вход/выход.
cells[ent][0]["w"] = false
cells[ex][c - 1]["e"] = false
var solution := bfs(cells, c, r, ent * c + 0, ex * c + (c - 1))
return {
"cols": c,
"rows": r,
"seed": seed,
"ent_row": ent,
"exit_row": ex,
"cells": cells,
"solution": solution,
"solvable": not solution.is_empty(),
}
static func neighbors_open(cells: Array, x: int, y: int, cols: int, rows: int) -> Array:
var out: Array = []
if x < 0 or y < 0 or x >= cols or y >= rows:
return out
var cell: Dictionary = cells[y][x]
if y > 0 and not bool(cell["n"]):
out.append(Vector2i(x, y - 1))
if x < cols - 1 and not bool(cell["e"]):
out.append(Vector2i(x + 1, y))
if y < rows - 1 and not bool(cell["s"]):
out.append(Vector2i(x, y + 1))
if x > 0 and not bool(cell["w"]):
out.append(Vector2i(x - 1, y))
return out
static func bfs(cells: Array, cols: int, rows: int, start_id: int, goal_id: int) -> Array:
var sx := start_id % cols
var sy := start_id / cols
var gx := goal_id % cols
var gy := goal_id / cols
if sx < 0 or sy < 0 or sx >= cols or sy >= rows:
return []
if gx < 0 or gy < 0 or gx >= cols or gy >= rows:
return []
var prev := {start_id: -1}
var queue: Array = [start_id]
while not queue.is_empty():
var current := int(queue.pop_front())
if current == goal_id:
var path: Array = []
var node := current
while node >= 0:
path.append(node)
node = int(prev.get(node, -1))
path.reverse()
return path
var cx := current % cols
var cy := current / cols
for n in neighbors_open(cells, cx, cy, cols, rows):
var nid: int = int(n.y) * cols + int(n.x)
if prev.has(nid):
continue
prev[nid] = current
queue.append(nid)
return []
static func find_dead_ends(cells: Array, cols: int, rows: int, ent_row: int,
exit_row: int) -> Dictionary:
var out := {}
for y in range(rows):
for x in range(cols):
var degree := neighbors_open(cells, x, y, cols, rows).size()
if degree == 1 and not (x == 0 and y == ent_row) \
and not (x == cols - 1 and y == exit_row):
out[y * cols + x] = true
return out
## Все рёбра лабиринта: closed=true/false. Ключи:
## v:bx:by — вертикальная стена слева от клетки (bx,by), 0<=bx<=cols;
## h:bx:by — горизонтальная стена над клеткой (bx,by), 0<=by<=rows.
static func wall_edges(cells: Array, cols: int, rows: int, ent_row: int,
exit_row: int) -> Dictionary:
var out := {}
for by in range(rows):
for bx in range(cols + 1):
var closed := true
if bx == 0:
closed = by != ent_row
elif bx == cols:
closed = by != exit_row
else:
closed = bool(cells[by][bx - 1]["e"])
out["v:%d:%d" % [bx, by]] = closed
for by in range(rows + 1):
for bx in range(cols):
var closed := true
if by == 0:
closed = true
elif by == rows:
closed = true
else:
closed = bool(cells[by - 1][bx]["s"])
out["h:%d:%d" % [bx, by]] = closed
return out
static func build_walls(cells: Array, cols: int, rows: int, ent_row: int,
exit_row: int, field: Rect2, corridor: float) -> Array[Rect2]:
var pitch := field.size.x / float(cols)
var wall := pitch - corridor
var out: Array[Rect2] = []
var edges := wall_edges(cells, cols, rows, ent_row, exit_row)
for by in range(rows):
for bx in range(cols + 1):
if not bool(edges["v:%d:%d" % [bx, by]]):
continue
out.append(Rect2(field.position.x + float(bx) * pitch - wall * 0.5,
field.position.y + float(by) * pitch - wall * 0.5,
wall, pitch + wall))
for by in range(rows + 1):
for bx in range(cols):
if not bool(edges["h:%d:%d" % [bx, by]]):
continue
out.append(Rect2(field.position.x + float(bx) * pitch - wall * 0.5,
field.position.y + float(by) * pitch - wall * 0.5,
pitch + wall, wall))
return out
static func hit_wall(center: Vector2, walls: Array, half: float) -> bool:
var box := Rect2(center - Vector2(half, half), Vector2(half, half) * 2.0)
for wall in walls:
if box.intersects(wall as Rect2):
return true
return false
## Движение с раздельным скольжением по осям. Возвращает новую позицию и
## флаг столкновения. Чистая функция — удобна для headless-теста.
static func move_packet(pos: Vector2, target: Vector2, speed: float, dt: float,
walls: Array, field: Rect2, half: float) -> Dictionary:
var delta := target - pos
var dist := delta.length()
var max_move := maxf(0.0, speed) * maxf(0.0, dt)
if dist > max_move and dist > 0.001:
delta = delta / dist * max_move
var blocked := false
if delta.length_squared() <= 0.000001:
return {"pos": pos, "blocked": false}
var steps := maxi(1, int(ceil(maxf(absf(delta.x), absf(delta.y)) / 3.0)))
var sx := delta.x / float(steps)
var sy := delta.y / float(steps)
for i in range(steps):
var nx := pos.x + sx
if absf(sx) > 0.0001:
if not hit_wall(Vector2(nx, pos.y), walls, half):
pos.x = nx
else:
blocked = true
var ny := pos.y + sy
if absf(sy) > 0.0001:
if not hit_wall(Vector2(pos.x, ny), walls, half):
pos.y = ny
else:
blocked = true
pos.x = clampf(pos.x, field.position.x + half, field.end.x - half)
pos.y = clampf(pos.y, field.position.y + half, field.end.y - half)
return {"pos": pos, "blocked": blocked}
static func quality_for(collisions: int, dead_ends: int) -> float:
return clampf(1.0 - 0.04 * float(maxi(0, collisions))
- 0.02 * float(maxi(0, dead_ends)), 0.0, 1.0)
# --- игра ------------------------------------------------------------------
func _process(delta: float) -> void:
if not running or _exited:
return
var half := HITBOX * 0.5
var result := move_packet(_packet, _target, float(params.get("speed", 420.0)),
minf(maxf(delta, 0.0), 0.1), _walls, _field, half)
_packet = result["pos"]
_collision_cd = maxf(0.0, _collision_cd - delta)
if bool(result["blocked"]) and _collision_cd <= 0.0:
_collisions += 1
_collision_cd = COLLISION_COOLDOWN
var id := _cell_under(_packet)
if id >= 0:
if _sol_index.has(id) and int(_sol_index[id]) > _max_sol:
_max_sol = int(_sol_index[id])
if _dead_ends.has(id) and not _visited_dead.has(id):
_visited_dead[id] = true
_dead_end_hits += 1
# Выход справа, в строке выхода.
if _packet.x >= _field.end.x - half - 1.5 \
and absf(_packet.y - _exit.y) <= _corridor * 0.5:
_exited = true
_play_sfx(&"minigame_win")
_finish(quality_for(_collisions, _dead_end_hits))
return
queue_redraw()
func _cell_under(pos: Vector2) -> int:
var gx := int(floor((pos.x - _field.position.x) / _pitch))
var gy := int(floor((pos.y - _field.position.y) / _pitch))
var cols := int(maze.get("cols", 1))
var rows := int(maze.get("rows", 1))
if gx < 0 or gy < 0 or gx >= cols or gy >= rows:
return -1
return gy * cols + gx
func _clamp_target(local: Vector2) -> Vector2:
var half := HITBOX * 0.5
return Vector2(
clampf(local.x, _field.position.x + half, _field.end.x - half),
clampf(local.y, _field.position.y + half, _field.end.y - half))
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if local.y < FOOTER_Y and _field.grow(20.0).has_point(local):
_target = _clamp_target(local)
return true
return false
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if local.y >= FOOTER_Y:
return
if _field.grow(20.0).has_point(local):
_target = _clamp_target(local)
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
var step := 12.0
match keycode:
KEY_LEFT:
_target.x -= step
KEY_RIGHT:
_target.x += step
KEY_UP:
_target.y -= step
KEY_DOWN:
_target.y += step
KEY_HOME:
_target = _entrance
_:
return false
_target = _clamp_target(_target)
return true
func _draw_game() -> void:
var font := Theme90.ui()
_draw_label("МАРШРУТ · ВЫХОД СПРАВА", Vector2(24.0, 34.0), 10,
Theme90.NAVY, 280.0)
_draw_label("СТОЛКНОВЕНИЙ %d · ТУПИКОВ %d" % [_collisions, _dead_end_hits],
Vector2(320.0, 34.0), 10, Theme90.SHADE, 280.0,
HORIZONTAL_ALIGNMENT_RIGHT)
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 4, 1),
_field.grow(10.0))
# Лёгкая шахматная подложка.
var pitch := _pitch
for y in range(int(maze.get("rows", 1))):
for x in range(int(maze.get("cols", 1))):
if (x + y) % 2 == 0:
draw_rect(Rect2(_field.position + Vector2(float(x) * pitch,
float(y) * pitch), Vector2(pitch, pitch)),
Color(0.18, 0.30, 0.32, 0.035))
for wall in _walls:
draw_rect(wall, Theme90.NAVY_DEEP)
# Вход/выход.
draw_rect(Rect2(_entrance - Vector2(3, 4), Vector2(6, 8)), Theme90.ACCENT)
draw_circle(_exit, maxf(5.0, _corridor * 0.32), Theme90.SUCCESS)
# Пакет и цель.
draw_circle(_target, 2.0, Theme90.ACCENT.lightened(0.25))
draw_style_box(Theme90.box(Theme90.ACCENT, Theme90.NAVY_DEEP, 3, 1,
Theme90.SHADOW), Rect2(_packet - Vector2(PACKET, PACKET) * 0.5,
Vector2(PACKET, PACKET)))
draw_circle(_packet + Vector2(0, -2), 2.0, Theme90.WHITE)
var progress := 0.0
if not _solution.is_empty():
progress = clampf((float(_max_sol) + 1.0) / float(_solution.size()), 0.0, 1.0)
_draw_label("ПРОГРЕСС %d%%" % roundi(progress * 100.0),
Vector2(_field.position.x, _field.end.y + 12.0), 10, Theme90.SHADE,
_field.size.x)
_draw_label("ВЕДИТЕ ПАКЕТ МЫШЬЮ · СТЕНЫ СКОЛЬЗЯТ · ВЫХОД — ЗЕЛЁНЫЙ",
Vector2(24.0, 344.0), 10, Theme90.SHADE, 430.0)
-1
View File
@@ -1 +0,0 @@
uid://b21ebusrqnwn
-356
View File
@@ -1,356 +0,0 @@
extends MiniGame
## «Кадр» (photo). Бюст клиента всегда движется случайно; игрок ведёт рамку
## и делает N снимков. FIX H4: движение считается по фиксированному
## логическому шагу (SIM_DT), внутри которого используется dt-вероятность
## смены цели 1-exp(-rate*dt), retarget_timer -= dt и current_dir = dir.
## Поэтому сид даёт одинаковую траекторию на 30 и 144 fps.
const SCAN := Rect2(30.0, 44.0, 580.0, 268.0)
const BUST := Vector2(36.0, 48.0)
const INSET := 4.0
const SIM_DT := 1.0 / 120.0
const TWITCH_RATE := 1.25
const TIERS := {
1: {"speed": 90.0, "frame_w": 220.0, "frame_h": 150.0, "shots": 3,
"switch_min": 0.8, "switch_max": 1.2, "turn_rate": 210.0},
2: {"speed": 140.0, "frame_w": 180.0, "frame_h": 120.0, "shots": 4,
"switch_min": 0.6, "switch_max": 1.0, "turn_rate": 260.0},
3: {"speed": 200.0, "frame_w": 150.0, "frame_h": 100.0, "shots": 5,
"switch_min": 0.5, "switch_max": 0.8, "turn_rate": 300.0},
4: {"speed": 260.0, "frame_w": 120.0, "frame_h": 80.0, "shots": 6,
"switch_min": 0.35, "switch_max": 0.65, "turn_rate": 340.0},
}
var state: Dictionary = {}
var params: Dictionary = {}
var _frame_pos: Vector2 = Vector2.ZERO
var _shots_taken: int = 0
var _captures: int = 0
var _acc: float = 0.0
var _flash: float = 0.0
var _last_capture_ok: bool = false
func _own_game_id() -> String:
return "photo"
func _setup() -> void:
params = tier_params(tier)
state = _make_state(rng, params)
_frame_pos = _clamp_frame(Vector2(SCAN.get_center().x, SCAN.position.y),
params)
_shots_taken = 0
_captures = 0
_acc = 0.0
_flash = 0.0
_last_capture_ok = false
# --- параметры/чистая математика ------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIERS.get(clampi(p_tier, 1, 4), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("photo", p_tier)
if row.is_empty():
return base
for key in ["speed", "frame_w", "frame_h", "switch_min", "switch_max",
"turn_rate"]:
if row.has(key):
base[key] = float(row[key])
for alias in [["retarget_min", "switch_min"], ["retarget_max", "switch_max"]]:
if row.has(alias[0]):
base[alias[1]] = float(row[alias[0]])
if row.has("shots"):
base["shots"] = int(row["shots"])
return base
static func generate(p_tier: int, seed: int) -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = seed
return _make_state(rng, tier_params(p_tier))
static func _make_state(p_rng: RandomNumberGenerator, p: Dictionary) -> Dictionary:
var speed := float(p.get("speed", 90.0)) * p_rng.randf_range(0.9, 1.1)
var pos := _random_point(p_rng)
var target := _random_point(p_rng)
var dir := Vector2.RIGHT.rotated(p_rng.randf_range(0.0, TAU))
var timer := p_rng.randf_range(float(p.get("switch_min", 0.8)),
float(p.get("switch_max", 1.2)))
return {
"pos": pos,
"target": target,
"current_dir": dir,
"retarget_timer": timer,
"speed": speed,
"params": p,
}
static func _random_point(p_rng: RandomNumberGenerator) -> Vector2:
var half := BUST * 0.5
return Vector2(
p_rng.randf_range(SCAN.position.x + half.x + 2.0,
SCAN.end.x - half.x - 2.0),
p_rng.randf_range(SCAN.position.y + half.y + 2.0,
SCAN.end.y - half.y - 2.0))
static func _clamp_bust(pos: Vector2) -> Vector2:
var half := BUST * 0.5
return Vector2(
clampf(pos.x, SCAN.position.x + half.x, SCAN.end.x - half.x),
clampf(pos.y, SCAN.position.y + half.y, SCAN.end.y - half.y))
## H4: один логический шаг. dt здесь всегда SIM_DT, поэтому вероятность
## смены цели масштабируется временем, а не кадром.
static func step_motion(p_state: Dictionary, p_rng: RandomNumberGenerator,
dt: float) -> void:
if dt <= 0.0:
return
var p: Dictionary = p_state.get("params", {})
var pos: Vector2 = p_state.get("pos", SCAN.get_center())
var target: Vector2 = p_state.get("target", SCAN.get_center())
var current_dir: Vector2 = p_state.get("current_dir", Vector2.RIGHT)
var speed := float(p_state.get("speed", float(p.get("speed", 90.0))))
var turn_rate := deg_to_rad(float(p.get("turn_rate", 240.0)))
var to_target := target - pos
var desired := current_dir
if to_target.length_squared() > 0.001:
desired = to_target.normalized()
var max_turn := turn_rate * dt
var angle := current_dir.angle_to(desired)
current_dir = current_dir.rotated(clampf(angle, -max_turn, max_turn))
# H4: явно фиксируем текущее направление и двигаемся по нему.
var dir := current_dir
pos += dir * speed * dt
pos = _clamp_bust(pos)
# Редкая резкая смена цели, вероятность зависит от dt.
if p_rng.randf() < 1.0 - exp(-TWITCH_RATE * dt):
target = _random_point(p_rng)
p_state["retarget_timer"] = p_rng.randf_range(
float(p.get("switch_min", 0.8)), float(p.get("switch_max", 1.2)))
var timer := float(p_state.get("retarget_timer", 1.0)) - dt
if timer <= 0.0 or pos.distance_to(target) < 24.0:
target = _random_point(p_rng)
timer = p_rng.randf_range(float(p.get("switch_min", 0.8)),
float(p.get("switch_max", 1.2)))
p_state["pos"] = pos
p_state["target"] = target
p_state["current_dir"] = current_dir
p_state["retarget_timer"] = timer
## Детерминированная симуляция на фиксированном шаге. fps влияет только на
## группировку вызовов, не на последовательность шагов/позиций.
static func simulate(seed: int, p_tier: int, duration: float, fps: int) -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = seed
var p := tier_params(p_tier)
var s := _make_state(rng, p)
var total_steps := int(floor(maxf(0.0, duration) / SIM_DT))
var positions: Array[Vector2] = []
var frame_dt := 1.0 / float(maxi(1, fps))
var acc := 0.0
var steps := 0
var guard := 0
while steps < total_steps and guard < total_steps * 4 + 16:
guard += 1
acc += frame_dt
while acc >= SIM_DT and steps < total_steps:
step_motion(s, rng, SIM_DT)
positions.append(s["pos"])
steps += 1
acc -= SIM_DT
return {
"positions": positions,
"final": s["pos"],
"steps": steps,
}
static func capture_success(bust_pos: Vector2, frame_pos: Vector2,
frame_size: Vector2) -> bool:
var b := Rect2(bust_pos - BUST * 0.5, BUST)
var f := Rect2(frame_pos, frame_size)
return b.position.x >= f.position.x and b.position.y >= f.position.y \
and b.end.x <= f.end.x and b.end.y <= f.end.y
static func quality_for(captures: int, shots: int) -> float:
if shots <= 0:
return 0.0
return clampf(float(maxi(0, captures)) / float(shots), 0.0, 1.0)
static func _clamp_frame(mouse: Vector2, p: Dictionary) -> Vector2:
var fw := float(p.get("frame_w", 220.0))
var fh := float(p.get("frame_h", 150.0))
var x := clampf(mouse.x - fw * 0.5,
SCAN.position.x + INSET, SCAN.end.x - INSET - fw)
var y := clampf(mouse.y - fh * 0.5,
SCAN.position.y + INSET, SCAN.end.y - INSET - fh)
return Vector2(x, y)
func _frame_size() -> Vector2:
return Vector2(float(params.get("frame_w", 220.0)),
float(params.get("frame_h", 150.0)))
func _frame_rect() -> Rect2:
return Rect2(_frame_pos, _frame_size())
# --- игра ------------------------------------------------------------------
func _process(delta: float) -> void:
if not running or _finished:
return
var mouse := _frame_pos + _frame_size() * 0.5
if is_inside_tree():
mouse = get_local_mouse_position()
if SCAN.grow(2.0).has_point(mouse):
_frame_pos = _clamp_frame(mouse, params)
_acc += minf(maxf(delta, 0.0), 0.1)
var guard := 0
while _acc >= SIM_DT and guard < 32:
guard += 1
step_motion(state, rng, SIM_DT)
_acc -= SIM_DT
if _flash > 0.0:
_flash = maxf(0.0, _flash - delta)
queue_redraw()
func _capture() -> void:
if _finished:
return
var shots := int(params.get("shots", 3))
if _shots_taken >= shots:
return
_shots_taken += 1
var ok := capture_success(state["pos"], _frame_pos, _frame_size())
_last_capture_ok = ok
if ok:
_captures += 1
_flash = 0.22
_play_sfx(&"minigame_win" if ok else &"minigame_fail")
if _shots_taken >= shots:
_finish(quality_for(_captures, shots))
queue_redraw()
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if local.y >= FOOTER_Y or not SCAN.grow(6.0).has_point(local):
return false
_frame_pos = _clamp_frame(local, params)
_capture()
return true
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if local.y >= FOOTER_Y:
return
_frame_pos = _clamp_frame(local, params)
queue_redraw()
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if keycode == KEY_SPACE or keycode == KEY_ENTER:
_capture()
return true
var step := 6.0
var center := _frame_pos + _frame_size() * 0.5
if keycode == KEY_LEFT:
center.x -= step
elif keycode == KEY_RIGHT:
center.x += step
elif keycode == KEY_UP:
center.y -= step
elif keycode == KEY_DOWN:
center.y += step
else:
return false
_frame_pos = _clamp_frame(center, params)
queue_redraw()
return true
# --- отрисовка -------------------------------------------------------------
func _draw_game() -> void:
var font := Theme90.ui()
var shots := int(params.get("shots", 3))
var inside := capture_success(state["pos"], _frame_pos, _frame_size())
_draw_label("СКАН КЛИЕНТА · КАДР %d / %d" % [_shots_taken, shots],
Vector2(24.0, 34.0), 10, Theme90.NAVY, 280.0)
_draw_label("УДАЧНЫХ %d · ОСТАЛОСЬ %d" % [_captures, shots - _shots_taken],
Vector2(320.0, 34.0), 10, Theme90.SHADE, 280.0,
HORIZONTAL_ALIGNMENT_RIGHT)
draw_style_box(Theme90.box(Theme90.DARK, Color(0, 0, 0, 0), 4, 1), SCAN)
# Мягкий фон скана: окно/стол, на игру не влияет.
draw_rect(Rect2(SCAN.position + Vector2(18, 16), Vector2(178, 118)),
Color(0.86, 0.84, 0.76, 0.12))
draw_line(SCAN.position + Vector2(18, 134), SCAN.position + Vector2(196, 134),
Color(1, 1, 1, 0.16), 2.0)
draw_rect(Rect2(SCAN.end - Vector2(170, 102), Vector2(120, 72)),
Color(0.65, 0.58, 0.45, 0.12))
var bust_pos: Vector2 = state["pos"]
_draw_bust(bust_pos)
var frame := _frame_rect()
var fcol := Theme90.SUCCESS if inside else Theme90.ACCENT_LIGHT
Theme90.draw_dashed_box(self, frame, fcol, 5.0, 2.0)
# Уголки рамки.
var corner := 12.0
for p in [frame.position, Vector2(frame.end.x, frame.position.y),
Vector2(frame.position.x, frame.end.y), frame.end]:
draw_circle(p, 2.2, fcol)
draw_line(frame.position + Vector2(0, corner),
frame.position + Vector2(0, 0), fcol, 2.0)
draw_line(frame.position + Vector2(0, 0),
frame.position + Vector2(corner, 0), fcol, 2.0)
draw_line(Vector2(frame.end.x, frame.position.y),
Vector2(frame.end.x, frame.position.y + corner), fcol, 2.0)
draw_line(Vector2(frame.end.x - corner, frame.position.y),
Vector2(frame.end.x, frame.position.y), fcol, 2.0)
if _flash > 0.0:
var a := _flash / 0.22
draw_rect(SCAN, Color(1, 1, 1, 0.22 * a))
var text := "В РАМКЕ" if _last_capture_ok else "МИМО"
_draw_label(text, Vector2(SCAN.get_center().x - 60.0, SCAN.position.y + 12.0),
16, Theme90.SUCCESS if _last_capture_ok else Theme90.ERROR, 120.0,
HORIZONTAL_ALIGNMENT_CENTER)
_draw_label("ВЕДИТЕ РАМКУ МЫШЬЮ · ЛКМ / ПРОБЕЛ — СНИМОК",
Vector2(24.0, 344.0), 10, Theme90.SHADE, 430.0)
_draw_label("рамка %d×%d" % [roundi(_frame_size().x), roundi(_frame_size().y)],
Vector2(466.0, 344.0), 10, Theme90.SHADE, 156.0,
HORIZONTAL_ALIGNMENT_RIGHT)
func _draw_bust(pos: Vector2) -> void:
var b := Rect2(pos - BUST * 0.5, BUST)
draw_circle(Vector2(pos.x, b.end.y - 4.0), 12.0, Color(0, 0, 0, 0.16))
# Плечи.
draw_colored_polygon(PackedVector2Array([
Vector2(pos.x - 15.0, b.end.y - 2.0),
Vector2(pos.x - 9.0, b.position.y + 26.0),
Vector2(pos.x + 9.0, b.position.y + 26.0),
Vector2(pos.x + 15.0, b.end.y - 2.0)]), Theme90.NAVY)
# Голова.
draw_circle(Vector2(pos.x, b.position.y + 14.0), 9.0, Theme90.NAVY)
# Шея.
draw_rect(Rect2(Vector2(pos.x - 3.0, b.position.y + 22.0), Vector2(6, 8)),
Theme90.NAVY_DEEP)
# Лицо/блик — чтобы бюст читался.
draw_circle(Vector2(pos.x + 3.0, b.position.y + 12.0), 1.5, Theme90.PAPER_HI)
-1
View File
@@ -1 +0,0 @@
uid://cld1besfvk2ow
-319
View File
@@ -1,319 +0,0 @@
extends MiniGame
## «Уровни» (sound). Вертикальные фейдеры, зелёные целевые зоны.
## T1–T4: 3/4/5/6 каналов, допуск 16/11/8/6%, на T3/T4 — синусоидальный дрейф.
## Таймера нет; качество фиксируется кнопкой «ЗАПИСАТЬ».
const TIERS := {
1: {"count": 3, "tolerance": 0.16, "drift_amp": 0.0, "drift_hz": 0.0},
2: {"count": 4, "tolerance": 0.11, "drift_amp": 0.0, "drift_hz": 0.0},
3: {"count": 5, "tolerance": 0.08, "drift_amp": 0.02, "drift_hz": 0.15},
4: {"count": 6, "tolerance": 0.06, "drift_amp": 0.03, "drift_hz": 0.25},
}
const LABELS := [
["НЧ", "60"], ["НСЧ", "250"], ["СЧ", "1К"], ["ВСЧ", "4К"], ["ВЧ", "12К"],
["УВЧ", "20К"],
]
const TRACK_TOP := 92.0
const TRACK_H := 154.0
const TRACK_W := 30.0
var state: Dictionary = {}
var _values: Array[float] = []
var _targets: Array[float] = []
var _phases: Array[float] = []
var _time: float = 0.0
var _dragging: int = -1
var _active: int = 0
func _own_game_id() -> String:
return "sound"
func _setup() -> void:
state = generate(tier, seed_value)
_values.clear()
_targets.clear()
_phases.clear()
for v in state.get("starts", []):
_values.append(float(v))
for v in state.get("targets", []):
_targets.append(float(v))
for p in state.get("phases", []):
_phases.append(float(p))
_time = 0.0
_dragging = -1
_active = 0
# --- параметры/чистая логика ----------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIERS.get(clampi(p_tier, 1, 4), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("sound", p_tier)
if row.is_empty():
return base
if row.has("channels"):
base["count"] = int(row["channels"])
if row.has("count"):
base["count"] = int(row["count"])
if row.has("tolerance"):
base["tolerance"] = float(row["tolerance"])
if row.has("drift"):
base["drift_amp"] = float(row["drift"])
if row.has("drift_amp"):
base["drift_amp"] = float(row["drift_amp"])
if row.has("drift_hz"):
base["drift_hz"] = float(row["drift_hz"])
return base
static func generate(p_tier: int, seed: int) -> Dictionary:
var p := tier_params(p_tier)
var count := clampi(int(p.get("count", 3)), 2, 6)
var tol := clampf(float(p.get("tolerance", 0.16)), 0.02, 0.30)
var amp := maxf(0.0, float(p.get("drift_amp", 0.0)))
var hz := maxf(0.0, float(p.get("drift_hz", 0.0)))
var rng := RandomNumberGenerator.new()
rng.seed = seed
var targets: Array[float] = []
var min_gap := tol * 2.0
var lo := tol
var hi := 1.0 - tol
var attempts := 0
while targets.size() < count and attempts < 600:
attempts += 1
var candidate := rng.randf_range(lo, hi)
var ok := true
for t in targets:
if absf(candidate - float(t)) < min_gap - 0.0001:
ok = false
break
if ok:
targets.append(candidate)
# Резерв: равномерная сетка, если rejection не сошёлся.
if targets.size() < count:
targets.clear()
var span := maxf(0.0, hi - lo)
var step := span / float(maxi(1, count - 1))
for i in range(count):
targets.append(clampf(lo + step * float(i), 0.02, 0.98))
targets.sort()
var starts: Array[float] = []
var phases: Array[float] = []
for i in range(count):
var target := float(targets[i])
var sign := -1.0 if rng.randf() < 0.5 else 1.0
var offset := rng.randf_range(tol * 1.10, tol * 2.8)
var start := clampf(target + sign * offset, 0.02, 0.98)
if absf(start - target) <= tol * 1.02:
start = clampf(target - sign * offset, 0.02, 0.98)
if absf(start - target) <= tol:
start = clampf(target + tol * 2.0, 0.02, 0.98)
if absf(start - target) <= tol:
start = clampf(target - tol * 2.0, 0.02, 0.98)
if absf(start - target) <= tol:
start = 0.02 if target >= 0.5 else 0.98
starts.append(start)
phases.append(rng.randf_range(0.0, TAU))
return {
"tier": clampi(p_tier, 1, 4),
"count": count,
"tolerance": tol,
"drift_amp": amp,
"drift_hz": hz,
"targets": targets,
"starts": starts,
"phases": phases,
}
static func target_at(target: float, amp: float, hz: float, phase: float,
time: float) -> float:
if amp <= 0.0 or hz <= 0.0:
return target
return clampf(target + amp * sin(TAU * hz * time + phase), 0.0, 1.0)
static func channel_quality(value: float, target: float, tol: float) -> float:
if tol <= 0.0:
return 1.0 if is_equal_approx(value, target) else 0.0
return clampf(1.0 - absf(value - target) / (2.0 * tol), 0.0, 1.0)
static func quality_from(values: Array, targets: Array, tol: float,
amp: float = 0.0, hz: float = 0.0, phases: Array = [], time: float = 0.0) -> float:
var n := mini(values.size(), targets.size())
if n <= 0:
return 0.0
var total := 0.0
for i in range(n):
var target := float(targets[i])
if amp > 0.0 and hz > 0.0:
var phase := float(phases[i]) if i < phases.size() else 0.0
target = target_at(target, amp, hz, phase, time)
total += channel_quality(float(values[i]), target, tol)
return clampf(total / float(n), 0.0, 1.0)
func _current_target(i: int) -> float:
if i < 0 or i >= _targets.size():
return 0.5
return target_at(_targets[i], float(state.get("drift_amp", 0.0)),
float(state.get("drift_hz", 0.0)), _phases[i] if i < _phases.size() else 0.0,
_time)
func _quality_now() -> float:
var targets: Array = []
for i in range(_targets.size()):
targets.append(_current_target(i))
return quality_from(_values, targets, float(state.get("tolerance", 0.16)))
func _process(delta: float) -> void:
if not running:
return
if float(state.get("drift_amp", 0.0)) > 0.0:
_time += maxf(0.0, delta)
queue_redraw()
# --- геометрия -------------------------------------------------------------
func _track_rect(i: int) -> Rect2:
var count := _values.size()
var spacing := minf(104.0, 480.0 / float(maxi(1, count)))
var center_x := 320.0
var x := center_x + (float(i) - float(count - 1) * 0.5) * spacing - TRACK_W * 0.5
return Rect2(x, TRACK_TOP, TRACK_W, TRACK_H)
func _value_to_y(rect: Rect2, value: float) -> float:
var pad := 7.0
return rect.position.y + pad + (1.0 - clampf(value, 0.0, 1.0)) * (rect.size.y - pad * 2.0)
func _y_to_value(rect: Rect2, y: float) -> float:
var pad := 7.0
var inner := rect.size.y - pad * 2.0
return clampf(1.0 - (y - rect.position.y - pad) / inner, 0.0, 1.0)
func _fader_at(local: Vector2) -> int:
for i in range(_values.size()):
var rect := _track_rect(i)
if local.x >= rect.position.x - 24.0 and local.x <= rect.end.x + 24.0 \
and local.y >= rect.position.y - 20.0 and local.y <= rect.end.y + 18.0:
return i
return -1
# --- ввод ------------------------------------------------------------------
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if ACTION_RECT.has_point(local):
_finish(_quality_now())
return true
var idx := _fader_at(local)
if idx < 0:
return false
_dragging = idx
_active = idx
_values[idx] = _y_to_value(_track_rect(idx), local.y)
_play_sfx(&"tick")
queue_redraw()
return true
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if _dragging < 0:
return
_values[_dragging] = _y_to_value(_track_rect(_dragging), local.y)
queue_redraw()
func _on_pointer_released(_local: Vector2, _event: InputEvent) -> void:
_dragging = -1
func _on_key_pressed(keycode: int, event: InputEvent) -> bool:
if keycode == KEY_ENTER or keycode == KEY_KP_ENTER:
_finish(_quality_now())
return true
if keycode == KEY_LEFT or keycode == KEY_A:
_active = posmod(_active - 1, _values.size())
queue_redraw()
return true
if keycode == KEY_RIGHT or keycode == KEY_D:
_active = (_active + 1) % _values.size()
queue_redraw()
return true
if keycode in [KEY_UP, KEY_DOWN, KEY_W, KEY_S]:
var key_event := event as InputEventKey
var step := 0.005 if key_event != null and key_event.shift_pressed else 0.02
var sign := 1.0 if keycode in [KEY_UP, KEY_W] else -1.0
_values[_active] = clampf(_values[_active] + sign * step, 0.0, 1.0)
queue_redraw()
return true
return false
# --- отрисовка -------------------------------------------------------------
func _draw_game() -> void:
var tol := float(state.get("tolerance", 0.16))
var in_zone := 0
for i in range(_values.size()):
if absf(_values[i] - _current_target(i)) <= tol:
in_zone += 1
_draw_label("МИКШЕР · КАНАЛ 97", Vector2(24.0, 46.0), 10, Theme90.ACCENT, 240.0)
_draw_label("В ЗОНЕ %d / %d · ДОПУСК ±%d%%" % [in_zone, _values.size(),
roundi(tol * 100.0)], Vector2(280.0, 46.0), 10,
Theme90.SUCCESS if in_zone == _values.size() else Theme90.SHADE, 320.0,
HORIZONTAL_ALIGNMENT_RIGHT)
var panel := Rect2(18.0, 58.0, 604.0, 250.0)
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 4, 1), panel)
# Общая шкала 100/50/0.
var first := _track_rect(0)
for v in [1.0, 0.5, 0.0]:
var y := _value_to_y(first, v)
Theme90.text(self, Theme90.ui(), Vector2(first.position.x - 44.0, y - 6.0),
"%d" % roundi(v * 100.0), 10, Theme90.SHADE, 34.0,
HORIZONTAL_ALIGNMENT_RIGHT)
for i in range(_values.size()):
var rect := _track_rect(i)
var target := _current_target(i)
var active := i == _active
draw_style_box(Theme90.box(Theme90.PAPER_LO, Theme90.BORDER_SOFT, 3, 1), rect)
var y_top := _value_to_y(rect, minf(1.0, target + tol))
var y_bottom := _value_to_y(rect, maxf(0.0, target - tol))
draw_rect(Rect2(rect.position.x + 2.0, y_top, rect.size.x - 4.0,
maxf(2.0, y_bottom - y_top)), Color(Theme90.SUCCESS.r, Theme90.SUCCESS.g,
Theme90.SUCCESS.b, 0.30))
draw_line(Vector2(rect.position.x - 4.0, _value_to_y(rect, target)),
Vector2(rect.end.x + 4.0, _value_to_y(rect, target)), Theme90.SUCCESS, 2.0)
var y := _value_to_y(rect, _values[i])
draw_rect(Rect2(rect.position.x + 4.0, y, rect.size.x - 8.0,
rect.end.y - y - 7.0), Color(Theme90.ACCENT.r, Theme90.ACCENT.g,
Theme90.ACCENT.b, 0.24))
draw_style_box(Theme90.box(Theme90.WHITE, Theme90.ACCENT, 3, 1,
Theme90.SHADOW), Rect2(rect.position.x - 7.0, y - 6.0, rect.size.x + 14.0, 12.0))
if active:
Theme90.draw_dashed_box(self, rect.grow(3.0), Theme90.ACCENT, 3.0, 1.0)
var label: Array = LABELS[i % LABELS.size()]
Theme90.text(self, Theme90.ui(), Vector2(rect.position.x - 10.0,
rect.end.y + 8.0), str(label[0]), 12, Theme90.DARK, rect.size.x + 20.0,
HORIZONTAL_ALIGNMENT_CENTER)
Theme90.text(self, Theme90.ui(), Vector2(rect.position.x - 10.0,
rect.end.y + 23.0), "%s · %d%%" % [str(label[1]), roundi(_values[i] * 100.0)],
10, Theme90.SHADE, rect.size.x + 20.0, HORIZONTAL_ALIGNMENT_CENTER)
_draw_label("СОВМЕСТИТЕ БЕГУНКИ С ЗЕЛЁНЫМИ ПОЛОСАМИ · СТРЕЛКИ — ТОЧНАЯ НАСТРОЙКА",
Vector2(24.0, 344.0), 10, Theme90.SHADE, 430.0)
_draw_action_button(ACTION_RECT, "ЗАПИСАТЬ", true, true)
-1
View File
@@ -1 +0,0 @@
uid://besue7m8ig8k6
-427
View File
@@ -1,427 +0,0 @@
extends MiniGame
## «Чужое слово» (text). Письмо с нормальными и чужими словами.
## Клик помечает/снимает слово; «ОТПРАВИТЬ» фиксирует.
## q = clamp((found - false_positive) / aliens, 0, 1), ложное слово
## учитывается не более одного раза.
const TIERS := {
1: {"words": 11, "aliens": 1, "lines": 2, "types": ["foreign"]},
2: {"words": 15, "aliens": 2, "lines": 2, "types": ["foreign", "deformed"]},
3: {"words": 19, "aliens": 3, "lines": 3, "types": ["foreign", "deformed", "semantic"]},
4: {"words": 24, "aliens": 4, "lines": 3, "types": ["foreign", "deformed", "semantic", "archaic"]},
}
const NORMAL := [
"смета", "отчёт", "курсор", "канал", "папка", "сервер", "файл", "запрос",
"договор", "смена", "пакет", "клиент", "письмо", "проверка", "срочно",
"подтвердить", "согласовано", "принято", "оплата", "счёт", "архив",
"копия", "печать", "модем", "линия", "обмен", "срок", "заявка",
"подпись", "курьер", "терминал", "диспетчер", "доставка", "опись",
"накладная", "пломба", "сверка", "реестр", "график", "смена", "склад",
"погрузка", "маршрут", "квитанция", "сектор", "порт", "кабель", "узел",
"сбой", "ремонт", "дежурный", "инструкция", "согласование", "скан",
]
const ALIEN_POOLS := {
"foreign": [
"deadline", "please", "update", "attachment", "urgent", "email",
"file", "message", "report", "invoice", "delivery", "storage",
],
"deformed": [
"подтверDить", "согласоваNо", "клиеNт", "проверXа", "докумеNт",
"срочNо", "пакеD", "адреZ", "сектоR", "накладNая",
],
"semantic": [
"СУХОЙ-МОДЕМ", "КВАДРАТНЫЙ-ЧАЙ", "ЖИДКИЙ-ФАКС", "ГЛУХОЙ-ЗВОНОК",
"СОЛЁНЫЙ-КАБЕЛЬ", "ЦИФРОВОЙ-СНЕГ", "МОКРАЯ-ПОЧТА", "ХРУСТЯЩИЙ-ДИСК",
],
"archaic": [
"ТЕЛЕГРАФ", "ПЕРФОЛЕНТА", "ТЕЛЕКС", "ГРАММОФОН", "АРИФМОМЕТР",
"ПАТЕФОН", "КЕРОСИНКА", "БОБИНА", "ТАБУЛЯТОР", "ДИСКЕТА",
],
}
const ACTION_LABEL := "ОТПРАВИТЬ"
var generated: Dictionary = {}
var _words: Array = []
var _marked: Dictionary = {}
var _layout: Array = []
var _hover: int = -1
var _found: int = 0
func _own_game_id() -> String:
return "text"
func _setup() -> void:
generated = generate(tier, seed_value)
_words = []
for w in generated.get("words", []):
_words.append((w as Dictionary).duplicate(true))
_marked.clear()
_found = 0
_hover = -1
_build_layout()
# --- параметры/чистая логика ----------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIERS.get(clampi(p_tier, 1, 4), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("text", p_tier)
if row.is_empty():
return base
for key in ["words", "aliens", "lines"]:
if row.has(key):
base[key] = int(row[key])
return base
static func generate(p_tier: int, seed: int) -> Dictionary:
var p := tier_params(p_tier)
var total := clampi(int(p.get("words", 11)), 6, 30)
var aliens := clampi(int(p.get("aliens", 1)), 1, mini(6, total - 2))
var lines := clampi(int(p.get("lines", 2)), 1, 4)
var allowed: Array = p.get("types", ["foreign"])
if allowed.is_empty():
allowed = ["foreign"]
var rng := RandomNumberGenerator.new()
rng.seed = seed
var normal_pool := NORMAL.duplicate()
_shuffle(normal_pool, rng)
var chosen: Array = []
var used := {}
for word in normal_pool:
var key := str(word).to_lower()
if used.has(key):
continue
used[key] = true
chosen.append(str(word))
if chosen.size() >= total - aliens:
break
while chosen.size() < total - aliens:
var filler := "пакет%d" % (chosen.size() + 1)
if not used.has(filler):
used[filler] = true
chosen.append(filler)
var alien_words: Array = []
var types_used: Array = []
var type_cycle: Array = allowed.duplicate()
_shuffle(type_cycle, rng)
for i in range(aliens):
var type := str(type_cycle[i % type_cycle.size()])
var pool: Array = ALIEN_POOLS.get(type, ALIEN_POOLS["foreign"])
var candidates := pool.duplicate()
_shuffle(candidates, rng)
var picked := ""
for candidate in candidates:
var candidate_key := str(candidate).to_lower()
if not used.has(candidate_key):
picked = str(candidate)
break
if picked.is_empty():
# Пул исчерпан — берём любую другую разрешённую категорию.
for alt in allowed:
var alt_pool: Array = ALIEN_POOLS.get(str(alt), [])
for candidate in alt_pool:
var candidate_key := str(candidate).to_lower()
if not used.has(candidate_key):
picked = str(candidate)
type = str(alt)
break
if not picked.is_empty():
break
if picked.is_empty():
picked = "чужое%d" % (i + 1)
used[picked.to_lower()] = true
alien_words.append({"text": picked, "type": type})
if not types_used.has(type):
types_used.append(type)
# Строки: сумма ровно total, все строки непустые.
var rows: Array = _split_counts(total, lines, rng)
var words: Array = []
for word in chosen:
words.append({"text": word, "alien": false, "type": "", "row": 0})
for alien in alien_words:
words.append({"text": str(alien["text"]), "alien": true,
"type": str(alien["type"]), "row": 0})
_shuffle(words, rng)
var row_index := 0
var in_row := 0
for i in range(words.size()):
if row_index >= rows.size():
row_index = rows.size() - 1
words[i]["row"] = row_index
in_row += 1
if in_row >= int(rows[row_index]) and row_index < rows.size() - 1:
row_index += 1
in_row = 0
var alien_count := 0
for w in words:
if bool(w["alien"]):
alien_count += 1
return {
"tier": clampi(p_tier, 1, 4),
"words": words,
"count": words.size(),
"aliens": alien_count,
"rows": rows,
"types": allowed,
}
static func _split_counts(total: int, lines: int, rng: RandomNumberGenerator) -> Array:
var out: Array = []
var base := total / lines
var rest := total % lines
for i in range(lines):
out.append(base + (1 if i < rest else 0))
if lines > 1 and rng.randf() < 0.5 and int(out[0]) > 1 and int(out[lines - 1]) > 1:
out[0] = int(out[0]) + 1
out[lines - 1] = int(out[lines - 1]) - 1
return out
static func validate_generation(data: Dictionary, p_tier: int) -> bool:
var p := tier_params(p_tier)
var words: Array = data.get("words", [])
var rows: Array = data.get("rows", [])
if words.size() != int(p.get("words", words.size())):
return false
var expected_aliens := int(p.get("aliens", 1))
var aliens := 0
var seen := {}
var row_sum := 0
for r in rows:
row_sum += int(r)
if row_sum != words.size() or rows.size() != int(p.get("lines", rows.size())):
return false
for w in words:
var text := str(w.get("text", "")).strip_edges()
if text.is_empty():
return false
var key := text.to_lower()
if seen.has(key):
return false
seen[key] = true
if bool(w.get("alien", false)):
aliens += 1
if not _is_known_alien(text):
return false
else:
if _is_known_alien(text):
return false
return aliens == expected_aliens
static func _is_known_alien(word: String) -> bool:
var key := word.strip_edges().to_lower()
for pool in ALIEN_POOLS.values():
for candidate in (pool as Array):
if str(candidate).strip_edges().to_lower() == key:
return true
return false
static func quality_for(found: int, false_positive: int, aliens: int) -> float:
if aliens <= 0:
return 1.0
return clampf((float(found) - float(false_positive)) / float(aliens), 0.0, 1.0)
static func _shuffle(list: Array, p_rng: RandomNumberGenerator) -> void:
for i in range(list.size() - 1, 0, -1):
var j := p_rng.randi_range(0, i)
var tmp = list[i]
list[i] = list[j]
list[j] = tmp
# --- раскладка/игра --------------------------------------------------------
func _build_layout() -> void:
_layout.clear()
var font := Theme90.ui()
if _words.is_empty():
return
var panel := Rect2(28.0, 62.0, 584.0, 232.0)
var inner_w := panel.size.x - 40.0
var rows_count := int(generated.get("rows", []).size())
if rows_count <= 0:
rows_count = 2
var counts: Array = generated.get("rows", [])
var size_px := 10
for candidate in [16, 12, 10]:
var fits := true
for r in range(counts.size()):
var width := 0.0
for w in _row_words(r, counts):
width += font.get_string_size(str(w["text"]), HORIZONTAL_ALIGNMENT_LEFT,
-1, int(candidate)).x + 10.0
width += 7.0 * float(maxi(0, _row_words(r, counts).size() - 1))
if width > inner_w:
fits = false
break
if fits:
size_px = int(candidate)
break
var line_h := float(size_px) + 13.0
var row_gap := maxf(6.0, (panel.size.y - 30.0 - line_h * float(rows_count))
/ float(maxi(1, rows_count - 1)))
var y := panel.position.y + 14.0
for r in range(counts.size()):
var row_list := _row_words(r, counts)
var widths: Array = []
var total_w := 0.0
for w in row_list:
var ww := font.get_string_size(str(w["text"]), HORIZONTAL_ALIGNMENT_LEFT,
-1, size_px).x + 10.0
widths.append(ww)
total_w += ww
total_w += 7.0 * float(maxi(0, row_list.size() - 1))
var x := panel.position.x + 20.0 + maxf(0.0, (inner_w - total_w) * 0.5)
var row_layout: Array = []
for i in range(row_list.size()):
var ww := float(widths[i])
row_layout.append({
"idx": int(row_list[i]["idx"]),
"rect": Rect2(x, y, ww, line_h),
})
x += ww + 7.0
_layout.append(row_layout)
y += line_h + row_gap
_layout.append(size_px)
func _row_words(r: int, counts: Array) -> Array:
var out: Array = []
for i in range(_words.size()):
if int(_words[i].get("row", 0)) == r:
var copy: Dictionary = (_words[i] as Dictionary).duplicate(true)
copy["idx"] = i
out.append(copy)
return out
func _word_index_at(local: Vector2) -> int:
for row in _layout:
if not (row is Array):
continue
for item in (row as Array):
var hit: Rect2 = (item as Dictionary)["rect"]
if hit.grow(3.0).has_point(local):
return int((item as Dictionary)["idx"])
return -1
func _false_positive_count() -> int:
var n := 0
for idx in _marked.keys():
var i := int(idx)
if i >= 0 and i < _words.size() and not bool(_words[i].get("alien", false)):
n += 1
return n
func _recount() -> void:
_found = 0
for idx in _marked.keys():
var i := int(idx)
if i >= 0 and i < _words.size() and bool(_words[i].get("alien", false)):
_found += 1
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if ACTION_RECT.has_point(local):
var fp := _false_positive_count()
_finish(quality_for(_found, fp, int(generated.get("aliens", 1))))
return true
var idx := _word_index_at(local)
if idx < 0:
return false
if _marked.has(idx):
_marked.erase(idx)
else:
_marked[idx] = true
_recount()
_play_sfx(&"tick")
queue_redraw()
return true
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
var idx := _word_index_at(local)
if idx != _hover:
_hover = idx
queue_redraw()
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if keycode == KEY_ENTER or keycode == KEY_KP_ENTER:
var fp := _false_positive_count()
_finish(quality_for(_found, fp, int(generated.get("aliens", 1))))
return true
return false
func _draw_game() -> void:
var font := Theme90.ui()
var fp := _false_positive_count()
_draw_label("ПИСЬМО КЛИЕНТА · ЗАДАНИЕ 97", Vector2(24.0, 46.0), 10,
Theme90.ACCENT, 260.0)
_draw_label("НАЙДЕНО %d / %d · ЛОЖНЫХ %d" % [_found,
int(generated.get("aliens", 1)), fp], Vector2(300.0, 46.0), 10,
Theme90.SUCCESS if _found >= int(generated.get("aliens", 1)) else Theme90.SHADE,
300.0, HORIZONTAL_ALIGNMENT_RIGHT)
var panel := Rect2(28.0, 62.0, 584.0, 232.0)
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 4, 1,
Theme90.SHADOW), panel)
draw_rect(Rect2(panel.position.x + 18.0, panel.position.y + 12.0,
panel.size.x - 36.0, 1.0), Theme90.HAIRLINE)
_draw_label("СЛУЖЕБНАЯ ЗАПИСКА · НАЙДИТЕ ЧУЖИЕ СЛОВА", Vector2(panel.position.x + 20.0,
panel.position.y + 20.0), 10, Theme90.SHADE, panel.size.x - 40.0)
var size_px := 12
if not _layout.is_empty() and typeof(_layout[_layout.size() - 1]) == TYPE_INT:
size_px = int(_layout[_layout.size() - 1])
for row in _layout:
if not (row is Array):
continue
for item in (row as Array):
var d: Dictionary = item
var idx := int(d["idx"])
var rect: Rect2 = d["rect"]
var word: String = str(_words[idx].get("text", ""))
var marked := _marked.has(idx)
if marked and _hover == idx:
draw_style_box(Theme90.box(Theme90.ACCENT_PALE, Theme90.ACCENT, 2, 1),
rect)
elif marked:
var fcol := Theme90.ERROR if not bool(_words[idx].get("alien", false)) \
else Theme90.SUCCESS
draw_style_box(Theme90.box(Color(fcol.r, fcol.g, fcol.b, 0.13),
fcol, 2, 1), rect)
elif _hover == idx:
draw_style_box(Theme90.box(Theme90.PAPER_LO, Theme90.BORDER_SOFT, 2, 1),
rect)
var color := Theme90.INK
if marked:
color = Theme90.ERROR if not bool(_words[idx].get("alien", false)) else Theme90.SUCCESS
Theme90.text_vcenter(self, font, rect, word, size_px, color,
rect.size.x, HORIZONTAL_ALIGNMENT_CENTER)
if marked and bool(_words[idx].get("alien", false)):
draw_line(Vector2(rect.position.x + 2.0, rect.get_center().y + 5.0),
Vector2(rect.end.x - 2.0, rect.get_center().y + 5.0),
Theme90.SUCCESS, 1.5)
_draw_label("КЛИК ПО СЛОВУ — ПОМЕТИТЬ · ПОВТОРНЫЙ КЛИК — СНЯТЬ",
Vector2(24.0, 300.0), 10, Theme90.SHADE, 400.0)
_draw_label("ЧУЖИЕ: ИНОСТРАННЫЕ, С ОШИБКОЙ, НЕУМЕСТНЫЕ, УСТАРЕВШИЕ",
Vector2(24.0, 344.0), 10, Theme90.SHADE, 430.0)
_draw_action_button(ACTION_RECT, ACTION_LABEL, true, true)
-1
View File
@@ -1 +0,0 @@
uid://cn5qitwtcdc72
-426
View File
@@ -1,426 +0,0 @@
extends MiniGame
## «Раскадровка» (video). Разложить кадры по слотам.
##
## T1 3/3 (силуэт), T2 4/4 (таймкод), T3 5 карточек/4 слота (1 скрытый
## брак), T4 6 карточек/5 слотов (1 брак + 1 зеркальный кадр).
## Таймкоды случайны для каждой раздачи, брак дублирует ровно один реальный
## таймкод и отличается силуэтом.
const TIERS := {
1: {"cards": 3, "slots": 3, "decoy": false, "mirror": false, "hint": "shape"},
2: {"cards": 4, "slots": 4, "decoy": false, "mirror": false, "hint": "timecode"},
3: {"cards": 5, "slots": 4, "decoy": true, "mirror": false, "hint": "ghost"},
4: {"cards": 6, "slots": 5, "decoy": true, "mirror": true, "hint": "timecode"},
}
const SHAPES := ["circle", "triangle", "square", "diamond", "cross"]
const COLORS := ["accent", "warn", "success", "error", "navy"]
const COLOR_HEX := {
"accent": Color(0.184314, 0.498039, 0.560784),
"warn": Color(0.772549, 0.541176, 0.180392),
"success": Color(0.247059, 0.560784, 0.368627),
"error": Color(0.760784, 0.356863, 0.290196),
"navy": Color(0.172549, 0.360784, 0.407843),
}
var generated: Dictionary = {}
var _cards: Array = []
var _selected: int = -1
var _card_slot: Array[int] = []
var _slot_rects: Array[Rect2] = []
var _tray_rects: Array = []
func _own_game_id() -> String:
return "video"
func _setup() -> void:
generated = generate(tier, seed_value)
_cards = []
for card in generated.get("cards", []):
_cards.append((card as Dictionary).duplicate(true))
_card_slot.clear()
for i in range(_cards.size()):
_card_slot.append(-1)
_selected = -1
_build_slot_rects()
# --- параметры/чистая логика ----------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIERS.get(clampi(p_tier, 1, 4), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("video", p_tier)
if row.is_empty():
return base
if row.has("frames"):
base["cards"] = int(row["frames"])
if row.has("cards"):
base["cards"] = int(row["cards"])
if row.has("slots"):
base["slots"] = int(row["slots"])
if row.has("decoy"):
base["decoy"] = bool(row["decoy"])
if row.has("decoys"):
base["decoy"] = int(row["decoys"]) > 0
if row.has("mirror"):
base["mirror"] = bool(row["mirror"])
return base
static func generate(p_tier: int, seed: int) -> Dictionary:
var p := tier_params(p_tier)
var slots := clampi(int(p.get("slots", 3)), 2, 5)
var cards_total := clampi(int(p.get("cards", slots)), slots, 7)
var want_decoy := bool(p.get("decoy", false)) and cards_total > slots
var want_mirror := bool(p.get("mirror", false))
var rng := RandomNumberGenerator.new()
rng.seed = seed
var shape_pool := SHAPES.duplicate()
var color_pool := COLORS.duplicate()
_shuffle(shape_pool, rng)
_shuffle(color_pool, rng)
# Таймкоды: случайная стартовая секунда и случайные интервалы; слот i
# получает i-й по возрастанию таймкод, поэтому порядок не фиксирован.
var seconds := rng.randi_range(0, 18)
var timecodes: Array[String] = []
for i in range(slots):
timecodes.append("00:%02d" % seconds)
seconds += rng.randi_range(2, 7)
var slot_shapes: Array = []
var cards: Array = []
var mirror_index := -1
if want_mirror:
mirror_index = rng.randi_range(0, slots - 1)
for i in range(slots):
var shape := str(shape_pool[i % shape_pool.size()])
var color := str(color_pool[i % color_pool.size()])
var mirrored := i == mirror_index
if mirrored and shape == "circle":
# Зеркало заметно только на несимметричной фигуре.
shape = "triangle"
slot_shapes.append({"shape": shape, "color": color, "timecode": timecodes[i]})
cards.append({
"id": cards.size(),
"timecode": timecodes[i],
"shape": shape,
"color": color,
"correct_slot": i,
"decoy": false,
"mirrored": mirrored,
})
if want_decoy:
var source_slot := rng.randi_range(0, slots - 1)
var source: Dictionary = cards[source_slot]
var decoy_shape := str(source["shape"])
var candidates: Array = []
for s in SHAPES:
if str(s) != decoy_shape:
candidates.append(str(s))
decoy_shape = str(candidates[rng.randi_range(0, candidates.size() - 1)])
cards.append({
"id": cards.size(),
"timecode": str(source["timecode"]),
"shape": decoy_shape,
"color": str(source["color"]),
"correct_slot": -1,
"decoy": true,
"mirrored": false,
})
# В лотке карточки перемешаны; брак не первый и не последний.
var tray := cards.duplicate(true)
_shuffle(tray, rng)
if want_decoy and tray.size() > 2:
var decoy_idx := -1
for i in range(tray.size()):
if bool((tray[i] as Dictionary).get("decoy", false)):
decoy_idx = i
break
if decoy_idx == 0 or decoy_idx == tray.size() - 1:
var safe_idx := rng.randi_range(1, tray.size() - 2)
var tmp = tray[decoy_idx]
tray[decoy_idx] = tray[safe_idx]
tray[safe_idx] = tmp
return {
"tier": clampi(p_tier, 1, 4),
"slots": slots,
"cards": tray,
"card_count": tray.size(),
"slot_timecodes": timecodes,
"slot_shapes": slot_shapes,
"hint": str(p.get("hint", "timecode")),
"has_decoy": want_decoy,
"has_mirror": want_mirror,
}
static func quality_from(slots: int, correct: int, wrong: int, decoys_in_slot: int) -> float:
if slots <= 0:
return 0.0
return clampf((float(correct) - 0.5 * float(maxi(0, wrong))
- float(maxi(0, decoys_in_slot))) / float(slots), 0.0, 1.0)
static func _shuffle(list: Array, p_rng: RandomNumberGenerator) -> void:
for i in range(list.size() - 1, 0, -1):
var j := p_rng.randi_range(0, i)
var tmp = list[i]
list[i] = list[j]
list[j] = tmp
static func decoy_info(data: Dictionary) -> Dictionary:
var cards: Array = data.get("cards", [])
var counts := {}
for card in cards:
var tc := str(card.get("timecode", ""))
counts[tc] = int(counts.get(tc, 0)) + 1
for card in cards:
if not bool(card.get("decoy", false)):
continue
var tc := str(card.get("timecode", ""))
var duplicate_real := -1
for other in cards:
if other == card or bool(other.get("decoy", false)):
continue
if str(other.get("timecode", "")) == tc:
duplicate_real = int(other.get("id", -1))
break
return {
"decoy_timecode": tc,
"duplicate_real": duplicate_real,
"decoy_shape": str(card.get("shape", "")),
"duplicate_count": int(counts.get(tc, 0)),
}
return {"decoy_timecode": "", "duplicate_real": -1, "decoy_shape": "",
"duplicate_count": 0}
# --- раскладка/игра --------------------------------------------------------
func _build_slot_rects() -> void:
_slot_rects.clear()
var slots := int(generated.get("slots", 3))
var area := Rect2(40.0, 54.0, 560.0, 128.0)
var gap := 10.0
var w := (area.size.x - gap * float(maxi(0, slots - 1))) / float(maxi(1, slots))
for i in range(slots):
_slot_rects.append(Rect2(area.position.x + float(i) * (w + gap),
area.position.y, w, area.size.y))
func _recompute_tray() -> void:
_tray_rects.clear()
var visible: Array = []
for i in range(_cards.size()):
if _card_slot[i] < 0:
visible.append(i)
var card_w := 84.0
var card_h := 96.0
var gap := 8.0
var total := float(visible.size()) * card_w + float(maxi(0, visible.size() - 1)) * gap
var x := 320.0 - total * 0.5
for idx in visible:
_tray_rects.append({"card": int(idx), "rect": Rect2(x, 224.0, card_w, card_h)})
x += card_w + gap
func _slot_at(local: Vector2) -> int:
for i in range(_slot_rects.size()):
if _slot_rects[i].has_point(local):
return i
return -1
func _card_at(local: Vector2) -> int:
_recompute_tray()
for entry in _tray_rects:
if (entry["rect"] as Rect2).has_point(local):
return int(entry["card"])
return -1
func _counts() -> Dictionary:
var correct := 0
var wrong := 0
var decoys := 0
for i in range(_cards.size()):
var slot := _card_slot[i]
if slot < 0:
continue
var card: Dictionary = _cards[i]
if bool(card.get("decoy", false)):
decoys += 1
elif slot == int(card.get("correct_slot", -2)):
correct += 1
else:
wrong += 1
return {"correct": correct, "wrong": wrong, "decoys": decoys}
func _quality_now() -> float:
var c := _counts()
return quality_from(int(generated.get("slots", 3)), int(c["correct"]),
int(c["wrong"]), int(c["decoys"]))
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if ACTION_RECT.has_point(local):
_finish(_quality_now())
return true
var si := _slot_at(local)
if si >= 0:
# Клик по занятому слоту возвращает кадр в лоток.
for i in range(_cards.size()):
if _card_slot[i] == si:
_card_slot[i] = -1
_selected = -1
_play_sfx(&"tick")
queue_redraw()
return true
if _selected >= 0:
_card_slot[_selected] = si
_selected = -1
_play_sfx(&"tick")
queue_redraw()
return true
var ci := _card_at(local)
if ci >= 0:
_selected = -1 if _selected == ci else ci
_play_sfx(&"tick")
queue_redraw()
return true
return false
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if keycode == KEY_ENTER or keycode == KEY_KP_ENTER:
_finish(_quality_now())
return true
return false
func _draw_game() -> void:
var font := Theme90.ui()
var slots := int(generated.get("slots", 3))
var hint := str(generated.get("hint", "timecode"))
var c := _counts()
_draw_label("РАСКАДРОВКА · СЛОЙ 97", Vector2(24.0, 46.0), 10,
Theme90.ACCENT, 260.0)
_draw_label("РАЗМЕЩЕНО %d / %d · ОШИБОК %d · БРАК %d" % [
_occupied_slots(), slots, int(c["wrong"]), int(c["decoys"])],
Vector2(300.0, 46.0), 10, Theme90.SHADE, 300.0,
HORIZONTAL_ALIGNMENT_RIGHT)
var slot_shapes: Array = generated.get("slot_shapes", [])
for i in range(slots):
var rect := _slot_rects[i]
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 3, 1), rect)
draw_rect(Rect2(rect.position.x + 1.0, rect.position.y + 20.0,
rect.size.x - 2.0, 1.0), Theme90.HAIRLINE)
_draw_label("СЛОТ %02d" % (i + 1), Vector2(rect.position.x + 8.0,
rect.position.y + 5.0), 10, Theme90.ACCENT, rect.size.x - 16.0)
var occupant := -1
for ci in range(_cards.size()):
if _card_slot[ci] == i:
occupant = ci
break
if occupant >= 0:
_draw_card(_cards[occupant], Rect2(rect.position + Vector2(8, 28),
rect.size - Vector2(16, 36)), true, hint)
_draw_label("КЛИК — ВЕРНУТЬ", Vector2(rect.position.x, rect.end.y - 14.0),
10, Theme90.SHADE, rect.size.x, HORIZONTAL_ALIGNMENT_CENTER)
continue
Theme90.draw_dashed_box(self, Rect2(rect.position + Vector2(8, 28),
rect.size - Vector2(16, 36)), Theme90.BORDER_SOFT, 3.0, 1.0)
if i < slot_shapes.size():
var ghost: Dictionary = slot_shapes[i]
var gshape := str(ghost.get("shape", "circle"))
var gcol: Color = COLOR_HEX.get(str(ghost.get("color", "accent")),
Theme90.NAVY)
if hint == "shape" or hint == "ghost":
_draw_frame_shape(Vector2(rect.get_center().x, rect.position.y + 72.0),
34.0, gshape, Color(gcol.r, gcol.g, gcol.b, 0.50), false)
if hint != "shape":
_draw_centered(str(ghost.get("timecode", "")),
Rect2(rect.position.x, rect.end.y - 34.0, rect.size.x, 24.0),
16 if hint == "ghost" else 24, Theme90.NAVY)
else:
_draw_centered(str(ghost.get("timecode", "")),
Rect2(rect.position.x, rect.end.y - 30.0, rect.size.x, 20.0),
10, Theme90.SHADE)
_recompute_tray()
for entry in _tray_rects:
var card: Dictionary = _cards[int(entry["card"])]
var rect: Rect2 = entry["rect"]
if int(entry["card"]) == _selected:
rect.position.y -= 4.0
_draw_card(card, rect, false, hint)
if int(entry["card"]) == _selected:
draw_rect(rect.grow(3.0), Theme90.NAVY_DEEP, false, 2.0)
_draw_label("КЛИК ПО КАДРУ, ЗАТЕМ ПО СЛОТУ · КЛИК ПО СЛОТУ — ВЕРНУТЬ КАДР",
Vector2(24.0, 344.0), 10, Theme90.SHADE, 430.0)
_draw_action_button(ACTION_RECT, "ОТПРАВИТЬ", true, true)
func _empty_slots() -> int:
var n := 0
for i in range(_cards.size()):
if _card_slot[i] < 0:
n += 1
return n
func _occupied_slots() -> int:
var n := 0
for i in range(_cards.size()):
if _card_slot[i] >= 0:
n += 1
return mini(n, int(generated.get("slots", 3)))
func _draw_card(card: Dictionary, rect: Rect2, in_slot: bool, hint: String) -> void:
var col: Color = COLOR_HEX.get(str(card.get("color", "accent")), Theme90.ACCENT)
draw_style_box(Theme90.box(col, Theme90.BORDER, 3, 1), rect)
var center := Vector2(rect.get_center().x, rect.position.y + rect.size.y * 0.46)
_draw_frame_shape(center, minf(rect.size.y * 0.34, 38.0),
str(card.get("shape", "circle")), Color(1, 1, 1, 0.90),
bool(card.get("mirrored", false)))
var label := str(card.get("timecode", ""))
if hint == "shape" and not in_slot:
label = "КАДР"
_draw_centered(label, Rect2(rect.position.x, rect.end.y - 25.0, rect.size.x, 20.0),
12 if rect.size.x >= 90.0 else 10, Theme90.WHITE)
func _draw_frame_shape(center: Vector2, s: float, shape: String, color: Color,
mirrored: bool) -> void:
var flip := -1.0 if mirrored else 1.0
match shape:
"circle":
draw_circle(center, s * 0.52, color)
"triangle":
draw_colored_polygon(PackedVector2Array([
center + Vector2(0, -s * 0.62) * Vector2(flip, 1),
center + Vector2(s * 0.58, s * 0.40) * Vector2(flip, 1),
center + Vector2(-s * 0.58, s * 0.40) * Vector2(flip, 1)]), color)
"square":
draw_rect(Rect2(center - Vector2(s, s) * 0.46, Vector2(s, s) * 0.92), color)
"diamond":
draw_colored_polygon(PackedVector2Array([
center + Vector2(0, -s * 0.64) * Vector2(flip, 1),
center + Vector2(s * 0.64, 0) * Vector2(flip, 1),
center + Vector2(0, s * 0.64) * Vector2(flip, 1),
center + Vector2(-s * 0.64, 0) * Vector2(flip, 1)]), color)
_:
draw_rect(Rect2(center - Vector2(s * 0.15, s * 0.60),
Vector2(s * 0.30, s * 1.20)), color)
draw_rect(Rect2(center - Vector2(s * 0.60, s * 0.15),
Vector2(s * 1.20, s * 0.30)), color)
-1
View File
@@ -1 +0,0 @@
uid://cdhe6b2d04ehx
-528
View File
@@ -1,528 +0,0 @@
extends MiniGame
## «Провода» (wires). FIX M11: порты не накладываются.
##
## Геометрия 640×360: верх стойки 56, порт 34×34, шаг 40 (34+6),
## высота стойки n*34 + (n-1)*6 + 16, низ <= 320. Левый центр x=140,
## правый x=466: 326 px между центрами, чистый коридор 292 px.
## Провода — квадратичные Безье с отдельной «полосой» на каждый провод
## (шаг 12 px), поэтому линии не сливаются.
const COLORS := {
"accent": Color(0.184314, 0.498039, 0.560784),
"navy": Color(0.172549, 0.360784, 0.407843),
"warn": Color(0.772549, 0.541176, 0.180392),
"error": Color(0.760784, 0.356863, 0.290196),
"success": Color(0.247059, 0.560784, 0.368627),
}
const COLOR_ORDER := ["accent", "navy", "warn", "error", "success"]
const SHAPES := ["circle", "triangle", "square", "diamond", "cross"]
const TIER_SPEC := {
1: {"wires": 3, "sockets": 3, "empty": 0},
2: {"wires": 4, "sockets": 4, "empty": 0},
3: {"wires": 5, "sockets": 6, "empty": 1},
4: {"wires": 6, "sockets": 6, "empty": 0},
}
const BOX := 34.0
const PITCH := 40.0
const LEFT_X := 140.0
const RIGHT_X := 466.0
const RACK_TOP := 56.0
const LANE_STEP := 12.0
var generated: Dictionary = {}
var geo: Dictionary = {}
var _wire_sockets: Array[int] = []
var _socket_owner: Array[int] = []
var _wrong: int = 0
var _drag_wire: int = -1
var _drag_pos: Vector2 = Vector2.ZERO
var _hover_socket: int = -1
var _status: String = ""
var _status_bad: bool = false
var _key_wire: int = 0
var _key_socket: int = 0
var _keyboard_on: bool = false
func _own_game_id() -> String:
return "wires"
func _setup() -> void:
generated = generate(tier, seed_value)
geo = geometry(tier)
_wire_sockets.clear()
_socket_owner.clear()
for i in range(generated.get("wires", []).size()):
_wire_sockets.append(-1)
for i in range(generated.get("sockets", []).size()):
_socket_owner.append(-1)
_wrong = 0
_drag_wire = -1
_hover_socket = -1
_status = ""
_status_bad = false
_key_wire = 0
_key_socket = 0
_keyboard_on = false
# --- параметры/чистая логика ----------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIER_SPEC.get(clampi(p_tier, 1, 4), TIER_SPEC[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("wires", p_tier)
if row.is_empty():
return base
for key in ["wires", "sockets"]:
if row.has(key):
base[key] = int(row[key])
if row.has("empty"):
base["empty"] = int(row["empty"])
if row.has("empty_sockets"):
base["empty"] = int(row["empty_sockets"])
return base
static func generate(p_tier: int, seed: int) -> Dictionary:
var p := tier_params(p_tier)
var wire_count := clampi(int(p.get("wires", 3)), 2, 6)
var socket_count := clampi(int(p.get("sockets", wire_count)), wire_count, 6)
var rng := RandomNumberGenerator.new()
rng.seed = seed
var colors := COLOR_ORDER.duplicate()
var shapes := SHAPES.duplicate()
_shuffle(colors, rng)
_shuffle(shapes, rng)
var combos: Array = []
match clampi(p_tier, 1, 4):
1:
for i in range(wire_count):
combos.append({"color": colors[i % colors.size()],
"shape": shapes[i % shapes.size()]})
2:
# Две пары похожих (повторяющихся) цветов, формы внутри пар разные.
var c0: String = colors[0]
var c1: String = colors[1]
var s: Array = shapes.slice(0, 4)
combos = [
{"color": c0, "shape": s[0]},
{"color": c0, "shape": s[1]},
{"color": c1, "shape": s[2]},
{"color": c1, "shape": s[3]},
]
3:
# Один общий цвет и одна общая форма у разных пар.
var s3: Array = shapes.slice(0, 5)
combos = [
{"color": colors[0], "shape": s3[0]},
{"color": colors[0], "shape": s3[1]},
{"color": colors[1], "shape": s3[0]},
{"color": colors[2], "shape": s3[2]},
{"color": colors[3], "shape": s3[3]},
]
_:
# T4: 2 общих цвета + 2 общие формы, все пары уникальны.
var s4: Array = shapes.slice(0, 4)
combos = [
{"color": colors[0], "shape": s4[0]},
{"color": colors[0], "shape": s4[1]},
{"color": colors[1], "shape": s4[0]},
{"color": colors[1], "shape": s4[2]},
{"color": colors[2], "shape": s4[2]},
{"color": colors[3], "shape": s4[3]},
]
combos = combos.slice(0, wire_count)
_shuffle(combos, rng)
var wires: Array = []
for i in range(combos.size()):
wires.append({
"id": i,
"color": str(combos[i]["color"]),
"shape": str(combos[i]["shape"]),
})
var socket_defs: Array = []
for c in combos:
socket_defs.append({"color": str(c["color"]), "shape": str(c["shape"]),
"empty": false})
while socket_defs.size() < socket_count:
socket_defs.append({"color": "", "shape": "", "empty": true})
_shuffle(socket_defs, rng)
var sockets: Array = []
for i in range(socket_defs.size()):
sockets.append({
"id": i,
"color": str(socket_defs[i]["color"]),
"shape": str(socket_defs[i]["shape"]),
"empty": bool(socket_defs[i]["empty"]),
})
return {
"tier": clampi(p_tier, 1, 4),
"wires": wires,
"sockets": sockets,
"wire_count": wires.size(),
"socket_count": sockets.size(),
}
static func geometry(p_tier: int) -> Dictionary:
var p := tier_params(p_tier)
var n := clampi(int(p.get("wires", 3)), 2, 6)
var s := clampi(int(p.get("sockets", n)), n, 6)
var rack_h := float(n) * BOX + float(n - 1) * 6.0 + 16.0
var right_h := float(s) * BOX + float(s - 1) * 6.0 + 16.0
var left_ports: Array[Vector2] = []
var right_ports: Array[Vector2] = []
for i in range(n):
left_ports.append(Vector2(LEFT_X, RACK_TOP + 8.0 + BOX * 0.5 + float(i) * PITCH))
for i in range(s):
right_ports.append(Vector2(RIGHT_X, RACK_TOP + 8.0 + BOX * 0.5 + float(i) * PITCH))
var left_rects: Array[Rect2] = []
var right_rects: Array[Rect2] = []
for center in left_ports:
left_rects.append(Rect2(center - Vector2(BOX, BOX) * 0.5, Vector2(BOX, BOX)))
for center in right_ports:
right_rects.append(Rect2(center - Vector2(BOX, BOX) * 0.5, Vector2(BOX, BOX)))
var lanes: Array[float] = []
for i in range(n):
lanes.append((float(i) - float(n - 1) * 0.5) * LANE_STEP)
return {
"box": BOX,
"pitch": PITCH,
"left_x": LEFT_X,
"right_x": RIGHT_X,
"top": RACK_TOP,
"left_rack": Rect2(LEFT_X - BOX * 0.5, RACK_TOP, BOX, rack_h),
"right_rack": Rect2(RIGHT_X - BOX * 0.5, RACK_TOP, BOX, right_h),
"rack_h": rack_h,
"right_rack_h": right_h,
"left_ports": left_ports,
"right_ports": right_ports,
"left_rects": left_rects,
"right_rects": right_rects,
"lanes": lanes,
"gap": RIGHT_X - LEFT_X,
"edge_gap": (RIGHT_X - BOX * 0.5) - (LEFT_X + BOX * 0.5),
}
static func matching_sockets(wire: Dictionary, sockets: Array) -> Array:
var out: Array = []
for socket in sockets:
if bool(socket.get("empty", false)):
continue
if str(socket.get("color", "")) == str(wire.get("color", "")) \
and str(socket.get("shape", "")) == str(wire.get("shape", "")):
out.append(int(socket.get("id", out.size())))
return out
static func validate_pairs(data: Dictionary) -> bool:
var wires: Array = data.get("wires", [])
var sockets: Array = data.get("sockets", [])
if wires.is_empty() or sockets.size() < wires.size():
return false
var seen_pairs := {}
for wire in wires:
var key := "%s|%s" % [str(wire.get("color", "")), str(wire.get("shape", ""))]
if seen_pairs.has(key):
return false
seen_pairs[key] = true
var matches := matching_sockets(wire, sockets)
if matches.size() != 1:
return false
return true
static func quality_for(connected: int, wrong: int, total: int) -> float:
if total <= 0:
return 0.0
return clampf((float(connected) - 0.25 * float(maxi(0, wrong))) / float(total),
0.0, 1.0)
static func _shuffle(list: Array, p_rng: RandomNumberGenerator) -> void:
for i in range(list.size() - 1, 0, -1):
var j := p_rng.randi_range(0, i)
var tmp = list[i]
list[i] = list[j]
list[j] = tmp
# --- игра ------------------------------------------------------------------
func _left_center(i: int) -> Vector2:
var ports: Array = geo.get("left_ports", [])
return ports[i] if i >= 0 and i < ports.size() else Vector2(LEFT_X, RACK_TOP)
func _right_center(i: int) -> Vector2:
var ports: Array = geo.get("right_ports", [])
return ports[i] if i >= 0 and i < ports.size() else Vector2(RIGHT_X, RACK_TOP)
func _wire_at(local: Vector2) -> int:
var rects: Array = geo.get("left_rects", [])
for i in range(rects.size()):
if (rects[i] as Rect2).grow(6.0).has_point(local):
return i
return -1
func _socket_at(local: Vector2) -> int:
var rects: Array = geo.get("right_rects", [])
for i in range(rects.size()):
if (rects[i] as Rect2).grow(6.0).has_point(local):
return i
return -1
func _connected_count() -> int:
var n := 0
for s in _wire_sockets:
if s >= 0:
n += 1
return n
func _matches(wi: int, si: int) -> bool:
var wires: Array = generated.get("wires", [])
var sockets: Array = generated.get("sockets", [])
if wi < 0 or wi >= wires.size() or si < 0 or si >= sockets.size():
return false
var wire: Dictionary = wires[wi]
var socket: Dictionary = sockets[si]
if bool(socket.get("empty", false)):
return false
if _socket_owner[si] >= 0:
return false
return str(wire["color"]) == str(socket["color"]) \
and str(wire["shape"]) == str(socket["shape"])
func _connect(wi: int, si: int) -> bool:
if _matches(wi, si):
if _wire_sockets[wi] >= 0:
_socket_owner[_wire_sockets[wi]] = -1
_wire_sockets[wi] = si
_socket_owner[si] = wi
_status = "ПОДКЛЮЧЕНО: %d ИЗ %d" % [_connected_count(), _wire_sockets.size()]
_status_bad = false
_play_sfx(&"minigame_win")
queue_redraw()
if _connected_count() == _wire_sockets.size():
_finish(quality_for(_connected_count(), _wrong, _wire_sockets.size()))
return true
_wrong += 1
_status_bad = true
var sockets: Array = generated.get("sockets", [])
if si >= 0 and si < sockets.size() and bool((sockets[si] as Dictionary).get("empty", false)):
_status = "ГНЕЗДО ПУСТОЕ · +1 ОШИБКА"
elif si >= 0 and si < _socket_owner.size() and _socket_owner[si] >= 0:
_status = "ГНЕЗДО ЗАНЯТО · +1 ОШИБКА"
else:
_status = "НЕ СОВПАЛО: НУЖНЫ ТОТ ЖЕ ЦВЕТ И ФОРМА · +1 ОШИБКА"
_play_sfx(&"minigame_fail")
queue_redraw()
return false
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
var wi := _wire_at(local)
if wi < 0:
return false
_keyboard_on = false
if _wire_sockets[wi] >= 0:
_socket_owner[_wire_sockets[wi]] = -1
_wire_sockets[wi] = -1
_drag_wire = wi
_drag_pos = local
_hover_socket = -1
_play_sfx(&"tick")
queue_redraw()
return true
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if _drag_wire < 0:
return
_drag_pos = local
_hover_socket = _socket_at(local)
queue_redraw()
func _on_pointer_released(local: Vector2, _event: InputEvent) -> void:
if _drag_wire < 0:
return
var wi := _drag_wire
_drag_wire = -1
_hover_socket = -1
var si := _socket_at(local)
if si < 0:
# Отпускание в пустоту — бесплатно.
queue_redraw()
return
_connect(wi, si)
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if keycode == KEY_E or keycode == KEY_ENTER:
if _keyboard_on:
_connect(_key_wire, _key_socket)
else:
var wi := _wire_at(get_local_mouse_position())
var si := _socket_at(get_local_mouse_position())
if wi >= 0 and si >= 0:
_connect(wi, si)
return true
if keycode == KEY_UP or keycode == KEY_W or keycode == KEY_TAB:
_keyboard_on = true
_key_wire = posmod(_key_wire - 1, maxi(1, _wire_sockets.size()))
queue_redraw()
return true
if keycode == KEY_DOWN or keycode == KEY_S:
_keyboard_on = true
_key_wire = (_key_wire + 1) % maxi(1, _wire_sockets.size())
queue_redraw()
return true
if keycode == KEY_LEFT or keycode == KEY_A:
_keyboard_on = true
_key_socket = posmod(_key_socket - 1, maxi(1, _socket_owner.size()))
queue_redraw()
return true
if keycode == KEY_RIGHT or keycode == KEY_D:
_keyboard_on = true
_key_socket = (_key_socket + 1) % maxi(1, _socket_owner.size())
queue_redraw()
return true
return false
# --- отрисовка -------------------------------------------------------------
func _draw_game() -> void:
var left_rack: Rect2 = geo.get("left_rack", Rect2())
var right_rack: Rect2 = geo.get("right_rack", Rect2())
_draw_label("ПОДКЛЮЧЕНО %d / %d" % [_connected_count(), _wire_sockets.size()],
Vector2(24.0, 46.0), 10, Theme90.NAVY, 240.0)
_draw_label("ОШИБОК %d · ПРОВОДОВ %d · ГНЁЗД %d" % [_wrong,
_wire_sockets.size(), _socket_owner.size()],
Vector2(320.0, 46.0), 10, Theme90.SHADE, 280.0,
HORIZONTAL_ALIGNMENT_RIGHT)
draw_style_box(Theme90.box(Theme90.PAPER_LO, Theme90.BORDER_SOFT, 4, 1),
left_rack.grow(6.0))
draw_style_box(Theme90.box(Theme90.PAPER_LO, Theme90.BORDER_SOFT, 4, 1),
right_rack.grow(6.0))
# Сначала уже подключённые кабели.
var wires: Array = generated.get("wires", [])
var sockets: Array = generated.get("sockets", [])
var lanes: Array = geo.get("lanes", [])
for wi in range(_wire_sockets.size()):
var si := _wire_sockets[wi]
if si < 0:
continue
var wire: Dictionary = wires[wi]
var lane := float(lanes[wi]) if wi < lanes.size() else 0.0
_draw_cable(_left_center(wi) + Vector2(BOX * 0.5, 0.0),
_right_center(si) - Vector2(BOX * 0.5, 0.0),
_color_of(str(wire["color"])), lane)
if _drag_wire >= 0 and _drag_wire < wires.size():
var wire: Dictionary = wires[_drag_wire]
var lane := float(lanes[_drag_wire]) if _drag_wire < lanes.size() else 0.0
_draw_cable(_left_center(_drag_wire) + Vector2(BOX * 0.5, 0.0),
_drag_pos, _color_of(str(wire["color"])), lane * 0.8)
for wi in range(wires.size()):
var wire: Dictionary = wires[wi]
var center := _left_center(wi)
var rect := Rect2(center - Vector2(BOX, BOX) * 0.5, Vector2(BOX, BOX))
var col := _color_of(str(wire["color"]))
var connected := _wire_sockets[wi] >= 0
draw_style_box(Theme90.box(col.lightened(0.70), col, 4, 1,
Theme90.SHADOW), rect)
_draw_shape(center, 18.0, str(wire["shape"]), col)
if connected:
draw_circle(Vector2(rect.end.x - 5.0, rect.position.y + 5.0), 3.0,
Theme90.SUCCESS)
if wi == _drag_wire:
draw_rect(rect.grow(3.0), Theme90.NAVY_DEEP, false, 2.0)
if _keyboard_on and wi == _key_wire:
Theme90.draw_dashed_box(self, rect.grow(4.0), Theme90.NAVY, 3.0, 1.0)
for si in range(sockets.size()):
var socket: Dictionary = sockets[si]
var center := _right_center(si)
var rect := Rect2(center - Vector2(BOX, BOX) * 0.5, Vector2(BOX, BOX))
if bool(socket.get("empty", false)):
Theme90.draw_dashed_box(self, rect, Theme90.SHADE, 3.0, 1.0)
_draw_label("—", Vector2(center.x - 5.0, center.y - 7.0), 16,
Theme90.SHADE)
continue
var col := _color_of(str(socket["color"]))
var hover := si == _hover_socket and _drag_wire >= 0
draw_style_box(Theme90.box(Theme90.PAPER_HI if not hover else Theme90.ACCENT_PALE,
col if not hover else Theme90.ACCENT, 4, 2 if hover else 1), rect)
_draw_shape(center, 18.0, str(socket["shape"]), col.darkened(0.05))
if _socket_owner[si] >= 0:
draw_circle(Vector2(rect.end.x - 5.0, rect.position.y + 5.0), 3.0,
Theme90.SUCCESS)
if _keyboard_on and si == _key_socket:
Theme90.draw_dashed_box(self, rect.grow(4.0), Theme90.NAVY, 3.0, 1.0)
if _status.is_empty():
_status = "ТЯНИТЕ ПРОВОД СЛЕВА В ПРАВОЕ ГНЕЗДО: ЦВЕТ + ФОРМА"
_draw_label(_status, Vector2(24.0, 344.0), 10,
Theme90.ERROR if _status_bad else Theme90.SHADE, 430.0)
if _status_bad:
_draw_label("+0.25 ШТРАФА", Vector2(466.0, 344.0), 10, Theme90.ERROR,
156.0, HORIZONTAL_ALIGNMENT_RIGHT)
func _draw_cable(from: Vector2, to: Vector2, color: Color, lane: float) -> void:
var mid := (from + to) * 0.5
var control := Vector2(mid.x, mid.y + lane)
var points := PackedVector2Array()
for i in range(25):
var t := float(i) / 24.0
var p := from.lerp(control, t).lerp(control.lerp(to, t), t)
points.append(p)
draw_polyline(points, color, 3.0, true)
func _draw_shape(center: Vector2, size: float, shape: String, color: Color) -> void:
match shape:
"circle":
draw_circle(center, size * 0.55, color)
"triangle":
draw_colored_polygon(PackedVector2Array([
center + Vector2(0, -size * 0.62),
center + Vector2(size * 0.60, size * 0.42),
center + Vector2(-size * 0.60, size * 0.42)]), color)
"square":
draw_rect(Rect2(center - Vector2(size, size) * 0.48,
Vector2(size, size) * 0.96), color)
"diamond":
draw_colored_polygon(PackedVector2Array([
center + Vector2(0, -size * 0.66),
center + Vector2(size * 0.66, 0),
center + Vector2(0, size * 0.66),
center + Vector2(-size * 0.66, 0)]), color)
_:
draw_rect(Rect2(center - Vector2(size * 0.16, size * 0.62),
Vector2(size * 0.32, size * 1.24)), color)
draw_rect(Rect2(center - Vector2(size * 0.62, size * 0.16),
Vector2(size * 1.24, size * 0.32)), color)
func _color_of(id: String) -> Color:
return COLORS.get(id, Theme90.ACCENT)
-1
View File
@@ -1 +0,0 @@
uid://b2cghsoxycui0
-235
View File
@@ -1,235 +0,0 @@
# run_all_tests.ps1 - headless test runner for PEAK HOUR 97 v2 (Windows).
#
# Usage:
# powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1
# $env:GODOT_BIN = "D:\path\Godot_v4.7.2-stable_win64_console.exe"
# powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1
#
# Each test is a separate Godot process with a per-test timeout. Missing test
# files are skipped (SKIP), not failed. APPDATA is redirected to a temp folder
# for the run so tests never touch the real user save. The final parse check
# runs `--headless --path <root> --quit`.
[CmdletBinding()]
param(
[string]$GodotBin = $env:GODOT_BIN,
[int]$TimeoutSec = 180
)
$ErrorActionPreference = "Stop"
$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
Set-Location $Root
if ([string]::IsNullOrWhiteSpace($GodotBin)) {
$GodotBin = "D:\Apps\godot\Godot_v4.7.2-stable_win64_console.exe"
}
if (-not (Test-Path -LiteralPath $GodotBin -PathType Leaf)) {
$fallback = Get-Command godot4 -ErrorAction SilentlyContinue
if (-not $fallback) { $fallback = Get-Command godot -ErrorAction SilentlyContinue }
if ($fallback) { $GodotBin = $fallback.Source }
}
if (-not (Test-Path -LiteralPath $GodotBin -PathType Leaf)) {
Write-Host "FAIL: Godot binary not found: $GodotBin" -ForegroundColor Red
Write-Host "Set GODOT_BIN or pass -GodotBin <path>."
exit 1
}
$Tests = @(
"res://tests/run_tests.gd",
"res://tests/test_hour.gd",
"res://tests/test_slots.gd",
"res://tests/test_shop.gd",
"res://tests/test_minigame.gd",
"res://tests/test_send_games.gd",
"res://tests/test_attack_games.gd",
"res://tests/test_file_grid.gd",
"res://tests/test_shop_modal.gd",
"res://tests/test_audio.gd",
"res://tests/test_ambient.gd",
"res://tests/test_fuzz.gd",
"res://tests/test_integration.gd",
"res://tests/test_attacks.gd",
"res://tests/test_controller.gd",
"res://tests/test_main_prep.gd",
"res://tests/test_main_hour.gd",
"res://tests/test_main_interactions.gd",
"res://tests/test_main_attacks.gd",
"res://tests/test_main_pause.gd",
"res://tests/test_main_fullscreen.gd",
"res://tests/test_main_polish.gd",
"res://tests/test_toast.gd",
"res://tests/test_full_playthrough.gd",
"res://scripts/ui/ui_smoke.gd"
)
$ErrorRe = 'SCRIPT ERROR|Parse Error|USER ERROR|Failed to load|Cannot open file|FAIL|SMOKE FAILED'
$LeakRe = 'ObjectDB instances leaked|leaked at exit'
function Quote-Arg([string]$Value) {
return '"' + $Value.Replace('"', '\"') + '"'
}
function Invoke-Godot([string]$Arguments, [int]$Timeout) {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $GodotBin
$psi.Arguments = $Arguments
$psi.WorkingDirectory = $Root
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$process = New-Object System.Diagnostics.Process
$process.StartInfo = $psi
$null = $process.Start()
$outTask = $process.StandardOutput.ReadToEndAsync()
$errTask = $process.StandardError.ReadToEndAsync()
$exited = $process.WaitForExit($Timeout * 1000)
if (-not $exited) {
try { $process.Kill() } catch { }
try { $process.WaitForExit(5000) | Out-Null } catch { }
}
$stdout = ""
$stderr = ""
try { $stdout = $outTask.Result } catch { }
try { $stderr = $errTask.Result } catch { }
$code = -1
if ($exited) { $code = $process.ExitCode }
return @{
ExitCode = $code
TimedOut = (-not $exited)
Output = ($stdout + "`n" + $stderr)
}
}
function Write-Tail([string]$Text, [int]$Count = 40) {
$lines = $Text -split "`r?`n"
if ($lines.Count -gt $Count) { $lines = $lines[($lines.Count - $Count)..($lines.Count - 1)] }
Write-Host "----- output (last $Count lines) -----"
foreach ($line in $lines) { Write-Host $line }
Write-Host "-------------------------------------"
}
function Test-Log([string]$Text, [string]$Pattern) {
# Case-sensitive on purpose: test summaries print "fails=0", which must not
# be mistaken for the uppercase FAIL marker.
return [bool]([regex]::IsMatch($Text, $Pattern))
}
$Tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("peak-hour-tests-" + [guid]::NewGuid().ToString("N"))
$LogDir = Join-Path $Tmp "logs"
$OldAppData = $env:APPDATA
New-Item -ItemType Directory -Path $Tmp -Force | Out-Null
New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
$env:APPDATA = Join-Path $Tmp "appdata"
New-Item -ItemType Directory -Path $env:APPDATA -Force | Out-Null
$Passed = 0
$Failed = 0
$Skipped = @()
$FailedNames = @()
Write-Host "PEAK HOUR 97 - headless tests (v2, Windows)"
Write-Host "Godot: $GodotBin"
Write-Host "APPDATA (temp): $env:APPDATA"
Write-Host ""
try {
foreach ($script in $Tests) {
$localPath = Join-Path $Root ($script -replace '^res://', '' -replace '/', '\')
$name = [System.IO.Path]::GetFileNameWithoutExtension($script)
if (-not (Test-Path -LiteralPath $localPath -PathType Leaf)) {
$Skipped += $name
Write-Host "SKIP $name (no file)"
continue
}
Write-Host ""
Write-Host ">>> $script"
$godotArgs = "--headless --path $(Quote-Arg $Root) --script $(Quote-Arg $script)"
$result = Invoke-Godot $godotArgs $TimeoutSec
$logPath = Join-Path $LogDir ($name + ".log")
Set-Content -LiteralPath $logPath -Value $result.Output -Encoding UTF8
if ($result.TimedOut) {
Write-Host "FAIL $name (timeout ${TimeoutSec}s)"
Write-Tail $result.Output
$Failed++
$FailedNames += $name
continue
}
if ($result.ExitCode -eq 0) {
if (Test-Log $result.Output $ErrorRe) {
Write-Host "FAIL $name (exit 0, but errors in log)"
Write-Tail $result.Output
$Failed++
$FailedNames += $name
} else {
Write-Host "PASS $name"
$Passed++
}
continue
}
if (Test-Log $result.Output $ErrorRe) {
Write-Host "FAIL $name (exit $($result.ExitCode))"
Write-Tail $result.Output
$Failed++
$FailedNames += $name
continue
}
if ((Test-Log $result.Output $LeakRe) -and -not (Test-Log $result.Output 'ERROR:')) {
Write-Host "PASS $name (exit $($result.ExitCode), harmless ObjectDB leak warning)"
$Passed++
continue
}
Write-Host "FAIL $name (exit $($result.ExitCode))"
Write-Tail $result.Output
$Failed++
$FailedNames += $name
}
Write-Host ""
Write-Host ">>> parse check: --headless --path . --quit"
$parseArgs = "--headless --path $(Quote-Arg $Root) --quit"
$parse = Invoke-Godot $parseArgs $TimeoutSec
Set-Content -LiteralPath (Join-Path $LogDir "parse_check.log") -Value $parse.Output -Encoding UTF8
$parseOk = $false
if (-not $parse.TimedOut) {
if ($parse.ExitCode -eq 0) {
$parseOk = $true
} elseif ((Test-Log $parse.Output $LeakRe) -and -not (Test-Log $parse.Output 'ERROR:')) {
$parseOk = $true
}
}
if ($parseOk) {
Write-Host "PASS parse-check"
$Passed++
} else {
Write-Host "FAIL parse-check (exit $($parse.ExitCode))"
Write-Tail $parse.Output
$Failed++
$FailedNames += "parse-check"
}
}
finally {
$env:APPDATA = $OldAppData
try {
Remove-Item -LiteralPath $Tmp -Recurse -Force -ErrorAction SilentlyContinue
} catch { }
}
Write-Host ""
Write-Host "================================"
Write-Host "Total: PASS=$Passed FAIL=$Failed SKIP=$($Skipped.Count)"
if ($Skipped.Count -gt 0) {
Write-Host "Skipped: $($Skipped -join ', ')"
}
if ($Failed -ne 0) {
Write-Host "Failed: $($FailedNames -join ', ')" -ForegroundColor Red
exit 1
}
Write-Host "All tests passed." -ForegroundColor Green
exit 0
+16 -39
View File
@@ -1,18 +1,18 @@
#!/usr/bin/env bash
# run_all_tests.sh — прогон всех headless-тестов PEAK HOUR 97 (v2).
# run_all_tests.sh — прогон всех headless-тестов PEAK HOUR 97.
#
# Запуск:
# bash scripts/run_all_tests.sh
# GODOT_BIN=/path/to/godot bash scripts/run_all_tests.sh
# GODOT_BIN="/d/Apps/godot/Godot_v4.7.2-stable_win64_console.exe" bash scripts/run_all_tests.sh
#
# GODOT_BIN имеет приоритет. Если он не задан и Linux-путь по умолчанию
# отсутствует, скрипт пробует `godot4`/`godot` из PATH.
#
# Каждый тест — отдельный процесс Godot с таймаутом 180 с. XDG_DATA_HOME
# выносится во временный каталог. В конце — проверка парсинга проекта.
# Отсутствующие файлы тестов пропускаются (SKIP), а не считаются FAIL:
# волны разработки иногда удаляют модули.
# выносится во временный каталог, чтобы тесты не трогали пользовательские
# данные и не мешали параллельным прогонам. В конце — проверка парсинга
# проекта (`--quit`).
#
# Известный безвредный шум Godot при выходе (ObjectDB instances leaked /
# leaked at exit) не считается провалом: тест, завершившийся с таким
# предупреждением и без реальных ошибок, помечается PASS.
set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -21,17 +21,8 @@ cd "$ROOT_DIR"
GODOT_BIN="${GODOT_BIN:-/home/eq/techlb/tools/godot/Godot_v4.7.2-stable_linux.x86_64}"
TEST_TIMEOUT="${TEST_TIMEOUT:-180}"
if [[ ! -x "$GODOT_BIN" ]]; then
for candidate in godot4 godot; do
if command -v "$candidate" >/dev/null 2>&1; then
GODOT_BIN="$(command -v "$candidate")"
break
fi
done
fi
if [[ ! -x "$GODOT_BIN" ]]; then
echo "FAIL: Godot binary is not executable: $GODOT_BIN" >&2
echo "Set GODOT_BIN=... explicitly." >&2
exit 1
fi
@@ -42,22 +33,18 @@ mkdir -p "$XDG_DATA_HOME" "$LOG_DIR"
export XDG_DATA_HOME
cleanup() {
# Осторожная уборка без rm -rf: только внутри созданного mktemp-каталога.
find "$TMP_BASE" -mindepth 1 -delete 2>/dev/null || true
rmdir "$TMP_BASE" 2>/dev/null || true
}
trap cleanup EXIT
# Aggregator + all standalone v2 suites. New wave-D1 suites are included.
TESTS=(
"res://tests/run_tests.gd"
"res://tests/test_hour.gd"
"res://tests/test_slots.gd"
"res://tests/test_shop.gd"
"res://tests/test_minigame.gd"
"res://tests/test_send_games.gd"
"res://tests/test_attack_games.gd"
"res://tests/test_file_grid.gd"
"res://tests/test_shop_modal.gd"
"res://tests/test_audio.gd"
"res://tests/test_ambient.gd"
"res://tests/test_fuzz.gd"
@@ -76,17 +63,7 @@ TESTS=(
"res://scripts/ui/ui_smoke.gd"
)
EXISTING_TESTS=()
SKIPPED_NAMES=()
for t in "${TESTS[@]}"; do
local_path="${ROOT_DIR}/${t#res://}"
if [[ -f "$local_path" ]]; then
EXISTING_TESTS+=("$t")
else
SKIPPED_NAMES+=("$(basename "$t" .gd)")
fi
done
# Какие строки означают настоящую ошибку (а не безвредный leak-шум).
ERROR_RE='SCRIPT ERROR|Parse Error|USER ERROR|Failed to load|Cannot open file|FAIL|SMOKE FAILED'
LEAK_RE='ObjectDB instances leaked|leaked at exit'
@@ -125,6 +102,7 @@ run_one() {
fi
if [[ "$rc" -eq 0 ]]; then
# Godot может вернуть 0, но напечатать SCRIPT ERROR/Parse Error в лог.
if grep -qE "$ERROR_RE" "$log"; then
echo "FAIL $name (exit 0, но в логе есть ошибки)"
print_log_tail "$log"
@@ -137,6 +115,7 @@ run_one() {
return 0
fi
# Ненулевой код: отличаем реальную ошибку от известного leak-шума.
if grep -qE "$ERROR_RE" "$log"; then
echo "FAIL $name (exit $rc, ${elapsed}s)"
print_log_tail "$log"
@@ -157,14 +136,11 @@ run_one() {
FAILED_NAMES+=("$name")
}
echo "PEAK HOUR 97 — headless tests (v2)"
echo "PEAK HOUR 97 — headless tests"
echo "Godot: $GODOT_BIN"
echo "XDG_DATA_HOME: $XDG_DATA_HOME"
if [[ ${#SKIPPED_NAMES[@]} -gt 0 ]]; then
echo "SKIP (нет файлов): ${SKIPPED_NAMES[*]}"
fi
for t in "${EXISTING_TESTS[@]}"; do
for t in "${TESTS[@]}"; do
run_one "$t"
done
@@ -189,10 +165,11 @@ fi
echo ""
echo "================================"
echo "Итог: PASS=$PASSED FAIL=$FAILED SKIP=${#SKIPPED_NAMES[@]}"
echo "Итог: PASS=$PASSED FAIL=$FAILED"
if [[ "$FAILED" -ne 0 ]]; then
echo "Провалены: ${FAILED_NAMES[*]}"
echo "Логи: $LOG_DIR"
# Сохраняем логи провалов рядом с прогоном для разбора в CI.
if [[ -n "${CI:-}" ]]; then
mkdir -p "$ROOT_DIR/build/test-logs"
for n in "${FAILED_NAMES[@]}"; do
File diff suppressed because it is too large. Load diff
+2 -4
View File
@@ -8,9 +8,7 @@ extends RefCounted
const SAVE_PATH := "user://save.json"
const TMP_PATH := "user://save.tmp"
## v2: клеточные сетки/фигуры. v1 читается для миграции (Game/FileRegistry).
const VERSION := 2
const SUPPORTED_VERSIONS: Array[int] = [1, 2]
const VERSION := 1
static func save(data: Dictionary) -> bool:
@@ -72,7 +70,7 @@ static func _read_save() -> Dictionary:
return {}
var data: Dictionary = parsed
var version := int(data.get("version", -1))
if not SUPPORTED_VERSIONS.has(version):
if version != VERSION:
push_warning("SaveManager: версия save %d не поддерживается (ожидается %d)" % [version, VERSION])
return {}
return data
+131 -467
View File
@@ -1,103 +1,119 @@
class_name Shop
extends RefCounted
## Магазин v2: 8 семейств лотов, ровно 3 предложения, реролл 40/80/120
## (максимум 3 за подготовку) и замок отдельного предложения.
## Магазин: 10 позиций из data/balance.json → shop (D13 + SLA +24, п.13).
##
## Уровни покупок хранятся в Game.upgrades под id лота и под агрегированным
## ключом эффекта (slot_level, speed_level, ...). Эффекты применяет Game
## (set_lot_level + динамические геттеры), магазин только списывает деньги и
## повышает уровень.
## Уровни покупок хранятся в Game.upgrades. Для совместимости с ядром
## дублируются агрегированные ключи эффектов: slot_level, buffer_level,
## timer_level, zone_level, channel_level, sections_unlocked,
## search_unlocked, shield_unlocked.
signal offers_changed
signal purchased(id: String)
const EFFECT_TARGETS := {
"connection_slots": "slot_level",
"transfer_speed": "speed_level",
"sla_cap": "sla_level",
"shield_charge": "shield_level",
"money_mult": "money_level",
"folder_cells": "folder_level",
"buffer_slots": "buffer_level",
"packet_timer": "timer_level",
"zone_width": "zone_level",
"channel_capacity": "channel_level",
"request_timer": "timer_level",
"sections": "sections_unlocked",
"search_highlight": "search_unlocked",
"shield": "shield_unlocked",
"sla_add": "sla_level",
}
const DEFAULT_RULES := {
"offers": 3,
"reroll_costs": [40, 80, 120],
"max_rerolls": 3,
"last_offer_weight": 0.5,
"pity_hours": 2,
"pity_weight": 1.5,
}
## Резерв, если data/balance.json недоступен. Совпадает с §3.2/3.3.
const FALLBACK_ITEMS := [
{"id": "lot_slots", "name_ru": "Порты коммутатора", "effect": "connection_slots", "value": 1,
"max_level": 4, "prices": [120, 300, 650, 1200],
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8]},
{"id": "lot_speed", "name_ru": "Оптоволокно", "effect": "transfer_speed", "value": 0.08,
"max_level": 6, "prices": [90, 130, 180, 250, 340, 450],
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8]},
{"id": "lot_sla", "name_ru": "Резервный ИБП", "effect": "sla_cap", "value": 35,
"max_level": 4, "prices": [120, 260, 480, 800],
"weight_by_hour": [0.8, 0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]},
{"id": "lot_shield", "name_ru": "Заземление", "effect": "shield_charge", "value": 1,
"max_level": 3, "prices": [150, 400, 800],
"weight_by_hour": [0.3, 0.3, 0.3, 0.8, 0.8, 0.8, 1.0, 1.0, 1.0]},
{"id": "lot_money", "name_ru": "Корпоративный контракт", "effect": "money_mult", "value": 0.05,
"max_level": 8, "prices": [80, 110, 150, 200, 260, 340, 440, 560],
"weight_by_hour": [0.6, 0.6, 0.6, 1.0, 1.0, 1.0, 1.2, 1.2, 1.2]},
{"id": "lot_folder", "name_ru": "Складской стеллаж", "effect": "folder_cells", "value": 1,
"max_level": 5, "prices": [80, 180, 360, 650, 1000],
"weight_by_hour": [1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.8, 0.8, 0.8]},
{"id": "lot_channel", "name_ru": "Пропускная способность", "effect": "channel_capacity", "value": 6,
"buffer_value": 1, "max_level": 6, "prices": [70, 100, 140, 190, 250, 330],
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.9, 0.9, 0.9]},
{"id": "lot_timer", "name_ru": "Регламент обработки", "effect": "request_timer", "value": 5,
"max_level": 5, "prices": [70, 110, 160, 230, 320],
"weight_by_hour": [1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.7, 0.7, 0.7]},
{"id": "shop_slot3", "name_ru": "Третий слот", "effect": "connection_slots", "value": 1, "price": 250, "max_level": 1},
{"id": "shop_slot4", "name_ru": "Четвёртый слот", "effect": "connection_slots", "value": 1, "price": 450, "max_level": 1},
{"id": "shop_buffer", "name_ru": "Буфер +2", "effect": "buffer_slots", "value": 2, "price": 200, "max_level": 2},
{"id": "shop_timer", "name_ru": "Таймер пакетов +4 с", "effect": "packet_timer", "value": 4, "price": 350, "max_level": 3},
{"id": "shop_zone", "name_ru": "Зона +5%", "effect": "zone_width", "value": 0.05, "price": 300, "max_level": 3},
{"id": "shop_channel", "name_ru": "Канал +25", "effect": "channel_capacity", "value": 25, "price": 250, "max_level": 4},
{"id": "shop_section", "name_ru": "Новая секция", "effect": "sections", "value": 1, "price": 300, "max_level": 5},
{"id": "shop_search", "name_ru": "Поиск", "effect": "search_highlight", "value": 1, "price": 250, "max_level": 1},
{"id": "shop_shield", "name_ru": "Щит", "effect": "shield", "value": 1, "price": 400, "max_level": 1},
{"id": "shop_sla", "name_ru": "SLA +24", "effect": "sla_add", "value": 24, "price": 300, "max_level": 3},
]
var _items: Array = []
func _init() -> void:
_load_items()
refresh()
# --- каталог ---------------------------------------------------------------
func refresh() -> void:
_items = []
var data = _data_node()
var raw = null
if data != null and data.get("balance") is Dictionary:
raw = data.balance.get("shop", null)
if raw is Array and not raw.is_empty():
for item in raw:
if item is Dictionary and not str(item.get("id", "")).is_empty():
_items.append(item.duplicate(true))
if _items.is_empty():
for item in FALLBACK_ITEMS:
_items.append(item.duplicate(true))
func items() -> Array:
return _items.duplicate(true)
## Каталог без закрытых прогрессией позиций. shop_sla доступен всегда.
func available_items() -> Array:
var out: Array = []
for item in _items:
if item is Dictionary and item_unlocked(item):
out.append(item.duplicate(true))
return out
## Открыта ли позиция: shop_sla всегда; shop_search/shield — по мета-награде;
## остальные — по открытию магазина (progression.unlocks.shop).
func item_unlocked(item: Dictionary) -> bool:
var key := _unlock_key(str(item.get("id", "")))
if key.is_empty():
return true
var game = _game()
if game != null and game.has_method("feature_unlocked"):
return bool(game.feature_unlocked(key))
return true
func item_count() -> int:
return _items.size()
func get_item(id: String) -> Dictionary:
for item in _items:
if item is Dictionary and str(item.get("id", "")) == str(id):
if str(item.get("id", "")) == str(id):
return item.duplicate(true)
return {}
## Позиции каталога без гейта прогрессии (v2: все лоты доступны с H2,
## видимость магазина решает фаза/час).
func available_items() -> Array:
return _items.duplicate(true)
func item_unlocked(_item: Dictionary) -> bool:
return true
func level(id: String) -> int:
var game = _game()
if game != null and game.has_method("level"):
return int(game.level(id))
return level_for(get_item(id), _levels(game))
if game == null:
return 0
var upgrades: Dictionary = game.upgrades if game.upgrades is Dictionary else {}
if upgrades.has(str(id)):
return int(upgrades[str(id)])
var item := get_item(id)
if item.is_empty():
return 0
var effect := str(item.get("effect", ""))
var target := str(EFFECT_TARGETS.get(effect, ""))
var aggregate := 0
if not target.is_empty():
aggregate = int(upgrades.get(target, 0))
# Для двух слотовых товаров агрегат slot_level раскладывается по порядку
# с учётом уже купленного второго товара.
if effect == "connection_slots":
var other_id := "shop_slot4" if id == "shop_slot3" else "shop_slot3"
var other_level := int(upgrades.get(other_id, 0))
return clampi(aggregate - other_level, 0, 1)
return clampi(aggregate, 0, max_level(id))
func max_level(id: String) -> int:
@@ -107,54 +123,64 @@ func max_level(id: String) -> int:
return maxi(1, int(item.get("max_level", 1)))
## Цена следующего уровня. -1, если лот максимален (никогда не индексируем
## prices[max_level]).
func price(id: String) -> int:
var item := get_item(id)
if item.is_empty():
return 0
return price_for(item, level(id))
var current := level(id)
var prices = item.get("prices", null)
if prices is Array and not (prices as Array).is_empty():
var arr: Array = prices
return maxi(0, int(arr[mini(current, arr.size() - 1)]))
if item.has("price"):
return maxi(0, int(item.get("price", 0)))
return maxi(0, int(item.get("cost", 0)))
func can_buy(id: String) -> bool:
var game = _game()
if game == null:
return false
if not offers().has(str(id)):
return false
var item := get_item(id)
if item.is_empty():
return false
if not item_unlocked(item):
return false
if level(id) >= max_level(id):
return false
var cost := price(id)
if cost < 0:
var game = _game()
if game == null:
return false
return int(game.money) >= cost
return int(game.money) >= price(id)
func buy(id: String) -> bool:
if not can_buy(id):
return false
var item := get_item(id)
var game = _game()
if game == null:
return false
var cost := price(id)
if cost < 0:
return false
game.money = maxi(0, int(game.money) - cost)
if game.has_signal("money_changed"):
game.money_changed.emit(game.money)
var new_level := level(id) + 1
if game.has_method("set_lot_level"):
game.set_lot_level(id, new_level)
else:
_apply_level_fallback(game, id, new_level)
if not (game.upgrades is Dictionary):
game.upgrades = {}
var current := level(id)
game.upgrades[id] = current + 1
var effect := str(item.get("effect", ""))
var target := str(EFFECT_TARGETS.get(effect, ""))
if not target.is_empty():
game.upgrades[target] = int(game.upgrades.get(target, 0)) + 1
if effect == "sections":
_unlock_section(game)
elif effect == "sla_add":
# SLA +24 за уровень; Game.add_sla сам клампит до MAX_SLA.
var value := float(item.get("value", 24))
if game.has_method("add_sla"):
game.add_sla(value, "shop")
_play_sfx(&"money_down")
purchased.emit(id)
offers_changed.emit()
if game.has_signal("toast"):
game.toast.emit("Куплено: %s" % str(get_item(id).get("name_ru", id)))
game.toast.emit("Куплено: %s" % str(item.get("name_ru", id)))
return true
@@ -163,327 +189,37 @@ func effect_level(effect: String) -> int:
var game = _game()
if game == null:
return 0
var target := str(EFFECT_TARGETS.get(str(effect), ""))
var target := str(EFFECT_TARGETS.get(effect, ""))
if target.is_empty():
return 0
return int(_levels(game).get(target, 0))
# --- v2: предложения/реролл -----------------------------------------------
func offers() -> Array[String]:
var out: Array[String] = []
var game = _game()
if game == null:
return out
for id in game.shop_offers:
out.append(str(id))
return out
## Сгенерировать 3 предложения на подготовку. Идемпотентно: повторный вызов
## для того же часа ничего не меняет, пока force = true.
func refresh(hour: int = 0, money: int = -1, force: bool = false) -> void:
_load_items()
if hour <= 0:
return
var game = _game()
if game == null:
return
var h := maxi(1, int(hour))
if not force and int(game.shop_offer_hour) == h and not game.shop_offers.is_empty():
offers_changed.emit()
return
var m := int(money) if money >= 0 else int(game.money)
var previous: Array = []
for id in game.shop_offers:
previous.append(str(id))
game.shop_offer_hour = h
game.shop_rerolls = 0
game.shop_locked.clear()
var selected := select_offers(h, _items, _levels(game), m, previous,
_seen(game), [], _offer_count())
_write_offers(game, selected, h)
offers_changed.emit()
func reroll_cost() -> int:
var game = _game()
if game == null:
return -1
var max_rerolls := int(_rules().get("max_rerolls", 3))
if int(game.shop_rerolls) >= max_rerolls:
return -1
if _non_maxed_count(_levels(game)) <= 0:
return -1
return _cost_for(int(game.shop_rerolls))
func can_reroll() -> bool:
var game = _game()
if game == null:
return false
if game.shop_offers.is_empty():
return false
var cost := reroll_cost()
if cost < 0:
return false
return int(game.money) >= cost
## Реролл сохраняет закреплённые предложения. Стоимость 40/80/120.
func reroll() -> bool:
if not can_reroll():
return false
var game = _game()
if game == null:
return false
var cost := reroll_cost()
if cost < 0:
return false
game.money = maxi(0, int(game.money) - cost)
if game.has_signal("money_changed"):
game.money_changed.emit(game.money)
game.shop_rerolls = int(game.shop_rerolls) + 1
var h := maxi(1, int(game.hour))
var previous: Array = []
for id in game.shop_offers:
previous.append(str(id))
var locked: Array = []
for id in game.shop_locked:
locked.append(str(id))
var selected := select_offers(h, _items, _levels(game), int(game.money), previous,
_seen(game), locked, _offer_count())
game.shop_offer_hour = h
_write_offers(game, selected, h)
offers_changed.emit()
return true
## Переключить замок предложения. Возвращает новое состояние (true = locked).
func toggle_lock(index: int) -> bool:
var game = _game()
if game == null:
return false
var ofs := offers()
if index < 0 or index >= ofs.size():
return false
var id := ofs[index]
if game.shop_locked.has(id):
game.shop_locked.erase(id)
else:
game.shop_locked.append(id)
offers_changed.emit()
return game.shop_locked.has(id)
func is_locked(index: int) -> bool:
var game = _game()
if game == null:
return false
var ofs := offers()
if index < 0 or index >= ofs.size():
return false
return game.shop_locked.has(ofs[index])
func locked_ids() -> Array[String]:
var out: Array[String] = []
var game = _game()
if game == null:
return out
for id in game.shop_locked:
out.append(str(id))
return out
# --- v2: генерация (статические, тестируемые) ------------------------------
## Вес лота: база по часу × 0.5 за прошлое предложение × 1.5 за pity.
static func lot_weight(lot: Dictionary, hour: int, last_offers: Array, last_seen: Dictionary) -> float:
var id := str(lot.get("id", ""))
var w := 1.0
var arr = lot.get("weight_by_hour", null)
if arr is Array and not (arr as Array).is_empty():
var table: Array = arr
w = float(table[clampi(int(hour) - 1, 0, table.size() - 1)])
var rules: Dictionary = DEFAULT_RULES
if last_offers.has(id):
w *= float(rules.get("last_offer_weight", 0.5))
if last_seen is Dictionary and last_seen.has(id):
var since := int(hour) - int(last_seen[id])
if since >= int(rules.get("pity_hours", 2)):
w *= float(rules.get("pity_weight", 1.5))
return maxf(0.0, w)
## Цена следующего уровня лота. -1 при максимальном уровне.
static func price_for(lot: Dictionary, lvl: int) -> int:
if lot.is_empty():
return 0
var max_level := maxi(1, int(lot.get("max_level", 1)))
if lvl >= max_level:
return -1
var prices = lot.get("prices", null)
if prices is Array and not (prices as Array).is_empty():
var arr: Array = prices
return maxi(0, int(arr[clampi(lvl, 0, arr.size() - 1)]))
if lot.has("price"):
return maxi(0, int(lot.get("price", 0)))
return maxi(0, int(lot.get("cost", 0)))
## Уровень лота из словаря upgrades: id лота → агрегат эффекта → 0.
static func level_for(lot: Dictionary, levels: Dictionary) -> int:
if lot.is_empty():
return 0
var id := str(lot.get("id", ""))
var max_level := maxi(1, int(lot.get("max_level", 1)))
if levels.has(id):
return clampi(int(levels[id]), 0, max_level)
var effect := str(lot.get("effect", ""))
var agg := str(EFFECT_TARGETS.get(effect, ""))
if not agg.is_empty() and levels.has(agg):
return clampi(int(levels[agg]), 0, max_level)
return 0
## Выбрать до count различных не-максимальных лотов.
## must_keep — закреплённые id (сохраняются в выдаче).
static func select_offers(
hour: int,
lots: Array,
levels: Dictionary,
money: int,
last_offers: Array,
last_seen: Dictionary,
must_keep: Array = [],
count: int = 3,
rand_source = null
) -> Array[String]:
var h := maxi(1, int(hour))
var lot_map: Dictionary = {}
for lot in lots:
if lot is Dictionary:
var id := str(lot.get("id", ""))
if not id.is_empty():
lot_map[id] = lot
var result: Array[String] = []
for raw_id in must_keep:
var kept := str(raw_id)
if result.has(kept) or not lot_map.has(kept):
continue
var kept_lot: Dictionary = lot_map[kept]
if level_for(kept_lot, levels) >= maxi(1, int(kept_lot.get("max_level", 1))):
continue
result.append(kept)
var pool: Array = []
for lot in lots:
if not (lot is Dictionary):
continue
var id := str(lot.get("id", ""))
if id.is_empty() or result.has(id):
continue
if level_for(lot, levels) >= maxi(1, int(lot.get("max_level", 1))):
continue
pool.append(lot)
var target := mini(maxi(0, int(count)), result.size() + pool.size())
while result.size() < target and not pool.is_empty():
var picked_index := _pick_weighted(pool, h, last_offers, last_seen, rand_source)
var picked: Dictionary = pool[picked_index]
result.append(str(picked.get("id", "")))
pool.remove_at(picked_index)
# Хотя бы одно доступное по деньгам предложение (если такое существует).
if money >= 0 and not _any_affordable(result, lot_map, levels, money):
_try_swap_affordable(result, lots, lot_map, levels, money, must_keep)
return result
return int(game.upgrades.get(target, 0))
# --- служебное -------------------------------------------------------------
func _load_items() -> void:
_items = []
## Ключ открытия позиции. Данные — из Data, иначе локальная копия правила.
func _unlock_key(id: String) -> String:
var data = _data_node()
if data != null and data.has_method("lot_defs"):
var raw = data.lot_defs()
if raw is Array and not (raw as Array).is_empty():
for item in raw:
if item is Dictionary and not str(item.get("id", "")).is_empty():
_items.append((item as Dictionary).duplicate(true))
if _items.is_empty():
for item in FALLBACK_ITEMS:
_items.append((item as Dictionary).duplicate(true))
if data != null and data.has_method("shop_unlock_key"):
return str(data.shop_unlock_key(id))
match id:
"shop_sla":
return ""
"shop_search":
return "search"
"shop_shield":
return "shield"
_:
return "shop"
func _write_offers(game, ids: Array, hour: int) -> void:
game.shop_offers.clear()
for id in ids:
game.shop_offers.append(str(id))
if not (game.shop_last_seen is Dictionary):
game.shop_last_seen = {}
for id in game.shop_offers:
game.shop_last_seen[str(id)] = int(hour)
func _apply_level_fallback(game, id: String, new_level: int) -> void:
if not (game.upgrades is Dictionary):
game.upgrades = {}
game.upgrades[id] = new_level
var effect := str(get_item(id).get("effect", ""))
var target := str(EFFECT_TARGETS.get(effect, ""))
if not target.is_empty():
game.upgrades[target] = new_level
func _offer_count() -> int:
return maxi(1, int(_rules().get("offers", 3)))
func _cost_for(reroll_index: int) -> int:
var costs = _rules().get("reroll_costs", [40, 80, 120])
if costs is Array and not (costs as Array).is_empty():
var arr: Array = costs
return maxi(0, int(arr[clampi(int(reroll_index), 0, arr.size() - 1)]))
return 40
func _non_maxed_count(levels: Dictionary) -> int:
var count := 0
for lot in _items:
if not (lot is Dictionary):
continue
if level_for(lot, levels) < maxi(1, int(lot.get("max_level", 1))):
count += 1
return count
func _rules() -> Dictionary:
var data = _data_node()
var out: Dictionary = DEFAULT_RULES.duplicate(true)
if data != null and data.has_method("shop_rules"):
var raw = data.shop_rules()
if raw is Dictionary and not (raw as Dictionary).is_empty():
for key in raw.keys():
out[str(key)] = raw[key]
return out
func _seen(game) -> Dictionary:
if game == null or not (game.shop_last_seen is Dictionary):
return {}
return game.shop_last_seen
func _levels(game) -> Dictionary:
if game == null:
return {}
if game.upgrades is Dictionary:
return game.upgrades
return {}
func _unlock_section(game) -> void:
if game.registry == null or not game.registry.has_method("create_section"):
return
for letter in ["D", "E", "F", "G", "H"]:
if not game.registry.sections.has(letter):
game.registry.create_section(letter)
return
func _game():
@@ -507,75 +243,3 @@ func _play_sfx(id: StringName) -> void:
var audio = (loop as SceneTree).root.get_node_or_null("Audio")
if audio != null and audio.has_method("play_sfx"):
audio.play_sfx(str(id))
# --- статические хелперы выбора -------------------------------------------
static func _pick_weighted(pool: Array, hour: int, last_offers: Array, last_seen: Dictionary, rand_source) -> int:
var weights: Array = []
var total := 0.0
for lot in pool:
var w := lot_weight(lot, hour, last_offers, last_seen)
weights.append(w)
total += w
if total <= 0.0:
return int(float(_randf(rand_source)) * float(pool.size())) % pool.size()
var roll := _randf(rand_source) * total
var acc := 0.0
for i in range(weights.size()):
acc += float(weights[i])
if roll < acc:
return i
return pool.size() - 1
static func _any_affordable(ids: Array, lot_map: Dictionary, levels: Dictionary, money: int) -> bool:
for raw_id in ids:
var id := str(raw_id)
if not lot_map.has(id):
continue
var p := price_for(lot_map[id], level_for(lot_map[id], levels))
if p >= 0 and p <= money:
return true
return false
static func _try_swap_affordable(ids: Array, lots: Array, lot_map: Dictionary, levels: Dictionary, money: int, must_keep: Array) -> void:
var candidate := ""
var best := 2147483647
for lot in lots:
if not (lot is Dictionary):
continue
var id := str(lot.get("id", ""))
if id.is_empty() or ids.has(id):
continue
var lvl := level_for(lot, levels)
if lvl >= maxi(1, int(lot.get("max_level", 1))):
continue
var p := price_for(lot, lvl)
if p >= 0 and p <= money and p < best:
best = p
candidate = id
if candidate.is_empty():
return
# Заменяем самое дорогое незакреплённое предложение.
var swap_index := -1
var worst := -1
for i in range(ids.size()):
var id := str(ids[i])
if must_keep.has(id):
continue
if not lot_map.has(id):
continue
var p := price_for(lot_map[id], level_for(lot_map[id], levels))
if p > worst:
worst = p
swap_index = i
if swap_index >= 0:
ids[swap_index] = candidate
static func _randf(rand_source) -> float:
if rand_source != null and rand_source.has_method("randf"):
return clampf(float(rand_source.randf()), 0.0, 0.999999)
return clampf(randf(), 0.0, 0.999999)
-134
View File
@@ -1,134 +0,0 @@
class_name DragController
extends Node
## Центральный контроллер перетаскивания: держит одно превью (FileShape) и
## его режим (shape | square), двигает превью за мышью и отсчитывает 250 мс
## наведения на папку БЕЗ Timer-узлов.
##
## Владелец — Main. Компоненты находят его через группу "drag_controller"
## или через свойство drag_controller.
##
## ВАЖНО: при отсутствии превью контроллер не должен падать — все вызовы
## проверяют is_instance_valid.
signal hover_open(folder_id: String)
signal drag_ended
const HOVER_DELAY := 0.25
const PREVIEW_OFFSET := Vector2(9, 9)
var _preview: Control = null
var _mode: String = "shape"
var _hover_id: String = ""
var _hover_left: float = 0.0
var _hover_fired: bool = false
func _ready() -> void:
if not is_in_group("drag_controller"):
add_to_group("drag_controller")
set_process(false)
func begin(preview_control: Control, p_mode: String = "shape") -> void:
end()
_preview = preview_control
_mode = _normalize(p_mode)
if is_instance_valid(_preview):
_preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
_preview.top_level = true
_preview.z_index = 200
if _preview.get_parent() == null:
add_child(_preview)
if _preview.has_method("set_mode"):
_preview.set_mode(_mode)
else:
_preview.set("mode", _mode)
_preview.visible = true
_preview.queue_redraw()
_place()
set_process(true)
func set_mode(p_mode: String) -> void:
var next := _normalize(p_mode)
_mode = next
if is_instance_valid(_preview):
if _preview.has_method("set_mode"):
_preview.set_mode(next)
else:
_preview.set("mode", next)
_preview.queue_redraw()
func end() -> void:
var had := is_instance_valid(_preview) or not _hover_id.is_empty()
if is_instance_valid(_preview):
_preview.visible = false
var parent := _preview.get_parent()
if parent != null:
parent.remove_child(_preview)
_preview.queue_free()
_preview = null
reset_hover()
if had:
drag_ended.emit()
set_process(false)
func preview() -> Control:
return _preview if is_instance_valid(_preview) else null
func mode() -> String:
return _mode
func hover_folder(folder_id: String) -> void:
if folder_id.is_empty():
reset_hover()
return
if folder_id == _hover_id:
# повторный вход в ту же папку не сбрасывает и не дублирует таймер
return
_hover_id = folder_id
_hover_left = HOVER_DELAY
_hover_fired = false
set_process(true)
func reset_hover() -> void:
_hover_id = ""
_hover_left = 0.0
_hover_fired = false
if not is_instance_valid(_preview):
set_process(false)
func hover_id() -> String:
return _hover_id
func _process(delta: float) -> void:
if not _hover_id.is_empty() and not _hover_fired:
_hover_left -= delta
if _hover_left <= 0.0:
_hover_fired = true
hover_open.emit(_hover_id)
if is_instance_valid(_preview) and _preview.visible:
_place()
if not is_instance_valid(_preview) and (_hover_id.is_empty() or _hover_fired):
set_process(false)
func _place() -> void:
if not is_instance_valid(_preview):
return
var vp := get_viewport()
if vp == null:
return
_preview.position = vp.get_mouse_position() - _preview.custom_minimum_size * 0.5 \
+ PREVIEW_OFFSET
func _normalize(value: String) -> String:
return "square" if value == "square" else "shape"
-1
View File
@@ -1 +0,0 @@
uid://cybnog1pa4ni4
+238 -850
View File
File diff suppressed because it is too large. Load diff
-390
View File
@@ -1,390 +0,0 @@
class_name FileShape
extends Control
## Одна фигура-полимино: превью drag, карточка пакета, стол сортировки.
##
## Рисуется без дочерних узлов: заливка занятых клеток, ОДИН внешний контур
## 2 px (внутренние границы не рисуются), подпись в верхней левой ЗАНЯТОЙ
## клетке. Тот же painter (`paint`) использует FileGrid — так превью и сетка
## выглядят одинаково.
##
## Режимы:
## shape — полимино по shape_id/rotation (для сетки и стола);
## square — одна клетка 1×1 (для карточки запроса: файл = квадрат).
##
## ВАЖНО: global class cache при `--script` может быть пустым, поэтому
## FileRegistry подключаем через preload.
const FR = preload("res://scripts/core/file_registry.gd")
const GAP := 0.0
var file_type: String = "text"
var shape_id: String = "O1"
var shape_rotation: int = 0
var file_name: String = ""
var ext: String = ""
var cell_size: float = 48.0
var mode: String = "shape"
var state: String = "stored"
var selected: bool = false
var hovered: bool = false
var show_label: bool = true
var alpha: float = 1.0
## Непрозрачная замена контура/заливки (ghost, ошибки). Alpha 0 — обычные.
var outline_override: Color = Color(0, 0, 0, 0)
var fill_override: Color = Color(0, 0, 0, 0)
func _init() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
_sync_size()
func setup(p_type: String, p_shape_id: String, p_rotation: int, p_name: String,
p_ext: String, p_cell: float) -> void:
file_type = p_type
shape_id = p_shape_id if not p_shape_id.is_empty() else "O1"
shape_rotation = posmod(int(p_rotation), 4)
file_name = p_name
ext = p_ext
cell_size = maxf(8.0, p_cell)
_sync_size()
func set_mode(value: String) -> void:
var next := "square" if value == "square" else "shape"
if mode == next:
return
mode = next
_sync_size()
queue_redraw()
func get_mode() -> String:
return mode
func set_state(value: String) -> void:
state = value
queue_redraw()
func set_selected(value: bool) -> void:
selected = value
queue_redraw()
func resolved_cells() -> Array:
if mode == "square":
return [Vector2i.ZERO]
return FR.shape_cells(shape_id, shape_rotation)
func _sync_size() -> void:
var cells := resolved_cells()
var b := FR.shape_bounds(cells)
var w := maxf(1.0, float(b.x) * cell_size)
var h := maxf(1.0, float(b.y) * cell_size)
custom_minimum_size = Vector2(w, h)
size = custom_minimum_size
func _draw() -> void:
var cells := resolved_cells()
paint(self, Vector2.ZERO, cells, cell_size, {
"type": file_type,
"name": file_name,
"ext": ext,
"state": state,
"selected": selected,
"hovered": hovered,
"alpha": alpha,
"show_label": show_label,
"gap": GAP,
"square": mode == "square",
"outline": outline_override,
"fill": fill_override,
})
# --- палитра ---------------------------------------------------------------
static func type_color(file_type: String) -> Color:
match file_type:
"sound":
return Theme90.WARN
"photo":
return Theme90.SUCCESS
"video":
return Theme90.ERROR
_:
return Theme90.ACCENT
static func ext_color(file_type: String, ext_value: String) -> Color:
var cat := Theme90.ext_category(ext_value)
if not cat.is_empty():
return Theme90.ext_color(cat)
return type_color(file_type)
static func _with_alpha(c: Color, a: float) -> Color:
return Color(c.r, c.g, c.b, c.a * a)
# --- геометрия -------------------------------------------------------------
## Рёбра внешнего контура в координатах клеток. Каждое ребро — словарь
## {"from": Vector2i-клетка, "dir": Vector2i-направление наружу}.
## Внутренние границы (сосед занят) не попадают в список.
static func outer_edges(cells: Array) -> Array:
var out: Array = []
var occupied := {}
for c in cells:
occupied[Vector2i(c)] = true
var dirs: Array[Vector2i] = [
Vector2i(1, 0), Vector2i(0, 1), Vector2i(-1, 0), Vector2i(0, -1),
]
for c in cells:
var p := Vector2i(c)
for d in dirs:
if not occupied.has(p + d):
out.append({"from": p, "dir": d})
return out
static func _cell_vec(value) -> Vector2:
if value is Vector2:
return value
var s := float(value)
return Vector2(s, s)
## Прямоугольник клетки в пикселях. gap расширяет заливку на половину зазора,
## чтобы соседние клетки одной фигуры сливались в цельное полимино.
## cell_px — float (квадрат) или Vector2 (прямоугольная клетка стола).
static func cell_pixel_rect(pos: Vector2, cell: Vector2i, cell_px, gap: float) -> Rect2:
var cs := _cell_vec(cell_px)
var step := cs + Vector2(gap, gap)
var p := pos + Vector2(float(cell.x) * step.x, float(cell.y) * step.y)
if gap > 0.0:
var e := gap * 0.5
return Rect2(p - Vector2(e, e), cs + Vector2(gap, gap))
return Rect2(p, cs)
## Отрезок внешнего ребра в пикселях.
static func edge_segment(pos: Vector2, from_cell: Vector2i, dir: Vector2i,
cell_px, gap: float) -> PackedVector2Array:
var cs := _cell_vec(cell_px)
var step := cs + Vector2(gap, gap)
var p := pos + Vector2(float(from_cell.x) * step.x, float(from_cell.y) * step.y)
var e := gap * 0.5 if gap > 0.0 else 0.0
var l := p.x - e
var t := p.y - e
var r := p.x + cs.x + e
var b := p.y + cs.y + e
if dir == Vector2i(1, 0):
return PackedVector2Array([Vector2(r, t), Vector2(r, b)])
if dir == Vector2i(-1, 0):
return PackedVector2Array([Vector2(l, t), Vector2(l, b)])
if dir == Vector2i(0, 1):
return PackedVector2Array([Vector2(l, b), Vector2(r, b)])
return PackedVector2Array([Vector2(l, t), Vector2(r, t)])
# --- painter ---------------------------------------------------------------
## Рисует фигуру. opts: type, name, ext, state, selected, hovered, alpha,
## show_label, gap, square, outline, fill.
static func paint(ci: CanvasItem, pos: Vector2, cells: Array, cell_px,
opts: Dictionary = {}) -> void:
var cs := _cell_vec(cell_px)
if cells.is_empty() or cs.x <= 0.0 or cs.y <= 0.0:
return
var file_type := str(opts.get("type", "text"))
var name := str(opts.get("name", ""))
var ext_value := str(opts.get("ext", ""))
var state := str(opts.get("state", "stored"))
var selected := bool(opts.get("selected", false))
var hovered := bool(opts.get("hovered", false))
var alpha := clampf(float(opts.get("alpha", 1.0)), 0.0, 1.0)
if alpha <= 0.01:
return
var show_label := bool(opts.get("show_label", true))
var gap := float(opts.get("gap", 0.0))
var square := bool(opts.get("square", false))
var outline_override: Color = opts.get("outline", Color(0, 0, 0, 0))
var fill_override: Color = opts.get("fill", Color(0, 0, 0, 0))
var base := type_color(file_type)
var locked := state == "locked"
var bad := state == "malicious" or state == "failed"
if locked:
base = base.lerp(Theme90.SHADE, 0.55)
var fill := fill_override if fill_override.a > 0.0 else _with_alpha(base, 0.22)
if hovered and fill_override.a <= 0.0:
fill = _with_alpha(base, 0.30)
fill.a *= alpha
var outline := outline_override
if outline.a <= 0.0:
if selected:
outline = Theme90.ACCENT
elif bad:
outline = Theme90.ERROR
elif locked:
outline = Theme90.SHADE
else:
outline = base
outline.a *= alpha
var outline_w := 2.6 if selected else 2.0
# 1) заливка занятых клеток (без внутренних швов)
for c in cells:
var cell := Vector2i(c)
var r := cell_pixel_rect(pos, cell, cell_px, gap)
ci.draw_rect(r, fill)
if hovered and fill_override.a <= 0.0:
ci.draw_rect(r, Color(1, 1, 1, 0.09 * alpha))
# 2) один внешний контур
for edge in outer_edges(cells):
var seg := edge_segment(pos, Vector2i(edge["from"]), Vector2i(edge["dir"]),
cell_px, gap)
ci.draw_line(seg[0], seg[1], outline, outline_w, false)
# 3) тонкая красная рамка вокруг bbox для malicious/failed
if bad:
var b := FR.shape_bounds(cells)
var step := cs + Vector2(gap, gap)
var frame := Rect2(pos - Vector2(2, 2),
Vector2(float(b.x) * step.x - gap + 4.0, float(b.y) * step.y - gap + 4.0))
ci.draw_rect(frame, _with_alpha(Theme90.ERROR, 0.55 * alpha), false, 1.0)
if not show_label:
return
var lbl := FR.label_cell(cells)
if square:
_paint_square_label(ci, pos, lbl, cs, gap, base, file_type, name, alpha, locked)
else:
_paint_cell_label(ci, pos, lbl, cs, gap, base, file_type, name, ext_value,
alpha, locked, state)
static func _paint_cell_label(ci: CanvasItem, pos: Vector2, lbl: Vector2i,
cs: Vector2, gap: float, base: Color, file_type: String, name: String,
ext_value: String, alpha: float, locked: bool, state: String) -> void:
var step := cs + Vector2(gap, gap)
var lr := Rect2(pos + Vector2(float(lbl.x) * step.x, float(lbl.y) * step.y), cs)
if lr.size.x < 16.0 or lr.size.y < 16.0:
return
var cell_min := minf(cs.x, cs.y)
var f := Theme90.ui()
var ink := Theme90.SHADE if locked else Theme90.INK
var shade := Theme90.SHADE
var pad := 3.0
var icon := Rect2(lr.position + Vector2(pad, pad), Vector2(8, 8))
draw_type_icon(ci, icon, file_type, base, alpha)
var tx := lr.position.x + 13.0
var tw := maxf(8.0, lr.size.x - 16.0)
var ty := lr.position.y + 2.0
if cell_min >= 72.0:
var lines := Theme90.wrap(f, name, 10, tw, 2)
for i in lines.size():
Theme90.text(ci, f, Vector2(tx, ty + float(i) * 11.0), lines[i], 10,
_with_alpha(ink, alpha), tw)
var ey := ty + float(maxi(1, lines.size())) * 11.0 + 1.0
if not ext_value.is_empty():
Theme90.text(ci, f, Vector2(lr.position.x + pad, ey),
Theme90.fit(f, ext_value, 10, lr.size.x - pad * 2.0), 10,
_with_alpha(shade, alpha), lr.size.x - pad * 2.0)
else:
var line := Theme90.fit(f, name, 10, tw)
Theme90.text(ci, f, Vector2(tx, ty), line, 10, _with_alpha(ink, alpha), tw)
if not ext_value.is_empty():
Theme90.text(ci, f, Vector2(lr.position.x + pad, ty + 12.0),
Theme90.fit(f, ext_value, 10, lr.size.x - pad * 2.0), 10,
_with_alpha(shade, alpha), lr.size.x - pad * 2.0)
# состояния в верхнем правом углу label-клетки
var badge := Vector2(lr.end.x - 8.0, lr.position.y + 8.0)
if state == "attached":
Theme90.draw_check_badge(ci, badge, 5.5, _with_alpha(Theme90.SUCCESS, alpha),
_with_alpha(Theme90.WHITE, alpha))
elif state == "malicious" or state == "failed":
ci.draw_circle(badge, 5.5, _with_alpha(Theme90.ERROR, alpha))
Theme90.text_vcenter(ci, f, Rect2(badge.x - 5.0, badge.y - 6.0, 10.0, 12.0),
"!", 10, _with_alpha(Theme90.WHITE, alpha), 10.0, HORIZONTAL_ALIGNMENT_CENTER)
elif locked:
Theme90.draw_lock_icon(ci, Vector2(lr.end.x - 12.0, lr.end.y - 11.0),
_with_alpha(Theme90.SHADE, alpha))
static func _paint_square_label(ci: CanvasItem, pos: Vector2, lbl: Vector2i,
cs: Vector2, gap: float, base: Color, file_type: String, name: String,
alpha: float, locked: bool) -> void:
var step := cs + Vector2(gap, gap)
var lr := Rect2(pos + Vector2(float(lbl.x) * step.x, float(lbl.y) * step.y), cs)
var f := Theme90.ui()
var cell_min := minf(cs.x, cs.y)
var has_name := cell_min >= 40.0 and not name.is_empty()
var icon_size := clampf(cell_min * 0.34, 9.0, 18.0)
var icon_y := lr.get_center().y - icon_size * 0.5
if has_name:
icon_y -= 6.0
var icon := Rect2(lr.get_center().x - icon_size * 0.5, icon_y,
icon_size, icon_size)
draw_type_icon(ci, icon, file_type, base, alpha)
if has_name:
var col := Theme90.SHADE if locked else Theme90.INK
Theme90.text(ci, f, Vector2(lr.position.x + 3.0, lr.end.y - 14.0),
Theme90.fit(f, name, 10, lr.size.x - 6.0), 10, _with_alpha(col, alpha),
lr.size.x - 6.0)
## 8×8 иконка типа: строки (text), волна (sound), горы (photo), play (video).
static func draw_type_icon(ci: CanvasItem, rect: Rect2, file_type: String,
color: Color, alpha: float = 1.0) -> void:
var s := minf(rect.size.x, rect.size.y)
if s <= 0.0:
return
var r := Rect2(rect.position.round(), Vector2(s, s))
var tint := _with_alpha(color, 0.18 * alpha)
ci.draw_style_box(Theme90.fill_box(tint, 2), r)
var c := _with_alpha(color, alpha)
var x := r.position.x
var y := r.position.y
match file_type:
"sound":
var pts := PackedVector2Array([
Vector2(x + 1.0, y + s * 0.55),
Vector2(x + s * 0.30, y + s * 0.25),
Vector2(x + s * 0.52, y + s * 0.75),
Vector2(x + s * 0.74, y + s * 0.38),
Vector2(x + s - 1.0, y + s * 0.55),
])
ci.draw_polyline(pts, c, 1.0, false)
"photo":
var pts := PackedVector2Array([
Vector2(x + 1.0, y + s - 2.0),
Vector2(x + s * 0.34, y + s * 0.30),
Vector2(x + s * 0.56, y + s * 0.62),
Vector2(x + s * 0.72, y + s * 0.44),
Vector2(x + s - 1.0, y + s - 2.0),
])
ci.draw_polyline(pts, c, 1.0, false)
ci.draw_circle(Vector2(x + s * 0.72, y + s * 0.26), 1.2, c)
"video":
ci.draw_colored_polygon(PackedVector2Array([
Vector2(x + s * 0.30, y + s * 0.18),
Vector2(x + s * 0.30, y + s * 0.82),
Vector2(x + s * 0.82, y + s * 0.50),
]), c)
_:
for i in 3:
var ly := y + s * (0.30 + float(i) * 0.20)
ci.draw_line(Vector2(x + s * 0.22, ly),
Vector2(x + s * (0.78 - float(i) * 0.10), ly), c, 1.0, false)
-1
View File
@@ -1 +0,0 @@
uid://cywkgtu02b8pk
+3 -55
View File
@@ -7,33 +7,19 @@ extends Control
signal folder_selected(folder_id: String)
signal expansion_toggled(folder_id: String)
signal file_dropped(folder_id: String, payload: Dictionary)
signal packet_dropped(folder_id: String, payload: Dictionary)
signal context_menu_requested(folder_id: String, global_pos: Vector2)
## Папка раскрыта/открыта наведением во время drag (250 мс).
signal hover_open_requested(folder_id: String)
const ROW_H := 24.0
const SECTION_H := 22.0
var folder_id := ""
var folder_name := ""
## v2: count — занятые КЛЕТКИ, capacity — всего клеток.
var count := 0
var capacity := 16
## Число файлов (для тултипа «N файлов · X из Y клеток»).
var files_count := 0
var free_text := ""
var depth := 0
var is_section := false
var expandable := true
var hover_open := false:
set(v):
if hover_open == v:
return
hover_open = v
queue_redraw()
if v and not is_section:
hover_open_requested.emit(folder_id)
var expanded := true:
set(v):
expanded = v
@@ -90,10 +76,6 @@ func _draw() -> void:
elif drop_active:
draw_style_box(Theme90.box(Theme90.ACCENT_PALE, Theme90.ACCENT, 3, 1),
Rect2(2, 1, size.x - 4, size.y - 2))
elif hover_open:
draw_style_box(Theme90.box(Theme90.ACCENT_PALE, Theme90.ACCENT_LIGHT, 3, 1),
Rect2(2, 1, size.x - 4, size.y - 2))
draw_rect(Rect2(2, 5, 2, size.y - 10), Theme90.ACCENT_LIGHT)
elif hovered:
draw_rect(Rect2(2, 1, size.x - 4, size.y - 2), Color(1, 1, 1, 0.45))
@@ -141,7 +123,7 @@ func _draw_section(r: Rect2, f: Font) -> void:
Theme90.draw_drive_icon(self, Vector2(sx + 16, 6), Theme90.NAVY)
var fg := Theme90.NAVY if selected else Theme90.INK
Theme90.text_vcenter(self, f, Rect2(sx + 32, 0, size.x - 80, size.y),
Theme90.fit(f, folder_name, 12, size.x - 110), 12, fg)
Theme90.fit(f, folder_name, 11, size.x - 110), 11, fg)
if not free_text.is_empty():
var tw := f.get_string_size(free_text, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x
Theme90.text(self, f, Vector2(size.x - tw - 8, 6), free_text, 10, Theme90.SHADE)
@@ -173,50 +155,16 @@ func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
if k != "file" and k != "packet":
return false
drop_active = true
if not is_section:
_request_hover_open()
return true
func _drop_data(_at_position: Vector2, data: Variant) -> void:
drop_active = false
hover_open = false
_reset_hover_timer()
if is_section or not (data is Dictionary):
if is_section:
return
if str(data.get("kind", "")) == "packet":
packet_dropped.emit(folder_id, data)
else:
file_dropped.emit(folder_id, data)
file_dropped.emit(folder_id, data)
func _notification(what: int) -> void:
if what == NOTIFICATION_DRAG_END:
drop_active = false
hover_open = false
_reset_hover_timer()
elif what == NOTIFICATION_MOUSE_EXIT:
if drop_active or hover_open:
drop_active = false
hover_open = false
_reset_hover_timer()
func set_hover(on: bool) -> void:
hover_open = on
func _request_hover_open() -> void:
if not is_inside_tree():
return
var dc := get_tree().get_first_node_in_group("drag_controller")
if dc != null and dc.has_method("hover_folder"):
dc.call("hover_folder", folder_id)
func _reset_hover_timer() -> void:
if not is_inside_tree():
return
var dc := get_tree().get_first_node_in_group("drag_controller")
if dc != null and dc.has_method("reset_hover"):
dc.call("reset_hover")
+31 -17
View File
@@ -20,9 +20,6 @@ const VHS_OVERLAY := preload("res://scripts/ui/vhs_overlay.gd")
const WIN_RECT := Rect2(120, 80, 1040, 560)
## Стартовая сводка v2: 9 часов, 50 $, SLA 60, 1 слот.
const START_STATS_TEXT := "9 часов · $50 на старте · 60 SLA · 1 слот"
var has_save := false
## Тонкий VHS-слой поверх меню. Игра и модалки не фильтруются.
var vhs = null
@@ -34,6 +31,9 @@ var _btn_about: Button90
static var _card_style: StyleBoxFlat = null
## Накопитель для перерисовки VHS на 12 Гц.
var _vhs_clock := 0.0
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
mouse_filter = Control.MOUSE_FILTER_STOP
@@ -64,6 +64,18 @@ func is_vhs_enabled() -> bool:
return vhs != null and vhs.enabled
## Медленная анимация: полоса и зерно. Перерисовываем меню на 12 Гц —
## чаще для такого эффекта не нужно, а текст остаётся тем же.
func _process(delta: float) -> void:
if vhs == null or not vhs.enabled or not vhs.is_available() or not visible:
return
vhs.advance(delta)
_vhs_clock += delta
if _vhs_clock >= 1.0 / 12.0:
_vhs_clock = 0.0
queue_redraw()
func _build() -> void:
_btn_continue = _make_button("Продолжить")
_btn_continue.disabled = not has_save
@@ -82,19 +94,11 @@ func _build() -> void:
_btn_about.pressed.connect(func() -> void: about.emit())
## VHS-слой — полноэкранный ColorRect поверх меню. Он фильтрует карточку,
## текст и кнопки, но остаётся внутри MainMenu; модалки — отдельные соседи
## в modal_root, они рисуются поверх и не фильтруются.
## VHS-слой — процедурный помощник (RefCounted, без Node/Control).
## Меню рисует его само в _draw(), поэтому ничто не перекрывает меню
## и не перехватывает мышь. Модалки — отдельные соседи в modal_root.
func _build_vhs() -> void:
vhs = VHS_OVERLAY.new()
vhs.name = "VhsOverlay"
add_child(vhs)
vhs.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
## Строка стартовой сводки (вынесена для тестов; рисуется в _draw_hero_text).
func start_stats_text() -> String:
return START_STATS_TEXT
func _tr(key: String, fallback: String) -> String:
@@ -146,12 +150,22 @@ func _draw() -> void:
Color(Theme90.ACCENT_LIGHT.r, Theme90.ACCENT_LIGHT.g, Theme90.ACCENT_LIGHT.b, 0.10))
draw_circle(Vector2(full.size.x * 0.06, full.size.y * 0.96), 300.0,
Color(0.96, 0.80, 0.72, 0.09))
# 2. Карточка, панель, декоративные фигуры и текст. VHS — отдельный
# full-rect слой-ребёнок, он фильтрует весь кадр меню в native-res.
# 2. VHS на бумаге.
if vhs != null:
vhs.draw_layer(self, full)
var w := _window()
var panel := _panel_rect(w)
# 3. Поверхности: карточка, панель, декоративные фигуры.
_draw_card(w)
_draw_actions_surface(w)
_draw_hero_shapes(w)
# 4. Мягкий VHS-проход по карточке и панели — ДО текста, поэтому буквы
# и кнопки остаются резкими.
if vhs != null:
vhs.draw_layer(self, w, true)
vhs.draw_layer(self, panel, true)
# 5. Текст поверх эффекта.
_draw_hero_text(w)
_draw_actions_text(w)
@@ -228,7 +242,7 @@ func _draw_hero_text(w: Rect2) -> void:
var strip := Rect2(x, w.position.y + 412, 512, 28)
Theme90.text_vcenter(self, f, Rect2(strip.position.x + 26, strip.position.y,
strip.size.x - 36, strip.size.y),
start_stats_text(), 11, Theme90.DARK)
"9 часов · $150 на старте · 128 SLA · 4 типа файлов", 11, Theme90.DARK)
# нижняя строка — только один тихий кредит
Theme90.text(self, f, Vector2(x, w.position.y + w.size.y - 28),
+177 -160
View File
@@ -1,20 +1,16 @@
class_name PacketCard
extends Control
## Карточка входящего пакета 400×52 (v2).
##
## Показывает мини-превью ФИГУРЫ, которую пакет займёт в папке, и текст
## «ЗАЙМЁТ N КЛЕТОК». Карточка перетаскивается в сетку папки или на строку
## папки: payload {"kind": "packet", "id", "shape_id", "rotation", …}.
## Соединений/атак больше нет — карточка только источник drag&drop.
## Карточка входящего пакета 400×44.
## Иконка, имя+ext, бейдж TCP/UDP, таймер (зелёный/жёлтый/красный + мигание <5 %),
## секунды и прогресс загрузки. TCP-пакет в ожидании соединяется кликом по
## карточке; перетаскивание в папку по-прежнему работает.
## Перетаскивается как {"kind": "packet", "id": ...}.
signal connect_pressed(id: String) # legacy: соединение удалено, не эмитится
signal connect_pressed(id: String)
const CARD := Vector2(400, 52)
const FR = preload("res://scripts/core/file_registry.gd")
const FILE_SHAPE = preload("res://scripts/ui/file_shape.gd")
const DRAG_CONTROLLER = preload("res://scripts/ui/drag_controller.gd")
const PREVIEW_CELL := 6.0
const PREVIEW_GAP := 1.0
const CARD := Vector2(400, 44)
## Допустимое смещение мыши, при котором нажатие считается кликом, а не drag.
const CLICK_SLOP := 6.0
var id := ""
var file_name := ""
@@ -22,12 +18,15 @@ var ext := ""
var type := "text"
var protocol := "TCP"
var time_left := 20.0
var time_total := 20.0
var time_total := 40.0
var progress := 0.0
var size_kb := 0
var state := "waiting"
var shape_id := "O1"
var shape_rotation := 0
var state := "normal"
## Карточка-угроза (мусор/DDoS/шифровальщик): без кнопки соединения,
## перетаскивается в щит-карантин.
var is_attack := false
var attack_kind := ""
var attack_id := ""
var hovered := false:
set(v):
@@ -36,6 +35,9 @@ var hovered := false:
var _blink := 0.0
var _blink_on := true
var _pressed := false
var _press_pos := Vector2.ZERO
var _drag_started := false
func _ready() -> void:
@@ -43,35 +45,61 @@ func _ready() -> void:
custom_minimum_size = CARD
if size == Vector2.ZERO:
size = CARD
mouse_default_cursor_shape = Control.CURSOR_DRAG
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
mouse_entered.connect(func() -> void: hovered = true)
mouse_exited.connect(func() -> void: hovered = false)
set_process(true)
_update_tooltip()
## Совместимая сигнатура: первые 9 аргументов как в v1, затем состояние и
## форма пакета. Атаки удалены — их аргументов здесь нет.
func setup(p_id: String, p_name: String, p_ext: String, p_type: String, p_proto: String,
p_left: float, p_total: float, p_progress: float = 0.0, p_size_kb: int = 0,
p_state: String = "waiting", p_shape_id: String = "O1",
p_rotation: int = 0) -> void:
p_is_attack: bool = false, p_attack_kind: String = "", p_attack_id: String = "",
p_state: String = "waiting") -> void:
id = p_id
file_name = p_name
ext = p_ext
type = p_type
protocol = str(p_proto).to_upper()
protocol = p_proto
time_left = p_left
time_total = p_total
progress = clampf(p_progress, 0.0, 1.0)
progress = p_progress
size_kb = p_size_kb
is_attack = p_is_attack
attack_kind = p_attack_kind
attack_id = p_attack_id
state = p_state
shape_id = p_shape_id if not p_shape_id.is_empty() else "O1"
shape_rotation = posmod(int(p_rotation), 4)
_update_tooltip()
_update_interaction()
queue_redraw()
## Клик по карточке соединяет только готовый TCP-пакет.
func _clickable() -> bool:
return not is_attack and protocol == "TCP" and state == "waiting"
func _update_interaction() -> void:
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if _clickable() \
else Control.CURSOR_ARROW
if is_attack:
tooltip_text = "УГРОЗА: %s\nПеретащите в ЩИТ (карантин)" % _display_name()
elif protocol == "TCP":
tooltip_text = "%s\n%d КБ · TCP · Клик — соединиться; перетащите в папку" % [
_display_name(), size_kb]
else:
tooltip_text = "%s\n%d КБ · UDP · перетащите в папку" % [_display_name(), size_kb]
## Имя для показа: атака-шифровальщик приходит уже с расширением (.exe),
## поэтому не добавляем тип повторно.
func _display_name() -> String:
if is_attack and file_name.contains("."):
# шифровальщик приходит как «счёт.exe» — расширение уже в имени
return file_name
if ext.is_empty():
return file_name
return "%s.%s" % [file_name, ext]
func set_time(left: float, total: float) -> void:
time_left = left
time_total = total
@@ -93,40 +121,6 @@ func timer_color() -> Color:
return Theme90.SUCCESS
## Клетки фигуры этого пакета (для тестов/подсказки).
func cells() -> Array:
return FR.shape_cells(shape_id, shape_rotation)
func cells_count() -> int:
return cells().size()
## «ЗАЙМЁТ N КЛЕТОК» — сколько места пакет займёт в папке.
func cells_text() -> String:
var n := cells_count()
var word := "КЛЕТОК"
if n % 10 == 1 and n % 100 != 11:
word = "КЛЕТКУ"
elif n % 10 >= 2 and n % 10 <= 4 and (n % 100 < 12 or n % 100 > 14):
word = "КЛЕТКИ"
return "ЗАЙМЁТ %d %s" % [n, word]
func _display_name() -> String:
if ext.is_empty():
return file_name
return "%s.%s" % [file_name, ext]
func _update_tooltip() -> void:
var shape_line := "%s · %s" % [_display_name(), cells_text().to_lower()]
if state == "loading":
tooltip_text = "%s\n%d КБ · %s · загрузка в папку" % [shape_line, size_kb, protocol]
else:
tooltip_text = "%s\n%d КБ · %s · перетащите в папку" % [shape_line, size_kb, protocol]
func _process(delta: float) -> void:
# мигание только у «горящих» пакетов (<5 %)
if ratio() < 0.05:
@@ -147,126 +141,149 @@ func _draw() -> void:
draw_style_box(Theme90.box(face, border, 4, 1,
Color(0, 0, 0, 0.11) if hovered else Theme90.SHADOW), r)
_draw_shape_preview(Rect2(4, 10, 30, 30))
if is_attack:
_draw_attack_icon(Rect2(6, 12, 20, 20))
else:
var tc := FileCard.type_color(type)
Theme90.draw_doc_icon(self, Rect2(6, 12, 20, 20), type, tc)
Theme90.text(self, f, Vector2(32, 2), _display_name(), 12,
Theme90.ERROR if is_attack else Theme90.INK, 186)
Theme90.text(self, f, Vector2(40, 2), Theme90.fit(f, _display_name(), 12, 168), 12,
Theme90.INK, 168)
# бейдж протокола
var badge := Rect2(214, 4, 30, 13)
# бейдж протокола / тип угрозы
var badge := Rect2(222, 4, 28, 13)
draw_style_box(Theme90.well_box(Theme90.WHITE), badge)
Theme90.text_vcenter(self, f, badge, protocol, 10,
Theme90.NAVY if protocol == "TCP" else Theme90.WARN, -1,
HORIZONTAL_ALIGNMENT_CENTER)
if is_attack:
var mark := "???"
var mark_color := Theme90.SHADE
match attack_kind:
"ddos":
mark = "???"
mark_color = Theme90.ERROR
"ransomware":
mark = "EXE"
mark_color = Theme90.ERROR
"junk":
mark = "??"
mark_color = Theme90.SHADE
_:
mark = "!"
mark_color = Theme90.WARN
Theme90.text_vcenter(self, f, badge, mark, 10, mark_color, -1, HORIZONTAL_ALIGNMENT_CENTER)
else:
Theme90.text_vcenter(self, f, badge, protocol, 10,
Theme90.NAVY if protocol == "TCP" else Theme90.WARN, -1, HORIZONTAL_ALIGNMENT_CENTER)
# таймер
var tr := Rect2(40, 20, 140, 5)
var tr := Rect2(32, 19, 160, 5)
var col := timer_color()
if not _blink_on:
col = Theme90.ERROR.lerp(Theme90.FACE, 0.45)
Theme90.draw_meter(self, tr, ratio(), col, Theme90.PAPER_LO)
var w := Theme90.num_line(self, Vector2(186, 17), str(int(ceilf(time_left))), 8,
timer_color(), " с", 10)
Theme90.text(self, f, Vector2(186 + w + 3, 19), "·", 10, Theme90.SHADE)
Theme90.num_line(self, Vector2(186 + w + 8, 17), str(size_kb), 8,
Theme90.SHADE, " КБ", 10)
# состояние + сколько клеток займёт
Theme90.text(self, f, Vector2(40, 31), _state_caption(), 10, _state_color(), 160)
Theme90.text(self, f, Vector2(204, 31), cells_text(), 10, Theme90.DARK, 150)
# секунды + размер
var w := Theme90.num_line(self, Vector2(197, 17), str(int(ceilf(time_left))), 8, timer_color(), " с", 10)
Theme90.text(self, f, Vector2(197 + w + 3, 19), "·", 10, Theme90.SHADE)
Theme90.num_line(self, Vector2(197 + w + 8, 17), str(size_kb), 8, Theme90.SHADE, " КБ", 10)
# прогресс загрузки справа
var pr := Rect2(310, 42, 80, 4)
Theme90.draw_meter(self, pr, progress, Theme90.ACCENT, Theme90.PAPER_LO)
Theme90.num_line(self, Vector2(348, 31), str(int(progress * 100.0)), 8,
Theme90.DARK, "%", 8)
# состояние
var status := "В ОЧЕРЕДИ"
var status_color := Theme90.DARK
if is_attack:
status = "УГРОЗА · В ЩИТ"
status_color = Theme90.ERROR
if attack_kind == "junk":
status = "МУСОР · В ЩИТ"
status_color = Theme90.SHADE
elif attack_kind == "ransomware":
status = "ВИРУС · В ЩИТ"
elif attack_kind == "ddos":
status = "DDoS · ДЕРЖИТЕ ЩИТ"
elif progress > 0.0:
status = "ПЕРЕДАЧА ИДЁТ"
status_color = Theme90.ACCENT
elif protocol != "TCP":
status = "ОДНОСТОРОННЯЯ ПЕРЕДАЧА"
else:
status = "ГОТОВ К СОЕДИНЕНИЮ"
Theme90.text(self, f, Vector2(32, 29), status, 10, status_color, 220)
func _state_caption() -> String:
if state == "loading" or progress > 0.0:
return "ЗАГРУЗКА В ПАПКУ"
if protocol != "TCP":
return "UDP · ЖДЁТ ПАПКУ"
return "ЖДЁТ ПАПКУ"
func _state_color() -> Color:
if state == "loading" or progress > 0.0:
return Theme90.ACCENT
return Theme90.DARK
func _draw_shape_preview(rect: Rect2) -> void:
# Лёгкая подложка 4×4, чтобы фигура читалась как «место в папке».
var grid_w := 4.0 * PREVIEW_CELL + 3.0 * PREVIEW_GAP
var origin := rect.position + Vector2(
roundf((rect.size.x - grid_w) * 0.5),
roundf((rect.size.y - grid_w) * 0.5))
for r in 4:
for c in 4:
var cell := Rect2(origin + Vector2(
float(c) * (PREVIEW_CELL + PREVIEW_GAP),
float(r) * (PREVIEW_CELL + PREVIEW_GAP)),
Vector2(PREVIEW_CELL, PREVIEW_CELL))
draw_rect(cell, Color(0, 0, 0, 0.045), true)
var cs := cells()
if cs.is_empty():
# прогресс загрузки (справа под кнопкой); у угроз — метка щита
if is_attack:
Theme90.text(self, f, Vector2(300, 29), "ЩИТ", 10, Theme90.ERROR, 76)
return
var bounds := FR.shape_bounds(cs)
var shape_w := float(bounds.x) * (PREVIEW_CELL + PREVIEW_GAP) - PREVIEW_GAP
var shape_h := float(bounds.y) * (PREVIEW_CELL + PREVIEW_GAP) - PREVIEW_GAP
var pos := origin + Vector2(
roundf((grid_w - shape_w) * 0.5),
roundf((grid_w - shape_h) * 0.5))
FILE_SHAPE.paint(self, pos, cs, PREVIEW_CELL, {
"type": type,
"alpha": 0.95,
"show_label": false,
"gap": PREVIEW_GAP,
})
var pr := Rect2(300, 27, 76, 4)
Theme90.draw_meter(self, pr, progress, Theme90.ACCENT, Theme90.PAPER_LO)
Theme90.num_line(self, Vector2(380, 21), str(int(progress * 100.0)), 8, Theme90.DARK, "%", 8)
func _draw_attack_icon(r: Rect2) -> void:
var f := Theme90.ui()
var soft := Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b, 0.12)
match attack_kind:
"junk":
draw_style_box(Theme90.box(Theme90.PAPER_LO, Theme90.BORDER_SOFT, 4, 1), r)
Theme90.text(self, f, Vector2(r.position.x, r.position.y + 2), "?", 14,
Theme90.SHADE, r.size.x, HORIZONTAL_ALIGNMENT_CENTER)
"ddos":
draw_style_box(Theme90.box(soft, Color(0, 0, 0, 0), 4, 0), r)
for i in 3:
draw_rect(Rect2(r.position.x + 4, r.position.y + 5 + i * 4, r.size.x - 8, 1.5),
Theme90.ERROR)
"ransomware":
draw_style_box(Theme90.box(soft, Color(0, 0, 0, 0), 4, 0), r)
draw_rect(Rect2(r.position.x + 4, r.position.y + 4, 12, 12), Theme90.ERROR, false, 1.0)
draw_rect(Rect2(r.position.x + 9, r.position.y + 9, 4, 4), Theme90.ERROR)
_:
draw_style_box(Theme90.box(soft, Color(0, 0, 0, 0), 4, 0), r)
Theme90.text(self, f, Vector2(r.position.x, r.position.y + 2), "!", 14,
Theme90.ERROR, r.size.x, HORIZONTAL_ALIGNMENT_CENTER)
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
_pressed = true
_press_pos = event.position
_drag_started = false
return
if not _pressed:
return
_pressed = false
# Drag начинается через _get_drag_data; отпускание после него — не клик.
if _drag_started or event.position.distance_to(_press_pos) > CLICK_SLOP:
_drag_started = false
return
if _clickable():
connect_pressed.emit(id)
accept_event()
elif event is InputEventMouseMotion and _pressed:
if event.position.distance_to(_press_pos) > CLICK_SLOP:
_drag_started = true
func _notification(what: int) -> void:
if what == NOTIFICATION_DRAG_END:
_pressed = false
_drag_started = false
func _get_drag_data(_at_position: Vector2) -> Variant:
var payload := {
_drag_started = true
var ghost := Theme90.DragGhost.new(_display_name(),
("УГРОЗА · В ЩИТ" if is_attack else "%d КБ · %s" % [size_kb, protocol]), "packet")
ghost.size = Vector2(168, 30)
set_drag_preview(ghost)
return {
"kind": "packet",
"id": id,
"name": file_name,
"protocol": protocol,
"type": type,
"ext": ext,
"size_kb": size_kb,
"shape_id": shape_id,
# Внутреннее поле — shape_rotation (Control.rotation занят), но ключ
# payload остаётся "rotation": это контракт FileGrid/TreePanel/HourDirector.
"rotation": shape_rotation,
"from": "packet",
"is_attack": is_attack,
"attack_kind": attack_kind,
"attack_id": attack_id,
}
var dc := _drag_controller()
if dc != null:
var preview := FILE_SHAPE.new()
preview.setup(type, shape_id, shape_rotation, file_name, ext, 36.0)
preview.set_mode("shape")
dc.begin(preview, "shape")
else:
# Без контроллера остаётся нативный ghost с фигурой-плашкой.
var ghost := Theme90.DragGhost.new(_display_name(), cells_text().to_lower(), "packet")
ghost.size = Vector2(176, 30)
set_drag_preview(ghost)
return payload
func _drag_controller() -> Node:
if not is_inside_tree():
return null
return get_tree().get_first_node_in_group("drag_controller")
func _can_drop_data(_at_position: Vector2, _data: Variant) -> bool:
return false
func _notification(what: int) -> void:
if what == NOTIFICATION_DRAG_END:
var dc := _drag_controller()
if dc != null and dc.has_method("end"):
dc.call("end")
+144 -306
View File
@@ -1,67 +1,43 @@
class_name RequestCard
extends Control
## Карточка запроса клиента 400×108 (v2).
##
## Показывает квадратные чипы прикреплённых файлов, счётчик
## «ПРИКРЕПЛЕНО X/Y», предсказанный выбор мини-игры (S → игра · T),
## переключатель TCP/UDP, явную кнопку ОТПРАВИТЬ, ОТКРЕПИТЬ и ОТКЛОНИТЬ.
## Атак/фишинга в карточке больше нет.
## Карточка запроса клиента 400×64. Светлая карточка, три слота файлов.
## TCP-запрос в ожидании соединяется кликом по самой карточке; X отклоняет,
## двойной клик по файлу отсоединяет его. attached-файл помечается зелёной
## галочкой, пустой слот — пунктиром и мигающим «ожидается».
signal send_pressed(id: String)
signal protocol_toggle_requested(id: String)
signal connect_pressed(id: String)
signal reject_pressed(id: String)
signal file_dropped(request_id: String, slot_index: int, payload: Dictionary)
signal detach_pressed(request_id: String, slot_index: int)
## Legacy: соединение удалено. Сигнал оставлен, чтобы старые подписи не падали.
signal connect_pressed(id: String)
const CARD := Vector2(400, 108)
const ECONOMY := preload("res://scripts/core/economy.gd")
const SLOT_X := 8.0
const SLOT_STEP := 90.0
const SLOT_Y := 30.0
const SLOT_W := 86.0
const SLOT_H := 26.0
const PROTO_RECT := Rect2(178, 6, 44, 16)
const COEFF := {"text": 1, "sound": 2, "photo": 4, "video": 8}
const SEND_ORDER := ["dots", "sound", "wires", "text", "video", "photo", "maze"]
const GAME_NAMES := {
"dots": "ОДНА ЛИНИЯ",
"sound": "УРОВНИ",
"wires": "ПРОВОДА",
"text": "ЧУЖОЕ СЛОВО",
"video": "РАСКАДРОВКА",
"photo": "КАДР",
"maze": "ЛАБИРИНТ",
}
const CARD := Vector2(400, 64)
const SLOT_X := 40.0
const SLOT_STEP := 72.0
const SLOT_Y := 36.0
const SLOT_W := 68.0
const SLOT_H := 20.0
var id := ""
var client_name := ""
var client_id := ""
var protocol := "TCP"
var slots: Array = []
var required_count := 0
var slot_count := 3
var time_left := 30.0
var time_total := 30.0
var time_total := 60.0
var progress := 0.0
var queued := false
var sending := false
var minigame_active := false
## Готовность контейнера (HourDirector.is_request_ready): файлы собраны.
var send_ready := false
var send_sum := 0
var minigame_id := ""
var minigame_tier := 0
var avatar_muted := false
var connected := false
## Запрос-угроза (фишинг): приглушённый аватар и бейдж «???».
var is_attack := false
var attack_kind := ""
var attack_id := ""
var hovered := false:
set(v):
hovered = v
queue_redraw()
var _send_btn: Button90
var _detach_btn: Button90
var _reject_btn: Button90
var _reject: Button90
var _blink := 0.0
var _blink_on := true
var _hover_slot := -1
@@ -79,73 +55,51 @@ func _ready() -> void:
hovered = false
_hover_slot = -1)
_send_btn = _make_button("ОТПРАВИТЬ", Vector2(8, 86), Vector2(92, 18), true)
_send_btn.pressed.connect(func() -> void:
if can_send():
send_pressed.emit(id))
_detach_btn = _make_button("ОТКРЕПИТЬ", Vector2(104, 86), Vector2(86, 18), false)
_detach_btn.pressed.connect(func() -> void:
var index := _last_attached_index()
if index >= 0:
detach_pressed.emit(id, index))
_reject_btn = _make_button("ОТКЛОНИТЬ", Vector2(194, 86), Vector2(90, 18), false)
_reject_btn.danger = true
_reject_btn.pressed.connect(func() -> void: reject_pressed.emit(id))
_reject = Button90.new()
_reject.name = "RejectButton"
_reject.icon = Button90.Icon.X
_reject.position = Vector2(386, 38)
_reject.size = Vector2(14, 14)
_reject.tooltip_text = "Отклонить запрос"
add_child(_reject)
_reject.pressed.connect(func() -> void: reject_pressed.emit(id))
set_process(true)
func _make_button(caption: String, pos: Vector2, sz: Vector2, accent: bool) -> Button90:
var b := Button90.new()
b.caption = caption
b.caption_size = 10
b.accent = accent
b.position = pos
b.size = sz
add_child(b)
return b
## Совместимые первые 8 аргументов v1 + v2-поля.
func setup(p_id: String, p_client: String, p_client_id: String, p_proto: String,
p_slots: Array, p_left: float, p_total: float, p_progress: float = 0.0,
p_required: int = 0, p_queued: bool = false, p_sending: bool = false,
p_send_sum: int = 0, p_minigame_id: String = "", p_minigame_tier: int = 0,
p_minigame_active: bool = false, p_ready: bool = false) -> void:
p_is_attack: bool = false, p_attack_kind: String = "", p_attack_id: String = "",
p_avatar_muted: bool = false) -> void:
id = p_id
client_name = p_client
client_id = p_client_id
protocol = str(p_proto).to_upper()
if protocol != "UDP":
protocol = "TCP"
slots = p_slots.duplicate(true) if p_slots is Array else []
required_count = maxi(int(p_required), slots.size())
if required_count <= 0 and not slots.is_empty():
required_count = slots.size()
protocol = p_proto
slots = p_slots
slot_count = maxi(p_slots.size(), 1)
time_left = p_left
time_total = p_total
progress = clampf(p_progress, 0.0, 1.0)
queued = p_queued
sending = p_sending
minigame_active = p_minigame_active
send_ready = p_ready
send_sum = int(p_send_sum)
minigame_id = str(p_minigame_id)
minigame_tier = int(p_minigame_tier)
tooltip_text = "%s · %s" % [client_name, client_id]
progress = p_progress
is_attack = p_is_attack
attack_kind = p_attack_kind
attack_id = p_attack_id
avatar_muted = p_avatar_muted
_update_interaction()
if is_attack and attack_kind == "phishing":
tooltip_text = "ПОДОЗРИТЕЛЬНЫЙ ЗАПРОС · %s" % client_id
else:
tooltip_text = "%s · %s" % [client_name, client_id]
custom_minimum_size = Vector2(CARD.x, 0)
_update_buttons()
queue_redraw()
func _update_buttons() -> void:
if _send_btn != null:
_send_btn.disabled = not can_send()
_send_btn.tooltip_text = "Отправить контейнер" if can_send() \
else _send_disabled_reason()
if _detach_btn != null:
_detach_btn.disabled = attached_count() <= 0 or sending or minigame_active or queued
if _reject_btn != null:
_reject_btn.disabled = sending
## Клик по карточке (не по слоту и не по X) соединяет TCP-запрос.
func _clickable() -> bool:
return not is_attack and protocol == "TCP" and not connected
func _update_interaction() -> void:
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if _clickable() \
else Control.CURSOR_ARROW
func ratio() -> float:
@@ -154,89 +108,6 @@ func ratio() -> float:
return clampf(time_left / time_total, 0.0, 1.0)
func attached_count() -> int:
var n := 0
for slot in slots:
if slot is Dictionary and bool(slot.get("attached", false)):
n += 1
return n
func can_send() -> bool:
if required_count <= 0:
return false
if sending or queued or minigame_active:
return false
return attached_count() >= required_count and send_ready
func send_disabled() -> bool:
return _send_btn != null and _send_btn.disabled
func _send_disabled_reason() -> String:
if sending:
return "Отправка уже идёт"
if queued:
return "Ожидание свободного слота"
if minigame_active:
return "Сначала завершите мини-игру"
if attached_count() < required_count:
return "Прикрепите файлы: %d из %d" % [attached_count(), required_count]
return "Отправка недоступна"
## Предсказание мини-игры: сумма коэффициентов → игра и ступень (§6).
## Пока мини-игра не запущена, игра/ступень считаются тем же плотным
## маппингом B4 (Economy.send_minigame_for), что и при реальной отправке.
func prediction() -> Dictionary:
var types: Array = []
var computed_sum := 0
for slot in slots:
if slot is Dictionary and bool(slot.get("attached", false)):
var t := str(slot.get("type", "text"))
types.append(t)
computed_sum += int(COEFF.get(t, 1))
var sum := send_sum if send_sum > 0 else computed_sum
var pick: Dictionary = ECONOMY.send_minigame_for(types)
var game := str(pick.get("id", "dots"))
var tier := clampi(int(pick.get("tier", 1)), 1, 4)
var index := int(pick.get("index", 0))
if not minigame_id.is_empty():
# Мини-игра уже активна: показываем выбор директора, не пересчитываем.
game = minigame_id
if minigame_tier > 0:
tier = clampi(minigame_tier, 1, 4)
return {
"sum": sum,
"index": index,
"id": game,
"name": _game_name(game),
"tier": tier,
}
func minigame_text() -> String:
var p := prediction()
if int(p.get("sum", 0)) <= 0:
return "МИНИ-ИГРА: —"
return "МИНИ-ИГРА: %s · T%d" % [str(p.get("name", "")), int(p.get("tier", 1))]
func difficulty_text() -> String:
return "СЛОЖНОСТЬ: %d" % int(prediction().get("sum", 0))
func _game_name(game_id: String) -> String:
var data := get_node_or_null("/root/Data")
if data != null and data.has_method("minigame_def"):
var def: Dictionary = data.call("minigame_def", game_id)
var name_ru := str(def.get("name_ru", ""))
if not name_ru.is_empty():
return name_ru.to_upper()
return str(GAME_NAMES.get(game_id, game_id.to_upper()))
func _process(delta: float) -> void:
_blink += delta
var on := int(_blink * 2.0) % 2 == 0
@@ -249,6 +120,10 @@ func _slot_rect(i: int) -> Rect2:
return Rect2(SLOT_X + i * SLOT_STEP, SLOT_Y, SLOT_W, SLOT_H)
func _icon_rect(i: int) -> Rect2:
return Rect2(SLOT_X + i * SLOT_STEP + 1, SLOT_Y + 1, 18, 18)
func _draw() -> void:
var r := Rect2(Vector2.ZERO, size)
var f := Theme90.ui()
@@ -257,21 +132,62 @@ func _draw() -> void:
draw_style_box(Theme90.box(bg, border, 4, 1,
Color(0, 0, 0, 0.11) if hovered else Theme90.SHADOW), r)
_draw_avatar(Rect2(8, 6, 20, 20))
_draw_avatar(Rect2(8, 8, 24, 24))
Theme90.text(self, f, Vector2(32, 4), Theme90.fit(f, client_name, 12, 140), 12,
Theme90.INK, 140)
Theme90.text(self, f, Vector2(32, 17), Theme90.fit(f, client_id, 9, 140), 9,
Theme90.SHADE, 140)
Theme90.text(self, f, Vector2(40, 4), Theme90.fit(f, client_name, 12, 146), 12,
Theme90.INK, 146)
Theme90.text(self, f, Vector2(40, 19), Theme90.fit(f, client_id, 10, 148), 10,
Theme90.SHADE, 148)
_draw_protocol_chip(f)
_draw_timer(f)
# протокол — маленький цветной чип
var chip := Rect2(190, 7, 34, 13)
if is_attack and attack_kind == "phishing":
draw_style_box(Theme90.box(Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b, 0.12),
Color(0, 0, 0, 0), 3, 0), chip)
Theme90.text_vcenter(self, f, chip, "???", 10, Theme90.ERROR, -1,
HORIZONTAL_ALIGNMENT_CENTER)
else:
var pcol := Theme90.NAVY if protocol == "TCP" else Theme90.WARN
draw_style_box(Theme90.box(Color(pcol.r, pcol.g, pcol.b, 0.12),
Color(0, 0, 0, 0), 3, 0), chip)
Theme90.text_vcenter(self, f, chip, protocol, 10, pcol, -1,
HORIZONTAL_ALIGNMENT_CENTER)
for i in maxi(required_count, 1):
for i in slot_count:
_draw_slot(i, f)
_draw_counter(f)
_draw_prediction(f)
# таймер / прогресс отправки
var tr := Rect2(300, 4, 92, 12)
var label := "ОТПРАВКА" if progress > 0.0 else "ОСТАЛОСЬ"
var value := "%d%%" % int(roundf(progress * 100.0)) if progress > 0.0 \
else "%d с" % int(ceilf(time_left))
Theme90.text(self, f, Vector2(tr.position.x, tr.position.y), label, 10, Theme90.SHADE)
var vw := f.get_string_size(value, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x
Theme90.text(self, f, Vector2(tr.end.x - vw, tr.position.y), value, 10,
_time_color())
var bar := Rect2(300, 20, 92, 4)
var fill := progress if progress > 0.0 else ratio()
var bcol := Theme90.ACCENT if progress > 0.0 else _time_color()
Theme90.draw_meter(self, bar, fill, bcol)
# маленькая подсказка-статус в правом нижнем углу (не кнопка)
var hint := ""
var hint_color := Theme90.ACCENT
if is_attack:
hint = ""
elif progress > 0.0:
hint = "ОТПРАВКА…"
elif protocol != "TCP":
hint = "UDP · АВТО"
hint_color = Theme90.WARN
elif connected:
hint = "ПОДКЛЮЧЁН"
hint_color = Theme90.SUCCESS
else:
hint = "КЛИК — СОЕДИНИТЬСЯ"
if not hint.is_empty():
Theme90.text_vcenter(self, f, Rect2(288, 42, 88, 16), hint, 8,
hint_color, -1, HORIZONTAL_ALIGNMENT_RIGHT)
if hovered and _drop_slot < 0:
draw_style_box(Theme90.box(Color(0, 0, 0, 0), Theme90.ACCENT_LIGHT, 4, 1),
@@ -281,66 +197,7 @@ func _draw() -> void:
Theme90.ACCENT, 4.0, 1.2)
func _draw_protocol_chip(f: Font) -> void:
var pcol := Theme90.NAVY if protocol == "TCP" else Theme90.WARN
draw_style_box(Theme90.box(Color(pcol.r, pcol.g, pcol.b, 0.13),
Color(pcol.r, pcol.g, pcol.b, 0.45), 3, 1), PROTO_RECT)
Theme90.text_vcenter(self, f, PROTO_RECT, protocol, 10, pcol, -1,
HORIZONTAL_ALIGNMENT_CENTER)
Theme90.draw_chevron(self, Vector2(PROTO_RECT.end.x - 5, PROTO_RECT.get_center().y),
2, pcol, 2.0, 1.0)
func _draw_timer(f: Font) -> void:
var label := "ОСТАЛОСЬ"
var value := "%d с" % int(ceilf(time_left))
var col := _time_color()
if sending:
label = "ОТПРАВКА"
value = "%d%%" % int(roundf(progress * 100.0))
col = Theme90.ACCENT
elif queued:
label = "СЛОТ"
value = "ОЖИДАНИЕ"
col = Theme90.WARN
elif minigame_active:
label = "СОСТОЯНИЕ"
value = "МИНИ-ИГРА"
col = Theme90.ACCENT
Theme90.text(self, f, Vector2(232, 4), label, 9, Theme90.SHADE)
var vw := f.get_string_size(value, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x
Theme90.text(self, f, Vector2(392 - vw, 3), value, 10, col)
var fill := progress if sending else ratio()
Theme90.draw_meter(self, Rect2(232, 19, 152, 4), fill,
Theme90.ACCENT if sending else col)
func _draw_counter(f: Font) -> void:
Theme90.text(self, f, Vector2(280, 31), "ПРИКРЕПЛЕНО", 8, Theme90.SHADE, 114)
var have := attached_count()
var cap := maxi(required_count, 0)
var col := Theme90.SUCCESS if have >= cap and cap > 0 else Theme90.DARK
Theme90.num_line(self, Vector2(280, 42), "%d/%d" % [have, cap], 12, col)
func _draw_prediction(f: Font) -> void:
var p := prediction()
Theme90.text(self, f, Vector2(8, 62), minigame_text(), 10, Theme90.NAVY, 224)
Theme90.text(self, f, Vector2(236, 62), difficulty_text(), 10, Theme90.DARK, 156)
if queued:
Theme90.text(self, f, Vector2(236, 73), "ОЖИДАНИЕ СЛОТА", 9, Theme90.WARN, 156)
elif sending:
Theme90.text(self, f, Vector2(236, 73), "ПЕРЕДАЧА ИДЁТ", 9, Theme90.ACCENT, 156)
elif minigame_active:
Theme90.text(self, f, Vector2(236, 73), "ИДЁТ МИНИ-ИГРА", 9, Theme90.ACCENT, 156)
elif _blink_on and attached_count() < required_count:
Theme90.text(self, f, Vector2(236, 73), "НУЖНО ФАЙЛОВ: %d" % maxi(0,
required_count - attached_count()), 9, Theme90.WARN, 156)
func _time_color() -> Color:
if queued:
return Theme90.WARN
if ratio() < 0.2:
return Theme90.ERROR
if ratio() < 0.5:
@@ -349,16 +206,20 @@ func _time_color() -> Color:
func _draw_avatar(r: Rect2) -> void:
var bg := Theme90.ACCENT
var bg := Theme90.PAPER_LO if avatar_muted else Theme90.ACCENT
var fg := Theme90.SHADE if avatar_muted else Theme90.WHITE
draw_style_box(Theme90.box(bg, Color(0, 0, 0, 0), 5, 0), r)
var f := Theme90.ui()
Theme90.text(self, f, Vector2(r.position.x, r.position.y + 3), _monogram(), 12,
Theme90.WHITE, r.size.x, HORIZONTAL_ALIGNMENT_CENTER)
draw_circle(Vector2(r.end.x - 3, r.end.y - 3), 2.5, Theme90.PAPER_HI)
draw_circle(Vector2(r.end.x - 3, r.end.y - 3), 1.6, Theme90.SUCCESS)
Theme90.text(self, f, Vector2(r.position.x, r.position.y + 5), _monogram(), 14, fg,
r.size.x, HORIZONTAL_ALIGNMENT_CENTER)
draw_circle(Vector2(r.end.x - 4, r.end.y - 4), 3.0, Theme90.PAPER_HI)
draw_circle(Vector2(r.end.x - 4, r.end.y - 4), 2.0,
Theme90.SHADE if avatar_muted else Theme90.SUCCESS)
func _monogram() -> String:
if is_attack:
return "?"
var parts := client_name.replace("«", " ").replace("»", " ").split(" ", false)
if parts.is_empty():
return "?"
@@ -370,37 +231,36 @@ func _monogram() -> String:
func _draw_slot(i: int, f: Font) -> void:
var cell := _slot_rect(i)
var ir := _icon_rect(i)
var data: Variant = slots[i] if i < slots.size() else null
var drop := _drop_slot == i
if data == null:
Theme90.draw_dashed_box(self, cell, Color(Theme90.SHADE.r, Theme90.SHADE.g,
Theme90.SHADE.b, 0.55), 2.5, 1.0)
Theme90.text(self, f, Vector2(cell.position.x + 5, cell.position.y + 5), "+",
12, Theme90.SHADE)
Theme90.text(self, f, Vector2(cell.position.x + 5, cell.position.y + 4), "+", 12,
Theme90.SHADE)
if _blink_on:
Theme90.text(self, f, Vector2(cell.position.x + 16, cell.position.y + 7),
"ожидается", 9, Theme90.WARN, SLOT_W - 18.0)
Theme90.text(self, f, Vector2(cell.position.x + 15, cell.position.y + 5),
"ожидается", 10, Theme90.WARN)
if drop:
Theme90.draw_dashed_box(self, cell.grow(1), Theme90.ACCENT, 2.5, 1.4)
return
var d: Dictionary = data
var t := str(d.get("type", "text"))
var col := FileCard.type_color(t)
draw_style_box(Theme90.box(Theme90.WHITE, Color(col.r, col.g, col.b, 0.55), 3, 1),
draw_style_box(Theme90.box(Theme90.WHITE, Color(col.r, col.g, col.b, 0.5), 3, 1),
cell)
# квадратный чип: только иконка типа + имя + тип, без фигуры
FileShape.draw_type_icon(self, Rect2(cell.position + Vector2(4, 6), Vector2(12, 12)),
t, col)
Theme90.draw_doc_icon(self, ir, t, col)
var nm := "%s.%s" % [str(d.get("name", "")), str(d.get("ext", ""))]
Theme90.text(self, f, Vector2(cell.position.x + 20, cell.position.y + 3),
Theme90.fit(f, nm, 9, SLOT_W - 24.0), 9, Theme90.INK, SLOT_W - 24.0)
Theme90.text(self, f, Vector2(cell.position.x + 20, cell.position.y + 14),
FileCard.type_title(t), 8, Theme90.SHADE, SLOT_W - 24.0)
Theme90.text(self, f, Vector2(cell.position.x + 22, cell.position.y + 5),
Theme90.fit(f, nm, 10, SLOT_W - 24.0), 10, Theme90.INK, SLOT_W - 24.0)
if bool(d.get("attached", false)):
Theme90.draw_check_badge(self, Vector2(cell.end.x - 7, cell.position.y + 7), 4.5)
Theme90.draw_check_badge(self, Vector2(cell.position.x + 17, cell.position.y + 2),
4.5)
# подсказка «отсоединить» на наведении
if _hover_slot == i or drop:
var xr := Rect2(cell.end.x - 12, cell.position.y + 14, 10, 10)
draw_circle(xr.get_center(), 5.0, Color(Theme90.ERROR.r, Theme90.ERROR.g,
var xr := Rect2(cell.end.x - 12, cell.position.y + 1, 11, 11)
draw_circle(xr.get_center(), 5.5, Color(Theme90.ERROR.r, Theme90.ERROR.g,
Theme90.ERROR.b, 0.92))
Theme90.draw_x(self, xr, Theme90.WHITE, 1.0)
if drop:
@@ -413,52 +273,36 @@ func _gui_input(event: InputEvent) -> void:
_hover_slot = _slot_at(event.position)
if prev != _hover_slot:
queue_redraw()
elif event is InputEventMouseButton and event.pressed \
and event.button_index == MOUSE_BUTTON_LEFT:
if PROTO_RECT.has_point(event.position):
protocol_toggle_requested.emit(id)
elif event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_LEFT:
var i := _slot_at(event.position)
if i >= 0:
# Слоты (и X отсоединения) не должны запускать соединение.
if event.double_click and i < slots.size() and slots[i] != null:
detach_pressed.emit(id, i)
accept_event()
return
accept_event()
return
var i := _slot_at(event.position)
if i >= 0:
if event.double_click and i < slots.size() and slots[i] != null \
and bool((slots[i] as Dictionary).get("attached", false)):
detach_pressed.emit(id, i)
accept_event()
return
accept_event()
if _clickable():
connect_pressed.emit(id)
func _slot_at(pos: Vector2) -> int:
for i in maxi(required_count, 1):
for i in slot_count:
if _slot_rect(i).has_point(pos):
return i
return -1
func _last_attached_index() -> int:
for i in range(slots.size() - 1, -1, -1):
var slot = slots[i]
if slot is Dictionary and bool(slot.get("attached", false)):
return i
return -1
func _can_drop_data(at_position: Vector2, data: Variant) -> bool:
if not (data is Dictionary):
return false
if str(data.get("kind", "")) != "file":
return false
# Файл со стола сортировки в запрос прикреплять нельзя (§4.7).
if str(data.get("from", "")) == "table":
if data.get("kind", "") != "file":
return false
var i := _slot_at(at_position)
if i < 0:
i = _first_empty()
_drop_slot = i
var dc := _drag_controller()
if dc != null:
dc.set_mode("square")
queue_redraw()
return true
@@ -473,16 +317,10 @@ func _drop_data(at_position: Vector2, data: Variant) -> void:
func _first_empty() -> int:
for i in maxi(required_count, 1):
for i in slot_count:
if i >= slots.size() or slots[i] == null:
return i
return maxi(0, required_count - 1)
func _drag_controller() -> Node:
if not is_inside_tree():
return null
return get_tree().get_first_node_in_group("drag_controller")
return slot_count - 1
func _notification(what: int) -> void:
+315 -707
View File
File diff suppressed because it is too large. Load diff
-577
View File
@@ -1,577 +0,0 @@
class_name SortingTable
extends Control
## Стол сортировки: сетка 4×6 (24 клетки) в правой колонке подготовки.
## Читает Game.registry.table_files(); drag folder→table, table→folder,
## table→table. Файл на столе нельзя прикрепить к запросу (это проверяет
## RequestCard), а переполненный стол отклоняет drop тостом «СТОЛ ЗАПОЛНЕН».
##
## Рисуется тем же painter'ом FileShape, что и FileGrid; дочерних узлов на
## файл нет.
signal file_dropped(payload: Dictionary)
signal table_changed
signal drop_rejected(reason: String)
signal file_selected(id: String)
signal selection_changed(id: String)
signal context_requested(id: String)
const FR = preload("res://scripts/core/file_registry.gd")
const FG = preload("res://scripts/ui/file_grid.gd")
const FILE_SHAPE = preload("res://scripts/ui/file_shape.gd")
const DRAG_CONTROLLER = preload("res://scripts/ui/drag_controller.gd")
const GAP := 6.0
const CELL_MIN := 56.0
const CELL_MAX := 110.0
const DEFAULT_COLS := 4
const DEFAULT_ROWS := 6
var drag_controller: Node = null
var _registry = null
var _files: Array = []
var _by_id: Dictionary = {}
var _cells_owner: Dictionary = {}
var _cols: int = DEFAULT_COLS
var _rows: int = DEFAULT_ROWS
var _cell: Vector2 = Vector2(CELL_MAX, CELL_MAX)
var _grid_origin: Vector2 = Vector2.ZERO
var _selected: String = ""
var _hover_id: String = ""
var _legacy_files: Array = []
var _drop_active: bool = false
var _drag_key: String = ""
var _drag_payload: Dictionary = {}
var _drag_rotation: int = 0
var _drag_active: bool = false
var _last_drop_pos: Vector2 = Vector2.ZERO
var _ghost_cells: Array = []
var _ghost_origin: Vector2i = Vector2i(-1, -1)
var _ghost_rotation: int = 0
var _ghost_ok: bool = false
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
clip_contents = true
mouse_default_cursor_shape = Control.CURSOR_ARROW
mouse_exited.connect(_on_mouse_exited)
resized.connect(_on_resized)
_bind_registry()
refresh()
# --- API -------------------------------------------------------------------
## data == bool → видимость; data == Array → legacy-снимок; иначе refresh().
func set_visible_data(data = null) -> void:
if data is bool:
visible = bool(data)
return
if data is Array:
var arr: Array = data
_legacy_files = arr.duplicate(true)
refresh()
func refresh() -> void:
_bind_registry()
_files.clear()
_by_id.clear()
_cells_owner.clear()
_cols = DEFAULT_COLS
_rows = DEFAULT_ROWS
if _registry != null:
_cols = maxi(1, int(_registry.table_cols))
_rows = maxi(1, int(_registry.table_rows))
for file in _registry.table_files():
_files.append(file)
_by_id[str(file.id)] = file
var cells := FR.shape_cells(str(file.shape_id), int(file.rotation))
for cell in cells:
_cells_owner[Vector2i(file.origin) + Vector2i(cell)] = str(file.id)
_files.sort_custom(func(a, b) -> bool:
return str(a.name).naturalnocasecmp_to(str(b.name)) < 0)
if not _selected.is_empty() and not _by_id.has(_selected):
_selected = ""
selection_changed.emit("")
_recalc_layout()
queue_redraw()
func occupied_cells() -> int:
return _cells_owner.size()
func total_cells() -> int:
return _cols * _rows
func file_count() -> int:
return _files.size()
func footer_text() -> String:
var total := _cols * _rows
return "%d %s · занято %d" % [total, FG.plural_cells(total), _cells_owner.size()]
func grid_metrics() -> Dictionary:
return {
"cell": _cell,
"cols": _cols,
"rows": _rows,
"origin": _grid_origin,
"gap": GAP,
}
## Статическая математика стола (для тестов/интеграции): 416×548 → 99×86.
static func cell_size(view: Vector2, cols: int, rows: int, gap: float = 6.0,
lo: float = CELL_MIN, hi: float = CELL_MAX) -> Vector2:
return FG.cell_size_pair(view, cols, rows, gap, lo, hi)
func select_file(id: String) -> void:
if id == _selected:
return
_selected = id if _by_id.has(id) else ""
selection_changed.emit(_selected)
queue_redraw()
# --- registry --------------------------------------------------------------
func _bind_registry() -> void:
if not is_inside_tree():
return
var game := get_tree().root.get_node_or_null("Game")
if game == null:
return
var next = game.get("registry")
if next == null:
return
if next != _registry:
_registry = next
if _registry.has_signal("table_changed") \
and not _registry.is_connected("table_changed", Callable(self, "_on_registry_table")):
_registry.connect("table_changed", Callable(self, "_on_registry_table"))
func _on_registry_table() -> void:
refresh()
# --- layout / hit test -----------------------------------------------------
func _on_resized() -> void:
_recalc_layout()
queue_redraw()
func _recalc_layout() -> void:
_cell = FG.cell_size_pair(size, _cols, _rows, GAP, CELL_MIN, CELL_MAX)
var gs := FG.grid_size_pair(_cols, _rows, _cell, GAP)
_grid_origin = Vector2(
maxf(1.0, roundf((size.x - gs.x) * 0.5)),
maxf(1.0, roundf((size.y - gs.y) * 0.5)))
func _cell_rect(c: int, r: int) -> Rect2:
return FG.cell_rect_pair(_grid_origin, _cell, GAP, c, r)
func _file_at(pos: Vector2) -> String:
var c := FG.cell_at_rect(pos, _grid_origin, _cell, GAP, _cols, _rows)
if c.x < 0:
return ""
return str(_cells_owner.get(c, ""))
func _get_cursor_shape(at_position: Vector2) -> int:
if _drag_active:
return Control.CURSOR_DRAG
return Control.CURSOR_POINTING_HAND if not _file_at(at_position).is_empty() \
else Control.CURSOR_ARROW
func _on_mouse_exited() -> void:
if not _hover_id.is_empty():
_hover_id = ""
tooltip_text = ""
queue_redraw()
func _update_hover(pos: Vector2) -> void:
var id := _file_at(pos)
if id == _hover_id:
return
_hover_id = id
queue_redraw()
if id.is_empty():
tooltip_text = ""
else:
var file = _by_id.get(id)
if file != null:
tooltip_text = "%s.%s · на столе" % [str(file.name), str(file.ext)]
# --- ввод ------------------------------------------------------------------
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
_update_hover(event.position)
elif event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_LEFT:
var id := _file_at(event.position)
if id.is_empty():
select_file("")
else:
_selected = id
file_selected.emit(id)
selection_changed.emit(id)
queue_redraw()
accept_event()
elif event.button_index == MOUSE_BUTTON_RIGHT:
var id := _file_at(event.position)
if not id.is_empty():
context_requested.emit(id)
accept_event()
func _input(event: InputEvent) -> void:
if not _drag_active:
return
if event is InputEventKey and event.pressed and not event.echo \
and event.keycode == KEY_R:
_rotate_drag()
get_viewport().set_input_as_handled()
elif event is InputEventMouseButton and event.pressed \
and event.button_index == MOUSE_BUTTON_RIGHT:
_rotate_drag()
get_viewport().set_input_as_handled()
func _rotate_drag() -> void:
_drag_rotation = posmod(_drag_rotation + 1, 4)
if _drop_active and not _drag_payload.is_empty():
_refresh_ghost(_last_drop_pos)
var dc := _drag_controller()
if dc != null and dc.preview() != null:
var p = dc.preview()
if p.has_method("setup") and p.get("shape_id") != null:
p.setup(p.file_type, p.shape_id, _drag_rotation, p.file_name, p.ext, p.cell_size)
queue_redraw()
# --- drag source -----------------------------------------------------------
func _get_drag_data(at_position: Vector2) -> Variant:
var id := _file_at(at_position)
if id.is_empty():
return null
var file = _by_id.get(id)
if file == null:
return null
_drag_active = true
_drag_rotation = posmod(int(file.rotation), 4)
var payload := FG.shape_payload_for(str(file.id), str(file.name), str(file.type),
str(file.ext), str(file.shape_id), int(file.rotation),
Vector2i(file.origin), "table")
_drag_payload = payload
mouse_default_cursor_shape = Control.CURSOR_DRAG
var dc := _drag_controller()
if dc != null:
var preview := FILE_SHAPE.new()
preview.setup(str(file.type), str(file.shape_id), int(file.rotation),
str(file.name), str(file.ext),
clampf(minf(_cell.x, _cell.y) * 0.55, 28.0, 56.0))
preview.set_mode("shape")
dc.begin(preview, "shape")
queue_redraw()
return payload
func _drag_controller() -> Node:
if drag_controller != null and is_instance_valid(drag_controller):
return drag_controller
if not is_inside_tree():
return null
var dc := get_tree().get_first_node_in_group("drag_controller")
if dc == null:
dc = DRAG_CONTROLLER.new()
dc.name = "DragControllerAuto"
get_tree().root.add_child(dc)
drag_controller = dc
return dc
func _existing_drag_controller() -> Node:
if drag_controller != null and is_instance_valid(drag_controller):
return drag_controller
if not is_inside_tree():
return null
return get_tree().get_first_node_in_group("drag_controller")
# --- drop target -----------------------------------------------------------
func _can_drop_data(at_position: Vector2, data: Variant) -> bool:
if not (data is Dictionary):
return false
var payload: Dictionary = data
if str(payload.get("kind", "")) != "file":
return false
var key := "file:" + str(payload.get("id", ""))
if key != _drag_key:
_drag_key = key
_drag_rotation = posmod(int(payload.get("rotation", 0)), 4)
_drop_active = true
_drag_payload = payload
_last_drop_pos = at_position
_refresh_ghost(at_position)
var dc := _drag_controller()
if dc != null:
dc.set_mode("shape")
return true
func _refresh_ghost(pos: Vector2) -> void:
var payload := _drag_payload
if payload.is_empty():
_ghost_cells.clear()
return
var shape_id := str(payload.get("shape_id", "O1"))
if shape_id.is_empty():
shape_id = "O1"
var target := FG.nearest_cell_rect(pos, _grid_origin, _cell, GAP, _cols, _rows)
var fit := _resolve_placement(payload, target, _drag_rotation)
_ghost_rotation = int(fit.get("rotation", _drag_rotation))
_ghost_origin = fit.get("origin", target)
_ghost_ok = bool(fit.get("ok", false))
_ghost_cells = FR.shape_cells(shape_id, _ghost_rotation)
queue_redraw()
func _resolve_placement(payload: Dictionary, target: Vector2i,
rotation: int) -> Dictionary:
var shape_id := str(payload.get("shape_id", "O1"))
if shape_id.is_empty():
shape_id = "O1"
var ignore := str(payload.get("id", ""))
if target.x < 0 or target.y < 0:
var auto := _find_fit(shape_id, rotation, ignore)
if not auto.is_empty():
return {"ok": true, "origin": auto["origin"], "rotation": auto["rotation"]}
return {"ok": false, "origin": Vector2i(-1, -1), "rotation": posmod(rotation, 4)}
for i in 4:
var rot := posmod(rotation + i, 4)
var origin := FG.anchor_origin(target, shape_id, rot)
if _can_place(shape_id, origin, rot, ignore):
return {"ok": true, "origin": origin, "rotation": rot}
return {
"ok": false,
"origin": FG.anchor_origin(target, shape_id, posmod(rotation, 4)),
"rotation": posmod(rotation, 4),
}
func _find_fit(shape_id: String, preferred: int, ignore_id: String) -> Dictionary:
for i in 4:
var rot := posmod(preferred + i, 4)
for r in _rows:
for c in _cols:
var origin := Vector2i(c, r)
if _can_place(shape_id, origin, rot, ignore_id):
return {"origin": origin, "rotation": rot}
return {}
func _can_place(shape_id: String, origin: Vector2i, rotation: int,
ignore_id: String) -> bool:
if _registry != null and _registry.has_method("can_place_table"):
return bool(_registry.can_place_table(shape_id, origin, rotation, ignore_id))
for cell in FR.shape_cells(shape_id, rotation):
var c := origin.x + int(cell.x)
var r := origin.y + int(cell.y)
if c < 0 or r < 0 or c >= _cols or r >= _rows:
return false
if not str(_cells_owner.get(Vector2i(c, r), "")).is_empty():
return false
return true
func _drop_data(at_position: Vector2, data: Variant) -> void:
if not (data is Dictionary):
_end_drop()
return
var payload: Dictionary = data
if str(payload.get("kind", "")) != "file":
_end_drop()
return
var target := FG.nearest_cell_rect(at_position, _grid_origin, _cell, GAP, _cols, _rows)
var fit := _resolve_placement(payload, target, _drag_rotation)
if not bool(fit.get("ok", false)):
_reject("СТОЛ ЗАПОЛНЕН")
_end_drop()
return
var file_id := str(payload.get("id", ""))
var ok := false
if _registry != null and not file_id.is_empty() \
and _registry.has_method("move_file_to_table"):
ok = bool(_registry.move_file_to_table(file_id, Vector2i(fit["origin"]),
int(fit["rotation"])))
if not ok:
_reject("СТОЛ ЗАПОЛНЕН")
_end_drop()
return
var out := payload.duplicate(true)
out["origin"] = Vector2i(fit["origin"])
out["rotation"] = int(fit["rotation"])
out["from"] = "table"
file_dropped.emit(out)
table_changed.emit()
refresh()
_end_drop()
func _reject(text: String) -> void:
drop_rejected.emit(text)
_notify(text)
func _notify(text: String) -> void:
var node: Node = self
while node != null:
if node.has_method("_toast"):
node.call("_toast", text, "warn")
return
node = node.get_parent()
func _end_drop() -> void:
_drop_active = false
_drag_key = ""
_ghost_cells.clear()
mouse_default_cursor_shape = Control.CURSOR_ARROW
queue_redraw()
func _notification(what: int) -> void:
if what == NOTIFICATION_DRAG_END:
var was_dragging := _drag_active
_drag_active = false
_end_drop()
var dc := _existing_drag_controller()
if dc != null:
dc.reset_hover()
if was_dragging:
dc.end()
# --- отрисовка -------------------------------------------------------------
func _draw() -> void:
if size.x < 2.0 or size.y < 2.0:
return
var f := Theme90.ui()
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER_SOFT, 3, 1),
Rect2(Vector2.ZERO, size))
_draw_grid_lines()
for file in _files:
_draw_file(file)
_draw_ghost(f)
if _files.is_empty():
_draw_empty(f)
_draw_footer(f)
func _draw_grid_lines() -> void:
var gs := FG.grid_size_pair(_cols, _rows, _cell, GAP)
if gs.x <= 0.0 or gs.y <= 0.0:
return
for r in _rows:
for c in _cols:
if (c + r) % 2 == 1:
draw_rect(_cell_rect(c, r), Color(0, 0, 0, 0.016))
var line := Color(0, 0, 0, 0.10)
var step := _cell + Vector2(GAP, GAP)
for c in range(1, _cols):
var x := _grid_origin.x + float(c) * step.x - GAP * 0.5
draw_line(Vector2(x, _grid_origin.y), Vector2(x, _grid_origin.y + gs.y),
line, 1.0, false)
for r in range(1, _rows):
var y := _grid_origin.y + float(r) * step.y - GAP * 0.5
draw_line(Vector2(_grid_origin.x, y), Vector2(_grid_origin.x + gs.x, y),
line, 1.0, false)
draw_rect(Rect2(_grid_origin, gs), line, false, 1.0)
func _draw_file(file) -> void:
var cells := FR.shape_cells(str(file.shape_id), int(file.rotation))
FILE_SHAPE.paint(self, _grid_origin, cells, _cell, {
"type": str(file.type),
"name": str(file.name),
"ext": str(file.ext),
"state": FG.file_state(file),
"selected": str(file.id) == _selected,
"hovered": str(file.id) == _hover_id,
"alpha": 1.0,
"show_label": true,
"gap": GAP,
})
func _draw_ghost(f: Font) -> void:
if not _drop_active or _ghost_cells.is_empty():
return
var color := Theme90.SUCCESS if _ghost_ok else Theme90.ERROR
var fill := Color(color.r, color.g, color.b, 0.16)
FILE_SHAPE.paint(self, _grid_origin, _ghost_cells, _cell, {
"type": "text",
"alpha": 0.9,
"show_label": false,
"gap": GAP,
"fill": fill,
"outline": color,
})
var b := FR.shape_bounds(_ghost_cells)
var step := _cell + Vector2(GAP, GAP)
var box := Rect2(_grid_origin + Vector2(float(_ghost_origin.x) * step.x,
float(_ghost_origin.y) * step.y), Vector2(
float(b.x) * step.x - GAP, float(b.y) * step.y - GAP))
Theme90.draw_dashed_box(self, box.grow(2.0), color, 4.0, 1.2)
var hint := "ОТПУСТИТЕ · R ПОВОРОТ" if _ghost_ok else "СТОЛ ЗАПОЛНЕН · R ПОВОРОТ"
var hw := f.get_string_size(hint, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x
var pill := Rect2(size.x - hw - 20.0, 6.0, hw + 14.0, 18.0)
draw_style_box(Theme90.box(Theme90.WHITE, color, 4, 1), pill)
Theme90.text_vcenter(self, f, pill, hint, 10, Theme90.NAVY, -1,
HORIZONTAL_ALIGNMENT_CENTER)
func _draw_empty(f: Font) -> void:
var gs := FG.grid_size_pair(_cols, _rows, _cell, GAP)
if gs.x <= 0.0 or gs.y <= 0.0:
return
var box := Rect2(_grid_origin, gs).grow(-10.0)
Theme90.draw_dashed_box(self, box, Color(Theme90.SHADE.r, Theme90.SHADE.g,
Theme90.SHADE.b, 0.40), 5.0, 1.0)
var lines := Theme90.wrap(f, "Здесь можно разложить файлы после часа пика",
12, minf(box.size.x - 24.0, 280.0), 2)
var total_h := float(lines.size()) * 15.0
var y := box.get_center().y - total_h * 0.5
for i in lines.size():
var w := f.get_string_size(lines[i], HORIZONTAL_ALIGNMENT_LEFT, -1, 12).x
Theme90.text(self, f, Vector2(box.get_center().x - w * 0.5, y + float(i) * 15.0),
lines[i], 12, Theme90.SHADE)
func _draw_footer(f: Font) -> void:
draw_rect(Rect2(0, size.y - 16.0, size.x, 16.0), Color(Theme90.PAPER_HI.r,
Theme90.PAPER_HI.g, Theme90.PAPER_HI.b, 0.94))
draw_line(Vector2(3, size.y - 16.0), Vector2(size.x - 3, size.y - 16.0),
Theme90.HAIRLINE, 1.0)
Theme90.text(self, f, Vector2(4, size.y - 12.0), footer_text(), 10,
Theme90.SHADE, size.x - 8.0)
-1
View File
@@ -1 +0,0 @@
uid://d1a87l837swyv
+35 -80
View File
@@ -10,7 +10,6 @@ extends Control
var segments: Array[Dictionary] = []
var _floats: Array[Control] = []
var _buffer_hint: Control = null
var _channel_hint: Control = null
const PAD := 14.0
@@ -18,54 +17,36 @@ const PAD := 14.0
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
segments = [
{"key": "tcp", "label": "TCP", "value": "0 / 1", "w": 132.0, "fill": 0.0},
{"key": "channel", "label": "КАНАЛ", "value": "0/10", "w": 222.0, "fill": 0.0,
"danger": false},
{"key": "sla", "label": "SLA", "value": "60 / 60", "w": 188.0, "fill": 1.0},
{"key": "buffer", "label": "МЕСТО В ПРИЁМЕ", "value": "0 / 6", "w": 158.0, "fill": 0.0},
{"key": "money", "label": "СЧЁТ", "value": "$ 50", "w": 176.0},
{"key": "hour", "label": "СМЕНА", "value": "ЧАС 1 05:00", "w": 356.0, "fill": 0.0},
{"key": "tcp", "label": "TCP", "value": "2 / 3", "w": 132.0, "fill": 0.667},
{"key": "channel", "label": "КАНАЛ", "value": "45%", "w": 222.0, "fill": 0.45},
{"key": "sla", "label": "SLA", "value": "128 / 200", "w": 188.0, "fill": 0.64},
{"key": "buffer", "label": "МЕСТО В ПРИЁМЕ", "value": "3 / 4", "w": 158.0, "fill": 0.75},
{"key": "money", "label": "СЧЁТ", "value": "$ 1 240", "w": 176.0},
{"key": "hour", "label": "СМЕНА", "value": "ЧАС 3 03:42", "w": 356.0, "fill": 0.463},
]
_buffer_hint = _make_hint("BufferHint", "buffer")
_channel_hint = _make_hint("ChannelHint", "channel")
_buffer_hint = Control.new()
_buffer_hint.name = "BufferHint"
_buffer_hint.mouse_filter = Control.MOUSE_FILTER_PASS
_buffer_hint.mouse_default_cursor_shape = Control.CURSOR_HELP
_buffer_hint.focus_mode = Control.FOCUS_NONE
add_child(_buffer_hint)
resized.connect(_layout_hint)
call_deferred("_layout_hint")
func _make_hint(node_name: String, key: String) -> Control:
var hint := Control.new()
hint.name = node_name
hint.mouse_filter = Control.MOUSE_FILTER_PASS
hint.mouse_default_cursor_shape = Control.CURSOR_HELP
hint.focus_mode = Control.FOCUS_NONE
add_child(hint)
hint.tooltip_text = _tooltip_for(key)
return hint
func _metric_value(key: String) -> String:
## Подсказка «что такое буфер» с текущим значением сегмента.
func _buffer_tooltip() -> String:
var raw := ""
for s in segments:
if str(s["key"]) == key:
return str(s["value"])
return ""
func _metric_pair(key: String) -> Vector2i:
var raw := _metric_value(key)
if str(s["key"]) == "buffer":
raw = str(s["value"])
break
var parts := raw.split("/")
var used := 0
var cap := 0
if parts.size() == 2:
used = parts[0].strip_edges().to_int()
cap = parts[1].strip_edges().to_int()
return Vector2i(used, cap)
## Подсказка «что такое буфер» с текущим значением сегмента.
func _buffer_tooltip() -> String:
var pair := _metric_pair("buffer")
var used := pair.x
var cap := pair.y
var out := "Буфер (место в приёме) — сколько пакетов можно держать одновременно\n"
out += "Сейчас: %d / %d" % [used, cap]
if cap > 0:
@@ -75,42 +56,24 @@ func _buffer_tooltip() -> String:
return out
## Подсказка канала (M19): почему передачи замедлились.
func _channel_tooltip() -> String:
var pair := _metric_pair("channel")
var out := "Канал — суммарный вес активных приёма и отправки.\n"
out += "Сейчас: %d / %d" % [pair.x, pair.y]
if pair.y > 0 and pair.x > pair.y:
out += "\nКанал перегружен: передачи идут медленнее, пока нагрузка выше нормы."
elif pair.y > 0 and pair.x == pair.y:
out += "\nКанал загружен полностью: следующая передача начнёт замедлять остальные."
else:
out += "\nЧем больше файлов одновременно, тем медленнее каждая передача."
return out
func _tooltip_for(key: String) -> String:
if key == "channel":
return _channel_tooltip()
return _buffer_tooltip()
## Прозрачные зоны над сегментами: только подсказки, без отрисовки.
## Прозрачная зона над сегментом буфера: только подсказка, без отрисовки.
func _layout_hint() -> void:
var rects := _segment_rects()
if _buffer_hint == null:
return
var idx := -1
for i in segments.size():
var key := str(segments[i]["key"])
var hint: Control = null
if key == "buffer":
hint = _buffer_hint
elif key == "channel":
hint = _channel_hint
if hint == null or i >= rects.size():
continue
var r: Rect2 = rects[i]
hint.position = Vector2(r.position.x, 0.0)
hint.size = Vector2(r.size.x, size.y)
hint.tooltip_text = _tooltip_for(key)
if str(segments[i]["key"]) == "buffer":
idx = i
break
if idx < 0:
return
var rects := _segment_rects()
if idx >= rects.size():
return
var r: Rect2 = rects[idx]
_buffer_hint.position = Vector2(r.position.x, 0.0)
_buffer_hint.size = Vector2(r.size.x, size.y)
_buffer_hint.tooltip_text = _buffer_tooltip()
func _segment_rects() -> Array[Rect2]:
@@ -175,8 +138,6 @@ func _is_numeric(s: String) -> bool:
func _gauge_color(s: Dictionary) -> Color:
if bool(s.get("danger", false)):
return Theme90.ERROR
match str(s["key"]):
"channel":
var fill := float(s.get("fill", 0.0))
@@ -201,8 +162,6 @@ func _gauge_color(s: Dictionary) -> Color:
func _value_color(s: Dictionary) -> Color:
if bool(s.get("danger", false)):
return Theme90.ERROR
match str(s["key"]):
"channel":
var fill := float(s.get("fill", 0.0))
@@ -226,18 +185,14 @@ func _value_color(s: Dictionary) -> Color:
return Theme90.INK
func set_metric(key: String, value: String, fill: float = -1.0,
danger: bool = false) -> void:
func set_metric(key: String, value: String, fill: float = -1.0) -> void:
for s in segments:
if str(s["key"]) == key:
s["value"] = value
if fill >= 0.0:
s["fill"] = fill
s["danger"] = danger
if key == "buffer" and _buffer_hint != null:
_buffer_hint.tooltip_text = _buffer_tooltip()
elif key == "channel" and _channel_hint != null:
_channel_hint.tooltip_text = _channel_tooltip()
queue_redraw()
return
+20 -152
View File
@@ -5,10 +5,8 @@ extends Control
signal folder_selected(folder_id: String)
signal file_dropped(folder_id: String, payload: Dictionary)
signal packet_dropped(folder_id: String, payload: Dictionary)
signal context_menu_requested(folder_id: String, global_pos: Vector2)
signal new_folder_requested
signal hover_open_requested(folder_id: String)
var _view: Control
var _content: Control
@@ -16,7 +14,6 @@ var _bar: ScrollBar90
var _new_btn: Button90
var _rows: Array[Dictionary] = []
var _selected := ""
var _drag: Node = null
func _ready() -> void:
@@ -47,8 +44,6 @@ func _ready() -> void:
resized.connect(_relayout)
_relayout()
call_deferred("_bind_drag_controller")
set_process(true)
func _view_rect() -> Rect2:
@@ -84,8 +79,7 @@ func _content_height() -> float:
return maxf(y + 2.0, 0.0)
## sections: [{id, name, free|free_cells, folders: [
## {id, name, count|occupied_cells, capacity|total_cells, files, locked}]}]
## sections: [{id, name, free, folders: [{id, name, count, capacity, locked}]}]
func set_data(sections: Array) -> void:
for c in _content.get_children():
c.queue_free()
@@ -94,7 +88,7 @@ func set_data(sections: Array) -> void:
var sid: String = str(sec.get("id", ""))
var srow := FolderRow.new()
srow.setup(sid, str(sec.get("name", "")), 0, true)
srow.free_text = _section_free_text(sec)
srow.free_text = str(sec.get("free", ""))
srow.custom_minimum_size = Vector2(0, FolderRow.SECTION_H)
srow.expanded = true
srow.expansion_toggled.connect(_on_toggle)
@@ -106,62 +100,22 @@ func set_data(sections: Array) -> void:
var frow := FolderRow.new()
var fid: String = str(fol.get("id", ""))
frow.setup(fid, str(fol.get("name", "")), 1, false)
frow.count = _folder_occupied(fol)
frow.capacity = _folder_total(fol)
frow.files_count = _folder_files(fol)
frow.tooltip_text = _folder_tooltip(frow.files_count, frow.count, frow.capacity)
frow.count = int(fol.get("count", 0))
frow.capacity = int(fol.get("capacity", 16))
frow.tooltip_text = "Папка заполнена" if frow.count >= frow.capacity \
else "%d из %d объектов" % [frow.count, frow.capacity]
frow.locked = bool(fol.get("locked", false))
frow.custom_minimum_size = Vector2(0, FolderRow.ROW_H)
frow.folder_selected.connect(_on_folder_clicked)
frow.expansion_toggled.connect(_on_toggle)
frow.file_dropped.connect(_on_drop)
frow.packet_dropped.connect(_on_packet_drop)
frow.context_menu_requested.connect(_on_context)
frow.hover_open_requested.connect(_on_row_hover_open)
_content.add_child(frow)
_rows.append({"row": frow, "section": sid, "visible": true})
_apply_layout()
_relayout()
if not _selected.is_empty():
select_folder(_selected)
call_deferred("_bind_drag_controller")
func _section_free_text(sec: Dictionary) -> String:
var raw = sec.get("free_text", null)
if raw is String:
return str(raw)
var cells_raw = sec.get("free_cells", sec.get("free", null))
if cells_raw is int or cells_raw is float:
return "свободно %d клеток" % int(cells_raw)
if cells_raw is String:
return str(cells_raw)
return ""
func _folder_occupied(fol: Dictionary) -> int:
return _pick_int(fol, ["occupied_cells", "occupied", "count_cells", "count"], 0)
func _folder_total(fol: Dictionary) -> int:
var total := _pick_int(fol, ["total_cells", "cells_total", "capacity"], 16)
return total if total > 0 else 16
func _folder_files(fol: Dictionary) -> int:
return _pick_int(fol, ["files", "file_count", "count"], 0)
func _pick_int(fol: Dictionary, keys: Array, fallback: int) -> int:
for key in keys:
var value = fol.get(key, null)
if value is int or value is float:
return int(value)
return fallback
func _folder_tooltip(files: int, occupied: int, total: int) -> String:
return "%d файлов · %d из %d клеток" % [files, occupied, total]
func _apply_layout() -> void:
@@ -213,36 +167,8 @@ func update_count(folder_id: String, count: int) -> void:
for e in _rows:
var row: FolderRow = e["row"]
if not row.is_section and row.folder_id == folder_id:
row.files_count = count
row.tooltip_text = _folder_tooltip(row.files_count, row.count, row.capacity)
row.count = count
row.queue_redraw()
break
func update_stats(folder_id: String, files: int, occupied: int, total: int) -> void:
for e in _rows:
var row: FolderRow = e["row"]
if not row.is_section and row.folder_id == folder_id:
row.files_count = files
row.count = occupied
row.capacity = total if total > 0 else row.capacity
row.tooltip_text = _folder_tooltip(row.files_count, row.count, row.capacity)
row.queue_redraw()
queue_redraw()
break
func footer_text() -> String:
var folders := 0
var files := 0
var occupied := 0
for e in _rows:
var row: FolderRow = e["row"]
if not row.is_section:
folders += 1
files += row.files_count
occupied += row.count
return "%d папок · %d файлов · %d клеток занято" % [folders, files, occupied]
func _on_toggle(folder_id: String) -> void:
@@ -259,79 +185,10 @@ func _on_drop(folder_id: String, payload: Dictionary) -> void:
file_dropped.emit(folder_id, payload)
func _on_packet_drop(folder_id: String, payload: Dictionary) -> void:
packet_dropped.emit(folder_id, payload)
func _on_context(folder_id: String, global_pos: Vector2) -> void:
context_menu_requested.emit(folder_id, global_pos)
## DragController: 250 мс над папкой истекли — раскрываем секцию и визуал.
func _bind_drag_controller() -> void:
if not is_inside_tree():
return
var dc := get_tree().get_first_node_in_group("drag_controller")
if dc == null or _drag == dc:
return
_drag = dc
if dc.has_signal("hover_open") \
and not dc.is_connected("hover_open", Callable(self, "_on_hover_open")):
dc.connect("hover_open", Callable(self, "_on_hover_open"))
func _process(_delta: float) -> void:
if _drag != null and is_instance_valid(_drag):
set_process(false)
return
_drag = null
_bind_drag_controller()
if _drag != null:
set_process(false)
func _on_hover_open(folder_id: String) -> void:
var row := _row_for(folder_id)
if row == null:
return
row.set_hover(true)
## FolderRow.hover_open_requested: открываем папку без повторного set_hover.
func _on_row_hover_open(folder_id: String) -> void:
if _selected != folder_id:
select_folder(folder_id)
_expand_section_of(folder_id)
folder_selected.emit(folder_id)
hover_open_requested.emit(folder_id)
func _row_for(folder_id: String) -> FolderRow:
for e in _rows:
var row: FolderRow = e["row"]
if not row.is_section and row.folder_id == folder_id:
return row
return null
func _expand_section_of(folder_id: String) -> void:
var target_section := ""
for e in _rows:
var row: FolderRow = e["row"]
if not row.is_section and row.folder_id == folder_id:
target_section = str(e.get("section", ""))
break
if target_section.is_empty():
return
for e in _rows:
var row: FolderRow = e["row"]
if row.is_section and row.folder_id == target_section and not row.expanded:
row.expanded = true
_apply_layout()
_relayout()
return
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
@@ -348,6 +205,17 @@ func _draw() -> void:
var f := Theme90.ui()
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER_SOFT, 3, 1), vr)
# подвал: папки · файлы · занятые клетки
# подвал: одна спокойная строка вместо двух «счётчиков»
var folders := 0
var objs := 0
for e in _rows:
var row: FolderRow = e["row"]
if not row.is_section:
folders += 1
objs += row.count
Theme90.text(self, f, Vector2(fr.position.x + 6, fr.position.y + 1),
footer_text(), 10, Theme90.SHADE, fr.size.x - 6.0)
"%d папок · %d объектов" % [folders, objs], 10, Theme90.SHADE, fr.size.x - 120)
var free := "8,5 МБ"
var fw := f.get_string_size(free, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x
Theme90.text(self, f, Vector2(fr.end.x - fw - 6, fr.position.y + 1), free, 10,
Theme90.SHADE)
+12 -33
View File
@@ -4,8 +4,8 @@ extends SceneTree
## godot --headless --path . --script scripts/ui/ui_smoke.gd
##
## Сценарий: меню → start_new_run() (подготовка) → _on_start_hour() (час).
## Проверяет геометрию каркаса 1280×720, наполнение дерева/сетки/панелей,
## сетку папки 5×4, стол сортировки 4×6, 5 ячеек слотов и статус-бар.
## Проверяет геометрию каркаса, наполнение дерева/сетки/панелей,
## содержимое статус-бара и появление 4 TCP-слотов.
const W := 1280.0
const H := 720.0
@@ -108,17 +108,9 @@ func _check_layout() -> void:
_check(not (_nodes[order[i]] as Rect2).intersects(_nodes[order[j]]),
"%s и %s не перекрываются" % [order[i], order[j]])
var grid: FileGrid = _main.get("grid")
var grid_w := 4.0 * 132.0 + 3.0 * 8.0
var view_w: float = (_nodes["FileGrid"].size.x - 18.0) if _nodes.has("FileGrid") else 0.0
if grid != null:
var gm: Dictionary = grid.grid_metrics()
var cell := float(gm.get("cell", 0.0))
var gap := float(gm.get("gap", 6.0))
var grid_w := 5.0 * cell + 4.0 * gap
_check(int(gm.get("cols", 0)) == 5 and int(gm.get("rows", 0)) == 4,
"сетка папки 5×4 (сейчас %s×%s)" % [gm.get("cols", 0), gm.get("rows", 0)])
_check(grid_w <= view_w + 0.5,
"5×cell+4×gap=%.0f ≤ ширины поля %.0f" % [grid_w, view_w])
_check(grid_w <= view_w, "4×132+3×8=%.0f ≤ ширины поля %.0f" % [grid_w, view_w])
func _check_menu() -> void:
@@ -184,19 +176,6 @@ func _check_prep() -> void:
print("сетка: %d файлов" % cards.size())
_check(cards.is_empty(), "в сетке 0 файлов на старте подготовки (сейчас %d)" % cards.size())
_check((_main.get("grid") as FileGrid).folder_name.length() > 0, "в сетке выбрана папка")
var grid_stats: Dictionary = (_main.get("grid") as FileGrid).stats()
_check(int(grid_stats.get("total", 0)) == 20, "сетка 5×4 = 20 клеток")
var table = _main.get("sorting_table")
_check(table != null and bool(table.visible), "в подготовке виден стол сортировки")
if table != null:
_check(int(table.total_cells()) == 24, "стол сортировки 4×6 = 24 клетки")
var tm: Dictionary = table.grid_metrics()
_check(int(tm.get("cols", 0)) == 4 and int(tm.get("rows", 0)) == 6,
"стол 4×6 (сейчас %s×%s)" % [tm.get("cols", 0), tm.get("rows", 0)])
var recv_hidden: WindowFrame = _main.get_node("ReceiveWindow")
var send_hidden: WindowFrame = _main.get_node("SendWindow")
_check(not recv_hidden.visible and not send_hidden.visible,
"ПРИЁМ/ОТПРАВКА скрыты в подготовке")
var files_win: WindowFrame = _main.get("files_window")
_check(files_win.title.contains((_main.get("grid") as FileGrid).folder_name),
"заголовок ФАЙЛЫ содержит имя папки (%s)" % files_win.title)
@@ -221,8 +200,8 @@ func _check_prep() -> void:
str(sb.get_metric("buffer").get("value", "")),
str(sb.get_metric("channel").get("value", "")),
str(sb.get_metric("hour").get("value", ""))])
_check(money.contains("$ 50"), "статус: счёт «$ 50» (сейчас «%s»)" % money)
_check(sla.contains("60 / 60"), "статус: SLA «60 / 60» (сейчас «%s»)" % sla)
_check(money.contains("$ 150"), "статус: счёт «$ 150» (сейчас «%s»)" % money)
_check(sla.contains("128 / 200"), "статус: SLA «128 / 200» (сейчас «%s»)" % sla)
var shop_btn = _main.get("_shop_btn")
_check(shop_btn != null and not bool(shop_btn.visible),
"в первом приготовлении кнопка МАГАЗИН скрыта")
@@ -230,8 +209,8 @@ func _check_prep() -> void:
"статус: TCP «0 / N»")
_check(str(sb.get_metric("buffer").get("value", "")).begins_with("0 /"),
"статус: буфер «0 / N»")
_check(str(sb.get_metric("channel").get("value", "")).contains("/"),
"статус: канал вида load/capacity")
_check(str(sb.get_metric("channel").get("value", "")).ends_with("%"),
"статус: канал в процентах")
# --------------------------------------------------------------------------
@@ -245,12 +224,12 @@ func _check_hour() -> void:
var cells: Array = _main.get("slot_cells")
print("слоты: %d" % cells.size())
_check(cells.size() == 5, "slot_cells = 5 (сейчас %d)" % cells.size())
_check(cells.size() == 4, "slot_cells = 4 (сейчас %d)" % cells.size())
var strip: Panel90 = _main.get_node("SlotsStrip")
var nodes := _find_all(strip, "SlotCell")
_check(nodes.size() == 5, "в полосе 5 SlotCell (сейчас %d)" % nodes.size())
_check(nodes.size() == 4, "в полосе 4 SlotCell (сейчас %d)" % nodes.size())
var strip_rect := Rect2(strip.global_position, strip.size)
for i in mini(cells.size(), 5):
for i in mini(cells.size(), 4):
var cell: SlotCell = cells[i]
var r := Rect2(cell.global_position, cell.size)
_check(r.size == Vector2(236, 44), "слот %d: 236×44 (сейчас %.0f×%.0f)" % [i, r.size.x, r.size.y])
@@ -270,7 +249,7 @@ func _check_hour() -> void:
_check(tcp.begins_with("0 /"), "статус[час]: TCP вида «0 / N» (сейчас «%s»)" % tcp)
_check(hour.contains("ЧАС"), "статус[час]: подпись «ЧАС N» (сейчас «%s»)" % hour)
_check(money.contains("$"), "статус[час]: счёт в долларах")
_check(sla.contains("/ 60"), "статус[час]: SLA из 60")
_check(sla.contains("/ 200"), "статус[час]: SLA из 200")
var shop_btn = _main.get("_shop_btn")
_check(shop_btn != null and not bool(shop_btn.visible),
"во время часа кнопка МАГАЗИН скрыта")
+132 -57
View File
@@ -1,79 +1,154 @@
class_name VhsOverlay
extends ColorRect
## Нативный VHS/CRT-слой главного меню.
extends RefCounted
## Процедурный VHS-слой главного меню. НЕ создаёт Node/Control и не читает
## screen texture.
##
## Полноэкранный ColorRect с canvas_item-шейдером: читает готовый кадр через
## hint_screen_texture и добавляет скан-линии (2 физических px), очень слабое
## зерно, хроматику ≤0.0003 UV, виньетку и редкую полосу. Считается в
## разрешении фактического окна (1920×1080), а не в логических 1280×720,
## поэтому линии ровные, без блоков и banding; текст остаётся резким.
## Меню рисует его прямо в MainMenu._draw() в два прохода:
## бумага → VHS фона → карточка/панель/фигуры → VHS по ним → текст.
## Поэтому слой не перекрывает меню, не перехватывает мышь и не может
## затереть кадр фоном подготовки. Кнопки — дети MainMenu, они поверх.
##
## Слой — ребёнок MainMenu и последний в дереве: он фильтрует меню (включая
## кнопки), но соседние модалки в modal_root рисуются поверх и не фильтруются.
## Состояние и параметры здесь; рисование вызывает меню:
## vhs.draw_layer(self, full_rect)
## vhs.advance(delta) + queue_redraw() при анимации
##
## В HEADLESS материал не создаётся и слой скрыт — тесты не тормозят.
## В HEADLESS текстуры не создаются и draw_layer() ничего не рисует.
const SHADER_PATH := "res://shaders/vhs.gdshader"
## Скан-линии: шаг 2 логических px (при 1920×1080 — 3 физ. px), 1 px затемнения.
const SCANLINE_STEP := 2.0
const SCANLINE_STRENGTH := 0.07
const GRAIN_SIZE := 128
const VIGNETTE_W := 320
const VIGNETTE_H := 180
## Включён ли эффект. Отключается кодом: menu.set_vhs_enabled(false)
## или напрямую overlay.set_enabled(false).
var enabled := true:
set(value):
enabled = value
_refresh()
const DARK := Color(0.035, 0.045, 0.05)
const LIGHT := Color(0.99, 0.99, 0.96)
## Общая сила слоя, 0.0..0.55. Выше 0.55 текст начинает терять контраст.
var intensity := 0.40:
set(value):
intensity = clampf(value, 0.0, 0.55)
if _mat != null:
_mat.set_shader_parameter("intensity", intensity)
## Включён ли эффект. Отключается кодом: menu.set_vhs_enabled(false).
var enabled := true
## Общая сила слоя, 0.0..0.55.
var intensity := 0.42
## Внутреннее время анимации (полоса и зерно).
var time := 0.0
var _mat: ShaderMaterial = null
var _grain: ImageTexture = null
var _vignette: ImageTexture = null
var _band: ImageTexture = null
var _ready_ok := false
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
mouse_filter = Control.MOUSE_FILTER_IGNORE
focus_mode = Control.FOCUS_NONE
color = Color(1, 1, 1, 1)
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_build_material()
func _init() -> void:
# В headless не тратим время на генерацию текстур и анимацию.
_ready_ok = DisplayServer.get_name() != "headless"
if not _ready_ok:
return
_build_grain()
_build_vignette()
_build_band()
## Публичный переключатель: true — эффект виден, false — слой полностью скрыт.
func set_enabled(value: bool) -> void:
enabled = value
func is_available() -> bool:
return _mat != null
return _ready_ok
func _build_material() -> void:
# В headless экранный шейдер не нужен и только замедляет тесты.
if DisplayServer.get_name() == "headless":
visible = false
set_process(false)
## Продвинуть время анимации. Вызывается меню из _process.
func advance(delta: float) -> void:
if enabled and _ready_ok:
time += delta
## Нарисовать VHS в rect. Canvas — обычно сам MainMenu.
##
## over_content=false — проход по бумажному фону: скан-линии, зерно,
## виньетка и полоса. over_content=true — мягкий проход по карточке и панели
## ДО отрисовки текста: чуть слабее, без виньетки, но с той же полосой.
func draw_layer(canvas: CanvasItem, rect: Rect2, over_content: bool = false) -> void:
if not enabled or not _ready_ok:
return
var shader := load(SHADER_PATH) as Shader
if shader == null:
push_warning("VHS: не удалось загрузить %s" % SHADER_PATH)
visible = false
return
_mat = ShaderMaterial.new()
_mat.shader = shader
_mat.set_shader_parameter("intensity", intensity)
material = _mat
_refresh()
var k := 0.72 if over_content else 1.0
# 1. Скан-линии: 1 px затемнения каждые 2 px.
var scan := Color(DARK.r, DARK.g, DARK.b, SCANLINE_STRENGTH * intensity * k)
var y := rect.position.y
while y < rect.end.y:
canvas.draw_rect(Rect2(rect.position.x, y, rect.size.x, 1.0), scan)
y += SCANLINE_STEP
# 2. Зерно: тайл 128×128, обновляется 2 раза в секунду.
var step := int(floor(time * 2.0))
var off := Vector2(float((step * 37) % GRAIN_SIZE), float((step * 53) % GRAIN_SIZE))
canvas.draw_texture_rect(_grain,
Rect2(rect.position - off, rect.size + off), true,
Color(1, 1, 1, intensity * k))
# 3. Виньетка — только у краёв кадра, не на карточке.
if not over_content:
canvas.draw_texture_rect(_vignette, rect, false, Color(1, 1, 1, intensity))
# 4. Редкая полоса прокрутки: медленно идёт сверху вниз с лёгким дрожанием.
# Позиция считается по логической высоте кадра (720), поэтому полоса
# совпадает на бумаге и на карточке; rect только обрезает её.
var band_h := 96.0
var span := 720.0 + band_h * 2.0
var band_top := fposmod(time * 0.055, 1.0) * span - band_h
var top := maxf(band_top, rect.position.y)
var bottom := minf(band_top + band_h, rect.end.y)
if bottom > top:
var v0 := (top - band_top) / band_h
var v1 := (bottom - band_top) / band_h
var src := Rect2(0.0, v0 * _band.get_height(), _band.get_width(),
(v1 - v0) * _band.get_height())
var jitter := sin(time * 0.7) * 4.0
canvas.draw_texture_rect_region(_band,
Rect2(rect.position.x + jitter, top, rect.size.x, bottom - top),
src, Color(1, 1, 1, intensity * k))
func _refresh() -> void:
# Сеттер может сработать до _ready — тогда состоянием займётся _ready.
if not is_node_ready():
return
var on: bool = enabled and _mat != null
visible = on
set_process(on)
# TIME в canvas_item шейдере обновляется движком автоматически, пока
# слой видим; ручных uniform-обновлений не нужно.
func _build_grain() -> void:
var img := Image.create(GRAIN_SIZE, GRAIN_SIZE, false, Image.FORMAT_RGBA8)
for yy in GRAIN_SIZE:
for xx in GRAIN_SIZE:
var n := float((xx * 73 + yy * 151 + 31) % 97) / 97.0
var c := DARK if n < 0.5 else LIGHT
var a := absf(n - 0.5) * 2.0 * 0.05
img.set_pixel(xx, yy, Color(c.r, c.g, c.b, a))
_grain = ImageTexture.create_from_image(img)
func _build_vignette() -> void:
var img := Image.create(VIGNETTE_W, VIGNETTE_H, false, Image.FORMAT_RGBA8)
for yy in VIGNETTE_H:
for xx in VIGNETTE_W:
var q := Vector2(float(xx) / VIGNETTE_W - 0.5,
float(yy) / VIGNETTE_H - 0.5)
q.x *= 16.0 / 9.0
var v := smoothstep(0.34, 0.94, q.length())
img.set_pixel(xx, yy, Color(DARK.r, DARK.g, DARK.b, v * 0.13))
_vignette = ImageTexture.create_from_image(img)
func _build_band() -> void:
const BW := 128
const BH := 32
var img := Image.create(BW, BH, false, Image.FORMAT_RGBA8)
for yy in BH:
var cy := absf(float(yy) / (BH - 1) - 0.5) * 2.0
var band_a := (1.0 - cy * cy) * 0.12
for xx in BW:
# слабые вертикальные штрихи — при дрожании полоса «плывёт»
var streak := 0.85 + 0.15 * float((xx * 29) % 17) / 17.0
if yy == 0:
# светлая ведущая кромка — «трекинг» кассеты
img.set_pixel(xx, yy, Color(LIGHT.r, LIGHT.g, LIGHT.b,
0.45 * streak))
elif yy == BH - 1:
img.set_pixel(xx, yy, Color(LIGHT.r, LIGHT.g, LIGHT.b,
0.28 * streak))
else:
img.set_pixel(xx, yy, Color(DARK.r, DARK.g, DARK.b,
band_a * streak))
_band = ImageTexture.create_from_image(img)
-82
View File
@@ -1,82 +0,0 @@
shader_type canvas_item;
render_mode unshaded;
// Нативный VHS/CRT-слой главного меню.
//
// Считается в ФИЗИЧЕСКИХ пикселях окна: скан-линии идут по FRAGCOORD,
// поэтому при 1920×1080 шаг ровно 2 px, без блоков и banding. Экранная
// текстура читается с линейной фильтрацией 1:1 — буквы не размываются.
// Хроматическая аберрация минимальна (0.0003 UV ≈ 0.6 px на краю кадра)
// и не сдвигает зелёный канал, поэтому форма букв остаётся резкой.
uniform sampler2D screen_texture : hint_screen_texture, repeat_disable, filter_linear;
uniform float intensity : hint_range(0.0, 0.55) = 0.40;
// Скан-линии: 2 физических px, затемнение до 6%.
uniform float scanline_strength : hint_range(0.0, 0.12) = 0.06;
uniform float scanline_step : hint_range(2.0, 3.0) = 2.0;
// Хроматика: очень мягкая, чтобы не двоить текст.
uniform float aberration : hint_range(0.0, 0.0006) = 0.0003;
// Зерно: ±0.025, обновление 2 Гц.
uniform float grain : hint_range(0.0, 0.04) = 0.025;
// Виньетка по краям кадра.
uniform float vignette : hint_range(0.0, 0.18) = 0.11;
// Медленная полоса прокрутки: только яркость, без сдвига картинки.
uniform float band_strength : hint_range(0.0, 0.05) = 0.04;
uniform float band_speed : hint_range(0.01, 0.3) = 0.055;
const vec3 BAND_TINT = vec3(0.85, 1.0, 1.0);
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
void fragment() {
vec2 res = 1.0 / max(SCREEN_PIXEL_SIZE, vec2(1e-6));
vec2 uv = SCREEN_UV;
// Хроматика: R/B расходятся к краям, G сэмплится ровно по UV.
float edge = 0.35 + abs(uv.x - 0.5) * 1.3;
vec2 shift = vec2(aberration * edge, 0.0);
vec3 col;
col.r = texture(screen_texture, uv + shift).r;
col.g = texture(screen_texture, uv).g;
col.b = texture(screen_texture, uv - shift).b;
// Скан-линии по физическим строкам. FRAGCOORD — центр пикселя (n+0.5),
// поэтому фазу берём от floor(y)+1, иначе cos((n+0.5)·PI) == 0.
float row = floor(FRAGCOORD.y);
float line = 0.5 + 0.5 * cos((row + 1.0) * (6.2831853 / scanline_step));
col *= 1.0 - scanline_strength * line;
// Зерно: пиксельный шум, обновляется 2 раза в секунду.
float gt = floor(TIME * 2.0);
float n = hash21(floor(FRAGCOORD.xy) + vec2(gt * 31.7, gt * 17.3));
col += (n - 0.5) * grain;
// Виньетка с поправкой на соотношение сторон.
vec2 q = uv - 0.5;
q.x *= res.x / max(res.y, 1.0);
col *= 1.0 - vignette * smoothstep(0.35, 0.95, length(q));
// Полоса: медленно идёт сверху вниз. Меняет только яркость и не двигает
// картинку, поэтому текст под ней не «плывёт».
float band_y = fract(TIME * band_speed + 0.15);
float d1 = abs(fract(uv.y - band_y + 0.5) - 0.5);
float d2 = abs(fract(uv.y - band_y - 0.5 + 0.5) - 0.5);
float band = exp(-pow(d1 / 0.045, 2.0))
+ 0.45 * exp(-pow(d2 / 0.035, 2.0));
col += band * band_strength * BAND_TINT;
vec3 base = texture(screen_texture, uv).rgb;
COLOR = vec4(mix(base, col, intensity), 1.0);
}
-1
View File
@@ -1 +0,0 @@
uid://kqjc8uchsx0s
-91
View File
@@ -1,91 +0,0 @@
# PEAK HOUR v2 - balance simulation report
Generated by `tests/sim_balance.py` from `data/balance.json`.
Seeds: **240**, hours: **9**, dt: **0.25 s**, send quality q=**0.85**, attack quality q=**0.85**, protocol: **TCP**.
## Model assumptions
- Spawn: file budget from `hours[].files` (+ carry), spawn only during 75% of the hour (225 s), interval 225/budget with +/-20% jitter. A full buffer freezes spawns without burning the budget.
- Types: `hours[].type_weights` only; zero weight never spawns.
- Unsent files persist across hours (the folder is not cleared); only files consumed by a successful send disappear. Unspawned budget is carried separately, capped at 20 files/hour.
- Requests: `request_cap` and `active_requests` from `hours[]`; interval `hours[].send_interval`; count from `request_mix`; a request reserves available files at spawn and does not consume request budget when no files exist.
- Player: competent, attaches after 1.5 s + 1 s/file, plays a no-timer send minigame for 7 + 1.5*tier seconds. The minigame pauses packet spawn/loading but not other request timers (production order).
- Timers: request `30 + 5*timer_level` (cap 55), packet 20 s. Timers pause in the player's own minigame and in the out-slot queue; payout uses `time_left` snapshotted at send start.
- Transfers: `base_time_sec * 0.92^speed_level * max(1, load/capacity)`; slots are shared by receive and send; files are consumed on successful send.
- Payout: exact `round(base_sum * speed * quality * multi * protocol * streak * money_mult)`; recv_fee is added on receive and is not multiplied.
- Folder: `grid.folder_dims[folder_level]`, 2 free folders at start, up to `constants.folders_max` (6) free folders created in prep. The spec allows up to 12; using 6 is the conservative reading and still makes `lot_folder` nearly worthless. Fragmentation is not modelled; cell capacity/occupancy is.
- Shop: 3 weighted offers after each hour, `last_offer` x0.5, pity x1.5, no rerolls (locks are irrelevant), at least one affordable offer; buy the best affordable predicted-ROI lot while ROI >= 0.5 before H9, and any positive ROI in the final prep (there is no shop after H9).
- ROI proxy: `money` = 5% of projected remaining income; `slots`/`speed`/`channel`/`timer`/`folder`/`sla`/`shield` use measured bottlenecks from the previous hour. It is deterministic, not an oracle.
## Analytical check (B1)
Expected base_sum assumes the full `request_cap` is reachable and the request mix/type weights are independent. `max income` is the hard upper bound (cap x 3 files, best allowed type, multiplier 1.4, recv fees).
| H | files | cap | act | E[n] | E[base/file] | E[base/req] | eff.reqs | E[base] | E[payout@1.15] | @1.25 | @1.40 | max base | max income | target | verdict |
|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|
| 1 | 4 | 2 | 1 | 1.30 | 24.2 | 31.5 | 2 | 63 | 78 | 84 | 93 | 136 | 198 | 65 | ok |
| 2 | 8 | 5 | 2 | 1.45 | 29.0 | 42.0 | 5 | 210 | 256 | 277 | 308 | 416 | 614 | 220 | ok |
| 3 | 12 | 5 | 2 | 1.60 | 36.0 | 57.7 | 5 | 288 | 360 | 389 | 432 | 960 | 1416 | 380 | ok |
| 4 | 16 | 6 | 3 | 1.60 | 42.7 | 68.3 | 6 | 410 | 518 | 559 | 620 | 1280 | 1888 | 560 | ok |
| 5 | 20 | 6 | 3 | 1.75 | 48.6 | 85.1 | 6 | 511 | 657 | 708 | 784 | 1440 | 2124 | 760 | ok |
| 6 | 20 | 7 | 3 | 1.75 | 54.3 | 95.0 | 7 | 665 | 844 | 910 | 1010 | 1600 | 2360 | 980 | ok |
| 7 | 20 | 7 | 4 | 1.90 | 58.7 | 111.5 | 7 | 780 | 984 | 1062 | 1179 | 1600 | 2360 | 1200 | ok |
| 8 | 20 | 7 | 4 | 1.90 | 63.8 | 121.1 | 7 | 848 | 1070 | 1155 | 1282 | 1600 | 2360 | 1450 | expected low |
| 9 | 20 | 8 | 4 | 1.90 | 67.4 | 128.0 | 8 | 1024 | 1278 | 1381 | 1534 | 1600 | 2360 | 1700 | expected low |
## Simulation: per-hour income
| H | target | mean | median | P10 | P90 | dev | done | failed | files sent/left | cells peak/cap/free | SLA end | attacks B/F | spend | verdict |
|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|
| 1 | 65 | 56 | 60 | 45 | 76 | -13.8% | 2.0 | 0.0 | 2.0/1.5 | 2/40/38 | 60 | 0.0/0.0 | 0 | PASS |
| 2 | 220 | 209 | 206 | 159 | 260 | -5.0% | 5.0 | 0.0 | 5.6/3.4 | 6/40/34 | 60 | 1.0/0.0 | 51 | PASS |
| 3 | 380 | 396 | 394 | 286 | 496 | +4.2% | 5.0 | 0.0 | 7.7/7.4 | 15/40/25 | 60 | 1.0/0.0 | 165 | PASS |
| 4 | 560 | 661 | 654 | 475 | 864 | +18.1% | 6.0 | 0.0 | 9.6/13.5 | 31/66/36 | 60 | 2.0/0.0 | 353 | PASS |
| 5 | 760 | 841 | 826 | 603 | 1 122 | +10.7% | 6.0 | 0.0 | 10.4/20.8 | 54/100/46 | 59 | 2.0/0.0 | 574 | PASS |
| 6 | 980 | 1 146 | 1 144 | 884 | 1 437 | +16.9% | 6.9 | 0.1 | 12.0/26.3 | 74/120/46 | 59 | 3.0/0.0 | 703 | PASS |
| 7 | 1200 | 1 422 | 1 399 | 1 041 | 1 869 | +18.5% | 6.9 | 0.1 | 13.3/30.0 | 91/120/29 | 58 | 3.0/0.0 | 620 | PASS |
| 8 | 1450 | 1 586 | 1 589 | 1 218 | 1 932 | +9.4% | 6.9 | 0.0 | 13.2/33.0 | 104/121/17 | 55 | 4.0/0.0 | 272 | PASS |
| 9 | 1700 | 1 948 | 1 974 | 1 534 | 2 399 | +14.6% | 7.8 | 0.1 | 14.7/33.7 | 115/173/58 | 60 | 3.9/0.0 | 3118 | PASS |
`spend` is the gold spent in the prep before that hour; H9 is the final prep, where the policy spends on any positive ROI because no shop follows.
## Run totals
- Mean total income: **8 184 $** (payout 7 060 + recv_fee 445 + attacks 679). Spec band: 7 300-8 500 $.
- Mean shop spend: **5 771 $** = **44.1%** of the 13 100 $ tree (target 55-65%). Mean money left after H9: **2 463 $**.
- Mean requests done: **52.2**; files received **121.4**, sent **87.8**, left unsent **33.7**.
- Mean SLA at end of H9: **58.8**; defeated runs: **14**.
- Attacks blocked/failed/shielded per run: **19.7/0.0/2.3**.
## Purchases and ROI proxy
| lot | effect | levels | buys | spend | value proxy | ROI | share of spend |
|---|---|---:|---:|---:|---:|---:|---:|
| lot_money | money_mult | 6.6 | 6.6 | 1 494 | 1 563 | 1.05 | 25.9% |
| lot_speed | transfer_speed | 4.1 | 4.1 | 800 | 475 | 0.59 | 13.9% |
| lot_channel | channel_capacity | 4.7 | 4.7 | 776 | 6 931 | 8.94 | 13.4% |
| lot_slots | connection_slots | 2.2 | 2.2 | 730 | 470 | 0.64 | 12.6% |
| lot_shield | shield_charge | 1.4 | 1.4 | 522 | 64 | 0.12 | 9.1% |
| lot_folder | folder_cells | 1.6 | 1.6 | 518 | 73 | 0.14 | 9.0% |
| lot_sla | sla_cap | 1.5 | 1.5 | 506 | 30 | 0.06 | 8.8% |
| lot_timer | request_timer | 2.9 | 2.9 | 425 | 169 | 0.40 | 7.4% |
## Tuning recommendations (not applied)
- No blockers: every hour mean is inside +/-20% and every analytical maximum cap is at least its target.
### Weak lots
These lots are bought only by the final-prep money dump (no shop after H9), so their spend is not evidence of value.
- `lot_shield` (shield_charge): mean level 1.43, ROI 0.12. q=0.85 clears every attack threshold, so auto-blocks only add 50% rewards. Lower the price or raise attack rewards/fail costs.
- `lot_folder` (folder_cells): mean level 1.60, ROI 0.14. Cap free folders or make the grid the binding resource; with 2-6 free folders the lot has almost no marginal value.
- `lot_sla` (sla_cap): mean level 1.46, ROI 0.06. SLA rarely binds for a competent player (2/240 defeats). Either lower the price/raise the effect or make timeouts hurt more.
### Best ROI is not the money lot
- Best ROI proxy is `lot_channel` (channel_capacity) at **8.94**, spend share 13.4%. `lot_money` is not the primary lever in this model, so a money-price change alone will not fix the tree coverage; check channel/slot/speed prices too.
- `lot_money` ROI is **1.05** (spend share 25.9%). It is a good buy but not front-loaded enough to justify a new price curve from this run; if you want one anyway, [120, 170, 230, 300, 390, 500, 640, 800] is the mild version (total 3 150 $).
## Verdict
**PASS** - every hour is inside +/-20% and no analytical cap is below its target.
+3 -52
View File
@@ -3,22 +3,12 @@ extends SceneTree
##
## Запуск:
## godot --headless --path . --script tests/run_tests.gd
##
## Часть модулей v2 — самостоятельные SceneTree-скрипты (`_init` + `quit`).
## Их нельзя вызывать через `run(t)` в этом же процессе: они сами управляют
## SceneTree и завершением. Поэтому такие модули запускаются дочерним
## процессом Godot через OS.execute, а здесь остаются только RefCounted-
## модули с `run(t)`. Это убирает ложные FAIL и сохраняет единый exit code.
const MODULES := [
["rng", "res://tests/test_rng.gd"],
]
## Standalone SceneTree-скрипты: [имя, путь]. Каждый — отдельный процесс.
const STANDALONE := [
["economy", "res://tests/test_economy.gd"],
["registry", "res://tests/test_registry.gd"],
["economy", "res://tests/test_economy.gd"],
["save", "res://tests/test_save.gd"],
["rng", "res://tests/test_rng.gd"],
]
var _checks: int = 0
@@ -33,14 +23,10 @@ func _init() -> void:
await process_frame
print("PEAK HOUR — headless tests")
var failed_modules: Array[String] = []
for entry in MODULES:
_module = str(entry[0])
_case = ""
_module_fails = 0
if not ResourceLoader.exists(str(entry[1])):
print("\n--- %s --- SKIP (нет файла)" % _module)
continue
print("\n--- %s ---" % _module)
var script = load(str(entry[1]))
if script == null:
@@ -59,18 +45,8 @@ func _init() -> void:
print("FAIL %s (%d)" % [_module, _module_fails])
failed_modules.append(_module)
for entry in STANDALONE:
var name := str(entry[0])
var path := str(entry[1])
if not ResourceLoader.exists(path):
print("\n--- %s (standalone) --- SKIP (нет файла)" % name)
continue
if not _run_standalone(name, path):
failed_modules.append(name)
print("")
print("Проверок в этом процессе: %d, провалов: %d" % [_checks, _fails])
print("Standalone-модулей: %d" % STANDALONE.size())
print("Проверок: %d, провалов: %d" % [_checks, _fails])
if _fails == 0:
print("PASS")
quit(0)
@@ -79,31 +55,6 @@ func _init() -> void:
quit(1)
## Запустить самостоятельный SceneTree-тест дочерним Godot.
## Возвращает true, если процесс завершился с кодом 0.
func _run_standalone(name: String, path: String) -> bool:
print("\n--- %s (standalone) ---" % name)
var godot := OS.get_executable_path()
if godot.is_empty():
print("FAIL %s: не удалось определить путь к Godot" % name)
return false
var project_dir := ProjectSettings.globalize_path("res://")
var args := PackedStringArray([
"--headless",
"--path", project_dir,
"--script", path,
])
var output: Array = []
var code := OS.execute(godot, args, output, true)
for line in output:
print(str(line).trim_suffix("\n").trim_suffix("\r"))
if code == 0:
print("PASS %s" % name)
return true
print("FAIL %s (exit %d)" % [name, code])
return false
# --- assert-хелперы --------------------------------------------------------
func set_case(name: String) -> void:
+579 -1533
View File
File diff suppressed because it is too large. Load diff
-559
View File
@@ -1,559 +0,0 @@
extends SceneTree
## Headless-тесты четырёх мини-игр атак: генерация, валидаторы, формулы
## качества, таблицы T1–T4, отсутствие таймеров и контракт finished.
##
## Запуск:
## godot --headless --path . --script tests/test_attack_games.gd
const JUNK := preload("res://scripts/minigames/attack/junk.gd")
const DDOS := preload("res://scripts/minigames/attack/ddos.gd")
const PHISHING := preload("res://scripts/minigames/attack/phishing.gd")
const RANSOMWARE := preload("res://scripts/minigames/attack/ransomware.gd")
const FACTORY := preload("res://scripts/minigames/minigame_factory.gd")
const AD := preload("res://scripts/systems/attack_director.gd")
const SEEDS := [1, 7, 42, 99, 1234, 2026]
const IDS := ["junk", "ddos", "phishing", "ransomware"]
const SEND_IDS := ["dots", "sound", "wires", "text", "video", "photo", "maze"]
var _checks: int = 0
var _fails: int = 0
var _case: String = ""
var _last_q: float = -1.0
var _finish_count: int = 0
func _init() -> void:
await process_frame
_test_junk_params()
_test_junk_generation()
_test_junk_quality()
_test_ddos_params()
_test_ddos_generation()
_test_ddos_motion_is_dt_based()
_test_ddos_quality()
_test_phishing_params()
_test_phishing_generation()
_test_phishing_quality()
_test_ransomware_params()
_test_ransomware_generation()
_test_ransomware_quality()
_test_no_timers_and_no_global_rng()
_test_factory_and_finished_contract()
_test_surrender_semantics()
_test_submit_quality_contract()
_finish()
# --- junk ------------------------------------------------------------------
func _test_junk_params() -> void:
_set_case("junk_params")
var expected := {
1: {"cards": 9, "junk": 2, "hints": 1, "threshold": 0.60},
2: {"cards": 10, "junk": 3, "hints": 2, "threshold": 0.65},
3: {"cards": 11, "junk": 4, "hints": 2, "threshold": 0.70},
4: {"cards": 12, "junk": 5, "hints": 3, "threshold": 0.75},
}
for tier in range(1, 5):
var p := JUNK.tier_params(tier)
var spec: Dictionary = expected[tier]
_eq(int(p["cards"]), int(spec["cards"]), "junk T%d cards" % tier)
_eq(int(p["junk"]), int(spec["junk"]), "junk T%d junk" % tier)
_eq(int(p["hints"]), int(spec["hints"]), "junk T%d hints" % tier)
_approx(float(p["threshold"]), float(spec["threshold"]), 0.0001,
"junk T%d threshold" % tier)
_ok(int(p["junk"]) < int(p["cards"]), "junk T%d есть нормальные карточки" % tier)
func _test_junk_generation() -> void:
_set_case("junk_generation")
for tier in range(1, 5):
var p := JUNK.tier_params(tier)
for seed in SEEDS:
var data := JUNK.generate_junk(tier, seed)
var cards: Array = data["cards"]
_eq(cards.size(), int(p["cards"]), "junk T%d seed %d: всего карточек" % [tier, seed])
_eq(int(data["total_junk"]), int(p["junk"]), "junk T%d seed %d: мусора" % [tier, seed])
_ok(JUNK.validate_junk(data), "junk T%d seed %d: валидатор" % [tier, seed])
var junk_seen := 0
var normal_seen := 0
var name_counts := {}
for card in cards:
var name := str((card as Dictionary).get("name", ""))
name_counts[name] = int(name_counts.get(name, 0)) + 1
for card in cards:
var c: Dictionary = card
var anomalies := JUNK.detected_anomalies(c, name_counts)
if bool(c.get("junk", false)):
junk_seen += 1
var independent := 0
for anomaly in anomalies:
if str(anomaly) != "duplicate_name":
independent += 1
_ok(independent >= 1,
"junk T%d seed %d: у мусора есть однозначный признак" % [tier, seed])
else:
normal_seen += 1
for anomaly in anomalies:
if str(anomaly) != "duplicate_name":
_fail("junk T%d seed %d: нормальная карточка с аномалией %s" %
[tier, seed, str(anomaly)])
_eq(junk_seen, int(p["junk"]), "junk T%d seed %d: счётчик мусора" % [tier, seed])
_eq(normal_seen, int(p["cards"]) - int(p["junk"]),
"junk T%d seed %d: счётчик нормальных" % [tier, seed])
_ok((data["hints"] as Array).size() <= int(p["hints"]),
"junk T%d seed %d: число подсказок" % [tier, seed])
_ok((data["hints"] as Array).size() >= 1,
"junk T%d seed %d: подсказки есть" % [tier, seed])
# Детерминизм по сиду и различие разных сидов.
_eq(str(JUNK.generate_junk(2, 77)), str(JUNK.generate_junk(2, 77)),
"junk одинаковый сид → одинаковая раздача")
_ok(str(JUNK.generate_junk(2, 77)) != str(JUNK.generate_junk(2, 78)),
"junk разные сиды → разные раздачи")
func _test_junk_quality() -> void:
_set_case("junk_quality")
_approx(JUNK.junk_quality(2, 0, 2), 1.0, 0.0001, "junk 2/2 = 1")
_approx(JUNK.junk_quality(1, 0, 2), 0.5, 0.0001, "junk 1/2 = 0.5")
_approx(JUNK.junk_quality(0, 0, 2), 0.0, 0.0001, "junk 0/2 = 0")
_approx(JUNK.junk_quality(0, 2, 2), 0.0, 0.0001, "junk ложные клампятся в 0")
_approx(JUNK.junk_quality(3, 1, 2), 1.0, 0.0001, "junk клампится сверху")
_approx(JUNK.junk_quality(1, 2, 2), 0.0, 0.0001, "junk больше ложных — 0")
_approx(JUNK.junk_quality(5, 1, 5), 0.8, 0.0001, "junk (5-1)/5 = 0.8")
_approx(JUNK.junk_quality(0, 0, 0), 0.0, 0.0001, "junk без мусора = 0")
# --- ddos ------------------------------------------------------------------
func _test_ddos_params() -> void:
_set_case("ddos_params")
var expected := {
1: {"packets": 8, "speed": 110.0, "shield_width": 260.0, "interval": 0.55,
"threshold": 0.60},
2: {"packets": 10, "speed": 140.0, "shield_width": 220.0, "interval": 0.45,
"threshold": 0.65},
3: {"packets": 12, "speed": 170.0, "shield_width": 180.0, "interval": 0.35,
"threshold": 0.70},
4: {"packets": 14, "speed": 200.0, "shield_width": 150.0, "interval": 0.28,
"threshold": 0.75},
}
for tier in range(1, 5):
var p := DDOS.tier_params(tier)
var spec: Dictionary = expected[tier]
_eq(int(p["packets"]), int(spec["packets"]), "ddos T%d packets" % tier)
_approx(float(p["speed"]), float(spec["speed"]), 0.0001, "ddos T%d speed" % tier)
_approx(float(p["shield_width"]), float(spec["shield_width"]), 0.0001,
"ddos T%d shield_width" % tier)
_approx(float(p["interval"]), float(spec["interval"]), 0.0001,
"ddos T%d interval" % tier)
_approx(float(p["threshold"]), float(spec["threshold"]), 0.0001,
"ddos T%d threshold" % tier)
func _test_ddos_generation() -> void:
_set_case("ddos_generation")
for tier in range(1, 5):
var p := DDOS.tier_params(tier)
for seed in SEEDS:
var wave := DDOS.generate_wave(tier, seed)
var packets: Array = wave["packets"]
_eq(packets.size(), int(p["packets"]), "ddos T%d seed %d: пакетов" % [tier, seed])
_eq(int(wave["total"]), int(p["packets"]), "ddos T%d seed %d: total" % [tier, seed])
_ok(DDOS.validate_wave(wave), "ddos T%d seed %d: волна валидна" % [tier, seed])
var xs: Array = []
var last_spawn := -1.0
for packet in packets:
var item: Dictionary = packet
xs.append(float(item["x"]))
_ok(float(item["spawn_at"]) > last_spawn,
"ddos T%d seed %d: spawn_at растёт" % [tier, seed])
last_spawn = float(item["spawn_at"])
_ok(is_finite(float(item["x"])) and is_finite(float(item["speed"])),
"ddos T%d seed %d: координаты/скорость конечны" % [tier, seed])
xs.sort()
for i in range(1, xs.size()):
_ok(float(xs[i]) - float(xs[i - 1]) >= 12.0 - 0.001,
"ddos T%d seed %d: позиции разнесены" % [tier, seed])
_ok(last_spawn < 30.0, "ddos T%d seed %d: волна конечна" % [tier, seed])
var start := float(wave["start_shield_x"])
var half := float(wave["shield_width"]) * 0.5
_ok(start - half >= 18.0 - 0.001 and start + half <= 622.0 + 0.001,
"ddos T%d seed %d: стартовый щит в поле" % [tier, seed])
func _test_ddos_motion_is_dt_based() -> void:
_set_case("ddos_dt_motion")
# Две одинаковые игры: 2 шага по 0.25 с против 1 шага 0.5 с. При чистом
# dt-интегрировании без покадрового damping активные пакеты совпадут.
var a = DDOS.new()
var b = DDOS.new()
a.start("ddos", 2, 4242)
b.start("ddos", 2, 4242)
a._elapsed = 100.0
b._elapsed = 100.0
a._process(0.0)
b._process(0.0)
_eq(a._packets.size(), int(a._wave["total"]), "ddos: все пакеты заспавнены")
_eq(b._packets.size(), int(b._wave["total"]), "ddos: все пакеты заспавнены (B)")
a._process(0.25)
a._process(0.25)
b._process(0.5)
_eq(a._blocked, b._blocked, "ddos: 2×0.25 и 1×0.5 дают одинаковые попадания")
_eq(a._breached, b._breached, "ddos: 2×0.25 и 1×0.5 дают одинаковые пробития")
_eq(a._packets.size(), b._packets.size(), "ddos: одинаковое число активных пакетов")
for i in range(mini(a._packets.size(), b._packets.size())):
var pa: Dictionary = a._packets[i]
var pb: Dictionary = b._packets[i]
_approx(float(pa["y"]), float(pb["y"]), 0.001, "ddos: y не зависит от fps")
_approx(float(pa["x"]), float(pb["x"]), 0.001, "ddos: x не зависит от fps")
a.free()
b.free()
func _test_ddos_quality() -> void:
_set_case("ddos_quality")
_approx(DDOS.ddos_quality(8, 8), 1.0, 0.0001, "ddos 8/8 = 1")
_approx(DDOS.ddos_quality(4, 8), 0.5, 0.0001, "ddos 4/8 = 0.5")
_approx(DDOS.ddos_quality(0, 8), 0.0, 0.0001, "ddos 0/8 = 0")
_approx(DDOS.ddos_quality(9, 8), 1.0, 0.0001, "ddos клампится сверху")
_approx(DDOS.ddos_quality(1, 0), 0.0, 0.0001, "ddos без пакетов = 0")
_approx(DDOS.ddos_quality(3, 4), 0.75, 0.0001, "ddos 3/4 = 0.75")
# --- phishing --------------------------------------------------------------
func _test_phishing_params() -> void:
_set_case("phishing_params")
var expected_clients := [3, 4, 5, 6]
var expected_threshold := [0.75, 0.75, 0.80, 0.80]
var expected_checks := [
["name"],
["name", "dept"],
["name", "dept", "id"],
["name", "dept", "id", "photo", "expiry"],
]
for tier in range(1, 5):
var p := PHISHING.tier_params(tier)
_eq(int(p["clients"]), expected_clients[tier - 1], "phishing T%d clients" % tier)
_approx(float(p["threshold"]), expected_threshold[tier - 1], 0.0001,
"phishing T%d threshold" % tier)
_eq(p["checks"], expected_checks[tier - 1], "phishing T%d checks" % tier)
func _test_phishing_generation() -> void:
_set_case("phishing_generation")
for tier in range(1, 5):
var p := PHISHING.tier_params(tier)
for seed in SEEDS:
var clients := PHISHING.generate_clients(tier, seed)
_eq(clients.size(), int(p["clients"]), "phishing T%d seed %d: клиентов" % [tier, seed])
_ok(PHISHING.validate_clients(clients, tier),
"phishing T%d seed %d: все решения однозначны" % [tier, seed])
var fakes := 0
var valids := 0
for client in clients:
var c: Dictionary = client
var diffs := PHISHING.client_diff_fields(c)
_ok(diffs.size() <= 1, "phishing T%d seed %d: не больше одного отличия" % [tier, seed])
if bool(c.get("valid", false)):
valids += 1
_eq(str(c.get("expected", "")), "issue", "phishing: честный → ВЫДАТЬ")
else:
fakes += 1
_eq(str(c.get("expected", "")), "deny", "phishing: подделка → ОТКАТАТЬ")
_eq(str(c.get("mismatch", "")), str(diffs[0]) if not diffs.is_empty() else "",
"phishing: mismatch = отличие")
_ok((c.get("checks", []) as Array).has(str(c.get("mismatch", ""))),
"phishing: отличие в проверяемом поле")
if str(c.get("mismatch", "")) == "id":
var file_id := str((c.get("file", {}) as Dictionary).get("id", ""))
var pass_id := str((c.get("pass", {}) as Dictionary).get("id", ""))
_eq(file_id.length(), pass_id.length(), "phishing: ID той же длины")
var has_cyrillic := false
for i in range(pass_id.length()):
if PHISHING.LOOKALIKE.values().has(pass_id.substr(i, 1)):
has_cyrillic = true
_ok(has_cyrillic, "phishing: поддельный ID содержит кириллицу")
_ok(valids >= 1 and fakes >= 1,
"phishing T%d seed %d: есть и честные, и подделки" % [tier, seed])
var named := PHISHING.generate_client(4, 123, 0, 1)
var diff_name := PHISHING.client_diff_fields(named)
_eq(diff_name.size(), 1, "phishing T4: подделка отличается одним полем")
func _test_phishing_quality() -> void:
_set_case("phishing_quality")
_approx(PHISHING.phishing_quality(3, 3), 1.0, 0.0001, "phishing 3/3 = 1")
_approx(PHISHING.phishing_quality(2, 3), 2.0 / 3.0, 0.0001, "phishing 2/3")
_approx(PHISHING.phishing_quality(0, 3), 0.0, 0.0001, "phishing 0/3 = 0")
_approx(PHISHING.phishing_quality(5, 3), 1.0, 0.0001, "phishing клампится сверху")
_approx(PHISHING.phishing_quality(0, 0), 0.0, 0.0001, "phishing без клиентов = 0")
# --- ransomware ------------------------------------------------------------
func _test_ransomware_params() -> void:
_set_case("ransomware_params")
var expected := {
1: {"cards": 12, "exe": 1, "threshold": 0.50, "masking": "extension_visible"},
2: {"cards": 16, "exe": 2, "threshold": 0.55, "masking": "extension_small"},
3: {"cards": 20, "exe": 2, "threshold": 0.60, "masking": "document_icon"},
4: {"cards": 24, "exe": 3, "threshold": 0.65, "masking": "similar_names"},
}
for tier in range(1, 5):
var p := RANSOMWARE.tier_params(tier)
var spec: Dictionary = expected[tier]
_eq(int(p["cards"]), int(spec["cards"]), "ransomware T%d cards" % tier)
_eq(int(p["exe"]), int(spec["exe"]), "ransomware T%d exe" % tier)
_approx(float(p["threshold"]), float(spec["threshold"]), 0.0001,
"ransomware T%d threshold" % tier)
_eq(str(p["masking"]), str(spec["masking"]), "ransomware T%d masking" % tier)
func _test_ransomware_generation() -> void:
_set_case("ransomware_generation")
for tier in range(1, 5):
var p := RANSOMWARE.tier_params(tier)
for seed in SEEDS:
var data := RANSOMWARE.generate_cards(tier, seed)
var cards: Array = data["cards"]
_eq(cards.size(), int(p["cards"]), "ransomware T%d seed %d: карточек" % [tier, seed])
_eq(int(data["total_exe"]), int(p["exe"]), "ransomware T%d seed %d: exe" % [tier, seed])
_ok(RANSOMWARE.validate_cards(data),
"ransomware T%d seed %d: валидатор" % [tier, seed])
var exec_count := 0
var target_count := 0
var trap_count := 0
for card in cards:
var c: Dictionary = card
if bool(c.get("executable", false)):
exec_count += 1
if bool(c.get("target", false)):
target_count += 1
_ok(bool(c.get("executable", false)), "ransomware: цель исполняемая")
if bool(c.get("trap", false)):
trap_count += 1
_ok(not bool(c.get("executable", false)), "ransomware: ловушка не exe")
_ok(not bool(c.get("target", false)), "ransomware: ловушка не цель")
_ok(str(c.get("name", "")).to_lower().contains("exe"),
"ransomware: в имени ловушки есть exe")
_ok(["DOC", "XLS"].has(str(c.get("ext", ""))),
"ransomware: расширение ловушки doc/xls")
_eq(exec_count, int(p["exe"]), "ransomware T%d seed %d: найдено exe" % [tier, seed])
_ok(exec_count >= 1, "ransomware T%d seed %d: минимум один exe" % [tier, seed])
_ok(target_count <= 1, "ransomware T%d seed %d: не больше одной цели" % [tier, seed])
if tier <= 2:
_eq(target_count, 1, "ransomware T%d seed %d: ровно одна цель" % [tier, seed])
if tier == 4:
_eq(trap_count, 2, "ransomware T4 seed %d: две ловушки" % seed)
else:
_eq(trap_count, 0, "ransomware T%d seed %d: без ловушек" % [tier, seed])
func _test_ransomware_quality() -> void:
_set_case("ransomware_quality")
_approx(RANSOMWARE.ransomware_quality(3, 0, 3), 1.0, 0.0001, "ransomware 3/3 = 1")
_approx(RANSOMWARE.ransomware_quality(1, 0, 2), 0.5, 0.0001, "ransomware 1/2 = 0.5")
_approx(RANSOMWARE.ransomware_quality(0, 0, 2), 0.0, 0.0001, "ransomware 0/2 = 0")
_approx(RANSOMWARE.ransomware_quality(1, 1, 2), 0.25, 0.0001,
"ransomware (1 - 0.5)/2 = 0.25")
_approx(RANSOMWARE.ransomware_quality(0, 4, 2), 0.0, 0.0001,
"ransomware ложные клампятся в 0")
_approx(RANSOMWARE.ransomware_quality(4, 0, 2), 1.0, 0.0001,
"ransomware клампится сверху")
_approx(RANSOMWARE.ransomware_quality(0, 0, 0), 0.0, 0.0001, "ransomware без exe = 0")
# --- общие требования ------------------------------------------------------
func _test_no_timers_and_no_global_rng() -> void:
_set_case("no_timers")
var scripts := {
"junk": JUNK,
"ddos": DDOS,
"phishing": PHISHING,
"ransomware": RANSOMWARE,
}
var paths := {
"junk": "res://scripts/minigames/attack/junk.gd",
"ddos": "res://scripts/minigames/attack/ddos.gd",
"phishing": "res://scripts/minigames/attack/phishing.gd",
"ransomware": "res://scripts/minigames/attack/ransomware.gd",
}
for id in IDS:
var game = scripts[id].new()
game.start(id, 2, 12345)
_eq(game.game_id, id, "%s: game_id" % id)
_eq(game.tier, 2, "%s: tier" % id)
_ok(game.running, "%s: running" % id)
_eq(_count_timers(game), 0, "%s: нет Timer-узлов" % id)
game._process(0.016)
_ok(not game.is_finished(), "%s: один кадр не завершает игру" % id)
game.free()
var source := FileAccess.get_file_as_string(str(paths[id]))
_ok(not source.is_empty(), "%s: исходник читается" % id)
_ok(not source.contains("Timer.new"), "%s: нет Timer.new" % id)
_ok(not source.contains("create_timer"), "%s: нет create_timer" % id)
_ok(not source.contains("Rng."), "%s: не использует глобальный Rng" % id)
_ok(source.contains("RandomNumberGenerator.new()"), "%s: локальный RNG" % id)
func _test_factory_and_finished_contract() -> void:
_set_case("factory_contract")
for id in IDS:
var game = FACTORY.create_attack(str(id), 2, 2026)
if game == null:
print("SKIP: MiniGameFactory.create_attack(%s) вернул null" % str(id))
continue
_ok(game is MiniGame, "%s: фабрика вернула MiniGame" % str(id))
_eq(game.game_id, str(id), "%s: фабрика выставила game_id" % str(id))
_eq(game.tier, 2, "%s: фабрика выставила tier" % str(id))
_ok(game.running, "%s: игра запущена" % str(id))
_finish_count = 0
_last_q = -1.0
game.finished.connect(_on_finished)
game._finish(0.37)
_eq(_finish_count, 1, "%s: finished эмитится один раз" % str(id))
_approx(_last_q, 0.37, 0.0001, "%s: finished(q)" % str(id))
_approx(game.quality(), 0.37, 0.0001, "%s: quality()" % str(id))
_ok(game.is_finished(), "%s: is_finished" % str(id))
_ok(not game.running, "%s: running=false после finish" % str(id))
game._finish(0.99)
_eq(_finish_count, 1, "%s: повторный finish не эмитит" % str(id))
game.free()
func _test_surrender_semantics() -> void:
_set_case("surrender")
var ad = AD.new()
var created: Dictionary = {}
ad.create_game_override = func(kind, tier, seed, ctx):
var g = FACTORY.create_attack(str(kind), int(tier), int(seed), ctx)
if g != null:
created[str(kind)] = g
return g
for id in IDS:
var attack_id := str(ad.spawn_attack(id))
_ok(not attack_id.is_empty(), "%s: AttackDirector открыл мини-игру" % id)
var game = created.get(id)
if game == null:
_ok(false, "%s: мини-игра атаки создана" % id)
continue
_ok(bool(game.is_attack), "%s: AttackDirector выставил is_attack" % id)
_eq(str(game.skip_label()), "СДАТЬСЯ", "%s: кнопка отказа — СДАТЬСЯ" % id)
_press_escape(game)
_ok(bool(game._skip_armed), "%s: Esc взводит сдачу" % id)
_ok(not bool(game.is_finished()), "%s: после первого Esc игра идёт" % id)
_press_escape(game)
_ok(bool(game.is_finished()), "%s: второй Esc подтверждает сдачу" % id)
_approx(float(game.quality()), 0.0, 0.0001, "%s: сдача даёт q=0" % id)
ad.cancel_active()
ad.free()
# Мини-игры отправки: Esc — no-op, кнопка остаётся ПРОПУСТИТЬ.
for id in SEND_IDS:
var send = FACTORY.create_send(id, 1, 909)
if send == null:
print("SKIP: MiniGameFactory.create_send(%s) вернул null" % id)
continue
_ok(not bool(send.is_attack), "%s: мини-игра отправки не атака" % id)
_eq(str(send.skip_label()), "ПРОПУСТИТЬ",
"%s: кнопка отправки — ПРОПУСТИТЬ" % id)
_press_escape(send)
_ok(not bool(send._skip_armed), "%s: Esc не взводит пропуск" % id)
_ok(not bool(send.is_finished()), "%s: Esc не завершает отправку" % id)
send.free()
func _test_submit_quality_contract() -> void:
_set_case("submit_contract")
var junk = JUNK.new()
junk.start("junk", 1, 555)
for card in junk._cards:
if bool((card as Dictionary).get("junk", false)):
junk._marked[int((card as Dictionary).get("id", -1))] = true
junk._submit()
_ok(junk.is_finished(), "junk: ПРОВЕРИТЬ завершает игру")
_approx(junk.quality(), 1.0, 0.0001, "junk: все снятые = q 1")
_ok(junk._result_detail.contains("СНЯТО"), "junk: result_detail заполнен")
junk.free()
var ransomware = RANSOMWARE.new()
ransomware.start("ransomware", 1, 556)
for card in ransomware._cards:
var c: Dictionary = card
if bool(c.get("executable", false)):
ransomware._marked[int(c.get("id", -1))] = true
ransomware._submit()
_ok(ransomware.is_finished(), "ransomware: ПРОВЕРИТЬ завершает игру")
_approx(ransomware.quality(), 1.0, 0.0001, "ransomware: все exe = q 1")
_ok(ransomware._result_detail.contains("НАЙДЕНО"), "ransomware: result_detail заполнен")
ransomware.free()
# --- assert-хелперы --------------------------------------------------------
func _count_timers(node: Node) -> int:
var count := 0
for child in node.get_children():
if child is Timer:
count += 1
count += _count_timers(child)
return count
func _on_finished(q: float) -> void:
_finish_count += 1
_last_q = q
func _press_escape(game) -> void:
var event := InputEventKey.new()
event.keycode = KEY_ESCAPE
event.pressed = true
game._input(event)
func _set_case(name: String) -> void:
_case = name
func _ok(condition: bool, message: String) -> bool:
_checks += 1
if condition:
return true
_fail(message + " — ожидалось true")
return false
func _eq(actual, expected, message: String) -> bool:
_checks += 1
if actual == expected:
return true
_fail("%s — получено %s, ожидалось %s" % [message, str(actual), str(expected)])
return false
func _approx(actual: float, expected: float, eps: float, message: String) -> bool:
_checks += 1
if absf(float(actual) - float(expected)) <= eps:
return true
_fail("%s — получено %.6f, ожидалось %.6f" % [message, float(actual), float(expected)])
return false
func _fail(message: String) -> void:
_fails += 1
print("FAIL [%s] %s" % [_case, message])
func _finish() -> void:
print("test_attack_games: проверок %d, провалов %d" % [_checks, _fails])
print("PASS test_attack_games" if _fails == 0 else "FAIL test_attack_games")
quit(0 if _fails == 0 else 1)
-1
View File
@@ -1 +0,0 @@
uid://cn5ssig4lnpbv
+377 -354
View File
@@ -1,55 +1,44 @@
extends SceneTree
## Headless-тест v2 AttackDirector: расписание, окно спавна, ступени по часу,
## щит-заряды, награды/штрафы из данных, H6-гонка «пауза до показа»,
## null-фабрика и отсутствие старых сущностей атак.
## Атаки: расписание, окно спавна, мини-игры и эффекты всех четырёх атак.
##
## Запуск:
## godot --headless --path . --script tests/test_attacks.gd
##
## Тесты используют публичные методы HourDirector/AttackDirector и, где
## это необходимо для детерминизма, читают служебные поля (schedule/elapsed).
## Числа берутся из data/balance.json (через Data), а не хардкодятся.
const HD := preload("res://scripts/core/hour_director.gd")
const AD := preload("res://scripts/systems/attack_director.gd")
const ECONOMY := preload("res://scripts/core/economy.gd")
var _checks: int = 0
var _fails: int = 0
var _known: int = 0
var _case: String = ""
var _game = null
var _hd = null
var _data = null
var _toasts: Array = []
var _resolved_events: Array = []
var _attack_shows: int = 0
var _hour_resolved: Array = []
class FakeGame:
extends Node
signal finished(quality: float)
var _seq: int = 0
var _attack_marks: Array = []
func _init() -> void:
await process_frame
_game = root.get_node_or_null("Game")
_data = root.get_node_or_null("Data")
if _game == null:
_fail("autoload Game недоступен")
_finish()
return
_hd = HD.new()
_hd.name = "HourDirectorAttacksTest"
root.add_child(_hd)
_hd.toast.connect(_on_toast)
_hd.show_attack_minigame.connect(_on_attack_show)
_hd.attack_resolved.connect(_on_attack_resolved)
_hd.attack_added.connect(_on_attack_added)
_test_schedule_table()
_test_tier_by_hour()
_test_schedule_window()
_test_pause_before_emit_race()
_test_shield_auto_block()
_test_rewards_and_fines()
_test_null_factory()
_test_no_old_entities()
_test_attack_unlocks()
_test_spawn_window()
_test_no_spawn_in_minigame()
_test_junk()
_test_ddos()
_test_shield_upgrade()
_test_phishing()
_test_ransomware()
_finish()
@@ -58,379 +47,413 @@ func _init() -> void:
func _test_schedule_table() -> void:
_set_case("schedule_table")
var expected: Array[int] = [0, 1, 1, 2, 2, 3, 3, 4, 4]
for h in range(1, 10):
_fresh(h)
var ad = _hd.attack_director
_eq(AD.attacks_for_hour(h), expected[h - 1], "атак в часе %d" % h)
_eq(ad._schedule.size(), expected[h - 1], "часть расписания H%d" % h)
var prev := ""
for i in range(ad._schedule.size()):
var t := float(ad._schedule[i])
_ok(t >= 59.999 and t <= 240.001, "H%d: время атаки в окне 60–240 (t=%.2f)" % [h, t])
if i > 0:
_ok(t >= float(ad._schedule[i - 1]), "H%d: расписание отсортировано" % h)
var kind := str(ad._schedule_kinds[i])
_ok(AD.KINDS.has(kind), "H%d: корректный вид атаки (%s)" % [h, kind])
if i > 0 and expected[h - 1] >= 2:
_ok(kind != prev, "H%d: вид не повторяется дважды подряд" % h)
prev = kind
for i in range(expected.size()):
_eq(AD.attacks_for_hour(i + 1), expected[i], "атак в часе %d" % (i + 1))
# Данные difficulty.attacks должны совпадать с расписанием (час 1 — 0, атаки с часа 2).
var data = root.get_node_or_null("Data")
if data != null:
for h in range(1, 10):
var row: Dictionary = data.difficulty_for(h)
if row.has("attacks"):
_eq(int(row["attacks"]), expected[h - 1], "balance difficulty.attacks для часа %d" % h)
func _test_tier_by_hour() -> void:
_set_case("tier_by_hour")
_eq(ECONOMY.attack_tier(1), 1, "H1 → T1")
_eq(ECONOMY.attack_tier(2), 1, "H2 → T1")
_eq(ECONOMY.attack_tier(3), 1, "H3 → T1")
_eq(ECONOMY.attack_tier(4), 2, "H4 → T2")
_eq(ECONOMY.attack_tier(5), 2, "H5 → T2")
_eq(ECONOMY.attack_tier(6), 3, "H6 → T3")
_eq(ECONOMY.attack_tier(8), 4, "H8 → T4")
_eq(ECONOMY.attack_tier(9), 4, "H9 → T4")
for pair in [[2, 1], [4, 2], [6, 3], [8, 4]]:
var h := int(pair[0])
var want := int(pair[1])
_fresh(h)
var ad = _hd.attack_director
var captured: Dictionary = {}
var fake := FakeGame.new()
ad.create_game_override = func(_kind, tier, _seed, _ctx) -> Node:
captured["tier"] = tier
return fake
ad.spawn_attack("junk")
_eq(int(captured.get("tier", 0)), want, "H%d: мини-игра открыта со ступенью T%d" % [h, want])
fake.emit_signal("finished", 1.0)
func _test_attack_unlocks() -> void:
_set_case("attack_unlocks")
# п.16: attack_junk/ddos — с часа 2, phishing — с 3, ransomware — с 4.
_reset(900, 1)
var ad = _hd.attack_director
_ok(str(ad.spawn_attack("junk")).is_empty(), "час 1: junk закрыт")
_eq(ad._unlocked_kinds().size(), 0, "час 1: открытых атак нет")
_reset(901, 2)
ad = _hd.attack_director
_ok(not str(ad.spawn_attack("junk")).is_empty(), "час 2: junk открыт")
_ok(not str(ad.spawn_attack("ddos")).is_empty(), "час 2: ddos открыт")
_ok(str(ad.spawn_attack("phishing")).is_empty(), "час 2: phishing закрыт")
_reset(902, 3)
ad = _hd.attack_director
_ok(not str(ad.spawn_attack("phishing")).is_empty(), "час 3: phishing открыт")
_ok(str(ad.spawn_attack("ransomware")).is_empty(), "час 3: ransomware закрыт")
_reset(903, 4)
ad = _hd.attack_director
_ok(not str(ad.spawn_attack("ransomware")).is_empty(), "час 4: ransomware открыт")
func _test_schedule_window() -> void:
func _test_spawn_window() -> void:
_set_case("spawn_window")
_fresh(2)
var ad = _hd.attack_director
var fake := FakeGame.new()
ad.create_game_override = func(_kind, _tier, _seed, _ctx) -> Node:
return fake
var auto_finish := func(_game_node, _kind) -> void:
fake.emit_signal("finished", 1.0)
_hd.show_attack_minigame.connect(auto_finish)
ad._schedule.clear()
ad._schedule.append(100.0)
ad._schedule_kinds.clear()
ad._schedule_kinds.append("junk")
ad._next_index = 0
ad.elapsed = 99.8
_attack_shows = 0
ad.tick(0.1)
_eq(_attack_shows, 0, "до 100 с атака не спавнится")
ad.tick(0.2)
_eq(_attack_shows, 1, "после 100 с атака спавнится")
_ok(ad.elapsed >= 99.999, "время атаки достигнуто")
_ok(not _hd.is_attack_paused(), "после завершения мини-игры пауза снята")
_hd.show_attack_minigame.disconnect(auto_finish)
fake.queue_free()
for h in range(1, 10):
_game.start_new_run(1000 + h)
# feature_unlocked смотрит на Game.hour — синхронизируем с часом теста.
_game.hour = h
_hd.start_hour(h)
_hd.set_time_scale(1.0)
_attack_marks.clear()
var guard := 0
while _hd.running and guard < 4000:
_hd.tick(0.1)
guard += 1
_ok(not _hd.running, "час %d завершился" % h)
# Час 1: расписание есть, но ни одна атака ещё не открыта.
var expected: int = 0 if h == 1 else AD.attacks_for_hour(h)
_eq(_attack_marks.size(), expected, "час %d: число заспавненных атак (с учётом unlock)" % h)
for mark in _attack_marks:
var t: float = float(mark.get("t", -1.0))
_ok(t >= 59.5 and t <= 240.5, "час %d: окно спавна 60–240 (t=%.1f)" % [h, t])
# Атака не спавнится, пока идёт мини-игра отправки.
_fresh(2)
ad = _hd.attack_director
_hd._minigame = Node.new()
_attack_shows = 0
ad._schedule.clear()
ad._schedule.append(0.0)
ad._schedule_kinds.clear()
ad._schedule_kinds.append("junk")
func _test_no_spawn_in_minigame() -> void:
_set_case("no_spawn_in_minigame")
_reset(555, 2)
var ad = _hd.attack_director
_ok(ad != null, "attack director есть")
if ad == null:
return
# Фиксируем момент спавна и подводим часы к нему вручную.
ad._schedule[0] = 100.0
ad._next_index = 0
ad.elapsed = 0.0
ad.elapsed = 99.95
var mg := Node.new()
_hd._minigame = mg
_attack_marks.clear()
_hd.tick(0.1)
_eq(_attack_shows, 0, "при открытой мини-игре отправки атака не спавнится")
_hd._minigame.queue_free()
_eq(_attack_marks.size(), 0, "во время мини-игры спавна нет")
_ok(not ad._allow_spawn, "allow_spawn=false во время мини-игры")
_hd._minigame = null
# --- H6: пауза до emit -----------------------------------------------------
func _test_pause_before_emit_race() -> void:
_set_case("pause_before_emit")
_fresh(2)
var ad = _hd.attack_director
var fake := FakeGame.new()
ad.create_game_override = func(_kind, _tier, _seed, _ctx) -> Node:
return fake
var race: Dictionary = {"paused_at_show": false, "public_paused": false}
var instant_finish := func(_game_node, _kind) -> void:
race["paused_at_show"] = _hd.is_attack_paused()
race["public_paused"] = _hd.paused
fake.emit_signal("finished", 1.0)
_hd.show_attack_minigame.connect(instant_finish)
var money_before: int = _game.money
_resolved_events.clear()
_hour_resolved.clear()
var attack_id := str(ad.spawn_attack("junk"))
_ok(not attack_id.is_empty(), "атака заспавнена")
_ok(bool(race.get("paused_at_show", false)), "H6: час на паузе ДО show_attack_minigame")
_ok(bool(race.get("public_paused", false)), "H6: публичный paused=true до показа окна")
_ok(not _hd.is_attack_paused(), "H6: мгновенный finished снял паузу")
_ok(not _hd.paused, "H6: публичный paused снят после finished")
_eq(ad.active_attacks.size(), 0, "атака закрыта")
_ok(_game.money > money_before, "мгновенный успех начислил награду")
_ok(_resolved_events.size() >= 1, "resolved эмитится")
_ok(_hour_resolved.size() >= 1, "hour.attack_resolved эмитится")
if not _hour_resolved.is_empty():
_eq(str(_hour_resolved[_hour_resolved.size() - 1].get("kind", "")), "junk", "attack_resolved: kind")
_eq(bool(_hour_resolved[_hour_resolved.size() - 1].get("success", false)), true, "attack_resolved: success")
_eq(int(_hour_resolved[_hour_resolved.size() - 1].get("tier", 0)), 1, "attack_resolved: tier")
_hd.show_attack_minigame.disconnect(instant_finish)
# Пока мини-игра открыта, время часа не тикает.
_fresh(2)
ad = _hd.attack_director
fake = FakeGame.new()
ad.create_game_override = func(_kind, _tier, _seed, _ctx) -> Node:
return fake
var time_before: float = _hd.time_left
ad.spawn_attack("ddos")
_ok(_hd.is_attack_paused(), "атака поставила час на паузу")
for i in range(10):
_hd.tick(0.1)
_approx(_hd.time_left, time_before, 0.0001, "во время атаки час стоит")
fake.emit_signal("finished", 0.0)
_ok(not _hd.is_attack_paused(), "после провала пауза снята")
mg.free()
_hd.tick(0.1)
_ok(_hd.time_left < time_before, "после атаки час снова идёт")
_eq(_attack_marks.size(), 1, "после закрытия мини-игры атака спавнится")
# --- щит -------------------------------------------------------------------
# --- мусор -----------------------------------------------------------------
func _test_shield_auto_block() -> void:
_set_case("shield")
_game.start_new_run(7001)
_game.hour = 2
_game.phase = &"hour"
_game.money = 500
_game.sla = 150.0
_game.upgrades["sla_level"] = 4
_game.upgrades["shield_level"] = 1
_game.regen_shield_for_hour()
_hd.reset_carry()
_hd.start_hour(2)
_game.phase = &"hour"
_game.money = 500
_game.sla = 150.0
func _test_junk() -> void:
_set_case("junk")
_reset(201, 2)
var ad = _hd.attack_director
var folder_id := _first_folder()
var money_before: int = _game.money
var sla_before: float = _game.sla
var id := str(ad.spawn_attack("junk"))
_ok(not id.is_empty(), "мусор заспавнен")
var junk_packet_id := str(ad.active_attacks[id].get("packet_id", ""))
_ok(not junk_packet_id.is_empty(), "мусорный пакет создан")
_ok(_hd.active_packets.has(junk_packet_id), "мусор занимает буфер приёма")
_eq(str(_hd.active_packets[junk_packet_id].attack_kind), "junk", "тип пакета — junk")
_ok(not _hd.receive_packet(junk_packet_id, folder_id), "мусор нельзя занести в папку")
_ok(ad.quarantine(id), "карантин мусора")
_eq(_game.money, money_before + 5, "мусор в карантин: +5 $")
_approx(_game.sla, sla_before + 1.0, 0.001, "мусор в карантин: +1 SLA")
_ok(not ad.active_attacks.has(id), "карточка мусора убрана")
_ok(not _hd.active_packets.has(junk_packet_id), "пакет мусора убран из приёма")
# Игнор 15 с → авто-занос (malicious/fake) и штраф.
_reset(202, 2)
ad = _hd.attack_director
money_before = _game.money
sla_before = _game.sla
id = str(ad.spawn_attack("junk"))
junk_packet_id = str(ad.active_attacks[id].get("packet_id", ""))
ad.tick(15.1)
_ok(not ad.active_attacks.has(id), "мусор исчез после 15 с")
_ok(not _hd.active_packets.has(junk_packet_id), "пакет мусора ушёл из приёма")
_eq(_game.money, money_before - 10, "мусор попал в приём: −10 $")
_approx(_game.sla, sla_before - 5.0, 0.001, "мусор попал в приём: −5 SLA")
var fake = _find_file_by_kind("junk")
_ok(fake != null, "авто-занос создал malicious/fake файл")
if fake != null:
_eq(str(fake.location), "malicious", "fake-файл помечен malicious")
_eq(str(fake.malicious_kind), "junk", "malicious_kind = junk")
_eq(str(fake.type), "", "у fake-файла нет типа")
_eq(str(fake.ext), "", "у fake-файла нет расширения")
# Приём/отправка временно отключены (filter_disabled_sec = 10).
_ok(_hd.recv_disabled_until > _hd.hour_elapsed, "после провала мусора приём отключён")
# --- DDoS ------------------------------------------------------------------
func _test_ddos() -> void:
_set_case("ddos")
_reset(301, 2)
var ad = _hd.attack_director
ad.resolved.connect(_on_resolved)
_eq(int(ad.shield_charges), 1, "1 заряд щита из Game")
_toasts.clear()
_attack_shows = 0
_resolved_events.clear()
var row := _tier_row("ddos", ECONOMY.attack_tier(2))
var expected := int(round(float(row.get("reward_money", 0)) * _money_scale(2) * 0.5))
var money_before: int = _game.money
var sla_before: float = _game.sla
var id := str(ad.spawn_attack("ddos"))
_ok(not id.is_empty(), "атака обработана щитом")
_eq(ad.active_attacks.size(), 0, "мини-игра не открывалась")
_eq(_attack_shows, 0, "show_attack_minigame не эмитится при щите")
_ok(not _hd.is_attack_paused(), "щит не ставит час на паузу")
_eq(int(ad.shield_charges), 0, "заряд израсходован")
_eq(int(_game.shield_charges), 0, "Game.shield_charges синхронизирован")
_eq(_game.money, money_before + expected, "50% награды начислено")
_approx(_game.sla, sla_before + float(row.get("reward_sla", 0)) * 0.5, 0.001,
"50% SLA-награды")
_ok(_has_toast("ЩИТ ПОГЛОТИЛ АТАКУ"), "тост «ЩИТ ПОГЛОТИЛ АТАКУ»")
_ok(_resolved_events.size() >= 1, "resolved(true) при щите")
if not _resolved_events.is_empty():
_ok(bool(_resolved_events[0].get("success", false)), "щит: success = true")
_ok(not id.is_empty(), "DDoS заспавнен")
var cards := int(ad.active_attacks[id].get("cards", 0))
_ok(cards >= 8 and cards <= 12, "DDoS: 8–12 карточек (получено %d)" % cards)
_ok(ad.hold_shield(0.8), "щит: первое удержание")
_ok(ad.hold_shield(0.8), "щит: второе удержание (1.6 с)")
_ok(not ad.active_attacks.has(id), "DDoS отражён за 1.5 с")
_eq(_game.money, money_before + 30, "DDoS отражён: +30 $")
_approx(_game.sla, sla_before + 2.0, 0.001, "DDoS отражён: +2 SLA")
_eq(int(_hd._attacks_blocked), 1, "DDoS учтён как отражённая атака")
# --- награды/штрафы -------------------------------------------------------
func _test_rewards_and_fines() -> void:
_set_case("reward_junk")
_fresh(2)
var ad = _hd.attack_director
var fake := FakeGame.new()
ad.create_game_override = func(_kind, _tier, _seed, _ctx) -> Node:
return fake
var row := _tier_row("junk", ECONOMY.attack_tier(2))
var money_before: int = _game.money
var sla_before: float = _game.sla
ad.spawn_attack("junk")
fake.emit_signal("finished", 1.0)
_eq(_game.money, money_before + int(round(float(row.get("reward_money", 0)) * _money_scale(2))),
"junk успех: деньги")
_approx(_game.sla, sla_before + float(row.get("reward_sla", 0)), 0.001, "junk успех: SLA")
_set_case("fail_ddos")
_fresh(4)
# Провал: 2 пакета теряются, канал −40% на 15 с, приём off 5 с.
_reset(302, 2)
ad = _hd.attack_director
fake = FakeGame.new()
ad.create_game_override = func(_kind, _tier, _seed, _ctx) -> Node:
return fake
row = _tier_row("ddos", ECONOMY.attack_tier(4))
money_before = _game.money
sla_before = _game.sla
ad.spawn_attack("ddos")
fake.emit_signal("finished", 0.0)
_eq(_game.money, money_before + int(round(float(row.get("fail_money", 0)) * _money_scale(4))),
"DDoS провал: деньги")
_approx(_game.sla, sla_before + float(row.get("fail_sla", 0)), 0.001, "DDoS провал: SLA")
_ok(_hd.channel_debuff_until > _hd.hour_elapsed, "DDoS провал: дебафф канала 15 с")
_set_case("fail_phishing")
_fresh(4)
ad = _hd.attack_director
fake = FakeGame.new()
ad.create_game_override = func(_kind, _tier, _seed, _ctx) -> Node:
return fake
var file = _new_file("Фишинг-цель", "text")
row = _tier_row("phishing", ECONOMY.attack_tier(4))
ad.spawn_attack("phishing")
fake.emit_signal("finished", 0.0)
_ok(_hd.send_disabled_until > _hd.hour_elapsed, "Фишинг провал: отправка -5 с")
_ok(file != null and str(file.location) == "locked", "Фишинг провал: файл заблокирован на 60 с")
_set_case("fail_ransomware")
_fresh(5)
ad = _hd.attack_director
fake = FakeGame.new()
ad.create_game_override = func(_kind, _tier, _seed, _ctx) -> Node:
return fake
ad.spawn_attack("ransomware")
fake.emit_signal("finished", 0.0)
_ok(not _hd.blocked_folders.is_empty(), "Шифровальщик провал: папка заблокирована на 15 с")
_set_case("threshold")
_fresh(2)
ad = _hd.attack_director
fake = FakeGame.new()
ad.create_game_override = func(_kind, _tier, _seed, _ctx) -> Node:
return fake
ad.spawn_attack("junk")
fake.emit_signal("finished", 0.59)
_ok(not _resolved_events.is_empty(), "порог: resolved есть")
if not _resolved_events.is_empty():
_eq(bool(_resolved_events[_resolved_events.size() - 1].get("success", true)), false,
"порог T1 junk 0.60: q=0.59 — провал")
var packets: Array[String] = []
for i in range(3):
packets.append(str(_hd.spawn_packet_now("text", "tcp")))
_eq(_hd.active_packets.size(), 3, "DDoS: три обычных пакета в буфере")
var factor_before: float = _hd.channel_factor()
id = str(ad.spawn_attack("ddos"))
ad.tick(10.1)
_ok(not ad.active_attacks.has(id), "DDoS провален по таймауту")
_eq(_hd.active_packets.size(), 1, "DDoS: потеряно ровно 2 пакета")
_eq(int(_hd._failed), 2, "DDoS: потери учтены")
# −15 за DDoS + 2 потерянных text-пакета × 2 SLA (п.13).
_approx(_game.sla, sla_before - 19.0, 0.001, "DDoS: −15 SLA и −2×2 за потерю пакетов")
_ok(_hd.recv_disabled_until - _hd.hour_elapsed >= 4.99, "DDoS: приём off 5 с")
_ok(_hd.channel_debuff_until - _hd.hour_elapsed >= 14.99, "DDoS: дебафф канала 15 с")
var factor_after: float = _hd.channel_factor()
_approx(factor_after, factor_before * 1.4, 0.0001, "DDoS: канал −40% (factor ×1.4)")
func _test_null_factory() -> void:
_set_case("null_factory")
_fresh(2)
# --- апгрейд «Щит» ---------------------------------------------------------
func _test_shield_upgrade() -> void:
_set_case("shield_upgrade")
_reset(601, 2)
var ad = _hd.attack_director
ad.create_game_override = func(_kind, _tier, _seed, _ctx) -> Node:
return null
_toasts.clear()
_resolved_events.clear()
_game.upgrades["shield_unlocked"] = 1
var money_before: int = _game.money
var id := str(ad.spawn_attack("junk"))
_ok(not id.is_empty(), "null-фабрика: id возвращён")
_eq(ad.active_attacks.size(), 0, "null-фабрика: активной атаки нет")
_ok(not _hd.is_attack_paused(), "null-фабрика: пауза снята")
_ok(_game.money > money_before, "null-фабрика: автоуспех с наградой")
_ok(_has_toast("АВТООТРАЖЕНА"), "null-фабрика: тост об автоуспехе")
_ok(_resolved_events.size() >= 1, "null-фабрика: resolved(true)")
if not _resolved_events.is_empty():
_ok(bool(_resolved_events[0].get("success", false)), "null-фабрика: success = true")
var sla_before: float = _game.sla
var id := str(ad.spawn_attack("ddos"))
_ok(not id.is_empty(), "щит: спавн DDoS обработан")
_ok(not ad.active_attacks.has(id), "щит: первый DDoS часа отражён автоматически")
_eq(_game.money, money_before + 30, "щит: автоотражение +30 $")
_approx(_game.sla, sla_before + 2.0, 0.001, "щит: автоотражение +2 SLA")
_eq(int(ad.shield_used_this_hour), 1, "щит: израсходован 1 раз за час")
_eq(int(_hd._attacks_blocked), 1, "щит: атака учтена как отражённая")
_ok(not _hd.recv_disabled_until > _hd.hour_elapsed, "щит: урона по приёму нет")
# Второй DDoS в том же часу автоотражением не покрывается.
money_before = _game.money
sla_before = _game.sla
id = str(ad.spawn_attack("ddos"))
_ok(ad.active_attacks.has(id), "щит: второй DDoS в часе не отражён автоматически")
var cards := int(ad.active_attacks.get(id, {}).get("cards", 0))
_ok(cards >= 8 and cards <= 12, "щит: второй DDoS — обычная волна карточек")
_ok(ad.hold_shield(0.8), "щит: второй DDoS — первое удержание")
_ok(ad.hold_shield(0.8), "щит: второй DDoS — второе удержание")
_ok(not ad.active_attacks.has(id), "щит: второй DDoS отражён вручную")
_eq(_game.money, money_before + 30, "щит: ручное отражение +30 $")
_approx(_game.sla, sla_before + 2.0, 0.001, "щит: ручное отражение +2 SLA")
# Счётчик сбрасывается в start_hour.
_hd.start_hour(3)
_eq(int(_hd.attack_director.shield_used_this_hour), 0, "щит: счётчик сброшен в start_hour")
# Без апгрейда автоматического отражения нет.
ad = _hd.attack_director
_game.upgrades.erase("shield_unlocked")
money_before = _game.money
sla_before = _game.sla
id = str(ad.spawn_attack("ddos"))
_ok(ad.active_attacks.has(id), "без щита DDoS требует ручного отражения")
_approx(_game.sla, sla_before, 0.001, "без щита урона ещё нет")
_ok(ad.hold_shield(1.6), "без щита ручное удержание работает")
_ok(not ad.active_attacks.has(id), "без щита DDoS отражён вручную")
_eq(_game.money, money_before + 30, "без щита ручное отражение +30 $")
func _test_no_old_entities() -> void:
_set_case("no_old_entities")
_fresh(5)
# --- фишинг ----------------------------------------------------------------
func _test_phishing() -> void:
_set_case("phishing")
# Отказ бесплатный (и даёт награду за отражение).
_reset(401, 3)
var ad = _hd.attack_director
var packets_before: int = _hd.active_packets.size()
var requests_before: int = _hd.active_requests.size()
for kind in AD.KINDS:
var fake := FakeGame.new()
ad.create_game_override = func(_kind, _tier, _seed, _ctx) -> Node:
return fake
var id := str(ad.spawn_attack(kind))
_ok(not id.is_empty(), "атака %s открыла мини-игру" % kind)
_ok(ad.active_attacks.has(id), "атака %s в активных до finished" % kind)
fake.emit_signal("finished", 1.0)
_ok(not ad.active_attacks.has(id), "атака %s закрыта после finished" % kind)
_eq(_hd.active_packets.size(), packets_before, "атаки не создают пакеты")
_eq(_hd.active_requests.size(), requests_before, "атаки не создают запросы")
for packet in _hd.active_packets.values():
_ok(not bool(packet.is_attack), "нет пакетов-атак")
for req in _hd.active_requests.values():
_ok(not bool(req.is_attack), "нет фишинговых запросов")
var file = _new_file("phish", "text")
_ok(file != null, "фишинг: файл для блокировки")
var money_before: int = _game.money
var sla_before: float = _game.sla
var id := str(ad.spawn_attack("phishing"))
_ok(not id.is_empty(), "фишинг заспавнен")
var req_id := str(ad.active_attacks[id].get("request_id", ""))
_ok(not req_id.is_empty(), "фишинг создал фейковый запрос")
_ok(_hd.active_requests.has(req_id), "фейковый запрос в директоре")
_ok(_hd.reject_request(req_id), "фишинг отклонён")
_eq(_game.money, money_before + 20, "отказ от фишинга: +20 $ (без штрафа)")
_approx(_game.sla, sla_before + 2.0, 0.001, "отказ от фишинга: +2 SLA")
_ok(not ad.active_attacks.has(id), "атака фишинга закрыта после отказа")
_ok(not _hd.active_requests.has(req_id), "фейковый запрос убран")
# Отправка фишинга: −40 $, −10 SLA, lock файла на 60 с.
_reset(402, 3)
ad = _hd.attack_director
file = _new_file("phish_send", "text")
money_before = _game.money
sla_before = _game.sla
id = str(ad.spawn_attack("phishing"))
req_id = str(ad.active_attacks[id].get("request_id", ""))
ad.resolve_phishing(req_id, "sent")
_eq(_game.money, money_before - 40, "фишинг отправлен: −40 $")
_approx(_game.sla, sla_before - 10.0, 0.001, "фишинг отправлен: −10 SLA")
_ok(not ad.active_attacks.has(id), "атака фишинга закрыта после отправки")
_ok(_hd.send_disabled_until - _hd.hour_elapsed >= 4.99, "фишинг: отправка off 5 с")
_check_lock(file, 60.0, "фишинг: lock 60 с")
# Таймаут: −20 $, −5 SLA, lock файла на 30 с.
_reset(403, 3)
ad = _hd.attack_director
file = _new_file("phish_timeout", "text")
money_before = _game.money
sla_before = _game.sla
id = str(ad.spawn_attack("phishing"))
req_id = str(ad.active_attacks[id].get("request_id", ""))
ad.resolve_phishing(req_id, "timeout")
_eq(_game.money, money_before - 20, "фишинг-таймаут: −20 $")
_approx(_game.sla, sla_before - 5.0, 0.001, "фишинг-таймаут: −5 SLA")
_ok(not ad.active_attacks.has(id), "атака фишинга закрыта после таймаута")
_check_lock(file, 30.0, "фишинг-таймаут: lock 30 с")
# --- шифровальщик ----------------------------------------------------------
func _test_ransomware() -> void:
_set_case("ransomware")
# Уничтожение .exe до заноса: +25 $, +2 SLA.
_reset(501, 4)
var file = null
var ad = _hd.attack_director
var money_before: int = _game.money
var sla_before: float = _game.sla
var id := str(ad.spawn_attack("ransomware"))
_ok(not id.is_empty(), "шифровальщик заспавнен")
var packet_id := str(ad.active_attacks[id].get("packet_id", ""))
_ok(_hd.active_packets.has(packet_id), "пакет .exe в приёме")
_ok(ad.quarantine(id), "пакет-шифровальщик уничтожен до заноса")
_eq(_game.money, money_before + 25, "шифровальщик уничтожен: +25 $")
_approx(_game.sla, sla_before + 2.0, 0.001, "шифровальщик уничтожен: +2 SLA")
_ok(not _hd.active_packets.has(packet_id), "пакет удалён")
_ok(not ad.active_attacks.has(id), "атака закрыта")
# Занос в папку: файл становится malicious, через 3 с должен быть locked.
_reset(502, 4)
ad = _hd.attack_director
file = null
id = str(ad.spawn_attack("ransomware"))
packet_id = str(ad.active_attacks[id].get("packet_id", ""))
var folder_id := _first_folder()
_ok(_hd.receive_packet(packet_id, folder_id), "шифровальщик принят в папку")
for i in range(100):
_hd.tick(0.1)
if not _hd.active_packets.has(packet_id):
break
_ok(not _hd.active_packets.has(packet_id), "пакет .exe загружен в папку")
file = _find_file_by_name("счёт")
_ok(file != null, "файл шифровальщика создан в реестре")
if file == null:
return
_ok(str(file.ext) == "exe", "расширение .exe")
_ok(str(file.location) == "malicious", "файл помечен malicious")
_ok(str(file.malicious_kind) == "ransomware", "malicious_kind = ransomware")
# D08: через folder_block_delay_sec (3 с) файл — locked, папка — blocked.
# Прокручиваем время, а не проверяем locked сразу после загрузки.
ad.tick(3.1)
_eq(str(file.location), "locked", "через 3.1 с файл locked")
_ok(_hd.is_folder_locked(folder_id), "через 3.1 с папка заблокирована")
_ok(not _game.registry.attach_file(str(file.id), "probe_req"),
"заблокированный шифровальщик недоступен для attach")
var probe_packet := str(_hd.spawn_packet_now("text", "tcp"))
_ok(not _hd.receive_packet(probe_packet, folder_id), "в заблокированную папку приём отклонён")
_hd._remove_packet(probe_packet)
money_before = _game.money
sla_before = _game.sla
_ok(_hd.destroy_attack_file(str(file.id)), "шифровальщик уничтожен (locked → malicious → remove)")
_ok(not _hd.is_folder_locked(folder_id), "после уничтожения папка разблокирована")
_eq(_game.money, money_before + 25, "уничтожение шифровальщика: +25 $")
_approx(_game.sla, sla_before + 2.0, 0.001, "уничтожение шифровальщика: +2 SLA")
_ok(not ad.active_attacks.has(id), "атака закрыта после уничтожения файла")
# Авто-unlock через 30 с: −30 $, −10 SLA, файл пропадает.
_reset(503, 4)
ad = _hd.attack_director
id = str(ad.spawn_attack("ransomware"))
packet_id = str(ad.active_attacks[id].get("packet_id", ""))
_ok(_hd.receive_packet(packet_id, folder_id), "шифровальщик принят в папку (авто-unlock)")
for i in range(100):
_hd.tick(0.1)
if not _hd.active_packets.has(packet_id):
break
file = _find_file_by_name("счёт")
_ok(file != null, "файл шифровальщика создан")
if file == null:
return
var file_id := str(file.id)
money_before = _game.money
sla_before = _game.sla
ad.tick(30.5)
_eq(_game.money, money_before - 30, "авто-unlock: −30 $")
_approx(_game.sla, sla_before - 10.0, 0.001, "авто-unlock: −10 SLA")
_ok(not _game.registry.files.has(file_id), "авто-unlock: файл потерян")
_ok(not _hd.is_folder_locked(folder_id), "авто-unlock: папка разблокирована")
_ok(not ad.active_attacks.has(id), "авто-unlock: атака закрыта")
# --- служебное -------------------------------------------------------------
func _fresh(hour: int) -> void:
_game.start_new_run(5000 + int(hour) * 31)
_game.hour = int(hour)
_game.phase = &"hour"
func _reset(seed: int, hour: int) -> void:
_game.start_new_run(seed)
_game.hour = hour
_hd.start_hour(hour)
_hd.set_time_scale(1.0)
_hd._recv_interval_current = 9999.0
_hd.spawn_recv_accum = 0.0
_game.money = 500
_game.sla = 150.0
_game.upgrades["sla_level"] = 4
_game.shield_charges = 0
_hd.reset_carry()
_hd.start_hour(int(hour))
_hd.set_time_scale(1.0)
_hd.files_spawned = _hd.files_budget
_hd.requests_spawned = _hd.request_cap
_hd.spawn_recv_accum = 0.0
_hd.spawn_send_accum = 0.0
_toasts.clear()
_resolved_events.clear()
_hour_resolved.clear()
_attack_shows = 0
var ad = _hd.attack_director
if ad != null:
ad.resolved.connect(_on_resolved)
func _tier_row(kind: String, tier: int) -> Dictionary:
if _data != null and _data.has_method("attack_def"):
var def: Dictionary = _data.attack_def(kind)
var tiers = def.get("tiers", null)
if tiers is Array:
var arr: Array = tiers
var idx := clampi(int(tier), 1, arr.size()) - 1
if arr[idx] is Dictionary:
return (arr[idx] as Dictionary).duplicate(true)
return {}
func _money_scale(hour: int) -> float:
var scale := maxf(0.75, float(hour) / 3.0)
if _data != null and _data.get("balance") is Dictionary:
var attacks = (_data.balance as Dictionary).get("attacks", null)
if attacks is Dictionary:
scale *= float((attacks as Dictionary).get("money_scale", 1.0))
return scale
func _new_file(name: String, type: String):
func _first_folder() -> String:
var section = _game.registry.sections.get("C")
if section == null or section.folder_ids.is_empty():
return null
return _game.registry.create_file(name, type, str(section.folder_ids[0]))
return ""
return str(section.folder_ids[0])
func _has_toast(fragment: String) -> bool:
for text in _toasts:
if str(text).contains(fragment):
return true
return false
func _new_file(base: String, type: String):
_seq += 1
return _game.registry.create_file("%s_%d" % [base, _seq], type, _first_folder())
func _on_toast(text: String) -> void:
_toasts.append(text)
func _find_file_by_name(name: String):
for file in _game.registry.files.values():
if str(file.name) == name:
return file
return null
func _on_attack_show(_game_node, _kind: String) -> void:
_attack_shows += 1
func _count_location(loc: String) -> int:
var n := 0
for file in _game.registry.files.values():
if str(file.location) == loc:
n += 1
return n
func _on_attack_resolved(kind: String, success: bool, tier: int) -> void:
_hour_resolved.append({"kind": kind, "success": success, "tier": tier})
func _find_file_by_kind(kind: String):
for file in _game.registry.files.values():
if str(file.malicious_kind) == kind:
return file
return null
func _on_resolved(id: String, success: bool) -> void:
_resolved_events.append({"id": id, "success": success})
func _check_lock(file, seconds: float, message: String) -> void:
if file == null:
_fail(message + ": файла нет")
return
_ok(str(file.location) == "locked", message + ": location locked")
var remaining: float = float(file.lock_until) - Time.get_unix_time_from_system()
_approx(remaining, seconds, 2.0, message + ": срок lock")
# --- assert-хелперы --------------------------------------------------------
func _on_attack_added(_id: String) -> void:
_attack_marks.append({"t": float(_hd.hour_elapsed), "hour": int(_hd.hour)})
func _set_case(name: String) -> void:
_case = name
@@ -456,7 +479,7 @@ func _approx(actual: float, expected: float, eps: float, message: String) -> boo
_checks += 1
if absf(float(actual) - float(expected)) <= eps:
return true
_fail("%s — получено %.6f, ожидалось %.6f" % [message, float(actual), float(expected)])
_fail("%s — получено %.6f, ожидалось %.6f" % [message, actual, expected])
return false
@@ -466,8 +489,8 @@ func _fail(message: String) -> void:
func _finish() -> void:
if _hd != null and is_instance_valid(_hd):
if _hd != null:
_hd.free()
print("test_attacks: проверок %d, провалов %d" % [_checks, _fails])
print("test_attacks: проверок %d, провалов %d, known bug %d" % [_checks, _fails, _known])
print("PASS test_attacks" if _fails == 0 else "FAIL test_attacks")
quit(0 if _fails == 0 else 1)
+143 -183
View File
@@ -1,10 +1,7 @@
extends SceneTree
## Headless-тест GameController v2: подготовка/час/итоги, авто-возврат стола,
## блок старта часа (M4/M18), магазин 3 офферов после H1, сброс рероллов,
## полный забег на 9 часов и save/load.
##
## Запуск:
## godot --headless --path . --script tests/test_controller.gd
## Headless-тест GameController: полный цикл prep → hour → hour_result → prep,
## магазин, 9 часов до run_result, save/load и continue_run.
## Запуск: godot --headless --path . --script tests/test_controller.gd
const GC := preload("res://scripts/game_controller.gd")
const SM := preload("res://scripts/systems/save_system.gd")
@@ -15,7 +12,6 @@ var _case: String = ""
var _game = null
var _c = null
var _name_seq: int = 0
var _hour_events: int = 0
var _run_events: int = 0
@@ -23,8 +19,6 @@ var _shop_events: int = 0
var _phase_events: int = 0
var _last_report: Dictionary = {}
var _last_run: Dictionary = {}
var _phases: Array = []
var _toasts: Array = []
var _had_save: bool = false
var _backup: String = ""
@@ -41,24 +35,21 @@ func _init() -> void:
_finish()
return
_c = GC.new()
_c.name = "GameControllerTest"
_c.name = "GameController"
root.add_child(_c)
await process_frame
_c.hour_finished.connect(_on_hour_finished)
_c.run_finished.connect(_on_run_finished)
_c.shop_changed.connect(_on_shop_changed)
_c.phase_changed.connect(_on_phase_changed)
_c.toast.connect(_on_toast)
_test_empty_continue()
_test_new_run()
_test_table_return_success()
_test_table_return_block()
_test_hour_start_and_modal()
_test_fast_hour_and_report()
_test_after_hour_and_shop()
_test_auto_send()
_test_fast_hour()
_test_after_hour()
_test_buy()
_test_reroll_reset()
_test_full_run()
_test_save_load()
_finish()
@@ -77,63 +68,37 @@ func _test_new_run() -> void:
_set_case("new_run")
_c.new_run(1)
_eq(String(_c.phase), "prep", "new_run → prep")
_eq(int(_game.money), 50, "стартовые деньги 50 $")
_approx(float(_game.sla), 60.0, 0.0001, "стартовый SLA 60")
_eq(int(_game.money), 150, "стартовые деньги")
_approx(float(_game.sla), 128.0, 0.0001, "стартовый SLA")
_eq(int(_game.hour), 1, "час 1")
_eq(String(_game.phase), "prep", "Game.phase = prep")
_ok(_c.has_save(), "автосейв на входе в prep")
_eq(_game.shop_offers.size(), 0, "H1: магазина/офферов нет")
_eq((_c.shop_data() as Array).size(), 0, "H1: shop_data пуст")
var prep: Dictionary = _c.prep_data()
_eq(int(prep.get("money", 0)), 50, "prep_data.money")
_approx(float(prep.get("sla", 0.0)), 60.0, 0.0001, "prep_data.sla")
_eq(int(prep.get("money", 0)), 150, "prep_data.money")
_approx(float(prep.get("sla", 0.0)), 128.0, 0.0001, "prep_data.sla")
_eq(int(prep.get("hour", 0)), 1, "prep_data.hour")
_ok(prep.get("upgrades") is Dictionary, "prep_data.upgrades")
var sections = prep.get("sections", [])
_ok(sections is Array and not (sections as Array).is_empty(), "есть секции")
func _test_table_return_success() -> void:
_set_case("table_return_ok")
_c.new_run(11)
var folder_id := _first_folder_id()
var file = _game.registry.create_file("Стол-ок", "text", folder_id)
_ok(file != null, "файл создан")
if file == null:
var sec_c: Dictionary = _find_section(sections, "C")
_ok(not sec_c.is_empty(), "есть секция C")
if sec_c.is_empty():
return
_ok(_game.registry.move_file_to_table(str(file.id)), "файл убран на стол")
_ok(_c.start_hour(), "старт часа разрешён")
_eq(String(_c.phase), "hour", "фаза hour")
_eq(str(file.location), "folder", "файл автоматически вернулся в папку")
_eq(_game.registry.table_file_count(), 0, "стол пуст")
func _test_table_return_block() -> void:
_set_case("table_return_block")
_c.new_run(12)
var folder_id := _first_folder_id()
var file = _game.registry.create_file("Стол-блок", "text", folder_id)
_ok(file != null, "файл создан")
if file == null:
return
_ok(_game.registry.move_file_to_table(str(file.id)), "файл убран на стол")
# Забиваем все папки до отказа.
for raw_folder_id in _game.registry.folders.keys():
_fill_folder(str(raw_folder_id))
_eq(_game.registry.table_file_count(), 1, "на столе остался 1 файл")
_toasts.clear()
var started: bool = bool(_c.start_hour())
_ok(not started, "старт часа заблокирован (M4/M18)")
_eq(String(_c.phase), "prep", "фаза осталась prep")
_eq(String(_game.phase), "prep", "Game.phase остался prep")
_eq(int(_game.hour), 1, "час не сдвинулся")
_ok(_has_toast("На столе остались файлы"), "тост о блоке старта")
_eq(_game.registry.table_file_count(), 1, "файл не потерян")
var folders = sec_c.get("folders", [])
_ok(folders is Array and not (folders as Array).is_empty(), "в C есть папка")
if folders is Array and not (folders as Array).is_empty():
var folder: Dictionary = folders[0]
_eq(str(folder.get("name", "")), "Разное", "стартовая папка «Разное»")
var folder_id := str(folder.get("id", ""))
var files: Dictionary = prep.get("files", {})
_ok(files.has(folder_id), "prep_data.files содержит папку")
_ok(files.get(folder_id) is Array, "файлы папки — массив")
func _test_hour_start_and_modal() -> void:
_set_case("hour_start")
_c.new_run(21)
_ok(_c.start_hour(), "start_hour")
_eq(String(_c.phase), "hour", "фаза hour")
_ok(bool(_c.hour_director.running), "HourDirector.running")
@@ -141,147 +106,175 @@ func _test_hour_start_and_modal() -> void:
_c.tick(0.1)
var hd = _c.hour_director
var status: Dictionary = _c.hour_data().get("status", {})
_eq(int(status.get("tcp_max", 0)), 1, "1 слот на старте")
_eq(int(status.get("buffer_max", 0)), 6, "буфер 6 на старте")
var hd_data: Dictionary = _c.hour_data()
var status: Dictionary = hd_data.get("status", {})
_eq(int(status.get("tcp_max", 0)), 2, "2 TCP-слота на старте")
_eq(int(status.get("buffer_max", 0)), 4, "буфер 4 на старте")
_eq(int(status.get("hour", 0)), 1, "статус: час 1")
_ok(float(status.get("time_left", 0.0)) > 299.0, "статус: таймер идёт")
_set_case("hour_data_modal")
# Пакет и запрос, чтобы проверить hour_data и заморозку модалкой.
_set_case("hour_data")
var packet_id := str(hd.spawn_packet_now("text", "tcp"))
_ok(not packet_id.is_empty(), "пакет заспавнен")
var folder_id := _first_folder_id()
var file = _game.registry.create_file("Тест контроллера", "text", folder_id)
_ok(file != null, "файл создан")
var req_id := str(hd.spawn_request_now(1))
var req_id := str(hd.spawn_request_now())
_ok(not req_id.is_empty(), "запрос создан")
var packet_id := str(hd.spawn_packet_now("text"))
_ok(not packet_id.is_empty(), "пакет создан")
_c.tick(0.1)
var hd_data: Dictionary = _c.hour_data()
hd_data = _c.hour_data()
_eq((hd_data.get("packets", []) as Array).size(), 1, "hour_data: 1 пакет")
_eq((hd_data.get("requests", []) as Array).size(), 1, "hour_data: 1 запрос")
var req_data: Dictionary = (hd_data.get("requests", []) as Array)[0]
_eq(int(req_data.get("required_count", 0)), 1, "hour_data: required_count")
_eq(int(req_data.get("missing", -1)), 1, "hour_data: missing = 1")
_eq((hd_data.get("slots", []) as Array).size(), 2, "hour_data: 2 слота")
if not (hd_data.get("packets", []) as Array).is_empty():
_eq(str(hd_data["packets"][0].get("id", "")), packet_id, "hour_data: id пакета")
_set_case("modal")
var req = hd.active_requests.get(req_id)
var packet = hd.active_packets.get(packet_id)
_ok(req != null, "запрос в активных")
if req == null:
return
var req_time := float(req.time_left)
var packet_time := float(packet.time_left)
_c.set_modal_open(true)
_ok(bool(hd.modal_open), "директор видит модалку")
var packets_before: int = hd.active_packets.size()
var accum_before: float = float(hd.spawn_recv_accum)
for i in range(10):
_c.tick(0.1)
_eq(hd.active_packets.size(), packets_before, "модалка: пакеты не спавнятся")
_approx(float(hd.spawn_recv_accum), accum_before, 0.0001, "модалка: спавн-аккумулятор стоит")
_approx(float(req.time_left), req_time - 1.0, 0.02, "модалка: таймер запроса идёт")
_approx(float(packet.time_left), packet_time, 0.0001, "модалка: таймер пакета стоит")
_c.set_modal_open(false)
_ok(not hd.modal_open, "модалка закрыта")
hd.reject_request(req_id)
_ok(not hd.active_requests.has(req_id), "запрос убран после reject")
func _test_auto_send() -> void:
_set_case("auto_send")
var hd = _c.hour_director
_ok(hd != null and bool(hd.running), "час идёт")
if hd == null:
return
var file = _game.registry.create_file("Авто контроллер", "text", _first_folder_id())
_ok(file != null, "файл для автоотправки создан")
if file == null:
return
var req_id := str(hd._make_request([file], "udp", false, "", ""))
var req = hd.active_requests.get(req_id)
_ok(req != null, "запрос создан")
if req == null:
return
_ok(hd.attach_file(req_id, str(file.id)), "файл прикреплён")
_ok(bool(req.sending), "UDP: отправка началась сразу после attach_file")
_ok(not bool(req.minigame_active), "minigame_active не поднимается")
_ok(not bool(hd.modal_open), "модалки нет")
var progress_before := float(req.send_progress)
_c.tick(0.1)
_ok(float(req.send_progress) > progress_before, "send_progress идёт")
hd._cleanup_request(req_id, true)
hd._remove_packet(packet_id)
func _test_fast_hour_and_report() -> void:
func _test_fast_hour() -> void:
_set_case("fast_hour")
_finish_current_hour()
_c.hour_director.set_time_scale(60.0)
for i in range(100):
# Изолируем переход фазы от автопотерь SLA в ускоренном прогоне.
_game.sla = 200.0
_c.tick(0.1)
if String(_c.phase) != "hour":
break
_eq(String(_c.phase), "hour_result", "час завершён → hour_result")
_eq(_hour_events, 1, "hour_finished один раз")
_eq(_hour_events, 1, "hour_finished эмитится один раз")
_ok(not _last_report.is_empty(), "отчёт часа не пустой")
_eq(int(_last_report.get("hour", 0)), 1, "в отчёте час 1")
for key in ["earned", "completed", "failed", "attacks", "attacks_failed",
"sla_delta", "files_carry", "packets_left", "requests_left"]:
_ok(_last_report.has(key), "отчёт содержит %s" % key)
_ok(_last_report.has("earned"), "в отчёте есть earned")
_ok(_last_report.has("completed"), "в отчёте есть completed")
_ok(_last_report.has("failed"), "в отчёте есть failed")
_ok(_last_report.has("sla_delta"), "в отчёте есть sla_delta")
_eq(String(_game.phase), "hour", "Game.phase ещё hour до next_after_hour")
_eq(int(_game.hour), 1, "Game.hour ещё 1 до next_after_hour")
_ok(not _c.current_report.is_empty(), "current_report заполнен")
func _test_after_hour_and_shop() -> void:
func _test_after_hour() -> void:
_set_case("next_after_hour")
_shop_events = 0
_c.next_after_hour()
_eq(String(_c.phase), "prep", "после отчёта → prep")
_eq(String(_game.phase), "prep", "Game.phase = prep")
_eq(int(_game.hour), 2, "следующий час = 2")
_ok(_c.has_save(), "автосейв после часа")
_ok(_shop_events > 0, "shop_changed при генерации офферов")
_set_case("shop_after_h1")
_eq(_game.shop_offer_hour, 2, "офферы сгенерированы для H2")
_eq(_game.shop_rerolls, 0, "рероллы сброшены")
_eq(_game.shop_offers.size(), 3, "ровно 3 оффера")
var unique: Dictionary = {}
for id in _game.shop_offers:
unique[str(id)] = true
_eq(unique.size(), 3, "офферы без дублей")
var shop_data: Array = _c.shop_data()
_eq(shop_data.size(), 3, "shop_data: 3 текущих предложения")
for entry in shop_data:
_ok(not str(entry.get("name", "")).is_empty(), "у оффера есть имя")
_ok(int(entry.get("price", -1)) >= 0, "у оффера есть цена")
_ok(int(entry.get("level", -1)) >= 0, "у оффера есть уровень")
_eq(int(_last_report.get("hour", 0)), 1, "отчёт всё ещё за час 1")
# Повторный next_after_hour в prep ничего не двигает.
_c.next_after_hour()
_eq(int(_game.hour), 2, "next_after_hour вне hour_result игнорируется")
func _test_buy() -> void:
_set_case("buy")
_game.money = 1000
_c.shop.refresh(2, int(_game.money), true)
var offers: Array = _c.shop_data()
_ok(offers.size() == 3, "3 предложения для покупки")
if offers.is_empty():
return
var entry: Dictionary = offers[0]
var id := str(entry.get("id", ""))
var price := int(entry.get("price", 0))
var level_before := int(entry.get("level", 0))
_shop_events = 0
_ok(_c.buy(id), "покупка оффера")
_eq(int(_game.money), 1000 - price, "деньги списаны по цене")
_eq(int(_c.shop.level(id)), level_before + 1, "уровень лота +1")
_ok(_shop_events > 0, "shop_changed после покупки")
_eq((_c.shop_data() as Array).size(), 3, "3 предложения остаются в магазине")
func _test_reroll_reset() -> void:
_set_case("reroll_reset")
_game.shop_rerolls = 2
_ok(_c.start_hour(), "старт часа 2")
_finish_current_hour()
_c.next_after_hour()
_eq(String(_c.phase), "prep", "H3 prep")
_eq(int(_game.hour), 3, "час 3")
_eq(int(_game.shop_rerolls), 0, "рероллы сброшены после часа")
_eq(int(_game.shop_offer_hour), 3, "офферы перегенерированы для H3")
_eq(_game.shop_offers.size(), 3, "3 оффера на H3")
var shop_before: int = _shop_events
_ok(_c.buy("shop_slot3"), "покупка третьего слота")
_eq(int(_game.money), 750, "списано 250")
_eq(int(_game.upgrades.get("slot_level", 0)), 1, "slot_level = 1")
_eq(_shop_events, shop_before + 1, "shop_changed эмитится")
_ok(not _c.buy("shop_slot3"), "повторная покупка → false")
_eq(int(_game.money), 750, "деньги не списаны повторно")
_eq((_c.shop_data() as Array).size(), 10, "shop_data: 10 позиций")
func _test_full_run() -> void:
_set_case("full_run")
_c.new_run(4242)
_eq(int(_game.hour), 1, "новый забег с часа 1")
_eq(int(_game.money), 50, "деньги нового забега")
_eq(int(_game.money), 150, "деньги нового забега")
var hours_before: int = _hour_events
var runs_before: int = _run_events
for h in range(1, 10):
_ok(_c.start_hour(), "старт часа %d" % h)
_eq(String(_c.phase), "hour", "фаза hour в часе %d" % h)
_finish_current_hour()
var hd = _c.hour_director
hd.set_time_scale(60.0)
# Изоляция механики контроллера: без массовых спавнов ускоренный
# прогон не должен упираться в SLA. Атаки остаются штатными.
hd.recv_disabled_until = 9999.0
hd.send_disabled_until = 9999.0
var finished := false
for i in range(100):
_game.sla = 200.0
_c.tick(0.1)
if String(_c.phase) != "hour":
finished = true
break
_ok(finished, "час %d завершился" % h)
_eq(String(_c.phase), "hour_result", "час %d: hour_result" % h)
_c.next_after_hour()
if h < 9:
_eq(String(_c.phase), "prep", "час %d: prep" % h)
_eq(int(_game.hour), h + 1, "час %d: следующий" % h)
_eq(_game.shop_offer_hour, h + 1, "час %d: офферы для H%d" % [h, h + 1])
_eq(_game.shop_offers.size(), 3, "час %d: 3 оффера" % h)
_eq(_hour_events - hours_before, 9, "9 отчётов часов")
_eq(_run_events - runs_before, 1, "run_finished один раз")
_eq(_run_events - runs_before, 1, "run_finished эмитится один раз")
_eq(String(_c.phase), "run_result", "9-й час → run_result")
_eq(String(_game.phase), "results", "Game.phase = results")
var result: Dictionary = _c.run_result()
_ok(not result.is_empty(), "run_result не пустой")
_ok(bool(result.get("won", false)), "победа при SLA > 0")
_eq(str(result.get("reason", "")), "victory", "reason = victory")
_eq(int(result.get("hours", 0)), 9, "hours = 9")
_eq(int(result.get("money_earned", -1)), 0, "money_earned без отправок = 0")
_ok(int(result.get("score", 0)) > 0, "score > 0")
_ok(int(result.get("money_earned", -1)) == 0, "money_earned без отправок = 0")
_ok(int(result.get("best", 0)) >= int(result.get("score", 0)), "best >= score")
_ok(not _last_run.is_empty(), "run_finished пришёл с данными")
_eq(int(_last_run.get("score", -1)), int(result.get("score", 0)), "сигнал = run_result()")
# Награды выданы по ходу часов, но итоговый экран должен их показать.
var unlocked: Array = result.get("unlocked", [])
_ok(unlocked.has("complete_run"), "unlocked: complete_run на победе")
_ok(unlocked.has("survive_hour_3") and unlocked.has("survive_hour_5"),
"unlocked: все награды текущего забега")
_eq(unlocked.size(), 3, "unlocked без дублей после победы")
_eq((_last_run.get("unlocked", []) as Array).size(), 3, "unlocked в сигнале run_finished")
func _test_save_load() -> void:
@@ -298,50 +291,6 @@ func _test_save_load() -> void:
_eq(String(_c.phase), "prep", "continue_run → prep")
_eq(String(_game.phase), "prep", "Game.phase = prep после continue_run")
_eq(int(_game.money), 321, "continue_run восстановил деньги")
_ok(int(_game.hour) >= 1, "continue_run восстановил час")
# --- служебное -------------------------------------------------------------
func _finish_current_hour() -> void:
var hd = _c.hour_director
hd.set_time_scale(60.0)
hd.files_spawned = hd.files_budget
hd.requests_spawned = hd.request_cap
hd.spawn_recv_accum = 0.0
hd.spawn_send_accum = 0.0
if hd.attack_director != null:
hd.attack_director._schedule.clear()
hd.attack_director._next_index = 0
for i in range(200):
_game.sla = 200.0
_c.tick(0.1)
if String(_c.phase) != "hour":
return
func _first_folder_id() -> String:
var section = _game.registry.sections.get("C")
if section == null or section.folder_ids.is_empty():
return ""
return str(section.folder_ids[0])
func _fill_folder(folder_id: String) -> void:
var guard := 0
while guard < 100:
guard += 1
_name_seq += 1
var f = _game.registry.create_file("Заполнение %d" % _name_seq, "text", folder_id)
if f == null:
return
func _has_toast(fragment: String) -> bool:
for text in _toasts:
if str(text).contains(fragment):
return true
return false
# --- обработчики -----------------------------------------------------------
@@ -360,13 +309,24 @@ func _on_shop_changed() -> void:
_shop_events += 1
func _on_phase_changed(phase: StringName) -> void:
func _on_phase_changed(_phase: StringName) -> void:
_phase_events += 1
_phases.append(String(phase))
func _on_toast(text: String) -> void:
_toasts.append(text)
# --- служебное -------------------------------------------------------------
func _first_folder_id() -> String:
var section = _game.registry.sections.get("C")
if section == null or section.folder_ids.is_empty():
return ""
return str(section.folder_ids[0])
func _find_section(sections: Array, id: String) -> Dictionary:
for section in sections:
if section is Dictionary and str(section.get("id", "")) == id:
return section
return {}
func _backup_save() -> void:
@@ -414,7 +374,7 @@ func _approx(actual: float, expected: float, eps: float, message: String) -> boo
_checks += 1
if absf(float(actual) - float(expected)) <= eps:
return true
_fail("%s — получено %.6f, ожидалось %.6f" % [message, float(actual), float(expected)])
_fail("%s — получено %.6f, ожидалось %.6f" % [message, actual, expected])
return false
+260 -465
View File
@@ -1,481 +1,317 @@
extends SceneTree
## Headless-тесты экономики v2: стартовые статы, лоты/капы, SLA, канал,
## таймеры, время передачи, выплата, выбор мини-игры, данные, прогрессия.
## Запуск: godot --headless --path . --script tests/test_economy.gd
extends RefCounted
## Тесты экономики: канал, таймеры, мини-игры, выплата, итоговый счёт.
const E := preload("res://scripts/core/economy.gd")
const D := preload("res://scripts/autoload/data.gd")
const G := preload("res://scripts/autoload/game.gd")
const EPS := 0.0001
var _checks: int = 0
var _fails: int = 0
var _case: String = ""
func run(t) -> void:
_test_channel(t)
_test_channel_time(t)
_test_timers(t)
_test_minigame(t)
_test_payout(t)
_test_score(t)
_test_data(t)
_test_game_defaults(t)
_test_progression(t)
_test_meta_awards_on_hours(t)
func _init() -> void:
await process_frame
_test_data()
_test_start_stats()
_test_levels_and_caps()
_test_sla_rules()
_test_channel_and_timers()
_test_transfer_time()
_test_payout()
_test_send_minigame()
_test_attack_tier()
_test_score()
_test_progression()
_test_meta_awards()
_finish()
func _test_channel(t) -> void:
t.set_case("channel")
# п.11: load = только активные передачи; слоты и фон не грузят канал.
t.approx(E.channel_load(1, 2, [], {}), 0.0, EPS, "hour 1, 2 idle-слота → load 0")
t.approx(E.channel_load(1, 4, [22, 22], {}), 44.0, EPS, "2 видео в передаче = 44 (слоты не влияют)")
t.approx(E.channel_factor(44.0, {}), 1.0, EPS, "44/80 = 0.55 → factor не ниже 1.0")
t.approx(E.channel_load(5, 4, [22, 22], {}), 44.0, EPS, "hour 5: только активные веса, без фона")
t.approx(E.channel_load(9, 4, [22, 22, 22, 22], {}), 88.0, EPS, "4 видео в передаче = 88")
t.approx(E.channel_factor(88.0, {}), 1.1, EPS, "88/80 → factor 1.1")
t.approx(E.channel_factor(E.channel_load(1, 0, [], {}), {}), 1.0, EPS, "час 1, пустой канал → factor 1.0")
t.approx(E.channel_factor(E.channel_load(9, 4, [], {}), {}), 1.0, EPS, "час 9, idle-слоты → factor 1.0")
t.approx(E.channel_load(1, 0, [], {}), 0.0, EPS, "idle канал = 0")
t.approx(E.channel_load(0, 0, [], {}), 0.0, EPS, "hour < 1 → idle 0")
t.approx(E.channel_load(1, 0, [1.5, 2.5], {}), 4.0, EPS, "сумма весов активных передач")
t.approx(E.channel_load(1, -3, [], {}), 0.0, EPS, "отрицательные слоты игнорируются")
t.approx(E.channel_load(1, 0, [-5, 3], {}), 3.0, EPS, "отрицательный вес клампится")
t.approx(E.channel_capacity({}), 80.0, EPS, "capacity base")
t.approx(E.channel_capacity({"channel": 2}), 130.0, EPS, "capacity +2 уровня")
t.approx(E.channel_capacity({"shop_channel": 3}), 155.0, EPS, "capacity по id магазина")
t.approx(E.channel_capacity({"channel": 4}), 180.0, EPS, "максимум 180 (80 + 4×25)")
t.approx(E.channel_factor(180.0, {"channel": 2}), 180.0 / 130.0, EPS, "factor по upgrades")
t.approx(E.channel_factor(10.0, {"channel": 4}), 1.0, EPS, "factor не ниже 1")
t.approx(E.channel_factor(102.0, 80.0), 1.275, EPS, "factor по числу capacity")
# --- данные ---------------------------------------------------------------
func _test_data() -> void:
_set_case("data")
var data = root.get_node_or_null("Data")
if data == null:
_fail("autoload Data недоступен")
return
_ok(not data.balance.is_empty(), "balance.json прочитан")
_ok(data.types.has("text"), "тип text есть")
var video: Dictionary = data.type_def("video")
_eq(int(video.get("cells", 0)), 4, "video: 4 клетки")
_approx(float(video.get("base_time_sec", 0.0)), 12.0, EPS, "video: base_time_sec 12")
_approx(float(video.get("base_pay", 0.0)), 80.0, EPS, "video: base_pay 80")
_approx(float(video.get("weight", 0.0)), 8.0, EPS, "video: weight 8")
_approx(float(video.get("minigame_coeff", 0.0)), 8.0, EPS, "video: coeff 8")
_approx(float(video.get("sla_loss", 0.0)), 16.0, EPS, "video: sla_loss 16")
_eq(int(data.type_def("sound").get("cells", 0)), 2, "sound: 2 клетки")
_eq(int(data.type_def("photo").get("cells", 0)), 3, "photo: 3 клетки")
_eq(int(data.type_def("nope").get("cells", 0)), 1, "неизвестный тип → text (merge defaults)")
var h1: Dictionary = data.hour_def(1)
_eq(int(h1.get("files", 0)), 4, "H1: 4 файла")
_eq(int(h1.get("request_cap", 0)), 2, "H1: кап 2 запроса")
_eq(int(h1.get("active_requests", 0)), 1, "H1: 1 активный запрос")
_eq(int(h1.get("attacks", 0)), 0, "H1: 0 атак")
_approx(float(h1.get("income_target", 0.0)), 65.0, EPS, "H1: цель дохода 65")
var w1: Dictionary = h1.get("type_weights", {})
_approx(float(w1.get("text", 0.0)), 0.70, EPS, "H1: text 70%")
_approx(float(w1.get("sound", 0.0)), 0.30, EPS, "H1: sound 30%")
_approx(float(w1.get("photo", 0.0)), 0.0, EPS, "H1: photo 0%")
_approx(float(w1.get("video", 0.0)), 0.0, EPS, "H1: video 0%")
var m1: Dictionary = h1.get("request_mix", {})
_approx(float(m1.get("1", 0.0)), 0.70, EPS, "H1: 1 файл 70%")
_approx(float(m1.get("2", 0.0)), 0.30, EPS, "H1: 2 файла 30%")
_approx(float(m1.get("3", 0.0)), 0.0, EPS, "H1: 3 файла 0%")
_eq(int(data.hour_def(2).get("files", 0)), 8, "H2: 8 файлов")
_eq(int(data.hour_def(9).get("files", 0)), 20, "H9: 20 файлов")
_eq(int(data.hour_def(9).get("request_cap", 0)), 8, "H9: кап 8 запросов")
_eq(int(data.hour_def(100).get("hour", 0)), 9, "hour > 9 → последняя строка")
_eq(data.hours.size(), 9, "9 строк бюджета часов")
for row in data.hours:
var weights: Dictionary = row.get("type_weights", {})
var total := 0.0
for key in ["text", "sound", "photo", "video"]:
total += float(weights.get(key, 0.0))
_approx(total, 1.0, EPS, "веса типов часа %d суммируются в 1.0" % int(row.get("hour", 0)))
var lots: Array = data.lot_defs()
_eq(lots.size(), 8, "ровно 8 лотов")
var expected_ids := ["lot_slots", "lot_speed", "lot_sla", "lot_shield",
"lot_money", "lot_folder", "lot_channel", "lot_timer"]
var prices_sum := 0
for i in range(expected_ids.size()):
var lot: Dictionary = data.lot_def(str(expected_ids[i]))
_ok(not lot.is_empty(), "лот %s есть" % str(expected_ids[i]))
_eq(str(lot.get("id", "")), str(expected_ids[i]), "id лота %d" % i)
_eq(int(lot.get("max_level", 0)), [4, 6, 4, 3, 8, 5, 6, 5][i], "max_level %s" % str(expected_ids[i]))
_eq((lot.get("weight_by_hour", []) as Array).size(), 9, "weight_by_hour %s: 9 значений" % str(expected_ids[i]))
var lot_prices: Array = lot.get("prices", [])
_eq(lot_prices.size(), int(lot.get("max_level", 0)), "prices %s по числу уровней" % str(expected_ids[i]))
for price in lot_prices:
prices_sum += int(price)
_eq(prices_sum, 13100, "сумма цен всех лотов = 13 100")
_eq(str(data.lot_def("lot_speed").get("effect", "")), "transfer_speed", "lot_speed effect")
_eq(str(data.lot_def("lot_channel").get("effect", "")), "channel_capacity", "lot_channel effect")
_ok(data.lot_def("lot_nope").is_empty(), "неизвестный лот → {}")
var rules: Dictionary = data.shop_rules()
_eq(int(rules.get("offers", 0)), 3, "shop_rules.offers = 3")
_eq(int(rules.get("max_rerolls", 0)), 3, "shop_rules.max_rerolls = 3")
_eq((rules.get("reroll_costs", []) as Array).size(), 3, "3 цены реролла")
var dots: Dictionary = data.minigame_def("dots")
_eq((dots.get("tiers", []) as Array).size(), 4, "dots: 4 ступени")
for gid in ["dots", "sound", "wires", "text", "video", "photo", "maze", "junk", "ddos", "phishing", "ransomware"]:
_ok(not data.minigame_def(gid).is_empty(), "мини-игра %s есть" % gid)
var sel: Dictionary = data.minigame_selection()
_eq((sel.get("order", []) as Array).size(), 7, "send order: 7 игр")
var sums: Array = sel.get("achievable_sums", [])
_eq(sums.size(), 19, "19 достижимых сумм")
var coeff: Dictionary = sel.get("coeff", {})
_eq(int(coeff.get("video", 0)), 8, "coeff video = 8 в selection")
_eq((data.attack_def("ddos").get("tiers", []) as Array).size(), 4, "ddos: 4 ступени")
var grid_rules: Dictionary = data.grid_rules()
_eq((grid_rules.get("folder_dims", []) as Array).size(), 6, "6 размеров сетки")
_eq((grid_rules.get("shapes", {}) as Dictionary).size(), 26, "26 фигур (моно/домино/три/тетромино)")
var s4: Dictionary = data.shape_def("S4")
_eq((s4.get("cells", []) as Array).size(), 4, "S4: 4 клетки")
_ok(data.shape_def("O1").size() > 0, "O1 есть")
_ok(data.tr("ui.title") != "ui.title", "строки читаются")
_ok(not str(data.folder_name()).is_empty(), "folder_name")
func _test_channel_time(t) -> void:
t.set_case("channel_time")
var def := {"recv_time": 2.0, "send_time": 3.0}
t.approx(E.recv_time(def, 1.02, {}), 2.04, EPS, "recv_time × factor")
t.approx(E.send_time(def, 1.02, {}), 3.06, EPS, "send_time × factor")
t.approx(E.recv_time(def, 1.0, {}), 2.0, EPS, "recv_time factor 1")
t.approx(E.send_time({"recv_time": 2.0}, 1.0, {}), 0.0, EPS, "нет send_time → 0")
# --- стартовые статы -------------------------------------------------------
func _test_timers(t) -> void:
t.set_case("timers")
# п.10: grace h1 1.5, h2 1.35, h3 1.2, h4+ 1.0.
t.approx(E.packet_timer(1, {}), 30.0, EPS, "packet hour 1 (20×1.5)")
t.approx(E.packet_timer(2, {}), 26.325, EPS, "packet hour 2 (19.5×1.35)")
t.approx(E.packet_timer(3, {}), 22.8, EPS, "packet hour 3 (19×1.2)")
t.approx(E.packet_timer(4, {}), 18.5, EPS, "packet hour 4 (без grace)")
t.approx(E.packet_timer(9, {}), 16.0, EPS, "packet hour 9")
t.approx(E.packet_timer(40, {}), 12.0, EPS, "packet min clamp")
t.approx(E.packet_timer(1, {"timer": 3}), 42.0, EPS, "packet +3 уровня (grace только на базу)")
t.approx(E.packet_timer(1, {"timer": 250}), 999.0, EPS, "packet max clamp")
t.approx(E.packet_timer(1, {"shop_timer": 1}), 34.0, EPS, "packet по id магазина")
t.approx(E.request_timer(1, {}), 60.0, EPS, "request hour 1 (30×2.0 — отдельный grace)")
t.approx(E.request_timer(2, {}), 50.15, EPS, "request hour 2 (29.5×1.7)")
t.approx(E.request_timer(3, {}), 40.6, EPS, "request hour 3 (29×1.4)")
t.approx(E.request_timer(4, {}), 28.5, EPS, "request hour 4 (без grace)")
t.approx(E.request_timer(9, {}), 26.0, EPS, "request hour 9")
t.approx(E.request_timer(40, {}), 20.0, EPS, "request min clamp")
t.approx(E.request_timer(1, {"timer": 2}), 68.0, EPS, "request +2 уровня")
t.approx(E.timer_grace(1), 1.5, EPS, "grace h1")
t.approx(E.timer_grace(2), 1.35, EPS, "grace h2")
t.approx(E.timer_grace(3), 1.2, EPS, "grace h3")
t.approx(E.timer_grace(4), 1.0, EPS, "grace h4+")
t.approx(E.request_timer_grace(1), 2.0, EPS, "request grace h1")
t.approx(E.request_timer_grace(2), 1.7, EPS, "request grace h2")
t.approx(E.request_timer_grace(3), 1.4, EPS, "request grace h3")
t.approx(E.request_timer_grace(4), 1.0, EPS, "request grace h4+")
func _test_start_stats() -> void:
_set_case("start_stats")
func _test_minigame(t) -> void:
t.set_case("minigame")
t.approx(E.minigame_difficulty([]), 1.0, EPS, "пустой набор → 1.0")
t.approx(E.minigame_difficulty(["text", "video"]), 2.5, EPS, "text+video = 2.5")
t.approx(E.minigame_difficulty(["text", "text", "text"]), 1.0, EPS, "3 text = 1.0")
t.approx(E.minigame_difficulty(["unknown"]), 1.0, EPS, "неизвестный тип = text")
t.eq(E.minigame_type([]), "text", "пустой набор → text")
t.eq(E.minigame_type(["text", "text", "text"]), "text", "3 text → text")
t.eq(E.minigame_type(["text", "video"]), "photo", "avg 2.5 → photo")
t.eq(E.minigame_type(["text", "sound"]), "sound", "avg 1.5 → sound")
t.eq(E.minigame_type(["text", "photo"]), "sound", "avg 2.0 → sound")
t.eq(E.minigame_type(["sound", "photo"]), "photo", "avg 2.5 → photo")
t.eq(E.minigame_type(["photo", "video"]), "video", "avg 3.5 → video")
t.eq(E.minigame_type(["video"]), "video", "video → video")
t.eq(E.minigame_type(["sound"]), "sound", "sound → sound")
t.eq(E.minigame_type(["photo"]), "photo", "photo → photo")
func _test_payout(t) -> void:
t.set_case("payout")
# base 100, n=2, 15/30, quality 1, tcp, streak 5:
# 100 × 0.8 × 1.0 × 1.1 × 1.15 × 1.1 = 111.32 → 111
t.eq(E.payout(100.0, 2, 15.0, 30.0, 1.0, "tcp", 5, {}), 111, "полный TCP-пример")
t.eq(E.payout(100.0, 1, 30.0, 30.0, 0.5, "udp", 0, {}), 75, "quality 0.5, udp")
t.eq(E.payout(100.0, 3, 30.0, 30.0, 1.0, "udp", 0, {}), 120, "multi n=3")
t.eq(E.payout(100.0, 1, 0.0, 30.0, 1.0, "udp", 0, {}), 60, "speed минимум 0.6")
t.eq(E.payout(100.0, 1, 60.0, 30.0, 1.0, "udp", 0, {}), 100, "time ratio клампится 1")
t.eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "TCP", 10, {}), 138, "TCP + streak 10")
t.eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, &"udp", 10, {}), 120, "streak +20%")
t.eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "udp", 50, {}), 120, "streak cap 10")
t.eq(E.payout(100.0, 0, 30.0, 30.0, 1.0, "udp", 0, {}), 0, "n=0 → 0")
t.eq(E.payout(0.0, 1, 30.0, 30.0, 1.0, "udp", 0, {}), 0, "base 0 → 0")
t.eq(E.payout(100.0, 1, 30.0, 0.0, 1.0, "udp", 0, {}), 100, "timer_total 0 → полная скорость")
t.eq(E.payout(100.0, 1, 30.0, 30.0, 2.0, "udp", 0, {}), 100, "quality клампится 1")
func _test_score(t) -> void:
t.set_case("score")
t.eq(E.score(9, 12000, 250.0), 23500, "9 часов, 12000, SLA 250")
t.eq(E.score(0, 0, 0.0), 0, "нулевой забег")
t.eq(E.score(1, 500, 200.0), 3500, "1 час, 500, SLA 200")
t.eq(E.score(-3, -100, -50.0), 0, "отрицательные значения клампятся")
func _test_data(t) -> void:
t.set_case("data")
var data = D.new()
data.load_all()
t.ok(not data.balance.is_empty(), "balance.json прочитан")
t.ok(data.types.has("text"), "тип text есть")
t.ok(data.types.size() >= 4, "минимум 4 типа файлов")
var text_def = data.type_def("text")
t.ok(text_def is Dictionary and not text_def.is_empty(), "type_def(text)")
t.ok(not str(text_def.get("ext", "")).is_empty(), "у text есть ext")
t.eq(str(data.type_def("nope").get("name_ru", "")), str(text_def.get("name_ru", "")), "неизвестный тип → text")
t.ok(not data.difficulty.is_empty(), "таблица сложности не пуста")
var first = data.difficulty_for(1)
t.eq(int(first.get("hour", 0)), 1, "difficulty_for(1)")
var last_row = data.difficulty[data.difficulty.size() - 1]
var beyond = data.difficulty_for(999)
t.eq(int(beyond.get("hour", 0)), int(last_row.get("hour", 0)), "hour > max → последняя строка")
t.ok(not data.shop.is_empty(), "магазин не пуст")
var first_item = data.shop[0]
var item_id := str(first_item.get("id", ""))
var found = data.shop_item(item_id)
t.eq(str(found.get("id", "")), item_id, "shop_item по id")
t.eq(data.shop_item("нет_такого_товара").size(), 0, "неизвестный товар → {}")
t.ok(not data.names.is_empty(), "names_ru.json прочитан")
t.ok(not data.strings.is_empty(), "strings_ru.json прочитан")
t.ok(data.unlocks.has("shop"), "progression.unlocks.shop прочитан")
t.ok(data.meta_rewards.has("survive_hour_3"), "progression.meta_rewards прочитан")
t.eq(int(data.unlocks.get("shop", {}).get("run_hour", 0)), 2, "shop открывается на часе 2")
t.eq(int(data.unlocks.get("attack_ransomware", {}).get("run_hour", 0)), 4, "ransomware открывается на часе 4")
t.ok(data.shop_item("shop_sla").size() > 0, "shop_sla есть в каталоге")
t.eq(int(data.shop_item("shop_sla").get("value", 0)), 24, "shop_sla value = 24")
t.eq(int(data.shop_item("shop_sla").get("max_level", 0)), 3, "shop_sla max_level = 3")
var available = data.available_shop()
t.ok(available is Array, "available_shop() — массив")
t.ok(not str(data.folder_name()).is_empty(), "folder_name() не пуст")
t.ok(data.tr("ui.title") != "ui.title", "вложенный ключ ui.title")
t.ok(data.has_string("ui.title"), "has_string для вложенного ключа")
t.ok(not data.has_string("точно.нет.ключа"), "has_string для отсутствующего ключа")
t.eq(data.tr("точно.нет.ключа"), "точно.нет.ключа", "нет ключа → сам ключ")
data.free()
func _test_game_defaults(t) -> void:
t.set_case("game_defaults")
var game = G.new()
game.start_new_run(1)
_eq(int(game.money), 50, "старт: money = 50")
_approx(float(game.sla), 60.0, EPS, "старт: SLA = 60")
_approx(game.sla_cap(), 60.0, EPS, "старт: кап SLA = 60")
_eq(game.hour, 1, "старт: hour = 1")
_eq(game.connection_slots(), 1, "старт: 1 слот")
_approx(game.channel_capacity(), 10.0, EPS, "старт: канал 10")
_eq(game.buffer_limit(), 6, "старт: буфер 6")
_approx(game.request_timer(), 30.0, EPS, "старт: таймер запроса 30")
_approx(game.packet_timer(), 20.0, EPS, "старт: таймер пакета 20")
_approx(game.money_mult(), 1.0, EPS, "старт: money_mult 1.0")
_approx(game.speed_mult(), 1.0, EPS, "старт: speed_mult 1.0")
_eq(game.shield_max(), 0, "старт: 0 зарядов щита")
_eq(game.shield_charges, 0, "старт: заряды щита = 0")
var dims: Vector2i = game.folder_dims()
_eq(dims.x, 5, "старт: сетка 5 столбцов")
_eq(dims.y, 4, "старт: сетка 4 строки")
_eq(game.level("connection_slots"), 0, "level(connection_slots) = 0")
_eq(game.level("slot_level"), 0, "level(slot_level) = 0")
_ok(game.upgrades.is_empty(), "старт: upgrades пусты")
_eq(game.shop_offers.size(), 0, "старт: офферов нет")
_eq(game.shop_rerolls, 0, "старт: рероллов 0")
game.free()
func _test_levels_and_caps() -> void:
_set_case("levels/caps")
var game = G.new()
game.start_new_run(2)
game.set_lot_level("lot_slots", 4)
_eq(game.level("lot_slots"), 4, "lot_slots = 4")
_eq(game.level("slot_level"), 4, "агрегат slot_level = 4")
_eq(int(game.upgrades.get("lot_slots", -1)), 4, "upgrades[lot_slots] = 4")
_eq(game.connection_slots(), 5, "4 уровня → 5 слотов")
game.set_lot_level("lot_speed", 6)
_eq(game.level("speed_level"), 6, "speed_level = 6")
_approx(game.speed_mult(), 0.606355, 0.0005, "pow(0.92, 6) ≈ 0.606")
_approx(E.speed_mult(6), pow(0.92, 6.0), EPS, "speed_mult = pow(0.92, 6) без ^")
game.set_lot_level("lot_sla", 4)
_approx(game.sla_cap(), 200.0, EPS, "4 уровня ИБП → кап 200")
_approx(game.sla, 200.0, EPS, "ИБП сразу добавляет +35 за уровень (60 → 200)")
game.start_new_run()
t.eq(int(game.money), 150, "start_new_run: money=150")
t.approx(float(game.sla), 128.0, 0.0001, "start_new_run: sla=128")
t.eq(int(game.hour), 1, "start_new_run: hour=1")
game.add_sla(1000.0)
_approx(game.sla, 200.0, EPS, "add_sla не выше динамического капа 200")
game.set_lot_level("lot_money", 8)
_approx(game.money_mult(), 1.4, EPS, "8 уровней контракта → ×1.4")
_approx(E.money_mult({"money_level": 4}), 1.2, EPS, "Economy.money_mult(level 4) = 1.2")
game.set_lot_level("lot_folder", 5)
var dims: Vector2i = game.folder_dims()
_eq(dims.x, 8, "folder level 5: 8 столбцов")
_eq(dims.y, 6, "folder level 5: 6 строк")
game.set_lot_level("lot_folder", 1)
dims = game.folder_dims()
_eq(dims.x, 6, "folder level 1: 6×4 → 6 столбцов")
_eq(dims.y, 4, "folder level 1: 6×4 → 4 строки")
game.set_lot_level("lot_channel", 6)
_approx(game.channel_capacity(), 46.0, EPS, "6 уровней канала → 46")
_eq(game.buffer_limit(), 12, "6 уровней канала → буфер 12")
game.set_lot_level("lot_timer", 5)
_approx(game.request_timer(), 55.0, EPS, "5 уровней таймера → 55")
game.set_lot_level("lot_timer", 99)
_eq(game.level("lot_timer"), 5, "уровень клампится max_level")
game.set_lot_level("lot_shield", 3)
_eq(game.shield_max(), 3, "3 уровня заземления → 3 заряда")
_eq(game.shield_charges, 3, "покупка сразу заряжает щит")
# Сетка уровней 0..5 из таблицы.
var expected := [[5, 4], [6, 4], [6, 5], [7, 5], [7, 6], [8, 6]]
for lvl in range(6):
game.set_lot_level("lot_folder", lvl)
var d: Vector2i = game.folder_dims()
_eq(d.x, int(expected[lvl][0]), "folder dims[%d].x" % lvl)
_eq(d.y, int(expected[lvl][1]), "folder dims[%d].y" % lvl)
t.approx(float(game.sla), 200.0, 0.0001, "add_sla(1000) не поднимает SLA выше 200")
game.free()
func _test_sla_rules() -> void:
_set_case("sla_rules")
var game = G.new()
game.start_new_run(3)
game.sla = 30.0
var after: float = game.regen_sla_for_hour()
_approx(after, 45.0, EPS, "regen_sla_for_hour: 30 + 15 = 45")
_approx(float(game.sla), 45.0, EPS, "SLA обновлён после регена")
game.sla = 55.0
game.add_sla(100.0)
_approx(float(game.sla), 60.0, EPS, "add_sla клампится текущим капом 60")
game.set_lot_level("lot_sla", 1)
game.sla = 90.0
game.hour = 2
game.regen_sla_for_hour()
_approx(float(game.sla), 95.0, EPS, "реген не превышает кап 95")
game.regen_sla_for_hour()
_approx(float(game.sla), 95.0, EPS, "повторный реген в том же часу — no-op")
# SLA 0 во время часа = поражение забега.
var reasons: Array = []
game.run_ended.connect(func(reason): reasons.append(str(reason)))
game.phase = &"hour"
game.add_sla(-999.0)
_approx(float(game.sla), 0.0, EPS, "SLA падает до 0")
_eq(str(game.phase), "results", "SLA 0 → фаза results")
_eq(reasons.size(), 1, "run_ended эмитится один раз")
if reasons.size() == 1:
_eq(str(reasons[0]), "sla", "причина поражения sla")
game.free()
func _test_channel_and_timers() -> void:
_set_case("channel/timers")
_approx(E.channel_capacity({}), 10.0, EPS, "capacity base 10")
_approx(E.channel_capacity({"channel_level": 1}), 16.0, EPS, "capacity +1 = 16")
_approx(E.channel_capacity({"channel": 6}), 46.0, EPS, "capacity +6 = 46")
_approx(E.channel_capacity({"lot_channel": 3}), 28.0, EPS, "capacity по id лота")
_approx(E.channel_factor(0.0, {}), 1.0, EPS, "пустой канал → factor 1")
_approx(E.channel_factor(46.0, {"channel_level": 6}), 1.0, EPS, "load = capacity → factor 1")
_approx(E.channel_factor(92.0, {"channel_level": 6}), 2.0, EPS, "load ×2 → factor 2")
_approx(E.channel_factor(100.0, 10.0), 10.0, EPS, "factor по числу capacity")
_approx(E.channel_load(1, 5, [8, 8], {}), 16.0, EPS, "2 видео в передаче = 16")
_approx(E.channel_load(1, 0, [1, 2, 4, 8], {}), 15.0, EPS, "сумма весов 1/2/4/8")
_approx(E.channel_load(1, 5, [-5, 3], {}), 3.0, EPS, "отрицательный вес клампится")
_eq(E.buffer_limit({}), 6, "буфер base 6")
_eq(E.buffer_limit({"channel_level": 6}), 12, "буфер 6 → 12")
_eq(E.buffer_limit({"channel_level": 99}), 12, "буфер cap 12")
_approx(E.packet_timer(1, {}), 20.0, EPS, "packet timer H1 = 20")
_approx(E.packet_timer(9, {"timer_level": 5}), 20.0, EPS, "packet timer не меняется")
_approx(E.request_timer(1, {}), 30.0, EPS, "request timer base 30")
_approx(E.request_timer(5, {"timer_level": 1}), 35.0, EPS, "request timer +1 = 35")
_approx(E.request_timer(9, {"timer_level": 5}), 55.0, EPS, "request timer max 55")
_approx(E.request_timer(9, {"timer_level": 99}), 55.0, EPS, "request timer cap 55")
_approx(E.request_timer(1, {"lot_timer": 2}), 40.0, EPS, "request timer по id лота")
func _test_transfer_time() -> void:
_set_case("transfer_time")
_approx(E.recv_transfer_time({"base_time_sec": 3}, 1.0, 0), 3.0, EPS, "text 3 с")
_approx(E.recv_transfer_time({"base_time_sec": 5}, 1.0, 0), 5.0, EPS, "sound 5 с")
_approx(E.recv_transfer_time({"base_time_sec": 8}, 1.0, 0), 8.0, EPS, "photo 8 с")
_approx(E.recv_transfer_time({"base_time_sec": 12}, 1.0, 0), 12.0, EPS, "video 12 с")
_approx(E.recv_transfer_time({"base_time_sec": 12}, 1.0, 1), 12.0 * 0.92, EPS, "speed 1 → ×0.92")
_approx(E.recv_transfer_time({"base_time_sec": 12}, 2.0, 0), 24.0, EPS, "channel factor 2")
_approx(E.recv_transfer_time({"base_time_sec": 8}, 1.0, 2), 8.0 * pow(0.92, 2.0), EPS, "speed 2")
_approx(E.send_transfer_time({"base_time_sec": 12}, 1.0, 0, "tcp"), 12.0, EPS, "TCP ×1.0")
_approx(E.send_transfer_time({"base_time_sec": 12}, 1.0, 0, "udp"), 9.6, EPS, "UDP ×0.8")
_approx(E.send_transfer_time({"base_time_sec": 12}, 1.0, 0, &"UDP"), 9.6, EPS, "UDP StringName")
_approx(E.recv_time({"base_time_sec": 8}, 1.0, {"speed_level": 2}), 8.0 * pow(0.92, 2.0), EPS, "legacy recv_time + speed")
_approx(E.send_time({"base_time_sec": 12}, 1.0, {"speed_level": 1}), 12.0 * 0.92, EPS, "legacy send_time")
_approx(E.recv_time({"recv_time": 2.0}, 1.0, {}), 2.0, EPS, "legacy поле recv_time")
func _test_payout() -> void:
_set_case("payout")
# 100 × 0.8 × 1.0 × 1.15 × 1.15 × 1.1 = 116.38 → 116
_eq(E.payout(100.0, 2, 15.0, 30.0, 1.0, "tcp", 5, {}), 116, "полный TCP-пример (multi 1.15)")
_eq(E.payout(100.0, 1, 30.0, 30.0, 0.5, "udp", 0, {}), 75, "quality 0.5, udp")
_eq(E.payout(100.0, 3, 30.0, 30.0, 1.0, "udp", 0, {}), 130, "multi n=3 (1.30)")
_eq(E.payout(100.0, 1, 0.0, 30.0, 1.0, "udp", 0, {}), 60, "speed минимум 0.6")
_eq(E.payout(100.0, 1, 60.0, 30.0, 1.0, "udp", 0, {}), 100, "time ratio клампится 1")
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "TCP", 10, {}), 138, "TCP + streak 10")
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, &"udp", 10, {}), 120, "streak +20%")
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "udp", 50, {}), 120, "streak cap 10")
_eq(E.payout(100.0, 0, 30.0, 30.0, 1.0, "udp", 0, {}), 0, "n=0 → 0")
_eq(E.payout(0.0, 1, 30.0, 30.0, 1.0, "udp", 0, {}), 0, "base 0 → 0")
_eq(E.payout(100.0, 1, 30.0, 0.0, 1.0, "udp", 0, {}), 100, "timer_total 0 → полная скорость")
_eq(E.payout(100.0, 1, 30.0, 30.0, 2.0, "udp", 0, {}), 100, "quality клампится 1")
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "udp", 0, {"money_level": 4}), 120, "money_mult +4 = ×1.2")
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "udp", 0, {"money_level": 8}), 140, "money_mult +8 = ×1.4")
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "udp", 0, {"lot_money": 2}), 110, "money_mult по id лота")
# Float-точность: 1/3 и 2/3 не должны стать int/int.
# 100 × (0.6 + 0.4/3) × 0.5 = 36.667 → 37 (при int/int было бы 30).
_eq(E.payout(100.0, 1, 1.0, 3.0, 0.0, "udp", 0, {}), 37, "time ratio 1/3 — float")
# 100 × (0.6 + 0.8/3) × 0.5 = 43.333 → 43.
_eq(E.payout(100.0, 1, 2.0, 3.0, 0.0, "udp", 0, {}), 43, "time ratio 2/3 — float")
func _test_send_minigame() -> void:
_set_case("send_minigame")
var m: Dictionary = E.send_minigame_for(["text"])
_eq(int(m.get("sum", 0)), 1, "S=1")
_eq(str(m.get("id", "")), "dots", "S=1 → dots")
_eq(int(m.get("tier", 0)), 1, "S=1 → T1")
_eq(int(m.get("index", -1)), 0, "S=1 → rank 0")
m = E.send_minigame_for(["sound"])
_eq(int(m.get("sum", 0)), 2, "S=2")
_eq(str(m.get("id", "")), "sound", "S=2 → sound")
_eq(int(m.get("tier", 0)), 1, "S=2 → T1")
m = E.send_minigame_for(["text", "sound"])
_eq(int(m.get("sum", 0)), 3, "S=3")
_eq(str(m.get("id", "")), "wires", "S=3 → wires")
_eq(int(m.get("tier", 0)), 1, "S=3 → T1")
m = E.send_minigame_for(["photo"])
_eq(int(m.get("sum", 0)), 4, "S=4")
_eq(str(m.get("id", "")), "text", "S=4 → text")
_eq(int(m.get("tier", 0)), 1, "S=4 → T1")
m = E.send_minigame_for(["video"])
_eq(int(m.get("sum", 0)), 8, "S=8")
_eq(str(m.get("id", "")), "dots", "S=8 → dots")
_eq(int(m.get("tier", 0)), 2, "S=8 → T2")
_eq(int(m.get("index", -1)), 7, "S=8 → rank 7")
m = E.send_minigame_for(["video", "video", "video"])
_eq(int(m.get("sum", 0)), 24, "S=24")
_eq(int(m.get("tier", 0)), 4, "S=24 → T4")
# B4: плотный список достижимых сумм, rank 18 → order[18 % 7] = video.
_eq(str(m.get("id", "")), "video", "S=24 → video (B4 reindex)")
_eq(int(m.get("index", -1)), 18, "S=24 → rank 18")
# Все 7 игр и все 4 ступени достижимы при максимум 3 файлах.
var pool := ["text", "sound", "photo", "video"]
var ids: Dictionary = {}
var tiers: Dictionary = {}
for a in range(pool.size()):
for b in range(pool.size()):
for c in range(pool.size()):
for combo in [[pool[a]], [pool[a], pool[b]], [pool[a], pool[b], pool[c]]]:
var r: Dictionary = E.send_minigame_for(combo)
var id := str(r.get("id", ""))
ids[id] = true
tiers[int(r.get("tier", 0))] = true
_ok(E.SEND_ORDER.has(id), "id %s из SEND_ORDER" % id)
_eq(ids.size(), 7, "достижимы все 7 игр отправки")
for t in [1, 2, 3, 4]:
_ok(tiers.has(t), "достижима ступень T%d" % t)
_ok(not ids.has("junk"), "атакующие игры не выбираются для отправки")
func _test_attack_tier() -> void:
_set_case("attack_tier")
_eq(E.attack_tier(1), 1, "H1 → T1")
_eq(E.attack_tier(2), 1, "H2 → T1")
_eq(E.attack_tier(3), 1, "H3 → T1")
_eq(E.attack_tier(4), 2, "H4 → T2")
_eq(E.attack_tier(5), 2, "H5 → T2")
_eq(E.attack_tier(6), 3, "H6 → T3")
_eq(E.attack_tier(7), 3, "H7 → T3")
_eq(E.attack_tier(8), 4, "H8 → T4")
_eq(E.attack_tier(9), 4, "H9 → T4")
func _test_score() -> void:
_set_case("score")
_eq(E.score(9, 8000, 200.0), 19000, "9 часов, 8000, SLA 200")
_eq(E.score(0, 0, 0.0), 0, "нулевой забег")
_eq(E.score(1, 500, 200.0), 3500, "1 час, 500, SLA 200")
_eq(E.score(-3, -100, -50.0), 0, "отрицательные значения клампятся")
# --- прогрессия ------------------------------------------------------------
func _test_progression() -> void:
_set_case("progression")
func _test_progression(t) -> void:
t.set_case("progression")
# run_tests.gd запускается из _init до _ready автолоадов — форсируем Data.
var loop := Engine.get_main_loop()
if loop is SceneTree:
var data = (loop as SceneTree).root.get_node_or_null("Data")
if data != null and data.get("unlocks") is Dictionary and (data.unlocks as Dictionary).is_empty():
data.load_all()
var game = G.new()
game.start_new_run(5)
_ok(not game.feature_unlocked("shop"), "час 1: магазин закрыт")
_ok(game.feature_unlocked("minigame_text"), "час 1: text открыт")
_ok(not game.feature_unlocked("minigame_sound"), "час 1: sound закрыт")
_ok(not game.feature_unlocked("attack_junk"), "час 1: junk закрыт")
# run_hour-гейты.
t.ok(not game.feature_unlocked("shop"), "час 1: магазин закрыт")
t.ok(game.feature_unlocked("minigame_text"), "час 1: text открыт")
t.ok(not game.feature_unlocked("minigame_sound"), "час 1: sound закрыт")
t.ok(not game.feature_unlocked("minigame_photo"), "час 1: photo закрыт")
t.ok(not game.feature_unlocked("attack_junk"), "час 1: junk закрыт")
game.hour = 2
_ok(game.feature_unlocked("shop"), "час 2: магазин открыт")
_ok(game.feature_unlocked("minigame_sound"), "час 2: sound открыт")
_ok(game.feature_unlocked("attack_ddos"), "час 2: ddos открыт")
_ok(not game.feature_unlocked("attack_phishing"), "час 2: phishing закрыт")
t.ok(game.feature_unlocked("shop"), "час 2: магазин открыт")
t.ok(game.feature_unlocked("minigame_sound"), "час 2: sound открыт")
t.ok(game.feature_unlocked("attack_ddos"), "час 2: ddos открыт")
t.ok(not game.feature_unlocked("attack_phishing"), "час 2: phishing закрыт")
game.hour = 4
_ok(game.feature_unlocked("minigame_video"), "час 4: video открыт")
_ok(game.feature_unlocked("attack_ransomware"), "час 4: ransomware открыт")
_ok(game.feature_unlocked("нет_такой_фичи"), "неизвестная фича считается открытой")
_ok(not game.feature_unlocked("search"), "search закрыт до мета-награды")
_ok(not game.feature_unlocked("shield"), "shield закрыт до мета-награды")
_eq(game.award_meta_for_run(2).size(), 0, "2 часа: мета-наград нет")
t.ok(game.feature_unlocked("minigame_video"), "час 4: video открыт")
t.ok(game.feature_unlocked("attack_ransomware"), "час 4: ransomware открыт")
t.ok(game.feature_unlocked("нет_такой_фичи"), "неизвестная фича считается открытой")
# Мета-гейты.
t.ok(not game.feature_unlocked("search"), "search закрыт до мета-награды")
t.ok(not game.feature_unlocked("shield"), "shield закрыт до мета-награды")
t.eq(game.award_meta_for_run(2).size(), 0, "2 часа: мета-наград нет")
var at3 := game.award_meta_for_run(3)
_ok(at3.has("survive_hour_3"), "3 часа: survive_hour_3 выдан")
_ok(game.feature_unlocked("search"), "после 3 часов search открыт")
_ok(not game.feature_unlocked("shield"), "shield ещё закрыт")
_eq(game.award_meta_for_run(3).size(), 0, "повторно награда не выдаётся")
t.ok(at3.has("survive_hour_3"), "3 часа: survive_hour_3 выдан")
t.ok(game.feature_unlocked("search"), "после 3 часов search открыт")
t.ok(not game.feature_unlocked("shield"), "shield ещё закрыт")
t.eq(game.award_meta_for_run(3).size(), 0, "повторно награда не выдаётся")
var at5 := game.award_meta_for_run(5)
_ok(at5.has("survive_hour_5"), "5 часов: survive_hour_5 выдан")
_ok(game.feature_unlocked("shield"), "после 5 часов shield открыт")
t.ok(at5.has("survive_hour_5"), "5 часов: survive_hour_5 выдан")
t.ok(game.feature_unlocked("shield"), "после 5 часов shield открыт")
var at9 := game.award_meta_for_run(9)
_ok(at9.has("complete_run"), "9 часов: complete_run выдан")
t.ok(at9.has("complete_run"), "9 часов: complete_run выдан")
t.ok(game.feature_unlocked("minigame_advanced"), "после 9 часов advanced открыт")
# Мета переживает reset_run и сохраняется/загружается.
game.reset_run()
_ok(game.feature_unlocked("search"), "reset_run не стирает мету")
t.ok(game.feature_unlocked("search"), "reset_run не стирает мету")
game.start_new_run(6)
_ok(game.feature_unlocked("shield"), "start_new_run не стирает мету")
t.ok(game.feature_unlocked("shield"), "start_new_run не стирает мету")
game.free()
func _test_meta_awards() -> void:
_set_case("meta_awards")
## Мета-награды выдаются по ходу забега в Game.end_hour, а не только в _finish_run.
func _test_meta_awards_on_hours(t) -> void:
t.set_case("meta_awards")
# run_tests.gd запускается из _init до _ready автолоадов — форсируем Data.
var loop := Engine.get_main_loop()
if loop is SceneTree:
var data = (loop as SceneTree).root.get_node_or_null("Data")
if data != null and data.get("unlocks") is Dictionary and (data.unlocks as Dictionary).is_empty():
data.load_all()
var game = G.new()
var toasts: Array = []
game.toast.connect(func(text): toasts.append(str(text)))
game.start_new_run(11)
toasts.clear()
# Обычный час без наград: тостов «Открыто: …» быть не должно.
game.end_hour()
_eq(int(game.stats.get("hours_done", 0)), 1, "1 час отработан")
_ok(not game.feature_unlocked("search"), "после 1 часа search закрыт")
_ok(not _has_unlock_toast(toasts), "1 час: тоста об открытии нет")
t.eq(int(game.stats.get("hours_done", 0)), 1, "1 час отработан")
t.ok(not game.feature_unlocked("search"), "после 1 часа search закрыт")
t.ok(not _has_unlock_toast(toasts), "1 час: тоста об открытии нет")
# 2 часа — ближайшая награда требует 3 часа.
game.end_hour()
_eq(int(game.stats.get("hours_done", 0)), 2, "2 часа отработано")
_ok(not game.feature_unlocked("search"), "после 2 часов search закрыт")
t.eq(int(game.stats.get("hours_done", 0)), 2, "2 часа отработано")
t.ok(not game.feature_unlocked("search"), "после 2 часов search закрыт")
t.ok(not _has_unlock_toast(toasts), "2 часа: тоста об открытии нет")
# 3 часа → survive_hour_3 сразу, забег не завершён.
game.end_hour()
_eq(int(game.stats.get("hours_done", 0)), 3, "3 часа отработано")
_ok(str(game.phase) != "results", "забег не завершён после 3 часов")
_ok(game.feature_unlocked("search"), "после 3 часов search открыт")
_ok(_has_unlock_toast(toasts), "3 часа: тост «Открыто: …» есть")
_ok(game.run_meta_awards.has("survive_hour_3"), "run_meta_awards: survive_hour_3")
_eq(game.run_meta_awards.size(), 1, "run_meta_awards без дублей")
t.eq(int(game.stats.get("hours_done", 0)), 3, "3 часа отработано")
t.ok(str(game.phase) != "results", "забег не завершён после 3 часов")
t.ok(game.feature_unlocked("search"), "после 3 часов search открыт без завершения забега")
t.ok(_has_unlock_toast(toasts), "3 часа: тост «Открыто: …» есть")
t.ok(game.run_meta_awards.has("survive_hour_3"), "run_meta_awards: survive_hour_3 за текущий забег")
t.eq(game.run_meta_awards.size(), 1, "run_meta_awards без дублей после 3 часов")
# 4 часа — новых наград нет.
game.end_hour()
_ok(not game.feature_unlocked("shield"), "после 4 часов shield ещё закрыт")
t.ok(not game.feature_unlocked("shield"), "после 4 часов shield ещё закрыт")
# 5 часов → survive_hour_5.
game.end_hour()
_eq(int(game.stats.get("hours_done", 0)), 5, "5 часов отработано")
_ok(game.feature_unlocked("shield"), "после 5 часов shield открыт")
_eq(game.run_meta_awards.size(), 2, "run_meta_awards: 2 награды")
t.eq(int(game.stats.get("hours_done", 0)), 5, "5 часов отработано")
t.ok(game.feature_unlocked("shield"), "после 5 часов shield открыт")
t.ok(game.meta_unlocks.has("survive_hour_5"), "survive_hour_5 в meta_unlocks")
t.ok(game.run_meta_awards.has("survive_hour_5"), "run_meta_awards: survive_hour_5 за текущий забег")
t.eq(game.run_meta_awards.size(), 2, "run_meta_awards: 2 награды, без дублей")
# 9 часов → complete_run выдаётся в end_hour до _finish_run.
for i in range(4):
game.end_hour()
_eq(int(game.stats.get("hours_done", 0)), 9, "9 часов отработано")
_eq(str(game.phase), "results", "9-й час завершает забег")
_ok(game.meta_unlocks.has("complete_run"), "complete_run выдан на 9-м часе")
_eq(game.run_meta_awards.size(), 3, "run_meta_awards: все 3 награды")
t.eq(int(game.stats.get("hours_done", 0)), 9, "9 часов отработано")
t.eq(str(game.phase), "results", "9-й час завершает забег")
t.ok(game.meta_unlocks.has("complete_run"), "complete_run выдан на 9-м часе")
t.ok(game.feature_unlocked("minigame_advanced"), "после 9 часов minigame_advanced открыт")
t.ok(game.run_meta_awards.has("complete_run"), "run_meta_awards: complete_run за текущий забег")
t.eq(game.run_meta_awards.size(), 3, "run_meta_awards: все 3 награды, без дублей")
t.ok(game.run_meta_awards.has("survive_hour_3") and game.run_meta_awards.has("survive_hour_5"),
"run_meta_awards хранит все награды забега")
# Идемпотентность: повторный вызов ничего не дублирует.
var size_before: int = game.meta_unlocks.size()
var again: Array = game.award_meta_for_run(9)
_eq(again.size(), 0, "повторный award_meta_for_run(9) ничего не выдаёт")
_eq(game.meta_unlocks.size(), size_before, "meta_unlocks не растёт при повторе")
t.eq(again.size(), 0, "повторный award_meta_for_run(9) ничего не выдаёт")
t.eq(game.meta_unlocks.size(), size_before, "meta_unlocks не растёт при повторе")
t.ok(again.is_empty(), "awarded пуст при повторе")
# Мета переживает reset_run и start_new_run.
game.reset_run()
_ok(game.run_meta_awards.is_empty(), "reset_run очищает run_meta_awards")
_ok(game.feature_unlocked("search"), "reset_run: search остаётся открыт")
t.ok(game.run_meta_awards.is_empty(), "reset_run очищает run_meta_awards")
t.ok(game.feature_unlocked("search"), "reset_run: search остаётся открыт")
t.ok(game.feature_unlocked("shield"), "reset_run: shield остаётся открыт")
t.ok(game.feature_unlocked("minigame_advanced"), "reset_run: minigame_advanced остаётся открыт")
game.start_new_run(12)
_eq(int(game.stats.get("hours_done", 0)), 0, "новый забег: часы сброшены")
_ok(game.feature_unlocked("shield"), "start_new_run: shield остаётся открыт")
t.eq(int(game.stats.get("hours_done", 0)), 0, "новый забег: часы сброшены")
t.ok(game.run_meta_awards.is_empty(), "start_new_run очищает run_meta_awards")
t.ok(game.feature_unlocked("search"), "start_new_run: search остаётся открыт")
t.ok(game.feature_unlocked("shield"), "start_new_run: shield остаётся открыт")
t.ok(game.feature_unlocked("minigame_advanced"), "start_new_run: minigame_advanced остаётся открыт")
game.free()
@@ -484,44 +320,3 @@ func _has_unlock_toast(toasts: Array) -> bool:
if str(text).contains("Открыто"):
return true
return false
# --- assert-хелперы --------------------------------------------------------
func _set_case(name: String) -> void:
_case = name
func _ok(condition: bool, message: String) -> bool:
_checks += 1
if condition:
return true
_fail(message)
return false
func _eq(actual, expected, message: String) -> bool:
_checks += 1
if actual == expected:
return true
_fail("%s — получено %s, ожидалось %s" % [message, str(actual), str(expected)])
return false
func _approx(actual: float, expected: float, eps: float, message: String) -> bool:
_checks += 1
if absf(actual - expected) <= eps:
return true
_fail("%s — получено %.6f, ожидалось %.6f" % [message, actual, expected])
return false
func _fail(message: String) -> void:
_fails += 1
print("FAIL [%s] %s" % [_case, message])
func _finish() -> void:
print("test_economy: проверок %d, провалов %d" % [_checks, _fails])
print("PASS test_economy" if _fails == 0 else "FAIL test_economy")
quit(0 if _fails == 0 else 1)
-449
View File
@@ -1,449 +0,0 @@
extends SceneTree
## Headless-тест клеточной сетки v2: чистая математика (cell size, label cell,
## поворот, hit-test, payload) + headless-инстансы FileGrid / SortingTable /
## DragController / FileShape без крашей.
##
## Запуск:
## godot --headless --path . --script tests/test_file_grid.gd
const FG = preload("res://scripts/ui/file_grid.gd")
const ST = preload("res://scripts/ui/sorting_table.gd")
const DC = preload("res://scripts/ui/drag_controller.gd")
const FS = preload("res://scripts/ui/file_shape.gd")
const FR = preload("res://scripts/core/file_registry.gd")
const TP = preload("res://scripts/ui/tree_panel.gd")
var _checks: int = 0
var _fails: int = 0
var _case: String = ""
func _init() -> void:
await process_frame
var game = root.get_node_or_null("Game")
if game == null:
_fail("autoload Game недоступен")
_finish()
return
_test_cell_sizes()
_test_hit_test()
_test_labels_and_rotation()
_test_payload_and_edges()
_test_plural()
game.start_new_run(1)
await process_frame
var registry = game.registry
var folder_id := _first_folder_id(registry)
if folder_id.is_empty():
_fail("нет стартовой папки")
_finish()
return
await _test_file_grid(registry, folder_id)
await _test_sorting_table(registry)
await _test_drag_controller(folder_id)
await _test_file_shape()
await _test_tree_panel()
_finish()
# --- математика ------------------------------------------------------------
func _test_cell_sizes() -> void:
_set_case("cell size")
var view := Vector2(558, 470)
_eq(FG.cell_size(view, 5, 4), 104.0, "5×4 при 558×470 → 104")
_eq(FG.cell_size(view, 6, 4), 88.0, "6×4 → 88")
_eq(FG.cell_size(view, 6, 5), 88.0, "6×5 → 88")
_eq(FG.cell_size(view, 7, 5), 74.0, "7×5 → 74 (floor)")
_eq(FG.cell_size(view, 7, 6), 73.0, "7×6 → 73")
_eq(FG.cell_size(view, 8, 6), 64.0, "8×6 → 64")
_eq(FG.cell_size(Vector2(20, 20), 5, 4), 56.0, "clamp снизу 56")
_eq(FG.cell_size(Vector2(4000, 4000), 5, 4), 104.0, "clamp сверху 104")
var table := FG.cell_size_pair(Vector2(416, 548), 4, 6, 6.0, 56.0, 110.0)
_eq(table, Vector2(99, 86), "стол 4×6 при 416×548 → 99×86")
_eq(ST.cell_size(Vector2(416, 548), 4, 6), Vector2(99, 86),
"SortingTable.cell_size → 99×86")
var table_min := FG.cell_size_pair(Vector2(100, 100), 4, 6, 6.0, 56.0, 110.0)
_eq(table_min, Vector2(56, 56), "стол clamp снизу 56")
var gs := FG.grid_size_of(5, 4, 104.0, 6.0)
_eq(gs, Vector2(544, 434), "размер поля 5×4 при cell=104")
var gs2 := FG.grid_size_pair(4, 6, Vector2(99, 86), 6.0)
_eq(gs2, Vector2(414, 546), "размер стола 4×6 при 99×86")
func _test_hit_test() -> void:
_set_case("hit test")
var o := Vector2.ZERO
_eq(FG.cell_at(Vector2(5, 5), o, 100.0, 10.0, 5, 4), Vector2i(0, 0),
"точка в первой клетке")
_eq(FG.cell_at(Vector2(115, 5), o, 100.0, 10.0, 5, 4), Vector2i(1, 0),
"точка во второй клетке")
_eq(FG.cell_at(Vector2(105, 5), o, 100.0, 10.0, 5, 4), Vector2i(-1, -1),
"зазор — не клетка")
_eq(FG.cell_at(Vector2(-1, 0), o, 100.0, 10.0, 5, 4), Vector2i(-1, -1),
"вне поля слева")
_eq(FG.cell_at(Vector2(560, 5), o, 100.0, 10.0, 5, 4), Vector2i(-1, -1),
"вне поля справа")
_eq(FG.nearest_cell(Vector2(106, 5), o, 100.0, 10.0, 5, 4), Vector2i(1, 0),
"drop в зазоре прилипает к ближней клетке")
_eq(FG.nearest_cell(Vector2(-30, -30), o, 100.0, 10.0, 5, 4),
Vector2i(-1, -1), "drop вне поля → auto-place")
var r := FG.cell_at_rect(Vector2(97, 95), o, Vector2(99, 86), 6.0, 4, 6)
_eq(r, Vector2i(0, 1), "прямоугольный hit-test стола")
func _test_labels_and_rotation() -> void:
_set_case("label cell")
_eq(FR.label_cell(FR.shape_cells("S4", 0)), Vector2i(1, 0),
"S: верхняя левая ЗАНЯТАЯ, не пустой угол bbox")
_eq(FR.label_cell(FR.shape_cells("Z4", 0)), Vector2i(0, 0), "Z: label (0,0)")
_eq(FR.label_cell(FR.shape_cells("L3c", 0)), Vector2i(1, 0),
"L3c: label (1,0), не угол bbox")
_eq(FR.label_cell(FR.shape_cells("T4a", 0)), Vector2i(0, 0), "T: label (0,0)")
_eq(FR.label_cell(FR.shape_cells("I4V", 0)), Vector2i(0, 0), "I4V: label (0,0)")
_eq(FG.label_cell(FG.shape_cells("S4", 0)), Vector2i(1, 0),
"FileGrid.label_cell делегирует реестру")
_eq(FG.anchor_origin(Vector2i(2, 2), "S4", 0), Vector2i(1, 2),
"origin для S при клике в (2,2)")
_eq(FG.anchor_origin(Vector2i(3, 3), "I4V", 0), Vector2i(3, 3),
"origin для I4V по label-клетке")
_set_case("rotation fit")
var r := FG.best_rotation("I4H", 0, func(rot: int) -> bool: return rot == 1)
_eq(r, 1, "первый подходящий поворот после текущего")
var r2 := FG.best_rotation("I4H", 3, func(rot: int) -> bool: return rot == 0)
_eq(r2, 0, "перебор 3→0")
var none := FG.best_rotation("I4H", 0, func(_rot: int) -> bool: return false)
_eq(none, -1, "ни один поворот не подходит")
func _test_payload_and_edges() -> void:
_set_case("payload / contour")
var p := FG.shape_payload_for("f1", "Договор", "text", "txt", "O1", 0,
Vector2i(2, 3), "folder")
_eq(str(p["kind"]), "file", "payload.kind")
_eq(str(p["id"]), "f1", "payload.id")
_eq(str(p["shape_id"]), "O1", "payload.shape_id")
_eq(p["origin"], Vector2i(2, 3), "payload.origin")
_eq(str(p["from"]), "folder", "payload.from")
_eq((p["cells"] as Array).size(), 1, "payload.cells")
_eq(FS.outer_edges(FR.shape_cells("O1", 0)).size(), 4, "O1: 4 внешних ребра")
_eq(FS.outer_edges(FR.shape_cells("O4", 0)).size(), 8, "O4: 8 внешних рёбер")
_eq(FS.outer_edges(FR.shape_cells("I4H", 0)).size(), 10, "I4H: 10 внешних рёбер")
var s_cells := FR.shape_cells("S4", 0)
var lbl := FR.label_cell(s_cells)
var top_empty := true
for cell in s_cells:
if int(cell.y) == 0 and int(cell.x) < lbl.x:
top_empty = false
_ok(top_empty, "левее label в верхней строке S нет занятых клеток")
func _test_plural() -> void:
_set_case("plural")
_eq(FG.plural_cells(1), "клетка", "1 клетка")
_eq(FG.plural_cells(2), "клетки", "2 клетки")
_eq(FG.plural_cells(24), "клетки", "24 клетки")
_eq(FG.plural_cells(20), "клеток", "20 клеток")
_eq(FG.plural_cells(11), "клеток", "11 клеток")
_eq(FG.plural_cells(21), "клетка", "21 клетка")
# --- headless-компоненты ---------------------------------------------------
func _test_file_grid(registry, folder_id: String) -> void:
_set_case("FileGrid")
var grid = FG.new()
grid.name = "TestFileGrid"
grid.size = Vector2(558, 470)
root.add_child(grid)
await process_frame
grid.set_folder(folder_id, "РАЗНОЕ", "C: \\ РАЗНОЕ")
await process_frame
_eq(grid.get_folder_id(), folder_id, "folder_id установлен")
_eq(grid.total_cells(), 20, "5×4 = 20 клеток")
_eq(grid.occupied_cells(), 0, "пустая папка: 0 клеток")
_eq(grid.file_count(), 0, "пустая папка: 0 файлов")
_eq(grid.footer_text(), "занято 0 из 20 · свободно 20", "пустой подвал")
var m: Dictionary = grid.grid_metrics()
_eq(float(m["cell"]), 104.0, "cell при 558×470 → 104")
_eq(int(m["cols"]), 5, "cols = 5")
_eq(int(m["rows"]), 4, "rows = 4")
_ok(grid.get_cards().is_empty(), "legacy get_cards() → []")
grid.set_files([{"id": "legacy", "name": "Старый"}])
_ok(grid.get_cards().is_empty(), "legacy set_files не строит карточки")
var file = registry.create_file("Договор 97", "text", folder_id)
_ok(file != null, "файл создан в папке")
if file != null:
grid.refresh()
_eq(grid.file_count(), 1, "после создания 1 файл")
_eq(grid.occupied_cells(), 1, "текст занимает 1 клетку")
_eq(grid.footer_text(), "занято 1 из 20 · свободно 19",
"подвал после создания")
_eq(grid.hidden_count(), 0, "опасных файлов нет")
var selected: Array = []
grid.selection_changed.connect(func(id: String) -> void: selected.append(id))
grid.select_file(str(file.id))
_eq(selected.size(), 1, "select_file → selection_changed")
_eq(str(selected[0]), str(file.id), "selection_changed с id")
grid.clear_selection()
_eq(selected.size(), 2, "clear_selection → второе событие")
_eq(str(selected[1]), "", "clear_selection шлёт пустой id")
var drops: Array = []
grid.file_dropped.connect(func(payload: Dictionary) -> void: drops.append(payload))
_ok(grid.accept_dropped_file({"kind": "file", "id": "x"}),
"legacy accept_dropped_file(file) → true")
_eq(drops.size(), 1, "legacy drop эмитит file_dropped")
var fit: Dictionary = grid._resolve_placement({
"kind": "file", "id": str(file.id), "shape_id": "O1",
"rotation": 0, "from": "folder",
}, Vector2i(4, 3), 0)
_ok(bool(fit.get("ok", false)), "точное размещение 1×1 в (4,3)")
_eq(fit.get("origin"), Vector2i(4, 3), "origin = клетка под курсором")
_eq(int(fit.get("rotation", -1)), 0, "поворот сохранён")
var ghost_fit: Dictionary = grid._resolve_placement({
"kind": "file", "id": str(file.id), "shape_id": "O1",
"rotation": 1, "from": "folder",
}, Vector2i(-1, -1), 1)
_ok(bool(ghost_fit.get("ok", false)), "auto-place находит место")
var rejected: Array = []
grid.drop_rejected.connect(func(reason: String) -> void: rejected.append(reason))
_ok(rejected.is_empty(), "без drop отказа нет")
registry.mark_failed(str(file.id))
grid.refresh()
_eq(grid.hidden_count(), 1, "failed считается опасным")
var sf: Array = []
grid.service_filter_changed.connect(func(on: bool) -> void: sf.append(on))
grid.set_service_filter(true)
_eq(sf.size(), 1, "service_filter_changed эмитится")
_ok(bool(sf[0]), "service_filter_changed(true)")
root.remove_child(grid)
grid.queue_free()
func _test_sorting_table(registry) -> void:
_set_case("SortingTable")
var table = ST.new()
table.name = "TestSortingTable"
table.size = Vector2(416, 548)
root.add_child(table)
await process_frame
table.refresh()
await process_frame
_eq(table.total_cells(), 24, "стол 4×6 = 24 клетки")
_eq(table.occupied_cells(), 0, "пустой стол")
_eq(table.footer_text(), "24 клетки · занято 0", "подвал пустого стола")
var m: Dictionary = table.grid_metrics()
_eq(m["cell"], Vector2(99, 86), "cell стола 99×86")
_eq(int(m["cols"]), 4, "стол: 4 колонки")
_eq(int(m["rows"]), 6, "стол: 6 строк")
var file = registry.create_file("Стол 1", "text", "", &"table")
_ok(file != null, "файл создан на столе")
if file != null:
table.refresh()
_eq(table.file_count(), 1, "на столе 1 файл")
_eq(table.occupied_cells(), 1, "на столе 1 клетка")
_eq(table.footer_text(), "24 клетки · занято 1", "подвал стола с файлом")
var fit: Dictionary = table._resolve_placement({
"kind": "file", "id": str(file.id), "shape_id": "O1",
"rotation": 0, "from": "table",
}, Vector2i(3, 5), 0)
_ok(bool(fit.get("ok", false)), "точное размещение на столе")
var auto: Dictionary = table._resolve_placement({
"kind": "file", "id": str(file.id), "shape_id": "O1",
"rotation": 0, "from": "folder",
}, Vector2i(-1, -1), 0)
_ok(bool(auto.get("ok", false)), "auto-place на столе")
var bad: Dictionary = table._resolve_placement({
"kind": "file", "id": str(file.id), "shape_id": "I4H",
"rotation": 0, "from": "folder",
}, Vector2i(3, 5), 0)
_ok(not bool(bad.get("ok", false)), "I4H не влезает в правый край стола")
table.set_visible_data(false)
_ok(not table.visible, "set_visible_data(false) скрывает стол")
table.set_visible_data(true)
_ok(table.visible, "set_visible_data(true) показывает стол")
root.remove_child(table)
table.queue_free()
func _test_drag_controller(folder_id: String) -> void:
_set_case("DragController")
var dc = DC.new()
dc.name = "TestDragController"
root.add_child(dc)
await process_frame
dc.begin(null, "shape")
_eq(dc.mode(), "shape", "режим shape")
_ok(dc.preview() == null, "begin(null) не создаёт превью")
dc.set_mode("square")
_eq(dc.mode(), "square", "режим square")
dc.set_mode("nonsense")
_eq(dc.mode(), "shape", "неизвестный режим → shape")
var opened: Array = []
dc.hover_open.connect(func(id: String) -> void: opened.append(id))
dc.hover_folder(folder_id)
dc._process(0.10)
_eq(opened.size(), 0, "250 мс ещё не истекли")
dc._process(0.20)
_eq(opened.size(), 1, "hover_open через 250 мс")
_eq(str(opened[0]), folder_id, "hover_open с folder_id")
dc.hover_folder(folder_id)
dc._process(0.50)
_eq(opened.size(), 1, "повторный hover не дублирует open")
dc.reset_hover()
dc._process(0.50)
_eq(opened.size(), 1, "reset_hover отменяет таймер")
dc.hover_folder("другая")
dc._process(0.30)
_eq(opened.size(), 2, "новая папка открывается отдельно")
var preview = FS.new()
preview.setup("video", "O4", 0, "Видео", "mp4", 40.0)
_eq(preview.custom_minimum_size, Vector2(80, 80), "превью O4 40 px → 80×80")
dc.begin(preview, "shape")
_ok(dc.preview() == preview, "begin сохраняет превью")
dc.set_mode("square")
_eq(preview.get_mode(), "square", "set_mode(SQUARE) обновляет превью")
_eq(preview.custom_minimum_size, Vector2(40, 40), "square 1×1 = 40×40")
dc.end()
_ok(dc.preview() == null, "end() освобождает превью")
dc.end()
_ok(dc.preview() == null, "повторный end() не падает")
root.remove_child(dc)
dc.queue_free()
func _test_file_shape() -> void:
_set_case("FileShape")
var shape = FS.new()
shape.setup("photo", "L3a", 0, "Фото", "jpg", 48.0)
_eq(shape.custom_minimum_size, Vector2(96, 96), "L3a bbox 2×2 при 48 px")
shape.set_mode("square")
_eq(shape.custom_minimum_size, Vector2(48, 48), "square → 1×1")
shape.set_mode("shape")
shape.shape_rotation = 1
shape.queue_redraw()
root.add_child(shape)
shape.queue_redraw()
await process_frame
_ok(shape.size.x > 0.0, "FileShape отрисован без краша")
root.remove_child(shape)
shape.queue_free()
func _test_tree_panel() -> void:
_set_case("TreePanel / FolderRow")
var tree = TP.new()
tree.name = "TestTreePanel"
tree.size = Vector2(280, 400)
root.add_child(tree)
await process_frame
tree.set_data([{
"id": "C", "name": "C:", "free_cells": 7,
"folders": [
{"id": "f1", "name": "Разное", "files": 2,
"occupied_cells": 5, "total_cells": 20},
{"id": "f2", "name": "Входящие", "files": 0,
"occupied_cells": 0, "total_cells": 20},
],
}])
await process_frame
_eq(tree.footer_text(), "2 папок · 2 файлов · 5 клеток занято",
"подвал дерева: папки/файлы/клетки")
var section_row = null
var folder_row = null
for e in tree.get("_rows"):
var row = e["row"]
if row.is_section:
section_row = row
elif row.folder_id == "f1":
folder_row = row
_ok(section_row != null, "секция построена")
if section_row != null:
_eq(section_row.free_text, "свободно 7 клеток", "секция: свободно 7 клеток")
_ok(folder_row != null, "папка построена")
if folder_row != null:
_eq(folder_row.count, 5, "FolderRow.count = занятые клетки")
_eq(folder_row.capacity, 20, "FolderRow.capacity = всего клеток")
_eq(folder_row.files_count, 2, "FolderRow.files_count = файлы")
_eq(folder_row.tooltip_text, "2 файлов · 5 из 20 клеток",
"тултип папки в клетках")
var opened: Array = []
tree.folder_selected.connect(func(id: String) -> void: opened.append(id))
folder_row.set_hover(true)
_eq(opened.size(), 1, "hover-open открывает папку через сигнал")
_eq(str(opened[0]), "f1", "hover-open с folder_id")
_ok(folder_row.hover_open, "визуал hover_open включён")
folder_row.set_hover(false)
var packets: Array = []
tree.packet_dropped.connect(func(fid: String, payload: Dictionary) -> void:
packets.append([fid, payload]))
folder_row._drop_data(Vector2.ZERO, {"kind": "packet", "id": "p1"})
_eq(packets.size(), 1, "packet drop из FolderRow")
_eq(str(packets[0][0]), "f1", "packet drop: folder_id")
tree.update_stats("f1", 3, 7, 24)
_eq(folder_row.count, 7, "update_stats обновляет клетки")
_eq(folder_row.capacity, 24, "update_stats обновляет ёмкость")
_eq(tree.footer_text(), "2 папок · 3 файлов · 7 клеток занято",
"подвал после update_stats")
root.remove_child(tree)
tree.queue_free()
# --- helpers ---------------------------------------------------------------
func _first_folder_id(registry) -> String:
if registry == null:
return ""
for sec in registry.sections.values():
if sec.folder_ids.size() > 0:
return str(sec.folder_ids[0])
return ""
func _set_case(name: String) -> void:
_case = name
func _ok(condition: bool, message: String) -> bool:
_checks += 1
if condition:
return true
_fail(message + " — ожидалось true")
return false
func _eq(actual, expected, message: String) -> bool:
_checks += 1
if actual == expected:
return true
_fail("%s — получено %s, ожидалось %s" % [_case + ": " + message,
str(actual), str(expected)])
return false
func _fail(message: String) -> void:
_fails += 1
printerr("FAIL [%s] %s" % [_case, message])
func _finish() -> void:
print("")
print("Проверок: %d, провалов: %d" % [_checks, _fails])
if _fails == 0:
print("PASS test_file_grid")
quit(0)
else:
print("FAIL test_file_grid")
quit(1)
-1
View File
@@ -1 +0,0 @@
uid://camwn2i5i06r1
+110 -129
View File
@@ -1,13 +1,12 @@
extends SceneTree
## Полный забег 9 часов без UI: GameController + HourDirector + детерминированный бот.
## Полный забег 9 часов без UI: GameController + HourDirector + автоигрок.
##
## Запуск:
## godot --headless --path . --script tests/test_full_playthrough.gd
##
## Бот v2: принимает пакеты drag&drop-эквивалентом, прикрепляет доступные файлы,
## явно жмёт start_send, сразу завершает мини-игру отправки q=0.8 и отражает
## атаки q=1.0. Проверяются сиды 1, 42, 777: 9 часов, run_result won, SLA>0,
## хотя бы одна успешная отправка, никакого soft-lock.
## Игрок: соединяет TCP, принимает пакеты в папки, прикрепляет required,
## отправляет запросы, отражает атаки (щит/карантин/отказ от фишинга),
## покупает апгрейды и расширяет склад. Проверяются 3 сида: 1, 42, 777.
const GC := preload("res://scripts/game_controller.gd")
@@ -15,14 +14,15 @@ const SEEDS: Array[int] = [1, 42, 777]
const MAX_ITER_PER_HOUR := 20000
const HOURS: int = 9
const BUY_ORDER: Array[String] = [
"lot_sla",
"lot_channel",
"lot_money",
"lot_speed",
"lot_timer",
"lot_folder",
"lot_slots",
"lot_shield",
"shop_sla",
"shop_buffer",
"shop_slot3",
"shop_timer",
"shop_channel",
"shop_slot4",
"shop_zone",
"shop_search",
"shop_shield",
]
var _checks: int = 0
@@ -53,8 +53,6 @@ func _init() -> void:
func _run_seed(seed: int) -> void:
_set_case("seed_%d" % seed)
# Глобальный RNG (магазин/утилиты) тоже детерминируем по сиду забега.
seed(int(seed))
_folder_seq = 0
_controller.new_run(seed)
_eq(str(_controller.phase), "prep", "seed %d: после new_run фаза prep" % seed)
@@ -70,18 +68,13 @@ func _run_seed(seed: int) -> void:
_fail("seed %d: перед часом %d фаза %s" % [seed, hour_index, _controller.phase])
break
_prepare()
_folder_seq = maxi(_folder_seq, 0)
if not _controller.start_hour():
_fail("seed %d: start_hour() для часа %d вернул false" % [seed, hour_index])
break
hours_played += 1
var hd = _controller.hour_director
hd.set_time_scale(16.0)
# Детерминированные мини-игры: без реального ожидания и wall time.
hd.send_game_factory = func(_id, _tier, _seed, _ctx):
return StubGame.new()
if hd.attack_director != null:
hd.attack_director.create_game_override = func(_kind, _tier, _seed, _ctx):
return StubGame.new()
var iterations := 0
while str(_controller.phase) == "hour" and iterations < MAX_ITER_PER_HOUR:
_controller.tick(0.1)
@@ -97,30 +90,26 @@ func _run_seed(seed: int) -> void:
])
break
# Час завершён (hour_result) или забег проигран (run_result).
if str(_controller.phase) == "hour_result":
var report: Dictionary = _controller.current_report
_eq(int(report.get("hour", 0)), hour_index, "seed %d: отчёт за час %d" % [seed, hour_index])
_ok(report.has("earned") and report.has("completed") and report.has("failed")
and report.has("attacks") and report.has("sla_delta"),
"seed %d, час %d: отчёт содержит ключи" % [seed, hour_index])
_ok(not is_nan(float(report.get("sla_delta", 0.0))),
"seed %d, час %d: sla_delta не NaN" % [seed, hour_index])
_ok(hd.active_packets.is_empty(),
"seed %d, час %d: нет активных пакетов после часа" % [seed, hour_index])
_ok(hd.active_requests.is_empty(),
"seed %d, час %d: нет активных запросов после часа" % [seed, hour_index])
_ok(not is_nan(float(report.get("sla_delta", 0.0))), "seed %d, час %d: sla_delta не NaN" % [seed, hour_index])
# После _finish_hour сущности уже очищены.
_ok(hd.active_packets.is_empty(), "seed %d, час %d: нет активных пакетов после часа" % [seed, hour_index])
_ok(hd.active_requests.is_empty(), "seed %d, час %d: нет активных запросов после часа" % [seed, hour_index])
if hd.slots != null:
_eq(int(hd.slots.used_count()), 0,
"seed %d, час %d: слоты свободны после часа" % [seed, hour_index])
_ok(int(report.get("packets_left", -1)) == 0,
"seed %d, час %d: packets_left = 0" % [seed, hour_index])
_ok(int(report.get("requests_left", -1)) == 0,
"seed %d, час %d: requests_left = 0" % [seed, hour_index])
_eq(int(hd.slots.used_count()), 0, "seed %d, час %d: слоты свободны после часа" % [seed, hour_index])
_ok(int(report.get("packets_left", -1)) == 0, "seed %d, час %d: packets_left = 0" % [seed, hour_index])
_ok(int(report.get("requests_left", -1)) == 0, "seed %d, час %d: requests_left = 0" % [seed, hour_index])
_ok(int(_game.money) >= 0, "seed %d, час %d: money >= 0" % [seed, hour_index])
_ok(not is_nan(float(_game.sla)),
"seed %d, час %d: SLA не NaN" % [seed, hour_index])
_ok(not is_nan(float(_game.sla)), "seed %d, час %d: SLA не NaN" % [seed, hour_index])
_controller.next_after_hour()
else:
# Поражение до конца часа (обычно SLA = 0).
lost_at_hour = hour_index
loss_reason = str(_controller.run_result().get("reason", "unknown"))
_ok(false, "seed %d: забег завершён досрочно в часе %d (reason=%s, SLA=%.1f)" % [
@@ -145,8 +134,6 @@ func _run_seed(seed: int) -> void:
_ok(int(result.get("hours", 0)) == HOURS, "seed %d: run_result().hours = 9" % seed)
_ok(int(_game.money) >= 0, "seed %d: money >= 0" % seed)
_ok(float(_game.sla) > 0.0, "seed %d: SLA > 0" % seed)
_ok(int(_game.stats.get("requests_done", 0)) > 0, "seed %d: files sent > 0" % seed)
_ok(int(result.get("money_earned", 0)) > 0, "seed %d: earned > 0" % seed)
_ok(str(_controller.phase) == "run_result", "seed %d: финальная фаза run_result" % seed)
_seed_results.append({
@@ -163,86 +150,117 @@ func _run_seed(seed: int) -> void:
})
# --- действия бота ---------------------------------------------------------
# --- действия игрока -------------------------------------------------------
func _play_tick() -> void:
var hd = _controller.hour_director
if hd == null:
return
# 1. Атаки: сначала защита, чтобы не занести шифровальщик/мусор.
if hd.attack_director != null:
for attack_id in hd.attack_director.active_attacks.keys().duplicate():
if not hd.attack_director.active_attacks.has(attack_id):
continue
var kind := str(hd.attack_director.active_attacks[attack_id].get("kind", ""))
if kind == "ddos":
hd.hold_shield(1.6)
elif kind == "phishing":
var request_id := str(hd.attack_director.active_attacks[attack_id].get("request_id", ""))
if hd.active_requests.has(request_id):
hd.reject_request(request_id)
else:
hd.quarantine_attack(str(attack_id))
else:
hd.quarantine_attack(str(attack_id))
# 1. Атака: мгновенно отражаем, иначе час стоит на паузе.
var ad = hd.attack_director
if ad != null and ad.is_minigame_active():
ad._on_minigame_finished(1.0)
# 2. Мини-игра отправки: сразу q=0.8.
if hd.is_minigame_active():
hd._on_send_minigame_finished(0.8)
# 3. Пакеты: принимаем в первую подходящую папку (или создаём новую).
# 2. Пакеты: TCP сначала соединяем, затем принимаем. Пакеты-атаки
# обрабатываются выше (карантин), в папку их не несём.
for packet_id in hd.active_packets.keys().duplicate():
if not hd.active_packets.has(packet_id):
continue
var packet = hd.active_packets[packet_id]
if str(packet.state) != "waiting":
if packet.is_attack:
continue
_try_receive(hd, str(packet_id))
var folder_id := _find_folder(hd)
if folder_id.is_empty():
_ensure_storage()
folder_id = _find_folder(hd)
if folder_id.is_empty():
continue
if str(packet.protocol) == "tcp" and str(packet.state) == "waiting":
if not hd.connect_packet(str(packet_id)):
continue
_finish_connection(str(packet_id))
if str(packet.state) == "connected" or str(packet.protocol) == "udp":
hd.receive_packet(str(packet_id), folder_id)
# 4. Запросы: докладываем файлы, затем явный start_send.
# 3. Запросы: прикрепить required, соединить TCP, отправить.
for req_id in hd.active_requests.keys().duplicate():
if not hd.active_requests.has(req_id):
continue
var req = hd.active_requests[req_id]
if req.sending or req.minigame_active or hd.is_request_queued(req_id):
if req.is_attack:
# Фишинг уже обработан в блоке атак; на всякий случай не отправляем.
if req.is_phishing():
hd.reject_request(str(req_id))
continue
var need := int(hd.request_required_count(req_id))
var guard := 0
while req.attached.size() < need and guard < 8:
guard += 1
var fid := _available_file(hd)
if fid.is_empty():
break
if not hd.attach_file(req_id, fid):
break
if req.attached.size() >= need:
if hd.start_send(req_id):
if hd.is_minigame_active():
hd._on_send_minigame_finished(0.8)
elif float(req.time_left) < 2.0:
hd.reject_request(req_id)
for file_id in req.required:
var file = hd.registry.files.get(str(file_id))
if file == null:
continue
if str(file.location) == "folder" or str(file.location) == "registry":
if str(file.attached_request).is_empty():
hd.attach_file(str(req_id), str(file_id))
if str(req.protocol) == "tcp" and not bool(req.connected) and not bool(req.sending):
if hd.connect_request(str(req_id)):
_finish_connection(str(req_id))
# Отправка автоматическая: udp стартует после attach, tcp — после
# успешного соединения. Если файлы собрать не удалось — отказ.
if not req.is_ready() and float(req.time_left) < 3.0:
hd.reject_request(str(req_id))
func _try_receive(hd, packet_id: String) -> bool:
var registry = hd.registry
if registry == null:
return false
for folder in registry.folders.values():
if hd.is_folder_locked(str(folder.id)):
continue
if hd.receive_packet(packet_id, str(folder.id)):
return true
# Ни одна папка не подошла по фигуре — расширяем склад.
for section in registry.sections.values():
var count := int(registry._folder_count_in_section(str(section.id)))
if count >= 12:
continue
_folder_seq += 1
var folder = registry.create_folder(str(section.id), "auto_%d" % _folder_seq)
if folder != null:
return hd.receive_packet(packet_id, str(folder.id))
return false
func _finish_connection(entity_id: String) -> void:
var hd = _controller.hour_director
var node = hd._connection_games.get(entity_id)
hd._on_connection_finished(true, entity_id)
if node != null and is_instance_valid(node):
node.free()
func _available_file(hd) -> String:
func _find_folder(hd) -> String:
if hd.registry == null:
return ""
for file in hd.registry.get_available_files():
if str(file.location) == "folder" and str(file.attached_request).is_empty():
return str(file.id)
for folder in hd.registry.folders.values():
if hd.is_folder_locked(str(folder.id)):
continue
if folder.has_free_slot(16):
return str(folder.id)
return ""
func _ensure_storage() -> void:
var registry = _game.registry
if registry == null:
return
if not _find_folder(_controller.hour_director).is_empty():
return
# Свободная папка в существующей секции.
for section in registry.sections.values():
var count := int(registry._folder_count_in_section(str(section.id)))
if count < 12:
_folder_seq += 1
registry.create_folder(str(section.id), "auto_%d" % _folder_seq)
return
# Все секции заполнены — покупаем новую секцию (если хватает денег).
if _controller.buy("shop_section"):
for section in registry.sections.values():
if int(registry._folder_count_in_section(str(section.id))) < 12:
_folder_seq += 1
registry.create_folder(str(section.id), "auto_%d" % _folder_seq)
return
func _prepare() -> void:
_ensure_storage()
for item_id in BUY_ORDER:
var guard := 0
while _controller.buy(item_id) and guard < 8:
@@ -295,43 +313,6 @@ func _finish() -> void:
print("")
if _controller != null:
_controller.free()
_controller = null
_stop_audio()
await process_frame
await process_frame
await process_frame
var audio = root.get_node_or_null("Audio")
if audio != null:
audio.queue_free()
await process_frame
await create_timer(0.1).timeout
print("test_full_playthrough: проверок %d, провалов %d" % [_checks, _fails])
print("PASS test_full_playthrough" if _fails == 0 else "FAIL test_full_playthrough")
quit(0 if _fails == 0 else 1)
func _stop_audio() -> void:
var audio = root.get_node_or_null("Audio")
if audio == null:
return
if audio.has_method("stop_music"):
audio.call("stop_music")
for child in audio.get_children():
if child is AudioStreamPlayer:
var player := child as AudioStreamPlayer
player.stop()
player.stream = null
if audio.get("_sfx_streams") is Dictionary:
(audio.get("_sfx_streams") as Dictionary).clear()
if audio.get("_music_streams") is Dictionary:
(audio.get("_music_streams") as Dictionary).clear()
## Мини-игра-заглушка: соединение finished есть, но игра сама не тикает.
class StubGame:
extends Node
signal finished(quality: float)
var running := true
var id := "stub"
+111 -201
View File
@@ -1,13 +1,13 @@
extends SceneTree
## Fuzz/инварианты реестра v2 + точечные проверки расхода файлов и отмены.
## Fuzz/инварианты реестра + точечные проверки cooldown и отмены запросов
## по failed-файлу.
##
## Запуск:
## godot --headless --path . --script tests/test_fuzz.gd
##
## Все шаги детерминированы (фиксированный seed). Виртуальное время
## передаётся в lock/tick явно, поэтому тест не зависит от системных часов.
## v2: cooldown после отправки удалён — файл расходуется при успехе и
## сохраняется при отмене/таймауте.
## передаётся в lock/cooldown/tick явно, поэтому тест не зависит от
## системных часов и порядка выполнения.
const FR := preload("res://scripts/core/file_registry.gd")
const HD := preload("res://scripts/core/hour_director.gd")
@@ -35,10 +35,10 @@ func _init() -> void:
_finish()
return
_fuzz_registry()
_consumption_case()
_cooldown_case()
_attach_once_case()
_failed_file_detaches()
_send_consumes_case()
_failed_cancels_all_requests()
_send_cooldown_case()
_finish()
@@ -69,7 +69,7 @@ func _fuzz_registry() -> void:
13: _op_destroy_malicious()
14: _op_lock()
15: _op_unlock()
16: _op_table()
16: _op_reserve_or_release()
17: _op_tick()
_check_invariants("шаг %d" % step)
@@ -117,10 +117,10 @@ func _op_delete_folder() -> void:
return
var folder_id := _pick_key(_reg.folders)
var folder = _reg.folders[folder_id]
var expected: bool = folder.count() == 0 and folder.reserved.is_empty()
var expected: bool = folder.count() == 0 and int(folder.reserved) == 0
if expected:
for f in _reg.files.values():
if str(f.folder_id) == folder_id and str(f.location) != "table":
if str(f.folder_id) == folder_id:
expected = false
break
_ok(_reg.delete_folder(folder_id) == expected, "delete_folder")
@@ -132,11 +132,19 @@ func _op_create_file() -> void:
_seq += 1
var types: Array[String] = ["text", "sound", "photo", "video"]
var file_type := types[_rng.randi_range(0, types.size() - 1)]
# v2: создаём в registry (без места в папке), затем двигаем move-операцией.
var file = _reg.create_file("fz_%d" % _seq, file_type, "", &"registry")
_ok(file != null, "create_file в registry")
if file != null:
_eq(str(file.location), "registry", "create_file: location registry")
var use_folder: bool = _rng.randf() < 0.65 and not _reg.folders.is_empty()
if use_folder:
var folder_id := _pick_key(_reg.folders)
var folder = _reg.folders[folder_id]
var has_room: bool = folder.has_free_slot(FR.MAX_FILES_PER_FOLDER)
var file = _reg.create_file("fz_%d" % _seq, file_type, folder_id, &"folder")
if has_room:
_ok(file != null, "create_file в папку при наличии места")
else:
_ok(file == null, "create_file: отказ при переполнении папки")
else:
var file = _reg.create_file("fz_%d" % _seq, file_type, "", &"registry")
_ok(file != null, "create_file в registry")
func _op_move_file() -> void:
@@ -146,23 +154,23 @@ func _op_move_file() -> void:
var target_id := _pick_key(_reg.folders)
var file = _reg.files[file_id]
var target = _reg.folders[target_id]
var loc := str(file.location)
if not str(file.attached_request).is_empty():
_ok(not _reg.move_file(file_id, target_id), "move_file: attached запрещён")
return
if loc != "folder" and loc != "registry" and loc != "table":
_ok(not _reg.move_file(file_id, target_id), "move_file: %s запрещён" % loc)
return
# Перемещение в ту же папку не проверяем: first-fit видит собственные клетки.
if loc == "folder" and str(file.folder_id) == target_id:
return
var expected: bool = not _reg._find_fit(target, str(file.shape_id), int(file.rotation)).is_empty()
var expected := true
if str(file.location) != "folder" and str(file.location) != "registry":
expected = false
elif not str(file.attached_request).is_empty():
expected = false
elif file.folder_id == target_id and str(file.location) == "folder":
expected = true
else:
var occupied: int = target.count()
if file.folder_id == target_id:
occupied -= 1
expected = occupied + int(target.reserved) < FR.MAX_FILES_PER_FOLDER
var result: bool = _reg.move_file(file_id, target_id)
_ok(result == expected, "move_file")
if result:
_eq(str(file.location), "folder", "move_file: location folder")
_eq(str(file.folder_id), target_id, "move_file: folder_id")
_eq(target.file_ids.count(file_id), 1, "move_file: ровно одна запись в папке")
_ok(str(file.location) == "folder" and file.folder_id == target_id, "move_file: состояние файла")
_ok(target.file_ids.count(file_id) == 1, "move_file: ровно одна запись в папке")
func _op_attach() -> void:
@@ -175,8 +183,7 @@ func _op_attach() -> void:
var result: bool = _reg.attach_file(file_id, req_id, _now)
_ok(result == expected, "attach_file")
if expected:
_ok(str(file.location) == "attached" and str(file.attached_request) == req_id,
"attach: состояние")
_ok(str(file.location) == "attached" and str(file.attached_request) == req_id, "attach: состояние")
func _op_detach() -> void:
@@ -196,14 +203,10 @@ func _op_mark_failed() -> void:
return
var file_id := _pick_key(_reg.files)
var file = _reg.files[file_id]
# Стол не участвует в failed/malicious-сценариях игры.
if str(file.location) == "table":
return
_ok(_reg.mark_failed(file_id), "mark_failed")
_ok(str(file.location) == "failed", "mark_failed: location")
_ok(str(file.attached_request).is_empty(), "mark_failed: откреплён")
_ok(float(file.lock_until) <= 0.0 and float(file.cooldown_until) <= 0.0,
"mark_failed: таймеры сброшены")
_ok(float(file.lock_until) <= 0.0 and float(file.cooldown_until) <= 0.0, "mark_failed: таймеры сброшены")
_ok(not _reg.attach_file(file_id, "rX", _now), "failed не attach")
@@ -212,8 +215,6 @@ func _op_mark_malicious() -> void:
return
var file_id := _pick_key(_reg.files)
var file = _reg.files[file_id]
if str(file.location) == "table":
return
_ok(_reg.mark_malicious(file_id, "test"), "mark_malicious")
_ok(str(file.location) == "malicious", "mark_malicious: location")
_ok(str(file.malicious_kind) == "test", "mark_malicious: kind")
@@ -225,8 +226,6 @@ func _op_remove() -> void:
return
var file_id := _pick_key(_reg.files)
var file = _reg.files[file_id]
if str(file.location) == "table":
return
var expected: bool = str(file.location) == "malicious" or str(file.location) == "failed"
var result: bool = _reg.remove_file(file_id)
_ok(result == expected, "remove_file")
@@ -248,10 +247,6 @@ func _op_lock() -> void:
return
var file_id := _pick_key(_reg.files)
var file = _reg.files[file_id]
# Атака блокирует только доступные файлы (не стол/не прикреплённые).
if str(file.location) == "table" or str(file.location) == "attached" \
or not str(file.attached_request).is_empty():
return
var expected: bool = str(file.location) != "failed" and str(file.location) != "malicious"
var result: bool = _reg.lock_file(file_id, 5.0, _now)
_ok(result == expected, "lock_file")
@@ -271,45 +266,27 @@ func _op_unlock() -> void:
var result: bool = _reg.unlock_file(file_id)
_ok(result == expected, "unlock_file")
if expected:
var loc := str(file.location)
_ok(loc == "folder" or loc == "registry" or loc == "table", "unlock: location")
_ok(str(file.location) == "folder" or str(file.location) == "registry", "unlock: location")
_ok(float(file.lock_until) <= 0.0, "unlock: lock_until = 0")
## Стол сортировки: файл можно унести на стол и вернуть в папку.
func _op_table() -> void:
func _op_reserve_or_release() -> void:
if _reg.folders.is_empty():
return
var folder_id := _pick_key(_reg.folders)
var folder = _reg.folders[folder_id]
if _rng.randf() < 0.5:
if _reg.files.is_empty():
return
var file_id := _pick_key(_reg.files)
var file = _reg.files[file_id]
var loc := str(file.location)
if loc == "table":
return
if loc != "folder" and loc != "registry":
return
if not str(file.attached_request).is_empty():
return
var fit_ok: bool = not _reg._find_table_fit(str(file.shape_id), int(file.rotation)).is_empty()
var result: bool = _reg.move_file_to_table(file_id)
_ok(result == fit_ok, "move_file_to_table")
if result:
_eq(str(file.location), "table", "table: location")
_ok(_reg.is_table_file(file_id), "table: is_table_file")
var expected: bool = folder.count() + int(folder.reserved) < FR.MAX_FILES_PER_FOLDER
var before: int = int(folder.reserved)
_ok(_reg.reserve_slot(folder_id) == expected, "reserve_slot")
if expected:
_ok(int(folder.reserved) == before + 1, "reserve_slot: +1")
else:
var table_ids := []
for id in _reg.files.keys():
if str(_reg.files[id].location) == "table":
table_ids.append(id)
if table_ids.is_empty():
return
var file_id := str(table_ids[_rng.randi_range(0, table_ids.size() - 1)])
var file = _reg.files[file_id]
var has_target: bool = not _reg._first_folder_id_with_space(file).is_empty()
var result: bool = _reg.move_from_table(file_id)
_ok(result == has_target, "move_from_table")
if result:
_eq(str(file.location), "folder", "table: вернулся в папку")
var expected: bool = int(folder.reserved) > 0
var before: int = int(folder.reserved)
_ok(_reg.release_slot(folder_id) == expected, "release_slot")
if expected:
_ok(int(folder.reserved) == before - 1, "release_slot: -1")
func _op_tick() -> void:
@@ -324,9 +301,10 @@ func _check_invariants(tag: String) -> void:
errors.append_array(_reg.validate())
var membership := {}
var folder_owner := {}
for folder in _reg.folders.values():
if folder.cells.size() > folder.total_cells():
errors.append("клеток в папке %s больше %d" % [folder.id, folder.total_cells()])
if folder.count() + int(folder.reserved) > FR.MAX_FILES_PER_FOLDER:
errors.append("capacity папки %s > %d" % [folder.id, FR.MAX_FILES_PER_FOLDER])
var seen := {}
for file_id in folder.file_ids:
var fid := str(file_id)
@@ -335,9 +313,11 @@ func _check_invariants(tag: String) -> void:
continue
seen[fid] = int(seen.get(fid, 0)) + 1
membership[fid] = int(membership.get(fid, 0)) + 1
if folder_owner.has(fid):
errors.append("файл %s в папках %s и %s" % [fid, folder_owner[fid], folder.id])
folder_owner[fid] = folder.id
if str(_reg.files[fid].folder_id) != str(folder.id):
errors.append("файл %s лежит в %s, но folder_id=%s" % [
fid, folder.id, _reg.files[fid].folder_id])
errors.append("файл %s лежит в %s, но folder_id=%s" % [fid, folder.id, _reg.files[fid].folder_id])
for fid in seen.keys():
if int(seen[fid]) > 1:
errors.append("файл %s дважды в папке %s" % [fid, folder.id])
@@ -348,23 +328,16 @@ func _check_invariants(tag: String) -> void:
var count := int(membership.get(fid, 0))
if count > 1:
errors.append("файл %s в двух папках" % fid)
if loc == "table":
if count != 0:
errors.append("файл %s на столе, но в folder.file_ids" % fid)
if not _reg.is_table_file(fid):
errors.append("файл %s имеет location=table, но не в _table_cells" % fid)
elif str(file.folder_id).is_empty():
if count != 0:
errors.append("файл %s без папки, но в folder.file_ids" % fid)
elif count != 1:
errors.append("файл %s должен быть ровно в одной папке (location=%s)" % [fid, loc])
if str(file.folder_id).is_empty() and count != 0:
errors.append("файл %s без папки, но в folder.file_ids" % fid)
if not str(file.folder_id).is_empty() and count != 1:
errors.append("файл %s должен быть ровно в одной папке" % fid)
if loc == "attached":
if str(file.attached_request).is_empty():
errors.append("attached-файл %s без запроса" % fid)
elif not str(file.attached_request).is_empty():
errors.append("файл %s прикреплён, но location=%s" % [fid, loc])
if (loc == "failed" or loc == "locked" or loc == "malicious") \
and not str(file.attached_request).is_empty():
if (loc == "failed" or loc == "locked" or loc == "malicious") and not str(file.attached_request).is_empty():
errors.append("%s-файл %s прикреплён" % [loc, fid])
if not FR.FILE_ITEM_SCRIPT.LOCATIONS.has(loc):
errors.append("неверный location %s" % loc)
@@ -377,8 +350,7 @@ func _check_invariants(tag: String) -> void:
for folder_id in section.folder_ids:
section_folders += 1
if not _reg.folders.has(str(folder_id)):
errors.append("секция %s ссылается на несуществующую папку %s" % [
section.id, folder_id])
errors.append("секция %s ссылается на несуществующую папку %s" % [section.id, folder_id])
if seen_folders.has(str(folder_id)):
errors.append("папка %s дважды в секции %s" % [folder_id, section.id])
seen_folders[str(folder_id)] = true
@@ -408,29 +380,22 @@ func _check_invariants(tag: String) -> void:
# --- точечные сценарии -----------------------------------------------------
## v2: файл расходуется при успешной отправке и исчезает из папки.
func _consumption_case() -> void:
_set_case("consumption")
func _cooldown_case() -> void:
_set_case("cooldown")
_reg = FR.new()
_reg.setup_new_run()
var folder_id := _first_folder(_reg)
var folder = _reg.folders[folder_id]
var file = _reg.create_file("consume.txt", "text", folder_id)
_ok(file != null, "consumption: файл создан")
var file = _reg.create_file("cooldown.txt", "text", folder_id)
_ok(file != null, "cooldown: файл создан")
if file == null:
return
var fid := str(file.id)
var base: float = 50000.0
_ok(_reg.attach_file(fid, "req_a", base), "consumption: прикреплён")
_ok(_reg.detach_file(fid), "consumption: отмена возвращает файл")
_ok(_reg.attach_file(fid, "req_b", base), "consumption: снова прикреплён")
_eq(str(file.location), "attached", "consumption: location attached")
var free_before: int = folder.free_cell_count()
_ok(_reg.consume_file(fid), "consumption: файл израсходован")
_ok(not _reg.files.has(fid), "consumption: файла нет в реестре")
_ok(folder.free_cell_count() > free_before, "consumption: клетки освобождены")
_ok(not _reg.attach_file(fid, "req_c", base), "consumption: израсходованный не attach")
_ok(_reg.validate().is_empty(), "consumption: реестр валиден")
_ok(_reg.set_cooldown(str(file.id), 20.0, base), "cooldown: установлен на 20 с")
_ok(not _reg.attach_file(str(file.id), "req_a", base), "cooldown: сразу после отправки attach=false")
_approx(float(file.cooldown_until), base + 20.0, 0.0001, "cooldown_until = now + 20")
# Через 21 секунду tick снимает cooldown.
_eq(_reg.tick(base + 21.0), 1, "tick(now+21) снял cooldown")
_ok(_reg.attach_file(str(file.id), "req_a", base + 21.0), "cooldown: после tick attach=true")
func _attach_once_case() -> void:
@@ -443,99 +408,72 @@ func _attach_once_case() -> void:
if file == null:
return
_ok(_reg.attach_file(str(file.id), "req_1", 1000.0), "attach_once: первое прикрепление")
_ok(not _reg.attach_file(str(file.id), "req_2", 1000.0),
"attach_once: второе прикрепление запрещено")
_ok(not _reg.attach_file(str(file.id), "req_2", 1000.0), "attach_once: второе прикрепление запрещено")
_ok(str(file.attached_request) == "req_1", "attach_once: запрос не перезаписан")
_ok(_reg.detach_file(str(file.id)), "attach_once: открепление")
_ok(_reg.attach_file(str(file.id), "req_2", 1000.0),
"attach_once: после detach можно прикрепить к другому")
_ok(_reg.attach_file(str(file.id), "req_2", 1000.0), "attach_once: после detach можно прикрепить к другому")
## v2: провал файла только открепляет его; запросы не отменяются и не штрафуются.
func _failed_file_detaches() -> void:
_set_case("failed_file_detaches")
func _failed_cancels_all_requests() -> void:
_set_case("failed_cancels_all")
_game.start_new_run(4242)
var hd = HD.new()
hd.start_hour(1)
hd.set_time_scale(1.0)
hd._next_spawn_at = 9999.0
hd._recv_interval_current = 9999.0
hd.spawn_recv_accum = 0.0
var folder_id := _first_folder(_game.registry)
var file = _game.registry.create_file("failed_detach.txt", "text", folder_id)
_ok(file != null, "failed_file: файл создан")
var file = _game.registry.create_file("failed_cancel.txt", "text", folder_id)
_ok(file != null, "failed_cancels_all: файл создан")
if file == null:
hd.free()
return
var fid := str(file.id)
var req_a := str(hd._make_request([file], "udp", false, "", ""))
var req_b := str(hd._make_request([file], "udp", false, "", ""))
_ok(not req_a.is_empty() and not req_b.is_empty(), "failed_file: два запроса созданы")
_ok(hd.attach_file(req_a, fid), "failed_file: файл прикреплён к запросу A")
_ok(not req_a.is_empty() and not req_b.is_empty(), "failed_cancels_all: два запроса с одним required")
_ok(hd.attach_file(req_a, str(file.id)), "failed_cancels_all: файл прикреплён к первому")
_game.money = 1000
_game.sla = 150.0
_game.registry.mark_failed(fid)
_game.registry.mark_failed(str(file.id))
hd.tick(0.1)
_ok(hd.active_requests.has(req_a), "failed_file: запрос A остался")
_ok(hd.active_requests.has(req_b), "failed_file: запрос B остался")
var req = hd.active_requests.get(req_a)
_ok(req != null and req.attached.is_empty(), "failed_file: файл откреплён от запроса")
_eq(_game.money, 1000, "failed_file: штрафа за отмену нет")
_approx(_game.sla, 150.0, 0.0001, "failed_file: штрафа SLA нет")
_ok(hd.reject_request(req_a), "failed_file: запрос A можно отклонить")
_ok(_game.registry.validate().is_empty(), "failed_file: реестр валиден")
_ok(not hd.active_requests.has(req_a), "failed_cancels_all: запрос A отменён")
_ok(not hd.active_requests.has(req_b), "failed_cancels_all: запрос B отменён")
_eq(_game.money, 970, "failed_cancels_all: два штрафа по −15 $")
_approx(_game.sla, 130.0, 0.001, "failed_cancels_all: два штрафа по −10 SLA")
_eq(int(hd._failed), 2, "failed_cancels_all: оба провала учтены")
_ok(_game.registry.validate().is_empty(), "failed_cancels_all: реестр валиден")
hd.free()
## v2: успешная отправка расходует файлы; отмена сохраняет; cooldown отсутствует.
func _send_consumes_case() -> void:
_set_case("send_consumes")
func _send_cooldown_case() -> void:
_set_case("send_cooldown")
_game.start_new_run(5151)
var hd = HD.new()
hd.send_game_factory = func(_id, _tier, _seed, _ctx):
return StubGame.new()
hd.start_hour(1)
hd.set_time_scale(1.0)
hd._next_spawn_at = 9999.0
hd._recv_interval_current = 9999.0
hd.spawn_recv_accum = 0.0
var folder_id := _first_folder(_game.registry)
var file = _game.registry.create_file("send_ok.txt", "text", folder_id)
_ok(file != null, "send_consumes: файл создан")
var file = _game.registry.create_file("send_cd.txt", "text", folder_id)
_ok(file != null, "send_cooldown: файл создан")
if file == null:
hd.free()
return
var fid := str(file.id)
var req_id := str(hd._make_request([file], "tcp", false, "", ""))
_ok(hd.attach_file(req_id, fid), "send_consumes: файл прикреплён")
var money_before := int(_game.money)
_ok(hd.start_send(req_id), "send_consumes: мини-игра открыта")
_ok(hd.is_minigame_active(), "send_consumes: мини-игра активна")
hd._on_send_minigame_finished(0.8)
_ok(hd.attach_file(req_id, str(file.id)), "send_cooldown: файл прикреплён")
var req = hd.active_requests.get(req_id)
_ok(req != null and (req.sending or hd.is_request_queued(req_id)),
"send_consumes: отправка/очередь после мини-игры")
req.connected = true
_ok(hd.start_send(req_id), "send_cooldown: отправка началась")
_ok(req.sending, "send_cooldown: отправка началась")
for i in range(300):
hd.tick(0.1)
if not hd.active_requests.has(req_id):
break
_ok(not hd.active_requests.has(req_id), "send_consumes: запрос завершён")
_ok(not _game.registry.files.has(fid), "send_consumes: файл израсходован")
_ok(int(_game.money) > money_before, "send_consumes: деньги выросли")
_eq(float(file.cooldown_until), 0.0, "send_consumes: cooldown не выставляется")
# Отмена мини-игры: файл остаётся прикреплённым и не расходуется.
var file2 = _game.registry.create_file("send_cancel.txt", "text", folder_id)
_ok(file2 != null, "send_consumes: второй файл создан")
if file2 == null:
hd.free()
return
var fid2 := str(file2.id)
var req2 := str(hd._make_request([file2], "udp", false, "", ""))
_ok(hd.attach_file(req2, fid2), "send_consumes: второй файл прикреплён")
_ok(hd.start_send(req2), "send_consumes: вторая мини-игра открыта")
hd.force_close_send_minigame()
_ok(_game.registry.files.has(fid2), "send_consumes: отмена сохранила файл")
_eq(str(_game.registry.files[fid2].location), "attached",
"send_consumes: файл остался прикреплён")
_ok(hd.reject_request(req2), "send_consumes: запрос отклоняется после отмены")
_ok(_game.registry.files.has(fid2), "send_consumes: отказ не расходует файл")
_ok(not hd.active_requests.has(req_id), "send_cooldown: запрос завершён")
_ok(float(file.cooldown_until) > 0.0, "send_cooldown: cooldown выставлен")
_ok(not _game.registry.attach_file(str(file.id), "after_send"), "send_cooldown: сразу после отправки attach=false")
var now := Time.get_unix_time_from_system()
_eq(_game.registry.tick(now + 21.0), 1, "send_cooldown: tick(now+21) снял cooldown")
_ok(_game.registry.attach_file(str(file.id), "after_send"), "send_cooldown: после tick attach=true")
hd.free()
@@ -589,34 +527,6 @@ func _fail(message: String) -> void:
func _finish() -> void:
_stop_audio()
print("test_fuzz: проверок %d, провалов %d, known bug %d" % [_checks, _fails, _known])
print("PASS test_fuzz" if _fails == 0 else "FAIL test_fuzz")
quit(0 if _fails == 0 else 1)
func _stop_audio() -> void:
var audio = root.get_node_or_null("Audio")
if audio == null:
return
if audio.has_method("stop_music"):
audio.call("stop_music")
for child in audio.get_children():
if child is AudioStreamPlayer:
var player := child as AudioStreamPlayer
player.stop()
player.stream = null
if audio.get("_sfx_streams") is Dictionary:
(audio.get("_sfx_streams") as Dictionary).clear()
if audio.get("_music_streams") is Dictionary:
(audio.get("_music_streams") as Dictionary).clear()
## Мини-игра-заглушка: сигнал finished, но игра сама не идёт.
class StubGame:
extends Node
signal finished(quality: float)
var running := true
var id := "stub"
+448 -602
View File
File diff suppressed because it is too large. Load diff
+70 -161
View File
@@ -1,14 +1,15 @@
extends SceneTree
## Интеграция v2: полный час на ускоренном времени, 9 часов подряд,
## слоты/очередь, явная отправка с мини-игрой, приём фигуры в папку.
## Интеграция: полный час на ускоренном времени, 9 часов подряд,
## слоты соединений и приём пакета в папку.
##
## Запуск:
## godot --headless --path . --script tests/test_integration.gd
##
## Атакующие мини-игры автоматически завершаются успешно, иначе час встал бы
## на паузе. Мини-игра отправки завершается сразу с q=0.8.
## Тест не открывает мини-игры, поэтому «игрок» не мешает автоспавну:
## проверяется именно устойчивость тикера, таймеров, слотов и атак.
const HD := preload("res://scripts/core/hour_director.gd")
const AD := preload("res://scripts/systems/attack_director.gd")
var _checks: int = 0
var _fails: int = 0
@@ -19,7 +20,6 @@ var _hd = null
var _hour_events: int = 0
var _reports: Array = []
var _attack_marks: Array = []
var _send_minigames: int = 0
var _seq: int = 0
@@ -33,12 +33,10 @@ func _init() -> void:
_hd = HD.new()
_hd.hour_finished.connect(_on_hour_finished)
_hd.attack_added.connect(_on_attack_added)
_hd.show_attack_minigame.connect(_on_show_attack_minigame)
_hd.show_minigame.connect(_on_show_minigame)
_test_full_hour()
_test_nine_hours()
_test_slots_and_queue()
_test_explicit_send()
_test_three_tcp_slots()
_test_auto_send()
_test_receive_to_folder()
_finish()
@@ -81,11 +79,12 @@ func _test_full_hour() -> void:
func _test_nine_hours() -> void:
_set_case("nine_hours")
_game.start_new_run(2026)
# Высокий кап SLA, чтобы стресс-прогон не проигрался на таймаутах пакетов.
_game.set_lot_level("lot_sla", 4)
_game.hour = 1
_game.phase = &"hour"
# unlock-гейты: на часе 1 ещё нет открытых атак (junk с часа 2).
var expected: Array[int] = [0, 1, 1, 2, 2, 3, 3, 4, 4]
for h in range(1, 10):
_game.sla = _game.sla_cap()
_game.sla = 200.0
_game.phase = &"hour"
_hour_events = 0
_reports.clear()
@@ -119,6 +118,7 @@ func _test_nine_hours() -> void:
if slot.occupied:
_ok(_hd.active_packets.has(slot.entity_id) or _hd.active_requests.has(slot.entity_id),
"час %d: слот не висит на мёртвой сущности" % h)
# Переход Game.hour через end_hour.
_game.phase = &"hour"
_ok(_game.end_hour(), "час %d: end_hour()" % h)
if h < 9:
@@ -128,101 +128,64 @@ func _test_nine_hours() -> void:
_eq(int(_game.stats.get("hours_done", 0)), 9, "stats.hours_done = 9")
# --- слоты и очередь -------------------------------------------------------
# --- слоты -----------------------------------------------------------------
func _test_slots_and_queue() -> void:
_set_case("slots_queue")
func _test_three_tcp_slots() -> void:
_set_case("three_tcp_slots")
_game.start_new_run(777)
_hd.start_hour(1)
_hd.set_time_scale(1.0)
_hd._next_spawn_at = 9999.0
_eq(_hd.slots.max_slots(), 1, "на старте 1 слот")
var folder_id := _first_folder()
_hd._recv_interval_current = 9999.0
_hd.spawn_recv_accum = 0.0
_eq(_hd.slots.max_slots(), 2, "на старте 2 слота")
var p1 := str(_hd.spawn_packet_now("text", "tcp"))
_ok(_hd.receive_packet(p1, folder_id), "пакет 1 принят в загрузку")
_eq(_hd.slots.used_count(), 1, "пакет 1 занял слот")
var p2 := str(_hd.spawn_packet_now("text", "tcp"))
_ok(not _hd.receive_packet(p2, folder_id), "второй пакет не влезает в единственный слот")
_eq(_hd.slots.used_count(), 1, "после отказа слот не изменился")
_tick_until_packet_gone(p1, 200)
_eq(_hd.slots.used_count(), 0, "после загрузки пакета 1 слот свободен")
_ok(_hd.receive_packet(p2, folder_id), "пакет 2 принимается после освобождения слота")
_tick_until_packet_gone(p2, 200)
# Занимаем слот приёмом снова, чтобы отправить запрос в очередь.
var p3 := str(_hd.spawn_packet_now("text", "tcp"))
_ok(_hd.receive_packet(p3, folder_id), "пакет 3 занял слот")
var file = _game.registry.create_file("queue.txt", "text", folder_id)
_ok(file != null, "файл для запроса создан")
if file == null:
return
var req_id := str(_hd._make_request([file], "tcp", false, "", ""))
_ok(_hd.attach_file(req_id, str(file.id)), "файл прикреплён")
_ok(_hd.start_send(req_id), "мини-игра отправки открыта")
_ok(_hd.is_minigame_active(), "мини-игра активна")
_hd._on_send_minigame_finished(0.8)
_ok(_hd.is_request_queued(req_id), "запрос встал в очередь out-слота")
var req = _hd.active_requests.get(req_id)
_ok(req != null and not req.sending, "в очереди запрос не отправляется")
_tick_until_packet_gone(p3, 200)
_ok(req != null and req.sending, "очередь выдала слот после загрузки пакета")
_tick_until_request_gone(req_id, 300)
_ok(not _hd.active_requests.has(req_id), "запрос завершён отправкой")
_ok(not _game.registry.files.has(str(file.id)), "файл израсходован успешной отправкой")
_ok(_hd.connect_packet(p1), "пакет 1 соединяется")
_finish_connection(p1)
_ok(_hd.connect_packet(p2), "пакет 2 соединяется")
_finish_connection(p2)
_eq(_hd.slots.used_count(), 2, "оба слота заняты")
_ok(not _hd.connect_packet(p3), "третий TCP-пакет не соединяется при 2 слотах")
_eq(_hd.slots.used_count(), 2, "после отказа слоты не изменились")
# Завершение первого пакета освобождает его слот.
_hd._remove_packet(p1)
_eq(_hd.slots.used_count(), 1, "после завершения первого пакета слот свободен")
_ok(_hd.connect_packet(p3), "третий пакет соединяется после освобождения слота")
_finish_connection(p3)
# --- явная отправка --------------------------------------------------------
# --- автоотправка ----------------------------------------------------------
func _test_explicit_send() -> void:
_set_case("explicit_send")
_send_minigames = 0
func _test_auto_send() -> void:
_set_case("auto_send")
_game.start_new_run(778)
_hd.start_hour(1)
_hd.set_time_scale(1.0)
_hd._next_spawn_at = 9999.0
_hd._recv_interval_current = 9999.0
_hd.spawn_recv_accum = 0.0
var folder_id := _first_folder()
var file = _game.registry.create_file("explicit.txt", "text", folder_id)
_ok(file != null, "файл для явной отправки создан")
var file = _game.registry.create_file("auto_send.txt", "text", folder_id)
_ok(file != null, "файл для автоотправки создан")
if file == null:
return
var req_id := str(_hd._make_request([file], "tcp", false, "", ""))
_ok(_hd.attach_file(req_id, str(file.id)), "файл прикреплён")
var req_id := str(_hd._make_request([file], "udp", false, "", ""))
var req = _hd.active_requests.get(req_id)
_ok(req != null, "запрос создан")
_ok(req != null and not req.sending, "attach не запускает отправку")
_ok(req != null and not req.minigame_active, "attach не открывает мини-игру")
_eq(_send_minigames, 0, "мини-игр отправки ещё не было")
var money_before := int(_game.money)
_ok(_hd.start_send(req_id), "start_send запускает мини-игру")
_eq(_send_minigames, 1, "show_minigame эмитится один раз")
if req != null:
_ok(req.minigame_active, "запрос в состоянии мини-игры")
_ok(not req.sending, "отправка начнётся после мини-игры")
_hd._on_send_minigame_finished(0.8)
if req != null:
_ok(not req.minigame_active, "мини-игра снята")
_ok(req.sending or _hd.is_request_queued(req_id),
"после мини-игры отправка или очередь")
_tick_until_request_gone(req_id, 300)
_ok(not _hd.active_requests.has(req_id), "запрос завершён отправкой")
_ok(not _game.registry.files.has(str(file.id)), "файл израсходован при успехе")
_ok(int(_game.money) > money_before, "деньги выросли при отправке")
# Отмена мини-игры (force close) не расходует файл.
var file2 = _game.registry.create_file("cancel.txt", "text", folder_id)
_ok(file2 != null, "файл для отмены создан")
if file2 == null:
if req == null:
return
var req_id2 := str(_hd._make_request([file2], "udp", false, "", ""))
_ok(_hd.attach_file(req_id2, str(file2.id)), "файл отмены прикреплён")
_ok(_hd.start_send(req_id2), "мини-игра отмены открыта")
_hd.force_close_send_minigame()
_ok(_game.registry.files.has(str(file2.id)), "отмена мини-игры сохранила файл")
_ok(_hd.reject_request(req_id2), "запрос можно отклонить после отмены")
_ok(_game.registry.files.has(str(file2.id)), "отказ не расходует файл")
_ok(_hd.attach_file(req_id, str(file.id)), "файл прикреплён")
_ok(bool(req.sending), "UDP: отправка стартовала автоматически")
_ok(not bool(req.minigame_active) and _hd._minigame == null, "MiniGame не создаётся")
_ok(not bool(_hd.modal_open), "модалки нет")
var progress_before := float(req.send_progress)
_hd.tick(0.1)
_ok(float(req.send_progress) > progress_before, "send_progress идёт")
for i in range(200):
_hd.tick(0.1)
if not _hd.active_requests.has(req_id):
break
_ok(not _hd.active_requests.has(req_id), "запрос завершён отправкой")
# --- приём -----------------------------------------------------------------
@@ -232,20 +195,20 @@ func _test_receive_to_folder() -> void:
_game.start_new_run(888)
_hd.start_hour(1)
_hd.set_time_scale(1.0)
_hd._next_spawn_at = 9999.0
_hd._recv_interval_current = 9999.0
_hd.spawn_recv_accum = 0.0
_game.sla = 150.0
var registry = _game.registry
var folder_id := _first_folder()
var folder = registry.get_folder(folder_id)
var files_before: int = registry.file_count()
var cells_before: int = folder.occupied_cell_count()
var folder_before: int = folder.count()
var money_before: int = _game.money
var sla_before := float(_game.sla)
var pid := str(_hd.spawn_packet_now("text", "tcp"))
var expected_name := str(_hd.active_packets[pid].file_name)
_ok(_hd.receive_packet(pid, folder_id), "пакет принят в загрузку")
_ok(str(_hd.active_packets[pid].state) == "loading", "состояние loading")
_eq(int(folder.reserved.size()), 1, "папка зарезервировала фигуру")
_eq(int(folder.reserved), 1, "папка зарезервировала слот")
for i in range(100):
_hd.tick(0.1)
if not _hd.active_packets.has(pid):
@@ -253,62 +216,36 @@ func _test_receive_to_folder() -> void:
_ok(not _hd.active_packets.has(pid), "пакет ушёл из буфера после загрузки")
_eq(registry.file_count(), files_before + 1, "файл появился в реестре")
_ok(registry.find_by_name(expected_name) != null, "имя файла сохранено")
var recv_fee := 1
var data = root.get_node_or_null("Data")
if data != null and data.get("balance") is Dictionary:
var types = (data.balance as Dictionary).get("types", {})
if types is Dictionary and (types as Dictionary).get("text") is Dictionary:
recv_fee = int(((types as Dictionary)["text"] as Dictionary).get("recv_fee", 1))
_eq(_game.money, money_before + recv_fee, "recv_fee text = +%d $" % recv_fee)
_approx(float(_game.sla), sla_before, 0.0001, "приём не меняет SLA")
_eq(_game.money, money_before + 2, "recv_fee text = +2 $")
_approx(float(_game.sla), 150.5, 0.0001, "приём: +0.5 SLA")
_eq(_hd._buffer_used(), 0, "буфер освобождён")
_eq(int(folder.reserved.size()), 0, "резерв папки снят")
_ok(folder.occupied_cell_count() > cells_before, "клетки папки заняты файлом")
_eq(int(folder.reserved), 0, "резерв папки снят")
_eq(folder.count(), folder_before + 1, "файл лежит в папке")
# Заполненная папка отклоняет приём.
_seq = 0
while folder.free_cell_count() > 0:
var need: int = 16 - folder.count()
for i in range(need):
_seq += 1
var filler = registry.create_file("fill_%d" % _seq, "text", folder_id)
if filler == null:
break
_eq(folder.free_cell_count(), 0, "папка заполнена до 0 свободных клеток")
var pid2 := str(_hd.spawn_packet_now("sound", "tcp"))
_ok(filler != null, "заполнение папки %d/16" % (folder.count()))
_eq(folder.count(), 16, "папка заполнена до 16")
var pid2 := str(_hd.spawn_packet_now("text", "tcp"))
var packets_before: int = _hd.active_packets.size()
var money_before_full: int = _game.money
_ok(not _hd.receive_packet(pid2, folder_id), "приём в заполненную папку отклонён")
_ok(_hd.active_packets.has(pid2), "отклонённый пакет остался в буфере")
_eq(_hd.active_packets.size(), packets_before, "буфер не изменился при отказе")
_eq(_game.money, money_before_full, "при отказе деньги не меняются")
_eq(int(folder.reserved.size()), 0, "при отказе резерв не выставлен")
# --- обработчики -----------------------------------------------------------
## Атаки в стресс-прогоне отражаются мгновенно, иначе час стоит на паузе.
func _on_show_attack_minigame(_game_node, _kind: String) -> void:
if _hd.attack_director != null and _hd.attack_director.is_minigame_active():
_hd.attack_director._on_minigame_finished(1.0)
func _on_show_minigame(_game_node) -> void:
_send_minigames += 1
_eq(int(folder.reserved), 0, "при отказе резерв не выставлен")
# --- служебное -------------------------------------------------------------
func _tick_until_packet_gone(pid: String, limit: int) -> void:
for i in limit:
if not _hd.active_packets.has(pid):
return
_hd.tick(0.1)
func _tick_until_request_gone(req_id: String, limit: int) -> void:
for i in limit:
if not _hd.active_requests.has(req_id):
return
_hd.tick(0.1)
func _finish_connection(entity_id: String) -> void:
var node = _hd._connection_games.get(entity_id)
_hd._on_connection_finished(true, entity_id)
if node != null and is_instance_valid(node):
node.free()
func _first_folder() -> String:
@@ -363,34 +300,6 @@ func _fail(message: String) -> void:
func _finish() -> void:
if _hd != null:
_hd.free()
_hd = null
_stop_audio()
await process_frame
await process_frame
await process_frame
var audio = root.get_node_or_null("Audio")
if audio != null:
audio.queue_free()
await process_frame
await create_timer(0.1).timeout
print("test_integration: проверок %d, провалов %d, known bug %d" % [_checks, _fails, _known])
print("PASS test_integration" if _fails == 0 else "FAIL test_integration")
quit(0 if _fails == 0 else 1)
## Гасим аудио, чтобы Godot не считал WAV-ресурсы утёкшими.
func _stop_audio() -> void:
var audio = root.get_node_or_null("Audio")
if audio == null:
return
if audio.has_method("stop_music"):
audio.call("stop_music")
for child in audio.get_children():
if child is AudioStreamPlayer:
var player := child as AudioStreamPlayer
player.stop()
player.stream = null
if audio.get("_sfx_streams") is Dictionary:
(audio.get("_sfx_streams") as Dictionary).clear()
if audio.get("_music_streams") is Dictionary:
(audio.get("_music_streams") as Dictionary).clear()
+184 -142
View File
@@ -1,11 +1,12 @@
extends SceneTree
## Headless-тест атак v2: только модальная мини-игра, без карточек/оверлеев.
## Headless-тест атак часа на экране Main.tscn.
##
## Проверяет:
## • controller.show_attack_minigame открывает модалку в modal_root;
## • час встаёт на паузу на время мини-игры и продолжается после;
## • успех/провал применяют деньги/SLA;
## • в ПРИЁМЕ/ОТПРАВКЕ нет сущностей-атак, щит-удержание удалено.
## Проверяет UI-интеграцию AttackDirector:
## • junk: карточка в приёме → drop на ShieldDrop → +5 $ / +1 SLA;
## • DDoS: плашка в ПРИЁМЕ и удержание щита 1.5 с → +30 $ / +2 SLA;
## • phishing: приглушённый RequestCard → отклонение → +20 $ / +2 SLA;
## • ransomware: карточка .exe → щит-карантин → +25 $ / +2 SLA;
## • обычный пакет на щите: −2 SLA, пакет остаётся.
##
## Запуск:
## godot --headless --path . --script tests/test_main_attacks.gd
@@ -14,17 +15,14 @@ const MAIN_SCENE := "res://scenes/Main.tscn"
var _checks := 0
var _fails := 0
var _finished := false
var _main: Node = null
var _controller = null
var _director = null
var _game = null
var _show_attack_signals := 0
var _folder_id := ""
func _init() -> void:
var watchdog := create_timer(90.0)
watchdog.timeout.connect(_on_timeout)
await process_frame
var packed := load(MAIN_SCENE) as PackedScene
if packed == null:
@@ -50,134 +48,203 @@ func _init() -> void:
if _director == null:
_finish()
return
_controller.show_attack_minigame.connect(_on_show_attack_minigame)
# Атаки открываются прогрессией: junk/ddos — час 2, phishing — 3,
# ransomware — 4. Ставим час 4, чтобы проверить все виды.
_game.hour = 4
_ok(_game.feature_unlocked("attack_junk"), "junk-атаки открыты (час 2)")
_ok(_game.feature_unlocked("attack_ddos"), "DDoS-атаки открыты (час 2)")
_ok(_game.feature_unlocked("attack_phishing"), "phishing-атаки открыты (час 3)")
_ok(_game.feature_unlocked("attack_ransomware"), "ransomware-атаки открыты (час 4)")
_ok(_main._shield_receive != null and _main._shield_send != null, "щиты ПРИЁМ/ОТПРАВКА созданы")
_folder_id = _first_folder_id()
_ok(not _folder_id.is_empty(), "в реестре есть папка")
for i in 4:
_ok(_game.registry.create_file("атака_%d" % i, "text", _folder_id) != null,
"создан файл атака_%d" % i)
_main._on_start_hour()
await process_frame
_eq(_controller.phase, &"hour", "фаза контроллера — hour")
_ok(_director.attack_director != null, "attack_director доступен")
# Старый UI атак удалён.
_ok(_main.get("_shield_receive") == null, "щита ПРИЁМ больше нет")
_ok(_main.get("_shield_send") == null, "щита ОТПРАВКА больше нет")
_ok(_main.get("_ddos_overlay") == null, "оверлея DDoS больше нет")
_ok(not _main.has_method("_on_quarantine_drop"), "карантин-drop удалён")
_ok(not _main.has_method("_on_shield_press"), "удержание щита удалено")
_ok(not _main.has_method("_refresh_attacks"), "карточки атак удалены")
_ok(not _director.hold_shield(1.5), "hold_shield больше не работает")
var packets_before: int = _main.packets.size()
var requests_before: int = _main.requests.size()
await _run_attack("junk", 1.0, true)
await _run_attack("ddos", 1.0, true)
await _run_attack("phishing", 0.0, false)
await _run_attack("ransomware", 1.0, true)
_eq(_show_attack_signals, 4, "controller.show_attack_minigame эмитился 4 раза")
_ok(_main._modal_game == null, "после всех атак модалки нет")
_ok(not bool(_controller.modal_open), "controller.modal_open сброшен")
_ok(not bool(_director.is_attack_paused()), "час не остался на паузе")
_eq(_main.packets.size(), packets_before, "атаки не добавляют карточки в ПРИЁМ")
_eq(_main.requests.size(), requests_before, "атаки не добавляют карточки в ОТПРАВКУ")
for card in _main.packets:
_ok(card.get("is_attack") == null, "в PacketCard нет ветки атаки")
for card in _main.requests:
_ok(card.get("is_attack") == null, "в RequestCard нет ветки атаки")
await _test_junk()
await _test_ddos()
await _test_phishing()
await _test_ransomware()
await _test_regular_packet()
await process_frame
print("")
print("Проверок: %d, провалов: %d" % [_checks, _fails])
_finish()
## Одна атака: модалка + пауза, затем результат.
func _run_attack(kind: String, quality: float, expect_success: bool) -> void:
var before_money := int(_game.money)
_game.sla = 30.0 if expect_success else 50.0
var before_sla := float(_game.sla)
var packets_before: int = _main.packets.size()
var requests_before: int = _main.requests.size()
# --- сценарии ---------------------------------------------------------------
var aid: String = _director.spawn_attack_now(kind)
_ok(not aid.is_empty(), "%s: атака создана" % kind)
func _test_junk() -> void:
var aid: String = _director.spawn_attack_now("junk")
_ok(not aid.is_empty(), "junk-атака создана")
if aid.is_empty():
return
var ad = _director.attack_director
var attack: Dictionary = ad.active_attacks.get(aid, {})
_eq(str(attack.get("state", "")), "minigame", "%s: атака в состоянии minigame" % kind)
_ok(ad.is_minigame_active(), "%s: мини-игра активна" % kind)
# Модалка показана через controller.show_attack_minigame.
_ok(_main._modal_game != null, "%s: модалка атаки показана" % kind)
if _main._modal_game != null:
_ok(_main._modal_game.get_parent() == _main.modal_root,
"%s: модалка лежит в modal_root" % kind)
_ok(_main._modal_game.has_signal("finished"),
"%s: у модалки есть сигнал finished" % kind)
_ok(bool(_controller.modal_open), "%s: controller.modal_open = true" % kind)
# Час на паузе: время не идёт.
_ok(bool(_director.paused), "%s: час помечен paused" % kind)
_ok(bool(_director.is_attack_paused()), "%s: is_attack_paused = true" % kind)
var time_before := float(_director.time_left)
_controller.tick(1.0)
_approx(float(_director.time_left), time_before, 0.001,
"%s: время часа стоит на паузе" % kind)
# Атак нет в списках пакетов/запросов.
_eq(_main.packets.size(), packets_before, "%s: ПРИЁМ не изменился" % kind)
_eq(_main.requests.size(), requests_before, "%s: ОТПРАВКА не изменилась" % kind)
# Завершаем мини-игру напрямую (как сделал бы игрок).
ad._on_minigame_finished(quality)
_main._refresh_hour()
await process_frame
await process_frame
_ok(not ad.active_attacks.has(aid), "%s: атака снята" % kind)
_ok(not ad.is_minigame_active(), "%s: мини-игра закрыта" % kind)
_eq(_main._modal_game, null, "%s: модалка снята" % kind)
_ok(not bool(_controller.modal_open), "%s: modal_open сброшен" % kind)
_ok(not bool(_director.is_attack_paused()), "%s: пауза снята" % kind)
_ok(float(_director.time_left) > 0.0, "%s: час продолжает идти" % kind)
if expect_success:
_ok(int(_game.money) > before_money,
"%s: успех повышает деньги (%d → %d)" % [kind, before_money, int(_game.money)])
_ok(float(_game.sla) > before_sla,
"%s: успех повышает SLA (%.1f → %.1f)" % [kind, before_sla, float(_game.sla)])
else:
_ok(int(_game.money) < before_money,
"%s: провал снижает деньги (%d → %d)" % [kind, before_money, int(_game.money)])
_ok(float(_game.sla) < before_sla,
"%s: провал снижает SLA (%.1f → %.1f)" % [kind, before_sla, float(_game.sla)])
func _on_show_attack_minigame(_game_node, _kind: String) -> void:
_show_attack_signals += 1
func _on_timeout() -> void:
if _finished:
var card = _find_packet_attack("junk")
_ok(card != null, "junk-карточка в приёме")
if card == null:
return
print("FAIL: тест превысил 90 секунд")
_finished = true
quit(1)
var pid: String = str(card.id)
var payload := {
"kind": "packet", "id": pid, "name": "?",
"is_attack": true, "attack_kind": "junk", "attack_id": aid,
}
var shield = _main._shield_receive
_ok(shield._can_drop_data(Vector2.ZERO, payload), "щит принимает junk-карточку")
_ok(not shield._can_drop_data(Vector2.ZERO, {"kind": "packet", "id": pid}),
"обычный пакет щит не принимает")
_game.sla = 150.0
var money0 := int(_game.money)
shield._drop_data(Vector2.ZERO, payload)
await process_frame
_eq(int(_game.money), money0 + 5, "junk: +5 $")
_ok(absf(float(_game.sla) - 151.0) < 0.01, "junk: +1 SLA (%.1f)" % float(_game.sla))
_ok(not _director.active_packets.has(pid), "junk-пакет удалён")
_ok(not _director.attack_director.active_attacks.has(aid), "junk-атака снята")
_ok(_find_packet_attack("junk") == null, "junk-карточка убрана из приёма")
func _test_ddos() -> void:
var aid: String = _director.spawn_attack_now("ddos")
_ok(not aid.is_empty(), "DDoS-атака создана")
if aid.is_empty():
return
_main._refresh_hour()
await process_frame
_ok(_main._ddos_overlay != null, "DDoS-плашка показана в ПРИЁМЕ")
_ok(float(_main._packet_shift) > 0.0, "карточки приёма сдвинуты под плашкой")
_game.sla = 150.0
var money0 := int(_game.money)
var held = _director.hold_shield(1.6)
_ok(held, "щит удерживается при активном DDoS")
_eq(int(_game.money), money0 + 30, "DDoS: +30 $")
_ok(absf(float(_game.sla) - 152.0) < 0.01, "DDoS: +2 SLA (%.1f)" % float(_game.sla))
_ok(not _director.attack_director.active_attacks.has(aid), "DDoS-атака отражена")
_main._refresh_hour()
await process_frame
_ok(_main._ddos_overlay == null, "DDoS-плашка убрана после отражения")
func _test_phishing() -> void:
var aid: String = _director.spawn_attack_now("phishing")
_ok(not aid.is_empty(), "phishing-атака создана")
if aid.is_empty():
return
_main._refresh_hour()
await process_frame
var attack: Dictionary = _director.attack_director.active_attacks.get(aid, {})
var rid := str(attack.get("request_id", ""))
_ok(not rid.is_empty(), "у фишинга есть request_id")
var card = _find_request(rid)
_ok(card != null, "RequestCard фишинга в ОТПРАВКЕ")
if card == null:
return
_ok(card.avatar_muted, "аватар фишинга приглушён")
_ok(card.attack_kind == "phishing", "карточка помечена как phishing")
_game.sla = 150.0
var money0 := int(_game.money)
_main._on_request_reject(rid)
await process_frame
_eq(int(_game.money), money0 + 20, "phishing: +20 $")
_ok(absf(float(_game.sla) - 152.0) < 0.01, "phishing: +2 SLA (%.1f)" % float(_game.sla))
_ok(not _director.active_requests.has(rid), "фишинг-запрос закрыт")
_ok(not _director.attack_director.active_attacks.has(aid), "фишинг-атака снята")
func _test_ransomware() -> void:
var aid: String = _director.spawn_attack_now("ransomware")
_ok(not aid.is_empty(), "ransomware-атака создана")
if aid.is_empty():
return
_main._refresh_hour()
await process_frame
var card = _find_packet_attack("ransomware")
_ok(card != null, "ransomware-карточка в приёме")
if card == null:
return
_ok(str(card.file_name).to_lower().ends_with(".exe"), "шифровальщик показан как .exe")
var pid: String = str(card.id)
_game.sla = 150.0
var money0 := int(_game.money)
var ok: bool = _main._on_quarantine_drop({
"kind": "packet", "id": pid,
"is_attack": true, "attack_kind": "ransomware", "attack_id": aid,
})
_ok(ok, "ransomware обезврежен через щит")
await process_frame
_eq(int(_game.money), money0 + 25, "ransomware: +25 $")
_ok(absf(float(_game.sla) - 152.0) < 0.01, "ransomware: +2 SLA (%.1f)" % float(_game.sla))
_ok(not _director.active_packets.has(pid), "ransomware-пакет удалён")
_ok(not _director.attack_director.active_attacks.has(aid), "ransomware-атака снята")
func _test_regular_packet() -> void:
var pid: String = _director.spawn_packet_now("text", "udp")
_ok(not pid.is_empty(), "обычный UDP-пакет заспавнен")
if pid.is_empty():
return
# UDP-потеря (20 %) — для теста возвращаем карточку сразу.
if str(_director.active_packets[pid].state) == "lost":
_director.active_packets[pid].state = "waiting"
_main._refresh_hour()
await process_frame
_game.sla = 150.0
var ok: bool = _main._on_quarantine_drop({
"kind": "packet", "id": pid, "name": "обычный",
"protocol": "UDP", "type": "text",
})
_ok(not ok, "обычный пакет не считается угрозой")
_ok(absf(float(_game.sla) - 148.0) < 0.01, "обычный пакет: −2 SLA (%.1f)" % float(_game.sla))
_ok(_director.active_packets.has(pid), "обычный пакет остался в приёме")
# --- хелперы ----------------------------------------------------------------
func _find_packet_attack(kind: String):
for c in _main.packets:
if is_instance_valid(c) and c.is_attack and str(c.attack_kind) == kind:
return c
return null
func _find_request(req_id: String):
for c in _main.requests:
if is_instance_valid(c) and str(c.id) == req_id:
return c
return null
func _first_folder_id() -> String:
if _game == null or _game.registry == null:
return ""
for section in _game.registry.sections.values():
if not section.folder_ids.is_empty():
return str(section.folder_ids[0])
return ""
func _finish() -> void:
if _finished:
return
_finished = true
if _main != null and is_instance_valid(_main):
_main.queue_free()
_stop_audio()
await process_frame
await process_frame
await process_frame
var audio = root.get_node_or_null("Audio")
if audio != null:
audio.queue_free()
await process_frame
await create_timer(0.1).timeout
if _fails == 0:
print("PASS")
else:
@@ -185,23 +252,6 @@ func _finish() -> void:
quit(0 if _fails == 0 else 1)
func _stop_audio() -> void:
var audio = root.get_node_or_null("Audio")
if audio == null:
return
if audio.has_method("stop_music"):
audio.call("stop_music")
for child in audio.get_children():
if child is AudioStreamPlayer:
var player := child as AudioStreamPlayer
player.stop()
player.stream = null
if audio.get("_sfx_streams") is Dictionary:
(audio.get("_sfx_streams") as Dictionary).clear()
if audio.get("_music_streams") is Dictionary:
(audio.get("_music_streams") as Dictionary).clear()
func _ok(condition: bool, message: String) -> void:
_checks += 1
if condition:
@@ -218,14 +268,6 @@ func _eq(actual, expected, message: String) -> void:
print("FAIL: %s — получено %s, ожидалось %s" % [message, actual, expected])
func _approx(actual: float, expected: float, eps: float, message: String) -> void:
_checks += 1
if absf(float(actual) - float(expected)) <= eps:
return
_fails += 1
print("FAIL: %s — получено %.6f, ожидалось %.6f" % [message, actual, expected])
func _fail(message: String) -> void:
_checks += 1
_fails += 1
+11 -20
View File
@@ -74,15 +74,7 @@ func _init() -> void:
if req == null:
_finish()
return
var fid := ""
for file in _game.registry.get_available_files():
if str(file.location) == "folder":
fid = str(file.id)
break
_ok(not fid.is_empty(), "есть свободный файл для прикрепления")
if fid.is_empty():
_finish()
return
var fid := str(req.required[0])
_ok(director.attach_file(rid, fid), "файл прикреплён к запросу")
_main._refresh_hour()
await process_frame
@@ -91,7 +83,7 @@ func _init() -> void:
var attached_before: Array = req.attached.duplicate()
_ok(attached_before.has(fid), "req.attached содержит файл")
_ok(_slot_attached(rid, fid), "слот запроса показывает attached=true")
_eq(_file_card_state(fid), "attached", "FileGrid показывает attached")
_eq(_file_card_state(fid), "attached", "FileCard показывает attached")
_eq(str(_game.registry.files[fid].attached_request), rid,
"file.attached_request указывает на запрос")
@@ -166,29 +158,28 @@ func _slot_attached(rid: String, fid: String) -> bool:
var req = _controller.hour_director.active_requests.get(rid)
if req == null:
return false
var hd = _controller.hour_director
var slots: Array = _main._request_slots({
"required_count": hd.request_required_count(rid),
"attached": req.attached,
})
var index: int = (req.attached as Array).find(fid)
var slots: Array = _main._request_slots({"required": req.required,
"attached": req.attached})
var index: int = (req.required as Array).find(fid)
if index < 0 or index >= slots.size() or slots[index] == null:
return false
return bool((slots[index] as Dictionary).get("attached", false))
func _file_card_state(fid: String) -> String:
var registry = _game.registry if _game != null else null
if registry == null or not registry.files.has(fid):
if _main.grid == null:
return ""
return FileGrid.file_state(registry.files[fid])
for card in _main.grid.get_cards():
if str(card.id) == fid:
return str(card.state)
return ""
func _request_card_attached(rid: String, fid: String) -> bool:
var req = _controller.hour_director.active_requests.get(rid)
if req == null:
return false
var index: int = (req.attached as Array).find(fid)
var index: int = (req.required as Array).find(fid)
for card in _main.requests:
if card == null or not is_instance_valid(card) or str(card.id) != rid:
continue
+117 -312
View File
@@ -1,19 +1,15 @@
extends SceneTree
## Headless-тест экрана часа Main.tscn ↔ GameController ↔ HourDirector (v2).
## Headless-тест экрана часа Main.tscn ↔ GameController ↔ HourDirector.
##
## Проверяет: окна ПРИЁМ/ОТПРАВКА, 5 ячеек слотов (база 1), превью фигуры
## пакета, прикрепление/отправку запроса, предсказание мини-игры, TCP/UDP,
## канал load/capacity, отсутствие карточек/оверлеев атак и магазин после H1.
## Проверяет: карточки пакетов/запросов, 4 слота, статус-бар, тиканье
## таймера, переход в итоги часа и возврат в подготовку.
##
## Запуск:
## godot --headless --path . --script tests/test_main_hour.gd
const MG := preload("res://scripts/minigames/minigame_base.gd")
var _checks := 0
var _fails := 0
var _finished := false
var _fake_game = null
func _init() -> void:
@@ -46,305 +42,126 @@ func _init() -> void:
folder_id = str(section.folder_ids[0])
break
_ok(folder_id != "", "в реестре есть папка для файлов")
var file_ids: Array = []
for i in 4:
var f = game.registry.create_file("тест_%d" % i, "text", folder_id)
_ok(f != null, "файл тест_%d создан" % i)
if f != null:
file_ids.append(str(f.id))
for i in 3:
_ok(game.registry.create_file("тест_%d" % i, "text", folder_id) != null,
"файл тест_%d создан" % i)
main._on_start_hour()
await process_frame
_eq(main.controller.phase, &"hour", "фаза контроллера — hour")
_ok(main._shop_btn != null and not main._shop_btn.visible,
"магазин скрыт во время часа")
# Регресс: акцентная кнопка старта не должна оставаться в строке меню в час.
_ok(main._start_btn != null and not main._start_btn.visible,
"кнопка НАЧАТЬ ЧАС скрыта во время часа")
_ok(main.receive_window.visible, "окно ПРИЁМ видно в час")
_ok(main.send_window.visible, "окно ОТПРАВКА видно в час")
_ok(main.sorting_table != null and not main.sorting_table.visible,
"стол сортировки скрыт в час")
# --- Стартовые статы H1 --------------------------------------------------
_eq(str(main.status_bar.get_metric("sla").get("value", "")), "60 / 60",
"статус SLA — 60 / 60")
_eq(str(main.status_bar.get_metric("tcp").get("value", "")), "0 / 1",
"статус слотов — 0 / 1")
_eq(str(main.status_bar.get_metric("channel").get("value", "")), "0/10",
"статус канала — load/capacity 0/10")
_eq(str(main.status_bar.get_metric("buffer").get("value", "")), "0 / 6",
"статус буфера — 0 / 6")
var director = main.controller.hour_director
_ok(director != null, "hour_director доступен")
# --- Пакет: карточка с фигурой и клетками --------------------------------
var pid: String = director.spawn_packet_now("sound", "tcp")
_ok(not pid.is_empty(), "пакет создан")
var rid: String = director.spawn_request_now(1)
_ok(not rid.is_empty(), "запрос на 1 файл создан")
var pid: String = director.spawn_packet_now("text", "tcp")
var rid: String = director.spawn_request_now()
main._refresh_hour()
await process_frame
_ok(main.packets.size() > 0, "карточки пакетов созданы (%d)" % main.packets.size())
_ok(main.requests.size() > 0, "карточки запросов созданы (%d)" % main.requests.size())
_eq(main.slot_cells.size(), 5, "пять ячеек слотов")
if main.slot_cells.size() == 5:
_ok(main.slot_cells[0].state != "locked", "слот 0 доступен (база 1)")
_eq(main.slot_cells[4].state, "locked", "слот 5 закрыт (максимум 5)")
if not main.packets.is_empty():
var pcard = main.packets[0]
var pkt = director.active_packets.get(pid)
_ok(pkt != null, "пакет есть в директоре")
if pkt != null:
_eq(float(pkt.timer_total), 20.0,
"таймер пакета H1 = 20 с (%.1f)" % float(pkt.timer_total))
_eq(str(pcard.shape_id), str(pkt.shape_id), "карточка показывает shape_id пакета")
_ok(pcard.cells_count() >= 2, "звук занимает минимум 2 клетки")
_ok(pcard.cells_text().begins_with("ЗАЙМЁТ"),
"карточка пакета: %s" % pcard.cells_text())
_ok(pcard.get("is_attack") == null, "в карточке пакета нет атаки")
var req0 = director.active_requests.get(rid)
if req0 != null:
_eq(float(req0.timer_total), 30.0,
"таймер запроса H1 = 30 с (%.1f)" % float(req0.timer_total))
_eq(main.slot_cells.size(), 4, "четыре SlotCell в полосе TCP")
# --- Запрос: до прикрепления кнопка ОТПРАВИТЬ выключена ------------------
var card = _request_card(main, rid)
_ok(card != null, "карточка запроса найдена")
if card == null:
_finish()
return
_eq(card.required_count, 1, "запросу нужен 1 файл")
_eq(card.attached_count(), 0, "прикреплено 0 файлов")
_ok(card.send_disabled(), "ОТПРАВИТЬ выключена без файлов")
_ok(card.minigame_text().contains("—"), "мини-игра не определена без файлов")
_eq(card.protocol, "TCP", "протокол по умолчанию TCP")
# --- Плюрализация заголовков окон: падежи счётчиков ---------------------
_ok(not str(director.spawn_request_now()).is_empty(),
"второй запрос создан для проверки падежа")
main._refresh_hour()
await process_frame
_eq(main.send_window.title_hint, "2 ЗАПРОСА", "2 запроса — «2 ЗАПРОСА»")
_eq(main.receive_window.title_hint, "1 ПАКЕТ", "1 пакет — «1 ПАКЕТ»")
while director.active_packets.size() < 3:
if str(director.spawn_packet_now("text", "tcp")).is_empty():
break
main._refresh_hour()
await process_frame
_eq(main.receive_window.title_hint, "3 ПАКЕТА", "3 пакета — «3 ПАКЕТА»")
_eq(main._plural_ru(5, "ПАКЕТ", "ПАКЕТА", "ПАКЕТОВ"), "5 ПАКЕТОВ",
"5 пакетов — «5 ПАКЕТОВ»")
# --- Раскладка v2: карточка 108 px, скролл вмещает 4 запроса -------------
# --- Вместимость ОТПРАВКИ: 3 и 4 запроса должны быть видны целиком -----
# Окно 856,320,416,276 → content 252 px. При шаге 64 карточка №4
# уходила за нижний клип (192+64 = 256 > 252).
var content: Control = main.send_window.content
_eq(int(content.size.y), 252, "контент ОТПРАВКИ: высота 252 px")
_eq(float(card.size.y), 108.0, "RequestCard v2: высота 108 px")
_ok(card.position.y + 24.0 <= content.size.y + 0.5,
"карточка %s: заголовок/таймер в клипе" % str(card.name))
main._sync_send_scroll(4)
_ok(main._send_bar != null and main._send_bar.visible,
"при 4 запросах в ОТПРАВКЕ появляется скролл")
_ok(main._send_bar != null and main._send_bar._limit() > 0.0,
"скролл позволяет показать все 4 карточки")
main._sync_send_scroll(main.requests.size())
main._send_scroll = 0.0
main._layout_request_cards(main.requests.size())
# --- attach через drop карточки: без автостарта --------------------------
main._on_file_to_request(rid, 0, {"kind": "file", "id": str(file_ids[0]),
"from": "folder"})
await process_frame
main._refresh_hour()
var req = director.active_requests.get(rid)
_ok(req != null, "запрос жив после прикрепления")
if req != null:
_eq(req.attached.size(), 1, "файл прикреплён")
_ok(not req.minigame_active, "attach НЕ запускает мини-игру")
_ok(not req.sending, "attach НЕ запускает отправку")
_ok(card.can_send(), "ОТПРАВИТЬ доступна после прикрепления")
_ok(not card.send_disabled(), "кнопка ОТПРАВИТЬ включена")
_ok(card.minigame_text().contains("МИНИ-ИГРА"), "подсказка мини-игры: %s"
% card.minigame_text())
_eq(card.difficulty_text(), "СЛОЖНОСТЬ: 1", "сложность текста = 1")
_eq(card.attached_count(), 1, "карточка: прикреплено 1/1")
# --- TCP/UDP -------------------------------------------------------------
main._on_request_protocol_toggle(rid)
await process_frame
main._refresh_hour()
_eq(str(director.active_requests[rid].protocol), "udp", "протокол переключён на UDP")
_eq(card.protocol, "UDP", "карточка показывает UDP")
main._on_request_protocol_toggle(rid)
await process_frame
main._refresh_hour()
_eq(str(director.active_requests[rid].protocol), "tcp", "протокол вернулся на TCP")
# --- Drop пакета в точную клетку сетки ----------------------------------
var drop_id: String = director.spawn_packet_now("text", "tcp")
_ok(not drop_id.is_empty(), "пакет для drop создан")
var folder = game.registry.folders[folder_id]
var target := Vector2i(-1, -1)
for r in folder.rows:
for c in folder.cols:
if game.registry.can_place(folder, "O1", Vector2i(c, r), 0):
target = Vector2i(c, r)
break
if target.x >= 0:
while director.active_requests.size() < 3:
if str(director.spawn_request_now()).is_empty():
break
_ok(target.x >= 0, "найдена свободная клетка для drop")
main._on_grid_packet_drop({"kind": "packet", "id": drop_id,
"shape_id": "O1", "rotation": 0}, target, 0)
await process_frame
var drop_pkt = director.active_packets.get(drop_id)
_ok(drop_pkt != null, "пакет остаётся в буфере до конца загрузки")
if drop_pkt != null:
_eq(str(drop_pkt.state), "loading", "drop в сетку начал загрузку")
_eq(str(main.status_bar.get_metric("channel").get("value", "")), "1/10",
"канал после старта приёма: 1/10")
var in_cell = null
for cell in main.slot_cells:
if cell.state == "active":
in_cell = cell
_ok(in_cell != null, "занятый слот показан")
if in_cell != null:
_eq(in_cell.direction, "down", "слот приёма смотрит вниз")
# Клик по занятому слоту больше не прерывает передачу.
if drop_pkt != null:
main._on_slot_close(drop_id)
_eq(str(drop_pkt.state), "loading", "клик по слоту не отменяет загрузку")
# Перегрузка канала: красный сегмент и подпись load/capacity.
main._refresh_hour_status({"tcp_used": 1, "tcp_max": 1, "channel": 12.0,
"channel_factor": 1.25, "sla": 60.0, "buffer_used": 1, "buffer_max": 6,
"money": int(game.money), "hour": 1, "time_left": 120.0})
_eq(str(main.status_bar.get_metric("channel").get("value", "")), "12/10",
"перегрузка канала показана как 12/10")
_ok(bool(main.status_bar.get_metric("channel").get("danger", false)),
"перегруженный канал помечен красным")
main._refresh_hour()
await process_frame
var cards3 := _request_cards(content)
print("ОТПРАВКА N=3: карточек %d, низ последней %.1f / %.1f" % [
cards3.size(), _cards_bottom(cards3), content.size.y])
_eq(cards3.size(), 3, "3 активных запроса → 3 карточки в ОТПРАВКЕ")
_ok(_cards_fit(cards3, content.size.y), "3 карточки целиком в клипе ОТПРАВКИ")
_eq(main.send_window.title_hint, "3 ЗАПРОСА", "3 запроса — «3 ЗАПРОСА»")
# --- Мини-игра отправки через подменную фабрику -------------------------
var holder := {"game": null}
director.send_game_factory = func(_id, _tier, _seed, _ctx):
var g := FakeGame.new()
holder["game"] = g
return g
main._on_request_send(rid)
await process_frame
_ok(director.active_requests[rid].minigame_active, "ОТПРАВИТЬ открывает мини-игру")
_ok(main._modal_game != null, "модалка мини-игры показана")
_ok(bool(main.controller.modal_open), "controller.modal_open = true на мини-игре")
_fake_game = holder.get("game")
_ok(_fake_game != null, "подменная мини-игра создана")
if _fake_game != null:
_fake_game.finished.emit(0.8)
await process_frame
await process_frame
_ok(main._modal_game == null, "мини-игра закрыта и освобождена")
_ok(not bool(main.controller.modal_open), "controller.modal_open = false после игры")
director.send_game_factory = Callable()
_fake_game = null
holder.clear()
var req2 = director.active_requests.get(rid)
if req2 != null:
_ok(not req2.minigame_active, "мини-игра снята с запроса")
# out-слот занят приёмом → запрос встаёт в очередь «ОЖИДАНИЕ СЛОТА».
_ok(req2.sending or director.is_request_queued(rid),
"после игры запрос отправляется или ждёт слот")
main._refresh_hour()
if req2 != null and director.is_request_queued(rid):
var qcard = _request_card(main, rid)
if qcard != null:
_ok(qcard.queued, "карточка показывает ожидание слота")
# --- Нет карточек/оверлеев атак -----------------------------------------
_ok(not main.has_method("_refresh_attacks"), "метод _refresh_attacks удалён")
_ok(main.get("_ddos_overlay") == null, "оверлея DDoS нет")
_ok(not main.has_method("_on_quarantine_drop"), "карантин-drop удалён")
_ok(not main.has_method("_on_shield_press"), "удержание щита удалено")
_ok(main.requests.size() > 0 and main.requests[0].get("is_attack") == null,
"в RequestCard нет ветки атаки")
_ok(main.packets.size() > 0 and main.packets[0].get("is_attack") == null,
"в PacketCard нет ветки атаки")
_ok(director.attack_added.get_connections().is_empty(),
"UI не подключён к attack_added")
_ok(director.attack_changed.get_connections().is_empty(),
"UI не подключён к attack_changed")
_ok(director.attack_removed.get_connections().is_empty(),
"UI не подключён к attack_removed")
# --- AttackDirector.cancel_active(): нода queue_free + attack_resolved ----
var fake_attack := Node.new()
main._show_modal_game(fake_attack)
_ok(main._modal_game == fake_attack, "модалка атаки установлена")
fake_attack.queue_free()
main.controller.attack_resolved.emit("junk", true, 1)
await process_frame
_ok(main._modal_game == null, "attack_resolved снял _modal_game")
_ok(not bool(main.controller.modal_open), "attack_resolved сбросил modal_open")
# --- D2 (§5.5): экран результата до клика ПРОДОЛЖИТЬ --------------------
var modal_mg = MG.new()
modal_mg.start("dots", 1, 4242, {})
var finish_count := [0]
modal_mg.finished.connect(func(_q: float) -> void: finish_count[0] += 1)
main._show_modal_game(modal_mg)
await process_frame
_ok(bool(modal_mg.defer_finish_signal), "Main включает defer_finish_signal")
modal_mg._finish(0.8)
await process_frame
await process_frame
_ok(is_instance_valid(modal_mg) and not modal_mg.is_queued_for_deletion(),
"после _finish нода жива — виден экран результата")
_eq(finish_count[0], 0, "finished НЕ эмитится до ПРОДОЛЖИТЬ")
_ok(modal_mg.is_finished(), "мини-игра помечена завершённой")
_ok(absf(float(modal_mg.quality()) - 0.8) < 0.0001, "качество 80% сохранено")
modal_mg._continue_clicked()
await process_frame
await process_frame
_eq(finish_count[0], 1, "finished эмитится ровно один раз по ПРОДОЛЖИТЬ")
_ok(not is_instance_valid(modal_mg) or modal_mg.is_queued_for_deletion(),
"после ПРОДОЛЖИТЬ нода освобождается")
_ok(main._modal_game == null, "_modal_game снят после ПРОДОЛЖИТЬ")
_ok(not bool(main.controller.modal_open), "modal_open сброшен после ПРОДОЛЖИТЬ")
# --- Конец часа при открытой мини-игре: не оставляем висячий _modal_game --
var rid3: String = director.spawn_request_now(1)
_ok(not rid3.is_empty(), "третий запрос создан для проверки конца часа")
var leftover_id := ""
for f in game.registry.files.values():
if str(f.attached_request).is_empty() and str(f.location) == "folder":
leftover_id = str(f.id)
while director.active_requests.size() < 4:
if str(director.spawn_request_now()).is_empty():
break
if not rid3.is_empty() and not leftover_id.is_empty():
_ok(director.attach_file(rid3, leftover_id), "файл прикреплён к третьему запросу")
var holder2 := {"game": null}
director.send_game_factory = func(_id, _tier, _seed, _ctx):
var g := FakeGame.new()
holder2["game"] = g
return g
main._on_request_send(rid3)
await process_frame
var ghost = holder2.get("game")
_ok(ghost != null and main._modal_game == ghost,
"мини-игра открыта перед концом часа")
director.time_left = 0.05
main.controller.tick(0.1)
await process_frame
await process_frame
_eq(main.controller.phase, &"hour_result", "час завершился с открытой мини-игрой")
_ok(main._modal_game == null, "force_close очистил _modal_game")
_ok(not bool(main.controller.modal_open), "modal_open сброшен при force-close")
_ok(ghost == null or not is_instance_valid(ghost) or ghost.is_queued_for_deletion(),
"нода принудительно закрытой мини-игры освобождена")
director.send_game_factory = Callable()
holder2.clear()
main._refresh_hour()
await process_frame
var cards4 := _request_cards(content)
print("ОТПРАВКА N=4: карточек %d, низ последней %.1f / %.1f" % [
cards4.size(), _cards_bottom(cards4), content.size.y])
_eq(cards4.size(), 4, "4 активных запроса → 4 карточки в ОТПРАВКЕ")
_ok(_cards_fit(cards4, content.size.y), "4 карточки целиком в клипе ОТПРАВКИ")
for card in cards4:
# Заголовок + таймер живут в верхних 40 px — они обязаны быть в клипе.
_ok(card.position.y + 40.0 < content.size.y,
"карточка %s: заголовок/таймер в клипе" % str(card.name))
# --- Завершение часа и магазин ------------------------------------------
director.time_left = 0.0
# Тайминги часа 1: пакет 30 с (grace 1.5), запрос 60 с (отдельный grace 2.0).
if not pid.is_empty() and director.active_packets.has(pid):
_ok(absf(float(director.active_packets[pid].timer_total) - 30.0) < 0.01,
"таймер пакета часа 1 = 30 с (%.1f)" % float(director.active_packets[pid].timer_total))
if not rid.is_empty() and director.active_requests.has(rid):
_ok(absf(float(director.active_requests[rid].timer_total) - 60.0) < 0.01,
"таймер запроса часа 1 = 60 с (%.1f)" % float(director.active_requests[rid].timer_total))
_ok(absf(float(director.channel_load())) < 0.01,
"канал в простое = 0 (%.2f)" % float(director.channel_load()))
_eq(str(main.status_bar.get_metric("channel").get("value", "")), "0%",
"статус-бар: канал 0% в простое")
var tcp_metric := str(main.status_bar.get_metric("tcp").get("value", ""))
var buffer_metric := str(main.status_bar.get_metric("buffer").get("value", ""))
var hour_metric := str(main.status_bar.get_metric("hour").get("value", ""))
var sla_metric := str(main.status_bar.get_metric("sla").get("value", ""))
_ok(not tcp_metric.is_empty(), "статус TCP не пуст: %s" % tcp_metric)
_ok(not buffer_metric.is_empty(), "статус буфера не пуст: %s" % buffer_metric)
_ok(hour_metric.begins_with("ЧАС "), "статус часа: %s" % hour_metric)
_eq(sla_metric, "128 / 200", "статус SLA — 128 / 200")
# Таймер пакета должен уменьшаться при тике часа.
var before := float(main.packets[0].time_left)
main.controller.tick(1.0)
main._refresh_hour()
var after := float(main.packets[0].time_left)
_ok(after < before, "таймер пакета уменьшился: %.2f → %.2f" % [before, after])
# Принудительно завершаем час.
director.time_left = 0.05
main.controller.tick(0.1)
await process_frame
_eq(main.controller.phase, &"hour_result", "фаза — hour_result")
_ok(main.hour_result_modal != null and main.hour_result_modal.visible,
"модалка итогов часа показана")
# Далее — подготовка следующего часа.
main.controller.next_after_hour()
await process_frame
_eq(main.controller.phase, &"prep", "после отчёта — снова prep")
_ok(main.hour_result_modal == null or not main.hour_result_modal.visible,
"модалка итогов часа скрыта в подготовке")
# Час 2: магазин появляется в подготовке.
_eq(int(game.hour), 2, "после первого часа Game.hour = 2")
_ok(main._shop_btn != null and main._shop_btn.visible,
"в подготовке часа 2 магазин виден")
_ok(main._start_btn != null and main._start_btn.visible,
"после возврата в prep кнопка НАЧАТЬ ЧАС снова видна")
_ok(main.sorting_table != null and main.sorting_table.visible,
"в подготовке часа 2 стол сортировки виден")
_eq(main.controller.shop_data().size(), 3, "после H1 — 3 предложения магазина")
# --- Реальный переход по сигналу phase_changed (без ручного _on_phase) ---
var started: bool = main.controller.start_hour()
@@ -371,12 +188,38 @@ func _init() -> void:
_finish()
## Карточка запроса по id (живая, видимая).
func _request_card(main, req_id: String):
for c in main.requests:
if c != null and is_instance_valid(c) and str(c.id) == req_id:
return c
return null
## Карточки запросов в контенте ОТПРАВКИ (живые, видимые).
func _request_cards(content: Control) -> Array:
var out: Array = []
if content == null:
return out
for c in content.get_children():
var sc = c.get_script()
if sc == null or sc.get_global_name() != "RequestCard":
continue
if not (c as Control).visible or c.is_queued_for_deletion():
continue
out.append(c)
return out
func _cards_bottom(cards: Array) -> float:
var bottom := 0.0
for c in cards:
bottom = maxf(bottom, c.position.y + c.size.y)
return bottom
## Все карточки целиком внутри клипа контента.
func _cards_fit(cards: Array, clip_h: float) -> bool:
if cards.is_empty():
return false
for c in cards:
if c.position.y < -0.01:
return false
if c.position.y + c.size.y > clip_h + 0.5:
return false
return true
func _on_timeout() -> void:
@@ -391,16 +234,6 @@ func _finish() -> void:
if _finished:
return
_finished = true
_stop_audio()
# Даём AudioServer отпустить playback и освобождаем сам autoload Audio.
var audio = root.get_node_or_null("Audio")
if audio != null:
audio.queue_free()
await process_frame
await process_frame
await process_frame
# Небольшая пауза, чтобы AudioServer гарантированно отпустил playback.
await create_timer(0.1).timeout
if _fails == 0:
print("PASS")
else:
@@ -408,24 +241,6 @@ func _finish() -> void:
quit(0 if _fails == 0 else 1)
## Гасим AudioStreamPlayer перед выходом: иначе Godot ругается на утечку WAV.
func _stop_audio() -> void:
var audio = root.get_node_or_null("Audio")
if audio == null:
return
if audio.has_method("stop_music"):
audio.call("stop_music")
for child in audio.get_children():
if child is AudioStreamPlayer:
var player := child as AudioStreamPlayer
player.stop()
player.stream = null
if audio.get("_sfx_streams") is Dictionary:
(audio.get("_sfx_streams") as Dictionary).clear()
if audio.get("_music_streams") is Dictionary:
(audio.get("_music_streams") as Dictionary).clear()
func _ok(condition: bool, message: String) -> void:
_checks += 1
if condition:
@@ -446,13 +261,3 @@ func _fail(message: String) -> void:
_checks += 1
_fails += 1
print("FAIL: %s" % message)
## Минимальная мини-игра для теста отправки: сигнал finished(q).
class FakeGame:
extends Node
signal finished(quality: float)
var running := true
var id := "fake"
+213 -288
View File
@@ -1,34 +1,30 @@
extends SceneTree
## Headless-тест интеракций экрана часа: Main.tscn ↔ GameController ↔ HourDirector (v2).
## Headless-тест интеракций экрана часа: Main.tscn ↔ GameController ↔ HourDirector.
##
## Проверяет реальные обработчики main.gd:
## • пакет: drag/drop в папку без соединения (ConnectButton удалён);
## • запрос: attach файла, явная кнопка ОТПРАВИТЬ, модалка мини-игры,
## пауза таймера запроса, завершение мини-игры;
## • очередь out-слота: запрос ждёт слот, таймер стоит;
## • отмена/таймаут запроса сохраняют файлы (расход только при успехе);
## • отказ от запроса, переключение TCP/UDP, 5 ячеек слотов.
## • TCP-пакет: соединение кликом по карточке → приём в папку;
## • UDP-пакет: приём без соединения;
## • запрос: прикрепление файлов, соединение кликом (TCP), автоотправка;
## • отказ от запроса;
## • занятость TCP-слотов и освобождение через _on_slot_close.
##
## Запуск:
## godot --headless --path . --script tests/test_main_interactions.gd
const MAIN_SCENE := "res://scenes/Main.tscn"
const ECONOMY := preload("res://scripts/core/economy.gd")
const CONNECTION_SCRIPT := preload("res://scripts/minigames/connection_game.gd")
const MINIGAME_SCRIPT := preload("res://scripts/minigames/minigame_base.gd")
var _checks := 0
var _fails := 0
var _finished := false
var _main: Node = null
var _controller = null
var _director = null
var _game = null
var _folder_id := ""
var _fake_game = null
func _init() -> void:
var watchdog := create_timer(90.0)
watchdog.timeout.connect(_on_timeout)
await process_frame
var packed := load(MAIN_SCENE) as PackedScene
if packed == null:
@@ -57,26 +53,25 @@ func _init() -> void:
_folder_id = _first_folder_id()
_ok(not _folder_id.is_empty(), "в реестре есть папка")
for i in 8:
for i in 3:
_ok(_game.registry.create_file("тест_%d" % i, "text", _folder_id) != null,
"создан файл тест_%d" % i)
_main._on_start_hour()
await process_frame
_eq(_controller.phase, &"hour", "фаза контроллера — hour")
_eq(_main.slot_cells.size(), 5, "пять ячеек слотов")
if _main.slot_cells.size() == 5:
_ok(_main.slot_cells[0].state != "locked", "слот 0 доступен (база 1)")
_eq(_main.slot_cells[4].state, "locked", "слот 5 закрыт (максимум 5)")
await _test_packet_drop()
await _test_attach_and_explicit_send()
await _test_queue_pause_and_timeout()
await _test_reject()
_test_protocol_toggle()
_test_prediction()
_test_connection_fail()
_test_tcp_packet()
_test_udp_packet()
_test_request_send()
_test_reject()
_test_slots()
# Дать queue_free из последнего сценария отработать до выхода.
await process_frame
await process_frame
print("")
print("Проверок: %d, провалов: %d" % [_checks, _fails])
_finish()
@@ -84,252 +79,216 @@ func _init() -> void:
# --- сценарии ---------------------------------------------------------------
## Пакет принимается drag&drop в папку: соединения больше нет.
func _test_packet_drop() -> void:
## Провал соединения закрывает модалку; автоповтора нет — нужно нажать снова.
func _test_connection_fail() -> void:
var pid: String = _director.spawn_packet_now("text", "tcp")
_ok(not pid.is_empty(), "пакет для приёма заспавнен")
_ok(not pid.is_empty(), "пакет для провала соединения заспавнен")
if pid.is_empty():
return
_main._on_packet_connect(pid)
var conn = _find_modal(CONNECTION_SCRIPT)
_ok(conn != null, "модалка соединения открыта")
_eq(int(_director.slots.used_count()), 1, "слот занят до провала")
# Модалка сама сообщает о провале: директор освобождает слот, Main удаляет окно.
_director._on_connection_finished(false, pid)
_main._on_modal_game_finished(false, pid, conn)
_eq(str(_director.active_packets[pid].state), "waiting", "пакет снова ждёт")
_eq(int(_director.slots.used_count()), 0, "слот освобождён после провала")
_ok(_main._modal_game == null, "модалка закрыта после провала")
_ok(_find_modal(CONNECTION_SCRIPT) == null, "модалка не висит в дереве")
# Никакого автоповтора: без нового нажатия соединение не начинается.
_main._refresh_hour()
_ok(_find_modal(CONNECTION_SCRIPT) == null, "автоповтора соединения нет")
# Повторное нажатие снова открывает модалку и доводит до успеха.
_main._on_packet_connect(pid)
var conn2 = _find_modal(CONNECTION_SCRIPT)
_ok(conn2 != null, "повторное нажатие открывает соединение снова")
if conn2 == null:
return
_eq(int(_director.slots.used_count()), 1, "слот снова занят")
_director._on_connection_finished(true, pid)
_main._on_modal_game_finished(true, pid, conn2)
_eq(str(_director.active_packets[pid].state), "connected", "повторное соединение успешно")
# Убираем пакет из приёма, чтобы не мешал следующим сценариям.
if _director.active_packets.has(pid):
_director.active_packets.erase(pid)
if _director.slots != null:
_director.slots.release_by_entity(pid)
func _test_tcp_packet() -> void:
var pid: String = _director.spawn_packet_now("text", "tcp")
_ok(not pid.is_empty(), "TCP-пакет заспавнен")
if pid.is_empty():
return
_main._refresh_hour()
await process_frame
var buffer_before := int(_director.buffer_used())
var files_before: int = _game.registry.files.size()
_eq(str(_director.active_packets[pid].state), "waiting", "пакет ждёт соединения")
var card = _packet_card(pid)
_ok(card != null, "карточка пакета найдена")
_ok(card != null, "карточка TCP-пакета найдена")
if card == null:
return
_ok(card.get_node_or_null("ConnectButton") == null, "у пакета нет кнопки СОЕДИНИТЬСЯ")
# Legacy-клик по пакету не открывает модалку и не занимает слот.
_main._on_packet_connect(pid)
_ok(_main._modal_game == null, "клик по пакету не открывает модалку")
_eq(int(_director.slots.used_count()), 0, "клик по пакету не занимает слот")
_eq(str(_director.active_packets[pid].state), "waiting", "пакет ждёт папку")
# Drag не должен считаться кликом.
var clicks := [0]
card.connect_pressed.connect(func(_id: String) -> void: clicks[0] += 1)
_press_card(card, Vector2(120, 20))
var motion := InputEventMouseMotion.new()
motion.position = Vector2(150, 20)
card._gui_input(motion)
_release_card(card, Vector2(150, 20))
_eq(clicks[0], 0, "перетаскивание не запускает соединение")
var files_before: int = _game.registry.files.size()
var buffer_before := int(_director.buffer_used())
var payload := {
"kind": "packet", "id": pid, "name": str(card.file_name),
"shape_id": str(card.shape_id), "rotation": int(card.shape_rotation),
}
_main._on_drop_to_folder(_folder_id, payload)
_eq(str(_director.active_packets[pid].state), "loading", "drop в папку начал загрузку")
_eq(int(_director.slots.used_count()), 1, "загрузка занимает in-слот")
_tick(60)
_ok(not _director.active_packets.has(pid), "пакет ушёл из приёма после загрузки")
_click_card(card, Vector2(120, 20))
_eq(clicks[0], 1, "клик по карточке эмитит connect_pressed")
var conn = _find_modal(CONNECTION_SCRIPT)
_ok(conn != null, "ConnectionGame добавлен в modal_root")
_ok(_main._modal_game == conn, "Main запомнил активную модалку")
_eq(int(_director.slots.used_count()), 1, "TCP-слот занят соединением")
# Пользователь прошёл полосу — директор уже обработал успех.
_director._on_connection_finished(true, pid)
_main._on_modal_game_finished(true, pid, conn)
_eq(str(_director.active_packets[pid].state), "connected", "пакет соединён")
_ok(_main._modal_game == null, "ссылка на модалку очищена")
_main._on_drop_to_folder(_folder_id, {"kind": "packet", "id": pid, "name": "тест"})
_ok(str(_director.active_packets[pid].state) == "loading", "пакет начал загрузку в папку")
_tick(40)
_ok(_game.registry.files.size() > files_before,
"файл появился в реестре (%d → %d)" % [files_before, _game.registry.files.size()])
_ok(int(_director.buffer_used()) < buffer_before,
"буфер уменьшился (%d → %d)" % [buffer_before, int(_director.buffer_used())])
_eq(int(_director.slots.used_count()), 0, "in-слот освобождён")
_ok(not _director.active_packets.has(pid), "пакет ушёл из приёма")
## Attach не запускает отправку. ОТПРАВИТЬ открывает мини-игру, таймер стоит.
func _test_attach_and_explicit_send() -> void:
var rid: String = _director.spawn_request_now(1)
func _test_udp_packet() -> void:
var pid: String = _director.spawn_packet_now("text", "udp")
_ok(not pid.is_empty(), "UDP-пакет заспавнен")
if pid.is_empty():
return
# UDP-потеря (20%) — карточка возвращается через 1.5 с; для теста ждём сразу.
if str(_director.active_packets[pid].state) == "lost":
_director.active_packets[pid].state = "waiting"
var files_before: int = _game.registry.files.size()
_main._on_packet_connect(pid)
_ok(_find_modal(CONNECTION_SCRIPT) == null, "для UDP модалка соединения не открывается")
_main._on_drop_to_folder(_folder_id, {"kind": "packet", "id": pid, "name": "udp"})
_ok(str(_director.active_packets[pid].state) == "loading", "UDP-пакет грузится без соединения")
_tick(40)
_ok(_game.registry.files.size() > files_before,
"UDP-файл появился в реестре (%d → %d)" % [files_before, _game.registry.files.size()])
_ok(not _director.active_packets.has(pid), "UDP-пакет ушёл из приёма")
func _test_request_send() -> void:
# Свежие файлы: предыдущие принятые могут быть на cooldown/прикреплены.
for i in 3:
_game.registry.create_file("запрос_%d" % i, "text", _folder_id)
var rid: String = _director.spawn_request_now()
_ok(not rid.is_empty(), "запрос заспавнен")
if rid.is_empty():
return
var req = _director.active_requests[rid]
_eq(int(_director.request_required_count(rid)), 1, "запросу нужен 1 файл")
var fid := _available_file()
_ok(not fid.is_empty(), "есть свободный файл для attach")
if fid.is_empty():
return
_ok(req.required.size() > 0, "у запроса есть required-файлы")
_main._on_file_to_request(rid, 0, {"kind": "file", "id": fid, "from": "folder"})
_eq(req.attached.size(), 1, "файл прикреплён через UI")
_ok(req.attached.has(fid), "req.attached содержит файл")
_ok(not req.sending, "attach НЕ запускает отправку")
_ok(not req.minigame_active, "attach НЕ открывает мини-игру")
_ok(_main._modal_game == null, "после attach модалки нет")
# Прикрепляем каждый required-файл через UI-обработчик.
for i in req.required.size():
var fid := str(req.required[i])
_main._on_file_to_request(rid, i, {"kind": "file", "id": fid})
_ok(req.attached.has(fid), "файл %s прикреплён" % fid)
_eq(req.attached.size(), req.required.size(), "все файлы прикреплены")
_main._refresh_hour()
await process_frame
var card = _request_card(rid)
_ok(card != null, "карточка запроса найдена")
if card == null:
return
_ok(card.get_node_or_null("ConnectButton") == null, "у запроса нет кнопки СОЕДИНИТЬСЯ")
_ok(card.can_send(), "ОТПРАВИТЬ доступна после attach")
_ok(not card.send_disabled(), "кнопка ОТПРАВИТЬ включена")
# Подменная мини-игра: проверяем реальный путь show_minigame → modal_root.
var holder := {"game": null}
_director.send_game_factory = func(_id, _tier, _seed, _ctx):
var g := FakeGame.new()
holder["game"] = g
return g
card._send_btn.pressed.emit()
await process_frame
_fake_game = holder.get("game")
_ok(_fake_game != null, "мини-игра отправки создана")
_ok(req.minigame_active, "ОТПРАВИТЬ включает мини-игру")
_ok(_main._modal_game == _fake_game, "Main запомнил модалку мини-игры")
_ok(_fake_game.get_parent() == _main.modal_root, "мини-игра лежит в modal_root")
_ok(bool(_controller.modal_open), "controller.modal_open = true на мини-игре")
var is_tcp := str(req.protocol).to_lower() == "tcp"
if is_tcp:
_ok(not req.sending, "TCP ждёт соединения перед отправкой")
_click_card(card, Vector2(140, 20))
var conn = _find_modal(CONNECTION_SCRIPT)
_ok(conn != null, "клик по TCP-запросу открыл ConnectionGame")
_ok(req.slot_reserved, "слот запроса зарезервирован")
_director._on_connection_finished(true, rid)
_main._on_modal_game_finished(true, rid, conn)
_ok(req.connected, "TCP-запрос подключён")
else:
_ok(req.sending, "UDP-запрос отправляется сразу после сборки")
# Пока мини-игра открыта, таймер запроса не идёт.
var left_before := float(req.time_left)
_tick(10)
_approx(float(req.time_left), left_before, 0.001,
"таймер запроса стоит во время мини-игры")
# Отправка стартует автоматически (core), без MiniGame-окна и лишнего клика.
_ok(req.sending, "отправка началась автоматически")
_ok(not req.minigame_active, "мини-игра отправки не включается")
_ok(_director._minigame == null, "директор не создаёт MiniGame")
_ok(_find_modal(MINIGAME_SCRIPT) == null, "MiniGame не добавлен в modal_root")
if _fake_game != null:
_fake_game.finished.emit(0.8)
await process_frame
await process_frame
_eq(_main._modal_game, null, "после finished модалка снята")
_ok(not bool(_controller.modal_open), "controller.modal_open сброшен")
_ok(not req.minigame_active, "мини-игра снята с запроса")
_ok(req.sending or _director.is_request_queued(rid),
"после мини-игры запрос отправляется или ждёт слот")
# Успешная отправка: файл расходуется, деньги растут.
var money_before := int(_game.money)
_tick(120)
var ticks := int(ceil(maxf(0.1, float(req.send_duration)) / 0.1)) + 10
_tick(ticks)
_ok(not _director.active_requests.has(rid), "запрос закрыт после отправки")
_ok(not _game.registry.files.has(fid), "файл израсходован при успешной отправке")
_ok(req.attached.is_empty(), "attached очищен после отправки")
_ok(int(_game.money) > money_before,
"деньги выросли (%d → %d)" % [money_before, int(_game.money)])
_director.send_game_factory = Callable()
_fake_game = null
holder.clear()
## Out-слот занят приёмом → запрос в очереди; таймер стоит; таймаут сохраняет файл.
func _test_queue_pause_and_timeout() -> void:
var pid: String = _director.spawn_packet_now("text", "tcp")
_ok(not pid.is_empty(), "пакет для занятия слота создан")
if pid.is_empty():
return
_ok(_director.receive_packet(pid, _folder_id), "пакет занял in-слот")
_eq(int(_director.slots.used_count()), 1, "in-слот занят")
var rid: String = _director.spawn_request_now(1)
var req = _director.active_requests.get(rid)
_ok(req != null, "запрос для очереди создан")
if req == null:
return
var fid := _available_file()
_ok(not fid.is_empty(), "есть файл для запроса в очередь")
if fid.is_empty():
return
_ok(_director.attach_file(rid, fid), "файл прикреплён к запросу очереди")
var holder := {"game": null}
_director.send_game_factory = func(_id, _tier, _seed, _ctx):
var g := FakeGame.new()
holder["game"] = g
return g
_main._on_request_send(rid)
await process_frame
var fake = holder.get("game")
_ok(fake != null, "мини-игра запроса очереди создана")
if fake != null:
fake.finished.emit(0.8)
await process_frame
await process_frame
_ok(_director.is_request_queued(rid), "запрос встал в очередь «ОЖИДАНИЕ СЛОТА»")
_ok(not req.sending, "в очереди запрос ещё не отправляется")
var left_before := float(req.time_left)
_tick(10)
_approx(float(req.time_left), left_before, 0.001,
"таймер запроса стоит, пока он в очереди")
# Загрузка пакета завершается → очередь получает слот.
for i in 200:
if not _director.active_packets.has(pid):
break
_tick(1)
_ok(not _director.active_packets.has(pid), "пакет освободил слот")
_ok(req.sending, "после освобождения слота запрос начал отправку")
# D2: после старта отправки таймер заморожен — низкий time_left не
# обрывает передачу, файл доходит до клиента.
req.time_left = 0.01
var money_send: int = _game.money
_tick(1)
_ok(_director.active_requests.has(rid),
"отправка не падает по таймауту при time_left=0.01")
_ok(req.sending, "запрос всё ещё отправляется")
_ok(_game.registry.files.has(fid), "файл не израсходован до конца передачи")
for i in 200:
if not _director.active_requests.has(rid):
break
_tick(1)
_ok(not _director.active_requests.has(rid), "запрос завершился успешно")
_ok(_game.money > money_send, "выплата за отправку начислена")
_ok(not _game.registry.files.has(fid), "файл израсходован успешной отправкой")
_director.send_game_factory = Callable()
_fake_game = null
holder.clear()
## Отказ: −5 $ / −5 SLA, прикреплённый файл возвращается в папку.
func _test_reject() -> void:
var rid: String = _director.spawn_request_now(1)
var req = _director.active_requests.get(rid)
_ok(req != null, "запрос для отказа создан")
if req == null:
for i in 2:
_game.registry.create_file("отказ_%d" % i, "text", _folder_id)
var rid: String = _director.spawn_request_now()
_ok(not rid.is_empty(), "запрос для отказа заспавнен")
if rid.is_empty():
return
var fid := _available_file()
if not fid.is_empty():
_ok(_director.attach_file(rid, fid), "файл прикреплён перед отказом")
var money_before := int(_game.money)
var sla_before := float(_game.sla)
_main._on_request_reject(rid)
_ok(not _director.active_requests.has(rid), "отклонённый запрос исчез")
_eq(int(_game.money), money_before - 5, "отказ: −5 $")
_approx(float(_game.sla), sla_before - 5.0, 0.001, "отказ: −5 SLA")
if not fid.is_empty():
_ok(_game.registry.files.has(fid), "файл не израсходован при отказе")
func _test_protocol_toggle() -> void:
var rid: String = _director.spawn_request_now(1)
var req = _director.active_requests.get(rid)
_ok(req != null, "запрос для смены протокола создан")
if req == null:
func _test_slots() -> void:
var ids: Array[String] = []
for i in 3:
var pid: String = _director.spawn_packet_now("text", "tcp")
_ok(not pid.is_empty(), "слотовый TCP-пакет %d заспавнен" % i)
ids.append(pid)
if ids.size() != 3:
return
_eq(str(req.protocol), "tcp", "по умолчанию TCP")
_main._on_request_protocol_toggle(rid)
_eq(str(req.protocol), "udp", "переключение на UDP")
_main._on_request_protocol_toggle(rid)
_eq(str(req.protocol), "tcp", "переключение обратно на TCP")
_director.reject_request(rid)
# Два первых соединения занимают оба базовых слота.
for i in 2:
_main._on_packet_connect(ids[i])
var conn = _find_modal(CONNECTION_SCRIPT)
_ok(conn != null, "слот %d: модалка открыта" % i)
_director._on_connection_finished(true, ids[i])
_main._on_modal_game_finished(true, ids[i], conn)
_eq(int(_director.slots.used_count()), 2, "оба TCP-слота заняты")
## Предсказание RequestCard совпадает с реальным выбором Economy (B4) для
## всех ключевых сумм, включая S=8 (dots/T2) и S=24 (video/T4).
func _test_prediction() -> void:
var cases := [
{"types": ["text"], "sum": 1, "id": "dots", "tier": 1},
{"types": ["text", "sound"], "sum": 3, "id": "wires", "tier": 1},
{"types": ["photo"], "sum": 4, "id": "text", "tier": 1},
{"types": ["video"], "sum": 8, "id": "dots", "tier": 2},
{"types": ["video", "video", "video"], "sum": 24, "id": "video", "tier": 4},
]
for entry in cases:
var tc: Dictionary = entry
var types: Array = tc["types"]
var slots: Array = []
for t in types:
slots.append({"type": str(t), "attached": true})
var card := RequestCard.new()
root.add_child(card)
card.setup("req_pred", "Клиент", "cli_1", "TCP", slots, 30.0, 30.0,
0.0, slots.size())
var p: Dictionary = card.prediction()
var expected: Dictionary = ECONOMY.send_minigame_for(types)
_eq(str(p.get("id", "")), str(expected.get("id", "")),
"S=%d: id совпадает с Economy" % int(tc["sum"]))
_eq(int(p.get("tier", 0)), int(expected.get("tier", 0)),
"S=%d: tier совпадает с Economy" % int(tc["sum"]))
_eq(int(p.get("sum", 0)), int(expected.get("sum", 0)),
"S=%d: sum совпадает с Economy" % int(tc["sum"]))
_eq(str(p.get("id", "")), str(tc["id"]), "S=%d: ожидаемая игра" % int(tc["sum"]))
_eq(int(p.get("tier", 0)), int(tc["tier"]), "S=%d: ожидаемая ступень" % int(tc["sum"]))
card.free()
# Третий не должен подключиться.
_main._on_packet_connect(ids[2])
_eq(int(_director.slots.used_count()), 2, "третий пакет слот не занял")
_eq(str(_director.active_packets[ids[2]].state), "waiting", "третий пакет ждёт")
_ok(_find_modal(CONNECTION_SCRIPT) == null, "для третьего модалки нет")
# Закрываем первый слот — третий должен подключиться.
_main._on_slot_close(ids[0])
_eq(int(_director.slots.used_count()), 1, "слот освобождён")
_eq(str(_director.active_packets[ids[0]].state), "waiting", "пакет вернулся в ожидание")
_main._on_packet_connect(ids[2])
var conn3 = _find_modal(CONNECTION_SCRIPT)
_ok(conn3 != null, "третий пакет подключился после освобождения слота")
_eq(int(_director.slots.used_count()), 2, "снова заняты два слота")
_director._on_connection_finished(true, ids[2])
_main._on_modal_game_finished(true, ids[2], conn3)
# --- хелперы ----------------------------------------------------------------
@@ -339,15 +298,6 @@ func _tick(count: int) -> void:
_controller.tick(0.1)
func _available_file() -> String:
if _game == null or _game.registry == null:
return ""
for file in _game.registry.get_available_files():
if str(file.location) == "folder" and str(file.attached_request).is_empty():
return str(file.id)
return ""
func _first_folder_id() -> String:
if _game == null or _game.registry == null:
return ""
@@ -357,6 +307,18 @@ func _first_folder_id() -> String:
return ""
func _find_modal(script) -> Node:
if _main == null or _main.modal_root == null:
return null
for c in _main.modal_root.get_children():
# Узлы, удалённые в этом же кадре (queue_free), ещё висят в дереве.
if c.is_queued_for_deletion():
continue
if c.get_script() == script:
return c
return null
func _packet_card(id: String):
for c in _main.packets:
if c != null and is_instance_valid(c) and str(c.id) == id:
@@ -371,29 +333,28 @@ func _request_card(id: String):
return null
func _on_timeout() -> void:
if _finished:
return
print("FAIL: тест превысил 90 секунд")
_finished = true
quit(1)
func _click_card(card: Control, pos: Vector2) -> void:
_press_card(card, pos)
_release_card(card, pos)
func _press_card(card: Control, pos: Vector2) -> void:
var press := InputEventMouseButton.new()
press.button_index = MOUSE_BUTTON_LEFT
press.pressed = true
press.position = pos
card._gui_input(press)
func _release_card(card: Control, pos: Vector2) -> void:
var release := InputEventMouseButton.new()
release.button_index = MOUSE_BUTTON_LEFT
release.pressed = false
release.position = pos
card._gui_input(release)
func _finish() -> void:
if _finished:
return
_finished = true
if _main != null and is_instance_valid(_main):
_main.queue_free()
_stop_audio()
await process_frame
await process_frame
await process_frame
var audio = root.get_node_or_null("Audio")
if audio != null:
audio.queue_free()
await process_frame
await create_timer(0.1).timeout
if _fails == 0:
print("PASS")
else:
@@ -401,24 +362,6 @@ func _finish() -> void:
quit(0 if _fails == 0 else 1)
## Гасим AudioStreamPlayer перед выходом, чтобы не было утечки WAV.
func _stop_audio() -> void:
var audio = root.get_node_or_null("Audio")
if audio == null:
return
if audio.has_method("stop_music"):
audio.call("stop_music")
for child in audio.get_children():
if child is AudioStreamPlayer:
var player := child as AudioStreamPlayer
player.stop()
player.stream = null
if audio.get("_sfx_streams") is Dictionary:
(audio.get("_sfx_streams") as Dictionary).clear()
if audio.get("_music_streams") is Dictionary:
(audio.get("_music_streams") as Dictionary).clear()
func _ok(condition: bool, message: String) -> void:
_checks += 1
if condition:
@@ -435,25 +378,7 @@ func _eq(actual, expected, message: String) -> void:
print("FAIL: %s — получено %s, ожидалось %s" % [message, actual, expected])
func _approx(actual: float, expected: float, eps: float, message: String) -> void:
_checks += 1
if absf(float(actual) - float(expected)) <= eps:
return
_fails += 1
print("FAIL: %s — получено %.6f, ожидалось %.6f" % [message, actual, expected])
func _fail(message: String) -> void:
_checks += 1
_fails += 1
print("FAIL: %s" % message)
## Минимальная мини-игра для теста: сигнал finished(q) и running для Main.
class FakeGame:
extends Node
signal finished(quality: float)
var running := true
var id := "fake"
+40 -49
View File
@@ -46,7 +46,7 @@ func _init() -> void:
_controller.set_paused(true)
_test_receive_scroll()
await _test_search()
_test_search()
_test_delete_folder()
_test_calm_backdrop()
await _test_destroy_contents()
@@ -58,37 +58,34 @@ func _init() -> void:
_finish()
## Магазин v2: закрыт до часа 2; 3 лота, без старых shop_search/shield.
## Магазин: закрыт до часа 2; позиции search/shield — только по мета-наградам.
func _test_shop_and_meta() -> void:
_eq(int(_game.hour), 1, "подготовка первого часа")
_ok(not _game.feature_unlocked("shop"), "магазин закрыт на часе 1")
_main._on_shop()
_ok(not _main.shop_modal.visible, "клик по МАГАЗИНУ на часе 1 не открывает окно")
_ok(not _shop_has("shop_search"), "старого shop_search больше нет")
_ok(not _shop_has("shop_shield"), "старого shop_shield больше нет")
_ok(_shop_has("lot_sla"), "v2-лот lot_sla доступен")
# Час 2: контроллер генерирует 3 предложения.
# Мета-награда ещё не получена — поиск закрыт и отсутствует в каталоге.
_ok(not _game.feature_unlocked("search"), "поиск закрыт до meta survive_hour_3")
_ok(not _shop_has("shop_search"), "shop_search отсутствует в доступном каталоге")
_ok(not _shop_has("shop_shield"), "shop_shield отсутствует в доступном каталоге")
_ok(_shop_has("shop_sla"), "shop_sla доступен всегда")
# Открываем час 2 → магазин доступен, мета-награда открывает поиск.
_game.hour = 2
_controller.shop.refresh(2, int(_game.money), true)
_controller.shop_changed.emit()
_main._update_shop_visibility()
_ok(_main._shop_btn.visible, "на часе 2 кнопка МАГАЗИН видна")
_ok(_game.feature_unlocked("shop"), "магазин открыт прогрессией часа 2")
_eq(_controller.shop_data().size(), 3, "на часе 2 — 3 предложения")
_main._on_shop()
_ok(_main.shop_modal.visible, "магазин открывается в подготовке часа 2")
_eq(_main.shop_modal.card_ids().size(), 3, "в магазине 3 карточки")
_main.shop_modal.close()
_ok(not _main.shop_modal.visible, "магазин закрывается по Закрыть/Esc")
_ok(_game.feature_unlocked("shop"), "магазин открыт прогрессией часа 2")
# Возвращаем H1, чтобы основной сценарий часа остался прежним.
_game.meta_unlocks["survive_hour_3"] = true
_ok(_game.feature_unlocked("search"), "meta survive_hour_3 открывает поиск")
_ok(_shop_has("shop_search"), "shop_search появился в каталоге после meta")
_game.hour = 1
_game.shop_offers.clear()
_game.shop_offer_hour = 0
_game.shop_locked.clear()
_main._update_shop_visibility()
_ok(not _main._shop_btn.visible, "на часе 1 магазин снова скрыт")
func _shop_has(id: String) -> bool:
@@ -111,7 +108,7 @@ func _test_receive_scroll() -> void:
if director == null:
return
# 4 пакета (базовый буфер 6) — всё влезает, скроллбар скрыт.
# 4 пакета (базовый буфер) — всё влезает, скроллбар скрыт.
for i in 4:
director.spawn_packet_now("text", "tcp")
_main._refresh_hour()
@@ -119,8 +116,8 @@ func _test_receive_scroll() -> void:
_eq(_main.packets.size(), 4, "4 карточки пакетов")
_ok(not bar.visible, "при 4 пакетах скроллбар скрыт")
# Канал+буфер до 8 (лот lot_channel) — карточки выходят за окно.
_game.set_lot_level("lot_channel", 2)
# Апгрейд буфера до 8 — карточки выходят за окно.
_game.upgrades["buffer_level"] = 2
for i in 4:
director.spawn_packet_now("text", "tcp")
_main._refresh_hour()
@@ -128,8 +125,6 @@ func _test_receive_scroll() -> void:
_eq(_main.packets.size(), 8, "8 карточек пакетов")
_ok(bar.visible, "при 8 пакетах скроллбар виден")
_ok(bar._limit() > 0.0, "у скроллбара есть запас прокрутки (%.1f)" % bar._limit())
if _main.packets.size() < 8:
return
var view_h := float(_main.receive_window.content.size.y)
_ok(_main.packets[7].position.y + _main.packets[7].size.y <= view_h + 0.5,
@@ -169,61 +164,57 @@ func _test_search() -> void:
_game.registry.create_file("отчёт_демо", "text", folder_id)
_game.registry.create_file("прайс_демо", "text", folder_id)
_main._refresh_prep()
_main.grid.refresh()
await process_frame
var total_before: int = _main.grid.file_count()
var total_before: int = _main.grid.get_cards().size()
_ok(total_before >= 2, "в папке есть файлы (%d)" % total_before)
edit.text = "отчёт"
_main._on_search_changed(edit.text)
_eq(_main._filtered_files().size(), 1, "по подстроке найден один файл")
_ok(_main.files_window.title_hint.contains("найдено 1"),
"заголовок показывает результат фильтра: %s" % _main.files_window.title_hint)
_ok(_main.grid.get_cards().size() < total_before,
"фильтр уменьшает список (%d → %d)" % [total_before, _main.grid.get_cards().size()])
_eq(_main.grid.get_cards().size(), 1, "по подстроке найден один файл")
edit.text = ""
_main._on_search_changed(edit.text)
_eq(_main._filtered_files().size(), total_before, "пустой запрос возвращает все файлы")
_eq(_main.grid.get_cards().size(), total_before, "пустой запрос возвращает все файлы")
# Без апгрейда поле скрыто.
_game.upgrades["search_unlocked"] = 0
_main._refresh_prep()
_ok(not edit.visible, "без апгрейда поле поиска скрыто")
_game.upgrades["search_unlocked"] = 1
await _test_service_filter()
_test_service_filter()
func _test_service_filter() -> void:
_main.grid.refresh()
await process_frame
var folder_id := str(_main.grid.folder_id)
var hidden_before: int = _main.grid.hidden_count()
_ok(_main.grid.file_count() > 0, "в сетке есть файлы для фильтра")
var fid := ""
for file in _game.registry.get_folder_files(folder_id):
if str(file.location) != "failed" and str(file.location) != "malicious":
fid = str(file.id)
break
_ok(not fid.is_empty(), "найден файл для пометки опасным")
if fid.is_empty():
var check = _main.grid._check
_ok(check != null, "чекбокс фильтра создан")
if check == null:
return
_ok(_game.registry.mark_failed(fid), "файл помечен failed")
_main.grid.refresh()
await process_frame
_eq(check.caption, "СКРЫТЬ ОПАСНЫЕ", "подпись фильтра — «СКРЫТЬ ОПАСНЫЕ»")
_ok(check.tooltip_text.contains("заблокированные"),
"tooltip объясняет, что скрывается")
var cards: Array = _main.grid.get_cards()
_ok(not cards.is_empty(), "в сетке есть карточки для фильтра")
if cards.is_empty():
return
var first: FileCard = cards[0]
var old_state := first.state
first.state = "locked"
var hidden: int = _main.grid.hidden_count()
_ok(hidden >= hidden_before + 1, "hidden_count считает опасные файлы (%d)" % hidden)
_ok(hidden >= 1, "hidden_count считает опасные файлы")
_main._on_service_filter(true)
_ok(bool(_main.grid.service_filter), "фильтр опасных включён")
var items: Array = _main.toast_layer._items
_ok(not items.is_empty(), "тост фильтра показан")
if not items.is_empty():
var text := str(items.back().txt)
_ok(text.contains(str(hidden)), "тост показывает реальное число (%s)" % text)
_ok(not text.contains("СЛУЖЕБНЫЕ"), "тост не про «служебные» (%s)" % text)
_main._on_service_filter(false)
_ok(not bool(_main.grid.service_filter), "фильтр опасных выключен")
_game.registry.remove_file(fid)
_main.grid.refresh()
_main.grid._on_service_toggled(false)
first.state = old_state
_main.grid._apply_filter()
func _test_delete_folder() -> void:
+81 -250
View File
@@ -1,8 +1,5 @@
extends SceneTree
## Headless-тест связки Main.tscn ↔ GameController на экране подготовки (v2).
##
## Проверяет стартовые статы, сетку 5×4, стол сортировки вместо окон
## ПРИЁМ/ОТПРАВКА, счётчики в клетках, превью фигуры пакета и магазин после H1.
## Headless-тест связки Main.tscn ↔ GameController на экране подготовки.
##
## Запуск:
## godot --headless --path . --script tests/test_main_prep.gd
@@ -33,19 +30,9 @@ func _init() -> void:
var game = root.get_node_or_null("Game")
_ok(game != null, "autoload Game доступен")
if game == null:
_finish()
return
if game != null:
_eq(int(game.money), 150, "стартовые деньги 150")
# --- Стартовые статы v2 --------------------------------------------------
_eq(int(game.money), 50, "стартовые деньги 50")
_ok(absf(float(game.sla) - 60.0) < 0.01, "стартовый SLA 60")
_ok(absf(float(game.sla_cap()) - 60.0) < 0.01, "кап SLA 60")
_eq(int(game.connection_slots()), 1, "стартовый слот один")
var dims: Vector2i = game.folder_dims()
_eq(dims, Vector2i(5, 4), "стартовая сетка папки 5×4")
# --- Дерево, сетка, заголовки -------------------------------------------
var rows: Array = main.tree.get("_rows")
var sections := 0
for e in rows:
@@ -56,94 +43,48 @@ func _init() -> void:
_ok(main.grid.folder_id != "", "выбрана первая папка для сетки")
_ok(main.files_window.title.contains(main.grid.folder_name),
"заголовок ФАЙЛЫ использует реальное имя папки (%s)" % main.files_window.title)
var grid_stats: Dictionary = main.grid.stats()
_eq(int(grid_stats.get("total", 0)), 20, "сетка 5×4 = 20 клеток")
_eq(int(main.grid.grid_metrics().get("cols", 0)), 5, "сетка: 5 столбцов")
_eq(int(main.grid.grid_metrics().get("rows", 0)), 4, "сетка: 4 строки")
_eq(str(main.grid.footer_text()), "занято 0 из 20 · свободно 20",
"подвал сетки считает клетки")
_ok(main.files_window.title_hint.contains("занято 0 из 20"),
"подвал ФАЙЛЫ считает клетки: %s" % main.files_window.title_hint)
_eq(str(main.status_bar.get_metric("money").get("value", "")), "$ 150", "статус-бар: деньги")
_eq(str(main.status_bar.get_metric("channel").get("value", "")), "0%", "статус-бар: канал 0%")
var sla_metric: Dictionary = main.status_bar.get_metric("sla")
_eq(str(sla_metric.get("value", "")), "128 / 200", "SLA на старте — 128 / 200")
_ok(absf(float(sla_metric.get("fill", 0.0)) - 128.0 / 200.0) < 0.01,
"заливка SLA = 128/200 (%.2f)" % float(sla_metric.get("fill", 0.0)))
# --- Статус-бар ----------------------------------------------------------
_eq(str(main.status_bar.get_metric("money").get("value", "")), "$ 50",
"статус-бар: деньги")
_eq(str(main.status_bar.get_metric("channel").get("value", "")), "0/10",
"статус-бар: канал 0/10 (load/capacity)")
_eq(str(main.status_bar.get_metric("sla").get("value", "")), "60 / 60",
"SLA на старте — 60 / 60")
_eq(str(main.status_bar.get_metric("tcp").get("value", "")), "0 / 1",
"статус-бар: слоты 0 / 1")
_eq(str(main.status_bar.get_metric("buffer").get("value", "")), "0 / 6",
"статус-бар: буфер 0 / 6")
# --- Правая колонка подготовки: стол сортировки -------------------------
_ok(main.sorting_table != null, "SortingTable создан")
if main.sorting_table != null:
_ok(main.sorting_table.visible, "стол сортировки виден в подготовке")
_ok(not main.receive_window.visible, "окно ПРИЁМ скрыто в подготовке")
_ok(not main.send_window.visible, "окно ОТПРАВКА скрыто в подготовке")
_eq(main.sorting_table.total_cells(), 24, "стол сортировки 4×6 = 24 клетки")
_eq(main.sorting_table.grid_metrics().get("cols", 0), 4, "стол: 4 столбца")
_eq(main.sorting_table.grid_metrics().get("rows", 0), 6, "стол: 6 строк")
# --- Магазин в H1 скрыт --------------------------------------------------
_ok(main.main_menu.visible == false, "главное меню скрыто после старта")
_ok(main._shop_btn != null and not main._shop_btn.visible,
"магазин скрыт в первом приготовлении")
_eq(main.controller.shop_data().size(), 0, "в H1 нет предложений магазина")
# --- Повторная перерисовка не должна падать ------------------------------
# Повторная перерисовка не должна падать.
main._refresh_prep()
_ok(main.grid.folder_id != "", "повторная подготовка не сбросила папку")
# --- Счётчики дерева в клетках ------------------------------------------
var folder_id := str(main.grid.folder_id)
var folder_row_count := -1
var folder_row_capacity := -1
for e in main.tree.get("_rows"):
var row = e["row"]
if not row.is_section and str(row.folder_id) == folder_id:
folder_row_count = int(row.count)
folder_row_capacity = int(row.capacity)
_eq(folder_row_count, 0, "дерево: папка показывает 0 занятых клеток")
_eq(folder_row_capacity, 20, "дерево: вместимость папки 20 клеток")
# --- Плюрализация заголовка ДИСКИ ---------------------------------------
var base_folders: int = main._count_folders(main.controller.prep_data().get("sections", []))
_eq(main.tree_window.title_hint,
main._plural_ru(base_folders, "ПАПКА", "ПАПКИ", "ПАПОК"),
"заголовок ДИСКИ совпадает с числом папок")
# --- Плюрализация заголовка ДИСКИ: 1 ПАПКА / 2 ПАПКИ / 5 ПАПОК ---------
_eq(main.tree_window.title_hint, "1 ПАПКА", "1 папка — «1 ПАПКА»")
_ok(game.registry.create_folder("C", "ПЛЮРАЛ 2") != null,
"вторая папка создана для проверки падежа")
main._refresh_prep()
_eq(main.tree_window.title_hint, "2 ПАПКИ", "2 папки — «2 ПАПКИ»")
while main._count_folders(main.controller.prep_data().get("sections", [])) < 5:
if game.registry.create_folder("C",
main._unique_folder_name("C", "ПЛЮРАЛ")) == null:
break
main._refresh_prep()
_eq(main.tree_window.title_hint, "5 ПАПОК", "5 папок — «5 ПАПОК»")
_eq(main._plural_ru(11, "ПАПКА", "ПАПКИ", "ПАПОК"), "11 ПАПОК",
"исключение 11–14 — «11 ПАПОК»")
_test_start_stats(main)
_test_settings_from_menu(main)
await _test_registry_autorefresh(main, game)
_test_packet_shape(main, game)
_test_search(main)
_test_folder_name_generation(main, game)
_test_rename_folder(main, game)
_test_folder_name_generation(main, game)
_test_top_menu(main)
_test_vhs(main)
await _test_shop_after_hour(main, game)
await _test_free_file_destruction(main, game)
print("")
print("Проверок: %d, провалов: %d" % [_checks, _fails])
_finish()
## Строка стартовой сводки главного меню соответствует v2 (50 $, SLA 60, 1 слот).
func _test_start_stats(main) -> void:
var text := str(main.main_menu.start_stats_text())
_ok(text.contains("50"), "сводка меню: $50 на старте (%s)" % text)
_ok(text.contains("60 SLA"), "сводка меню: 60 SLA (%s)" % text)
_ok(text.contains("1 слот"), "сводка меню: 1 слот (%s)" % text)
_ok(text.contains("9 часов"), "сводка меню: 9 часов (%s)" % text)
_ok(not text.contains("150"), "сводка меню без устаревших $150")
_ok(not text.contains("128"), "сводка меню без устаревшего SLA 128")
## Кнопка «Настройки» главного меню открывает SettingsModal поверх меню.
func _test_settings_from_menu(main) -> void:
main.main_menu.visible = true
@@ -159,126 +100,68 @@ func _test_settings_from_menu(main) -> void:
## Изменения реестра сами обновляют сетку и счётчики дерева (без _refresh_prep).
func _test_registry_autorefresh(main, game) -> void:
var folder_id := str(main.grid.folder_id)
var before_cells: int = main.grid.occupied_cells()
var before_files: int = main.grid.file_count()
var before: int = main.grid.get_cards().size()
var file = game.registry.create_file("авто_файл", "text", folder_id)
_ok(file != null, "файл для автообновления создан")
if file == null:
return
await process_frame
await process_frame
_eq(main.grid.file_count(), before_files + 1,
"сетка обновилась по registry_changed (%d → %d)" % [before_files,
main.grid.file_count()])
_eq(main.grid.occupied_cells(), before_cells + 1,
"занятые клетки выросли на 1 (текст)")
_ok(main.files_window.title_hint.contains("занято %d из 20"
% main.grid.occupied_cells()), "подвал ФАЙЛЫ обновился: %s"
% main.files_window.title_hint)
_eq(main.grid.get_cards().size(), before + 1,
"сетка обновилась по registry_changed (%d → %d)" % [before,
main.grid.get_cards().size()])
var count_ok := false
for e in main.tree.get("_rows"):
var row = e["row"]
if not row.is_section and str(row.folder_id) == folder_id:
count_ok = int(row.count) == main.grid.occupied_cells()
_ok(count_ok, "дерево обновило счётчик ЗАНЯТЫХ КЛЕТОК")
count_ok = int(row.count) == before + 1
_ok(count_ok, "дерево обновило счётчик папки")
# Прикрепление меняет состояние файла в реестре (сетка читает registry).
# state attached приходит из prep_data и обновляет FileCard.
_ok(game.registry.attach_file(str(file.id), "req_auto"),
"файл прикреплён к тестовому запросу")
_eq(str(file.attached_request), "req_auto", "у файла выставлен attached_request")
await process_frame
await process_frame
var state := ""
for c in main.grid.get_cards():
if str(c.id) == str(file.id):
state = str(c.state)
_eq(state, "attached", "FileCard state обновился до attached")
game.registry.detach_file(str(file.id))
await process_frame
## Карточка пакета показывает превью фигуры и «ЗАЙМЁТ N КЛЕТОК».
func _test_packet_shape(main, game) -> void:
var director = main.controller.hour_director
_ok(director != null, "hour_director доступен в подготовке")
if director == null:
return
var pid: String = director.spawn_packet_now("sound", "tcp")
_ok(not pid.is_empty(), "пакет для превью создан")
if pid.is_empty() or not director.active_packets.has(pid):
return
var pkt = director.active_packets[pid]
var card := PacketCard.new()
main.receive_window.content.add_child(card)
card.setup(str(pkt.id), str(pkt.file_name), "wav", str(pkt.type), "TCP",
20.0, 20.0, 0.0, 512, "waiting", str(pkt.shape_id), int(pkt.rotation))
_eq(card.cells_count(), 2, "звук занимает 2 клетки")
_ok(card.cells_text().begins_with("ЗАЙМЁТ 2"), "подпись карточки: %s"
% card.cells_text())
_ok(not card.shape_id.is_empty(), "у карточки есть shape_id")
var payload = card._get_drag_data(Vector2.ZERO)
_ok(payload is Dictionary, "drag-данные карточки — словарь")
if payload is Dictionary:
_eq(str(payload.get("kind", "")), "packet", "payload kind = packet")
_eq(str(payload.get("id", "")), str(pkt.id), "payload id пакета")
_eq(str(payload.get("shape_id", "")), str(pkt.shape_id),
"payload содержит shape_id")
_ok(payload.has("rotation"), "payload содержит rotation")
card.queue_free()
await process_frame
## Поиск использует grid.set_filter и не схлопывает клетки.
func _test_search(main) -> void:
main._search_edit.visible = true
main._search_edit.text = "авто_файл"
main._on_search_changed("авто_файл")
_eq(main._filtered_files().size(), 1, "поиск нашёл один файл")
_ok(main.files_window.title_hint.contains("найдено 1"),
"заголовок показывает результат фильтра: %s" % main.files_window.title_hint)
main._search_edit.text = "нет_такого"
main._on_search_changed("нет_такого")
_eq(main._filtered_files().size(), 0, "поиск без совпадений — 0 файлов")
main._search_edit.text = ""
main._on_search_changed("")
main._search_edit.visible = false
main._apply_file_filter()
## Имена новых папок генерируются и остаются уникальными в секции.
## D2 (H5): папки №3–6 стоят `folder_new_cost` (100 $), №7 упирается в
## `folders_max` (6); без денег новая папка не создаётся.
func _test_folder_name_generation(main, game) -> void:
# «Разное» уже создано setup_new_run — получаем следующее имя.
_eq(main._unique_folder_name("C", "Разное"), "Разное 2",
"занятое имя получает суффикс 2")
var before: int = game.registry.folder_count()
_eq(before, 2, "на старте ровно 2 папки")
# Без денег третья папка не создаётся.
game.money = 0
main._new_folder_in("C")
_eq(game.registry.folder_count(), before, "без денег папка не создаётся")
_eq(int(game.money), 0, "неудачная покупка не уводит деньги в минус")
# 3-я папка: ровно 100 $ и списание.
game.add_money(100)
main._new_folder_in("C")
_eq(game.registry.folder_count(), before + 1, "3-я папка создана за 100 $")
_eq(int(game.money), 0, "3-я папка списала 100 $")
var third_id := str(main.grid.folder_id)
_ok(game.registry.rename_folder(third_id, "Разное 2"),
"третья папка переименована для проверки имён")
_ok(game.registry.create_folder("C", "Разное 2") != null, "папка «Разное 2» создана")
_eq(main._unique_folder_name("C", "Разное"), "Разное 3",
"следующий дубль получает суффикс 3")
# 4-я, 5-я, 6-я папки — тоже по 100 $.
for expected in [4, 5, 6]:
game.add_money(100)
main._new_folder_in("C")
_eq(game.registry.folder_count(), expected,
"%d-я папка создана за 100 $" % expected)
_eq(int(game.money), 0, "%d-я папка списала 100 $" % expected)
_ok(game.registry.folder_count() <= 6, "всего не больше 6 папок")
# 7-я папка запрещена лимитом, деньги не списываются.
game.add_money(100)
# _new_folder_in больше не падает на дубле: два вызова — два имени.
var before: int = game.registry.folder_count()
main._new_folder_in("C")
_eq(game.registry.folder_count(), 6, "7-я папка заблокирована лимитом")
_eq(int(game.money), 100, "заблокированная 7-я папка не списала деньги")
main._new_folder_in("C")
_eq(game.registry.folder_count(), before + 2, "две новые папки созданы")
var names := {}
for folder in game.registry.folders.values():
if str(folder.section) != "C":
continue
var key := str(folder.name).to_lower()
_ok(not names.has(key), "имя папки уникально в секции: %s" % str(folder.name))
names[key] = true
# Лимит 12 папок: лишняя не создаётся.
for i in 20:
if main._section_folder_count("C") >= 12:
break
game.registry.create_folder("C", main._unique_folder_name("C", "Лимит"))
_eq(main._section_folder_count("C"), 12, "в секции ровно 12 папок")
var at_limit: int = game.registry.folder_count()
main._new_folder_in("C")
_eq(game.registry.folder_count(), at_limit, "сверх 12 папок в секции не создаётся")
main._refresh_prep()
@@ -363,38 +246,6 @@ func _test_top_menu(main) -> void:
main.confirm_modal._answer(true)
_ok(main.main_menu.visible, "подтверждение возвращает в главное меню из подготовки")
_ok(not bool(main.controller.paused), "в подготовке пауза не включается")
main.main_menu.visible = false
## D2 (M16): без денег и с SLA=1 обычный файл можно уничтожить в подготовке
## после подтверждения (без штрафа SLA) и начать час — soft-lock исключён.
func _test_free_file_destruction(main, game) -> void:
_eq(main.controller.phase, &"prep", "перед тестом удаления — подготовка")
var folder_id := str(main.grid.folder_id)
if folder_id.is_empty():
_ok(false, "нет выбранной папки для теста удаления")
return
var file = game.registry.create_file("Удаляемый", "text", folder_id)
_ok(file != null, "файл для удаления создан")
if file == null:
return
var fid := str(file.id)
game.money = 0
game.sla = 1.0
var sla_before := float(game.sla)
main._destroy_selected_file(fid)
_ok(main.confirm_modal != null and main.confirm_modal.visible,
"удаление обычного файла просит подтверждение")
if main.confirm_modal == null:
return
main.confirm_modal._answer(true)
_ok(not game.registry.files.has(fid), "файл уничтожен после подтверждения")
_eq(int(game.money), 0, "удаление файла не стоит денег")
_approx(float(game.sla), sla_before, 0.001, "удаление файла не стоит SLA")
main._on_start_hour()
await process_frame
_eq(main.controller.phase, &"hour",
"после удаления файла час всё равно начинается")
## VHS-слой: только главное меню, не мешает вводу и не строится в headless.
@@ -403,46 +254,34 @@ func _test_vhs(main) -> void:
_ok(overlay != null, "VHS-слой создан")
if overlay == null:
return
_ok(overlay.get_parent() == main.main_menu, "VHS-слой — ребёнок главного меню")
_eq(overlay.mouse_filter, Control.MOUSE_FILTER_IGNORE, "VHS не ловит мышь")
_eq(overlay.process_mode, Node.PROCESS_MODE_ALWAYS, "VHS работает при паузе")
_ok(not overlay.is_available(), "в headless VHS-материал не создаётся")
_ok(overlay is RefCounted and not (overlay is Node),
"VHS — RefCounted, не создаёт Node/Control")
_ok(not overlay.is_available(), "в headless VHS-текстуры не создаются")
_ok(main.main_menu.is_vhs_enabled(), "VHS включён по умолчанию")
main.main_menu.set_vhs_enabled(true)
_ok(overlay.enabled, "set_vhs_enabled(true) включает эффект")
main.main_menu.set_vhs_enabled(false)
_ok(not main.main_menu.vhs.enabled, "set_vhs_enabled(false) отключает эффект")
main.main_menu.set_vhs_enabled(true)
# Модалка настроек показывает состояние, но применяет только по «Подтвердить».
main.main_menu.set_vhs_enabled(false)
main._open_settings()
_ok(main.settings_modal._vhs != null and not main.settings_modal._vhs.checked,
"настройки показывают VHS выключенным")
_ok(not bool(main.settings_modal.get_state().get("vhs", true)),
"get_state().vhs — false")
main.settings_modal.close()
main.main_menu.set_vhs_enabled(true)
## После первого часа: 3 лота, кнопка МАГАЗИН и open_prep.
func _test_shop_after_hour(main, game) -> void:
main._on_start_hour()
await process_frame
_eq(main.controller.phase, &"hour", "час 1 запущен")
_ok(main.receive_window.visible, "ПРИЁМ виден в час")
_ok(main.send_window.visible, "ОТПРАВКА видна в час")
_ok(main.sorting_table != null and not main.sorting_table.visible,
"стол сортировки скрыт в час")
var director = main.controller.hour_director
director.time_left = 0.05
main.controller.tick(0.1)
await process_frame
_eq(main.controller.phase, &"hour_result", "час 1 завершён")
main.controller.next_after_hour()
await process_frame
_eq(main.controller.phase, &"prep", "после отчёта — подготовка часа 2")
_eq(int(game.hour), 2, "Game.hour = 2")
_ok(main._shop_btn != null and main._shop_btn.visible,
"магазин виден после первого часа")
var offers: Array = main.controller.shop_data()
_eq(offers.size(), 3, "после H1 — 3 предложения")
_ok(main.sorting_table.visible, "стол снова виден в подготовке")
main._on_shop()
_ok(main.shop_modal.visible, "магазин открывается в подготовке")
_eq(main.shop_modal.card_ids().size(), 3, "в магазине 3 карточки")
main.shop_modal.close()
_ok(not main.shop_modal.visible, "магазин закрывается")
main._shop_btn.visible = true
# Применение настроек меняет только меню; игровые панели не затрагиваются.
main._apply_settings({"sfx": 70, "music": 55, "calm_bg": true,
"fullscreen": false, "vhs": false}, false)
_ok(not main.main_menu.is_vhs_enabled(), "настройки применяют VHS к меню")
main._apply_settings({"sfx": 70, "music": 55, "calm_bg": true,
"fullscreen": false, "vhs": true}, false)
_ok(main.main_menu.is_vhs_enabled(), "настройки возвращают VHS")
func _finish() -> void:
@@ -469,14 +308,6 @@ func _eq(actual, expected, message: String) -> void:
print("FAIL: %s — получено %s, ожидалось %s" % [message, actual, expected])
func _approx(actual: float, expected: float, eps: float, message: String) -> void:
_checks += 1
if absf(float(actual) - float(expected)) <= eps:
return
_fails += 1
print("FAIL: %s — получено %.6f, ожидалось %.6f" % [message, float(actual), float(expected)])
func _fail(message: String) -> void:
_checks += 1
_fails += 1
+132 -231
View File
@@ -1,250 +1,149 @@
extends SceneTree
## Headless-тесты базы мини-игр, фабрики и выбора игры.
## Headless-тест мини-игр: формула зоны D04, скорости, выбор типа, quality.
## Запуск: godot --headless --path . --script tests/test_minigame.gd
const CG := preload("res://scripts/minigames/connection_game.gd")
const MG := preload("res://scripts/minigames/minigame_base.gd")
const FACTORY := preload("res://scripts/minigames/minigame_factory.gd")
const ECONOMY := preload("res://scripts/core/economy.gd")
var _checks: int = 0
var _fails: int = 0
var _case: String = ""
var _last_q: float = -1.0
var _finish_count: int = 0
var _connection_result: int = -1
var _minigame_quality: float = -1.0
func _init() -> void:
await process_frame
_test_base_contract()
_test_base_finish()
_test_base_skip_and_esc()
_test_verdicts()
_test_factory_send()
_test_selection()
_test_attack_placeholder()
_test_no_timers()
_test_zone_formula()
_test_speeds()
_test_type_selection()
_test_minigame_stub()
_test_connection_node()
_test_connection_miss_closes()
_finish()
func _test_base_contract() -> void:
_set_case("base_contract")
func _test_zone_formula() -> void:
_set_case("zone")
_approx(CG.compute_zone(1, 0, 0), 0.30, 0.0001, "час 1, 0 успехов, 0 зон")
_approx(CG.compute_zone(9, 0, 0), 0.26, 0.0001, "час 9 → 0.26")
_approx(CG.compute_zone(1, 10, 0), 0.25, 0.0001, "10 успехов → 0.25")
_approx(CG.compute_zone(1, 0, 1), 0.35, 0.0001, "зона +5%")
_approx(CG.compute_zone(1, 0, 3), 0.36, 0.0001, "cap 0.36")
_approx(CG.compute_zone(1, 80, 0), 0.18, 0.0001, "нижний clamp 0.18")
_approx(CG.compute_zone(25, 0, 0), 0.18, 0.0001, "поздний час → clamp 0.18")
_approx(CG.compute_zone(12, 10, 1), 0.245, 0.0001, "смешанный пример")
_approx(CG.compute_zone(0, 0, 0), 0.30, 0.0001, "час < 1 клампится")
_approx(CG.compute_zone(1, -5, 0), 0.30, 0.0001, "отрицательные успехи клампятся")
func _test_speeds() -> void:
_set_case("speeds")
_approx(CG.speed_for(0), 440.0, 0.0001, "палочка 1 = 440")
_approx(CG.speed_for(1), 340.0, 0.0001, "палочка 2 = 340")
_approx(CG.speed_for(2), 280.0, 0.0001, "палочка 3 = 280")
_approx(CG.speed_for(3), 440.0, 0.0001, "индекс зацикливается")
func _test_type_selection() -> void:
_set_case("type")
_eq(ECONOMY.minigame_type([]), "text", "пусто → text")
_eq(ECONOMY.minigame_type(["text"]), "text", "text → text")
_eq(ECONOMY.minigame_type(["text", "sound"]), "sound", "среднее 1.5 → sound")
_eq(ECONOMY.minigame_type(["text", "photo"]), "sound", "среднее 2.0 → sound")
_eq(ECONOMY.minigame_type(["text", "video"]), "photo", "среднее 2.5 → photo")
_eq(ECONOMY.minigame_type(["photo", "video"]), "video", "среднее 3.5 → video")
_eq(ECONOMY.minigame_type(["video"]), "video", "video → video")
_eq(MG.select_type(["sound", "photo"]), "photo", "MiniGame.select_type повторяет правило")
_approx(ECONOMY.minigame_difficulty(["text", "video"]), 2.5, 0.0001, "difficulty = mean")
func _test_minigame_stub() -> void:
_set_case("minigame")
var mg = MG.new()
_ok(mg.process_mode == Node.PROCESS_MODE_ALWAYS, "PROCESS_MODE_ALWAYS")
_ok(mg.mouse_filter == Control.MOUSE_FILTER_STOP, "mouse_filter STOP")
_eq(mg.custom_minimum_size, Vector2(640, 360), "окно 640×360")
_ok(not mg.visible, "до start окно скрыто")
_ok(not mg.is_finished(), "до start не завершена")
mg.start("dots", 2, 12345, {"request_id": "req_1"})
_eq(mg.game_id, "dots", "game_id из известного id")
_eq(mg.tier, 2, "tier из difficulty")
_eq(mg.seed_value, 12345, "seed сохранён")
_eq(mg.rng.seed, 12345, "локальный RNG засеян")
_ok(mg.running, "running = true")
_ok(mg.visible, "окно видимо")
_eq(str(mg.context.get("request_id", "")), "req_1", "context сохранён")
_approx(mg.quality(), 0.0, 0.0001, "quality стартует с 0")
# Два одинаковых сида дают одинаковую последовательность локального RNG.
var a = MG.new()
var b = MG.new()
a.start("sound", 1, 777)
b.start("sound", 1, 777)
for i in range(5):
_approx(a.rng.randf(), b.rng.randf(), 0.0, "RNG сида совпадает #%d" % i)
var c = MG.new()
c.start("sound", 1, 778)
var differs := false
for i in range(5):
if not is_equal_approx(a.rng.randf(), c.rng.randf()):
differs = true
_ok(differs, "разные сиды дают разные значения")
# Старый вызов start(type, difficulty) продолжает парситься/работать.
var legacy = MG.new()
legacy.start("photo", 2.5)
_eq(legacy.game_id, "photo", "legacy type → game_id")
_ok(legacy.tier >= 2 and legacy.tier <= 3, "legacy difficulty → tier")
a.free()
b.free()
c.free()
legacy.free()
mg.free()
func _test_base_finish() -> void:
_set_case("base_finish")
var mg = MG.new()
_finish_count = 0
_last_q = -1.0
mg.finished.connect(_on_finished)
mg.start("dots", 1, 5)
mg._finish(1.7)
_eq(_finish_count, 1, "finished эмитится один раз")
_approx(_last_q, 1.0, 0.0001, "q клампится сверху в 1.0")
_approx(mg.quality(), 1.0, 0.0001, "quality() = 1.0")
_ok(mg.is_finished(), "is_finished = true")
_ok(not mg.running, "running = false")
_ok(mg.result_text().contains("100%"), "result_text содержит q%")
_ok(mg.result_text().contains("ЧИСТО"), "result_text содержит вердикт")
mg._finish(0.0)
_eq(_finish_count, 1, "повторный _finish не эмитит")
_approx(mg.quality(), 1.0, 0.0001, "повторный _finish не меняет q")
var neg = MG.new()
neg.finished.connect(_on_finished)
_finish_count = 0
_last_q = -1.0
neg.start("maze", 4, 9)
neg._finish(-0.5)
_approx(_last_q, 0.0, 0.0001, "q клампится снизу в 0.0")
neg.free()
mg.free()
func _test_base_skip_and_esc() -> void:
_set_case("base_skip")
var mg = MG.new()
_finish_count = 0
_last_q = -1.0
mg.finished.connect(_on_finished)
mg.start("wires", 1, 3)
mg._on_skip_clicked()
_ok(mg._skip_armed, "первый клик только взводит пропуск")
_ok(not mg.is_finished(), "первый клик не завершает")
mg._on_skip_clicked()
_eq(_finish_count, 1, "второй клик — finished")
_approx(_last_q, 0.0, 0.0001, "пропуск даёт q=0")
var esc_game = MG.new()
esc_game.start("text", 2, 4)
_ok(mg.process_mode == Node.PROCESS_MODE_ALWAYS, "MiniGame PROCESS_MODE_ALWAYS")
mg.start("photo", 2.5)
_eq(mg.game_type, "photo", "тип сохранён")
_approx(mg.difficulty, 2.5, 0.0001, "сложность сохранена")
_approx(mg.quality(), 1.0, 0.0001, "quality-заглушка = 1.0")
_approx(mg.progress, 0.0, 0.0001, "полоса стартует с 0")
mg.progress = 1.5
mg._restart_bar()
_approx(mg.progress, 0.0, 0.0001, "«Переделать» перезапускает полосу")
_approx(mg.quality(), 1.0, 0.0001, "«Переделать» не меняет quality")
_eq(MG.type_label("video"), "ВИДЕО", "заголовок ВИДЕО")
_eq(MG.type_label("sound"), "ЗВУК", "заголовок ЗВУК")
var esc := InputEventKey.new()
esc.pressed = true
esc.keycode = KEY_ESCAPE
esc_game._input(esc)
_ok(not esc_game.is_finished(), "Esc в игре отправки — no-op")
esc_game.free()
mg.free()
mg._input(esc)
_ok(mg.running, "Esc в мини-игре — no-op")
mg.finished.connect(_on_minigame_finished)
mg._send()
_approx(_minigame_quality, 1.0, 0.0001, "finished(1.0)")
func _test_verdicts() -> void:
_set_case("verdicts")
_eq(MG.verdict(1.0), "ЧИСТО", "1.0 → ЧИСТО")
_eq(MG.verdict(0.75), "ЧИСТО", "0.75 → ЧИСТО")
_eq(MG.verdict(0.74), "НОРМАЛЬНО", "0.74 → НОРМАЛЬНО")
_eq(MG.verdict(0.45), "НОРМАЛЬНО", "0.45 → НОРМАЛЬНО")
_eq(MG.verdict(0.44), "СЛАБО", "0.44 → СЛАБО")
_eq(MG.verdict(0.0), "СЛАБО", "0.0 → СЛАБО")
func _test_connection_node() -> void:
_set_case("connection")
var cg = CG.new()
_ok(cg.process_mode == Node.PROCESS_MODE_ALWAYS, "ConnectionGame PROCESS_MODE_ALWAYS")
cg.finished.connect(_on_connection_finished)
cg.start(3, 2, 1, "req_test")
_approx(cg.zone_ratio, 0.33, 0.0001, "зона из формулы для hour 3/succ 2/zone 1")
_approx(cg.zone_width_px(), 0.33 * 480.0, 0.0001, "ширина зоны в px")
_eq(cg.request_id, "req_test", "request_id сохранён")
_ok(cg.running, "игра запущена")
_ok(cg.visible, "окно видимо")
# Три попадания строго по очереди.
_connection_result = -1
for i in range(3):
var x: float = cg.zone_start_x() + 1.0
cg.stick_positions[i] = x
cg._attempt_click()
_ok(_connection_result == 1, "три попадания → finished(true)")
# Esc — отмена.
_connection_result = -1
var cg2 = CG.new()
cg2.finished.connect(_on_connection_finished)
cg2.start(1, 0, 0, "req_cancel")
var esc := InputEventKey.new()
esc.pressed = true
esc.keycode = KEY_ESCAPE
cg2._input(esc)
_eq(_connection_result, 0, "Esc → finished(false)")
func _test_factory_send() -> void:
_set_case("factory_send")
_eq(FACTORY.send_ids().size(), 7, "зарегистрировано 7 игр")
for id in FACTORY.send_ids():
var game = FACTORY.create_send(str(id), 1, 123)
_ok(game != null, "create_send(%s) не null" % str(id))
if game == null:
continue
_ok(game is MiniGame, "create_send(%s) — MiniGame" % str(id))
_eq(game.game_id, str(id), "game_id %s" % str(id))
_eq(game.tier, 1, "tier T1")
_eq(game.seed_value, 123, "seed проброшен")
_ok(game.visible, "игра показана после start")
game.free()
for id in ["dots", "sound", "wires", "text", "video", "photo", "maze"]:
var hard = FACTORY.create_send(id, 4, 99)
_ok(hard != null, "T4 create_send(%s)" % id)
if hard != null:
_eq(hard.tier, 4, "T4 tier у %s" % id)
hard.free()
_ok(FACTORY.create_send("nope", 1, 1) == null, "неизвестная игра → null")
_ok(FACTORY.is_send_id("maze"), "is_send_id maze")
_ok(not FACTORY.is_send_id("junk"), "is_send_id junk = false")
_eq(FACTORY.tier_for_hour(1), 1, "tier_for_hour 1 → 1")
_eq(FACTORY.tier_for_hour(2), 1, "tier_for_hour 2 → 1")
_eq(FACTORY.tier_for_hour(4), 2, "tier_for_hour 4 → 2")
_eq(FACTORY.tier_for_hour(6), 3, "tier_for_hour 6 → 3")
_eq(FACTORY.tier_for_hour(8), 4, "tier_for_hour 8 → 4")
_eq(FACTORY.tier_for_hour(9), 4, "tier_for_hour 9 → 4")
func _test_selection() -> void:
_set_case("selection")
var one := FACTORY.send_for_types(["text"])
_eq(str(one.get("id", "")), "dots", "S=1 → dots")
_eq(int(one.get("tier", 0)), 1, "S=1 → T1")
_eq(int(one.get("sum", 0)), 1, "sum=1")
var video := FACTORY.send_for_types(["video"])
_eq(str(video.get("id", "")), "dots", "S=8 → dots")
_eq(int(video.get("tier", 0)), 2, "S=8 → T2")
var three_video := FACTORY.send_for_types(["video", "video", "video"])
_ok(FACTORY.send_ids().has(str(three_video.get("id", ""))), "S=24 → известная игра")
_ok(int(three_video.get("tier", 0)) >= 1 and int(three_video.get("tier", 0)) <= 4,
"S=24 → допустимая ступень")
_eq(int(three_video.get("sum", 0)), 24, "sum=24")
# Каноническая формула спецификации (idx = S-1) отдельно от Economy-делегата.
var canonical := FACTORY.selection_for_sum(24)
_eq(str(canonical.get("id", "")), "wires", "канон S=24 → wires")
_eq(int(canonical.get("tier", 0)), 4, "канон S=24 → T4")
_eq(str(FACTORY.selection_for_sum(8).get("id", "")), "dots", "канон S=8 → dots/T2")
_eq(int(FACTORY.selection_for_sum(8).get("tier", 0)), 2, "канон S=8 → T2")
var mix := FACTORY.send_for_types(["text", "sound"])
_eq(str(mix.get("id", "")), "wires", "S=3 → wires")
_eq(int(mix.get("tier", 0)), 1, "S=3 → T1")
var photo := FACTORY.send_for_types(["photo"])
_eq(str(photo.get("id", "")), "text", "S=4 → text")
var seven := FACTORY.send_for_types(["text", "sound", "photo"])
_ok(FACTORY.send_ids().has(str(seven.get("id", ""))), "S=7 → известная игра")
_eq(int(seven.get("sum", 0)), 7, "S=7 sum")
_eq(str(FACTORY.selection_for_sum(7).get("id", "")), "maze", "канон S=7 → maze")
_eq(int(FACTORY.selection_for_sum(7).get("tier", 0)), 1, "канон S=7 → T1")
var empty := FACTORY.selection_for_sum(0)
_eq(str(empty.get("id", "")), "dots", "S=0 клампится в dots/T1")
_eq(int(empty.get("tier", 0)), 1, "S=0 → T1")
var cap := FACTORY.selection_for_sum(99)
_eq(int(cap.get("index", -1)), 27, "idx клампится в 27")
_eq(int(cap.get("tier", 0)), 4, "idx 27 → T4")
func _test_attack_placeholder() -> void:
_set_case("attack_placeholder")
var attack = FACTORY.create_attack("junk", 1, 7)
if attack == null:
_ok(true, "атакующий скрипт ещё не добавлен — null допустим")
else:
_ok(attack is MiniGame, "существующая атака — MiniGame")
attack.free()
var missing = FACTORY.create_attack("not_a_real_attack", 1, 7)
_ok(missing == null, "неизвестная атака → null")
func _test_no_timers() -> void:
_set_case("no_timers")
for id in FACTORY.send_ids():
var game = FACTORY.create_send(str(id), 2, 55)
if game == null:
_fail("нет игры %s" % str(id))
continue
_eq(_count_timers(game), 0, "нет Timer-узлов в %s" % str(id))
# _process не должен падать и не должен завершать игру сам.
game._process(0.1)
_ok(not game.is_finished(), "один кадр не завершает %s" % str(id))
game.free()
func _count_timers(node: Node) -> int:
var count := 0
for child in node.get_children():
if child is Timer:
count += 1
count += _count_timers(child)
return count
func _test_connection_miss_closes() -> void:
_set_case("connection_miss")
# п.9: промах → вспышка 0.8 с → finished(false), без автоповтора.
var cg = CG.new()
cg.finished.connect(_on_connection_finished)
_connection_result = -1
cg.start(1, 0, 0, "req_miss")
cg.stick_positions[0] = CG.BAR_RECT.position.x + CG.BAR_RECT.size.x - 1.0
cg._attempt_click()
_ok(cg.missed, "промах зафиксирован")
_ok(cg.flash_left > 0.0, "идёт красная вспышка")
_eq(_connection_result, -1, "во время вспышки finished ещё не эмитится")
_eq(cg.active_index, 0, "попадание не засчитано")
for i in range(10):
cg._process(0.1)
_eq(_connection_result, 0, "после 0.8 с finished(false)")
_ok(cg.game_over, "игра закрыта, автоповтора нет")
_ok(not cg.running, "running = false")
# Уход палочки за край — тот же провал.
var cg2 = CG.new()
cg2.finished.connect(_on_connection_finished)
_connection_result = -1
cg2.start(1, 0, 0, "req_edge")
cg2.stick_positions[0] = CG.BAR_RECT.position.x + CG.BAR_RECT.size.x
cg2._process(0.1)
_ok(cg2.flash_left > 0.0 or cg2.game_over, "уход за край запускает провал")
if cg2.flash_left > 0.0:
for i in range(10):
cg2._process(0.1)
_eq(_connection_result, 0, "уход за край → finished(false)")
# --- assert-хелперы --------------------------------------------------------
@@ -257,7 +156,7 @@ func _ok(condition: bool, message: String) -> bool:
_checks += 1
if condition:
return true
_fail(message + " — ожидалось true")
_fail(message)
return false
@@ -271,10 +170,9 @@ func _eq(actual, expected, message: String) -> bool:
func _approx(actual: float, expected: float, eps: float, message: String) -> bool:
_checks += 1
if absf(float(actual) - float(expected)) <= eps:
if absf(actual - expected) <= eps:
return true
_fail("%s — получено %.6f, ожидалось %.6f" % [message, float(actual),
float(expected)])
_fail("%s — получено %.6f, ожидалось %.6f" % [message, actual, expected])
return false
@@ -283,9 +181,12 @@ func _fail(message: String) -> void:
print("FAIL [%s] %s" % [_case, message])
func _on_finished(q: float) -> void:
_finish_count += 1
_last_q = q
func _on_minigame_finished(quality: float) -> void:
_minigame_quality = quality
func _on_connection_finished(success: bool, _request_id: String) -> void:
_connection_result = 1 if success else 0
func _finish() -> void:
+327 -654
View File
File diff suppressed because it is too large. Load diff
+50 -149
View File
@@ -1,56 +1,19 @@
extends SceneTree
## Тесты SaveManager v2: v2 roundtrip, приём v1, отклонение v3, атомарность.
##
## Standalone: godot --headless --path . --script tests/test_save.gd
## Harness: tests/run_tests.gd вызывает run(self).
extends RefCounted
## Тесты SaveManager: roundtrip, битый JSON, несовпадение версии, атомарность.
const S := preload("res://scripts/systems/save_system.gd")
const R := preload("res://scripts/core/file_registry.gd")
const G := preload("res://scripts/autoload/game.gd")
var _checks: int = 0
var _fails: int = 0
var _case: String = ""
var _had_save: bool = false
var _backup: String = ""
func _init() -> void:
if Engine.get_main_loop() != null:
return
await process_frame
run(self)
print("test_save: проверок %d, провалов %d" % [_checks, _fails])
print("PASS test_save" if _fails == 0 else "FAIL test_save")
quit(0 if _fails == 0 else 1)
func run(t) -> void:
_backup_save()
_test_version(t)
_test_roundtrip(t)
_test_corrupt(t)
_test_versions(t)
_test_registry_v2_roundtrip(t)
_test_delete(t)
_restore_save()
# --- версия -----------------------------------------------------------------
func _test_version(t) -> void:
t.set_case("version")
t.eq(int(S.VERSION), 2, "SaveManager.VERSION = 2")
t.ok(S.SUPPORTED_VERSIONS.has(1), "v1 поддерживается для миграции")
t.ok(S.SUPPORTED_VERSIONS.has(2), "v2 поддерживается")
# --- базовый roundtrip ------------------------------------------------------
func _test_roundtrip(t) -> void:
t.set_case("roundtrip")
t.ok(S.save({
"money": 321,
"sla": 60.0,
"sla": 198.5,
"name": "Смена",
"nested": {"a": [1, 2, 3], "b": true},
"upgrades": {"shop_channel": 2},
@@ -59,10 +22,10 @@ func _test_roundtrip(t) -> void:
t.ok(not FileAccess.file_exists(S.TMP_PATH), "временный файл убран")
var got: Dictionary = S.load()
t.eq(int(got.get("version", 0)), 2, "version = 2")
t.eq(int(got.get("version", 0)), S.VERSION, "version")
t.ok(got.has("timestamp"), "timestamp")
t.eq(int(got.get("money", 0)), 321, "money")
t.approx(float(got.get("sla", 0.0)), 60.0, 0.0001, "sla")
t.approx(float(got.get("sla", 0.0)), 198.5, 0.0001, "sla")
t.eq(str(got.get("name", "")), "Смена", "кириллица")
var nested = got.get("nested", {})
t.ok(nested is Dictionary, "вложенный словарь")
@@ -72,11 +35,11 @@ func _test_roundtrip(t) -> void:
if arr is Array:
t.eq(int(arr[1]), 2, "элемент массива")
t.eq(bool(nested.get("b", false)), true, "bool")
var upgrades = got.get("upgrades", {})
t.ok(upgrades is Dictionary, "upgrades")
if upgrades is Dictionary:
t.eq(int(upgrades.get("shop_channel", 0)), 2, "уровень апгрейда")
# --- битый json -------------------------------------------------------------
func _test_corrupt(t) -> void:
t.set_case("corrupt")
_write("{ это не json")
var corrupt: Dictionary = S.load()
@@ -85,87 +48,24 @@ func _test_corrupt(t) -> void:
t.ok(S.save({"money": 5}), "save поверх битого файла")
t.eq(int(S.load().get("money", 0)), 5, "данные восстановлены")
# --- версии -----------------------------------------------------------------
func _test_versions(t) -> void:
t.set_case("versions")
_write(JSON.stringify({"version": 1, "money": 77, "registry": {}}))
var v1: Dictionary = S.load()
t.eq(int(v1.get("version", 0)), 1, "v1 принимается")
t.eq(int(v1.get("money", 0)), 77, "данные v1 доступны")
_write(JSON.stringify({"version": 3, "money": 1}))
t.eq(S.load().size(), 0, "v3 отклоняется")
_write(JSON.stringify({"version": 0, "money": 1}))
t.eq(S.load().size(), 0, "v0 отклоняется")
t.set_case("version")
_write(JSON.stringify({"version": 999, "money": 1}))
t.eq(S.load().size(), 0, "version mismatch → {}")
_write(JSON.stringify({"money": 1}))
t.eq(S.load().size(), 0, "нет version → {}")
_write("[]")
t.eq(S.load().size(), 0, "JSON-массив → {}")
_write("null")
t.eq(S.load().size(), 0, "JSON null → {}")
# --- v2: реестр целиком -----------------------------------------------------
func _test_registry_v2_roundtrip(t) -> void:
t.set_case("registry_v2")
var reg = R.new()
reg.setup_new_run()
var section = reg.sections.get("C", null)
var folder_id := ""
if section != null and not section.folder_ids.is_empty():
folder_id = str(section.folder_ids[0])
t.ok(not folder_id.is_empty(), "стартовая папка есть")
var text = reg.create_file("Сейв-текст", "text", folder_id)
t.ok(text != null, "text создан")
var video = reg.create_file("Сейв-видео", "video", folder_id)
t.ok(video != null, "video создан")
# Зафиксируем точную позицию/поворот для проверки roundtrip.
if video != null:
video.shape_id = "I4V"
t.ok(reg.move_file(video.id, folder_id, Vector2i(1, 0), 0), "видео поставлено вручную")
if text != null:
t.ok(reg.move_file(text.id, folder_id, Vector2i(0, 0), 0), "текст поставлен вручную")
var table_file = reg.create_file("Стол", "text", "", &"table")
t.ok(table_file != null, "файл на столе создан")
t.eq(reg.validate().size(), 0, "реестр валиден до save")
var payload := {
"version": S.VERSION,
"money": 100,
"registry": reg.to_dict(),
}
t.ok(S.save(payload), "save payload v2")
var loaded: Dictionary = S.load()
t.eq(int(loaded.get("version", 0)), 2, "загружена версия 2")
var raw_registry = loaded.get("registry", {})
t.ok(raw_registry is Dictionary, "registry в save")
if raw_registry is Dictionary:
var reg2 = R.new()
t.ok(reg2.from_dict(raw_registry), "from_dict сохранённого registry")
t.eq(reg2.files.size(), reg.files.size(), "файлы восстановлены")
if video != null:
t.eq(reg2.file_origin(video.id), Vector2i(1, 0), "origin видео сохранён")
t.eq(str(reg2.get_file(video.id).shape_id), "I4V", "shape_id видео сохранён")
if table_file != null:
t.eq(str(reg2.get_file(table_file.id).location), "table", "location table сохранён")
t.eq(reg2.validate().size(), 0, "registry после load валиден: %s" % str(reg2.validate()))
# --- delete -----------------------------------------------------------------
func _test_delete(t) -> void:
t.set_case("delete")
t.ok(S.delete_save(), "delete_save")
t.ok(not S.has_save(), "после delete_save файла нет")
t.ok(not S.delete_save(), "повторный delete_save — false")
_test_game_roundtrip(t)
_restore_save()
# --- служебное --------------------------------------------------------------
# --- служебное -------------------------------------------------------------
func _backup_save() -> void:
_had_save = FileAccess.file_exists(S.SAVE_PATH)
@@ -194,36 +94,37 @@ func _write(text: String) -> void:
file.close()
# --- assert-хелперы ---------------------------------------------------------
func _test_game_roundtrip(t) -> void:
t.set_case("game")
var game = G.new()
game.start_new_run(4242)
t.eq(int(game.money), 150, "стартовые деньги")
t.approx(float(game.sla), 128.0, 0.0001, "стартовый SLA")
t.eq(str(game.phase), "prep", "фаза prep")
t.eq(int(game.rng_seed), 4242, "сид забега")
var folder_id := ""
var section = game.registry.sections.get("C", null)
if section != null and not section.folder_ids.is_empty():
folder_id = str(section.folder_ids[0])
t.ok(not folder_id.is_empty(), "стартовая папка в registry")
t.ok(game.registry.create_file("Сейв-файл", "text", folder_id) != null, "файл в registry")
game.add_money(100)
game.add_sla(5.0)
t.eq(int(game.money), 250, "деньги после add_money")
t.approx(float(game.sla), 133.0, 0.0001, "SLA после add_sla (+5 от 128)")
game.meta_unlocks["survive_hour_3"] = true
game.run_meta_awards.append("survive_hour_3")
func set_case(name: String) -> void:
_case = name
func ok(condition: bool, message: String) -> bool:
_checks += 1
if condition:
return true
fail(message + " — ожидалось true")
return false
func eq(actual, expected, message: String) -> bool:
_checks += 1
if actual == expected:
return true
fail("%s — получено %s, ожидалось %s" % [message, str(actual), str(expected)])
return false
func approx(actual: float, expected: float, eps: float, message: String) -> bool:
_checks += 1
if absf(float(actual) - float(expected)) <= eps:
return true
fail("%s — получено %.6f, ожидалось %.6f" % [message, actual, expected])
return false
func fail(message: String) -> void:
_fails += 1
print("FAIL [%s] %s" % [_case, message])
t.ok(game.save_game(), "Game.save_game")
var game2 = G.new()
t.ok(game2.load_game(), "Game.load_game")
t.eq(int(game2.money), 250, "деньги восстановлены")
t.approx(float(game2.sla), 133.0, 0.0001, "SLA восстановлен")
t.ok(game2.meta_unlocks.has("survive_hour_3"), "мета-открытия восстановлены")
t.ok(game2.run_meta_awards.has("survive_hour_3"), "run_meta_awards восстановлены")
t.eq(str(game2.phase), "prep", "фаза восстановлена")
t.eq(int(game2.rng_seed), 4242, "сид восстановлен")
t.eq(game2.registry.files.size(), game.registry.files.size(), "файлы восстановлены")
t.ok(game2.registry.find_by_name("Сейв-файл") != null, "файл найден после load")
game.free()
game2.free()
-469
View File
@@ -1,469 +0,0 @@
extends SceneTree
## Headless-юнит-тесты чистых функций семи игр отправки.
## Запуск: godot --headless --path . --script tests/test_send_games.gd
const DOTS := preload("res://scripts/minigames/send/dots.gd")
const SOUND := preload("res://scripts/minigames/send/sound.gd")
const WIRES := preload("res://scripts/minigames/send/wires.gd")
const TEXT := preload("res://scripts/minigames/send/text.gd")
const VIDEO := preload("res://scripts/minigames/send/video.gd")
const PHOTO := preload("res://scripts/minigames/send/photo.gd")
const MAZE := preload("res://scripts/minigames/send/maze.gd")
var _checks: int = 0
var _fails: int = 0
var _case: String = ""
func _init() -> void:
await process_frame
_test_dots()
_test_sound()
_test_wires()
_test_text()
_test_video()
_test_photo()
_test_maze()
_finish()
# --- dots ------------------------------------------------------------------
func _test_dots() -> void:
_set_case("dots")
var expected := {
1: {"cols": 3, "rows": 3, "obstacles": 0},
2: {"cols": 4, "rows": 3, "obstacles": 2},
3: {"cols": 4, "rows": 4, "obstacles": 4},
4: {"cols": 5, "rows": 4, "obstacles": 6},
}
for tier in range(1, 5):
var spec: Dictionary = expected[tier]
var p := DOTS.tier_params(tier)
_eq(int(p["cols"]), int(spec["cols"]), "T%d cols" % tier)
_eq(int(p["rows"]), int(spec["rows"]), "T%d rows" % tier)
_eq(int(p["obstacles"]), int(spec["obstacles"]), "T%d obstacles" % tier)
for seed in [1, 7, 42, 99, 1234, 2026]:
var b := DOTS.generate_tier(tier, seed)
var cols := int(b["cols"])
var rows := int(b["rows"])
var obstacles := int(spec["obstacles"])
_eq(int(b["blocked"].size()), obstacles, "T%d seed %d: число препятствий" % [tier, seed])
_eq(int(b["free_count"]), cols * rows - obstacles,
"T%d seed %d: свободных клеток" % [tier, seed])
_eq(int(b["path"].size()), cols * rows - obstacles,
"T%d seed %d: длина пути = свободные клетки" % [tier, seed])
_ok(DOTS.validate_board(b), "T%d seed %d: доска решаема/валидна" % [tier, seed])
_eq(int(b["start"]), int(b["path"][0]), "T%d seed %d: старт = начало пути" % [tier, seed])
_eq(int(b["finish"]), int(b["path"][b["path"].size() - 1]),
"T%d seed %d: финиш = конец пути" % [tier, seed])
var blocked: Array = b["blocked"]
var free: Array = b["free"]
var path: Array = b["path"]
var seen := {}
for i in range(path.size()):
var id := int(path[i])
_ok(not blocked.has(id), "T%d seed %d: путь не по препятствию" % [tier, seed])
_ok(free.has(id), "T%d seed %d: путь по свободной клетке" % [tier, seed])
_ok(not seen.has(id), "T%d seed %d: клетка не повторяется" % [tier, seed])
seen[id] = true
if i > 0:
var prev := int(path[i - 1])
var dx := absi(id % cols - prev % cols)
var dy := absi(id / cols - prev / cols)
_eq(dx + dy, 1, "T%d seed %d: соседние клетки пути" % [tier, seed])
# Свободные + препятствия покрывают всю сетку без пересечений.
var union := {}
for id in free:
union[int(id)] = true
for id in blocked:
union[int(id)] = true
_eq(union.size(), cols * rows, "T%d seed %d: покрытие сетки" % [tier, seed])
_approx(DOTS.quality_for(0, 0, 2), 1.0, 0.0001, "dots q без ошибок = 1")
_approx(DOTS.quality_for(2, 0, 2), 0.92, 0.0001, "dots 2 отката T2 = 0.92")
_approx(DOTS.quality_for(0, 3, 1), 0.7, 0.0001, "dots 3 ошибки = 0.7")
_approx(DOTS.quality_for(50, 50, 4), 0.0, 0.0001, "dots q без нижнего порога 0.3")
# --- sound -----------------------------------------------------------------
func _test_sound() -> void:
_set_case("sound")
var expected_counts := [3, 4, 5, 6]
var expected_tol := [0.16, 0.11, 0.08, 0.06]
for tier in range(1, 5):
for seed in [1, 7, 42, 99, 1234]:
var data := SOUND.generate(tier, seed)
var count := int(data["count"])
_eq(count, expected_counts[tier - 1], "sound T%d count" % tier)
_approx(float(data["tolerance"]), expected_tol[tier - 1], 0.0001,
"sound T%d tol" % tier)
_eq((data["targets"] as Array).size(), count, "sound targets size")
_eq((data["starts"] as Array).size(), count, "sound starts size")
var targets: Array = data["targets"]
var starts: Array = data["starts"]
var tol := float(data["tolerance"])
for i in range(count):
_ok(float(targets[i]) >= tol - 0.001 and float(targets[i]) <= 1.0 - tol + 0.001,
"sound T%d цель внутри дорожки" % tier)
_ok(absf(float(starts[i]) - float(targets[i])) > tol,
"sound T%d старт вне допуска" % tier)
for i in range(count):
for j in range(i + 1, count):
_ok(absf(float(targets[i]) - float(targets[j])) >= 2.0 * tol - 0.001,
"sound T%d цели не сливаются" % tier)
var drift := SOUND.generate(tier, 5)
if tier <= 2:
_approx(float(drift["drift_amp"]), 0.0, 0.0001, "sound T%d без дрейфа" % tier)
elif tier == 3:
_approx(float(drift["drift_amp"]), 0.02, 0.0001, "sound T3 дрейф 2%")
_approx(float(drift["drift_hz"]), 0.15, 0.0001, "sound T3 дрейф 0.15 Гц")
else:
_approx(float(drift["drift_amp"]), 0.03, 0.0001, "sound T4 дрейф 3%")
_approx(float(drift["drift_hz"]), 0.25, 0.0001, "sound T4 дрейф 0.25 Гц")
_approx(SOUND.channel_quality(0.5, 0.5, 0.16), 1.0, 0.0001, "sound центр = 1")
_approx(SOUND.channel_quality(0.66, 0.5, 0.16), 0.5, 0.0001, "sound край = 0.5")
_approx(SOUND.channel_quality(0.34, 0.5, 0.16), 0.5, 0.0001, "sound край снизу = 0.5")
_approx(SOUND.channel_quality(0.82, 0.5, 0.16), 0.0, 0.0001, "sound 2*tol = 0")
_approx(SOUND.quality_from([0.5, 0.34], [0.5, 0.5], 0.16), 0.75, 0.0001,
"sound среднее 0.75")
_approx(SOUND.target_at(0.5, 0.0, 0.0, 0.0, 3.0), 0.5, 0.0001,
"sound без дрейфа цель статична")
_ok(SOUND.target_at(0.5, 0.03, 0.25, 0.0, 1.0) != 0.5,
"sound с дрейфом цель смещается")
# --- wires -----------------------------------------------------------------
func _test_wires() -> void:
_set_case("wires")
var expected := {
1: {"wires": 3, "sockets": 3},
2: {"wires": 4, "sockets": 4},
3: {"wires": 5, "sockets": 6},
4: {"wires": 6, "sockets": 6},
}
for tier in range(1, 5):
var geo := WIRES.geometry(tier)
_approx(float(geo["box"]), 34.0, 0.0001, "wires T%d box 34" % tier)
_approx(float(geo["pitch"]), 40.0, 0.0001, "wires T%d pitch 40" % tier)
_ok(float(geo["gap"]) >= 300.0 - 0.001, "wires T%d зазор >= 300" % tier)
_eq((geo["left_rects"] as Array).size(), int(expected[tier]["wires"]),
"wires T%d число левых портов" % tier)
_eq((geo["right_rects"] as Array).size(), int(expected[tier]["sockets"]),
"wires T%d число правых портов" % tier)
var left_rack: Rect2 = geo["left_rack"]
var right_rack: Rect2 = geo["right_rack"]
_ok(left_rack.end.y <= 320.0, "wires T%d левая стойка <= 320" % tier)
_ok(right_rack.end.y <= 320.0, "wires T%d правая стойка <= 320" % tier)
for side in ["left_rects", "right_rects"]:
var rects: Array = geo[side]
var rack := left_rack if side == "left_rects" else right_rack
for i in range(rects.size()):
var a: Rect2 = rects[i]
_ok(a.position.x >= rack.position.x and a.position.y >= rack.position.y
and a.end.x <= rack.end.x + 0.001 and a.end.y <= rack.end.y + 0.001,
"wires T%d порт внутри стойки" % tier)
for j in range(i + 1, rects.size()):
_ok(not a.intersects(rects[j]), "wires T%d порты не пересекаются" % tier)
var lanes: Array = geo["lanes"]
for i in range(1, lanes.size()):
_ok(absf(float(lanes[i]) - float(lanes[i - 1])) >= 8.0 - 0.001,
"wires T%d полосы >= 8 px" % tier)
for seed in [1, 7, 42, 99, 1234, 2026]:
var data := WIRES.generate(tier, seed)
_eq((data["wires"] as Array).size(), int(expected[tier]["wires"]),
"wires T%d wires" % tier)
_eq((data["sockets"] as Array).size(), int(expected[tier]["sockets"]),
"wires T%d sockets" % tier)
_ok(WIRES.validate_pairs(data), "wires T%d seed %d пары уникальны/матчатся" % [tier, seed])
var sockets: Array = data["sockets"]
var empty_count := 0
for socket in sockets:
if bool(socket.get("empty", false)):
empty_count += 1
if tier == 3:
_eq(empty_count, 1, "wires T3 одно пустое гнездо")
else:
_eq(empty_count, 0, "wires T%d без пустых" % tier)
if tier >= 4:
var color_counts := {}
var shape_counts := {}
for wire in data["wires"]:
var c := str(wire["color"])
var s := str(wire["shape"])
color_counts[c] = int(color_counts.get(c, 0)) + 1
shape_counts[s] = int(shape_counts.get(s, 0)) + 1
var shared_colors := 0
var shared_shapes := 0
for k in color_counts.keys():
if int(color_counts[k]) >= 2:
shared_colors += 1
for k in shape_counts.keys():
if int(shape_counts[k]) >= 2:
shared_shapes += 1
_ok(shared_colors >= 2, "wires T4 два общих цвета")
_ok(shared_shapes >= 2, "wires T4 две общие формы")
_approx(WIRES.quality_for(3, 0, 3), 1.0, 0.0001, "wires все подключены = 1")
_approx(WIRES.quality_for(2, 1, 4), 0.4375, 0.0001, "wires 2/4 и 1 ошибка = 0.4375")
_approx(WIRES.quality_for(0, 20, 6), 0.0, 0.0001, "wires q клампится в 0")
# --- text ------------------------------------------------------------------
func _test_text() -> void:
_set_case("text")
var expected := {
1: {"words": 11, "aliens": 1, "lines": 2},
2: {"words": 15, "aliens": 2, "lines": 2},
3: {"words": 19, "aliens": 3, "lines": 3},
4: {"words": 24, "aliens": 4, "lines": 3},
}
for tier in range(1, 5):
for seed in [1, 7, 42, 99, 1234, 2026]:
var data := TEXT.generate(tier, seed)
_eq((data["words"] as Array).size(), int(expected[tier]["words"]),
"text T%d число слов" % tier)
_eq(int(data["aliens"]), int(expected[tier]["aliens"]), "text T%d чужих" % tier)
_eq((data["rows"] as Array).size(), int(expected[tier]["lines"]),
"text T%d строк" % tier)
var row_sum := 0
for r in data["rows"]:
row_sum += int(r)
_eq(row_sum, int(expected[tier]["words"]), "text T%d сумма по строкам" % tier)
_ok(TEXT.validate_generation(data, tier),
"text T%d seed %d: уникальность и однозначность" % [tier, seed])
var texts := {}
var all_rows_used := {}
for word in data["words"]:
texts[str(word["text"]).to_lower()] = true
all_rows_used[int(word["row"])] = true
_eq(texts.size(), int(expected[tier]["words"]), "text T%d нет дублей" % tier)
_eq(all_rows_used.size(), int(expected[tier]["lines"]),
"text T%d все строки заполнены" % tier)
_approx(TEXT.quality_for(1, 0, 1), 1.0, 0.0001, "text 1 найден = 1")
_approx(TEXT.quality_for(1, 1, 1), 0.0, 0.0001, "text ложное = 0")
_approx(TEXT.quality_for(2, 1, 4), 0.25, 0.0001, "text 2-1 из 4 = 0.25")
_approx(TEXT.quality_for(0, 5, 1), 0.0, 0.0001, "text q не уходит ниже 0")
# --- video -----------------------------------------------------------------
func _test_video() -> void:
_set_case("video")
var expected := {
1: {"cards": 3, "slots": 3},
2: {"cards": 4, "slots": 4},
3: {"cards": 5, "slots": 4},
4: {"cards": 6, "slots": 5},
}
var seen_timecodes := {}
for tier in range(1, 5):
for seed in [1, 7, 42, 99, 1234, 2026]:
var data := VIDEO.generate(tier, seed)
_eq((data["cards"] as Array).size(), int(expected[tier]["cards"]),
"video T%d карточек" % tier)
_eq(int(data["slots"]), int(expected[tier]["slots"]), "video T%d слотов" % tier)
var decoys := 0
var mirrored := 0
var real_slots := {}
for card in data["cards"]:
if bool(card.get("decoy", false)):
decoys += 1
_eq(int(card.get("correct_slot", 0)), -1, "video брак без правильного слота")
else:
real_slots[int(card.get("correct_slot", -1))] = true
if bool(card.get("mirrored", false)):
mirrored += 1
for i in range(int(expected[tier]["slots"])):
_ok(real_slots.has(i), "video T%d слот %d имеет реальный кадр" % [tier, i])
_eq(real_slots.size(), int(expected[tier]["slots"]), "video T%d уникальные верные слоты" % tier)
if tier <= 2:
_eq(decoys, 0, "video T%d без брака" % tier)
_eq(mirrored, 0, "video T%d без зеркала" % tier)
else:
_eq(decoys, 1, "video T%d ровно 1 брак" % tier)
var info := VIDEO.decoy_info(data)
_eq(int(info["duplicate_count"]), 2, "video T%d таймкод брака дублируется ровно дважды" % tier)
_ok(int(info["duplicate_real"]) >= 0, "video T%d брак дублирует реальный кадр" % tier)
var real_shape := ""
for card in data["cards"]:
if int(card.get("id", -1)) == int(info["duplicate_real"]):
real_shape = str(card.get("shape", ""))
_ok(real_shape != str(info["decoy_shape"]), "video T%d силуэт брака отличается" % tier)
if tier == 4:
_eq(mirrored, 1, "video T4 ровно 1 зеркальный кадр")
else:
_eq(mirrored, 0, "video T%d без зеркала" % tier)
var tc_key := "|".join(data.get("slot_timecodes", []))
seen_timecodes["%d:%d:%s" % [tier, seed, tc_key]] = true
# Таймкоды зависят от раздачи, а не жёстко от индекса.
_ok(seen_timecodes.size() >= 8, "video таймкоды меняются от сида")
_approx(VIDEO.quality_from(4, 4, 0, 0), 1.0, 0.0001, "video всё верно = 1")
_approx(VIDEO.quality_from(4, 3, 1, 0), 0.625, 0.0001, "video 3 верно, 1 ошибка = 0.625")
_approx(VIDEO.quality_from(4, 3, 0, 1), 0.5, 0.0001, "video брак в слоте = 0.5")
_approx(VIDEO.quality_from(4, 0, 4, 4), 0.0, 0.0001, "video q не ниже 0")
# --- photo -----------------------------------------------------------------
func _test_photo() -> void:
_set_case("photo")
var speeds := [90.0, 140.0, 200.0, 260.0]
var frame_w := [220.0, 180.0, 150.0, 120.0]
var frame_h := [150.0, 120.0, 100.0, 80.0]
var shots := [3, 4, 5, 6]
for tier in range(1, 5):
var p := PHOTO.tier_params(tier)
_approx(float(p["speed"]), speeds[tier - 1], 0.0001, "photo T%d speed" % tier)
_approx(float(p["frame_w"]), frame_w[tier - 1], 0.0001, "photo T%d frame_w" % tier)
_approx(float(p["frame_h"]), frame_h[tier - 1], 0.0001, "photo T%d frame_h" % tier)
_eq(int(p["shots"]), shots[tier - 1], "photo T%d shots" % tier)
for seed in [1, 7, 42, 99, 1234]:
var a := PHOTO.simulate(seed, tier, 1.0, 30)
var b := PHOTO.simulate(seed, tier, 1.0, 144)
_ok(int(a["steps"]) > 0, "photo T%d шаги есть" % tier)
_eq(int(a["steps"]), int(b["steps"]), "photo T%d 30/144 fps: одинаковое число шагов" % tier)
var pa: Array = a["positions"]
var pb: Array = b["positions"]
_eq(pa.size(), pb.size(), "photo T%d 30/144 fps: одинаковое число позиций" % tier)
var max_diff := 0.0
for i in range(mini(pa.size(), pb.size())):
max_diff = maxf(max_diff, (pa[i] as Vector2).distance_to(pb[i] as Vector2))
_ok(max_diff <= 0.0001, "photo T%d 30/144 fps: траектории совпадают (%.6f)" % [tier, max_diff])
var first := PHOTO.simulate(1, tier, 0.8, 60)
var second := PHOTO.simulate(2, tier, 0.8, 60)
_ok((first["final"] as Vector2).distance_to(second["final"] as Vector2) > 1.0,
"photo T%d разные сиды → разные траектории" % tier)
_ok(frame_w[tier - 1] <= 220.0 and frame_h[tier - 1] <= 150.0,
"photo T%d рамка не тривиальная" % tier)
_ok(PHOTO.capture_success(Vector2(150, 150), Vector2(100, 100), Vector2(220, 150)),
"photo бюст целиком в рамке → успех")
_ok(not PHOTO.capture_success(Vector2(100, 150), Vector2(100, 100), Vector2(220, 150)),
"photo бюст частично вне рамки → провал")
_approx(PHOTO.quality_for(2, 4), 0.5, 0.0001, "photo 2 из 4 = 0.5")
_approx(PHOTO.quality_for(4, 4), 1.0, 0.0001, "photo 4 из 4 = 1")
_approx(PHOTO.quality_for(0, 3), 0.0, 0.0001, "photo 0 из 3 = 0")
_ok(float(PHOTO.tier_params(3)["frame_w"]) < float(PHOTO.tier_params(1)["frame_w"]),
"photo T3 рамка меньше T1")
# --- maze ------------------------------------------------------------------
func _test_maze() -> void:
_set_case("maze")
var expected := {
1: {"cols": 9, "rows": 4, "field_w": 360.0, "field_h": 160.0, "corridor": 28.0, "speed": 420.0},
2: {"cols": 11, "rows": 4, "field_w": 440.0, "field_h": 160.0, "corridor": 24.0, "speed": 470.0},
3: {"cols": 13, "rows": 4, "field_w": 494.0, "field_h": 152.0, "corridor": 20.0, "speed": 520.0},
4: {"cols": 15, "rows": 5, "field_w": 520.0, "field_h": 180.0, "corridor": 18.0, "speed": 560.0},
}
for tier in range(1, 5):
var p := MAZE.tier_params(tier)
var spec: Dictionary = expected[tier]
for key in ["cols", "rows"]:
_eq(int(p[key]), int(spec[key]), "maze T%d %s" % [tier, key])
for key in ["field_w", "field_h", "corridor", "speed"]:
_approx(float(p[key]), float(spec[key]), 0.0001, "maze T%d %s" % [tier, key])
for seed in [1, 7, 42, 99, 1234, 2026]:
var data := MAZE.generate_tier(tier, seed)
var cols := int(data["cols"])
var rows := int(data["rows"])
var ent := int(data["ent_row"])
var ex := int(data["exit_row"])
_ok(bool(data["solvable"]), "maze T%d seed %d решаем" % [tier, seed])
var bfs_path := MAZE.bfs(data["cells"], cols, rows, ent * cols, ex * cols + (cols - 1))
_ok(bfs_path.size() >= 2, "maze T%d seed %d BFS-путь есть" % [tier, seed])
_eq(int(bfs_path[0]), ent * cols, "maze T%d BFS старт" % tier)
_eq(int(bfs_path[bfs_path.size() - 1]), ex * cols + cols - 1, "maze T%d BFS финиш" % tier)
for i in range(1, bfs_path.size()):
var a := int(bfs_path[i - 1])
var b := int(bfs_path[i])
var ax := a % cols
var ay := a / cols
var bx := b % cols
var by := b / cols
_eq(absi(ax - bx) + absi(ay - by), 1, "maze T%d BFS шаг по соседям" % tier)
var nbrs := MAZE.neighbors_open(data["cells"], ax, ay, cols, rows)
_ok(nbrs.has(Vector2i(bx, by)), "maze T%d BFS идёт через открытую стену" % tier)
# Стенки: внутренние рёбра совпадают с cells.
var edges := MAZE.wall_edges(data["cells"], cols, rows, ent, ex)
for by in range(rows):
for bx in range(1, cols):
var closed := bool(edges["v:%d:%d" % [bx, by]])
_eq(closed, bool(data["cells"][by][bx - 1]["e"]),
"maze T%d вертикальная стена %d:%d" % [tier, bx, by])
for by in range(1, rows):
for bx in range(cols):
var closed := bool(edges["h:%d:%d" % [bx, by]])
_eq(closed, bool(data["cells"][by - 1][bx]["s"]),
"maze T%d горизонтальная стена %d:%d" % [tier, bx, by])
var field := Rect2(40.0, 80.0, float(spec["field_w"]), float(spec["field_h"]))
var walls := MAZE.build_walls(data["cells"], cols, rows, ent, ex, field,
float(spec["corridor"]))
var closed_edges := 0
for k in edges.keys():
if bool(edges[k]):
closed_edges += 1
_eq(walls.size(), closed_edges, "maze T%d число стенок = рёбрам" % tier)
for wall in walls:
_ok((wall as Rect2).size.x > 0.0 and (wall as Rect2).size.y > 0.0,
"maze T%d стенка имеет размер" % tier)
_approx(MAZE.quality_for(0, 0), 1.0, 0.0001, "maze без ошибок = 1")
_approx(MAZE.quality_for(5, 2), 0.76, 0.0001, "maze 5 столкновений/2 тупика = 0.76")
_approx(MAZE.quality_for(20, 30), 0.0, 0.0001, "maze q без нижнего порога 0.4")
# Скольжение: стена не пропускает пакет насквозь.
var field_test := Rect2(0.0, 0.0, 100.0, 100.0)
var walls_test := [Rect2(45.0, 0.0, 10.0, 100.0)]
var moved := MAZE.move_packet(Vector2(20, 50), Vector2(90, 50), 100.0, 1.0,
walls_test, field_test, 4.5)
_ok(bool(moved["blocked"]), "maze движение в стену помечено")
_ok((moved["pos"] as Vector2).x < 45.0, "maze пакет не прошёл сквозь стену")
# --- assert-хелперы --------------------------------------------------------
func _set_case(name: String) -> void:
_case = name
func _ok(condition: bool, message: String) -> bool:
_checks += 1
if condition:
return true
_fail(message + " — ожидалось true")
return false
func _eq(actual, expected, message: String) -> bool:
_checks += 1
if actual == expected:
return true
_fail("%s — получено %s, ожидалось %s" % [message, str(actual), str(expected)])
return false
func _approx(actual: float, expected: float, eps: float, message: String) -> bool:
_checks += 1
if absf(float(actual) - float(expected)) <= eps:
return true
_fail("%s — получено %.6f, ожидалось %.6f" % [message, float(actual), float(expected)])
return false
func _fail(message: String) -> void:
_fails += 1
print("FAIL [%s] %s" % [_case, message])
func _finish() -> void:
print("test_send_games: проверок %d, провалов %d" % [_checks, _fails])
print("PASS test_send_games" if _fails == 0 else "FAIL test_send_games")
quit(0 if _fails == 0 else 1)
-1
View File
@@ -1 +0,0 @@
uid://cwyrc544wsryb
+163 -395
View File
@@ -1,443 +1,215 @@
extends SceneTree
## Headless-тест магазина v2: 8 лотов, 3 предложения, веса/pity, реролл
## 40/80/120 (макс 3), замки, покупки/эффекты, save/load.
## Headless-тест магазина (10 позиций, цены, покупки, эффекты, unlock-гейты).
## Запуск: godot --headless --path . --script tests/test_shop.gd
const SHOP := preload("res://scripts/systems/shop.gd")
const SM := preload("res://scripts/core/slot_manager.gd")
const IDS := [
"lot_slots", "lot_speed", "lot_sla", "lot_shield",
"lot_money", "lot_folder", "lot_channel", "lot_timer",
"shop_slot3", "shop_slot4", "shop_buffer", "shop_timer", "shop_zone",
"shop_channel", "shop_section", "shop_search", "shop_shield", "shop_sla",
]
const FIRST_PRICES := {
"lot_slots": 120, "lot_speed": 90, "lot_sla": 120, "lot_shield": 150,
"lot_money": 80, "lot_folder": 80, "lot_channel": 70, "lot_timer": 70,
}
const MAX_LEVELS := {
"lot_slots": 4, "lot_speed": 6, "lot_sla": 4, "lot_shield": 3,
"lot_money": 8, "lot_folder": 5, "lot_channel": 6, "lot_timer": 5,
const PRICES := {
"shop_slot3": 250, "shop_slot4": 450, "shop_buffer": 200,
"shop_timer": 350, "shop_zone": 300, "shop_channel": 250,
"shop_section": 300, "shop_search": 250, "shop_shield": 400,
"shop_sla": 300,
}
var _checks: int = 0
var _fails: int = 0
var _case: String = ""
var _purchased: Array[String] = []
var _offers_changed: int = 0
var _game = null
func _init() -> void:
await process_frame
_game = root.get_node_or_null("Game")
if _game == null:
var game = root.get_node_or_null("Game")
if game == null:
_fail("autoload Game недоступен")
_finish()
return
game.start_new_run(1)
_test_catalog()
_test_refresh()
_test_weights()
_test_affordable()
_test_reroll()
_test_lock()
_test_buy_and_effects()
_test_slot_and_folder_effects()
_test_maxed()
_test_save_load()
_test_signals()
_test_unlocks()
# Дальнейшие кейсы — в часе 2, когда магазин и базовые позиции открыты.
game.hour = 2
game.meta_unlocks["survive_hour_3"] = true
game.meta_unlocks["survive_hour_5"] = true
_test_money_gate()
_test_slots()
_test_effects()
_test_sla_effect()
_test_limits()
_test_signal()
_finish()
# --- кейсы -----------------------------------------------------------------
func _test_catalog() -> void:
_set_case("catalog")
_game.start_new_run(1)
var shop = SHOP.new()
_eq(shop.item_count(), 8, "ровно 8 лотов")
var total := 0
_eq(shop.item_count(), 10, "ровно 10 позиций")
for id in IDS:
var item: Dictionary = shop.get_item(id)
_ok(not item.is_empty(), "лот %s есть" % id)
_eq(shop.price(id), int(FIRST_PRICES[id]), "цена первого уровня %s" % id)
_eq(shop.max_level(id), int(MAX_LEVELS[id]), "max_level %s" % id)
var item := shop.get_item(id)
_ok(not item.is_empty(), "товар %s есть" % id)
_eq(shop.price(id), int(PRICES[id]), "цена %s" % id)
_eq(shop.max_level(id), int(item.get("max_level", 1)), "max_level %s" % id)
_eq(shop.level(id), 0, "начальный уровень %s" % id)
for p in item.get("prices", []):
total += int(p)
_eq(total, 13100, "сумма цен всех лотов = 13 100")
_ok(shop.get_item("scanner").is_empty(), "сканера нет")
_eq(shop.max_level("нет_товара"), 0, "неизвестный лот → max_level 0")
_eq(shop.price("нет_товара"), 0, "неизвестный лот → price 0")
# Максимальный уровень: price() = -1, без индексации prices[max_level].
_game.upgrades["lot_timer"] = 5
_game.upgrades["timer_level"] = 5
_eq(shop.price("lot_timer"), -1, "maxed lot_timer → price -1")
_ok(not shop.can_buy("lot_timer"), "maxed нельзя купить")
_game.upgrades.clear()
_ok(shop.get_item("scanner").is_empty(), "сканера нет (D13)")
_ok(shop.get_item("autosort").is_empty(), "автосортировки нет (D13)")
var sla_item := shop.get_item("shop_sla")
_eq(str(sla_item.get("effect", "")), "sla_add", "shop_sla: effect sla_add")
_eq(int(sla_item.get("value", 0)), 24, "shop_sla: +24")
_eq(shop.max_level("shop_sla"), 3, "shop_sla: max 3 уровня")
func _test_refresh() -> void:
_set_case("refresh")
var shop = _reset(2, 1000)
shop.refresh(2, 1000, true)
var ofs: Array = shop.offers()
_eq(ofs.size(), 3, "ровно 3 предложения")
_eq(_distinct(ofs), 3, "предложения без дублей")
for id in ofs:
_ok(IDS.has(id), "оффер %s из каталога" % id)
_ok(shop.level(id) < shop.max_level(id), "оффер %s не максимален" % id)
_eq(int(_game.shop_offer_hour), 2, "shop_offer_hour = 2")
_eq(int(_game.shop_rerolls), 0, "после новой подготовки рероллы = 0")
_eq(shop.offers(), _game.shop_offers, "offers() читает Game.shop_offers")
# Повторный refresh в том же часу идемпотентен (без force).
var before: Array = shop.offers()
shop.refresh(2, 1000)
_eq(shop.offers(), before, "refresh без force не меняет ассортимент")
# Инварианты на 40 генерациях (force).
for i in range(40):
shop.refresh(2, 1000, true)
var current: Array = shop.offers()
if current.size() != 3:
_fail("генерация %d: не 3 оффера (%d)" % [i, current.size()])
break
if _distinct(current) != current.size():
_fail("генерация %d: дубли в офферах" % i)
break
var bad := false
for id in current:
if shop.level(id) >= shop.max_level(id):
bad = true
if bad:
_fail("генерация %d: максимальный лот в офферах" % i)
break
_ok(true, "40 генераций: 3/без дублей/без maxed")
func _test_weights() -> void:
_set_case("weights")
func _test_unlocks() -> void:
_set_case("unlocks")
var game = root.get_node("Game")
var shop = SHOP.new()
var slots: Dictionary = shop.get_item("lot_slots")
_approx(SHOP.lot_weight(slots, 2, [], {}), 1.0, 0.0001, "lot_slots H2 = 1.0")
_approx(SHOP.lot_weight(slots, 5, [], {}), 1.0, 0.0001, "lot_slots H5 = 1.0")
_approx(SHOP.lot_weight(slots, 9, [], {}), 0.8, 0.0001, "lot_slots H9 = 0.8")
_approx(SHOP.lot_weight(slots, 9, ["lot_slots"], {}), 0.4, 0.0001, "прошлый оффер ×0.5")
_approx(SHOP.lot_weight(slots, 9, [], {"lot_slots": 7}), 1.2, 0.0001, "pity (2 часа) ×1.5")
_approx(SHOP.lot_weight(slots, 9, [], {"lot_slots": 8}), 0.8, 0.0001, "1 час назад — без pity")
_approx(SHOP.lot_weight(slots, 9, ["lot_slots"], {"lot_slots": 7}), 0.6, 0.0001, "×0.5 и ×1.5 вместе")
_approx(SHOP.lot_weight(shop.get_item("lot_shield"), 2, [], {}), 0.3, 0.0001, "lot_shield H2 = 0.3")
_approx(SHOP.lot_weight(shop.get_item("lot_shield"), 9, [], {}), 1.0, 0.0001, "lot_shield H9 = 1.0")
_approx(SHOP.lot_weight(shop.get_item("lot_money"), 9, [], {}), 1.2, 0.0001, "lot_money H9 = 1.2")
_approx(SHOP.lot_weight(shop.get_item("lot_timer"), 9, [], {}), 0.7, 0.0001, "lot_timer H9 = 0.7")
_approx(SHOP.lot_weight(shop.get_item("lot_folder"), 5, [], {}), 0.9, 0.0001, "lot_folder H5 = 0.9")
# Pity через реальную генерацию: лот, которого не было 2+ часа, получает ×1.5.
var seen := {"lot_shield": 1}
var weights: Array = []
for lot in shop.items():
weights.append(SHOP.lot_weight(lot, 3, [], seen))
_ok(weights.size() == 8, "веса считаются для всех 8 лотов")
# Час 1: магазин ещё закрыт, но shop_sla доступен всегда.
game.start_new_run(1)
_ok(not shop.item_unlocked(shop.get_item("shop_slot3")), "час 1: slot3 закрыт")
_ok(not shop.can_buy("shop_slot3"), "час 1: slot3 нельзя купить")
_ok(shop.item_unlocked(shop.get_item("shop_sla")), "shop_sla открыт всегда")
game.money = 300
_ok(shop.can_buy("shop_sla"), "shop_sla можно купить на часе 1")
game.money = 150
var available = shop.available_items()
_eq(available.size(), 1, "час 1: в available_items только shop_sla")
_ok(not shop.item_unlocked(shop.get_item("shop_search")), "search закрыт до мета")
_ok(not shop.item_unlocked(shop.get_item("shop_shield")), "shield закрыт до мета")
game.hour = 2
_ok(shop.item_unlocked(shop.get_item("shop_slot3")), "час 2: базовые позиции открыты")
_ok(not shop.item_unlocked(shop.get_item("shop_search")), "час 2: search ещё закрыт")
game.meta_unlocks["survive_hour_3"] = true
_ok(shop.item_unlocked(shop.get_item("shop_search")), "мета survive_hour_3 открывает search")
_ok(not shop.item_unlocked(shop.get_item("shop_shield")), "shield ещё закрыт")
game.meta_unlocks["survive_hour_5"] = true
_ok(shop.item_unlocked(shop.get_item("shop_shield")), "мета survive_hour_5 открывает shield")
_eq(shop.available_items().size(), 10, "все 10 позиций открыты")
func _test_affordable() -> void:
_set_case("affordable")
var shop = _reset(2, 0)
shop.refresh(2, 0, true)
_eq(shop.offers().size(), 3, "при 0 $ всё равно 3 оффера (без краша)")
for i in range(30):
_game.money = 71
shop.refresh(2, 71, true)
var ofs: Array = shop.offers()
var has_cheap: bool = ofs.has("lot_channel") or ofs.has("lot_timer")
if not has_cheap:
_fail("при 71 $ нет доступного лота за 70 (офферы: %s)" % str(ofs))
break
_ok(true, "при 71 $ всегда есть доступный лот за 70")
# Дорогие лоты не заменяются, если доступных нет.
_game.money = 60
shop.refresh(2, 60, true)
_eq(shop.offers().size(), 3, "при 60 $ предложения не ломаются")
func _test_money_gate() -> void:
_set_case("money")
var game = root.get_node("Game")
var shop = SHOP.new()
game.money = 249
_ok(not shop.can_buy("shop_slot3"), "249 $ мало для слота за 250")
_ok(not shop.buy("shop_slot3"), "buy без денег → false")
_eq(game.money, 249, "деньги не списаны")
game.money = 250
_ok(shop.can_buy("shop_slot3"), "250 $ хватает")
_ok(not shop.can_buy("shop_slot4"), "на 450 не хватает")
func _test_reroll() -> void:
_set_case("reroll")
var shop = _reset(2, 1000)
shop.refresh(2, 1000, true)
_eq(shop.reroll_cost(), 40, "первый реролл 40 $")
_ok(shop.can_reroll(), "реролл доступен")
_ok(shop.reroll(), "reroll #1")
_eq(int(_game.money), 960, "списано 40")
_eq(int(_game.shop_rerolls), 1, "shop_rerolls = 1")
_eq(shop.offers().size(), 3, "после реролла 3 оффера")
_eq(_distinct(shop.offers()), 3, "после реролла без дублей")
_eq(shop.reroll_cost(), 80, "второй реролл 80 $")
_ok(shop.reroll(), "reroll #2")
_eq(int(_game.money), 880, "списано 80")
_eq(shop.reroll_cost(), 120, "третий реролл 120 $")
_ok(shop.reroll(), "reroll #3")
_eq(int(_game.money), 760, "списано 120")
_eq(int(_game.shop_rerolls), 3, "shop_rerolls = 3")
_eq(shop.reroll_cost(), -1, "после лимита cost = -1")
_ok(not shop.can_reroll(), "после лимита реролл недоступен")
_ok(not shop.reroll(), "4-й reroll → false")
_eq(int(_game.money), 760, "деньги не списаны после лимита")
# Недостаточно денег.
shop = _reset(2, 39)
shop.refresh(2, 39, true)
_eq(shop.reroll_cost(), 40, "цена реролла 40 $")
_ok(not shop.can_reroll(), "39 $ мало для реролла")
_ok(not shop.reroll(), "reroll без денег → false")
_eq(int(_game.money), 39, "деньги не списаны")
# end_hour сбрасывает рероллы на новой подготовке.
shop = _reset(2, 1000)
shop.refresh(2, 1000, true)
shop.reroll()
_eq(int(_game.shop_rerolls), 1, "перед end_hour: 1 реролл")
_game.end_hour()
_eq(int(_game.shop_rerolls), 0, "end_hour сбрасывает рероллы")
_eq(_game.shop_locked.size(), 0, "end_hour очищает замки")
func _test_lock() -> void:
_set_case("lock")
var shop = _reset(2, 1000)
shop.refresh(2, 1000, true)
var ofs: Array = shop.offers()
var locked_id: String = str(ofs[1])
_ok(shop.toggle_lock(1), "toggle_lock(1) → locked")
_ok(shop.is_locked(1), "is_locked(1)")
_ok(_game.shop_locked.has(locked_id), "id попал в Game.shop_locked")
_ok(not shop.toggle_lock(-1), "невалидный индекс → false")
_ok(not shop.is_locked(99), "is_locked(99) → false")
_ok(shop.reroll(), "реролл с замком")
_ok(shop.offers().has(locked_id), "закреплённый лот остался после реролла")
_ok(shop.is_locked(0), "закреплённый лот стоит первым (must_keep)")
_eq(shop.offers().size(), 3, "после реролла с замком всё ещё 3 оффера")
_ok(not shop.toggle_lock(0), "toggle_lock(0) → unlocked")
_ok(not shop.is_locked(0), "замок снят")
shop.reroll()
_ok(shop.offers().size() == 3, "реролл без замков")
func _test_buy_and_effects() -> void:
_set_case("buy")
var shop = _reset(2, 10000)
_set_offers(["lot_sla", "lot_slots", "lot_money"])
_eq(shop.price("lot_sla"), 120, "lot_sla цена 120")
_ok(shop.buy("lot_sla"), "покупка lot_sla")
_eq(int(_game.money), 9880, "списано 120")
_eq(shop.level("lot_sla"), 1, "уровень lot_sla = 1")
_eq(_game.level("sla_level"), 1, "агрегат sla_level = 1")
_approx(_game.sla_cap(), 95.0, 0.0001, "кап SLA 60 → 95")
_approx(float(_game.sla), 95.0, 0.0001, "SLA сразу 60 → 95")
_eq(shop.price("lot_sla"), 260, "цена 2-го уровня 260")
_ok(shop.buy("lot_sla"), "покупка lot_sla 2-й раз")
_eq(shop.price("lot_sla"), 480, "цена 3-го уровня 480")
_eq(int(_game.money), 9620, "после второй покупки 9620")
_approx(float(_game.sla), 130.0, 0.0001, "SLA 95 → 130")
_ok(shop.buy("lot_slots"), "покупка lot_slots")
_eq(shop.level("lot_slots"), 1, "lot_slots = 1")
_eq(_game.level("slot_level"), 1, "slot_level = 1")
_ok(shop.buy("lot_money"), "покупка lot_money")
_approx(_game.money_mult(), 1.05, 0.0001, "money_mult ×1.05")
_eq(shop.level("lot_money"), 1, "lot_money уровень 1")
# channel/speed/timer/shield — каждый эффект применяется сразу.
_set_offers(["lot_channel", "lot_speed", "lot_timer"])
_ok(shop.buy("lot_channel"), "покупка lot_channel")
_approx(_game.channel_capacity(), 16.0, 0.0001, "канал 10 → 16")
_eq(_game.buffer_limit(), 7, "буфер 6 → 7")
_ok(shop.buy("lot_speed"), "покупка lot_speed")
_approx(_game.speed_mult(), 0.92, 0.0001, "скорость ×0.92")
_ok(shop.buy("lot_timer"), "покупка lot_timer")
_approx(_game.request_timer(), 35.0, 0.0001, "таймер запроса 30 → 35")
_set_offers(["lot_shield", "lot_sla", "lot_money"])
_ok(shop.buy("lot_shield"), "покупка lot_shield")
_eq(_game.shield_max(), 1, "1 заряд щита")
_eq(_game.shield_charges, 1, "щит заряжен сразу")
# Купить можно только то, что в текущих предложениях.
_set_offers(["lot_channel", "lot_speed", "lot_timer"])
_ok(not shop.buy("lot_money"), "лот вне предложений купить нельзя")
_ok(not shop.can_buy("lot_money"), "can_buy вне предложений → false")
_ok(not shop.buy("нет_такого"), "неизвестный id → false")
# Недостаточно денег.
shop = _reset(2, 10000)
_set_offers(["lot_slots"])
_game.money = 119
_ok(not shop.can_buy("lot_slots"), "119 $ мало для 120")
_ok(not shop.buy("lot_slots"), "buy без денег → false")
_eq(int(_game.money), 119, "деньги не списаны")
func _test_slot_and_folder_effects() -> void:
_set_case("slots/folder")
var shop = _reset(2, 100000)
_set_offers(["lot_slots"])
_ok(shop.buy("lot_slots"), "slot #1")
_eq(SM.new().max_slots(), 2, "SlotManager видит 2 слота")
_ok(shop.buy("lot_slots"), "slot #2")
func _test_slots() -> void:
_set_case("slots")
var game = root.get_node("Game")
var shop = SHOP.new()
game.money = 1000
_ok(shop.buy("shop_slot3"), "покупка третьего слота")
_eq(game.money, 750, "списано 250")
_eq(shop.level("shop_slot3"), 1, "уровень товара 1")
_eq(int(game.upgrades.get("slot_level", 0)), 1, "slot_level = 1")
_eq(SM.new().max_slots(), 3, "SlotManager видит 3 слота")
_ok(shop.buy("lot_slots"), "slot #3")
_ok(shop.buy("lot_slots"), "slot #4")
_eq(SM.new().max_slots(), 5, "SlotManager видит 5 слотов")
_eq(shop.price("lot_slots"), -1, "maxed lot_slots → price -1")
_ok(not shop.buy("lot_slots"), "5-й уровень не продаётся")
_ok(not shop.can_buy("shop_slot3"), "повторно слот 3 не купить")
_ok(shop.buy("shop_slot4"), "покупка четвёртого слота")
_eq(int(game.upgrades.get("slot_level", 0)), 2, "slot_level = 2")
_eq(SM.new().max_slots(), 4, "SlotManager видит 4 слота")
_eq(game.money, 300, "после двух слотов 300 $")
shop = _reset(2, 100000)
_set_offers(["lot_folder"])
var folder = _first_folder()
_ok(folder != null, "есть существующая папка для роста")
var probe = null
if folder != null:
probe = _game.registry.create_file("рост_папки", "text", str(folder.id))
_ok(probe != null, "файл в папке до покупки лота создан")
var probe_origin: Vector2i = probe.origin if probe != null else Vector2i.ZERO
var probe_shape := str(probe.shape_id) if probe != null else "O1"
var probe_rotation := int(probe.rotation) if probe != null else 0
var dims: Vector2i = _game.folder_dims()
_eq(dims.x, 5, "folder старт: 5")
_eq(dims.y, 4, "folder старт: 4")
_ok(shop.buy("lot_folder"), "folder #1")
dims = _game.folder_dims()
_eq(dims.x, 6, "folder #1: 6 столбцов")
_eq(dims.y, 4, "folder #1: 4 строки")
if folder != null:
_eq(int(folder.cols), 6, "существующая папка выросла до 6 столбцов")
_eq(int(folder.rows), 4, "существующая папка выросла до 4 строк")
if probe != null:
_eq(probe.origin, probe_origin, "origin файла не изменился при росте")
_ok(_game.registry.can_place(folder, probe_shape, probe_origin,
probe_rotation, str(probe.id)),
"файл после роста лежит в валидных клетках")
for i in range(4):
shop.buy("lot_folder")
dims = _game.folder_dims()
_eq(dims.x, 8, "folder max: 8 столбцов")
_eq(dims.y, 6, "folder max: 6 строк")
if folder != null:
_eq(int(folder.cols), 8, "существующая папка выросла до 8 столбцов")
_eq(int(folder.rows), 6, "существующая папка выросла до 6 строк")
if probe != null:
_eq(probe.origin, probe_origin, "origin файла не изменился на max")
_ok(_game.registry.can_place(folder, probe_shape, probe_origin,
probe_rotation, str(probe.id)),
"файл после max-роста лежит в валидных клетках")
_eq(shop.level("lot_folder"), 5, "folder уровень 5")
_eq(shop.price("lot_folder"), -1, "maxed lot_folder → price -1")
# Обратный порядок покупки: четвёртый слот не должен «съедать» третий.
game.upgrades.clear()
game.money = 1000
var shop2 = SHOP.new()
_ok(shop2.buy("shop_slot4"), "сначала куплен 4-й слот")
_eq(shop2.level("shop_slot3"), 0, "3-й слот ещё не куплен")
_ok(shop2.can_buy("shop_slot3"), "3-й слот доступен после 4-го")
_ok(not shop2.can_buy("shop_slot4"), "4-й слот больше не купить")
_ok(shop2.buy("shop_slot3"), "3-й слот куплен вторым")
_eq(int(game.upgrades.get("slot_level", 0)), 2, "slot_level = 2 в обратном порядке")
func _test_maxed() -> void:
_set_case("all_maxed")
var shop = _reset(2, 100000)
for id in IDS:
_game.set_lot_level(id, int(MAX_LEVELS[id]))
shop.refresh(2, 100000, true)
_eq(shop.offers().size(), 0, "все лоты maxed → 0 офферов (без краша)")
_eq(shop.reroll_cost(), -1, "реролл недоступен")
_ok(not shop.can_reroll(), "can_reroll → false")
_ok(not shop.reroll(), "reroll → false")
for id in IDS:
_eq(shop.price(id), -1, "maxed %s → price -1" % id)
_ok(not shop.can_buy(id), "maxed %s нельзя купить" % id)
func _test_effects() -> void:
_set_case("effects")
var game = root.get_node("Game")
var shop = SHOP.new()
game.money = 100000
_ok(shop.buy("shop_buffer"), "буфер куплен")
_eq(int(game.upgrades.get("buffer_level", 0)), 1, "buffer_level = 1")
_ok(shop.buy("shop_timer"), "таймер куплен")
_eq(int(game.upgrades.get("timer_level", 0)), 1, "timer_level = 1")
_ok(shop.buy("shop_zone"), "зона куплена")
_eq(int(game.upgrades.get("zone_level", 0)), 1, "zone_level = 1")
_ok(shop.buy("shop_channel"), "канал куплен")
_eq(int(game.upgrades.get("channel_level", 0)), 1, "channel_level = 1")
_ok(shop.buy("shop_section"), "секция куплена")
_eq(int(game.upgrades.get("sections_unlocked", 0)), 1, "sections_unlocked = 1")
_ok(game.registry.sections.has("D"), "реестр получил секцию D")
_ok(shop.buy("shop_search"), "поиск куплен")
_eq(int(game.upgrades.get("search_unlocked", 0)), 1, "search_unlocked = 1")
_ok(shop.buy("shop_shield"), "щит куплен")
_eq(int(game.upgrades.get("shield_unlocked", 0)), 1, "shield_unlocked = 1")
func _test_save_load() -> void:
_set_case("save/load")
var shop = _reset(2, 1000)
shop.refresh(2, 1000, true)
shop.toggle_lock(0)
shop.reroll()
_eq(int(_game.shop_rerolls), 1, "перед save: 1 реролл")
var saved_offers: Array = shop.offers()
var saved_locked: Array = shop.locked_ids()
var saved_money := int(_game.money)
_ok(_game.save_game(), "save_game")
_game.shop_offers.clear()
_game.shop_rerolls = 0
_game.shop_offer_hour = 0
_game.shop_locked.clear()
_game.money = 1
_ok(_game.load_game(), "load_game")
_eq(shop.offers(), saved_offers, "офферы восстановлены из сейва")
_eq(int(_game.shop_rerolls), 1, "рероллы восстановлены")
_eq(int(_game.shop_offer_hour), 2, "час офферов восстановлен")
_eq(shop.locked_ids(), saved_locked, "замки восстановлены")
_eq(int(_game.money), saved_money, "деньги восстановлены")
for id in shop.offers():
_ok(shop.level(id) < shop.max_level(id), "восстановленный оффер %s не maxed" % id)
# Перегенерация при пустых офферах на часе > 1.
_game.hour = 3
_game.shop_offers.clear()
_game.shop_offer_hour = 0
_game.save_game()
_ok(_game.load_game(), "load_game с пустыми офферами")
_eq(shop.offers().size(), 3, "пустые офферы догенерированы")
_eq(int(_game.shop_offer_hour), 3, "shop_offer_hour = 3")
func _test_sla_effect() -> void:
_set_case("sla_add")
var game = root.get_node("Game")
var shop = SHOP.new()
game.money = 10000
game.upgrades.erase("shop_sla")
game.upgrades.erase("sla_level")
game.sla = 128.0
_ok(shop.buy("shop_sla"), "shop_sla куплен")
_approx(float(game.sla), 152.0, 0.0001, "SLA 128 → 152")
_eq(shop.level("shop_sla"), 1, "уровень shop_sla = 1")
_eq(shop.effect_level("sla_add"), 1, "effect_level(sla_add) = 1")
_eq(int(game.money), 9700, "списано 300")
_ok(shop.buy("shop_sla"), "shop_sla куплен 2-й раз")
_approx(float(game.sla), 176.0, 0.0001, "SLA 152 → 176")
_ok(shop.buy("shop_sla"), "shop_sla куплен 3-й раз")
_approx(float(game.sla), 200.0, 0.0001, "SLA 176 → 200")
_eq(shop.level("shop_sla"), 3, "уровень shop_sla = 3")
_ok(not shop.can_buy("shop_sla"), "4-й уровень не продаётся (max 3)")
# add_sla клампит: покупка при 180 не поднимает выше 200.
game.upgrades.erase("shop_sla")
game.upgrades.erase("sla_level")
game.sla = 180.0
_ok(shop.buy("shop_sla"), "shop_sla при SLA 180")
_approx(float(game.sla), 200.0, 0.0001, "SLA клампится на 200")
func _test_signals() -> void:
_set_case("signals")
var shop = _reset(2, 500)
func _test_limits() -> void:
_set_case("limits")
var game = root.get_node("Game")
var shop = SHOP.new()
game.money = 100000
game.upgrades["shop_buffer"] = 2
game.upgrades["buffer_level"] = 2
_ok(not shop.can_buy("shop_buffer"), "буфер max 2")
_ok(not shop.buy("shop_buffer"), "buy сверх max → false")
game.upgrades["shop_timer"] = 3
game.upgrades["timer_level"] = 3
_ok(not shop.can_buy("shop_timer"), "таймер max 3")
_ok(shop.can_buy("shop_zone"), "зону ещё можно купить")
_ok(not shop.can_buy("нет_товара"), "неизвестный id → false")
func _test_signal() -> void:
_set_case("signal")
var game = root.get_node("Game")
var shop = SHOP.new()
game.money = 10000
shop.purchased.connect(_on_purchased)
shop.offers_changed.connect(_on_offers_changed)
shop.refresh(2, 500, true)
_ok(_offers_changed >= 1, "refresh эмитит offers_changed")
_purchased.clear()
var target := ""
for id in shop.offers():
if shop.can_buy(id):
target = id
break
_ok(not target.is_empty(), "есть доступный оффер")
if not target.is_empty():
var before_changed := _offers_changed
_ok(shop.buy(target), "покупка для сигналов")
_eq(_purchased.size(), 1, "purchased эмитится")
if _purchased.size() == 1:
_eq(_purchased[0], target, "id в purchased")
_ok(_offers_changed > before_changed, "покупка эмитит offers_changed")
_ok(shop.buy("shop_zone"), "покупка для сигнала")
_eq(_purchased.size(), 1, "purchased эмитится")
if not _purchased.is_empty():
_eq(_purchased[0], "shop_zone", "id в сигнале")
# --- хелперы ---------------------------------------------------------------
func _reset(hour: int, money: int):
_game.start_new_run(1)
_game.hour = hour
_game.money = money
return SHOP.new()
func _set_offers(ids: Array) -> void:
_game.shop_offers.clear()
for id in ids:
_game.shop_offers.append(str(id))
func _first_folder():
var registry = _game.registry
if registry == null:
return null
for section in registry.sections.values():
if not section.folder_ids.is_empty():
return registry.folders.get(str(section.folder_ids[0]))
return null
func _distinct(arr: Array) -> int:
var seen: Dictionary = {}
for item in arr:
seen[str(item)] = true
return seen.size()
# --- assert-хелперы --------------------------------------------------------
func _set_case(name: String) -> void:
_case = name
@@ -476,10 +248,6 @@ func _on_purchased(id: String) -> void:
_purchased.append(id)
func _on_offers_changed() -> void:
_offers_changed += 1
func _finish() -> void:
print("test_shop: проверок %d, провалов %d" % [_checks, _fails])
print("PASS test_shop" if _fails == 0 else "FAIL test_shop")
-310
View File
@@ -1,310 +0,0 @@
extends SceneTree
## Headless-тест v2-окна магазина: 3 карточки, тексты, состояния кнопок,
## реролл 40/80/120, «ВСЁ КУПЛЕНО», замок и закрытие.
## Запуск: godot --headless --path . --script tests/test_shop_modal.gd
const SHOP := preload("res://scripts/systems/shop.gd")
const MODAL := preload("res://scripts/ui/shop_modal.gd")
const IDS := [
"lot_slots", "lot_speed", "lot_sla", "lot_shield",
"lot_money", "lot_folder", "lot_channel", "lot_timer",
]
const MAX_LEVELS := {
"lot_slots": 4, "lot_speed": 6, "lot_sla": 4, "lot_shield": 3,
"lot_money": 8, "lot_folder": 5, "lot_channel": 6, "lot_timer": 5,
}
var _checks := 0
var _fails := 0
var _case := ""
var _game = null
var _modal = null
var _shop = null
func _init() -> void:
await process_frame
_game = root.get_node_or_null("Game")
var stage := Control.new()
stage.name = "ShopModalStage"
stage.size = Vector2(1280, 720)
root.add_child(stage)
_modal = MODAL.new()
_modal.name = "ShopModalTest"
stage.add_child(_modal)
_modal.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
if _game == null:
_test_fallback_without_game()
_finish()
return
_test_layout_and_texts()
_test_maxed_card()
_test_unaffordable_card()
_test_reroll_costs()
_test_all_bought()
_test_lock_persists()
_test_fewer_offers()
_test_close_and_escape()
_test_legacy_open()
_finish()
# --- кейсы -----------------------------------------------------------------
func _test_layout_and_texts() -> void:
_set_case("layout/texts")
var shop = _reset(2, 1000)
shop.refresh(2, 1000, true)
_modal.open_prep(shop, 1000, 2)
var win: Rect2 = _modal.window_rect()
_approx(win.size.x, 900.0, 0.001, "окно 900 px")
_approx(win.size.y, 600.0, 0.001, "окно 600 px")
_eq(_modal.card_rects().size(), 3, "ровно 3 карточки")
_ok(_modal.is_open(), "open_prep делает окно видимым")
var rects: Array = _modal.card_rects()
for i in rects.size():
var r: Rect2 = rects[i]
_approx(r.size.x, 260.0, 0.001, "card %d: ширина 260" % i)
_approx(r.size.y, 360.0, 0.001, "card %d: высота 360" % i)
_ok(r.position.x >= win.position.x and r.position.y >= win.position.y,
"card %d: внутри окна (лево/верх)" % i)
_ok(r.end.x <= win.end.x and r.end.y <= win.end.y,
"card %d: внутри окна (право/низ)" % i)
# Константы раскладки: 3*260 + 2*24 = 828; поля (900-828)/2 = 36.
var base_x: float = win.position.x + 36.0
_approx(rects[0].position.x, base_x, 0.001, "card 0 x = окно + 36")
_approx(rects[1].position.x, base_x + 284.0, 0.001, "card 1 x = +284 (260+24)")
_approx(rects[2].position.x, base_x + 568.0, 0.001, "card 2 x = +568")
_approx(rects[0].position.y, win.position.y + 88.0, 0.001, "cards y = окно + 88")
var infos: Array = _modal.card_infos()
_eq(infos.size(), 3, "card_infos: 3 записи")
for i in infos.size():
var info: Dictionary = infos[i]
_ok(not str(info.get("id", "")).is_empty(), "card %d: id не пуст" % i)
_ok(not str(info.get("title", "")).is_empty(), "card %d: заголовок не пуст" % i)
_ok(not str(info.get("level_text", "")).is_empty(), "card %d: уровень не пуст" % i)
_ok(str(info.get("level_text", "")).begins_with("УРОВЕНЬ"),
"card %d: текст уровня из shop.level" % i)
_ok(not str(info.get("now_text", "")).is_empty(), "card %d: СЕЙЧАС не пуст" % i)
_ok(not str(info.get("after_text", "")).is_empty(), "card %d: ПОСЛЕ не пуст" % i)
_ok(not str(info.get("button_text", "")).is_empty(), "card %d: кнопка цены не пуста" % i)
_ok(int(info.get("price", 0)) > 0, "card %d: цена > 0" % i)
_ok(not bool(info.get("maxed", true)), "card %d: не максимален" % i)
_ok(str(info.get("button_text", "")).contains("$"),
"card %d: доступная кнопка показывает цену" % i)
_ok(_modal.balance_text().contains("БАЛАНС"), "баланс содержит БАЛАНС")
_ok(_modal.close_text().contains("ЗАКРЫТ"), "кнопка закрытия — ЗАКРЫТЬ")
_ok(_modal.reroll_text().contains("40"), "реролл на старте 40 $")
func _test_maxed_card() -> void:
_set_case("maxed")
var shop = _reset(2, 10000)
_game.set_lot_level("lot_timer", int(MAX_LEVELS["lot_timer"]))
_set_offers(["lot_timer", "lot_slots", "lot_money"])
_modal.open_prep(shop, 10000, 2)
var infos: Array = _modal.card_infos()
_eq(infos.size(), 3, "maxed-кейс: 3 карточки без краша")
var first: Dictionary = infos[0]
_ok(bool(first.get("maxed", false)), "lot_timer maxed")
_ok(str(first.get("button_text", "")).contains("МАКСИМУМ"),
"maxed-карточка показывает МАКСИМУМ")
_ok(int(first.get("price", 0)) <= 0, "maxed price = -1/0")
_ok(_modal._buy_buttons[0].disabled, "maxed-кнопка disabled")
func _test_unaffordable_card() -> void:
_set_case("unaffordable")
var shop = _reset(2, 0)
_set_offers(["lot_slots", "lot_speed", "lot_money"])
_modal.open_prep(shop, 0, 2)
var infos: Array = _modal.card_infos()
_eq(infos.size(), 3, "unaffordable: 3 карточки")
for i in infos.size():
var info: Dictionary = infos[i]
_ok(str(info.get("button_text", "")).contains("НЕ ХВАТАЕТ"),
"card %d: НЕ ХВАТАЕТ" % i)
_ok(not bool(info.get("affordable", true)), "card %d: not affordable" % i)
_ok(_modal._buy_buttons[0].disabled, "не хватает — кнопка disabled")
func _test_reroll_costs() -> void:
_set_case("reroll")
var shop = _reset(2, 1000)
shop.refresh(2, 1000, true)
_modal.open_prep(shop, 1000, 2)
_ok(_modal.reroll_text().contains("40"), "реролл #1 = 40 $")
_ok(not _modal.reroll_disabled(), "реролл #1 доступен")
_ok(shop.reroll(), "reroll #1")
_ok(_modal.reroll_text().contains("80"), "реролл #2 = 80 $")
_ok(shop.reroll(), "reroll #2")
_ok(_modal.reroll_text().contains("120"), "реролл #3 = 120 $")
_ok(shop.reroll(), "reroll #3")
_ok(_modal.reroll_text().contains("ЛИМИТ ОБНОВЛЕНИЙ"),
"после 3-го — ЛИМИТ ОБНОВЛЕНИЙ")
_ok(_modal.reroll_disabled(), "после лимита кнопка disabled")
func _test_all_bought() -> void:
_set_case("all_bought")
var shop = _reset(2, 100000)
for id in IDS:
_game.set_lot_level(id, int(MAX_LEVELS[id]))
shop.refresh(2, 100000, true)
_modal.open_prep(shop, 100000, 2)
_eq(_modal.card_rects().size(), 0, "все лоты maxed → 0 карточек")
_ok(_modal.is_all_bought(), "is_all_bought true")
_ok(_modal.reroll_text().contains("ЛИМИТ ОБНОВЛЕНИЙ"),
"all bought: ЛИМИТ ОБНОВЛЕНИЙ")
_ok(_modal.reroll_disabled(), "all bought: реролл disabled")
func _test_lock_persists() -> void:
_set_case("lock")
var shop = _reset(2, 1000)
shop.refresh(2, 1000, true)
_modal.open_prep(shop, 1000, 2)
var locked_id: String = str(shop.offers()[0])
var seen: Array = []
var handler := func(index: int) -> void:
seen.append(index)
shop.toggle_lock(index)
_modal.refresh()
_modal.lock_toggled.connect(handler)
_modal.toggle_lock(0)
_eq(seen.size(), 1, "lock_toggled эмитится с индексом")
_ok(shop.is_locked(0), "Shop.is_locked(0)")
_ok(_modal.is_card_locked(0), "карточка показывает lock")
_ok(bool(_modal.card_infos()[0].get("locked", false)), "locked в card_info")
_ok(shop.reroll(), "реролл с закреплённым лотом")
_ok(shop.offers().has(locked_id), "закреплённый лот остался после реролла")
_ok(_modal.card_ids().has(locked_id), "UI показывает закреплённый лот")
_ok(_modal.is_card_locked(0), "замок сохранился на первой карточке")
_modal.lock_toggled.disconnect(handler)
func _test_fewer_offers() -> void:
_set_case("fewer_offers")
var shop = _reset(2, 1000)
_set_offers(["lot_slots", "lot_speed"])
_modal.open_prep(shop, 1000, 2)
_eq(_modal.card_rects().size(), 2, "2 оффера → 2 карточки, без краша")
var win: Rect2 = _modal.window_rect()
for r in _modal.card_rects():
_ok(r.position.x >= win.position.x and r.end.x <= win.end.x,
"fewer: карточка внутри окна")
func _test_close_and_escape() -> void:
_set_case("close")
var shop = _reset(2, 1000)
shop.refresh(2, 1000, true)
_modal.open_prep(shop, 1000, 2)
var closes: Array = []
var on_closed := func() -> void:
closes.append(true)
_modal.closed.connect(on_closed)
_modal.close()
_eq(closes.size(), 1, "close() эмитит closed")
_ok(not _modal.is_open(), "после close окно скрыто")
_modal.open_prep(shop, 1000, 2)
var event := InputEventKey.new()
event.keycode = KEY_ESCAPE
event.pressed = true
_modal._unhandled_input(event)
_eq(closes.size(), 2, "Esc эмитит closed")
_ok(not _modal.is_open(), "после Esc окно скрыто")
_modal.closed.disconnect(on_closed)
func _test_legacy_open() -> void:
_set_case("legacy")
_modal.open(500, {})
_ok(_modal.is_open(), "legacy open(money, levels) открывает окно")
_eq(_modal.card_rects().size(), 3, "legacy: 3 карточки из Data")
for info in _modal.card_infos():
_ok(not str(info.get("title", "")).is_empty(), "legacy: заголовок не пуст")
_ok(not str(info.get("button_text", "")).is_empty(), "legacy: кнопка не пуста")
_ok(_modal.reroll_disabled(), "legacy: реролл disabled (нет Shop)")
_modal.close()
func _test_fallback_without_game() -> void:
_set_case("fallback/no_game")
print("Game autoload недоступен — legacy-проверка ShopModal")
var fallback = SHOP.new()
_ok(fallback.item_count() >= 8, "Shop: fallback-каталог загружен")
_modal.open(500, {})
_ok(_modal.is_open(), "legacy open без Game не падает")
_eq(_modal.card_rects().size(), 3, "legacy без Game: 3 карточки")
_modal.close()
# --- хелперы ---------------------------------------------------------------
func _reset(hour: int, money: int):
_game.start_new_run(1)
_game.hour = hour
_game.money = money
_game.shop_offers.clear()
_game.shop_rerolls = 0
_game.shop_offer_hour = 0
_game.shop_locked.clear()
_shop = SHOP.new()
return _shop
func _set_offers(ids: Array) -> void:
_game.shop_offers.clear()
for id in ids:
_game.shop_offers.append(str(id))
func _set_case(name: String) -> void:
_case = name
func _ok(condition: bool, message: String) -> bool:
_checks += 1
if condition:
return true
_fail(message)
return false
func _eq(actual, expected, message: String) -> bool:
_checks += 1
if actual == expected:
return true
_fail("%s — получено %s, ожидалось %s" % [message, str(actual), str(expected)])
return false
func _approx(actual: float, expected: float, eps: float, message: String) -> bool:
_checks += 1
if absf(actual - expected) <= eps:
return true
_fail("%s — получено %.6f, ожидалось %.6f" % [message, actual, expected])
return false
func _fail(message: String) -> void:
_fails += 1
print("FAIL [%s] %s" % [_case, message])
func _finish() -> void:
print("test_shop_modal: проверок %d, провалов %d" % [_checks, _fails])
print("PASS test_shop_modal" if _fails == 0 else "FAIL test_shop_modal")
quit(0 if _fails == 0 else 1)
-1
View File
@@ -1 +0,0 @@
uid://d2jnjm66p2nkn
+11 -27
View File
@@ -1,5 +1,5 @@
extends SceneTree
## Headless-тест слотов соединений v2 (база 1, максимум 5).
## Headless-тест слотов соединений.
## Запуск: godot --headless --path . --script tests/test_slots.gd
const SM := preload("res://scripts/core/slot_manager.gd")
@@ -30,37 +30,22 @@ func _test_sizes() -> void:
var game = root.get_node("Game")
game.upgrades.clear()
var sm = SM.new()
_eq(SM.BASE_SLOTS, 1, "BASE_SLOTS = 1")
_eq(SM.MAX_SLOTS, 5, "MAX_SLOTS = 5")
_eq(sm.max_slots(), 1, "старт: 1 слот")
_eq(sm.slots.size(), 1, "массив слотов размера 1")
_eq(sm.max_slots(), 2, "старт: 2 слота")
_eq(sm.slots.size(), 2, "массив слотов размера 2")
_eq(sm.used_count(), 0, "на старте слоты свободны")
game.upgrades["slot_level"] = 1
_eq(sm.max_slots(), 2, "slot_level=1 → 2 слота")
game.upgrades["slot_level"] = 4
_eq(sm.max_slots(), 5, "slot_level=4 → 5 слотов")
_eq(sm.max_slots(), 3, "slot_level=1 → 3 слота")
game.upgrades["slot_level"] = 2
_eq(sm.max_slots(), 4, "slot_level=2 → 4 слота")
game.upgrades["slot_level"] = 99
_eq(sm.max_slots(), 5, "cap: максимум 5 слотов")
# Агрегат читается и по id лота (v2-сейв пишет оба ключа).
game.upgrades.clear()
game.upgrades["lot_slots"] = 2
_eq(sm.max_slots(), 3, "lot_slots=2 → 3 слота")
game.upgrades.clear()
_eq(sm.max_slots(), 4, "cap: максимум 4 слота")
game.upgrades["slot_level"] = 0
func _test_reserve_release() -> void:
_set_case("reserve/release")
var game = root.get_node("Game")
# База: 1 слот — второй reserve отклоняется.
game.upgrades.clear()
var one = SM.new()
_eq(one.reserve("in", "pkt_solo", "A"), 0, "1 слот: reserve → 0")
_eq(one.reserve("out", "req_solo", "B"), -1, "1 слот: второй reserve → -1")
_eq(one.used_count(), 1, "1 слот: занят 1")
_eq(one.free_count(), 0, "1 слот: свободно 0")
_ok(one.release(0), "release(0)")
game.upgrades["slot_level"] = 1
game.upgrades["slot_level"] = 0
var sm = SM.new()
sm.changed.connect(_on_changed)
_changes = 0
@@ -80,7 +65,6 @@ func _test_reserve_release() -> void:
_eq(sm.release_by_entity("pkt_c"), 1, "release_by_entity")
_eq(sm.release_by_entity("нет"), 0, "release_by_entity неизвестного → 0")
_eq(sm.free_count(), 1, "свободен один слот")
game.upgrades["slot_level"] = 0
func _test_directions_and_entity() -> void:
@@ -115,7 +99,7 @@ func _test_weights() -> void:
var weights: Array = sm.active_weights(reg)
_eq(weights.size(), 1, "один вес для одного файла")
if weights.size() == 1:
_approx(float(weights[0]), 4.0, 0.0001, "вес photo = 4 (v2)")
_approx(float(weights[0]), 14.0, 0.0001, "вес photo = 14")
var reg2_file = reg.create_file("Слотовый запрос", "sound", folder_id)
if reg2_file != null:
reg.attach_file(reg2_file.id, "req_slot_test")
@@ -124,7 +108,7 @@ func _test_weights() -> void:
var weights2: Array = sm2.active_weights(reg)
_eq(weights2.size(), 1, "вес запроса по прикреплённым файлам")
if weights2.size() == 1:
_approx(float(weights2[0]), 2.0, 0.0001, "вес sound = 2 (v2)")
_approx(float(weights2[0]), 10.0, 0.0001, "вес sound = 10")
# --- assert-хелперы --------------------------------------------------------