Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40c08c16ae | ||
|
|
d8e3c1bb49 |
No files matched your search
+44
-89
@@ -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 ещё не выполнен.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||

|
||||
|
||||
2. **Подготовка.** «Подготовка: сетка папок, стол сортировки 4×6, SLA 60/60; магазин откроется после первого часа».
|
||||
2. **Подготовка.** «Подготовка: склад и папки, SLA 128/200, магазин откроется после первого часа».
|
||||
|
||||

|
||||
|
||||
3. **Час пик.** «Час пик: пакеты-фигуры в приёме и запросы с таймерами, протоколами и слотами».
|
||||
3. **Час пик.** «Час пик: 3 пакета в приёме и 2 запроса с таймерами/протоколами/слотами».
|
||||
|
||||

|
||||
|
||||
4. **Атаки.** «Мини-игра атаки: час на паузе, качество и вердикт после действия».
|
||||
4. **Атаки.** «DDoS-баннер в приёме и фишинговый запрос с ???».
|
||||
|
||||

|
||||
|
||||
5. **Магазин.** «Магазин после часа: три предложения, реролл и замок, без крестика».
|
||||
5. **Магазин.** «Магазин после часа: доступные улучшения, SLA +24, без крестика».
|
||||
|
||||

|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
+142
-1485
File diff suppressed because it is too large.
Load diff
+892
-1007
File diff suppressed because it is too large.
Load diff
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 +0,0 @@
|
||||
uid://djv0odpjkyh8p
|
||||
@@ -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 +0,0 @@
|
||||
uid://dmrrnmmspoyhy
|
||||
@@ -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 +0,0 @@
|
||||
uid://lj8d55h1pplr
|
||||
@@ -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
@@ -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 "СЛАБО"
|
||||
@@ -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
|
||||
@@ -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 +0,0 @@
|
||||
uid://co766vx8eagfo
|
||||
@@ -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 +0,0 @@
|
||||
uid://b21ebusrqnwn
|
||||
@@ -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 +0,0 @@
|
||||
uid://cld1besfvk2ow
|
||||
@@ -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 +0,0 @@
|
||||
uid://besue7m8ig8k6
|
||||
@@ -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 +0,0 @@
|
||||
uid://cn5qitwtcdc72
|
||||
@@ -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 +0,0 @@
|
||||
uid://cdhe6b2d04ehx
|
||||
@@ -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 +0,0 @@
|
||||
uid://b2cghsoxycui0
|
||||
@@ -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
@@ -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
|
||||
|
||||
+545
-485
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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)
|
||||
@@ -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 +0,0 @@
|
||||
uid://cybnog1pa4ni4
|
||||
+238
-850
File diff suppressed because it is too large.
Load diff
@@ -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 +0,0 @@
|
||||
uid://cywkgtu02b8pk
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 +0,0 @@
|
||||
uid://d1a87l837swyv
|
||||
+35
-80
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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 +0,0 @@
|
||||
uid://kqjc8uchsx0s
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 +0,0 @@
|
||||
uid://cn5ssig4lnpbv
|
||||
+377
-354
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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 +0,0 @@
|
||||
uid://camwn2i5i06r1
|
||||
+110
-129
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+70
-161
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+50
-149
@@ -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()
|
||||
@@ -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 +0,0 @@
|
||||
uid://cwyrc544wsryb
|
||||
+163
-395
@@ -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")
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
uid://d2jnjm66p2nkn
|
||||
+11
-27
@@ -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-хелперы --------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user