4 Commits
93 changed files with 7533 additions and 2547 deletions

No files matched your search

+98 -63
View File
@@ -1,4 +1,4 @@
# AI_REPORT — PEAK HOUR 97 (v2.0)
# AI_REPORT — PEAK HOUR 97 (v3.0)
## 1. Инструменты и модели
@@ -16,52 +16,59 @@ test-writer, explorer, feis-regulator, oracle):
MR-ревью — агент **oracle** (opencode-go/deepseek-v4.1-flash): dry-run
74–90 с, комментарий в MR, без мержа.
## 2. Что изменилось в v2.0
## 2. Что изменилось в v3.0
- **Экономика:** старт 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 переписаны под фактические
числа и поведение; старые решения помечены, а не удалены.
- **Запросы:** 1–4 **конкретных именованных файла** вместо анонимных слотов;
имена на карточке 2×2, прикрепление по точному `file_id`, «ПОТЕРЯН» для
пропавшего файла, жёсткая резервация. Новые `request_mix` H1–H9 и
`request_cap`; H1-затравка и анти-голодание.
- **TCP:** возвращена полоска соединения (3 попадания, 440/340/280 px/с,
формула зоны, промах −4 SLA / 8 с, Esc 0 SLA / 4 с). TCP платит ×1.15,
UDP — ×0.85 и передаёт ×0.8.
- **Мини-игры:** 6 отправки + 4 атаки; игра с соединением проводов удалена;
у отправки 3 ступени (старые T2–T4), у атак 4. Выбор — по сумме
коэффициентов и таблице `rank_games` на 27 достижимых сумм.
- **Магазин:** 8 лотов, дерево **8 085 $**, SLA до **220**, ускорение ×0.90,
щит с 70 % награды, папки до 9×6, новая папка 180 $.
- **UI:** Golos Text для тела текста, Press Start 2P только для логотипа и
цифр; шкала 11/13/17/26; пиксельные иконки `art/icons/`; карточка запроса
400×128 с сеткой файлов и прогнозом; пустое состояние отправки; «СЛОТЫ».
- **Туториал:** 5 шагов с событийными триггерами, пульсирующие рамки,
«ДАЛЕЕ», сохранение `tutorial_done`, повтор из главного меню.
- **Документация:** GDD/DECISIONS/README/ITCH_PAGE/UI_REFRESH/ASSETS
переписаны под фактические числа v3; старые решения помечены, а не удалены.
## 3. Где применялся ИИ
- **Код:** HourDirector, FileRegistry, Economy, Shop, AttackDirector,
SaveManager, UI и мини-игры.
- **UI v2:** Kaleidos, маскот **БАЙТ**, туториал, VHS главного меню.
SaveManager, UI, соединение TCP и мини-игры.
- **UI v3:** Golos Text, пиксельные иконки, карточка запроса, туториал с
рамками, VHS главного меню.
- **Ambient:** 4 WAV (audio/amb), генератор generate_ambient.py, autoload
после первого жеста, mute/пауза/громкость.
- **Баланс:** цели дохода, дерево магазина, симулятор на 240 сидах.
- **Аудит:** B4 (дыры в выборе мини-игры), H5 (лимит папок/цена),
M8 (масштаб денег атак), M11 (геометрия wires T4), M16 (soft-lock
«нет денег/нет места»).
- **Баланс:** цели дохода, дерево магазина 8 085 $, симулятор на 240 сидах.
- **Аудит:** B4 (дыры в выборе мини-игры) закрыт ещё в v2; в v3 выбор
перестроен на 27 сумм и 3 ступени. M8 (масштаб денег атак), M16
(soft-lock «нет денег/нет места») сохранены.
- **Тесты/доки/звук:** unit/fuzz/интеграция/полный забег; docs/;
18 SFX + hum.wav на Python.
## 4. Приём: «враждебный критик»
Промпт просил быть враждебным критиком: «найди блокеры, из-за которых
прототип нельзя собрать; не хвали». Для v2 главный найденный дефект —
**B4**: выбор мини-игры по S−1 давал дыры и недостижимые строки, потому что
при максимум 3 файлах существуют только 19 сумм из 28. Исправление:
прототип нельзя собрать; не хвали». Главный дефект v2 — **B4**: выбор
мини-игры по `S−1` давал дыры и недостижимые строки. В v3 выбор перестроен:
ACHIEVABLE_SUMS = [1..14, 16, 17, 18, 20, 24]
ACHIEVABLE_SUMS = [1..22, 24, 25, 26, 28, 32] # 27 сумм для 1–4 файлов
rank = index of S in ACHIEVABLE_SUMS
tier = clamp(1 + floor(rank * 4 / 19), 1, 4)
id = order[rank % 7]
tier = T1 при rank 0–7, T2 при 8–13, T3 при 14–26
id = RANK_GAMES[rank] # фиксированная таблица
Все 7 игр и все 4 ступени достижимы; S=24 → video/T4. Старый пример S−1 из
спецификации отменён. Дополнительно закрыты M11 (низ wires T4 ≤ 320),
M16 (бесплатное уничтожение неприкреплённого файла) и H5 (лимит 6 папок /
100 $).
Все 6 игр достижимы в каждой из 3 ступеней; S=32 → video/T3. Дополнительно
закрыты: H1-голодание запросов (анти-голодание + seed-файл), потерянные
required-файлы (подстановка того же типа), неоднозначность TCP-протокола
(блокировка после соединения). M16 (бесплатное уничтожение неприкреплённого
файла) сохранён, но файл, закреплённый за запросом, удалить нельзя.
## 5. Что сделано без ИИ
@@ -72,52 +79,80 @@ M16 (бесплатное уничтожение неприкреплённог
## 6. Ассеты и лицензии
- **Handjet** и **Press Start 2P** — SIL OFL 1.1, тексты в
art/fonts/OFL-Handjet.txt и OFL-PressStart2P.txt.
- **Golos Text** — SIL OFL 1.1 (`art/fonts/OFL-GolosText.txt`), основной
шрифт UI с v3.
- **Press Start 2P** — SIL OFL 1.1 (`art/fonts/OFL-PressStart2P.txt`), только
логотип и цифры.
- **Handjet** — SIL OFL 1.1, с v3 в теме не используется и помечен как
legacy.
- Pixelify Sans в теме не используется (нет глифов О/П); файл и OFL
оставлены в art/fonts/.
- UI, иконки, маскот **БАЙТ** и icon.svg — команда проекта, MIT.
- Пиксельные иконки `art/icons/*.png` сгенерированы `generate_icons.py`
(Pillow), команда проекта, **MIT**; исходный тайл-лист был недоступен,
поэтому набор перегенерирован с нуля.
- UI, маскот **БАЙТ** и icon.svg — команда проекта, MIT.
- SFX, ambient и hum.wav — скрипты проекта, MIT/CC0.
- Код — MIT (LICENSE); OFL-шрифты совместимы с MIT при сохранении лицензий.
Подробности — в ASSETS.md.
## 7. Проверки v2.0
## 7. Проверки v3.0
По прогону v2 (Windows, Godot 4.7.2):
- **Симулятор баланса:** `python tests/sim_balance.py`, 240 сидов, 9 часов,
отчёт `tests/SIM_BALANCE_REPORT.md`. Вердикт **PASS**: все 9 часов в ±20 %,
аналитические капы не ниже целей.
- В отчёте: средний итог — **7 533 $** (payout 6 495 + recv_fee 410 + атаки
628), расход магазина — **4 681 $** (**57,9 %** дерева **8 085 $**),
**28 поражений из 240**, SLA в конце H9 — **62,2**; 54,4 запроса,
18,7 отражённых атак, 4,4 атаки щитом. Все 9 часов в ±20 % (H1 — 149 $ при
цели 150), отчёт соответствует текущему `data/balance.json`.
- **Тесты:** актуальный список — **25 наборов + parse-check** в
`scripts/run_all_tests.sh` (см. также `tests/`; счётчик может меняться между
волнами). Полный прогон перепроверен после финальных правок кода:
`PASS=26 FAIL=0 SKIP=0`.
- **Web-пресет** `export_presets.cfg` существует: `thread_support=false`,
экспорт в `build/web/index.html`, просмотр только по HTTP.
- **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.
### Финальная проверка v3
- **Headless:** полный прогон после всех правок — `PASS=26 FAIL=0 SKIP=0`
(25 наборов, включая `ui_smoke`, + parse-check).
- **Баланс:** симулятор — `PASS`, 240 сидов, все 9 часов в ±20 %, средний
итог **7 533 $**, магазин — **4 681 $ (57,9 %)** дерева **8 085 $**,
поражений **28/240** (`tests/SIM_BALANCE_REPORT.md`, шапка v3).
- **E2E на реальной сцене Main (1920×1080):** **190/190** проверок PASS —
меню → подготовка → час, приём пакета, прикрепление конкретного файла,
модалка TCP и промах/кулдаун, мини-игра отправки → завершение (файлы
списаны, деньги +18, сетка обновлена), UDP-обход, конец часа при открытом
соединении, H1-голодание, очистка ghost-привязок при save/load, защита
ввода в модалках, шаг туториала «отправка»; повторный «НОВЫЙ ЗАБЕГ» тоже
получает seed-файл в подготовке.
- **Web (Godot 4.7.2, Web/no-threads; Chromium + SwiftShader):** сборка
загружается, WebGL2 OK, 0 ошибок/предупреждений в консоли; меню →
«Новый забег» → «НАЧАТЬ ЧАС» работает; drag пакета в папку и drag нужного
файла в чип запроса работают; модалка соединения открывается (палочка
промахивается сама, если не кликнуть за ~1,1 с, — так задумано).
- **Полоска TCP** — рефлекторная мини-игра: 3 попадания, зона 30 % на H1;
уход палочки за край = автопромах; промах = −4 SLA и кулдаун 8 с
(`Esc` = 4 с, без штрафа).
## 8. Известные отклонения
- ПРОПУСТИТЬ даёт q=0; quality = 0.5, то есть половина quality-множителя,
а не нулевая выплата. Осознанно.
- speed выплаты фиксируется на старте отправки, пока таймер был заморожен
- `ПРОПУСТИТЬ` даёт `q=0`; `quality = 0.5`, то есть половина
quality-множителя, а не нулевая выплата. Осознанно.
- `speed` выплаты фиксируется на старте отправки, пока таймер был заморожен
мини-игрой; быстрый игрок видит ≈1.0.
- Лимит папок 6, новая папка 100 $ (review H5).
- Лимит папок 6, новая папка 180 $.
- Бесплатное уничтожение любого неприкреплённого файла в подготовке
(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».
(review M16); файл, закреплённый за запросом, не удаляется.
- Деньги атак масштабируются по часу `max(0.75, hour/3)` (review M8).
- H2 теоретически может выдать T2-запрос из трёх фото (S12); на практике
кривая H2 остаётся почти полностью T1.
- Расход магазина 57,9 % — внутри целевой полосы; это не «полный выкуп».
## 9. Ограничения
Туториал — маскот и подсказки; web-save — IndexedDB; плейтест, громкость и
Туториал автоматически запускается только в первом часу первого забега;
повтор — из главного меню. Web-save — IndexedDB; плейтест, громкость и
масштаб — за человеком; remote push ещё не выполнен. Мета-ключ `search`
остаётся в данных, но UI-поиск не подключён. Скриншоты в docs/screenshots/
стоит пересобрать под v2 перед публикацией страницы.
остаётся в данных, но UI-поиск не подключён. Скриншоты в `docs/screenshots/`
стоит пересобрать под v3 перед публикацией страницы.
+67 -25
View File
@@ -5,23 +5,40 @@
## Шрифты
### Handjet — основной UI-шрифт
### Golos Text — основной UI-шрифт (v3)
| Поле | Значение |
| --- | --- |
| Файл | `art/fonts/GolosText-VF.ttf` (variable; локальное имя для `GolosText[wght].ttf`) |
| Оси | `wght` 400–900; в теме — `wght 600` |
| Автор / проект | The Golos Text Project Authors — https://github.com/googlefonts/golos-text |
| Источник | https://github.com/google/fonts/tree/main/ofl/golostext |
| Лицензия | SIL Open Font License 1.1 — `art/fonts/OFL-GolosText.txt` |
| SHA-256 | `17bb58fb69aec2dfb047a2ebf52534023e9b688c97a6b7ac795b0a72912c2063` |
| Кириллица | полная (О/П/Ё/№), гуманистический рисунок, хорошо читается в 11–17 px |
| Использование | `default_font` темы `art/fonts/ui_theme.tres`; весь текст UI |
Типографическая шкала v3: **11 / 13 / 17 / 26 px** (`FS_MICRO`, `FS_BODY`,
`FS_HEAD`, `FS_LOGO`). Подписи файлов в сетке — 12/14/16 px в зависимости от
размера клетки.
### Handjet — legacy, в теме не используется
| Поле | Значение |
| --- | --- |
| Файл | `art/fonts/Handjet-VF.ttf` (variable; локальное имя для `Handjet[ELGR,ELSH,wght].ttf`) |
| Оси | `wght` 100–900, `ELGR` 1–2, `ELSH` 0–16; в теме — `wght 700, ELGR 2, ELSH 2` |
| Оси | `wght` 100–900, `ELGR` 1–2, `ELSH` 0–16 |
| Автор / проект | The Handjet Project Authors / Rosetta Type — https://github.com/google/fonts/tree/main/ofl/handjet |
| Прямая ссылка | https://raw.githubusercontent.com/google/fonts/main/ofl/handjet/Handjet%5BELGR%2CELSH%2Cwght%5D.ttf |
| Лицензия | SIL Open Font License 1.1 — `art/fonts/OFL-Handjet.txt` |
| SHA-256 | `9262749e8bb0b73ebcae0e20428689c3c59576eebeb6c4e1020300d2d41bdf4d` |
| Кириллица | полная (проверено по cmap: А–Я, а–я, Ё/ё, цифры; 1339 кодпоинтов) |
| Использование | `default_font` темы `art/fonts/ui_theme.tres`; все UI-тексты |
| Кириллица | полная (1339 кодпоинтов) |
| Использование | **нигде**: после перехода на Golos Text (D51) файл оставлен как исторический артефакт |
Handjet узкий (средняя ширина ≈0.36 em), поэтому в карточки 132 px влезают
имена файлов. Допустимые кегли: **10 / 12 / 16 / 24**.
TTF, `.import` и OFL можно удалить без влияния на игру; тема на Handjet не
ссылается. Узкий рисунок Handjet больше не нужен: Golos Text держит плотный
русский текст в карточках 400 px.
### Press Start 2P — логотип и крупные цифры
### Press Start 2P — только логотип и цифры
| Поле | Значение |
| --- | --- |
@@ -32,10 +49,10 @@ Handjet узкий (средняя ширина ≈0.36 em), поэтому в
| Лицензия | SIL Open Font License 1.1 — `art/fonts/OFL-PressStart2P.txt` |
| SHA-256 | `034c77f1f05ec89421e4a63f0e3a4ca1ecf852cc6d2bf611f126f275728e017d` |
| Кириллица | полная (656 кодпоинтов, включая Ё/ё и служебные `:/-`) |
| Использование | только логотип, заголовки и числовые строки (`theme90.gd → logo()`) |
| Использование | **только** логотип, заголовки и числовые акценты (`theme90.gd → logo()`) |
Шрифт слишком широкий для карточек (17 символов не влезают в 132 px), поэтому
основным текстом он не используется.
Шрифт слишком широкий для тела текста и карточек, поэтому основным текстом
он не используется (D51).
### Pixelify Sans — в теме не используется
@@ -54,22 +71,46 @@ Handjet узкий (средняя ширина ≈0.36 em), поэтому в
### Совместимость лицензий
Все три шрифта — **SIL OFL 1.1**. OFL разрешает встраивать шрифты в игру
(включая коммерческую), но требует сохранять текст лицензии и не продавать
шрифт отдельно. Для производных шрифтов действует Reserved Font Name
(у Press Start 2P — «Press Start 2P»). Это совместимо с MIT-лицензией кода:
Golos Text, Handjet, Press Start 2P и Pixelify Sans — **SIL OFL 1.1**.
OFL разрешает встраивать шрифты в игру (включая коммерческую), но требует
сохранять текст лицензии и не продавать шрифт отдельно. Для производных
шрифтов действует Reserved Font Name. Это совместимо с MIT-лицензией кода:
MIT не накладывает ограничений на шрифты, а OFL-тексты лежат рядом с файлами.
## Графика
| Файл | Автор | Лицензия | Примечание |
| --- | --- | --- | --- |
| UI, иконки, фаски | команда проекта | MIT | рисуются кодом в `scripts/ui/theme90.gd`; внешних текстур нет |
| `art/icons/*.png` | команда проекта | MIT | 32×32 RGBA, пиксельные иконки файлов/папки/пакета; сгенерированы `art/icons/generate_icons.py` |
| UI, панели, маскот | команда проекта | MIT | рисуются кодом в `scripts/ui/theme90.gd`; внешних UI-текстур нет |
| `icon.svg` | команда проекта | MIT | иконка проекта, формы без текста (ThorVG) |
| `docs/screenshots/*.png` | команда проекта | MIT | кадры игры 1280×720, сняты из работающей сборки |
`art/icons/` и `art/ui/` пусты (`.gitkeep`): спрайтов, тайлсетов и сторонней
графики в проекте нет.
### Пиксельные иконки файлов
`art/icons/` содержит `file_text.png`, `file_sound.png`, `file_photo.png`,
`file_video.png`, `folder.png` и `packet.png` (все 32×32 RGBA). Иконки
показываются в сетке папки, drag-preview, чипах запроса и карточках пакетов;
в `Theme90.draw_file_icon()` они рисуются с `nearest`, поэтому остаются
резкими в 16/24/32 px.
- Генератор: `art/icons/generate_icons.py` (Python 3 + Pillow), палитра
Kaleidos, контур 1 px `#2C2A26`, без сглаживания.
- Лицензия: **MIT**, сгенерировано в команде проекта в стиле пиксельного
тайл-листа; сторонних спрайтов нет.
- Исходный тайл-лист/лист иконок был недоступен, поэтому набор **не вырезан**
из готового файла, а **перегенерирован** скриптом с нуля. Это позволяет
воспроизвести все PNG одной командой и не зависит от потерянного исходника.
Пересборка:
```bash
cd peak-hour
python3 art/icons/generate_icons.py
# превью: python3 art/icons/generate_icons.py --preview /tmp/opencode/icons_preview.png
```
`art/ui/` по-прежнему пуст (`.gitkeep`): отдельных UI-спрайтов нет.
## Аудио
@@ -108,21 +149,22 @@ python3 audio/generate_sfx.py
```bash
cd peak-hour/art/fonts
curl -L -o Handjet-VF.ttf \
"https://raw.githubusercontent.com/google/fonts/main/ofl/handjet/Handjet%5BELGR%2CELSH%2Cwght%5D.ttf"
# Golos Text: скачайте GolosText[wght].ttf из google/fonts/ofl/golostext
# и сохраните локально как GolosText-VF.ttf
curl -L -o PressStart2P-Regular.ttf \
"https://raw.githubusercontent.com/google/fonts/main/ofl/pressstart2p/PressStart2P-Regular.ttf"
sha256sum Handjet-VF.ttf PressStart2P-Regular.ttf
sha256sum GolosText-VF.ttf PressStart2P-Regular.ttf
```
Ожидаемые хеши — в таблицах выше. Тексты OFL уже лежат в `art/fonts/`; при
обновлении шрифта обновите и их. Pixelify Sans скачивается по ссылке из его
раздела, но для сборки не требуется.
обновлении шрифта обновите и их. Handjet и Pixelify Sans для сборки не
требуются.
### Графика
Генерировать нечего: UI и иконки рисуются кодом, `icon.svg` — обычный SVG.
После клонирования достаточно импортировать проект в Godot 4.7.2.
Иконки пересобираются `python3 art/icons/generate_icons.py` (нужен Pillow).
`icon.svg` — обычный SVG. После клонирования достаточно импортировать проект
в Godot 4.7.2.
## Чего в проекте нет
+67 -36
View File
@@ -12,47 +12,60 @@
**Окно:** 1920×1080, логический viewport 1280×720, fractional scale.
**Забег:** 9 игровых часов по 5 минут (≈45 минут) + подготовка между часами.
## Статус v2.0
## Статус v3.0
Играбельный веб-прототип полного цикла: **меню → подготовка → час → итоги
часа → … → итоги забега**. Актуальная спецификация — [GDD v2.0](docs/GDD.md),
решения — [DECISIONS](docs/DECISIONS.md) (D29–D44).
часа → … → итоги забега**. Актуальная спецификация — [GDD v3.0](docs/GDD.md),
решения — [DECISIONS](docs/DECISIONS.md) (актуальны D45–D52, D01–D44 — история).
Реализовано:
- **9 часов**, старт **50 $**, **SLA 60/60** (кап растёт +35 за уровень
`lot_sla` до 200), 1 слот (макс. 5), канал 10 (макс. 46), буфер 6 (макс. 12),
таймер запроса 30 с (макс. 55), таймер пакета 20 с.
- **9 часов**, старт **50 $**, **SLA 60/60** (кап +40 за уровень `lot_sla`
до **220**), 1 слот (макс. 5), канал 10 (макс. 46), буфер 6 (макс. 12),
таймер запроса 30 с (макс. 65), таймер пакета 20 с.
- **4 типа файлов-полимино:** текст 1, звук 2, фото 3, видео 4 клетки;
поворот `R`/ПКМ во время drag; сетка папки 5×4 → 8×6.
поворот `R`/ПКМ во время drag; сетка папки **5×4 → 6×4 → 7×5 → 8×5 →
8×6 → 9×6** (до 54 клеток). Новая папка — **180 $**, всего до 6.
- **Стол сортировки 4×6** в подготовке; файлы со стола нельзя прикрепить,
на старте часа они автоматически возвращаются в папки (если места нет —
час не начинается).
- **Запросы на 1–4 конкретных именованных файла:** имена видны на карточке
сеткой 2×2, прикрепление проверяется по точному `file_id`, потерянный файл
помечается «ПОТЕРЯН», обязательные файлы жёстко зарезервированы. H1
стартует с seed-файла и сразу с запроса на него; анти-голодание не даёт
панели запросов простаивать.
- **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 ко времени. Файлы расходуются при успешной
отправке и сохраняются при отмене/таймауте.
подготовке. Полное дерево — **8 085 $**.
- **6 мини-игр отправки** (`dots`, `sound`, `text`, `video`, `photo`, `maze`;
**3 ступени**) и **4 мини-игры атак** (`junk`, `ddos`, `phishing`,
`ransomware`; 4 ступени); без таймеров, с шапкой «название + цель», полосой
прогресса и экраном результата: имя игры, `q%`, полоса качества, вердикт,
детали и `ПРОДОЛЖИТЬ`/`Esc`.
- **Явная отправка** `СОЕДИНИТЬСЯ`/`ОТПРАВИТЬ` (автостарта нет) и
переключатель **TCP/UDP**: TCP требует три попадания в полосе соединения,
платит **×1.15**; UDP соединяется сразу, платит **×0.85** и передаёт
**×0.8** ко времени. Файлы расходуются при успешной отправке и сохраняются
при отмене/таймауте.
- **20 атак за забег** (`0,1,1,2,2,3,3,4,4`), 4 вида, 4 ступени; атака — это
мини-игра, которая ставит час на паузу. Щит «Заземление» гасит атаку
автоматически (50 % награды, без штрафа).
автоматически (**70 %** награды, без штрафа).
- **Пошаговый туториал v3:** 5 шагов (пакет → прикрепление → TCP-соединение →
мини-игра → SLA/атаки), пульсирующие рамки у цели и кнопка «ДАЛЕЕ».
Прохождение хранится в `Game.stats["tutorial_done"]`; кнопка «ОБУЧЕНИЕ» в
главном меню запускает повтор.
- **Save VERSION 2**: позиции только в `folder.files`/`table`, метаданные в
`files`; v1-сейвы мигрируют без потери файлов и без уменьшения сеток;
офферы/рероллы/замки и стол сохраняются.
- Мета-награды после 3/5/9 часов, маскот **БАЙТ**, UI v2 «Kaleidos»,
ambient машинного зала, VHS только в главном меню.
офферы/рероллы/замки, стол и `tutorial_done` сохраняются.
- Мета-награды после 3/5/9 часов, маскот **БАЙТ**, UI v3 «Kaleidos», ambient
машинного зала, VHS только в главном меню.
Ограничения:
- Туториал — маскот и несколько подсказок, без пошагового сценария.
- Сохранение в Web идёт через IndexedDB (`user://save.json`); проверяйте
продолжение после перезагрузки вкладки.
- Средний расход магазина в симуляторе — **44,1 %** дерева (ниже старой
цели 55–65 %); это известное ограничение баланса, а не «полный выкуп».
- Средний расход магазина в симуляторе — **57,9 %** дерева 8 085 $; это
внутри целевой полосы 55–65 %, то есть не «полный выкуп».
- Remote push ещё не выполнен; сборка для itch.io готовится локально.
## Управление
@@ -65,10 +78,12 @@
| Пакет из «Приёма» → папка | drag&drop |
| Файл из папки → карточка запроса | drag&drop |
| Файл ↔ стол сортировки | drag&drop |
| Соединение TCP | кнопка `СОЕДИНИТЬСЯ`, 3 попадания в зелёную зону |
| Отправка запроса | кнопка `ОТПРАВИТЬ` |
| TCP/UDP | переключатель на карточке запроса до отправки |
| TCP/UDP | переключатель на карточке запроса до соединения |
| Мини-игра: действие | ЛКМ / `Space` / стрелки (по игре) |
| Пропустить мини-игру | `ПРОПУСТИТЬ` + подтверждение |
| `dots`: линия | нажать на «С» и вести курсор по клеткам |
| Пропустить мини-игру | `ПРОПУСТИТЬ`/`СДАТЬСЯ` + второй клик |
| Результат мини-игры | `ПРОДОЛЖИТЬ` или `Esc` |
| Магазин: закрыть | `ЗАКРЫТЬ` или `Esc` |
| Пауза | `Esc` |
@@ -118,7 +133,10 @@ python -m http.server 8000 --directory build/web
## Тесты
Полный прогон — **25 тестовых наборов + parse-check (итого 26/26 PASS)**:
Актуальный список — **25 наборов (включая `scripts/ui/ui_smoke.gd`) +
parse-check = 26/26 PASS** в `scripts/run_all_tests.sh` (см. также `tests/`;
счётчик наборов может меняться между волнами). Полный прогон перепроверен
после финальных правок кода: `PASS=26 FAIL=0 SKIP=0`.
```powershell
# Windows
@@ -149,8 +167,18 @@ Windows-раннер сам ищет Godot (сначала `GODOT_BIN`, зате
python tests/sim_balance.py
```
Фактический итог v2: **26/26 PASS** (25 наборов + parse-check), симулятор
завершается с кодом 0 и вердиктом `PASS`. Подробности — в [AI_REPORT](AI_REPORT.md).
Отчёт `tests/SIM_BALANCE_REPORT.md` (240 сидов, 9 часов, `q=0.85`, TCP): все
9 часов в ±20 % (H1 — **149 $** при цели **150**), средний итог **7 533 $**
(payout 6 495 + `recv_fee` 410 + атаки 628), расход магазина **4 681 $** =
**57,9 %** дерева 8 085 $, **28 поражений из 240**. Вердикт `PASS`.
Подробности — в [AI_REPORT](AI_REPORT.md).
Финальная проверка после всех правок: headless `PASS=26 FAIL=0 SKIP=0`
(25 наборов + parse-check), E2E на реальной сцене `Main` в 1920×1080 —
**190/190** проверок PASS, веб-сборка (Godot 4.7.2, no-threads, Chromium +
SwiftShader) загружается с WebGL2 и нулём ошибок в консоли, проходят
меню → «Новый забег» → «НАЧАТЬ ЧАС», drag пакета в папку и файла в чип
запроса, открытие модалки соединения.
## Структура
@@ -160,7 +188,7 @@ Game-JAM/
├─ export_presets.cfg # Web (no threads)
├─ scenes/ # Main + UI/мини-игры
├─ scripts/
│ ├─ main.gd # UI, ввод, модалки, drag&drop
│ ├─ main.gd # UI, ввод, модалки, drag&drop, туториал
│ ├─ game_controller.gd # цикл menu → prep → hour → results
│ ├─ run_all_tests.ps1/.sh # полный headless-прогон
│ ├─ autoload/ # Game, Data, Audio, Rng, Ambient
@@ -168,9 +196,10 @@ Game-JAM/
│ ├─ entities/ # файлы, папки, секции, пакеты, запросы, слоты
│ ├─ systems/ # Shop, AttackDirector, SaveManager
│ ├─ ui/ # Theme90, FileGrid, SortingTable, ShopModal, VHS
│ └─ minigames/ # MiniGame, factory, send/, attack/
│ └─ minigames/ # MiniGame, factory, connection_game, send/, attack/
├─ data/ # balance.json, strings_ru.json, names_ru.json
├─ art/fonts/ # Handjet, Press Start 2P + OFL
├─ art/fonts/ # Golos Text, Press Start 2P + OFL
├─ art/icons/ # пиксельные иконки + generate_icons.py
├─ audio/ # sfx/, music/, amb/, generate_*py
├─ shaders/ # vhs.gdshader (только главное меню)
├─ tests/ # headless-наборы, sim_balance.py
@@ -187,18 +216,20 @@ Game-JAM/
## Документация
- [GDD](docs/GDD.md) — правила и числа v2.0.
- [DECISIONS](docs/DECISIONS.md) — решения D01–D44 (актуальны D29–D44).
- [GDD](docs/GDD.md) — правила и числа v3.0.
- [DECISIONS](docs/DECISIONS.md) — решения D01–D52 (актуальны D45–D52).
- [AI_REPORT](AI_REPORT.md) — модели, приёмы и проверки.
- [ASSETS](ASSETS.md) — шрифты, звук, лицензии и воспроизведение.
- [UI_REFRESH](docs/UI_REFRESH.md) — UI v2 «Kaleidos».
- [ASSETS](ASSETS.md) — шрифты, иконки, звук, лицензии и воспроизведение.
- [UI_REFRESH](docs/UI_REFRESH.md) — UI v3 «Kaleidos».
- [IMPLEMENTATION](docs/IMPLEMENTATION.md) и [TEST_PLAN](docs/TEST_PLAN.md) —
исторические контракты v1 и матрица тестов.
исторические контракты v1 и матрица тестов (v3-дельты — в начале
IMPLEMENTATION).
- [ITCH_PAGE](docs/ITCH_PAGE.md) — текст страницы itch.io.
- [screenshots](docs/screenshots/) — кадры меню, подготовки, часа и атак.
## Лицензии
Код — **MIT** ([LICENSE](LICENSE)). Шрифты — **SIL OFL 1.1**
(`art/fonts/OFL-*.txt`). UI и иконка — MIT. SFX и `hum.wav` — MIT/CC0,
сгенерированы `audio/generate_sfx.py`. Подробности — в [ASSETS.md](ASSETS.md).
(`art/fonts/OFL-*.txt`). UI, пиксельные иконки (`art/icons/`) и иконка — MIT.
SFX и `hum.wav` — MIT/CC0, сгенерированы `audio/generate_sfx.py`.
Подробности — в [ASSETS.md](ASSETS.md).
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://cwms0uuyam0fk"
path="res://.godot/imported/GolosText-VF.ttf-af72b844d15035464cce46e3ecf9f3ea.fontdata"
[deps]
source_file="res://art/fonts/GolosText-VF.ttf"
dest_files=["res://.godot/imported/GolosText-VF.ttf-af72b844d15035464cce46e3ecf9f3ea.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}
+93
View File
@@ -0,0 +1,93 @@
Copyright 2019 The Golos Text Project Authors (https://github.com/googlefonts/golos-text)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+125 -127
View File
@@ -1,17 +1,15 @@
[gd_resource type="Theme" format=3]
[ext_resource type="FontFile" path="res://art/fonts/Handjet-VF.ttf" id="1_hjthv"]
[ext_resource type="FontFile" path="res://art/fonts/GolosText-VF.ttf" id="1_fum4e"]
[ext_resource type="FontFile" path="res://art/fonts/PressStart2P-Regular.ttf" id="2_fum4e"]
[sub_resource type="FontVariation" id="FontVariation_fum4e"]
base_font = ExtResource("1_hjthv")
[sub_resource type="FontVariation" id="FontVariation_lr7a5"]
base_font = ExtResource("1_fum4e")
variation_opentype = {
1162626898: 2.0,
1162629960: 2.0,
2003265652: 700.0
2003265652: 600.0
}
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lr7a5"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cg7jb"]
bg_color = Color(0.913725, 0.901961, 0.866667, 1)
border_width_left = 1
border_width_top = 1
@@ -24,7 +22,7 @@ corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
corner_detail = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cg7jb"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dc2yr"]
bg_color = Color(0, 0, 0, 0)
border_width_left = 1
border_width_top = 1
@@ -37,7 +35,7 @@ corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
corner_detail = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dc2yr"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6m2gx"]
bg_color = Color(1, 1, 1, 1)
border_width_left = 1
border_width_top = 1
@@ -53,7 +51,7 @@ shadow_color = Color(0, 0, 0, 0.094)
shadow_size = 3
shadow_offset = Vector2(0, 2)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6m2gx"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_x3s7b"]
bg_color = Color(0.968627, 0.960784, 0.933333, 1)
border_width_left = 1
border_width_top = 1
@@ -69,7 +67,7 @@ shadow_color = Color(0, 0, 0, 0.094)
shadow_size = 3
shadow_offset = Vector2(0, 2)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_x3s7b"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukyte"]
bg_color = Color(0.847059, 0.827451, 0.788235, 1)
border_width_left = 1
border_width_top = 1
@@ -82,7 +80,7 @@ corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
corner_detail = 4
[sub_resource type="Image" id="Image_ukyte"]
[sub_resource type="Image" id="Image_wjr02"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
@@ -91,10 +89,10 @@ data = {
"width": 14
}
[sub_resource type="ImageTexture" id="ImageTexture_wjr02"]
image = SubResource("Image_ukyte")
[sub_resource type="ImageTexture" id="ImageTexture_4munx"]
image = SubResource("Image_wjr02")
[sub_resource type="Image" id="Image_4munx"]
[sub_resource type="Image" id="Image_oc7r5"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
@@ -103,10 +101,10 @@ data = {
"width": 14
}
[sub_resource type="ImageTexture" id="ImageTexture_oc7r5"]
image = SubResource("Image_4munx")
[sub_resource type="ImageTexture" id="ImageTexture_y0w1v"]
image = SubResource("Image_oc7r5")
[sub_resource type="Image" id="Image_y0w1v"]
[sub_resource type="Image" id="Image_8m7xh"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
@@ -115,10 +113,10 @@ data = {
"width": 14
}
[sub_resource type="ImageTexture" id="ImageTexture_8m7xh"]
image = SubResource("Image_y0w1v")
[sub_resource type="ImageTexture" id="ImageTexture_dywbi"]
image = SubResource("Image_8m7xh")
[sub_resource type="Image" id="Image_dywbi"]
[sub_resource type="Image" id="Image_47b07"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
@@ -127,15 +125,15 @@ data = {
"width": 14
}
[sub_resource type="ImageTexture" id="ImageTexture_47b07"]
image = SubResource("Image_dywbi")
[sub_resource type="ImageTexture" id="ImageTexture_danrt"]
image = SubResource("Image_47b07")
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_danrt"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_1j4f6"]
bg_color = Color(0, 0, 0, 0)
border_color = Color(0, 0, 0, 0)
corner_detail = 4
[sub_resource type="Image" id="Image_1j4f6"]
[sub_resource type="Image" id="Image_djhhc"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
@@ -144,10 +142,10 @@ data = {
"width": 9
}
[sub_resource type="ImageTexture" id="ImageTexture_djhhc"]
image = SubResource("Image_1j4f6")
[sub_resource type="ImageTexture" id="ImageTexture_a0hrt"]
image = SubResource("Image_djhhc")
[sub_resource type="Image" id="Image_a0hrt"]
[sub_resource type="Image" id="Image_jvde3"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
@@ -156,10 +154,10 @@ data = {
"width": 9
}
[sub_resource type="ImageTexture" id="ImageTexture_jvde3"]
image = SubResource("Image_a0hrt")
[sub_resource type="ImageTexture" id="ImageTexture_372il"]
image = SubResource("Image_jvde3")
[sub_resource type="Image" id="Image_372il"]
[sub_resource type="Image" id="Image_i4ifm"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
@@ -168,10 +166,10 @@ data = {
"width": 9
}
[sub_resource type="ImageTexture" id="ImageTexture_i4ifm"]
image = SubResource("Image_372il")
[sub_resource type="ImageTexture" id="ImageTexture_11awt"]
image = SubResource("Image_i4ifm")
[sub_resource type="Image" id="Image_11awt"]
[sub_resource type="Image" id="Image_loh4e"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
@@ -180,10 +178,10 @@ data = {
"width": 9
}
[sub_resource type="ImageTexture" id="ImageTexture_loh4e"]
image = SubResource("Image_11awt")
[sub_resource type="ImageTexture" id="ImageTexture_a1mnj"]
image = SubResource("Image_loh4e")
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_a1mnj"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4uvxq"]
bg_color = Color(0.603922, 0.584314, 0.541176, 0.3)
border_color = Color(0, 0, 0, 0)
corner_radius_top_left = 2
@@ -192,7 +190,7 @@ corner_radius_bottom_right = 2
corner_radius_bottom_left = 2
corner_detail = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4uvxq"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_petse"]
bg_color = Color(0.184314, 0.498039, 0.560784, 0.55)
border_color = Color(0, 0, 0, 0)
corner_radius_top_left = 2
@@ -201,7 +199,7 @@ corner_radius_bottom_right = 2
corner_radius_bottom_left = 2
corner_detail = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_petse"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_j2rjp"]
bg_color = Color(0.847059, 0.827451, 0.788235, 1)
border_width_left = 1
border_width_top = 1
@@ -214,7 +212,7 @@ corner_radius_bottom_right = 2
corner_radius_bottom_left = 2
corner_detail = 4
[sub_resource type="Image" id="Image_j2rjp"]
[sub_resource type="Image" id="Image_7om8y"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
@@ -223,10 +221,10 @@ data = {
"width": 12
}
[sub_resource type="ImageTexture" id="ImageTexture_7om8y"]
image = SubResource("Image_j2rjp")
[sub_resource type="ImageTexture" id="ImageTexture_o55pb"]
image = SubResource("Image_7om8y")
[sub_resource type="Image" id="Image_o55pb"]
[sub_resource type="Image" id="Image_wl44i"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
@@ -235,10 +233,10 @@ data = {
"width": 12
}
[sub_resource type="ImageTexture" id="ImageTexture_wl44i"]
image = SubResource("Image_o55pb")
[sub_resource type="ImageTexture" id="ImageTexture_mni32"]
image = SubResource("Image_wl44i")
[sub_resource type="Image" id="Image_mni32"]
[sub_resource type="Image" id="Image_8vjq1"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
@@ -247,10 +245,10 @@ data = {
"width": 12
}
[sub_resource type="ImageTexture" id="ImageTexture_8vjq1"]
image = SubResource("Image_mni32")
[sub_resource type="ImageTexture" id="ImageTexture_w06qd"]
image = SubResource("Image_8vjq1")
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_w06qd"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_barbq"]
bg_color = Color(0.513726, 0.780392, 0.831373, 1)
border_color = Color(0, 0, 0, 0)
corner_radius_top_left = 2
@@ -259,7 +257,7 @@ corner_radius_bottom_right = 2
corner_radius_bottom_left = 2
corner_detail = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_barbq"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dvi42"]
bg_color = Color(0.184314, 0.498039, 0.560784, 1)
border_color = Color(0, 0, 0, 0)
corner_radius_top_left = 2
@@ -268,7 +266,7 @@ corner_radius_bottom_right = 2
corner_radius_bottom_left = 2
corner_detail = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dvi42"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_foj1q"]
bg_color = Color(1, 1, 1, 1)
border_width_left = 1
border_width_top = 1
@@ -281,7 +279,7 @@ corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
corner_detail = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_foj1q"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jyq6k"]
content_margin_left = 2.0
content_margin_top = 2.0
content_margin_right = 2.0
@@ -298,7 +296,7 @@ corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
corner_detail = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jyq6k"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cjati"]
bg_color = Color(0.968627, 0.960784, 0.933333, 1)
border_width_left = 1
border_width_top = 1
@@ -314,7 +312,7 @@ shadow_color = Color(0, 0, 0, 0.094)
shadow_size = 3
shadow_offset = Vector2(0, 2)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cjati"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_m7381"]
content_margin_left = 2.0
content_margin_top = 2.0
content_margin_right = 2.0
@@ -327,7 +325,7 @@ corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
corner_detail = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_m7381"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_of6nr"]
content_margin_left = 5.0
content_margin_top = 5.0
content_margin_right = 5.0
@@ -347,7 +345,7 @@ shadow_color = Color(0, 0, 0, 0.16)
shadow_size = 6
shadow_offset = Vector2(0, 3)
[sub_resource type="Image" id="Image_of6nr"]
[sub_resource type="Image" id="Image_s7l1c"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
@@ -356,10 +354,10 @@ data = {
"width": 9
}
[sub_resource type="ImageTexture" id="ImageTexture_s7l1c"]
image = SubResource("Image_of6nr")
[sub_resource type="ImageTexture" id="ImageTexture_7j4rs"]
image = SubResource("Image_s7l1c")
[sub_resource type="Image" id="Image_7j4rs"]
[sub_resource type="Image" id="Image_05v8i"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
@@ -368,10 +366,10 @@ data = {
"width": 9
}
[sub_resource type="ImageTexture" id="ImageTexture_05v8i"]
image = SubResource("Image_7j4rs")
[sub_resource type="ImageTexture" id="ImageTexture_7ph3p"]
image = SubResource("Image_05v8i")
[sub_resource type="Image" id="Image_7ph3p"]
[sub_resource type="Image" id="Image_2b66g"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
@@ -380,10 +378,10 @@ data = {
"width": 9
}
[sub_resource type="ImageTexture" id="ImageTexture_2b66g"]
image = SubResource("Image_7ph3p")
[sub_resource type="ImageTexture" id="ImageTexture_1tdcs"]
image = SubResource("Image_2b66g")
[sub_resource type="Image" id="Image_1tdcs"]
[sub_resource type="Image" id="Image_hv5yg"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
@@ -392,74 +390,74 @@ data = {
"width": 9
}
[sub_resource type="ImageTexture" id="ImageTexture_hv5yg"]
image = SubResource("Image_1tdcs")
[sub_resource type="ImageTexture" id="ImageTexture_yjsxn"]
image = SubResource("Image_hv5yg")
[resource]
default_font = SubResource("FontVariation_fum4e")
default_font_size = 12
default_font = SubResource("FontVariation_lr7a5")
default_font_size = 13
Button/colors/font_color = Color(0.121569, 0.117647, 0.105882, 1)
Button/colors/font_disabled_color = Color(0.603922, 0.584314, 0.541176, 1)
Button/colors/font_hover_color = Color(0.172549, 0.360784, 0.407843, 1)
Button/colors/font_pressed_color = Color(0.121569, 0.27451, 0.313726, 1)
Button/font_sizes/font_size = 12
Button/fonts/font = SubResource("FontVariation_fum4e")
Button/styles/disabled = SubResource("StyleBoxFlat_lr7a5")
Button/styles/focus = SubResource("StyleBoxFlat_cg7jb")
Button/styles/hover = SubResource("StyleBoxFlat_dc2yr")
Button/styles/normal = SubResource("StyleBoxFlat_6m2gx")
Button/styles/pressed = SubResource("StyleBoxFlat_x3s7b")
Button/font_sizes/font_size = 13
Button/fonts/font = SubResource("FontVariation_lr7a5")
Button/styles/disabled = SubResource("StyleBoxFlat_cg7jb")
Button/styles/focus = SubResource("StyleBoxFlat_dc2yr")
Button/styles/hover = SubResource("StyleBoxFlat_6m2gx")
Button/styles/normal = SubResource("StyleBoxFlat_x3s7b")
Button/styles/pressed = SubResource("StyleBoxFlat_ukyte")
CheckBox/colors/font_color = Color(0.121569, 0.117647, 0.105882, 1)
CheckBox/colors/font_disabled_color = Color(0.603922, 0.584314, 0.541176, 1)
CheckBox/colors/font_hover_color = Color(0.172549, 0.360784, 0.407843, 1)
CheckBox/colors/font_pressed_color = Color(0.121569, 0.27451, 0.313726, 1)
CheckBox/icons/checked = SubResource("ImageTexture_wjr02")
CheckBox/icons/radio_checked = SubResource("ImageTexture_oc7r5")
CheckBox/icons/radio_unchecked = SubResource("ImageTexture_8m7xh")
CheckBox/icons/unchecked = SubResource("ImageTexture_47b07")
CheckBox/styles/focus = SubResource("StyleBoxFlat_danrt")
CheckBox/styles/hover = SubResource("StyleBoxFlat_danrt")
CheckBox/styles/normal = SubResource("StyleBoxFlat_danrt")
CheckBox/styles/pressed = SubResource("StyleBoxFlat_danrt")
HScrollBar/icons/decrement = SubResource("ImageTexture_djhhc")
HScrollBar/icons/decrement_highlight = SubResource("ImageTexture_jvde3")
HScrollBar/icons/increment = SubResource("ImageTexture_i4ifm")
HScrollBar/icons/increment_highlight = SubResource("ImageTexture_loh4e")
HScrollBar/styles/grabber = SubResource("StyleBoxFlat_a1mnj")
HScrollBar/styles/grabber_highlight = SubResource("StyleBoxFlat_4uvxq")
HScrollBar/styles/grabber_pressed = SubResource("StyleBoxFlat_4uvxq")
HScrollBar/styles/scroll = SubResource("StyleBoxFlat_petse")
HScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_petse")
CheckBox/icons/checked = SubResource("ImageTexture_4munx")
CheckBox/icons/radio_checked = SubResource("ImageTexture_y0w1v")
CheckBox/icons/radio_unchecked = SubResource("ImageTexture_dywbi")
CheckBox/icons/unchecked = SubResource("ImageTexture_danrt")
CheckBox/styles/focus = SubResource("StyleBoxFlat_1j4f6")
CheckBox/styles/hover = SubResource("StyleBoxFlat_1j4f6")
CheckBox/styles/normal = SubResource("StyleBoxFlat_1j4f6")
CheckBox/styles/pressed = SubResource("StyleBoxFlat_1j4f6")
HScrollBar/icons/decrement = SubResource("ImageTexture_a0hrt")
HScrollBar/icons/decrement_highlight = SubResource("ImageTexture_372il")
HScrollBar/icons/increment = SubResource("ImageTexture_11awt")
HScrollBar/icons/increment_highlight = SubResource("ImageTexture_a1mnj")
HScrollBar/styles/grabber = SubResource("StyleBoxFlat_4uvxq")
HScrollBar/styles/grabber_highlight = SubResource("StyleBoxFlat_petse")
HScrollBar/styles/grabber_pressed = SubResource("StyleBoxFlat_petse")
HScrollBar/styles/scroll = SubResource("StyleBoxFlat_j2rjp")
HScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_j2rjp")
HSlider/constants/grabber_offset = 0
HSlider/icons/grabber = SubResource("ImageTexture_7om8y")
HSlider/icons/grabber_disabled = SubResource("ImageTexture_wl44i")
HSlider/icons/grabber_highlight = SubResource("ImageTexture_8vjq1")
HSlider/styles/grabber_area = SubResource("StyleBoxFlat_w06qd")
HSlider/styles/grabber_area_highlight = SubResource("StyleBoxFlat_barbq")
HSlider/styles/slider = SubResource("StyleBoxFlat_petse")
HSlider/icons/grabber = SubResource("ImageTexture_o55pb")
HSlider/icons/grabber_disabled = SubResource("ImageTexture_mni32")
HSlider/icons/grabber_highlight = SubResource("ImageTexture_w06qd")
HSlider/styles/grabber_area = SubResource("StyleBoxFlat_barbq")
HSlider/styles/grabber_area_highlight = SubResource("StyleBoxFlat_dvi42")
HSlider/styles/slider = SubResource("StyleBoxFlat_j2rjp")
HeadLabel/base_type = &"Label"
HeadLabel/font_sizes/font_size = 16
HeadLabel/fonts/font = SubResource("FontVariation_fum4e")
HeadLabel/font_sizes/font_size = 17
HeadLabel/fonts/font = SubResource("FontVariation_lr7a5")
Label/colors/font_color = Color(0.121569, 0.117647, 0.105882, 1)
Label/font_sizes/font_size = 12
Label/fonts/font = SubResource("FontVariation_fum4e")
Label/font_sizes/font_size = 13
Label/fonts/font = SubResource("FontVariation_lr7a5")
LineEdit/colors/font_color = Color(0.121569, 0.117647, 0.105882, 1)
LineEdit/colors/font_placeholder_color = Color(0.603922, 0.584314, 0.541176, 1)
LineEdit/font_sizes/font_size = 12
LineEdit/fonts/font = SubResource("FontVariation_fum4e")
LineEdit/styles/focus = SubResource("StyleBoxFlat_dvi42")
LineEdit/styles/normal = SubResource("StyleBoxFlat_foj1q")
LineEdit/font_sizes/font_size = 13
LineEdit/fonts/font = SubResource("FontVariation_lr7a5")
LineEdit/styles/focus = SubResource("StyleBoxFlat_foj1q")
LineEdit/styles/normal = SubResource("StyleBoxFlat_jyq6k")
LogoLabel/base_type = &"Label"
LogoLabel/colors/font_color = Color(0.172549, 0.360784, 0.407843, 1)
LogoLabel/font_sizes/font_size = 24
LogoLabel/font_sizes/font_size = 26
LogoLabel/fonts/font = ExtResource("2_fum4e")
MicroLabel/base_type = &"Label"
MicroLabel/colors/font_color = Color(0.172549, 0.164706, 0.14902, 1)
MicroLabel/font_sizes/font_size = 10
MicroLabel/font_sizes/font_size = 11
NumLabel/base_type = &"Label"
NumLabel/font_sizes/font_size = 12
NumLabel/font_sizes/font_size = 13
NumLabel/fonts/font = ExtResource("2_fum4e")
Panel/styles/panel = SubResource("StyleBoxFlat_jyq6k")
Panel/styles/panel = SubResource("StyleBoxFlat_cjati")
PopupMenu/colors/font_accelerator_color = Color(0.603922, 0.584314, 0.541176, 1)
PopupMenu/colors/font_color = Color(0.121569, 0.117647, 0.105882, 1)
PopupMenu/colors/font_disabled_color = Color(0.603922, 0.584314, 0.541176, 1)
@@ -471,20 +469,20 @@ PopupMenu/constants/item_end_padding = 12
PopupMenu/constants/item_start_padding = 8
PopupMenu/constants/separator_outline_size = 0
PopupMenu/constants/v_separation = 3
PopupMenu/font_sizes/font_size = 12
PopupMenu/fonts/font = SubResource("FontVariation_fum4e")
PopupMenu/styles/hover = SubResource("StyleBoxFlat_cjati")
PopupMenu/styles/panel = SubResource("StyleBoxFlat_m7381")
PopupMenu/font_sizes/font_size = 13
PopupMenu/fonts/font = SubResource("FontVariation_lr7a5")
PopupMenu/styles/hover = SubResource("StyleBoxFlat_m7381")
PopupMenu/styles/panel = SubResource("StyleBoxFlat_of6nr")
TooltipLabel/colors/font_color = Color(0.121569, 0.117647, 0.105882, 1)
TooltipLabel/font_sizes/font_size = 10
TooltipLabel/fonts/font = SubResource("FontVariation_fum4e")
TooltipPanel/styles/panel = SubResource("StyleBoxFlat_m7381")
VScrollBar/icons/decrement = SubResource("ImageTexture_s7l1c")
VScrollBar/icons/decrement_highlight = SubResource("ImageTexture_05v8i")
VScrollBar/icons/increment = SubResource("ImageTexture_2b66g")
VScrollBar/icons/increment_highlight = SubResource("ImageTexture_hv5yg")
VScrollBar/styles/grabber = SubResource("StyleBoxFlat_a1mnj")
VScrollBar/styles/grabber_highlight = SubResource("StyleBoxFlat_4uvxq")
VScrollBar/styles/grabber_pressed = SubResource("StyleBoxFlat_4uvxq")
VScrollBar/styles/scroll = SubResource("StyleBoxFlat_petse")
VScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_petse")
TooltipLabel/font_sizes/font_size = 11
TooltipLabel/fonts/font = SubResource("FontVariation_lr7a5")
TooltipPanel/styles/panel = SubResource("StyleBoxFlat_of6nr")
VScrollBar/icons/decrement = SubResource("ImageTexture_7j4rs")
VScrollBar/icons/decrement_highlight = SubResource("ImageTexture_7ph3p")
VScrollBar/icons/increment = SubResource("ImageTexture_1tdcs")
VScrollBar/icons/increment_highlight = SubResource("ImageTexture_yjsxn")
VScrollBar/styles/grabber = SubResource("StyleBoxFlat_4uvxq")
VScrollBar/styles/grabber_highlight = SubResource("StyleBoxFlat_petse")
VScrollBar/styles/grabber_pressed = SubResource("StyleBoxFlat_petse")
VScrollBar/styles/scroll = SubResource("StyleBoxFlat_j2rjp")
VScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_j2rjp")
Binary file not shown.

After

Width:  |  Height:  |  Size: 294 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://djb7w10tao8ns"
path="res://.godot/imported/file_photo.png-b23698027d48c718a6fc500f302db87b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/file_photo.png"
dest_files=["res://.godot/imported/file_photo.png-b23698027d48c718a6fc500f302db87b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dw5oie5dxfky4"
path="res://.godot/imported/file_sound.png-ef9804b30db6fa708a74022e1489cfcd.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/file_sound.png"
dest_files=["res://.godot/imported/file_sound.png-ef9804b30db6fa708a74022e1489cfcd.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 213 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://gtofenp8bd1n"
path="res://.godot/imported/file_text.png-e2cb12a8276ae34819d095735a1e4828.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/file_text.png"
dest_files=["res://.godot/imported/file_text.png-e2cb12a8276ae34819d095735a1e4828.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 239 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c8yfny20jcs15"
path="res://.godot/imported/file_video.png-ffcbd0629f5e28094e97a5a051839521.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/file_video.png"
dest_files=["res://.godot/imported/file_video.png-ffcbd0629f5e28094e97a5a051839521.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cb7ge0n5gg8mu"
path="res://.godot/imported/folder.png-2d34cb1a6aa9dbae664638a5df628136.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/folder.png"
dest_files=["res://.godot/imported/folder.png-2d34cb1a6aa9dbae664638a5df628136.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""Генерирует пиксельные иконки файлов для PEAK HOUR (32×32 RGBA PNG).
Стиль: плоские заливки из палитры Kaleidos, контур 1 px #2C2A26,
без сглаживания (nearest). Иконки читаются в размерах 16/24/32 px.
Запуск:
python3 art/icons/generate_icons.py
python3 art/icons/generate_icons.py --preview /tmp/opencode/icons_preview.png
"""
from __future__ import annotations
import argparse
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
SIZE = 32
# --- палитра Kaleidos ------------------------------------------------------
OUTLINE = (44, 42, 38, 255) # #2C2A26
NAVY = (44, 92, 104, 255) # #2C5C68
TEAL = (47, 127, 143, 255) # #2F7F8F
TEAL_LIGHT = (131, 199, 212, 255) # #83C7D4
TEAL_PALE = (230, 245, 247, 255) # #E6F5F7
WARN = (197, 138, 46, 255) # #C58A2E
SUCCESS = (63, 143, 94, 255) # #3F8F5E
ERROR = (194, 91, 74, 255) # #C25B4A
PAPER = (247, 245, 238, 255) # #F7F5EE
PAPER_LO = (216, 211, 201, 255) # #D8D3C9
WHITE = (255, 255, 255, 255)
def canvas() -> Image.Image:
return Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
def shape(d: ImageDraw.ImageDraw, pts: list[tuple[int, int]], fill, outline=OUTLINE) -> None:
"""Заливка многоугольника + контур 1 px по его периметру."""
d.polygon(pts, fill=fill)
if outline is not None:
d.line(pts + [pts[0]], fill=outline, width=1, joint="curve")
def box(d: ImageDraw.ImageDraw, xy: tuple[int, int, int, int], fill, outline=OUTLINE) -> None:
"""
Прямоугольник с координатами включительно (как у ImageDraw.rectangle).
"""
d.rectangle(xy, fill=fill)
if outline is not None:
d.rectangle(xy, outline=outline)
def file_text() -> Image.Image:
img = canvas()
d = ImageDraw.Draw(img)
# страница с отрезанным верхним правым углом
page = [(7, 4), (19, 4), (25, 10), (25, 27), (7, 27)]
shape(d, page, PAPER)
# загнутый уголок
shape(d, [(19, 4), (25, 10), (19, 10)], PAPER_LO)
# три строки текста
d.rectangle((10, 13, 22, 14), fill=NAVY)
d.rectangle((10, 17, 22, 18), fill=NAVY)
d.rectangle((10, 21, 18, 22), fill=NAVY)
return img
def file_sound() -> Image.Image:
img = canvas()
d = ImageDraw.Draw(img)
# янтарный бейдж со срезанными углами
badge = [(6, 3), (26, 3), (29, 6), (29, 26), (26, 29), (6, 29), (3, 26), (3, 6)]
shape(d, badge, WARN)
# восьмая нота: головка, штиль, флажок
head = [(9, 21), (11, 19), (15, 19), (17, 21), (17, 24), (15, 26), (11, 26), (9, 24)]
shape(d, head, OUTLINE)
d.rectangle((16, 8, 17, 21), fill=OUTLINE)
shape(d, [(17, 8), (23, 11), (23, 14), (20, 14), (20, 13), (17, 12)], OUTLINE)
return img
def file_photo() -> Image.Image:
img = canvas()
d = ImageDraw.Draw(img)
# тёмная рамка
box(d, (3, 6, 28, 26), NAVY)
# светлое поле кадра
box(d, (6, 9, 25, 23), TEAL_PALE)
# дальняя и ближняя горы
shape(d, [(6, 23), (13, 12), (19, 23)], TEAL_LIGHT, outline=None)
shape(d, [(11, 23), (18, 14), (25, 23)], SUCCESS, outline=None)
# солнце
d.rectangle((20, 11, 22, 13), fill=WARN)
return img
def file_video() -> Image.Image:
img = canvas()
d = ImageDraw.Draw(img)
# тело плёнки
box(d, (3, 8, 28, 24), NAVY)
# перфорация сверху и снизу
for x in (5, 9, 21, 25):
d.rectangle((x, 10, x + 1, 11), fill=PAPER)
d.rectangle((x, 21, x + 1, 22), fill=PAPER)
# play
shape(d, [(13, 11), (22, 16), (13, 21)], PAPER)
return img
def folder() -> Image.Image:
img = canvas()
d = ImageDraw.Draw(img)
body = [(3, 27), (3, 9), (13, 9), (16, 12), (29, 12), (29, 27)]
shape(d, body, WARN)
# светлая кромка передней стенки
d.line([(5, 25), (27, 25)], fill=(226, 175, 91, 255), width=1)
return img
def packet() -> Image.Image:
img = canvas()
d = ImageDraw.Draw(img)
# конверт
box(d, (3, 10, 28, 25), PAPER)
# клапан
shape(d, [(4, 11), (16, 19), (27, 11)], TEAL_PALE, outline=None)
d.line([(4, 11), (16, 19)], fill=NAVY, width=1)
d.line([(16, 19), (27, 11)], fill=NAVY, width=1)
# нижние сгибы
d.line([(4, 24), (12, 18)], fill=PAPER_LO, width=1)
d.line([(27, 24), (19, 18)], fill=PAPER_LO, width=1)
# входящий пакет: teal-бейдж со стрелкой вниз
shape(d, [(21, 19), (29, 19), (29, 27), (21, 27)], TEAL)
d.rectangle((24, 21, 25, 23), fill=WHITE)
d.polygon([(22, 23), (27, 23), (24, 26)], fill=WHITE)
return img
ICONS = {
"file_text": file_text,
"file_sound": file_sound,
"file_photo": file_photo,
"file_video": file_video,
"folder": folder,
"packet": packet,
}
# Проверка: только полностью прозрачные или полностью непрозрачные пиксели.
# Это гарантирует отсутствие сглаживания по краям.
def assert_hard_edges(img: Image.Image, name: str) -> None:
alpha = img.getchannel("A")
values = set(alpha.getdata())
assert values <= {0, 255}, f"{name}: найден полупрозрачный пиксель {values}"
def build_preview(path: Path) -> None:
"""Контактный лист: все иконки в 16/24/32 px + увеличение 32 px ×4."""
scale = 4
cell_w = 200
cell_h = 236
cols = 3
sheet = Image.new("RGBA", (cols * cell_w, 2 * cell_h), (233, 230, 221, 255))
d = ImageDraw.Draw(sheet)
try:
font = ImageFont.truetype("/usr/share/fonts/dejavu/DejaVuSans.ttf", 15)
small = ImageFont.truetype("/usr/share/fonts/dejavu/DejaVuSans.ttf", 11)
except OSError: # pragma: no cover - depends on system fonts
font = ImageFont.load_default()
small = font
for index, (name, factory) in enumerate(ICONS.items()):
col = index % cols
row = index // cols
x0 = col * cell_w
y0 = row * cell_h
d.rectangle((x0 + 4, y0 + 4, x0 + cell_w - 6, y0 + cell_h - 6),
fill=(247, 245, 238, 255), outline=(154, 149, 137, 255))
d.text((x0 + 12, y0 + 10), name, fill=(31, 30, 27, 255), font=font)
icon = factory()
# три реальных размера в ряд, каждый на белой «карточке»
cx = x0 + 12
for px in (16, 24, 32):
d.rectangle((cx - 2, y0 + 44, cx + px + 1, y0 + 47 + px),
fill=(255, 255, 255, 255), outline=(216, 211, 201, 255))
sheet.alpha_composite(icon.resize((px, px), Image.Resampling.NEAREST),
(cx, y0 + 46))
d.text((cx, y0 + 52 + px), f"{px}", fill=(90, 86, 78, 255), font=small)
cx += 42
# увеличение 32 px ×4 — чтобы видеть пиксели и контур
big = icon.resize((SIZE * scale, SIZE * scale), Image.Resampling.NEAREST)
sheet.alpha_composite(big, (x0 + 34, y0 + 92))
path.parent.mkdir(parents=True, exist_ok=True)
sheet.save(path)
print(f"preview saved: {path}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--preview", type=Path, default=None,
help="куда сохранить контактный лист (PNG)")
args = parser.parse_args()
out_dir = Path(__file__).resolve().parent
for name, factory in ICONS.items():
img = factory()
assert_hard_edges(img, name)
target = out_dir / f"{name}.png"
img.save(target)
print(f"saved {target.relative_to(out_dir.parent.parent)} {img.size}")
if args.preview is not None:
build_preview(args.preview)
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 370 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dj6scq4olnonj"
path="res://.godot/imported/packet.png-2a847fa0ce4ca153b815c85ae5cddeab.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/packet.png"
dest_files=["res://.godot/imported/packet.png-2a847fa0ce4ca153b815c85ae5cddeab.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+60 -74
View File
@@ -11,8 +11,8 @@
"money_start": 50,
"sla_start": 60,
"sla_base": 60,
"sla_per_level": 35,
"sla_hard_cap": 200,
"sla_per_level": 40,
"sla_hard_cap": 220,
"sla_regen_per_hour": 15,
"slots_start": 1,
"slots_max": 5,
@@ -23,14 +23,14 @@
"buffer_per_level": 1,
"buffer_max": 12,
"request_timer": 30,
"request_timer_per_level": 5,
"request_timer_max": 55,
"request_timer_per_level": 7,
"request_timer_max": 65,
"packet_timer": 20,
"folder_cols": 5,
"folder_rows": 4,
"folders_start": 2,
"folders_max": 6,
"folder_new_cost": 100,
"folder_new_cost": 180,
"max_sections": 1,
"section_start": "C",
"folders_per_section": 12,
@@ -40,7 +40,7 @@
"required_name_char_limit": 12,
"file_cooldown_after_send_sec": 0,
"consume_files_on_send": true,
"required_per_request": { "min": 1, "max": 3 },
"required_per_request": { "min": 1, "max": 4 },
"spawn_window_ratio": 0.75,
"spawn_jitter": 0.2,
"buffer_per_upgrade": 1
@@ -60,17 +60,17 @@
"speed_mult": 1.0
},
"formulas": {
"max_sla": "clamp(60 + 35 * sla_level, 60, 200)",
"max_sla": "clamp(60 + 40 * sla_level, 60, 220)",
"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)",
"request_timer": "min(65, 30 + 7 * timer_level)",
"packet_timer": "20 (не покупается)",
"speed": "pow(0.92, speed_level)",
"speed": "pow(0.90, 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)",
"folder_dims": "[[5,4],[6,4],[7,5],[8,5],[8,6],[9,6]][folder_level]",
"transfer_time": "base_time_sec * pow(0.90, speed_level) * max(1.0, load / capacity)",
"protocol_time": "UDP ×0.8, TCP ×1.0 (только отправка)"
}
},
@@ -82,7 +82,7 @@
"size_kb": 64,
"cells": 1,
"base_time_sec": 3,
"base_pay": 20,
"base_pay": 18,
"recv_fee": 1,
"weight": 1,
"minigame_coeff": 1,
@@ -96,7 +96,7 @@
"size_kb": 3072,
"cells": 2,
"base_time_sec": 5,
"base_pay": 34,
"base_pay": 30,
"recv_fee": 2,
"weight": 2,
"minigame_coeff": 2,
@@ -110,7 +110,7 @@
"size_kb": 5120,
"cells": 3,
"base_time_sec": 8,
"base_pay": 52,
"base_pay": 44,
"recv_fee": 4,
"weight": 4,
"minigame_coeff": 4,
@@ -124,7 +124,7 @@
"size_kb": 20480,
"cells": 4,
"base_time_sec": 12,
"base_pay": 80,
"base_pay": 69,
"recv_fee": 6,
"weight": 8,
"minigame_coeff": 8,
@@ -135,27 +135,27 @@
"hours": [
{
"hour": 1,
"files": 4,
"request_cap": 2,
"active_requests": 1,
"files": 7,
"request_cap": 6,
"active_requests": 2,
"attacks": 0,
"income_target": 65,
"income_target": 150,
"recv_interval": 12,
"send_interval": 22,
"send_interval": 12,
"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 }
"request_mix": { "1": 0.76, "2": 0.24, "3": 0.0, "4": 0.0 }
},
{
"hour": 2,
"files": 8,
"request_cap": 5,
"request_cap": 6,
"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 }
"request_mix": { "1": 0.63, "2": 0.32, "3": 0.05, "4": 0.0 }
},
{
"hour": 3,
@@ -167,7 +167,7 @@
"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 }
"request_mix": { "1": 0.53, "2": 0.37, "3": 0.10, "4": 0.0 }
},
{
"hour": 4,
@@ -179,7 +179,7 @@
"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 }
"request_mix": { "1": 0.50, "2": 0.36, "3": 0.12, "4": 0.02 }
},
{
"hour": 5,
@@ -191,7 +191,7 @@
"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 }
"request_mix": { "1": 0.42, "2": 0.38, "3": 0.16, "4": 0.04 }
},
{
"hour": 6,
@@ -203,7 +203,7 @@
"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 }
"request_mix": { "1": 0.40, "2": 0.38, "3": 0.17, "4": 0.05 }
},
{
"hour": 7,
@@ -215,7 +215,7 @@
"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 }
"request_mix": { "1": 0.32, "2": 0.40, "3": 0.21, "4": 0.07 }
},
{
"hour": 8,
@@ -227,19 +227,19 @@
"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 }
"request_mix": { "1": 0.30, "2": 0.39, "3": 0.22, "4": 0.09 }
},
{
"hour": 9,
"files": 20,
"request_cap": 8,
"request_cap": 7,
"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 }
"request_mix": { "1": 0.27, "2": 0.38, "3": 0.23, "4": 0.12 }
}
],
"payout": {
@@ -248,7 +248,7 @@
"quality_base": 0.5,
"quality_bonus": 0.5,
"multi_step": 0.15,
"protocol_mult": { "TCP": 1.15, "UDP": 1.0 },
"protocol_mult": { "TCP": 1.15, "UDP": 0.85 },
"udp_time_mult": 0.8,
"streak_step": 0.02,
"streak_cap": 10,
@@ -284,7 +284,7 @@
"view_h": 470,
"folder_cols": 5,
"folder_rows": 4,
"folder_dims": [[5, 4], [6, 4], [6, 5], [7, 5], [7, 6], [8, 6]],
"folder_dims": [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 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)",
@@ -341,40 +341,40 @@
"effect": "connection_slots",
"value": 1,
"max_level": 4,
"prices": [120, 300, 650, 1200],
"prices": [90, 200, 400, 700],
"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% за уровень (умножается).",
"desc_ru": "Скорость приёма и отправки +10% за уровень (умножается).",
"effect": "transfer_speed",
"value": 0.08,
"value": 0.10,
"max_level": 6,
"prices": [90, 130, 180, 250, 340, 450],
"prices": [55, 75, 100, 135, 180, 245],
"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.",
"desc_ru": "Держит узел живым: кап SLA +40 и мгновенное восстановление +40.",
"effect": "sla_cap",
"value": 35,
"value": 40,
"max_level": 4,
"prices": [120, 260, 480, 800],
"prices": [80, 160, 270, 430],
"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% награды, без штрафа.",
"desc_ru": "Автоматически гасит атаку: 70% награды, без штрафа.",
"effect": "shield_charge",
"value": 1,
"max_level": 3,
"prices": [150, 400, 800],
"prices": [100, 220, 410],
"level_names": ["Заземление", "Громоотвод", "Защитный контур"],
"weight_by_hour": [0.3, 0.3, 0.3, 0.8, 0.8, 0.8, 1.0, 1.0, 1.0]
},
@@ -385,18 +385,18 @@
"effect": "money_mult",
"value": 0.05,
"max_level": 8,
"prices": [80, 110, 150, 200, 260, 340, 440, 560],
"prices": [70, 95, 130, 170, 220, 285, 365, 465],
"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 клетки на первом уровне.",
"desc_ru": "Больше места под фигуры: 20 → 24 клетки на первом уровне (до 54).",
"effect": "folder_cells",
"value": 1,
"max_level": 5,
"prices": [80, 180, 360, 650, 1000],
"prices": [50, 100, 180, 290, 440],
"level_names": ["Стеллаж", "Секция стеллажа", "Складская линия", "Автоматизированный склад", "Ангар"],
"weight_by_hour": [1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.8, 0.8, 0.8]
},
@@ -408,36 +408,37 @@
"value": 6,
"buffer_value": 1,
"max_level": 6,
"prices": [70, 100, 140, 190, 250, 330],
"prices": [45, 70, 95, 130, 175, 230],
"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 с.",
"desc_ru": "Больше времени на поиск файлов и прикрепление: 30 → 37 с (до 65).",
"effect": "request_timer",
"value": 5,
"value": 7,
"max_level": 5,
"prices": [70, 110, 160, 230, 320],
"prices": [45, 75, 115, 165, 230],
"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 ступени достижимы."
"order": ["dots", "sound", "text", "video", "photo", "maze"],
"achievable_sums": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 24, 25, 26, 28, 32],
"max_index": 26,
"tier_max_rank": [7, 13],
"rank_games": ["text", "sound", "dots", "photo", "maze", "dots", "photo", "video", "dots", "text", "video", "maze", "photo", "sound", "video", "text", "maze", "sound", "video", "sound", "photo", "dots", "dots", "maze", "maze", "photo", "video"],
"formula": "S=sum(coeff по 1-4 файлам); rank=index of S in achievable_sums (или ближайшей меньшей); tier=1 при rank<=7, 2 при 8<=rank<=13, иначе 3; id=rank_games[rank]; S=0 → dots/T1",
"note": "6 игр отправки (wires удалена), 3 ступени, ровно 27 достижимых сумм коэффициентов для 1-4 файлов. Таблица rank_games задаёт игру по rank напрямую (без modulo)."
},
"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 }
@@ -447,27 +448,15 @@
"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" }
@@ -477,7 +466,6 @@
"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 }
@@ -487,7 +475,6 @@
"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 }
@@ -497,7 +484,6 @@
"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 }
@@ -549,7 +535,7 @@
"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,
"shield_reward_scale": 0.7,
"money_scale": 1.0,
"kinds": {
"junk": {
@@ -615,7 +601,7 @@
"spawn_speedup_after_end": 0.0,
"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)"
"timer_grace": "v2: таймеры не масштабируются по часу (packet 20, request 30+7*level, cap 65)"
},
"progression": {
"unlocks": {
@@ -675,11 +661,11 @@
"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"
"formula": "load = sum(weight(active_transfers)); factor = max(1.0, load / capacity); actual_time = base_time_sec * pow(0.90, speed_level) * factor"
},
"minigame_selection": {
"coeff": { "text": 1, "sound": 2, "photo": 4, "video": 8 },
"formula": "см. minigames.selection.formula (плотный список достижимых сумм, B4)",
"formula": "см. minigames.selection.formula (27 достижимых сумм для 1-4 файлов, 3 ступени, rank_games)",
"quality_stub": 1.0
}
}
-1
View File
@@ -147,7 +147,6 @@
"weak": "СЛАБО",
"dots": "Одна линия",
"sound": "Уровни",
"wires": "Провода",
"text": "Чужое слово",
"video": "Раскадровка",
"photo": "Кадр",
+230 -47
View File
@@ -1,22 +1,26 @@
# DECISIONS — решения v2.0
# DECISIONS — решения v3.0
> Каждое решение: ID, что решили, что меняется в GDD. Дедлайн
> 26.10.2026 23:59 (Minsk). Источник правил — `docs/GDD.md` (v2.0).
> 26.10.2026 23:59 (Minsk). Источник правил — `docs/GDD.md` (v3.0).
>
> **v2.0 (25.10.2026):** актуальны решения **D29–D44**. Решения D01–D28
> сохранены как история; конфликтующие с v2.0 помечены пометкой
> «**Статус v2.0: устарело**» с указанием замены. Ничего не удалено.
> **v3.0 (26.10.2026):** актуальны решения **D45–D52**. Решения **D01–D44**
> сохранены как история; конфликтующие с v3.0 помечены пометкой
> «**Статус v3.0: история**» или «**заменено**» с указанием замены. Ничего не
> удалено.
>
> **Сдвиг ID:** диапазон D26–D28 уже занят решениями v1.2 (аудит, VHS,
> ambient), поэтому 16 новых решений v2.0 идут **D29–D44** — это эквивалент
> запрошенного диапазона D26–D41 со сдвигом +3.
> **Сдвиг ID:** диапазон D26–D28 занят решениями v1.2 (аудит, VHS, ambient),
> D29–D44 — решения v2.0, поэтому восемь новых решений v3.0 идут **D45–D52**.
>
> **Статус решений 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 остаются
> в силе.
> **Где смотреть актуальные числа:** GDD v3.0 и `data/balance.json`; этот файл
> хранит историю формулировок. Прежние суммы дерева, кап SLA, число файлов,
> состав игр отправки и множитель скорости в телах D01–D44 — **история
> v1/v2**, а не текущая спецификация. Замены: D45–D52.
>
> **Статус решений v1.x в v3.0 (кратко):** D01→D46, D02→D33/D46,
> D03→D34/D38, D04→D45, D05→D29/D46, D06→D46, D07→D38/D39, D08→D38,
> D09→D46, D10→D34/D45, D12→D29/D40/D50, D13→D50/D37, D15→D51, D16→D43,
> D17→D34/D49, D19→D29/D50, D20→D29/D50, D21→D29, D22→D33/D45, D23→D29/D38,
> D25→D43. D11, D14, D18, D24 (кроме магазина), D27, D28 остаются в силе.
## D01 — карточка запроса
**Решение:** в карточке запроса показывать имя требуемого файла
@@ -57,6 +61,9 @@
**GDD:** §11.
**Статус v2.0: устарело.** Полоска соединения удалена. TCP/UDP — переключатель
оплаты/скорости: TCP ×1.15 к выплате, UDP ×0.8 ко времени (D33, GDD §11).
**Статус v3.0: формула снова актуальна.** TCP-соединение вернулось (D45):
та же формула зоны, скорости 440/340/280, 3 попадания; промах −4 SLA / 8 с,
Esc — 0 SLA / 4 с. UDP соединения не требует.
## D05 — один источник истины
**Решение:** буфер = `4 + 2×buffer_upgrades` (max 8); зона — только
@@ -109,6 +116,9 @@
**Статус v2.0: обновлено.** Добавлены `table` и `sending`; `connected` —
legacy и всегда `true` (полоски нет). Резервации приёма живут в папке, а не
в запросе (D31/D32, GDD §17).
**Статус v3.0: обновлено.** `connected` снова рабочий флаг TCP; добавлены
`connecting`, `connect_attempts`, `connect_cooldown_until`, `required_by`
(D45/D46, GDD §17).
## D10 — поведение Esc
**Решение:** в час — пауза; в мини-игре — no-op; в панели соединения —
@@ -118,6 +128,8 @@ legacy и всегда `true` (полоски нет). Резервации пр
**Статус v2.0: частично устарело.** Панели соединения нет. В мини-игре
отправки `Esc` во время игры — no-op; в мини-игре атаки — двухшаговое
подтверждение `СДАТЬСЯ`; на экране результата — `ПРОДОЛЖИТЬ` (D34, GDD §18).
**Статус v3.0: обновлено.** TCP-панель вернулась: `Esc` отменяет соединение
без штрафа SLA, с кулдауном 4 с (D45, GDD §11.1, §18).
## D11 — устойчивость к лагам
**Решение:** `delta = min(delta, 0.1)`; авто-пауза при
@@ -135,8 +147,8 @@ legacy и всегда `true` (полоски нет). Резервации пр
**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).
награды атак. «Полный выкуп к H8–H9» не подтверждён (56,7–57,9 % дерева в v3).
Штраф `−(10+2×hour)` сохранён (D29/D40/D50, GDD §12–13).
## D13 — магазин
**Решение:** 9 позиций: 3-й слот 250, 4-й слот 450, буфер +2 — 200×2,
@@ -146,7 +158,8 @@ legacy и всегда `true` (полоски нет). Резервации пр
**GDD:** §14.
**v1.2:** добавлена 10-я позиция «SLA +24» (300, max 3); открытия — D23.
**Статус v2.0: устарело.** 8 лотов, 3 оффера, рероллы и замки; зоны/секции/
поиска в магазине нет. Полное дерево — 13 100 $ (D36/D37, GDD §14).
поиска в магазине нет. Полное дерево — **8 085 $** в v3 (D50); прежние
числа v2 — история.
## D14 — фон
**Решение:** шейдер фона — статичный градиент + слабый шум 320×180,
@@ -165,6 +178,8 @@ Press Start 2P остаётся только для крупных заголо
Допустимые кегли: **10 / 12 / 16 / 24** (Handjet узкий, поэтому 10
разрешён).
**GDD:** §21.
**Статус v3.0: заменено D51.** Основной шрифт — **Golos Text**, шкала
**11/13/17/26**; Press Start 2P — только логотип и цифры; Handjet — legacy.
## D16 — порядок P0
**Решение:** порядок P0: данные/реестр → слоты → приём/отправка → UI.
@@ -205,9 +220,9 @@ 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).
**Статус v2.0: устарело.** Старт SLA 60/60; лота «SLA +24» нет, кап в v3
растёт на +40 за уровень `lot_sla` до 220. Потеря пакета по типу сохранена
(D29/D40/D50, GDD §12).
## D20 — таймеры и grace
@@ -330,14 +345,16 @@ fallback `SLA +24` неактуальны: соединения и лота SLA+
## D29 — стартовые статы и формулы
**Решение:** забег начинается с **50 $** и **SLA 60/60**. Кап SLA —
`60 + 35×sla_level`, жёсткий максимум **200**. Слоты — `1 + slot_level`
`60 + 40×sla_level`, жёсткий максимум **220**. Слоты — `1 + slot_level`
(1…5), канал — `10 + 6×channel_level` (10…46, он же даёт буфер
`6 + уровень`, 6…12), таймер запроса — `30 + 5×timer_level` (30…55),
`6 + уровень`, 6…12), таймер запроса — `30 + 7×timer_level` (30…65),
таймер пакета — фиксированные **20 с**. Сетка папки —
`[5×4, 6×4, 6×5, 7×5, 7×6, 8×6]`. На старте 2 папки, максимум 6, новая —
**100 $**. В начале часа SLA +15 и заряды щита восстанавливаются.
`[5×4, 6×4, 7×5, 8×5, 8×6, 9×6]`. На старте 2 папки, максимум 6, новая —
**180 $**. В начале часа SLA +15 и заряды щита восстанавливаются.
**GDD:** §4, §6, §8, §9, §12, §14.
**Заменяет:** числа D05, D12, D19, D20, D21.
**Статус v3.0: обновлено.** Числа приведены к v3 (D50): SLA-кап 220,
таймер +7 с до 65, сетки 5×4 → 9×6, новая папка 180 $.
## D30 — сетки папок и файлы-полимино
@@ -363,7 +380,7 @@ fallback `SLA +24` неактуальны: соединения и лота SLA+
## D32 — запросы и прикрепление
**Решение:** запрос требует 1–3 файла (слоты контейнера), конкретные id не
**Решение:** запрос требует до трёх файлов (слоты контейнера), конкретные id не
зашиты — игрок сам выбирает файлы. Файл прикреплён только к одному запросу;
со стола не прикрепляется. При `locked`/`failed`/`malicious` файл
открепляется, контейнер нужно собрать снова. Таймер запроса замирает в его
@@ -371,23 +388,28 @@ fallback `SLA +24` неактуальны: соединения и лота SLA+
`ОТКЛОНИТЬ` — −5 $/−5 SLA.
**GDD:** §7, §8, §17.
**Заменяет:** D01, D06; обновляет D09.
**Статус v3.0: заменено D46.** Запрос снова требует конкретные `file_id`,
но их **1–4**; имена видны на карточке, есть «ПОТЕРЯН» и жёсткая резервация.
## D33 — отправка, расход файлов и TCP/UDP
**Решение:** отправка — только по явной кнопке `ОТПРАВИТЬ`; прикрепление и
закрытие мини-игры автостарт не запускают. TCP/UDP переключаются до старта:
TCP — выплата ×1.15, время ×1.0; UDP — время ×0.8, выплата ×1.0. После
TCP — выплата ×1.15, время ×1.0; UDP — время ×0.8, выплата **×0.85**. После
мини-игры запрос занимает out-слот или встаёт в очередь «ОЖИДАНИЕ СЛОТА»
(таймер заморожен), пока слот не освободится. При успешной отправке файлы
**расходуются**; при отмене/таймауте/конце часа сохраняются. Полоски
соединения нет; `connected` — legacy.
**расходуются**; при отмене/таймауте/конце часа сохраняются. Полоска
соединения в v2 была удалена; в v3 TCP-соединение вернулось (D45), а
`connected` снова рабочий флаг.
**GDD:** §7, §11, §17.
**Заменяет:** D02 (расход), D04, D22.
**Статус v3.0: заменено D45.** TCP снова требует полоску соединения; UDP
шлёт сразу. Выплата UDP — **×0.85** (не ×1.0), TCP — ×1.15, время UDP ×0.8.
## D34 — мини-игры: состав и контракт
**Решение:** 11 игр — 7 отправки (`dots`, `sound`, `wires`, `text`, `video`,
`photo`, `maze`) и 4 атаки (`junk`, `ddos`, `phishing`, `ransomware`). Общий
**Решение:** 11 игр — семь отправки (включая удалённую в v3 игру с проводами)
и 4 атаки (`junk`, `ddos`, `phishing`, `ransomware`). Общий
контракт: без таймеров, конечная цель, локальный seeded RNG, `q ∈ 0…1`,
экран результата `q%`/вердикт и `ПРОДОЛЖИТЬ`; `finished` эмитится только
после `ПРОДОЛЖИТЬ` в модальном режиме (награда не применяется раньше).
@@ -399,18 +421,25 @@ TCP — выплата ×1.15, время ×1.0; UDP — время ×0.8, вы
**Заменяет:** D17 (заглушка q), D08 (теллы).
**Примечание:** строка `minigame.surrender` (`СДАТЬСЯ`) используется
мини-играми атак; у мини-игр отправки кнопка — `ПРОПУСТИТЬ`.
**Статус v3.0: заменено D47/D48.** Сейчас **10** игр: 6 отправки + 4 атаки;
игры с соединением проводов нет; у отправки 3 ступени. Общий контракт
(без таймеров, seeded RNG, отложенный `finished`) сохранён и дополнен шапкой
«название + цель», полосой прогресса и двухкликовым отказом (GDD §10).
## D35 — выбор мини-игры отправки (B4)
**Решение:** коэффициенты `text 1 / sound 2 / photo 4 / video 8`;
`S = sum(coeff)` по 1–3 файлам. `rank` — позиция S в плотном списке
`S = sum(coeff)` по трём файлам максимум. `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 отменён).
floor(rank×4/19), 1, 4)`, `id = order[rank % 7]`, где order — семь игр v2
(включая удалённую в v3 игру с проводами). Суммы 15/19/21/22/23 недостижимы,
поэтому 28-элементный массив не перечисляется. Все 7 игр и 4 ступени были
достижимы; `S=24 → video/T4` (старый пример S−1 отменён).
**GDD:** §8.4, §10.
**Заменяет:** D17 (средний коэффициент/пороги).
**Статус v3.0: история.** Актуальная формула — **27 достижимых сумм для 1–4
файлов**, 3 ступени и фиксированная таблица `rank_games` (D49, GDD §8.4).
Старые «19 сумм / 4 ступени / rank % 7» больше не действуют.
## D36 — магазин: 8 лотов и 3 оффера
@@ -418,9 +447,12 @@ wires, text, video, photo, maze`. Суммы 15/19/21/22/23 недостижим
`lot_money`, `lot_folder`, `lot_channel`, `lot_timer`), ровно **3**
предложения в подготовке. Магазин — только в подготовке с H2, в H1 и во
время часа недоступен. Максимальные лоты не предлагаются. Сумма всех
уровней — **13 100 $**. Крестика нет: `ЗАКРЫТЬ`/`Esc`.
уровней v2 — историческая; в v3 дерево стоит **8 085 $** (D50). Крестика нет:
`ЗАКРЫТЬ`/`Esc`.
**GDD:** §3, §14.
**Заменяет:** D13, D19 (10 позиций).
**Статус v3.0: история в числах.** 8 лотов и 3 оффера сохранены, но цены,
эффекты и сумма дерева заменены D50 (**8 085 $**).
## D37 — рероллы, замки, веса и сохранение офферов
@@ -456,16 +488,21 @@ DDoS — канал −40 % (factor ×1.4) на 15 с; фишинг — случ
папка заблокирована на 15 с. SLA/деньги — из `attacks.kinds.*.tiers`.
**GDD:** §9, §12, §12.1.
**Заменяет:** D07 (таблица урона).
**Статус v3.0: история в числах.** Щит теперь даёт **70 %** награды (D50);
остальные эффекты провала сохранены.
## D40 — SLA, реген и поражение
**Решение:** старт 60/60; в начале часа +15 (не выше капа), кап растёт на
+35 за `lot_sla` до 200. Потеря пакета — 2/4/8/16 по типу; таймаут запроса
**Решение:** старт 60/60; в начале часа +15 (не выше капа), кап рос на
+35 за `lot_sla` до исторического максимума v2 (в v3 — +40 до 220, D50).
Потеря пакета — 2/4/8/16 по типу; таймаут запроса
и провал запросов в конце часа — `−(10+2×hour)`; отклонение — −5/−5;
уничтожение папки — −5 SLA. Пассивных +0.5/+1 нет. **SLA ≤ 0 во время часа
= поражение**; «конца забега по складу» нет.
**GDD:** §12, §16.
**Заменяет:** D12 (SLA-прирост), D19 (старт), D02 (склад).
**Статус v3.0: история в числах.** Кап SLA — **220** (+40 за уровень), не 200;
см. D50.
## D41 — save v2 и миграция
@@ -481,15 +518,16 @@ DDoS — канал −40 % (factor ×1.4) на 15 с; фишинг — случ
## 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 частично
остаётся неистраченным, забег заканчивается. Это принято как честное
ограничение текущей политики покупок, а не как «полный выкуп».
**Решение:** цели дохода H1–H9 — **150/220/380/560/760/980/1200/1450/
1700 $**. `request_cap`, параметры симулятора и расход магазина в v2 были
зафиксированы отдельно; в v3 они заменены (D50, GDD §13). В прогоне v2 все
часы укладывались в ±20 %, но расход магазина оставался ниже целевой полосы
55–65 % — это было зафиксировано честно, а не задекларировано как «полный
выкуп».
**GDD:** §13, §14.
**Статус v3.0: история.** В отчёте v3 — **7 354 $**, **56,7 %** дерева
**8 085 $**, **32/240** поражений; после H1-правки свежий прогон — **7 533 $**,
**57,9 %**, **28/240**; см. D50 и `tests/SIM_BALANCE_REPORT.md`.
## D43 — проверки, запуск и Web
@@ -515,8 +553,153 @@ DDoS — канал −40 % (factor ×1.4) на 15 с; фишинг — случ
- лимит папок **6**, новая папка **100 $** (review H5);
- любой не прикреплённый файл можно уничтожить в подготовке бесплатно —
защита от soft-lock «нет денег/нет места» (review M16);
- `wires` T4: `box 34`, `pitch 40`, верх `56`, чтобы низ был ≤320 (review M11);
- удалённая в v3 игра с проводами: T4 имел `box 34`, `pitch 40`, верх `56`,
чтобы низ был ≤320 (review M11); в v3 неактуально;
- деньги атак масштабируются по часу `max(0.75, hour/3)` (review M8).
**GDD:** §26.
**Не является обещанием:** это не баги «на потом», а принятые рамки
джем-прототипа; менять только вместе с балансом и тестами.
**Статус v3.0: история.** Пункт про геометрию игры с проводами неактуален:
игра удалена (D47). Лимит папок теперь 6 при цене **180 $**, магазин усилен
(D50). Остальные упрощения (q=0 у пропуска, snapshot `speed`, M16, M8)
сохранены; актуальный список — GDD §26.
---
# Решения v3.0 (D45–D52)
## D45 — TCP-соединение возвращено
**Решение:** TCP перед мини-игрой отправки снова требует мини-игру соединения
`scripts/minigames/connection_game.gd`: три палочки по очереди, скорости
**440/340/280 px/с**, три попадания в зелёную зону. UDP соединения не требует
и открывает мини-игру отправки сразу.
```
W = clamp(0.30 − 0.005×(hour−1) − 0.005×successes + 0.05×timer_level,
0.18, 0.36)
```
- Промах/уход за край: **−4 SLA**, кулдаун повтора **8 с**, панель
закрывается (автоповтора нет).
- `Esc`: **0 SLA**, кулдаун **4 с**, запрос остаётся.
- Таймер запроса на время соединения заморожен; час идёт.
- Успех сразу открывает мини-игру отправки, увеличивает `connect_successes`
и **блокирует протокол** до конца запроса.
- Счётчик успехов сбрасывается на новом забеге (`reset_carry()`).
- Экономика: TCP — выплата **×1.15**, время ×1.0; UDP — выплата **×0.85**,
время **×0.8**.
**GDD:** §11, §11.1, §11.2, §17, §18.
**Заменяет:** D04 (снова актуальна), D33 (часть про отсутствие соединения).
## D46 — запросы на конкретные файлы, 1–4
**Решение:** запрос хранит **конкретные `file_id`** в `required`; число
файлов — **1–4** по `hours[].request_mix`. При создании выбираются реальные
файлы из пула: свободные, не прикреплённые и не затребованные другим
запросом. На карточке имена обязательных файлов видны сеткой 2×2.
- Прикрепление принимается только для `required`-файла этого запроса; чужой
файл отклоняется с подсказкой.
- **Жёсткая резервация:** обязательный файл нельзя уничтожить, унести на стол
или прикрепить к другому запросу (`required_by`).
- Пропавший файл (`failed`/`malicious`/удалён) показывается как **«ПОТЕРЯН»**;
директор пытается подставить свободный файл того же типа, иначе отправка
невозможна.
- Таймер — **30 + 7×timer_level**, максимум **65 с**; замирает в мини-игре,
соединении, очереди out-слота и передаче.
- `request_cap` — **6/6/5/6/6/7/7/7/7**; `request_mix` H1–H9 — в GDD §13.
- H1-затравка: seed-файл `text` + сразу запрос на него; первый пакет в `t=0`.
Анти-голодание создаёт запрос, если активных нет и есть свободный файл
(минимальный зазор 3 с).
**GDD:** §3, §7, §13, §17.
**Заменяет:** D32; обновляет D01, D06, D09.
## D47 — удаление игры с соединением проводов
**Решение:** из набора отправки удалена send-игра, где нужно было соединять
провода с гнёздами. Осталось **6 игр отправки**: `dots`, `sound`, `text`,
`video`, `photo`, `maze`. Файла игры и записи в `balance.json` больше нет;
`MiniGameFactory` не пропускает старый id наружу. Упоминания в `dev_capture`
и тестах остались только как негативные проверки «этот id не выбирается».
**GDD:** §10, §24.
**Заменяет:** D34 (состав 7 игр отправки).
## D48 — сдвиг ступеней отправки
**Решение:** у мини-игр отправки **3 ступени**, у атак — **4**. Старая
ступень T1 отправки исчезла: прежние T2/T3/T4 стали T1/T2/T3 в
`balance.json → minigames.games.*.tiers`. Параметры игр перенесены без
смены смысла: T1 = старый T2, T2 = старый T3, T3 = старый T4.
**GDD:** §10, §24.
**Заменяет:** D34/D35 в части «4 ступени отправки».
## D49 — коэффициенты и таблица выбора мини-игры
**Решение:** коэффициенты — `text 1`, `sound 2`, `photo 4`, `video 8`.
`S = sum(coeff)` по **1–4** файлам. Достижимо ровно **27 сумм**:
`[1..22, 24, 25, 26, 28, 32]`.
```
rank = позиция S в ACHIEVABLE_SUMS (или ближайшая меньшая)
tier = T1 при rank 0–7, T2 при 8–13, T3 при 14–26
id = RANK_GAMES[rank]
```
`RANK_GAMES` — фиксированная таблица на 27 позиций (`economy.gd`), без
modulo. **Каждая из 6 игр достижима в каждой ступени.** `S=0` → `dots`/T1.
H1 — всегда T1; H2 — почти всегда T1 (T2 только у редкого запроса из трёх
фото, S12); H9 даёт ≈**33/22/46 %** запросов T1/T2/T3.
**GDD:** §8.4.
**Заменяет:** D35.
## D50 — усиление магазина v3
**Решение:** 8 лотов, полное дерево — **8 085 $**. Новые цены и эффекты:
| Лот | Эффект | Цены |
| --- | --- | --- |
| `lot_slots` | +1 слот | 90 / 200 / 400 / 700 |
| `lot_speed` | время передачи ×0.90 за уровень | 55 / 75 / 100 / 135 / 180 / 245 |
| `lot_sla` | кап +40 и мгновенно +40 SLA (до 220) | 80 / 160 / 270 / 430 |
| `lot_shield` | +1 заряд щита, 70 % награды | 100 / 220 / 410 |
| `lot_money` | выплата +5 % за уровень | 70 / 95 / 130 / 170 / 220 / 285 / 365 / 465 |
| `lot_folder` | сетка папки до 9×6 | 50 / 100 / 180 / 290 / 440 |
| `lot_channel` | канал +6, буфер +1 | 45 / 70 / 95 / 130 / 175 / 230 |
| `lot_timer` | таймер запроса +7 с (до 65) | 45 / 75 / 115 / 165 / 230 |
Сетки: `[5,4] → [6,4] → [7,5] → [8,5] → [8,6] → [9,6]`. Новая папка —
**180 $**; `sla_hard_cap` — **220**. Базовые выплаты типов: 18/30/44/69.
Симулятор v3: в отчёте **7 354 $**, расход **56,7 %** дерева, **32/240**
поражений; после H1-правки — **7 533 $**, **57,9 %**, **28/240**.
**GDD:** §5, §12, §13, §14.
**Заменяет:** числа D29, D36, D39, D40, D42.
## D51 — Golos Text и пиксельные иконки
**Решение:** тело интерфейса набрано **Golos Text** (`art/fonts/GolosText-VF.ttf`,
SIL OFL 1.1, полная кириллица); **Press Start 2P** — только логотип и
цифровые акценты. Шкала — **11/13/17/26 px**; подписи файлов — 12/14/16 px по
клетке. Handjet и Pixelify Sans в теме не используются и помечены как legacy.
Пиксельные иконки файлов — `art/icons/*.png` (32×32, nearest), генерируются
`art/icons/generate_icons.py` (Pillow) в палитре Kaleidos. Лицензия — MIT,
команда проекта. Исходный тайл-лист был недоступен, поэтому набор
**перегенерирован** скриптом, а не вырезан из готового файла.
**GDD:** §1–2, §20, §21.
**Заменяет:** D15.
## D52 — перестройка туториала
**Решение:** вместо ленты подсказок — **5 событийных шагов**:
`packet` → `attach` → `connect` → `send` → `sla`. Каждый шаг ждёт своё
действие (аварийные таймауты 30/40/45/40/12 с), вокруг цели рисуется
пульсирующая рамка, кнопка **«ДАЛЕЕ»** пропускает шаг.
- Завершение пишется в `Game.stats["tutorial_done"]` и сохраняется в сейве.
- Автозапуск — только в **первом часу первого забега**, если обучение ещё не
пройдено.
- Кнопка **«ОБУЧЕНИЕ»** в главном меню сбрасывает флаг и запускает повтор:
в новом забеге — с подготовки, в идущем — с текущего часа.
**GDD:** §18.1.
**Заменяет:** старый «таймерный» туториал v2.
+316 -184
View File
@@ -1,37 +1,49 @@
# PEAK HOUR — GDD (v2.0)
# PEAK HOUR — GDD (v3.0)
> **Статус:** актуальная спецификация **v2.0** (25.10.2026). Источник чисел —
> `data/balance.json` (version 2.0) и код `scripts/core`, `scripts/systems`.
> Актуальные решения — **D29–D44** в `docs/DECISIONS.md`; D01–D28 сохранены
> **Статус:** актуальная спецификация **v3.0** (26.10.2026). Источник чисел —
> `data/balance.json` (version 2.0, разделы v3) и код `scripts/core`,
> `scripts/systems`, `scripts/minigames`.
> Актуальные решения — **D45–D52** в `docs/DECISIONS.md`; D01–D44 сохранены
> как история и в спорных местах помечены как устаревшие.
> Godot **4.7.2** → **Web (HTML5, no threads)**, RU-only, окно **1920×1080**,
> логический viewport **1280×720** (`canvas_items/keep/fractional`),
> 4 человека, джем FEIS, дедлайн 26.10.2026 23:59 Minsk.
## v2.0 — что изменилось
## v3.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–4 именованных файла**
(не анонимные слоты), показывает их имена на карточке сеткой 2×2, проверяет
прикрепление по точному `file_id`, пишет «ПОТЕРЯН» для пропавшего файла и
жёстко резервирует обязательные файлы. Новая таблица `request_mix` H1–H9 и
`request_cap`; H1-затравка и анти-голодание.
- **TCP-соединение вернулось.** TCP перед мини-игрой отправки требует пройти
полоску соединения (3 попадания), UDP — нет. Есть промах/отмена с разными
штрафами и кулдаунами, формула зоны, три скорости палочек и блокировка
протокола после соединения.
- **6 игр отправки, 3 ступени.** Игра с соединением проводов удалена. Старая
ступень T1 исчезла: прежние T2/T3/T4 стали T1/T2/T3. У атак по-прежнему
4 ступени. Выбор игры — по сумме коэффициентов и фиксированной таблице
`rank_games` на 27 достижимых сумм.
- **Магазин усилен.** 8 лотов, полное дерево **8 085 $**, новые цены и более
сильные эффекты: SLA до **220**, ускорение `×0.90` за уровень, щит с **70 %**
награды, папки до **9×6**, новая папка — **180 $**.
- **UI v3.** Основной шрифт — **Golos Text**, Press Start 2P — только
логотип/цифры; кегли **11/13/17/26**. Пиксельные иконки файлов из
`art/icons/`. Карточка запроса **400×128** с сеткой обязательных файлов,
подсказкой полного имени и прогнозом мини-игры.
- **Туториал перестроен.** 5 пошаговых шагов, пульсирующие рамки вокруг цели,
кнопка «ДАЛЕЕ», сохранение `Game.stats["tutorial_done"]`, автозапуск только
в первом часу первого забега и повтор из главного меню.
- **Полировка.** Убрана подпись занятости диска, добавлено пустое состояние
панели запросов с подсказкой, полоса слотов переименована в «СЛОТЫ»,
подсказки атак перенесены внутрь окна, исправлено голодание запросов в H1.
## v2.0 — история
В v2 экономика была перезапущена, появились файлы-полимино, 8 лотов магазина,
атаки-мини-игры и save v2. Конкретные числа v2 заменены в v3 и сохранены
только в `docs/DECISIONS.md` как история решений D29–D44. Актуальные значения
ниже — из `data/balance.json` и кода v3.
## 1–2. Обзор и платформа
@@ -41,9 +53,11 @@
Ограничения: окно 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).
блокирующего IO). Основной шрифт — **Golos Text** (OFL 1.1, полная
кириллица), Press Start 2P — только логотип и числовые акценты.
UI v3 «Kaleidos»: тёплая бумага, 1 px контуры, мягкие тени и бирюзовый
акцент (D18, D51). Игровой UI без CRT; VHS-слой — только в главном меню
(D27).
## 3. Структура смены
@@ -55,7 +69,7 @@ menu → prep(H1) → hour(H1) → hour result → prep(H2) → … → hour(H9)
```
- **Подготовка:** таймеров нет. Доступны папки, стол сортировки 4×6, новая
папка (100 $, максимум 6) и магазин (только с H2).
папка (**180 $**, максимум 6) и магазин (только с H2).
- **Час:** 300 с; спавн файлов идёт в первые **75 %** часа (225 с), атаки —
в окно **60–240 с**. В конце часа активные запросы проваливаются по
таймауту (штраф `−(10 + 2×hour)`), файлы при этом сохраняются; пакеты
@@ -64,8 +78,12 @@ menu → prep(H1) → hour(H1) → hour result → prep(H2) → … → hour(H9)
- **Старт часа:** SLA **+15** (не выше капа), заряды щита восстанавливаются,
файлы со стола автоматически возвращаются в папки. Если хотя бы одному
файлу не хватило места, час **не начинается** — игрок освобождает место.
- **H1-затравка:** в первой папке создаётся один seed-файл `text`, сразу же
появляется запрос ровно на него, первый пакет приходит в `t = 0`. Пока
запросов нет и есть свободный файл, анти-голодание создаёт запрос, не ждя
полный `send_interval`.
- **Победа:** отработаны все 9 часов. **Поражение:** SLA ≤ 0 во время часа.
Отдельного «конца забега по складу» в v2 нет.
Отдельного «конца забега по складу» нет.
## 4. Файлы и типы
@@ -73,29 +91,29 @@ menu → prep(H1) → hour(H1) → hour result → prep(H2) → … → hour(H9)
| Тип | Клетки | Формы | `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 |
| `text` (текст) | 1 | 1 | 3 | 18 | 1 | 1 | 2 |
| `sound` (звук) | 2 | 2 | 5 | 30 | 2 | 2 | 4 |
| `photo` (фото) | 3 | 6 | 8 | 44 | 4 | 4 | 8 |
| `video` (видео) | 4 | 17 | 12 | 69 | 6 | 8 | 16 |
- Имена уникальны в пределах забега, генерируются из `data/names_ru.json`.
- `weight` входит в нагрузку канала; `sla_loss` — штраф за потерю пакета.
- Пакет в приёме показывает тип/расширение и фигуру; зловредных пакетов и
«файлов-ловушек» в v2 нет — атаки вынесены в мини-игры (§12.1).
- Качество отправки больше не заглушка: его даёт мини-игра (§10).
«файлов-ловушек» нет — атаки вынесены в мини-игры (§12.1).
- Качество отправки даёт мини-игра (§10).
## 5. Папки, сетка и секции
- Одна секция **C**. На старте **2 папки** (`Разное`, `Входящие`), игрок может
создать новую за **100 $**, всего до **6** папок (review H5). Имена
уникальны внутри секции (`Имя`, `Имя 2`).
создать новую за **180 $**, всего до **6** папок. Имена уникальны внутри
секции (`Имя`, `Имя 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 |
| Сетка | 5×4 | 6×4 | 7×5 | 8×5 | 8×6 | 9×6 |
| Клеток | 20 | 24 | 35 | 40 | 48 | 54 |
- Файл занимает несколько клеток по форме; пересечения запрещены. При
перемещении доступен точный drop или auto-fit (first-fit по строкам, затем
@@ -106,7 +124,8 @@ menu → prep(H1) → hour(H1) → hour result → prep(H2) → … → hour(H9)
- Файл в `attached`/`locked`/`failed`/`malicious` нельзя перемещать.
- «Уничтожить содержимое» папки удаляет все её файлы после подтверждения и
снимает **5 SLA**; пустая папка — без штрафа. Обычный не прикреплённый файл
можно уничтожить в подготовке без штрафа (review M16).
можно уничтожить в подготовке без штрафа (review M16), но файл, закреплённый
за запросом, удалить нельзя.
- Загрузка сейва не уменьшает сетку: при нехватке места папка растёт до
ближайшего размера из таблицы, иначе файл уходит на стол или в `registry`
без потери.
@@ -120,42 +139,60 @@ menu → prep(H1) → hour(H1) → hour result → prep(H2) → … → hour(H9)
прошлого часа, но не больше **20** файлов за час. Интервал спавна —
`225 с / бюджет` с джиттером ±20 %.
- Пакет ждёт в буфере **20 с** (фиксировано, не покупается). После переноса в
папку начинается загрузка: `base_time_sec × 0.92^speed_level × channel_factor`.
папку начинается загрузка: `base_time_sec × 0.90^speed_level × channel_factor`.
Пока идёт загрузка, таймер пакета не тикает.
- Папка резервирует клетки фигуры **во время приёма**; отмена/таймаут
освобождает резервацию. Это исключает двойную выдачу места.
## 7. Запросы
- Запрос требует **1–3 файла** (веса — `hours[].request_mix`). В v2 `required`
— это слоты контейнера, а не заранее выбранные id: игрок сам решает, какие
файлы положить.
- Запрос требует **1–4 конкретных файла**. Число файлов разыгрывается по
`hours[].request_mix`; при создании запрос выбирает реальные файлы из пула
(свободные, не прикреплённые, не затребованные другим запросом) и хранит их
`file_id` в `required`.
- На карточке показаны имена обязательных файлов (сетка 2×2). Прикрепление
принимается только для файла из `required` этого запроса; чужой файл
отклоняется с подсказкой «Нужен файл: …».
- **Жёсткая резервация:** обязательный файл нельзя уничтожить, унести на стол
или прикрепить к другому запросу. Пока запрос жив, файл закреплён за ним
(`required_by`).
- Если обязательный файл исчез (`failed`/`malicious`/удалён), карточка пишет
**«ПОТЕРЯН»**. Директор пытается подставить свободный файл того же типа; если
замены нет, запрос остаётся с дыркой — его можно отклонить, но отправить
нельзя.
- Файл может быть прикреплён только к одному запросу. Прикрепить файл со
стола нельзя — сначала верните его в папку.
- Если файл стал `locked`/`failed`/`malicious`, он автоматически открепляется
от запроса; контейнер снова нужно собрать.
- Таймер запроса: **30 + 5×timer_level**, максимум **55 с**. Он замирает,
пока открыта мини-игра этого запроса, пока запрос ждёт out-слот и пока идёт
передача. Время часа при этом продолжается.
- Таймер запроса: **30 + 7×timer_level**, максимум **65 с**. Он замирает,
пока открыта мини-игра этого запроса, пока идёт соединение TCP, пока запрос
ждёт out-слот и пока идёт передача. Время часа при этом продолжается.
- `ОТКЛОНИТЬ` — штраф **−5 $ и −5 SLA**, запрос удаляется, файлы остаются.
- Максимум запросов и одновременно активных запросов — из `hours[]`
(`request_cap` / `active_requests`), см. §13.
(`request_cap` / `active_requests`), см. §13. `request_cap` в v3 —
**6/6/5/6/6/7/7/7/7** (H1 поднят до 6 вместе с H1-правкой).
- Анти-голодание: если активных запросов нет, а подходящий файл есть,
директор создаёт запрос, не дожидаясь полного интервала (минимальный зазор
3 с). Это исправляет простой H1.
- В окне отправки видны 4 карточки, дальше — скролл; высота карточки 56 px.
Пустой список показывает плашку «ЖДЁМ ЗАПРОС» и подсказку
`next_request_hint()`: «Следующий запрос ~N с», «Все запросы часа выданы»
или «Нет свободных файлов для запроса».
## 8. Таймеры и фазы
| Таймер | Значение | Примечание |
| --- | --- | --- |
| Пакет | 20 с | фиксировано; загрузка считается отдельно |
| Запрос | 30 + 5×уровень, макс. 55 с | без per-hour grace |
| Запрос | 30 + 7×уровень, макс. 65 с | без per-hour grace |
| Час | 300 с | 9 часов на забег |
| Мини-игра | нет | конечная цель, не время |
| Соединение TCP | нет | таймер запроса заморожен |
| Атака | 60–240 с от начала часа | окно спавна |
- `delta = min(delta, 0.1)`, `Engine.max_fps = 60`, авто-пауза при потере
фокуса окна (D11).
- **Мини-игра отправки** замораживает спавн пакетов, загрузку, спавн запросов
и таймер своего запроса; час и таймеры других запросов продолжают идти.
- **Мини-игра соединения TCP** тоже замораживает таймер своего запроса.
- **Мини-игра атаки** ставит на паузу весь час (все таймеры), пауза
выставляется до показа окна.
- Модалки подготовки (магазин, пауза, настройки) не тикают час, потому что
@@ -164,43 +201,41 @@ menu → prep(H1) → hour(H1) → hour result → prep(H2) → … → hour(H9)
### 8.4. Мини-игры запросов: выбор
Коэффициенты типов: `text 1`, `sound 2`, `photo 4`, `video 8`.
`S = sum(coeff)` по фактически прикреплённым файлам (1–3 файла). Выбор — по
**плотному списку достижимых сумм**, а не по `S−1`:
`S = sum(coeff)` по **1–4** фактически прикреплённым (или обязательным)
файлам. Достижимо ровно **27 сумм**:
```
ACHIEVABLE_SUMS = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,16,17,18,20,24]
ACHIEVABLE_SUMS = [1..22, 24, 25, 26, 28, 32]
rank = индекс S в списке (или ближайшей меньшей достижимой)
tier = clamp(1 + floor(rank * 4 / 19), 1, 4)
id = [dots, sound, wires, text, video, photo, maze][rank % 7]
tier = T1 при rank 0–7, T2 при 8–13, T3 при 14–26
id = RANK_GAMES[rank]
```
Суммы 15/19/21/22/23 недостижимы при максимум 3 файлах, поэтому старый
28-элементный массив не перечисляется буквально. Это фикс **B4**: в
отображаемой таблице нет «дыр» и недостижимых строк, все 7 игр и все 4
ступени достижимы. Пример: `S=24 → rank 18 → video / T4` (старый пример
`S−1` из спецификации отменён).
`RANK_GAMES` — фиксированная таблица на 27 позиций (`scripts/core/economy.gd`),
без modulo:
| 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 |
| rank | S | tier | игра | rank | S | tier | игра |
| ---: | ---: | ---: | --- | ---: | ---: | ---: | --- |
| 0 | 1 | T1 | text | 14 | 15 | T3 | video |
| 1 | 2 | T1 | sound | 15 | 16 | T3 | text |
| 2 | 3 | T1 | dots | 16 | 17 | T3 | maze |
| 3 | 4 | T1 | photo | 17 | 18 | T3 | sound |
| 4 | 5 | T1 | maze | 18 | 19 | T3 | video |
| 5 | 6 | T1 | dots | 19 | 20 | T3 | sound |
| 6 | 7 | T1 | photo | 20 | 21 | T3 | photo |
| 7 | 8 | T1 | video | 21 | 22 | T3 | dots |
| 8 | 9 | T2 | dots | 22 | 24 | T3 | dots |
| 9 | 10 | T2 | text | 23 | 25 | T3 | maze |
| 10 | 11 | T2 | video | 24 | 26 | T3 | maze |
| 11 | 12 | T2 | maze | 25 | 28 | T3 | photo |
| 12 | 13 | T2 | photo | 26 | 32 | T3 | video |
| 13 | 14 | T2 | sound | | | | |
- **Каждая из 6 игр достижима в каждой из 3 ступеней.**
- H1 — всегда T1 (в пуле только text/sound, максимум два звука = S4).
H2 — практически всегда T1: T2 возможен только у редкого запроса из трёх
фото (S12). H9 даёт примерно **33 / 22 / 46 %** запросов T1 / T2 / T3.
- `S = 0` (нет файлов) → `dots` / T1.
## 9. Канал
@@ -212,7 +247,7 @@ id = [dots, sound, wires, text, video, photo, maze][rank % 7]
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
time = base_time_sec × 0.90^speed_level × factor
```
- `lot_channel` даёт **+6 канала и +1 буфера** за уровень, максимум 6.
@@ -221,80 +256,112 @@ time = base_time_sec × 0.92^speed_level × factor
## 10. Мини-игры
Всего **11** игр: 7 отправки и 4 атаки. Общие правила:
Всего **10** игр: **6 отправки** и **4 атаки**. Общие правила:
- **Нет таймеров** и поражения по времени; игра заканчивается действием
игрока или кнопкой отказа.
- Локальный seeded RNG: сид выдаёт `HourDirector`/`AttackDirector`, состояние
не зависит от глобального `randi()`.
- Экран результата: **`q%` + вердикт** (`ЧИСТО` ≥75 %, `НОРМАЛЬНО` ≥45 %,
`СЛАБО` <45 %) и кнопка **`ПРОДОЛЖИТЬ`**. Сигнал `finished` эмитится
только после `ПРОДОЛЖИТЬ` (в модальном режиме), поэтому награда/штраф не
применяются, пока игрок не закрыл результат.
- Кнопка отказа с подтверждением: в мини-играх отправки — `ПРОПУСТИТЬ`,
в мини-играх атак — `СДАТЬСЯ`; она даёт `q = 0`.
- `Esc` во время игры отправки — no-op; в игре атаки — `СДАТЬСЯ`
с двухшаговым подтверждением. На экране результата `Esc` = `ПРОДОЛЖИТЬ`.
- Шапка мини-игры — две строки: название и цель. В шапке же полоса прогресса;
цель дополнительно продублирована строкой с акцентной чертой. Отказ —
`ПРОПУСТИТЬ` (отправка) или `СДАТЬСЯ` (атака) — требует **двух кликов**:
первый взводит кнопку, второй подтверждает.
- Экран результата: название игры, крупный `q%`, полоса качества, вердикт
(`ЧИСТО` ≥75 %, `НОРМАЛЬНО` ≥45 %, `СЛАБО` <45 %), детальная строка и
кнопка **`ПРОДОЛЖИТЬ`**. `Esc` на результате = продолжить. Сигнал
`finished` эмитится только после `ПРОДОЛЖИТЬ` (в модальном режиме), поэтому
награда/штраф не применяются, пока игрок не закрыл результат.
- `Esc` во время игры отправки — no-op; в игре атаки — двухшаговое `СДАТЬСЯ`.
- Все формулы клампятся в `0…1`. Параметры ступеней (сетки, допуски, число
карточек, пороги) — `data/balance.json → minigames.games`.
| Игра | Тип | Цель | `q` |
| --- | --- | --- | --- |
| `dots` Одна линия | send | пройти все клетки без повторов | `1 − undo_penalty×undos − 0.1×invalid` |
| `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` |
| `maze` Лабиринт | send | дойти до выхода | `1 − penalty×collisions − penalty×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`.
`dots` управляется **нажатием и протяжкой**: линия растёт непрерывно, пока
курсор идёт по соседним свободным клеткам. Быстрое движение сэмплируется
шагами ~полклетки, поэтому курсор не «перескакивает» клетку. Недопустимое
движение по чужой/занятой клетке **молча игнорируется** и не штрафует;
ошибкой считается только осознанный клик. `R` сбрасывает линию.
Для `wires` T4 используется геометрия `box 34`, `pitch 40`, верх `56`, чтобы
нижняя граница была `≤ 320` (review M11); T1–T3 — `36/42`.
Подсказки атак находятся **внутри окна** игры (в нижней полосе), поэтому не
обрезаются границей модалки и не уходят за экран.
## 11. Протоколы и явная отправка
Полоски соединения и «трёх попаданий» в v2 **нет**. TCP/UDP — это выбор
экономики/скорости, а не мини-игра.
TCP и UDP различаются экономикой, временем и наличием соединения.
- Переключатель TCP/UDP доступен до старта отправки; во время мини-игры,
очереди или передачи менять протокол нельзя.
- **TCP:** выплата `×1.15`, время передачи `×1.0`.
- **UDP:** время передачи `×0.8`, выплата `×1.0`.
- Отправка начинается только по кнопке **`ОТПРАВИТЬ`**. Прикрепление файлов
автостарт не запускает.
- Переключатель TCP/UDP доступен до старта отправки; во время соединения,
мини-игры, очереди или передачи менять протокол нельзя. После успешного
соединения TCP протокол **заблокирован**.
- **TCP:** сначала мини-игра соединения, затем мини-игра отправки.
Выплата `×1.15`, время передачи `×1.0`.
- **UDP:** соединения нет, мини-игра отправки открывается сразу.
Время передачи `×0.8`, выплата `×0.85`.
- Отправка начинается только по кнопке **`СОЕДИНИТЬСЯ`** (TCP без связи) или
**`ОТПРАВИТЬ`**. Прикрепление файлов автостарт не запускает. Успешное
соединение сразу открывает мини-игру отправки, если контейнер собран.
- После мини-игры запрос занимает свободный **out-слот**; если слота нет —
встаёт в очередь `ОЖИДАНИЕ СЛОТА` (таймер запроса заморожен) и стартует,
когда слот освободится. Это продолжение уже начатой отправки, а не
автоматический старт нового запроса.
- Слоты общие для приёма и отправки: `1 + slot_level`, максимум 5.
- Выплата:
когда слот освободится.
- Слоты общие для приёма и отправки: `1 + slot_level`, максимум 5. Полоса
слотов подписана **«СЛОТЫ»**.
### 11.1. Мини-игра TCP-соединения
`scripts/minigames/connection_game.gd`, окно 640×240, полоса 480×40.
```
W = clamp(0.30 − 0.005×(hour−1) − 0.005×successes + 0.05×timer_level,
0.18, 0.36)
```
- Три палочки по очереди, скорости **440 / 340 / 280 px/с**; нужно три
попадания в зелёную зону. Клик — ЛКМ или `Пробел`.
- **Промах / уход за край:** `−4 SLA` и кулдаун повтора **8 с**. Панель
закрывается, автоповтора нет — игрок сам жмёт «СОЕДИНИТЬСЯ» снова.
- **`Esc`:** отмена без штрафа SLA, кулдаун **4 с**.
- Таймер запроса на время соединения **заморожен**; час продолжает идти.
- Успех увеличивает счётчик `connect_successes` (влияет на ширину зоны),
открывает мини-игру отправки и блокирует протокол.
- Счётчик успешных TCP-соединений **сбрасывается на новом забеге**
(`reset_carry()`), а не каждый час.
### 11.2. Выплата
```
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
protocol = 1.15 для TCP, 0.85 для UDP
streak = 1 + 0.02 × min(10, streak)
money_mult = 1 + 0.05 × money_level
```
`recv_fee` начисляется при приёме и не умножается на эти коэффициенты.
`base_sum` — сумма `base_pay` прикреплённых файлов.
`base_sum` — сумма `base_pay` прикреплённых файлов. При успешной отправке
файлы **расходуются**; при отмене/таймауте/конце часа сохраняются.
## 12. SLA и штрафы
- Старт забега: **SLA 60 / 60**. Кап = `60 + 35×sla_level`, максимум **200**;
покупка уровня сразу добавляет +35 к текущему SLA.
- Старт забега: **SLA 60 / 60**. Кап = `60 + 40×sla_level`, максимум **220**;
покупка уровня сразу добавляет **+40** к текущему SLA.
- В начале каждого часа: **+15 SLA** (не выше капа), затем восстанавливаются
заряды щита.
- Потеря пакета: `text 2`, `sound 4`, `photo 8`, `video 16` SLA.
- Таймаут запроса и провал запросов в конце часа: `−(10 + 2×hour)` SLA.
- Промах TCP-соединения: **−4 SLA**; отмена по `Esc` — без штрафа.
- Отклонение запроса: `−5 $`, `−5 SLA`.
- Уничтожение содержимого папки: `−5 SLA` (пустая папка — 0).
- Провал атаки: SLA/деньги по таблице `attacks.kinds.*.tiers` и эффекты §12.1.
@@ -307,12 +374,12 @@ money_mult = 1 + 0.05 × money_level
- Виды: `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)`).
(`clamp(1 + floor((hour−2)/2), 1, 4)`). У атак **4 ступени**.
- Каждая атака — мини-игра (§10); на время неё **весь час** на паузе.
Карточек, пакетов, запросов и файлов атака не создаёт.
- **Щит «Заземление»:** заряды = уровень `lot_shield` (макс. 3),
восстанавливаются в начале часа. Заряд тратится автоматически **до**
открытия мини-игры: атака считается отражённой, награда **50 %**, штрафа
открытия мини-игры: атака считается отражённой, награда **70 %**, штрафа
нет.
- Успех/провал дают деньги и SLA из `attacks.kinds.*.tiers`; порог качества
(`threshold`) свой у каждого вида/ступени. Деньги масштабируются:
@@ -332,23 +399,39 @@ money_mult = 1 + 0.05 × money_level
| Час | 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 |
| Доход, $ | 150 | 220 | 380 | 560 | 760 | 980 | 1 200 | 1 450 | 1 700 |
| `files` | 7 | 8 | 12 | 16 | 20 | 20 | 20 | 20 | 20 |
| `request_cap` | 6 | 6 | 5 | 6 | 6 | 7 | 7 | 7 | 7 |
| `active_requests` | 2 | 2 | 2 | 3 | 3 | 3 | 4 | 4 | 4 |
| `attacks` | 0 | 1 | 1 | 2 | 2 | 3 | 3 | 4 | 4 |
Распределение числа обязательных файлов (`request_mix`, 1–4):
| Час | 1 файл | 2 файла | 3 файла | 4 файла |
| ---: | ---: | ---: | ---: | ---: |
| H1 | 0.76 | 0.24 | 0.00 | 0.00 |
| H2 | 0.63 | 0.32 | 0.05 | 0.00 |
| H3 | 0.53 | 0.37 | 0.10 | 0.00 |
| H4 | 0.50 | 0.36 | 0.12 | 0.02 |
| H5 | 0.42 | 0.38 | 0.16 | 0.04 |
| H6 | 0.40 | 0.38 | 0.17 | 0.05 |
| H7 | 0.32 | 0.40 | 0.21 | 0.07 |
| H8 | 0.30 | 0.39 | 0.22 | 0.09 |
| H9 | 0.27 | 0.38 | 0.23 | 0.12 |
Доход складывается из выплат за отправки, `recv_fee` за принятые файлы и
денежных наград за отражённые атаки. Магазин — основной сток денег.
Проверка `tests/sim_balance.py` (240 сидов, 9 часов, `dt=0.25`, `q=0.85`, TCP):
Проверка `tests/sim_balance.py` (240 сидов, 9 часов, `dt=0.25`, `q=0.85`,
TCP; отчёт — `tests/SIM_BALANCE_REPORT.md`):
- средний итог **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».
- Средний итог — **7 533 $** (payout 6 495 + `recv_fee` 410 + атаки 628),
расход магазина **4 681 $** — **57,9 %** дерева **8 085 $**, **28 поражений
из 240**, SLA в конце H9 — **62,2**; в среднем 54,4 запроса, 18,7 отражённых
атак и 4,4 атаки щитом. Все 9 часов в ±20 % (H1 — 149 $ при цели 150),
вердикт `PASS`.
- Аналитические капы не ниже целей, а расход магазина попадает в полосу
55–65 %.
## 14. Магазин
@@ -366,16 +449,17 @@ money_mult = 1 + 0.05 × money_level
| Лот | Эффект | Уровни | Цены |
| --- | --- | ---: | --- |
| `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 |
| `lot_slots` Порты коммутатора | +1 слот | 4 | 90 / 200 / 400 / 700 |
| `lot_speed` Оптоволокно | время передачи `×0.90` за уровень | 6 | 55 / 75 / 100 / 135 / 180 / 245 |
| `lot_sla` Резервный ИБП | кап +40 и сразу +40 SLA (до 220) | 4 | 80 / 160 / 270 / 430 |
| `lot_shield` Заземление | +1 заряд щита, 70 % награды | 3 | 100 / 220 / 410 |
| `lot_money` Корпоративный контракт | выплата +5 % за уровень | 8 | 70 / 95 / 130 / 170 / 220 / 285 / 365 / 465 |
| `lot_folder` Складской стеллаж | следующая сетка папки | 5 | 50 / 100 / 180 / 290 / 440 |
| `lot_channel` Пропускная способность | канал +6, буфер +1 | 6 | 45 / 70 / 95 / 130 / 175 / 230 |
| `lot_timer` Регламент обработки | таймер запроса +7 с (до 65) | 5 | 45 / 75 / 115 / 165 / 230 |
Сумма всех уровней — **13 100 $**.
Сумма всех уровней — **8 085 $**. Новая папка стоит **180 $** (максимум 6).
Сетки папок: `5×4 → 6×4 → 7×5 → 8×5 → 8×6 → 9×6`.
## 15. Save
@@ -385,10 +469,11 @@ money_mult = 1 + 0.05 × money_level
- **Единый источник позиций:** `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-регена.
`required_by`, shape_id и т.д.), без дублирования origin/rotation.
- В сейв входят деньги, SLA, час, фаза, streak, stats (включая
`tutorial_done`), upgrades, мета-открытия, `run_meta_awards`, seed/state RNG,
реестр, офферы/рероллы/час офферов/`last_seen`/замки, заряды щита и час
последнего SLA-регена.
- Миграция v1: позиции восстанавливаются first-fit, папки не уменьшаются
(`resize_folders` только растёт, при нехватке — ближайший больший размер),
лишние файлы уходят на стол или в `registry` — файлы не теряются.
@@ -408,12 +493,13 @@ money_mult = 1 + 0.05 × money_level
- `FileItem.location` ∈ `registry` / `folder` / `table` / `attached` /
`locked` / `malicious` / `failed`;
- `attached_request`, `malicious_kind`, `lock_until`, `cooldown_until`
(`cooldown` в v2 равен 0, файлы расходуются), `shape_id`, `origin`,
`rotation`;
- `attached_request`, `required_by`, `malicious_kind`, `lock_until`,
`cooldown_until` (`cooldown` в v3 равен 0, файлы расходуются), `shape_id`,
`origin`, `rotation`;
- у запроса: `sending`, `slot_reserved`, `minigame_active`, `quality`,
`send_sum`, `minigame_id`, `minigame_tier`; `required_count`/`queued` —
метаданные. `connected` оставлен legacy-полем и всегда `true`: полоски нет.
`send_sum`, `minigame_id`, `minigame_tier`, `connected`, `connecting`,
`connect_attempts`, `connect_cooldown_until`; `required_count`/`queued` —
метаданные. `connected` снова рабочий флаг TCP-соединения (D45);
- у пакета: `waiting` / `loading`; у папки — `reserved` (резервации приёма).
## 18. Esc и паузирование
@@ -421,10 +507,34 @@ money_mult = 1 + 0.05 × money_level
- В час — пауза часа.
- В мини-игре отправки во время игры — no-op; в мини-игре атаки —
двухшаговое подтверждение `СДАТЬСЯ`. На экране результата — `ПРОДОЛЖИТЬ`.
- В мини-игре TCP-соединения — отмена: `0 SLA`, кулдаун 4 с, запрос остаётся.
- В магазине — закрыть.
- Панели соединения больше нет; отменять нечего.
- На итогах забега — в главное меню (не soft-lock).
## 18.1. Туториал v3
Туториал — не таймерная лента фраз, а **5 шагов, каждый из которых ждёт
событие** (с аварийным таймаутом):
| Шаг | Событие | Текст | Таймаут |
| ---: | --- | --- | ---: |
| 1 | `packet` | Перетащи пакет из «ПРИЁМА» в папку | 30 с |
| 2 | `attach` | Открой папку и перетащи файл в карточку запроса | 40 с |
| 3 | `connect` | Нажми «СОЕДИНИТЬСЯ» и попади в зелёную зону три раза | 45 с |
| 4 | `send` | Пройди мини-игру: качество влияет на оплату | 40 с |
| 5 | `sla` | Следи за SLA и отражай атаки | 12 с |
- Вокруг целевого элемента рисуется **пульсирующая рамка** (маскот БАЙТ
подсказывает текстом); рамка не перехватывает мышь.
- Кнопка **«ДАЛЕЕ»** пропускает текущий шаг; после шага — короткая пауза
подтверждения, затем следующий.
- Завершение сохраняется в `Game.stats["tutorial_done"]` и переживает
перезапуск/загрузку сейва.
- Автозапуск — только в **первом часу первого забега**, если обучение ещё не
пройдено. В подготовке H1 показывается вводная фраза БАЙТА.
- Кнопка **«ОБУЧЕНИЕ»** в главном меню сбрасывает `tutorial_done` и запускает
повтор: в новом забеге — с подготовки, в идущем — с текущего часа.
## 19. Тексты UI
Строки игрока — из `data/strings_ru.json`, ключи `btn.*`, `msg.*`, `ui.*`,
@@ -433,25 +543,42 @@ Fallback-строки в коде совпадают по смыслу с JSON.
## 20. Читаемость
- Пакет показывает тип, расширение и фигуру; при drag виден ghost и
количество клеток.
- Файлы в папке/на столе различаются цветом типа и подписью в занятой клетке.
- Запрос показывает имя клиента, `client_id`, протокол, число слотов,
таймер и кнопки `ОТПРАВИТЬ`/`ОТКЛОНИТЬ`.
- У атаки нет «карточки с теллом»: игрок сразу видит мини-игру и её цель.
- Пакет показывает тип, расширение, фигуру и пиксельную иконку; при drag
виден ghost, иконка и количество клеток.
- Файлы в папке/на столе различаются цветом типа, иконкой и подписью в
занятой клетке; кегль подписи — 12/14/16 px в зависимости от клетки.
Иконки — `art/icons/*.png` (32×32, nearest), показываются в сетке папки,
drag-preview, чипах запроса и карточках пакетов.
- Карточка запроса — **400×128**: имя клиента, `client_id`, протокол, таймер,
сетка обязательных файлов **2×2** с полными именами, счётчик
«ПРИКРЕПЛЕНО X/Y», прогноз `МИНИ-ИГРА: … · Tn` / `СЛОЖНОСТЬ: N` и кнопки
`СОЕДИНИТЬСЯ` (TCP без связи) / `ОТПРАВИТЬ`, `ОТКРЕПИТЬ`, `ОТКЛОНИТЬ`.
Имена файлов и клиентов набраны крупнее прежнего. При наведении на чип
полное имя показывается строкой внутри карточки и в tooltip; длинные имена
не обрезаются до одной буквы.
- Потерянный обязательный файл подсвечивается красным и подписан
**«ПОТЕРЯН»**.
- У атаки нет «карточки с теллом»: игрок сразу видит мини-игру, её цель и
подсказки внутри окна.
- В подготовке правая колонка — стол сортировки; в час — окна `ПРИЁМ` и
`ОТПРАВКА`.
`ОТПРАВКА`. Подпись занятости диска убрана из панели файлов и подвала сетки,
чтобы не дублировать счётчик клеток.
- Полоса слотов подписана **«СЛОТЫ»** (общие для приёма и отправки).
## 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`,
- Основной шрифт — **Golos Text** (`art/fonts/GolosText-VF.ttf`, SIL OFL 1.1,
полная кириллица; вариативный `wght 400–900`, в теме вес 600).
- Press Start 2P — только логотип и числовые акценты, не тело текста.
- Типографическая шкала: **11 / 13 / 17 / 26 px** (`FS_MICRO`, `FS_BODY`,
`FS_HEAD`, `FS_LOGO`). Отдельно: подписи файлов 12–16 px по размеру клетки.
- Handjet и Pixelify Sans в теме **не используются**; файлы оставлены как
история (см. `ASSETS.md`).
- UI v3 «Kaleidos»: бумага `#E9E6DD`, 1 px контуры, тени `#00000018`,
скругления 3–4 px, акцент `#2F7F8F`; спокойный фон — плоская бумага.
- Маскот **БАЙТ** ведёт короткий туториал первого часа.
- Пиксельные иконки файлов сгенерированы `art/icons/generate_icons.py`
(32×32, палитра Kaleidos, nearest). Лицензия — MIT, команда проекта.
- Маскот **БАЙТ** ведёт пошаговый туториал.
- VHS — только главное меню: `shaders/vhs.gdshader` +
`scripts/ui/vhs_overlay.gd`; чекбокс в настройках, HEADLESS не рендерит.
@@ -469,46 +596,51 @@ Fallback-строки в коде совпадают по смыслу с JSON.
```
scripts/
├─ main.gd # UI, ввод, модалки, drag&drop
├─ 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/
└─ minigames/ # MiniGame, factory, connection_game, send/, attack/
```
Проверки: `bash scripts/run_all_tests.sh` — **25 наборов + parse-check**,
актуальный список и статус — в `scripts/run_all_tests.sh` и `tests/`.
Баланс: `python tests/sim_balance.py` → `tests/SIM_BALANCE_REPORT.md`.
## 24. Приёмка (definition of done)
Полный забег из 9 часов; 4 типа файлов-полимино; 7 мини-игр отправки и 4
атаки; 8 лотов магазина с 3 офферами/рероллами/замками; явная отправка с
TCP/UDP; save v2 с миграцией v1; RU-тексты; HTML5-сборка работает на itch.io.
Полный забег из 9 часов; 4 типа файлов-полимино; **6 мини-игр отправки**
(3 ступени) и **4 атаки** (4 ступени); 8 лотов магазина с 3 офферами,
рероллами и замками; явная отправка с TCP-соединением и UDP; запросы на
1–4 именованных файла; save v2 с миграцией v1; RU-тексты; пошаговый туториал;
HTML5-сборка работает на itch.io.
## 25. Решения v2.0
## 25. Решения v3.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.
- **D45** возврат TCP-соединения; **D46** запросы на конкретные файлы (1–4);
**D47** удаление игры с проводами; **D48** сдвиг ступеней отправки;
**D49** коэффициенты и таблица выбора; **D50** усиление магазина;
**D51** Golos Text и пиксельные иконки; **D52** перестройка туториала.
- Решения v2 (D29–D44) сохранены как история; их числа заменены в v3 и
помечены в DECISIONS.
## 26. Известные отклонения v2.0
## 26. Известные отклонения v3.0
- `ПРОПУСТИТЬ`/`СДАТЬСЯ` дают `q = 0`; `quality = 0.5 + 0.5×0 = 0.5`, то есть
половину quality-множителя, а не нулевую выплату. Это осознанно.
- `speed` в выплате использует `time_left / timer_total`, зафиксированное
**на старте отправки**, пока таймер запроса был заморожен мини-игрой.
Быстрый игрок видит `speed ≈ 1.0`; это принятое упрощение.
- Лимит папок — **6**, новая папка — **100 $** (review H5).
- Лимит папок — **6**, новая папка — **180 $**.
- Любой не прикреплённый файл можно уничтожить в подготовке бесплатно —
защита от soft-lock «нет денег и нет места» (review M16).
- `wires` T4: `box 34`, `pitch 40`, верх `56` (review M11).
защита от soft-lock «нет денег и нет места» (review M16). Файл, закреплённый
за запросом, не удаляется.
- Денежные награды/штрафы атак масштабируются по часу:
`max(0.75, hour/3) × attacks.money_scale` (review M8).
- H2 теоретически может выдать T2-запрос из трёх фото (S12); на практике это
редкий случай, и кривая сложности H2 остаётся почти полностью T1.
+31
View File
@@ -7,6 +7,37 @@
> Язык: GDScript. UI строится кодом; `.tscn` — только корень сцены.
> Комментарии/UI — на русском, идентификаторы — на английском.
## 0. Дельты v3.0 (читать первым)
Этот файл — исторический контракт v1. Актуальная спецификация — `docs/GDD.md`
v3.0 и `docs/DECISIONS.md` D45–D52. Ниже — что в контракте уже неверно:
- **Запросы:** `required` снова хранит **конкретные `file_id`**, но их 1–4, а
не 1–3; запрос резервирует файлы (`required_by`), UI показывает имена
сеткой 2×2 и «ПОТЕРЯН». См. GDD §7, D46.
- **Соединение:** `ConnectionGame` **вернулось** в v3. Формула зоны и скорости
из раздела «ConnectionGame» снова актуальны (D45), но панель вызывается
только для TCP и замораживает таймер своего запроса; промах −4 SLA / 8 с,
Esc — 0 SLA / 4 с.
- **Мини-игры:** не 11, а **10** (6 отправки + 4 атаки); игры с проводами нет.
У отправки **3 ступени**, у атак 4; выбор — `Economy.selection_for_sum()` по
27 суммам и таблице `rank_games`, без `rank % 7`. См. GDD §8.4, §10.
- **Магазин:** не 9 позиций, а **8 лотов**; дерево **8 085 $**; SLA-кап
**220**; цены/эффекты — GDD §14, D50. Поля «9 позиций D13», «SLA +24»,
«секция/поиск/зона» устарели.
- **Экономика/буфер:** SLA-кап +40 до 220 (прежняя схема v2 — история),
таймер запроса +7 с до 65 (не +5 до 55), буфер 6+уровень (не 4+2×),
папка 180 $ (не 100), сетки 5×4 → 9×6. Ускорение передачи `×0.90` за
уровень (прежний множитель v2 — история).
- **Шрифты:** Golos Text — тело текста, Press Start 2P — только логотип/цифры;
шкала 11/13/17/26; иконки — `art/icons/*.png`. Handjet — legacy.
- **Туториал:** 5 событийных шагов с рамками и «ДАЛЕЕ», а не лента подсказок;
прогресс в `Game.stats["tutorial_done"]`.
Исторические разделы ниже (9 позиций, 4+2 буфера, анонимные слоты, `rank % 7`,
панель соединения как «удалённая») оставлены для провенанса и не являются
текущим контрактом.
## 1. Автолоады
| Автолоад | Файл | Ответственность |
+36 -31
View File
@@ -21,39 +21,43 @@ SLA — и не дайте атакам положить канал.
повернуть и плотно уложить в сетку папки; на столе сортировки всегда есть
место, чтобы разобраться с завалом.
Клиенты присылают запросы на 1–3 файла. Соберите контейнер, выберите
протокол — TCP платит больше, UDP быстрее — и нажмите «ОТПРАВИТЬ».
Отправка не абстрактная: перед ней открывается мини-игра. Всего их
одиннадцать: семь для отправки и четыре для атак. В них нет таймеров —
только конечная цель и качество выполнения, от которого зависит выплата.
Результат показывает проценты и вердикт, а награда применяется после
«ПРОДОЛЖИТЬ».
Клиенты присылают запросы на 1–4 конкретных файла — их имена видны на
карточке, и прикрепить нужно именно этот файл. Соберите контейнер, выберите
протокол — TCP платит больше, UDP быстрее — и нажмите «СОЕДИНИТЬСЯ» или
«ОТПРАВИТЬ». TCP сначала требует провести три палочки через зелёную зону;
UDP отправляет сразу. Отправка не абстрактная: перед ней открывается
мини-игра. Всего их десять: шесть для отправки и четыре для атак, у отправки
три ступени, у атак четыре. В них нет таймеров — только конечная цель и
качество выполнения, от которого зависит выплата. Результат показывает
проценты, вердикт и детали, а награда применяется после «ПРОДОЛЖИТЬ».
Смена длится девять игровых часов по пять минут. На старте **SLA 60/60**;
в начале часа восстанавливается 15 SLA. Потерянный пакет бьёт по типу
файла: текст 2, звук 4, фото 8, видео 16. Первый час проходит без атак;
дальше в узел лезут мусор, DDoS, фишинг и шифровальщик. Каждая атака — это
отдельная мини-игра, которая ставит час на паузу: отбейте её, и получите
деньги и SLA; провалите — получите штраф и временный дебафф. «Заземление»
гасит атаку автоматически, но приносит только половину награды.
Смена длится девять игровых часов по пять минут. На старте **SLA 60/60**,
кап растёт до 220; в начале часа восстанавливается 15 SLA. Потерянный пакет
бьёт по типу файла: текст 2, звук 4, фото 8, видео 16. Первый час проходит
без атак; дальше в узел лезут мусор, DDoS, фишинг и шифровальщик. Каждая
атака — это отдельная мини-игра, которая ставит час на паузу: отбейте её, и
получите деньги и SLA; провалите — получите штраф и временный дебафф.
«Заземление» гасит атаку автоматически, но приносит 70 % награды.
Магазин открывается после первого часа: **8 улучшений** — порты, оптика,
ИБП, заземление, контракты, стеллажи, канал и регламент. Каждую подготовку
предлагаются три лота; ассортимент можно обновить за 40/80/120 $ или
закрепить нужное предложение. Полное дерево стоит 13 100 $ — выкупить всё
закрепить нужное предложение. Полное дерево стоит 8 085 $ — выкупить всё
за один забег не получится, придётся выбирать.
Дожить до конца смены — победа; уронить SLA — поражение. Сохранение
работает между часами, включая офферы магазина и стол сортировки.
Интерфейс вдохновлён Kaleidos: тёплая бумага, тонкие 1 px контуры, мягкие
тени и бирюзовый акцент. В первом часе рядом подсказывает маскот **БАЙТ**.
В главном меню — лёгкий VHS-эффект, который отключается в настройках; в игре
UI остаётся чистым. Фон — ambient машинного зала, включается после первого
жеста и регулируется вместе с музыкой.
тени и бирюзовый акцент. В первом часу БАЙТ ведёт пошаговый туториал:
пакет → прикрепление → TCP-соединение → мини-игра → SLA и атаки; шаг можно
пропустить кнопкой «ДАЛЕЕ», а повторить обучение — кнопкой «ОБУЧЕНИЕ» в
меню. В главном меню — лёгкий VHS-эффект, который отключается в настройках;
в игре UI остаётся чистым. Фон — ambient машинного зала, включается после
первого жеста и регулируется вместе с музыкой.
UI рисуется кодом, без внешних текстур; Handjet держит плотный русский
текст, Press Start 2P — логотип и цифры. Звуки синтезированы скриптом
Тело интерфейса набрано Golos Text, Press Start 2P — только логотип и цифры;
иконки файлов — пиксельные, из `art/icons/`. Звуки синтезированы скриптом
проекта. Собрано на Godot 4.7.2 для Web без threads: игра запускается в
браузере без COOP/COEP. Это играбельный джем-прототип полного цикла: меню,
подготовка, девять часов, итоги, магазин, пауза, настройки, атаки,
@@ -69,10 +73,11 @@ UI рисуется кодом, без внешних текстур; Handjet д
| Пакет из «Приёма» → папка | drag&drop |
| Файл из папки → карточка запроса | drag&drop |
| Файл ↔ стол сортировки | drag&drop |
| Отправка контейнера | кнопка «ОТПРАВИТЬ» |
| Отправка контейнера | кнопка «СОЕДИНИТЬСЯ» (TCP) или «ОТПРАВИТЬ» |
| TCP / UDP | переключатель на карточке запроса |
| Мини-игра: действие | ЛКМ / Space / стрелки |
| Пропустить мини-игру | «ПРОПУСТИТЬ» + подтверждение |
| `dots`: линия | нажать на «С» и вести курсор по клеткам |
| Пропустить мини-игру | «ПРОПУСТИТЬ»/«СДАТЬСЯ» + второй клик |
| Результат мини-игры | «ПРОДОЛЖИТЬ» или Esc |
| Пауза | Esc |
@@ -89,10 +94,10 @@ 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
сгенерированы скриптом проекта — MIT/CC0. Код — MIT. Полный список и
инструкции по воспроизведению: [ASSETS.md](https://git.194-146-38-78.sslip.io/admin/Game-JAM/-/blob/main/ASSETS.md).
Шрифты **Golos Text** и **Press Start 2P** — SIL OFL 1.1. UI, пиксельные
иконки (`art/icons/`), маскот **БАЙТ** и 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).
## Ссылки
@@ -136,10 +141,10 @@ 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) — обложка страницы.
- [ ] Пересобрать скриншоты под v3 (запросы с именами файлов, TCP-полоска,
иконки, сетка 9×6, 3 оффера) и загрузить 6 кадров из docs/screenshots/:
обязательные — main_menu.png, hour.png, attacks.png; дополнительно —
prep.png, shop.png, settings.png; cover.png (630×500) — обложка страницы.
- [ ] Сверить ссылку на репозиторий и ветку (main); при другой ветке
заменить main на фактическую.
- [ ] Прогнать полный забег вручную на itch.io, проверить сохранение после
+4
View File
@@ -1,5 +1,9 @@
# TEST_PLAN — матрица тестов PEAK HOUR
> **Исторический документ v1.** Актуальная матрица и запуск —
> `scripts/run_all_tests.sh` + README §Тесты; числа v3 — `data/balance.json`,
> GDD v3.
> Числовые данные — только `data/balance.json` и `docs/DECISIONS.md`.
> Юнит-Runner — headless-прогон `tests/run_tests.gd`
> (`godot --headless --path . --script tests/run_tests.gd`).
+58 -22
View File
@@ -1,15 +1,19 @@
# UI_REFRESH — UI v2 «Kaleidos»
# UI_REFRESH — UI v3 «Kaleidos»
Статус: **реализовано** в v1.2. Это короткая карта текущего UI, а не план
Статус: **реализовано** в v3. Это короткая карта текущего UI, а не план
интеграции. Стиль: тёплая бумага, тонкие 1 px контуры, мягкие тени,
скругления 3–4 px, бирюзовый акцент, меньше визуального шума.
## Окно и масштаб
- Окно **1600×900**, логический viewport **1280×720**.
- Окно **1920×1080**, логический viewport **1280×720**.
- `stretch/mode = canvas_items`, `aspect = keep`, `scale_mode = fractional`.
- Шрифты: Handjet (текст), Press Start 2P (логотип/цифры).
- Всё рисуется кодом; внешних текстур UI нет.
- Шрифты: **Golos Text** (тело текста), **Press Start 2P** (логотип/цифры).
Handjet и Pixelify Sans в теме не используются (D51).
- Типографическая шкала: **11 / 13 / 17 / 26 px**. Подписи файлов — 12–16 px
по размеру клетки.
- Почти всё рисуется кодом; внешняя графика — только пиксельные иконки
`art/icons/*.png` (32×32, `nearest`).
## Палитра
@@ -22,26 +26,26 @@ error #C25B4A · success #3F8F5E · warn #C58A2E · shadow #00000018
Контур — 1 px `Theme90.BORDER`; тень только у карточек/панелей/модалок.
Радиус: 3 px — кнопки/строки, 4 px — карточки/панели, 6–8 px — модалки.
Кегли: 10 / 12 / 16 / 24.
Кегли: 11 / 13 / 17 / 26.
## Компоненты
| Файл | Поведение v2 |
| Файл | Поведение v3 |
| --- | --- |
| `theme90.gd` | палитра, `StyleBoxFlat`-хелперы, бумажный фон, `draw_meter`, иконки кодом |
| `theme90.gd` | палитра, `StyleBoxFlat`-хелперы, бумажный фон, `draw_meter`, пиксельные иконки из `art/icons` |
| `button90.gd` | высота 24, скругление 3, teal-hover, pressed inset, `active` — pale-teal |
| `panel90.gd` | светлые панели; `GROUP` — тонкая полоса заголовка |
| `window_frame.gd` | светлый титлбар, тёмный текст, волосяная линия; крестик только при `close_enabled` |
| `status_bar.gd` | одна светлая полоса, 1 px разделители, цветные шкалы |
| `file_card.gd` | белая карточка, иконка 24×24, зелёная attached-галочка, tiny locked/malicious |
| `request_card.gd` | компактные слоты 20 px, галочка attached, мигающее «ожидается» |
| `folder_row.gd`, `tree_panel.gd`, `file_grid.gd` | мягкое teal-выделение, светлые поверхности |
| `file_card.gd` | белая карточка, пиксельная иконка, зелёная attached-галочка, tiny locked/malicious |
| `request_card.gd` | **400×128**, сетка обязательных файлов **2×2**, счётчик, прогноз мини-игры, `СОЕДИНИТЬСЯ`/`ОТПРАВИТЬ`, `ПОТЕРЯН`, полное имя по hover |
| `folder_row.gd`, `tree_panel.gd`, `file_grid.gd` | мягкое teal-выделение, светлые поверхности, иконки в сетке и drag-preview |
| `scroll_bar.gd` | тонкая дорожка, teal только при hover; скрыт без прокрутки |
| `shop_modal.gd` | 10 позиций (включая `SLA +24`), фильтр `Data.available_shop()`, без крестика; «Закрыть»/`Esc` |
| `main_menu.gd` | логотип, слоган, 4 кнопки, 3 шага смены, сводка; VHS-фон только здесь |
| `shop_modal.gd` | 8 лотов v3, 3 оффера, рероллы/замки, без крестика; «Закрыть»/`Esc` |
| `main_menu.gd` | логотип, слоган, кнопки (включая **«ОБУЧЕНИЕ»**), 3 шага смены, сводка; VHS-фон только здесь |
| `vhs_overlay.gd` | нативный CRT/VHS-слой меню: скан-линии, зерно, виньетка, полоса |
| `tutorial_mascot.gd` | маскот **БАЙТ** 64–96 px + облачко, не ловит мышь |
| `main.gd` | сборка экрана, магазин после часа, поиск, автообновление, скролл приёма |
| `main.gd` | сборка экрана, магазин после часа, поиск, автообновление, скролл приёма, шаги туториала и пульсирующие рамки |
## Ключевые сценарии
@@ -53,18 +57,48 @@ error #C25B4A · success #3F8F5E · warn #C58A2E · shadow #00000018
- **Приём:** скролл для 5–8 пакетов; после приёма/перемещения текущая папка и
дерево обновляются автоматически.
- **Папки:** имена из `names_ru.json`, уникализируются в секции (`Имя`,
`Имя 2`); пустые папки можно удалять.
- **Attached:** зелёная галочка у файла и слота запроса.
- **Туториал:** БАЙТ и короткие подсказки в первом часе; без пошагового
сценария.
`Имя 2`); пустые папки можно удалять. Сетка растёт до **9×6**.
- **Запросы:** карточка 400×128 показывает имена 1–4 обязательных файлов
сеткой 2×2. Наведение на чип показывает полное имя строкой внутри карточки
и в tooltip; потерянный файл — красный чип с «ПОТЕРЯН». Прогноз
`МИНИ-ИГРА: … · Tn` и `СЛОЖНОСТЬ: N` считается по обязательным файлам ещё
до прикрепления.
- **TCP:** кнопка `СОЕДИНИТЬСЯ` открывает полоску соединения 640×240
(3 попадания, 440/340/280 px/с). После успеха сразу открывается мини-игра
отправки, протокол блокируется. UDP идёт сразу в мини-игру.
- **Attached:** зелёная галочка у файла и чипа запроса.
- **Пустая отправка:** плашка «ЖДЁМ ЗАПРОС» и подсказка «Следующий запрос
~N с» / «Все запросы часа выданы» / «Нет свободных файлов для запроса».
- **Туториал:** 5 шагов (пакет → прикрепление → TCP → мини-игра → SLA/атаки),
пульсирующая рамка вокруг цели, кнопка «ДАЛЕЕ», сохранение
`tutorial_done`, повтор из меню кнопкой «ОБУЧЕНИЕ».
- **Настройки:** кнопка на главном экране открывает рабочую модалку.
- **Магазин без X:** закрытие только «Закрыть» или `Esc`.
- **Calm-фон:** при включённом «Спокойном фоне» Backdrop рисует плоскую
`PAPER` без dither; серый `#C0C0C0` не появляется.
- **Итоги забега:** строка «ОТКРЫТО: …» показывает полученные мета-награды;
`Esc` ведёт в главное меню, без soft-lock.
- **Drag пакета на слот:** перетаскивание пакета на TCP-слот запускает
настоящее соединение, а не только визуальный перенос.
- **Слоты:** полоса подписана «СЛОТЫ» — слоты общие для приёма и отправки.
- **Подписи занятости диска** в панели файлов и подвале сетки больше нет:
остаётся только счётчик файлов/клеток.
- **Тосты:** сверху по центру (400 px, x = 440…840), стопкой вниз от `y=44`;
панель ОТПРАВКА не перекрывают.
- **Подписи файлов:** 3 / 2 / 1 строки при клетке ≥88 / 72–87 / <72 px,
кегль 12–16 px с авто-уменьшением до 11 px, полное имя — в tooltip.
- **Чипы запросов:** имя 10 px (9 px, только если это позволяет показать имя
целиком); при наведении — полное имя и папка в строке карточки и tooltip.
## Мини-игры
- Общая шапка 640×360: две строки — название и цель, полоса прогресса в
шапке, акцентная линия у цели.
- `dots` — непрерывная линия протяжкой; быстрое движение сэмплируется, а
недопустимые клетки игнорируются молча.
- `ПРОПУСТИТЬ`/`СДАТЬСЯ` требует двух кликов: первый взводит кнопку, второй
подтверждает.
- Результат: имя игры, `q%`, полоса качества, вердикт, детали, `ПРОДОЛЖИТЬ`;
`Esc` = продолжить.
- Подсказки атак — внутри окна, в нижней полосе, без обрезки.
## VHS-эффект главного меню
@@ -116,5 +150,7 @@ godot --headless --path . --script scripts/ui/ui_smoke.gd
bash scripts/run_all_tests.sh
```
`ui_smoke` — 132 проверки, 0 провалов, `SMOKE OK`. Скриншоты для страницы —
в `docs/screenshots/`; после смены UI их нужно переснять в 1600×900.
`ui_smoke` и остальные наборы — см. `scripts/run_all_tests.sh` и `tests/`
(число проверок/наборов может меняться между волнами). Скриншоты для страницы —
в `docs/screenshots/`; после смены UI их нужно переснять под текущий вид
(окно 1920×1080, логический кадр 1280×720).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cg7ddssqqlh8q"
path="res://.godot/imported/minigame.png-e653e1ec9e6eda804b745f4054348377.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/screenshots/minigame.png"
dest_files=["res://.godot/imported/minigame.png-e653e1ec9e6eda804b745f4054348377.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 142 KiB

+25 -36
View File
@@ -15,28 +15,28 @@ const DEFAULT_TYPES := {
"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,
"base_pay": 18, "weight": 1, "minigame_coeff": 1, "sla_loss": 2,
"shapes": ["O1"],
},
"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,
"base_pay": 30, "weight": 2, "minigame_coeff": 2, "sla_loss": 4,
"shapes": ["I2H", "I2V"],
},
"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,
"base_pay": 44, "weight": 4, "minigame_coeff": 4, "sla_loss": 8,
"shapes": ["I3H", "I3V", "L3a", "L3b", "L3c", "L3d"],
},
"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,
"base_pay": 69, "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"],
},
@@ -54,8 +54,11 @@ const DEFAULT_DIFFICULTY := [
{"hour": 8, "max_requests": 12, "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_SEND_ORDER := ["dots", "sound", "text", "video", "photo", "maze"]
const DEFAULT_ACHIEVABLE_SUMS := [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
21, 22, 24, 25, 26, 28, 32,
]
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]
@@ -76,18 +79,8 @@ const DEFAULT_TYPE_WEIGHTS := [
[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],
]
## Доли числа файлов в запросе: 1..4 (§2.6). Добавлен ключ "4" (4 файла).
const DEFAULT_REQUEST_MIX := {"1": 0.76, "2": 0.24, "3": 0.0, "4": 0.0}
const DEFAULT_LOT_IDS := [
"lot_slots", "lot_speed", "lot_sla", "lot_shield",
@@ -102,17 +95,17 @@ 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_VALUES := [1, 0.10, 40, 1, 0.05, 1, 6, 7]
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],
[90, 200, 400, 700],
[55, 75, 100, 135, 180, 245],
[80, 160, 270, 430],
[100, 220, 410],
[70, 95, 130, 170, 220, 285, 365, 465],
[50, 100, 180, 290, 440],
[45, 70, 95, 130, 175, 230],
[45, 75, 115, 165, 230],
]
const DEFAULT_LOT_WEIGHTS := [
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8],
@@ -135,7 +128,7 @@ const DEFAULT_SHOP_RULES := {
"pity_weight": 1.5,
}
const DEFAULT_GRID_DIMS := [[5, 4], [6, 4], [6, 5], [7, 5], [7, 6], [8, 6]]
const DEFAULT_GRID_DIMS := [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]]
var balance: Dictionary = {}
var constants: Dictionary = {}
@@ -355,7 +348,7 @@ func minigame_selection() -> Dictionary:
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))
out["max_index"] = int(out.get("max_index", 26))
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)))
@@ -363,9 +356,9 @@ func minigame_selection() -> Dictionary:
return out
## Legacy-ступень для индекса 0..27 (idx/7 + 1).
## Legacy-ступень для индекса (idx / 6 + 1), три ступени отправки.
func minigame_tier(index: int) -> int:
return clampi(1 + int(floor(float(maxi(0, index)) / 7.0)), 1, 4)
return clampi(1 + int(floor(float(maxi(0, index)) / 6.0)), 1, 3)
# --- v2: атаки -------------------------------------------------------------
@@ -602,11 +595,7 @@ func _default_hour_row(hour: int) -> Dictionary:
"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],
},
"request_mix": DEFAULT_REQUEST_MIX.duplicate(),
}
+47 -21
View File
@@ -19,8 +19,8 @@ const START_MONEY := 50
const START_SLA := 60.0
const HOURS_PER_RUN := 9
## Жёсткий кап SLA (main.gd показывает его в статус-баре). Рабочий кап —
## sla_cap(): 60 + 35 * sla_level, не выше MAX_SLA.
const MAX_SLA := 200.0
## sla_cap(): 60 + 40 * sla_level, не выше MAX_SLA.
const MAX_SLA := 220.0
const REGISTRY_SCRIPT := preload("res://scripts/core/file_registry.gd")
const SAVE_SCRIPT := preload("res://scripts/systems/save_system.gd")
@@ -38,18 +38,18 @@ const LOT_EFFECT_KEYS := {
"request_timer": "timer_level",
}
const FALLBACK_LOT_DIMS := [[5, 4], [6, 4], [6, 5], [7, 5], [7, 6], [8, 6]]
const FALLBACK_LOT_DIMS := [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]]
## Резерв, если 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]},
"lot_slots": {"id": "lot_slots", "name_ru": "Порты коммутатора", "effect": "connection_slots", "value": 1, "max_level": 4, "prices": [90, 200, 400, 700]},
"lot_speed": {"id": "lot_speed", "name_ru": "Оптоволокно", "effect": "transfer_speed", "value": 0.10, "max_level": 6, "prices": [55, 75, 100, 135, 180, 245]},
"lot_sla": {"id": "lot_sla", "name_ru": "Резервный ИБП", "effect": "sla_cap", "value": 40, "max_level": 4, "prices": [80, 160, 270, 430]},
"lot_shield": {"id": "lot_shield", "name_ru": "Заземление", "effect": "shield_charge", "value": 1, "max_level": 3, "prices": [100, 220, 410]},
"lot_money": {"id": "lot_money", "name_ru": "Корпоративный контракт", "effect": "money_mult", "value": 0.05, "max_level": 8, "prices": [70, 95, 130, 170, 220, 285, 365, 465]},
"lot_folder": {"id": "lot_folder", "name_ru": "Складской стеллаж", "effect": "folder_cells", "value": 1, "max_level": 5, "prices": [50, 100, 180, 290, 440]},
"lot_channel": {"id": "lot_channel", "name_ru": "Пропускная способность", "effect": "channel_capacity", "value": 6, "buffer_value": 1, "max_level": 6, "prices": [45, 70, 95, 130, 175, 230]},
"lot_timer": {"id": "lot_timer", "name_ru": "Регламент обработки", "effect": "request_timer", "value": 7, "max_level": 5, "prices": [45, 75, 115, 165, 230]},
}
var money: int = START_MONEY
@@ -236,8 +236,8 @@ func set_lot_level(id: String, new_level: int) -> int:
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_cap() читает новый уровень), добавляем +40 сразу.
var gain := _lot_value(def, 40.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:
@@ -251,19 +251,20 @@ func set_lot_level(id: String, new_level: int) -> int:
func sla_cap() -> float:
var base := _const("sla_base", START_SLA)
var step := _const("sla_per_level", 35.0)
var step := _lot_effect_value("lot_sla", "value", _const("sla_per_level", 40.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)
var step := _lot_effect_value("lot_money", "value", _const("money_mult_step", 0.05))
return 1.0 + step * float(level("money_level"))
## pow(0.92, speed_level) — множитель времени передачи.
## pow(0.90, speed_level) — множитель времени передачи (10% за уровень).
func speed_mult() -> float:
return pow(0.92, float(maxi(0, level("speed_level"))))
var cut := clampf(_lot_effect_value("lot_speed", "value", 0.10), 0.01, 0.9)
return pow(1.0 - cut, float(maxi(0, level("speed_level"))))
func folder_dims() -> Vector2i:
@@ -285,21 +286,21 @@ func connection_slots() -> int:
func channel_capacity() -> float:
var base := _const("channel_capacity", 10.0)
var step := _const("channel_per_level", 6.0)
var step := _lot_effect_value("lot_channel", "value", _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 step := int(_lot_effect_value("lot_channel", "buffer_value", _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)
var step := _lot_effect_value("lot_timer", "value", _const("request_timer_per_level", 7.0))
var cap := _const("request_timer_max", 65.0)
return clampf(base + step * float(level("timer_level")), base, cap)
@@ -428,6 +429,23 @@ func load_game() -> bool:
if not reg.from_dict(raw_registry):
push_warning("Game: не удалось восстановить registry")
return false
# runtime-резервы под запросы не переживают загрузку: сами запросы не
# сохраняются, поэтому file.required_by/attached_request и location
# "attached" после load указывали бы на несуществующий запрос. Такой файл
# нельзя ни переместить, ни удалить — он навсегда занимает клетку.
for file in reg.files.values():
file.required_by = ""
file.attached_request = ""
if String(file.location) == "attached":
file.location = &"folder" if not String(file.folder_id).is_empty() else &"registry"
# _repair_after_load уже разложил файл по папкам/клеткам. Если папки
# (или её bookkeeping) нет, файл должен быть видимым в реестре, а не
# числиться в несуществующем месте.
if String(file.location) == "folder":
var folder = reg.folders.get(String(file.folder_id), null)
if folder == null or not folder.file_ids.has(file.id):
file.folder_id = ""
file.location = &"registry"
registry = reg
upgrades = _dict_of(data.get("upgrades", {}))
@@ -617,6 +635,14 @@ func _lot_value(def: Dictionary, fallback_value: float) -> float:
return float(fallback_value)
## Значение поля лота (source of truth для эффекта), fallback — константа.
func _lot_effect_value(id: String, field: String, fallback_value: float) -> float:
var def := _lot_def(id)
if def.has(field):
return float(def.get(field))
return float(fallback_value)
func _shop_item(id: String) -> Dictionary:
var data = _data_node()
if data != null and data.has_method("shop_item"):
+57 -26
View File
@@ -15,15 +15,15 @@ const CHANNEL_CAPACITY_STEP := 6.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 REQUEST_TIMER_STEP := 7.0
const REQUEST_TIMER_MAX := 65.0
const BUFFER_BASE := 6
const BUFFER_PER_LEVEL := 1
const BUFFER_MAX := 12
## Множитель времени передачи за уровень «Оптоволокна».
const SPEED_STEP := 0.92
## Множитель времени передачи за уровень «Оптоволокна» (10% за уровень).
const SPEED_STEP := 0.90
## Коэффициенты мини-игр: text 1, sound 2, photo 4, video 8.
const MINIGAME_COEFF := {
@@ -33,12 +33,27 @@ const MINIGAME_COEFF := {
"video": 8,
}
const SEND_ORDER := ["dots", "sound", "wires", "text", "video", "photo", "maze"]
## Игры отправки (wires удалена). Порядок совпадает с data.gd/minigame_factory.
const SEND_ORDER := ["dots", "sound", "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]
## Игра по rank (позиция суммы в ACHIEVABLE_SUMS). Прямая таблица, без modulo.
const RANK_GAMES := [
"text", "sound", "dots", "photo", "maze",
"dots", "photo", "video", "dots", "text",
"video", "maze", "photo", "sound", "video",
"text", "maze", "sound", "video", "sound",
"photo", "dots", "dots", "maze", "maze",
"photo", "video",
]
## Достижимые суммы коэффициентов для 1–4 файлов: 27 значений.
const ACHIEVABLE_SUMS := [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14,
15, 16, 17, 18, 19, 20, 21, 22, 24, 25, 26, 28, 32,
]
## rank 0..7 → T1, 8..13 → T2, 14..26 → T3.
const TIER_MAX_RANK := [7, 13]
const PAYOUT_SPEED_BASE := 0.6
const PAYOUT_SPEED_BONUS := 0.4
@@ -46,6 +61,7 @@ const PAYOUT_QUALITY_BASE := 0.5
const PAYOUT_QUALITY_BONUS := 0.5
const PAYOUT_MULTI_STEP := 0.15
const PAYOUT_TCP_MULT := 1.15
const PAYOUT_UDP_MULT := 0.85
const PAYOUT_UDP_TIME_MULT := 0.8
const PAYOUT_STREAK_STEP := 0.02
const PAYOUT_STREAK_CAP := 10
@@ -87,7 +103,7 @@ static func buffer_limit(upgrades: Dictionary = {}) -> int:
return clampi(BUFFER_BASE + BUFFER_PER_LEVEL * level, BUFFER_BASE, BUFFER_MAX)
## pow(0.92, speed_level): 0 → 1.0, 6 → ≈0.606.
## pow(0.90, speed_level): 0 → 1.0, 6 → ≈0.531.
static func speed_mult(speed_level: int) -> float:
return pow(SPEED_STEP, float(maxi(0, int(speed_level))))
@@ -101,7 +117,7 @@ static func type_base_time(type_def: Dictionary) -> float:
return 0.0
## Время приёма: base_time_sec × pow(0.92, speed_level) × channel_factor.
## Время приёма: base_time_sec × pow(0.90, 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)
@@ -133,7 +149,7 @@ static func packet_timer(_hour: int = 1, _upgrades: Dictionary = {}) -> float:
return PACKET_TIMER_BASE
## v2: 30 + 5 × timer_level, не выше 55 с.
## v2: 30 + 7 × timer_level, не выше 65 с.
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)
@@ -172,35 +188,50 @@ static func minigame_type(types: Array) -> String:
return "video"
## Выбор мини-игры отправки по сумме коэффициентов (B4).
## Выбор мини-игры отправки по сумме коэффициентов (1–4 файла).
##
## rank — позиция суммы S в плотном списке достижимых сумм (для
## недостижимой S — позиция ближайшей меньшей достижимой).
## tier = clamp(1 + floor(rank * 4.0 / count), 1, 4) — все 4 ступени
## достижимы. game = order[rank % 7] — все 7 игр достижимы.
## rank — позиция суммы S в списке ACHIEVABLE_SUMS (для недостижимой S —
## позиция ближайшей меньшей достижимой). tier: rank 0..7 → T1, 8..13 → T2,
## 14..26 → T3. Игра берётся напрямую из RANK_GAMES[rank], без modulo.
##
## Примечание: из-за плотного списка S=24 даёт rank 18 → "video"/T4
## (а не "wires", как в старой формуле idx = S-1).
## S = 0 (нет файлов) → dots/T1.
static func send_minigame_for(types: Array) -> Dictionary:
var total := 0
for type in types:
total += int(MINIGAME_COEFF.get(str(type), 1))
total += int(MINIGAME_COEFF.get(str(type).to_lower(), 1))
return selection_for_sum(total)
## Публичный алиас выбора по готовой сумме (для MiniGameFactory/тестов).
static func selection_for_sum(total: int) -> Dictionary:
var sum := maxi(0, int(total))
if sum <= 0:
return {"id": "dots", "tier": 1, "index": 0, "sum": 0}
var count := ACHIEVABLE_SUMS.size()
var rank := 0
for i in range(count):
if int(ACHIEVABLE_SUMS[i]) <= total:
if int(ACHIEVABLE_SUMS[i]) <= sum:
rank = i
else:
break
var tier := clampi(1 + int(floor(float(rank) * 4.0 / float(count))), 1, 4)
var tier := 1
if rank > int(TIER_MAX_RANK[1]):
tier = 3
elif rank > int(TIER_MAX_RANK[0]):
tier = 2
return {
"id": str(SEND_ORDER[rank % SEND_ORDER.size()]),
"id": str(RANK_GAMES[clampi(rank, 0, RANK_GAMES.size() - 1)]),
"tier": tier,
"index": rank,
"sum": total,
"sum": sum,
}
## Список достижимых сумм коэффициентов (копия, чтобы нельзя было испортить const).
static func achievable_sums() -> Array:
return ACHIEVABLE_SUMS.duplicate()
## Ступень атаки: 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)
@@ -209,7 +240,7 @@ static func attack_tier(hour: int) -> int:
# --- выплата ---------------------------------------------------------------
## payout = round(base_sum × speed × quality × multi × protocol × streak ×
## money_mult). money_level берётся из upgrades.
## money_mult). TCP ×1.15, UDP ×0.85; money_level из upgrades.
static func payout(
base_sum: float,
n: int,
@@ -228,7 +259,7 @@ static func payout(
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 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 protocol_mult := PAYOUT_TCP_MULT if str(protocol).to_lower() == "tcp" else PAYOUT_UDP_MULT
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)
+21 -2
View File
@@ -34,7 +34,7 @@ const DEFAULT_TABLE_ROWS = 6
const DEFAULT_FOLDERS_START = 2
## Fallback, если data/balance.json ещё не содержит grid.folder_dims.
const GRID_DIMS = [[5, 4], [6, 4], [6, 5], [7, 5], [7, 6], [8, 6]]
const GRID_DIMS = [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]]
## Fallback-формы (совпадают с data/balance.json → grid.shapes).
const SHAPES = {
@@ -521,6 +521,11 @@ func auto_place_file(file_id: String, folder_id: String) -> bool:
## Перемещение: origin=(-1,-1) → auto-place, иначе точное размещение.
## Файл, зарезервированный под запрос (required_by), ПЕРЕМЕЩАТЬ МЕЖДУ ПАПКАМИ
## можно: запрос ищет файл по id и остаётся выполнимым из любой папки, а карточка
## запроса показывает актуальную папку. Блокируются только разрушительные
## действия (destroy_file/remove_file/move_file_to_table) и прикрепление к
## другому запросу (attach_file).
func move_file(file_id: String, folder_id: String, origin: Vector2i = Vector2i(-1, -1), rotation: int = -1) -> bool:
var file = files.get(file_id)
if file == null:
@@ -728,6 +733,8 @@ func move_file_to_table(file_id: String, origin: Vector2i = Vector2i(-1, -1), ro
var file = files.get(file_id)
if file == null:
return false
if not file.required_by.is_empty():
return false
if file.location == &"attached" or not file.attached_request.is_empty():
return false
if file.location == &"locked" or file.location == &"failed" or file.location == &"malicious":
@@ -750,7 +757,6 @@ func move_file_to_table(file_id: String, origin: Vector2i = Vector2i(-1, -1), ro
file.attached_request = ""
if old_folder != null:
folder_changed.emit(old_folder)
grid_changed.emit(old_folder.id)
file_changed.emit(file)
table_changed.emit()
registry_changed.emit()
@@ -976,6 +982,8 @@ func attach_file(file_id: String, request_id: String, now: float = -1.0) -> bool
return false
if file.location == &"table":
return false
if not file.required_by.is_empty() and file.required_by != request_id:
return false
if not file.attached_request.is_empty():
return false
if not file.can_attach(now):
@@ -1013,6 +1021,9 @@ func mark_failed(file_id: String) -> bool:
return false
file.location = &"failed"
file.attached_request = ""
# Проваленный файл больше не может обслужить запрос: снимаем резерв,
# иначе он навсегда остаётся «занятым» и его нельзя уничтожить.
file.required_by = ""
file.lock_until = 0.0
file.cooldown_until = 0.0
file._location_before_lock = &"folder" if not file.folder_id.is_empty() else &"registry"
@@ -1028,6 +1039,9 @@ func mark_malicious(file_id: String, kind: String) -> bool:
file.location = &"malicious"
file.malicious_kind = kind
file.attached_request = ""
# Заражённый файл не является валидной резервацией запроса: снимаем
# required_by, иначе запрос навсегда ждёт мёртвый файл, а удалить его нельзя.
file.required_by = ""
file.lock_until = 0.0
file.cooldown_until = 0.0
file._location_before_lock = &"folder" if not file.folder_id.is_empty() else &"registry"
@@ -1124,6 +1138,9 @@ func remove_file(file_id: String, force: bool = false) -> bool:
var file = files.get(file_id)
if file == null:
return false
# Файл зарезервирован под конкретный запрос: удалять нельзя.
if not file.required_by.is_empty():
return false
if file.location == &"attached" or not file.attached_request.is_empty():
return false
if not force and file.location != &"malicious" and file.location != &"failed":
@@ -1774,6 +1791,8 @@ func _clear_file_placement(file) -> void:
if folder != null:
folder.clear_owner(file.id)
folder.file_ids.erase(file.id)
# Старая папка тоже должна перерисоваться (перенос/расход файла).
grid_changed.emit(folder.id)
_clear_table_cells(file.id)
+561 -104
View File
@@ -3,8 +3,8 @@ extends Node
## Единственный тикер часа (v2).
##
## Час — это бюджет файлов, бюджет запросов, окно спавна 75% часа и атаки.
## Никаких полосок соединения, мусорных/шифровальщицких пакетов и
## фишинговых запросов: всё это заменено мини-играми атак (§5.7).
## «Мусорные»/шифровальщицкие пакеты и фишинговые запросы заменены
## мини-играми атак (§5.7); TCP-соединение — отдельная мини-игра-полоска.
##
## Важные правила:
## - приём занимает слот `in` + резервирует клетки фигуры в папке;
@@ -30,15 +30,15 @@ signal attack_changed(id: String)
signal attack_removed(id: String)
signal toast(text: String)
signal hour_finished(report: Dictionary)
## Legacy-сигнал: соединение удалено из игры. Оставлен, чтобы не ломать main.gd.
## (поле-словарь соединений больше не нужно и удалено)
signal show_connection(game)
signal show_minigame(game)
signal minigame_closed(req_id: String)
signal minigame_cancelled(req_id: String)
signal show_attack_minigame(game, kind: String)
signal attack_resolved(kind: String, success: bool, tier: int)
signal spawn_frozen(reason: String)
## TCP: показать мини-игру установления соединения.
## UI создаёт ConnectionGame и по завершении зовёт connection_finished().
signal show_connection(request_id: String, hour: int, successes: int)
const PACKET_SCRIPT := preload("res://scripts/entities/packet.gd")
const REQUEST_SCRIPT := preload("res://scripts/entities/request.gd")
@@ -59,8 +59,11 @@ const MAX_SPAWN_STEPS := 32
const REJECT_FINE_MONEY := -5
const REJECT_FINE_SLA := -5.0
const DEFAULT_PACKET_SLA_LOSS := 2.0
## Минимальный зазор между попытками спавна запроса (анти-голодание H1).
const REQUEST_ATTEMPT_MIN_GAP := 3.0
const MAX_REQUIRED_FILES := 4
const TYPE_ORDER: Array[String] = ["text", "sound", "photo", "video"]
const DEFAULT_REQUEST_MIX := {"1": 0.7, "2": 0.3, "3": 0.0}
const DEFAULT_REQUEST_MIX := {"1": 0.76, "2": 0.24, "3": 0.0, "4": 0.0}
const DEFAULT_TYPE_WEIGHTS := {"text": 1.0, "sound": 0.0, "photo": 0.0, "video": 0.0}
## Активные сущности часа.
@@ -95,12 +98,16 @@ var _recv_interval_current: float = DEFAULT_RECV_INTERVAL
var _send_interval_current: float = DEFAULT_SEND_INTERVAL
var _next_spawn_at: float = -1.0
var _spawn_frozen_notified: bool = false
## Момент последней попытки создать запрос (hour_elapsed). Нужен анти-голоданию.
var _last_request_attempt_t: float = -REQUEST_ATTEMPT_MIN_GAP
## Состояние/настройки.
var game = null
var registry = null
var rng = null
var successes_this_hour: int = 0
## Успешных TCP-соединений за забег (вход в формулу зоны ConnectionGame).
var connect_successes: int = 0
var shield_until: float = 0.0
var recv_disabled_until: float = 0.0
var send_disabled_until: float = 0.0
@@ -116,6 +123,7 @@ var _next_packet_num: int = 1
var _next_request_num: int = 1
var _order_counter: int = 0
var _registry_accum: float = 0.0
var _lost_check_accum: float = 0.0
var _finish_guard: bool = false
var _attack_paused: bool = false
@@ -145,6 +153,9 @@ func start_hour(p_hour: int) -> void:
_bind()
_clear_entities()
hour = clampi(int(p_hour), 1, 24)
# Строго до сброса счётчиков: не сеем заново при reload/continue.
var seed_h1: bool = hour == 1 and requests_spawned == 0 \
and registry != null and registry.files.is_empty()
var def := _hour_def()
var base_files := maxi(0, int(def.get("files", 4)))
files_budget = mini(MAX_FILES_PER_HOUR, base_files + maxi(0, carry_files))
@@ -166,8 +177,13 @@ func start_hour(p_hour: int) -> void:
hour_elapsed = 0.0
spawn_recv_accum = 0.0
spawn_send_accum = 0.0
_lost_check_accum = 0.0
# Анти-голодание: первый запрос может появиться сразу, как только в папке
# есть подходящий файл (а не через send_interval).
_last_request_attempt_t = -REQUEST_ATTEMPT_MIN_GAP
_recv_interval_current = _next_receive_interval()
_next_spawn_at = _recv_interval_current
# H1: первый пакет приходит сразу, чтобы час не начинался с простоя.
_next_spawn_at = 0.0 if hour == 1 else _recv_interval_current
_spawn_frozen_notified = false
_send_interval_current = _send_interval()
_modal_count = 0
@@ -192,12 +208,79 @@ func start_hour(p_hour: int) -> void:
running = true
_finish_guard = false
if seed_h1:
_bootstrap_h1()
slot_changed.emit()
## H1-затравка: один text-файл в первой папке + сразу запрос на него.
func _bootstrap_h1() -> void:
if registry == null or not registry.files.is_empty():
# Защита от двойного сида: если в реестре уже есть файлы (например,
# seed, созданный reset_for_prep()), новый не создаём.
return
var folder_id := _first_folder_id()
if folder_id.is_empty():
return
var file = registry.create_file(_make_file_name(), "text", folder_id)
if file == null:
return
var req_id := _create_request(1, "tcp", [str(file.id)])
if req_id.is_empty():
return
requests_spawned += 1
_last_request_attempt_t = hour_elapsed
func _first_folder_id() -> String:
if registry == null:
return ""
for section in registry.sections.values():
for folder_id in section.folder_ids:
if registry.folders.has(str(folder_id)):
return str(folder_id)
for folder_id in registry.folders.keys():
return str(folder_id)
return ""
## Сброс переноса неразобранных файлов. Вызывается на новом забеге/загрузке.
func reset_carry() -> void:
carry_files = 0
connect_successes = 0
## Чистое состояние директора для экрана подготовки перед H1.
##
## Выполняет обычный start_hour(1) (создаёт seed-файл H1), но затем убирает
## активные запросы/пакеты и все резервации required_by. В подготовке игрок
## не видит запросов, поэтому зарезервированный seed нельзя оставлять:
## «уничтожить содержимое» не смогло бы его удалить, а SLA списался бы.
## Сам запрос появится при старте часа — через starvation guard, либо через
## bootstrap, если игрок успел уничтожить seed и реестр опустел.
func reset_for_prep() -> void:
_bind()
# BUG A: счётчики спавна сбрасываем ДО start_hour(1). start_hour сеет seed
# H1 только при requests_spawned == 0, а после сыгранного часа он > 0 —
# иначе второй и последующие «Новые забеги» оставались без seed-файла в
# подготовке (асимметрия с первым забегом).
_reset_spawn_counters()
start_hour(1)
_clear_entities()
# Чистое состояние подготовки: bootstrap-запрос снят, счётчики пусты.
_reset_spawn_counters()
if registry != null:
for file in registry.files.values():
if file != null:
file.required_by = ""
running = false
## Счётчики спавна, которые должны быть нулевыми перед/после подготовки.
func _reset_spawn_counters() -> void:
requests_spawned = 0
spawned_this_hour = 0
_last_request_attempt_t = -REQUEST_ATTEMPT_MIN_GAP
func tick(delta: float) -> void:
@@ -249,6 +332,12 @@ func set_time_scale(value: float) -> void:
func is_minigame_active() -> bool:
# Пока TCP-запрос соединяется, открыта модалка мини-игры соединения.
# Флаг мини-игры ещё не выставлен (_minigame == null), поэтому атаки
# считали, что экран свободен, и могли наложить второй модал.
for req in active_requests.values():
if req != null and bool(req.get("connecting")):
return true
return _minigame != null
@@ -305,6 +394,52 @@ func request_required_count(req_id: String) -> int:
return _required_count(req)
## Конкретные файлы, которые требует запрос (в порядке required).
func request_required_ids(req_id: String) -> Array[String]:
var out: Array[String] = []
var req = active_requests.get(req_id)
if req == null:
return out
for file_id in req.required:
out.append(str(file_id))
return out
func request_required_names(req_id: String) -> Array[String]:
var out: Array[String] = []
for file_id in request_required_ids(req_id):
out.append(request_file_name(file_id))
return out
## Отображаемое имя файла или «ПОТЕРЯН», если файла уже нет в реестре.
func request_file_name(file_id: String) -> String:
if registry != null and registry.files.has(str(file_id)):
var file = registry.files[str(file_id)]
if file != null:
return str(file.display_name())
return "ПОТЕРЯН"
## required-файлы, которые ещё не прикреплены (id).
func request_missing(req_id: String) -> Array[String]:
var out: Array[String] = []
var req = active_requests.get(req_id)
if req == null:
return out
for file_id in req.required:
if not req.attached.has(str(file_id)):
out.append(str(file_id))
return out
func request_missing_names(req_id: String) -> Array[String]:
var out: Array[String] = []
for file_id in request_missing(req_id):
out.append(request_file_name(file_id))
return out
## v2-готовность: собраны все слоты, запрос не занят мини-игрой/очередью.
## Request.is_ready() остался legacy (проверяет TCP-connect) — не используем.
func is_request_ready(req_id: String) -> bool:
@@ -407,6 +542,55 @@ func spawn_request_now(count_override: int = 0) -> String:
return _spawn_random_request(true, int(count_override))
## Пул файлов, из которых можно собрать запрос: лежат в разблокированной
## папке, не прикреплены и ещё не затребованы другим активным запросом.
func _request_file_pool() -> Array:
var pool: Array = []
if registry == null:
return pool
var claimed: Dictionary = {}
for req in active_requests.values():
for file_id in req.required:
claimed[str(file_id)] = true
for file in registry.files.values():
if str(file.location) != "folder":
continue
if not str(file.attached_request).is_empty():
continue
if claimed.has(str(file.id)):
continue
if not str(file.required_by).is_empty():
continue
if not file.is_available():
continue
if is_folder_locked(str(file.folder_id)):
continue
pool.append(file)
return pool
## Выбрать N конкретных файлов: один равномерный shuffle пула, первые N.
func _pick_request_files(pool: Array, count: int) -> Array[String]:
var out: Array[String] = []
var want := clampi(int(count), 1, MAX_REQUIRED_FILES)
if pool.is_empty():
return out
want = mini(want, pool.size())
var shuffled := pool.duplicate()
_shuffle_array(shuffled)
for i in range(want):
out.append(str(shuffled[i].id))
return out
func _shuffle_array(items: Array) -> void:
for i in range(items.size() - 1, 0, -1):
var j := _randi_range(0, i)
var tmp = items[i]
items[i] = items[j]
items[j] = tmp
func _spawn_random_request(force: bool = false, count_override: int = 0) -> String:
if registry == null:
return ""
@@ -415,38 +599,56 @@ func _spawn_random_request(force: bool = false, count_override: int = 0) -> Stri
return ""
if _normal_request_count() >= active_request_limit:
return ""
var available: Array = []
for file in registry.get_available_files():
if not is_folder_locked(str(file.folder_id)):
available.append(file)
if available.is_empty():
var pool := _request_file_pool()
if pool.is_empty():
return ""
var count := 0
if count_override > 0:
if available.size() < count_override:
return ""
count = count_override
else:
count = _roll_required_count()
if available.size() < count:
return ""
var count := count_override if count_override > 0 else _roll_required_count()
count = clampi(count, 1, MAX_REQUIRED_FILES)
count = mini(count, pool.size())
if count <= 0:
return ""
var req_id := _create_request(count, "tcp")
var file_ids := _pick_request_files(pool, count)
if file_ids.is_empty():
return ""
var req_id := _create_request(count, "tcp", file_ids)
if req_id.is_empty():
return ""
requests_spawned += 1
return req_id
## Legacy-хелпер: создать запрос под N слотов (файлы не прикрепляются).
## Legacy-хелпер: создать запрос на переданные файлы (уже подключённый TCP).
func _make_request(files: Array, protocol: String, _is_attack: bool,
_attack_kind: String, _attack_id: String) -> String:
var count := maxi(1, files.size())
return _create_request(count, protocol)
var file_ids: Array[String] = []
for file in files:
if file != null:
file_ids.append(str(file.id))
var count := maxi(1, file_ids.size())
return _create_request(count, protocol, file_ids, true)
func _create_request(count: int, protocol: String) -> String:
## Создать запрос с КОНКРЕТНЫМИ required-файлами.
## file_ids пустой → попытаться выбрать из пула (count будет зажат).
## connected=false — для TCP потребуется мини-игра соединения.
func _create_request(count: int, protocol: String, file_ids: Array = [],
p_connected: bool = false) -> String:
if registry == null:
return ""
var ids: Array[String] = []
for file_id in file_ids:
if ids.size() >= MAX_REQUIRED_FILES:
break
if registry.files.has(str(file_id)) and not ids.has(str(file_id)):
ids.append(str(file_id))
if ids.is_empty():
var pool := _request_file_pool()
if pool.is_empty():
return ""
var want := clampi(int(count), 1, MAX_REQUIRED_FILES)
ids = _pick_request_files(pool, mini(want, pool.size()))
if ids.is_empty():
return ""
var req = REQUEST_SCRIPT.new()
req.id = "req_%d" % _next_request_num
_next_request_num += 1
@@ -460,14 +662,17 @@ func _create_request(count: int, protocol: String) -> String:
req.attack_kind = ""
req.attack_id = ""
req.required.clear()
for file_id in ids:
req.required.append(file_id)
var file = registry.files.get(str(file_id))
if file != null and (file.required_by.is_empty() or file.required_by == req.id):
file.required_by = req.id
req.attached.clear()
# required — это слоты контейнера (required_count), а не заранее выбранные
# файлы: игрок сам решает, какие файлы положить (attach_file).
for i in range(maxi(1, int(count))):
req.required.append("__slot_%s_%d" % [req.id, i])
# Соединения в v2 нет: TCP/UDP — выбор оплаты/скорости, а не мини-игра.
req.connected = true
req.set_meta("required_count", maxi(1, int(count)))
req.required_types = _types_for_ids(ids)
req.connected = bool(p_connected)
req.connecting = false
req.connect_attempts = 0
req.set_meta("required_count", ids.size())
req.set_meta("queued", false)
active_requests[req.id] = req
request_added.emit(req.id)
@@ -475,18 +680,36 @@ func _create_request(count: int, protocol: String) -> String:
return req.id
func _types_for_ids(file_ids: Array[String]) -> Array[String]:
var out: Array[String] = []
if registry == null:
return out
for file_id in file_ids:
var file = registry.files.get(str(file_id))
if file != null:
out.append(str(file.type))
return out
func attach_file(req_id: String, file_id: String) -> bool:
var req = active_requests.get(req_id)
if req == null or registry == null:
return false
if req.sending or req.minigame_active or _send_queue.has(req_id):
if req.sending or req.minigame_active or req.connecting or _send_queue.has(req_id):
return false
if req.attached.has(file_id):
return true
if not req.required.has(file_id):
var missing := request_missing(req_id)
var shown := file_id
if not missing.is_empty():
shown = str(missing[0])
_emit_toast("Нужен файл: %s" % request_file_name(shown))
return false
var need := _required_count(req)
if req.attached.size() >= need:
_emit_toast("Контейнер заполнен")
return false
if req.attached.has(file_id):
return true
var file = registry.files.get(file_id)
if file == null:
return false
@@ -502,8 +725,7 @@ func attach_file(req_id: String, file_id: String) -> bool:
if not registry.attach_file(file_id, req_id):
return false
req.attached.append(file_id)
_fill_required_slot(req, file_id)
req.required_types = _attached_types(req)
# M4: required_types описывает типы required-слотов, а не порядок вложений.
request_changed.emit(req_id)
return true
@@ -512,15 +734,14 @@ func detach_file(req_id: String, file_id: String) -> bool:
var req = active_requests.get(req_id)
if req == null or registry == null:
return false
if req.sending or req.minigame_active or _send_queue.has(req_id):
if req.sending or req.minigame_active or req.connecting or _send_queue.has(req_id):
return false
if not req.attached.has(file_id):
return false
if registry.files.has(file_id):
registry.detach_file(file_id)
req.attached.erase(file_id)
_clear_required_slot(req, file_id)
req.required_types = _attached_types(req)
# M4: порядок вложений не должен переписывать типы required-слотов.
request_changed.emit(req_id)
return true
@@ -529,7 +750,7 @@ func reject_request(req_id: String) -> bool:
var req = active_requests.get(req_id)
if req == null:
return false
if req.sending or req.minigame_active:
if req.sending or req.minigame_active or req.connecting:
_emit_toast("Запрос уже в работе")
return false
_add_money(REJECT_FINE_MONEY)
@@ -541,21 +762,108 @@ func reject_request(req_id: String) -> bool:
return true
## v2: соединения нет. Метод-заглушка, чтобы не ломать старый UI.
## Legacy: соединения пакетов нет.
func connect_packet(_packet_id: String) -> bool:
return false
## v2: соединения нет. Метод-заглушка, чтобы не ломать старый UI.
func connect_request(_req_id: String) -> bool:
return false
## Начать установку TCP-соединения (мини-игра «полоска»).
func connect_request(req_id: String) -> bool:
var req = active_requests.get(req_id)
if req == null:
return false
if not running or _finish_guard:
return false
if req.protocol != "tcp" or req.connected or req.connecting:
return false
if req.sending or req.minigame_active or _send_queue.has(req_id):
return false
# C1/C2: пока любая модалка владеет экраном, второй мини-игры не создаём.
if modal_open:
_emit_toast("Сначала закройте текущее окно")
return false
if _minigame != null:
_emit_toast("Сначала закройте мини-игру")
return false
if hour_elapsed < send_disabled_until:
_emit_toast("Отправка временно недоступна")
return false
if hour_elapsed < req.connect_cooldown_until:
_emit_toast("СОЕДИНЕНИЕ: ПОВТОР ЧЕРЕЗ %d с" % int(ceil(req.connect_cooldown_until - hour_elapsed)))
return false
var problem := _send_ready_error(req)
if not problem.is_empty():
_emit_toast(problem)
return false
return _begin_connection(req)
## Открыть мини-игру соединения. Возвращает false, если она уже идёт.
func _begin_connection(req) -> bool:
if req == null or req.connected or req.connecting:
return false
req.connecting = true
req.connect_attempts += 1
request_changed.emit(str(req.id))
show_connection.emit(str(req.id), hour, connect_successes)
return true
## UI закрыл мини-игру соединения. missed=true — промах (штраф SLA и 8 с),
## missed=false (Esc/отмена) — только 4 с паузы, без штрафа.
## Совместимость: старый вызов с двумя аргументами трактуется как отмена.
func connection_finished(req_id: String, success: bool, missed: bool = false) -> bool:
var req = active_requests.get(req_id)
if req == null:
return false
if not req.connecting:
return false
req.connecting = false
if not bool(success):
if bool(missed):
req.connect_cooldown_until = hour_elapsed + 8.0
_add_sla(-4.0)
else:
req.connect_cooldown_until = hour_elapsed + 4.0
request_changed.emit(req_id)
_emit_toast("СОЕДИНЕНИЕ НЕ УСТАНОВЛЕНО")
return true
req.connected = true
req.connect_cooldown_until = 0.0
connect_successes += 1
request_changed.emit(req_id)
if not running or _finish_guard:
return true
var problem := _send_ready_error(req)
if not problem.is_empty():
_emit_toast(problem)
return true
if _minigame != null:
return true
return _launch_send_minigame(req_id)
func _send_ready_error(req) -> String:
if req == null:
return "Контейнер не собран"
var need := _required_count(req)
if need <= 0 or req.attached.size() < need:
return "Контейнер не собран"
if not _attached_files_valid(req):
return "Файл контейнера недоступен"
return ""
func is_request_connected(req_id: String) -> bool:
var req = active_requests.get(req_id)
return req != null and bool(req.connected)
func set_request_protocol(req_id: String, protocol: String) -> bool:
var req = active_requests.get(req_id)
if req == null:
return false
if req.sending or req.minigame_active or _send_queue.has(req_id):
if req.sending or req.minigame_active or req.connecting or req.connected or _send_queue.has(req_id):
return false
var clean := _clean_protocol(protocol)
if clean.is_empty():
@@ -572,7 +880,11 @@ func start_send(req_id: String) -> bool:
return false
if not running or _finish_guard:
return false
if req.sending or req.minigame_active or _send_queue.has(req_id):
if req.sending or req.minigame_active or req.connecting or _send_queue.has(req_id):
return false
# C1/C2: модалка (атака/отправка/соединение) уже занимает экран.
if modal_open:
_emit_toast("Сначала закройте текущее окно")
return false
if _minigame != null:
_emit_toast("Сначала закройте мини-игру")
@@ -580,23 +892,34 @@ func start_send(req_id: String) -> bool:
if hour_elapsed < send_disabled_until:
_emit_toast("Отправка временно недоступна")
return false
var need := _required_count(req)
if need <= 0 or req.attached.size() < need:
_emit_toast("Контейнер не собран")
if req.protocol == "tcp" and hour_elapsed < req.connect_cooldown_until:
_emit_toast("СОЕДИНЕНИЕ: ПОВТОР ЧЕРЕЗ %d с" % int(ceil(req.connect_cooldown_until - hour_elapsed)))
return false
if _attached_files_valid(req) == false:
_emit_toast("Файл контейнера недоступен")
var problem := _send_ready_error(req)
if not problem.is_empty():
_emit_toast(problem)
return false
# TCP сначала требует мини-игру соединения; UDP шлёт сразу.
if req.protocol == "tcp" and not req.connected:
return _begin_connection(req)
return _launch_send_minigame(req_id)
## Собранный запрос проходит в обычную мини-игру отправки.
func _launch_send_minigame(req_id: String) -> bool:
var req = active_requests.get(req_id)
if req == null:
return false
var problem := _send_ready_error(req)
if not problem.is_empty():
_emit_toast(problem)
return false
var types := _attached_types(req)
req.required_types = types
var selection: Dictionary = ECONOMY.send_minigame_for(types)
req.send_sum = int(selection.get("sum", 0))
req.minigame_id = str(selection.get("id", "dots"))
req.minigame_tier = clampi(int(selection.get("tier", 1)), 1, 4)
req.minigame_active = true
# Legacy Request.is_ready() перестаёт быть true на время мини-игры/отправки.
_blank_required_slots(req)
request_changed.emit(req_id)
var context := {
@@ -884,11 +1207,15 @@ func report(packets_left_override: int = -1, requests_left_override: int = -1) -
func _tick_requests(dt: float) -> void:
_check_failed_files()
_lost_check_accum += dt
if _lost_check_accum >= 0.5:
_lost_check_accum = 0.0
_check_lost_required()
for id in active_requests.keys():
if not active_requests.has(id):
continue
var req = active_requests[id]
if req.minigame_active or _send_queue.has(str(id)):
if req.minigame_active or req.connecting or _send_queue.has(str(id)):
continue
# D2: отправка уже началась — игрок не может на неё повлиять, поэтому
# таймер запроса замирает и тикает только прогресс передачи. Таймаут
@@ -954,20 +1281,71 @@ func _tick_spawns(dt: float) -> void:
func _tick_request_spawns(dt: float) -> void:
if requests_spawned >= request_cap or _normal_request_count() >= active_request_limit:
if requests_spawned >= request_cap:
spawn_send_accum = 0.0
return
spawn_send_accum += dt
var steps := 0
while spawn_send_accum >= _send_interval_current and steps < MAX_SPAWN_STEPS:
steps += 1
spawn_send_accum -= _send_interval_current
_send_interval_current = _send_interval()
if requests_spawned >= request_cap or _normal_request_count() >= active_request_limit:
spawn_send_accum = 0.0
return
if not _spawn_random_request().is_empty():
break
if _normal_request_count() >= active_request_limit:
# Слот занят: «заряд» не копим, чтобы после освобождения соблюсти
# интервал между запросами.
spawn_send_accum = 0.0
else:
spawn_send_accum += dt
var steps := 0
while spawn_send_accum >= _send_interval_current and steps < MAX_SPAWN_STEPS:
steps += 1
spawn_send_accum -= _send_interval_current
_last_request_attempt_t = hour_elapsed
var req_id := _spawn_random_request()
_send_interval_current = _send_interval()
if not req_id.is_empty():
spawn_send_accum = 0.0
break
if requests_spawned >= request_cap \
or _normal_request_count() >= active_request_limit:
spawn_send_accum = 0.0
break
# Анти-голодание: пока запросов нет, не ждём полный интервал (22 с в H1).
_maybe_starvation_spawn()
func _request_starvation_ready() -> bool:
if not active_requests.is_empty():
return false
if requests_spawned >= request_cap:
return false
if _normal_request_count() >= active_request_limit:
return false
if hour_elapsed - _last_request_attempt_t < REQUEST_ATTEMPT_MIN_GAP:
return false
return not _request_file_pool().is_empty()
func _maybe_starvation_spawn() -> void:
if not _request_starvation_ready():
return
_last_request_attempt_t = hour_elapsed
if not _spawn_random_request().is_empty():
spawn_send_accum = 0.0
## Подсказка для пустого экрана запросов. "" — подсказка неуместна.
func next_request_hint() -> String:
if not running or _finish_guard:
return ""
if requests_spawned >= request_cap:
return "Все запросы часа выданы"
if _normal_request_count() >= active_request_limit:
return ""
if _request_file_pool().is_empty():
return "Нет свободных файлов для запроса"
if _request_starvation_ready():
return "Следующий запрос ~0 с"
var wait := maxf(0.0, _send_interval_current - spawn_send_accum)
if active_requests.is_empty():
var gap_left := maxf(0.0,
REQUEST_ATTEMPT_MIN_GAP - (hour_elapsed - _last_request_attempt_t))
wait = minf(wait, gap_left)
return "Следующий запрос ~%d с" % int(ceil(wait))
func _tick_attacks(dt: float) -> void:
@@ -1091,7 +1469,14 @@ func _complete_send(req_id: String) -> void:
if game != null:
game.streak = streak + 1
_add_stat("requests_done", 1)
_emit_toast("Отправлено: +%d $" % payout)
var files_word := "файлов"
var n10 := n % 10
var n100 := n % 100
if n10 == 1 and n100 != 11:
files_word = "файл"
elif n10 >= 2 and n10 <= 4 and (n100 < 12 or n100 > 14):
files_word = "файла"
_emit_toast("ОТПРАВЛЕНО: %d %s · +%d $" % [n, files_word, payout])
_play_sfx(&"money_up")
# §2.8: при успешной отправке файлы расходуются.
@@ -1111,6 +1496,11 @@ func _cleanup_request(req_id: String, detach: bool) -> void:
for file_id in req.attached.duplicate():
if registry.files.has(str(file_id)):
registry.detach_file(str(file_id))
if registry != null:
for file_id in req.required:
var file = registry.files.get(str(file_id))
if file != null and file.required_by == req_id:
file.required_by = ""
req.attached.clear()
req.required_types.clear()
_send_queue.erase(req_id)
@@ -1123,6 +1513,7 @@ func _cleanup_request(req_id: String, detach: bool) -> void:
req.sending = false
req.slot_reserved = false
req.minigame_active = false
req.connecting = false
req.set_meta("queued", false)
active_requests.erase(req_id)
request_removed.emit(req_id)
@@ -1151,10 +1542,74 @@ func _check_failed_files() -> void:
_clear_required_slot(req, str(file_id))
changed = true
if changed:
req.required_types = _attached_types(req)
request_changed.emit(str(req_id))
func _check_lost_required() -> void:
if registry == null or active_requests.is_empty():
return
for req_id in active_requests.keys().duplicate():
if not active_requests.has(req_id):
continue
var req = active_requests[req_id]
if req == null:
continue
# M5: во время активной передачи файлы уже «в пути» — потерю не
# подменяем и не начисляем выплату за уничтоженный посреди отправки файл.
if bool(req.sending):
continue
var changed := false
for i in range(req.required.size()):
var fid := str(req.required[i])
var file = registry.files.get(fid)
var lost := file == null
if file != null:
var loc := str(file.location)
lost = loc == "failed" or loc == "malicious"
if not lost:
continue
var lost_type := ""
if file != null:
lost_type = str(file.type)
elif i < req.required_types.size() and req.required_types.size() == req.required.size():
lost_type = str(req.required_types[i])
if req.attached.has(fid):
req.attached.erase(fid)
if file != null and str(file.attached_request) == str(req_id):
registry.detach_file(fid)
changed = true
if file != null and file.required_by == str(req_id):
file.required_by = ""
var replacement := _find_substitute(lost_type)
if replacement.is_empty():
# Оставляем дырку: запрос можно отклонить, отправка невозможна.
continue
req.required[i] = replacement
var substitute = registry.files.get(replacement)
if substitute != null:
substitute.required_by = str(req_id)
if i < req.required_types.size():
req.required_types[i] = str(substitute.type)
else:
req.required_types.append(str(substitute.type))
changed = true
if changed:
request_changed.emit(str(req_id))
## Замена потерянного required-файла: равномерный пул, приоритет — тот же тип.
func _find_substitute(preferred_type: String) -> String:
var pool := _request_file_pool()
if pool.is_empty():
return ""
_shuffle_array(pool)
if not preferred_type.is_empty():
for file in pool:
if str(file.type) == preferred_type:
return str(file.id)
return str(pool[0].id)
func force_close_send_minigame() -> void:
var req_id := _minigame_request_id
if _minigame == null and req_id.is_empty():
@@ -1396,37 +1851,25 @@ func _default_type_weights(p_hour: int) -> Dictionary:
func _required_count(req) -> int:
if req == null:
return 0
if req.has_meta("required_count"):
return maxi(0, int(req.get_meta("required_count")))
return maxi(0, int(req.required_count()))
var count := 0
if req.required is Array:
count = (req.required as Array).size()
if count <= 0 and req.has_meta("required_count"):
count = int(req.get_meta("required_count"))
return clampi(count, 0, MAX_REQUIRED_FILES)
## Совместимость со старым UI: required содержит N слотов; заполненный слот
## несёт id файла, пустой — служебный placeholder "__slot_...".
func _fill_required_slot(req, file_id: String) -> void:
if req == null:
return
for i in range(req.required.size()):
var value := str(req.required[i])
if value.begins_with("__slot_"):
req.required[i] = file_id
return
## Legacy-совместимость: required теперь хранит конкретные id, слоты не меняем.
func _fill_required_slot(_req, _file_id: String) -> void:
pass
func _clear_required_slot(req, file_id: String) -> void:
if req == null:
return
for i in range(req.required.size()):
if str(req.required[i]) == file_id:
req.required[i] = "__slot_%s_%d" % [req.id, i]
return
func _clear_required_slot(_req, _file_id: String) -> void:
pass
func _blank_required_slots(req) -> void:
if req == null:
return
for i in range(req.required.size()):
req.required[i] = "__slot_%s_%d" % [req.id, i]
func _blank_required_slots(_req) -> void:
pass
func _request_ready(req) -> bool:
@@ -1436,19 +1879,30 @@ func _request_ready(req) -> bool:
return false
if _send_queue.has(str(req.id)):
return false
return req.attached.size() >= _required_count(req) and _required_count(req) > 0
var need := _required_count(req)
if need <= 0:
return false
for file_id in req.required:
if not req.attached.has(str(file_id)):
return false
return true
func _attached_files_valid(req) -> bool:
if registry == null:
if req == null or registry == null:
return false
# Исчезнувший required-файл (consume/remove) делает запрос неотправляемым.
for file_id in req.required:
if not registry.files.has(str(file_id)):
return false
for file_id in req.attached:
var file = registry.files.get(str(file_id))
if file == null:
return false
if is_folder_locked(str(file.folder_id)):
return false
if str(file.location) == "locked" or str(file.location) == "table":
var loc := str(file.location)
if loc == "locked" or loc == "table" or loc == "failed" or loc == "malicious":
return false
return true
@@ -1489,8 +1943,8 @@ func _roll_required_count() -> int:
for i in range(keys.size()):
acc += float(weights[i])
if roll < acc:
return clampi(int(str(keys[i])), 1, 3)
return clampi(int(str(keys[keys.size() - 1])), 1, 3)
return clampi(int(str(keys[i])), 1, MAX_REQUIRED_FILES)
return clampi(int(str(keys[keys.size() - 1])), 1, MAX_REQUIRED_FILES)
# --- канал -----------------------------------------------------------------
@@ -1652,9 +2106,14 @@ func _clear_entities() -> void:
for file_id in req.attached.duplicate():
if registry.files.has(str(file_id)):
registry.detach_file(str(file_id))
for file_id in req.required:
var file = registry.files.get(str(file_id))
if file != null and file.required_by == str(req_id):
file.required_by = ""
req.attached.clear()
req.sending = false
req.minigame_active = false
req.connecting = false
req.slot_reserved = false
for packet_id in active_packets.keys().duplicate():
_remove_packet(str(packet_id))
@@ -1721,9 +2180,7 @@ func _on_show_attack_minigame(game_node, kind: String) -> void:
show_attack_minigame.emit(game_node, kind)
## Legacy-хук: соединения больше нет.
func _on_connection_finished(_success: bool, _entity_id: String) -> void:
pass
## Legacy-хук больше не используется: UI зовёт connection_finished(req_id, success).
func _add_money(delta: int) -> void:
+2
View File
@@ -31,6 +31,8 @@ var type: String = "text"
var folder_id: String = ""
var location: StringName = &"folder"
var attached_request: String = ""
## Runtime-резерв: id запроса, для которого файл обязателен (не сохраняется).
var required_by: String = ""
var malicious_kind: String = ""
var lock_until: float = 0.0
var cooldown_until: float = 0.0
+12
View File
@@ -18,6 +18,12 @@ var timer_total: float = 0.0
var slot_reserved: bool = false
var connected: bool = false
## TCP: идёт мини-игра установления соединения (полоска). Таймер заморожен.
var connecting: bool = false
## TCP: сколько раз игрок пытался установить соединение в этом часе.
var connect_attempts: int = 0
## TCP: до какого hour_elapsed действует пауза после неудачи.
var connect_cooldown_until: float = 0.0
var minigame_active: bool = false
var sending: bool = false
@@ -91,6 +97,9 @@ func to_dict() -> Dictionary:
"timer_total": timer_total,
"slot_reserved": slot_reserved,
"connected": connected,
"connecting": connecting,
"connect_attempts": connect_attempts,
"connect_cooldown_until": connect_cooldown_until,
"minigame_active": minigame_active,
"sending": sending,
"quality": quality,
@@ -120,6 +129,9 @@ static func from_dict(d: Dictionary):
r.timer_total = float(d.get("timer_total", 0.0))
r.slot_reserved = bool(d.get("slot_reserved", false))
r.connected = bool(d.get("connected", false))
r.connecting = bool(d.get("connecting", false))
r.connect_attempts = maxi(0, int(d.get("connect_attempts", 0)))
r.connect_cooldown_until = maxf(0.0, float(d.get("connect_cooldown_until", 0.0)))
r.minigame_active = bool(d.get("minigame_active", false))
r.sending = bool(d.get("sending", false))
r.quality = clampf(float(d.get("quality", 1.0)), 0.0, 1.0)
+21 -8
View File
@@ -38,6 +38,7 @@ const HOURS_PER_RUN := 9
const FOLDER_CAPACITY := 16
const HOUR_DIRECTOR_SCRIPT := preload("res://scripts/core/hour_director.gd")
const CONNECTION_GAME_SCRIPT := preload("res://scripts/minigames/connection_game.gd")
const SHOP_SCRIPT := preload("res://scripts/systems/shop.gd")
const SAVE_SCRIPT := preload("res://scripts/systems/save_system.gd")
const ECONOMY := preload("res://scripts/core/economy.gd")
@@ -399,28 +400,38 @@ func _on_hour_toast(text: String) -> void:
toast.emit(text)
func _on_show_connection(game_node) -> void:
modal_open = true
show_connection.emit(game_node)
## HourDirector эмитит трио (request_id, hour, successes): ConnectionGame
## создаёт контроллер, а Main только монтирует готовую ноду в modal_root.
func _on_show_connection(request_id: String, hour: int, successes: int) -> void:
var zone_level := 0
if game != null and game.has_method("level"):
# Бонус зоны соединения привязан к уровню лота «Регламент обработки».
zone_level = maxi(0, int(game.level("timer_level")))
var node = CONNECTION_GAME_SCRIPT.new()
node.start(int(hour), int(successes), zone_level, str(request_id))
# Через set_modal_open(), иначе HourDirector не узнаёт о модалке и час
# продолжает спавнить пакеты/запросы под ней.
set_modal_open(true)
show_connection.emit(node)
func _on_show_minigame(game_node) -> void:
modal_open = true
set_modal_open(true)
show_minigame.emit(game_node)
func _on_minigame_closed(req_id: String) -> void:
modal_open = false
set_modal_open(false)
minigame_closed.emit(req_id)
func _on_show_attack_minigame(game_node, kind: String) -> void:
modal_open = true
set_modal_open(true)
show_attack_minigame.emit(game_node, kind)
func _on_attack_resolved(kind: String, success: bool, tier: int) -> void:
modal_open = false
set_modal_open(false)
attack_resolved.emit(kind, success, tier)
@@ -487,7 +498,9 @@ func _reset_director_state() -> void:
if hour_director == null:
return
hour_director.reset_carry()
hour_director.start_hour(1)
# Подготовка перед H1: seed-файл создаём, но активный запрос/резервации
# не оставляем — иначе игрок не видит запрос, а файл уже занят.
hour_director.reset_for_prep()
hour_director.running = false
hour_director.paused = false
hour_director.set_modal_open(false)
+894 -86
View File
File diff suppressed because it is too large. Load diff
+4 -2
View File
@@ -10,7 +10,8 @@ extends MiniGame
const GAME_ID := "ddos"
const TITLE_RU := "ЩИТ"
const FIELD := Rect2(18.0, 58.0, 604.0, 246.0)
## Поле заканчивается на y=294, чтобы подсказка (y=298..311) не легла на доску.
const FIELD := Rect2(18.0, 58.0, 604.0, 236.0)
const PACKET_W := 18.0
const PACKET_H := 20.0
const SHIELD_H := 16.0
@@ -370,7 +371,8 @@ func _draw_game() -> void:
func _draw_footer_hint() -> void:
_draw_label("ЩИТ СЛЕДУЕТ ЗА МЫШЬЮ ПО X · СТРЕЛКИ ← → ТОЖЕ", Vector2(18.0, 350.0),
# Подсказка живёт в свободной полосе между полем (y=294) и футером (y=320).
_draw_label("ЩИТ СЛЕДУЕТ ЗА МЫШЬЮ ПО X · СТРЕЛКИ ← → ТОЖЕ", Vector2(18.0, 298.0),
10, Theme90.SHADE, 604.0)
+6 -10
View File
@@ -18,7 +18,7 @@ const COLS := 3
const CARD_W := 190.0
const CARD_GAP := 8.0
const GRID_TOP := 58.0
const GRID_BOTTOM := 306.0
const GRID_BOTTOM := 294.0
const VERIFY_RECT := Rect2(466.0, 320.0, 156.0, 28.0)
const TIERS := {
@@ -533,14 +533,9 @@ func _card_rect(index: int) -> Rect2:
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)
@@ -637,10 +632,11 @@ func _draw_card(index: int) -> void:
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)
var text := "ПОДСКАЗКИ: " + " · ".join(hints) if not hints.is_empty() \
else "СНИМАЙТЕ ТОЛЬКО МУСОР"
# Полная подсказка — в полосе между сеткой (y=294) и футером (y=320).
_draw_label(Theme90.fit(Theme90.ui(), text, 10, 604.0),
Vector2(18.0, 298.0), 10, Theme90.SHADE, 604.0)
# --- служебное -------------------------------------------------------------
+7 -6
View File
@@ -13,10 +13,10 @@ extends MiniGame
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 ISSUE_RECT := Rect2(142.0, 262.0, 166.0, 30.0)
const DENY_RECT := Rect2(332.0, 262.0, 166.0, 30.0)
const FILE_RECT := Rect2(24.0, 62.0, 286.0, 196.0)
const PASS_RECT := Rect2(330.0, 62.0, 286.0, 196.0)
const STAMP_TTL := 0.12
const TIERS := {
@@ -604,5 +604,6 @@ func _draw_footer_hint() -> void:
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)
# Подсказка — в полосе между кнопками (y=292) и футером (y=320).
_draw_label(Theme90.fit(Theme90.ui(), "СВЕРЬТЕ: " + " · ".join(labels), 10, 604.0),
Vector2(18.0, 298.0), 10, Theme90.SHADE, 604.0)
+3 -2
View File
@@ -14,7 +14,7 @@ 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_BOTTOM := 294.0
const GRID_GAP := 8.0
const TIERS := {
@@ -516,7 +516,8 @@ func _draw_footer_hint() -> void:
text = "ИКОНКА ДОКУМЕНТА НИЧЕГО НЕ ЗНАЧИТ · НАВЕДИТЕ НА ФАЙЛ"
elif masking == "similar_names":
text = "«EXE» В ИМЕНИ — ЕЩЁ НЕ ПРИГОВОР · ПРОВЕРЬТЕ РАСШИРЕНИЕ"
_draw_label(text, Vector2(18.0, 350.0), 10, Theme90.SHADE, 604.0)
# Подсказка — в полосе между сеткой (y=294) и футером (y=320).
_draw_label(text, Vector2(18.0, 298.0), 10, Theme90.SHADE, 604.0)
# --- служебное -------------------------------------------------------------
+65 -7
View File
@@ -20,6 +20,9 @@ const STICK_H := 18.0
const STICK_SPEEDS: Array[float] = [440.0, 340.0, 280.0]
const FLASH_SEC := 0.8
const CLICKS_REQUIRED := 3
## Окно гасит клик, который открыл панель: кнопка «Соединиться» находится
## вне модалки, но тот же/следующий event мог долететь уже до неё.
const INPUT_GRACE_SEC := 0.35
var hour: int = 1
var successes: int = 0
@@ -36,6 +39,8 @@ var missed: bool = false
var game_over: bool = false
var _emitted: bool = false
var _input_grace: float = 0.0
var _armed: bool = false
func _init() -> void:
@@ -49,7 +54,16 @@ func _init() -> void:
func _ready() -> void:
# start() мог быть вызван до add_child: окно монтируется уже после клика,
# поэтому отсчёт grace начинаем заново в момент появления в дереве.
if running:
_input_grace = INPUT_GRACE_SEC
_armed = false
_center_on_screen()
# Main ставит модалку через PRESET_CENTER уже после add_child, а в Godot
# 4.7.2 этот пресет обнуляет offsets (окно уезжает в правый нижний угол).
# Повторяем центрирование в конце кадра, когда родитель уже modal_root.
call_deferred("_center_on_screen")
get_viewport().size_changed.connect(_center_on_screen)
@@ -64,6 +78,8 @@ func start(p_hour: int, p_successes: int, p_zone_level: int, p_request_id: Strin
running = true
missed = false
flash_left = 0.0
_input_grace = INPUT_GRACE_SEC
_armed = false
_reset_attempt()
visible = true
set_process(true)
@@ -118,6 +134,12 @@ func _process(delta: float) -> void:
return
# Вспышка идёт по реальному delta, движение палочек — с D11-капом 0.1.
var raw_dt := maxf(delta, 0.0)
# Grace тикает по реальному времени до игровой логики: клик, открывший
# панель, не должен успеть стать попыткой соединения.
if _input_grace > 0.0:
_input_grace = maxf(0.0, _input_grace - raw_dt)
if _input_grace <= 0.0:
_armed = true
if flash_left > 0.0:
flash_left = maxf(0.0, flash_left - raw_dt)
if flash_left <= 0.0:
@@ -140,19 +162,47 @@ func _process(delta: float) -> void:
func _input(event: InputEvent) -> void:
if not running or game_over:
return
# Esc доступен всегда (в том числе в grace): отмена не должна штрафовать.
if event is InputEventKey and event.pressed and not event.echo:
if event.keycode == KEY_ESCAPE:
_mark_input_handled()
_finish(false)
return
if event.keycode == KEY_SPACE:
if event.keycode == KEY_SPACE or event.keycode == KEY_ENTER \
or event.keycode == KEY_KP_ENTER:
if _input_grace > 0.0:
# Тот же клик/нажатие, что открыло модалку, не считаем попыткой.
_mark_input_handled()
return
if not _armed:
return
_mark_input_handled()
_attempt_click()
return
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
if event.button_index != MOUSE_BUTTON_LEFT:
return
if _input_grace > 0.0:
# И press, и release в grace гасим: событие могло прийти от кнопки
# «СОЕДИНИТЬСЯ», которая находится вне окна модалки.
_mark_input_handled()
return
if event.pressed:
# Только свежее нажатие: отпускание кнопки ниже взводит _armed.
if not _armed:
return
# Клики вне окна 640×240 (карточка запроса, меню, статус-бар за
# модалкой) не должны считаться попыткой соединения: иначе промах
# закрывает панель, штрафует SLA и вешает кулдаун.
var local: Vector2 = event.position - global_position
if not Rect2(Vector2.ZERO, size).has_point(local):
return
_mark_input_handled()
_attempt_click()
else:
# Первое отпускание после grace тоже разрешает попытки (защита от
# зажатой кнопки/double-click).
_armed = true
func _attempt_click() -> void:
@@ -266,11 +316,19 @@ func _draw() -> void:
func _center_on_screen() -> void:
var viewport := get_viewport()
if viewport == null:
return
var screen := get_viewport_rect().size
position = ((screen - WINDOW_SIZE) * 0.5).floor()
# get_viewport_rect() — физический размер окна (1920×1080), а UI-канва
# логическая 1280×720. Центрируем по реальному родителю (modal_root),
# иначе модалка уезжает в правый нижний квадрант. Fallback — вьюпорт.
var area := Vector2.ZERO
if is_inside_tree() and get_parent() is Control:
area = (get_parent() as Control).size
if area.x <= 0.0 or area.y <= 0.0:
# Вне дерева (нода создана до add_child) вьюпорта ещё нет —
# центрируем позже в _ready()/повторном start().
if not is_inside_tree():
return
area = get_viewport_rect().size
position = ((area - size) * 0.5).floor()
position.x = maxf(0.0, position.x)
position.y = maxf(0.0, position.y)
+154 -45
View File
@@ -12,7 +12,9 @@ extends Control
signal finished(quality: float)
const WINDOW_SIZE := Vector2(640.0, 360.0)
const HEADER_H := 30.0
## Шапка двухстрочная: название + цель игры. Контент мини-игр начинается
## с y=44, поэтому высота 42 не перекрывает жёстко заданные координаты.
const HEADER_H := 42.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)
@@ -20,24 +22,39 @@ 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 SEND_IDS: Array[String] = [
"dots", "sound", "wires", "text", "video", "photo", "maze",
"dots", "sound", "text", "video", "photo", "maze",
]
const ATTACK_IDS: Array[String] = [
"junk", "ddos", "phishing", "ransomware",
]
const GAME_TITLES := {
"dots": "ОДНА ЛИНИЯ",
"sound": "УРОВНИ",
"wires": "ПРОВОДА",
"text": "ЧУЖОЕ СЛОВО",
"video": "РАСКАДРОВКА",
"photo": "КАДР",
"maze": "ЛАБИРИНТ",
"junk": "ФИЛЬТР",
"ddos": "ЩИТ",
"phishing": "ПАСПОРТ",
"ransomware": "КАРАНТИН",
}
const LEGACY_TYPES := {
"text": "text",
"sound": "sound",
"photo": "photo",
"video": "video",
"connection": "wires",
"connect": "wires",
# Старые id «проводов» после удаления игры ведут на ближайшую по смыслу.
"connection": "dots",
"connect": "dots",
}
## Короткие цели для игр атак (файлы атак не переопределяют _goal_text).
## Игры отправки возвращают свою строку сами.
const ATTACK_GOALS := {
"junk": "СНИМИТЕ МУСОРНЫЕ ФАЙЛЫ · НОРМАЛЬНЫЕ НЕ ТРОГАЙТЕ",
"ddos": "ДВИГАЙТЕ ЩИТ И ОТРАЖАЙТЕ ПАКЕТЫ",
"phishing": "СВЕРЬТЕ ДАННЫЕ КЛИЕНТА С ЭТАЛОНОМ",
"ransomware": "ИЗОЛИРУЙТЕ ФАЙЛЫ С ОПАСНЫМ РАСШИРЕНИЕМ",
}
## Общие поля контракта.
@@ -76,6 +93,10 @@ func _init() -> void:
func _ready() -> void:
_center_on_screen()
# Main ставит модалку через PRESET_CENTER уже после add_child, а в Godot
# 4.7.2 этот пресет обнуляет offsets (окно уезжает в правый нижний угол).
# Повторяем центрирование в конце кадра, когда родитель уже modal_root.
call_deferred("_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)
@@ -94,10 +115,12 @@ func start(type_or_id: String, p_difficulty: float = 1.0, p_seed: int = 0,
game_id = resolved
difficulty = maxf(0.0, float(p_difficulty))
# Старый вызов передавал 2.5 и т.п.; фабрика передаёт 1..4.
tier = clampi(int(round(difficulty)), 1, 4)
# Старый вызов передавал 2.5 и т.п.; фабрика передаёт 1..3 (отправка)
# или 1..4 (атаки). Атаки сохраняют четыре ступени.
var max_tier := 4 if is_attack_id(game_id) else 3
tier = clampi(int(round(difficulty)), 1, max_tier)
if difficulty >= 1.0 and difficulty <= 4.0 and is_equal_approx(difficulty, roundf(difficulty)):
tier = clampi(int(difficulty), 1, 4)
tier = clampi(int(difficulty), 1, max_tier)
context = p_context.duplicate(true)
seed_value = p_seed
@@ -146,15 +169,37 @@ func title() -> String:
return str(GAME_TITLES.get(game_id, "МИНИ-ИГРА"))
## Максимум ступеней: у отправки три (после удаления T1), у атак четыре.
func max_tier() -> int:
return 4 if is_attack or is_attack_id(game_id) else 3
func tier_label() -> String:
return "T%d" % tier
return "СЛОЖНОСТЬ %d/%d" % [clampi(tier, 1, max_tier()), max_tier()]
## Подпись кнопки отказа: атаки сдаются, отправки пропускаются.
## Текст с подсказкой «2 клика» добавляется только при отрисовке, чтобы
## контракт skip_label() оставался прежним для тестов и других систем.
func skip_label() -> String:
return "СДАТЬСЯ" if is_attack else "ПРОПУСТИТЬ"
## Одна строка цели игры под шапкой. Переопределяется наследниками.
func _goal_text() -> String:
return str(ATTACK_GOALS.get(game_id, ""))
## Прогресс 0..1 для тонкой полосы в шапке; -1 — не показывать.
func _progress_ratio() -> float:
return -1.0
## Короткая статистика для экрана результата (одна строка).
func _result_stats() -> String:
return ""
func quality() -> float:
return quality_value
@@ -205,11 +250,17 @@ func _emit_finished_once() -> void:
func _default_result_detail() -> String:
var parts: Array[String] = []
var stats := _result_stats()
if not stats.is_empty():
parts.append(stats)
var reward := str(context.get("reward_text", ""))
var penalty := str(context.get("penalty_text", ""))
if not reward.is_empty():
return reward
return penalty
parts.append(reward)
var penalty := str(context.get("penalty_text", ""))
if not penalty.is_empty():
parts.append(penalty)
return " · ".join(parts)
# --- ввод ------------------------------------------------------------------
@@ -309,15 +360,29 @@ func _draw() -> void:
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)
Theme90.text(self, font, Vector2(tr.position.x + 24.0, tr.position.y + 3.0),
title(), 18, Theme90.NAVY_DEEP, tr.size.x - 400.0)
var badge := Rect2(tr.end.x - 196.0, tr.position.y + 7.0, 42.0, 16.0)
var badge := Rect2(tr.end.x - 124.0, tr.position.y + 6.0, 116.0, 17.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,
Theme90.text_vcenter(self, font, badge, tier_label(), 11, 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)
var ratio := _progress_ratio()
if ratio >= 0.0:
var bar := Rect2(tr.end.x - 252.0, tr.position.y + 16.0, 104.0, 6.0)
Theme90.text(self, font, Vector2(bar.position.x - 38.0, bar.position.y - 2.0),
"%d%%" % roundi(clampf(ratio, 0.0, 1.0) * 100.0), 10, Theme90.DARK,
34.0, HORIZONTAL_ALIGNMENT_RIGHT)
_draw_bar(bar, ratio, Theme90.SUCCESS if ratio >= 0.999 else Theme90.ACCENT)
var goal := _goal_text()
if not goal.is_empty():
draw_rect(Rect2(tr.position.x + 14.0, tr.position.y + 26.0, 2.0, 12.0),
Theme90.ACCENT_LIGHT)
Theme90.text(self, font, Vector2(tr.position.x + 24.0, tr.position.y + 25.0),
Theme90.fit(font, goal, 11, tr.size.x - 48.0), 11, Theme90.DARK,
tr.size.x - 48.0)
_draw_game()
_draw_skip_button()
@@ -344,13 +409,15 @@ func _draw_skip_button() -> void:
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,
label = "ЕЩЁ КЛИК — СДАЧА" if is_attack else "ЕЩЁ КЛИК — ПРОПУСК"
else:
label = "%s · 2 КЛИКА" % label
Theme90.text_vcenter(self, Theme90.ui(), SKIP_RECT, label, 11, fg,
-1, HORIZONTAL_ALIGNMENT_CENTER)
func _draw_action_button(rect: Rect2, label: String, enabled: bool = true,
primary: bool = true) -> void:
primary: bool = true, size_px: int = 12) -> void:
var face := Theme90.PAPER_HI
var fg := Theme90.INK
var border := Theme90.BORDER
@@ -360,17 +427,17 @@ func _draw_action_button(rect: Rect2, label: String, enabled: bool = true,
border = Theme90.ACCENT.darkened(0.18)
elif not enabled:
face = Theme90.PAPER
fg = Theme90.SHADE
fg = Theme90.DARK
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,
Theme90.text_vcenter(self, Theme90.ui(), rect, label, size_px, 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,
Theme90.text_vcenter(self, Theme90.ui(), rect, label, 11, Theme90.DARK,
-1, HORIZONTAL_ALIGNMENT_CENTER)
@@ -404,31 +471,39 @@ func _result_continue_rect() -> Rect2:
func _result_panel_rect() -> Rect2:
return Rect2((size.x - 380.0) * 0.5, (size.y - 196.0) * 0.5, 380.0, 196.0)
return Rect2((size.x - 400.0) * 0.5, (size.y - 232.0) * 0.5, 400.0, 232.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(self, Theme90.logo(), Vector2(panel.position.x, panel.position.y + 10.0),
"РЕЗУЛЬТАТ", 13, Theme90.NAVY_DEEP, panel.size.x,
HORIZONTAL_ALIGNMENT_CENTER)
Theme90.text(self, Theme90.ui(), Vector2(panel.position.x, panel.position.y + 30.0),
Theme90.fit(Theme90.ui(), title(), 12, panel.size.x - 36.0), 12, Theme90.NAVY,
panel.size.x, 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.text(self, Theme90.logo(), Vector2(panel.position.x, panel.position.y + 46.0),
q_text, 30, Theme90.INK, panel.size.x, HORIZONTAL_ALIGNMENT_CENTER)
var qcol := 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)
_draw_bar(Rect2(panel.position.x + 26.0, panel.position.y + 86.0,
panel.size.x - 52.0, 8.0), quality_value, qcol)
var verdict := verdict_for(quality_value)
Theme90.text(self, Theme90.ui(), Vector2(panel.position.x, panel.position.y + 102.0),
verdict, 22, qcol, 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,
panel.position.y + 134.0), Theme90.fit(Theme90.ui(), _result_detail, 11,
panel.size.x - 36.0), 11, Theme90.DARK, panel.size.x - 36.0,
HORIZONTAL_ALIGNMENT_CENTER)
Theme90.text(self, Theme90.ui(), Vector2(panel.position.x,
panel.position.y + 154.0), "Esc — продолжить", 10, Theme90.DARK,
panel.size.x, HORIZONTAL_ALIGNMENT_CENTER)
var cont := _result_continue_rect()
_draw_action_button(cont, "ПРОДОЛЖИТЬ", true, true)
_draw_action_button(cont, "ПРОДОЛЖИТЬ", true, true, 13)
# --- служебное -------------------------------------------------------------
@@ -437,9 +512,23 @@ static func resolve_game_id(value: String) -> String:
var v := value.strip_edges().to_lower()
if SEND_IDS.has(v):
return v
if ATTACK_IDS.has(v):
return v
return str(LEGACY_TYPES.get(v, ""))
static func is_attack_id(value: String) -> bool:
var v := str(value).strip_edges().to_lower()
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.has_method("minigame_def"):
var def = data.call("minigame_def", v)
if def is Dictionary and not (def as Dictionary).is_empty():
return str((def as Dictionary).get("kind", "")).to_lower() == "attack"
return ATTACK_IDS.has(v)
## Безопасное чтение data/minigame_def(id) с fallback на balance.json.
## Возвращает копию словаря; при отсутствии данных — пустой словарь.
static func data_minigame_def(id: String) -> Dictionary:
@@ -466,20 +555,32 @@ static func data_minigame_def(id: String) -> Dictionary:
## Строка tier из Data.minigame_def(id). Поддерживает tiers как Array
## (текущий формат balance.json) и как Dictionary (старый/альтернативный).
##
## Отправка теперь имеет 3 ступени, атаки — 4. Если balance.json ещё хранит
## старые 4 строки для отправки, tier 1 читает вторую строку (старый T2):
## так игра работает и до, и после синхронного сдвига таблиц.
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 attack_def := is_attack_id(id) \
or str(def.get("kind", "")).to_lower() == "attack"
var t := clampi(p_tier, 1, 4 if attack_def else 3)
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)
var index := t - 1
if not attack_def and arr.size() >= 4:
index = clampi(t, 0, arr.size() - 1)
if index >= 0 and index < arr.size() and arr[index] is Dictionary:
row = (arr[index] as Dictionary).duplicate(true)
elif tiers is Dictionary:
var map := tiers as Dictionary
var value = map.get(str(t), map.get(t, null))
var key := t
if not attack_def and map.size() >= 4:
key = clampi(t + 1, 1, 4)
var value = map.get(str(key), map.get(key, null))
if value is Dictionary:
row = (value as Dictionary).duplicate(true)
if row.is_empty():
@@ -491,11 +592,19 @@ static func data_minigame_tier(id: String, p_tier: int) -> Dictionary:
func _center_on_screen() -> void:
var viewport := get_viewport()
if viewport == null:
return
var screen := get_viewport_rect().size
position = ((screen - WINDOW_SIZE) * 0.5).floor()
# get_viewport_rect() отдаёт ФИЗИЧЕСКИЙ размер окна (1920×1080), а UI-канва
# логическая 1280×720. Центрируем по фактическому родителю (modal_root),
# иначе окно уезжает в правый нижний квадрант. Fallback — вьюпорт.
var area := Vector2.ZERO
if is_inside_tree() and get_parent() is Control:
area = (get_parent() as Control).size
if area.x <= 0.0 or area.y <= 0.0:
# Вне дерева (нода создана фабрикой до add_child) вьюпорта ещё нет —
# центрируем позже в _ready()/повторном start().
if not is_inside_tree():
return
area = get_viewport_rect().size
position = ((area - size) * 0.5).floor()
position.x = maxf(0.0, position.x)
position.y = maxf(0.0, position.y)
+25 -20
View File
@@ -4,12 +4,13 @@ extends RefCounted
##
## create_send() загружает res://scripts/minigames/send/<id>.gd.
## create_attack() — заготовка: возвращает null, если скрипта атаки ещё нет.
## send_for_types() сначала спрашивает Economy.send_minigame_for(), если этот
## метод уже реализован другим агентом, иначе использует локальную формулу.
## selection_for_sum()/send_for_types() делегируют Economy (единственный
## источник правды: 6 игр, 3 ступени, 27 достижимых сумм).
const SEND_IDS: Array[String] = [
"dots", "sound", "wires", "text", "video", "photo", "maze",
"dots", "sound", "text", "video", "photo", "maze",
]
const SEND_TIERS := 3
const ATTACK_IDS: Array[String] = [
"junk", "ddos", "phishing", "ransomware",
]
@@ -22,6 +23,7 @@ const COEFF := {
const SEND_DIR := "res://scripts/minigames/send"
const ATTACK_DIR := "res://scripts/minigames/attack"
const ECONOMY_PATH := "res://scripts/core/economy.gd"
const ECONOMY_SCRIPT := preload("res://scripts/core/economy.gd")
static func create_send(game_id: String, tier: int, seed: int,
@@ -42,7 +44,7 @@ static func create_send(game_id: String, tier: int, seed: int,
if node != null:
node.free()
return null
node.start(id, clampi(tier, 1, 4), seed, context)
node.start(id, clampi(tier, 1, SEND_TIERS), seed, context)
return node
@@ -76,20 +78,22 @@ static func send_ids() -> Array[String]:
static func tier_for_hour(hour: int) -> int:
return clampi(1 + int(floor(float(maxi(1, hour) - 2) / 2.0)), 1, 4)
return clampi(1 + int(floor(float(maxi(1, hour) - 2) / 2.0)), 1, SEND_TIERS)
## Единая формула выбора: idx = S-1, tier = floor(idx/7)+1,
## игра = SEND_ORDER[idx % 7].
## Канонический выбор: полностью делегируется Economy.selection_for_sum()
## (rank в achievable_sums, таблица rank_games, 3 ступени). Здесь только
## страховка: не пропускаем наружу неизвестную игру и ступень вне 1..3.
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,
}
var result: Dictionary = ECONOMY_SCRIPT.selection_for_sum(sum)
var id := str(result.get("id", "")).strip_edges().to_lower()
if not SEND_IDS.has(id):
id = SEND_IDS[0]
result["id"] = id
result["tier"] = clampi(int(result.get("tier", 1)), 1, SEND_TIERS)
result["sum"] = sum
return result
static func send_for_types(types: Array) -> Dictionary:
@@ -97,12 +101,13 @@ static func send_for_types(types: Array) -> Dictionary:
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 id := str(delegated.get("id", "")).strip_edges().to_lower()
# Economy может работать по старым таблицам: «wires» уже нет, а
# ступень могла остаться четвёртой. Не пропускаем такое наружу.
if SEND_IDS.has(id):
delegated["id"] = id
delegated["tier"] = clampi(int(delegated.get("tier", 1)), 1, SEND_TIERS)
return delegated
var sum := 0
for type in types:
sum += int(COEFF.get(str(type).to_lower(), 1))
+96 -24
View File
@@ -6,10 +6,9 @@ extends MiniGame
## новый локальный 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},
1: {"cols": 4, "rows": 3, "obstacles": 2, "undo_penalty": 0.04},
2: {"cols": 4, "rows": 4, "obstacles": 4, "undo_penalty": 0.04},
3: {"cols": 5, "rows": 4, "obstacles": 6, "undo_penalty": 0.03},
}
const MAX_BOARD_ATTEMPTS := 16
const MAX_START_TRIES := 8
@@ -20,6 +19,7 @@ var board: Dictionary = {}
var _path: Array = []
var _undos: int = 0
var _invalid: int = 0
var _dragging: bool = false
var _cell_rects: Array[Rect2] = []
var _cell_ids: Array = []
var _stride: float = 60.0
@@ -35,13 +35,30 @@ func _setup() -> void:
_path.clear()
_undos = 0
_invalid = 0
_dragging = false
_build_geometry()
func _goal_text() -> String:
return "ВЕДИТЕ ЛИНИЮ ОТ «С» ПО ВСЕМ КЛЕТКАМ · R — СБРОС"
func _progress_ratio() -> float:
var free_count := int(board.get("free_count", 0))
if free_count <= 0:
return 0.0
return clampf(float(_path.size()) / float(free_count), 0.0, 1.0)
func _result_stats() -> String:
return "ЛИНИЯ %d/%d · ОТКАТОВ %d · ОШИБОК %d" % [_path.size(),
int(board.get("free_count", 0)), _undos, _invalid]
# --- параметры -------------------------------------------------------------
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 base: Dictionary = (TIERS.get(clampi(p_tier, 1, 3), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("dots", p_tier)
if row.is_empty():
return base
@@ -352,47 +369,104 @@ func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
var id := _cell_at(local)
if id < 0:
return false
_dragging = false
if _path.is_empty():
if id == int(board.get("start", -1)):
_path = [id]
_dragging = true
_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()
_pop_path()
_dragging = true
return true
if id == last:
_dragging = true
return true
if not free.has(id) or _path.has(id):
if _try_append_cell(id):
_dragging = true
else:
# Ошибкой считается только осознанный клик, не движение курсора.
_invalid += 1
queue_redraw()
return true
return true
## Общая точка роста пути для клика и drag: молча игнорирует недопустимые
## клетки, чтобы быстрый проход мышью не портил качество.
func _try_append_cell(id: int) -> bool:
if id < 0 or _path.is_empty() or _finished:
return false
var last := int(_path[_path.size() - 1])
if id == last:
return false
var free := _free_map()
if not free.has(id) or _path.has(id):
return false
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:
return false
if id == int(board.get("finish", -1)) \
and _path.size() < int(board.get("free_count", 0)) - 1:
# Финиш можно только последним: иначе линия не покроет все порты.
_invalid += 1
queue_redraw()
return true
return false
_path.append(id)
_play_sfx(&"tick")
if _path.size() == int(board.get("free_count", 0)) and id == int(board.get("finish", -1)):
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 _pop_path() -> void:
if _path.size() < 2:
return
_path.pop_back()
_undos += 1
_play_sfx(&"tick")
queue_redraw()
func _handle_motion_cell(id: int) -> void:
if id < 0 or _path.is_empty() or _finished:
return
var last := int(_path[_path.size() - 1])
if id == last:
return
if _path.size() >= 2 and id == int(_path[_path.size() - 2]):
_pop_path()
return
_try_append_cell(id)
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if not _dragging or _finished or _path.is_empty():
return
# Быстрый drag может проскочить целую клетку между двумя motion-событиями.
# Поэтому идём от центра последней клетки к курсору шагами ~полклетки и
# скармливаем каждую точку той же логике добавления.
var start := _cell_center(int(_path[_path.size() - 1]))
var dist := start.distance_to(local)
var step := maxf(6.0, _stride * 0.5)
var steps := maxi(1, int(ceil(dist / step)))
for i in range(1, steps + 1):
var point := start.lerp(local, float(i) / float(steps))
_handle_motion_cell(_cell_at(point))
if _finished:
return
_handle_motion_cell(_cell_at(local))
func _on_pointer_released(_local: Vector2, _event: InputEvent) -> void:
_dragging = false
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if keycode == KEY_R:
_reset_line()
@@ -404,6 +478,7 @@ func _reset_line() -> void:
if not _path.is_empty():
_undos += 1
_path.clear()
_dragging = false
_play_sfx(&"tick")
queue_redraw()
@@ -421,7 +496,7 @@ func _draw_game() -> void:
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)
Vector2(300.0, 46.0), 10, Theme90.DARK, 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)))
@@ -455,9 +530,6 @@ func _draw_game() -> void:
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)")
+22 -12
View File
@@ -4,13 +4,11 @@ extends MiniGame
## Качество: 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,
1: {"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,
2: {"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,
3: {"cols": 15, "rows": 5, "field_w": 520.0, "field_h": 180.0,
"corridor": 18.0, "speed": 560.0},
}
const HITBOX := 9.0
@@ -73,10 +71,24 @@ func _setup() -> void:
int(maze["ent_row"]), int(maze["exit_row"]))
func _goal_text() -> String:
return "ПРОВЕДИТЕ ПАКЕТ ОТ ВХОДА ДО ЗЕЛЁНОГО ВЫХОДА"
func _progress_ratio() -> float:
if _solution.is_empty():
return 0.0
return clampf((float(_max_sol) + 1.0) / float(_solution.size()), 0.0, 1.0)
func _result_stats() -> String:
return "СТОЛКНОВЕНИЙ %d · ТУПИКОВ %d" % [_collisions, _dead_end_hits]
# --- параметры/генерация ---------------------------------------------------
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 base: Dictionary = (TIERS.get(clampi(p_tier, 1, 3), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("maze", p_tier)
if row.is_empty():
return base
@@ -410,10 +422,10 @@ func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
func _draw_game() -> void:
var font := Theme90.ui()
_draw_label("МАРШРУТ · ВЫХОД СПРАВА", Vector2(24.0, 34.0), 10,
Theme90.NAVY, 280.0)
_draw_label("МАРШРУТ · ВЫХОД СПРАВА", Vector2(24.0, 46.0), 10,
Theme90.DARK, 280.0)
_draw_label("СТОЛКНОВЕНИЙ %d · ТУПИКОВ %d" % [_collisions, _dead_end_hits],
Vector2(320.0, 34.0), 10, Theme90.SHADE, 280.0,
Vector2(320.0, 46.0), 10, Theme90.DARK, 280.0,
HORIZONTAL_ALIGNMENT_RIGHT)
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 4, 1),
@@ -441,7 +453,5 @@ func _draw_game() -> void:
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,
Vector2(_field.position.x, _field.end.y + 12.0), 10, Theme90.DARK,
_field.size.x)
_draw_label("ВЕДИТЕ ПАКЕТ МЫШЬЮ · СТЕНЫ СКОЛЬЗЯТ · ВЫХОД — ЗЕЛЁНЫЙ",
Vector2(24.0, 344.0), 10, Theme90.SHADE, 430.0)
+26 -14
View File
@@ -11,13 +11,11 @@ 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,
1: {"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,
2: {"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,
3: {"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},
}
@@ -47,10 +45,25 @@ func _setup() -> void:
_last_capture_ok = false
func _goal_text() -> String:
return "НАВЕДИТЕ РАМКУ НА КЛИЕНТА · ЛКМ / ПРОБЕЛ — СНИМОК"
func _progress_ratio() -> float:
var shots := int(params.get("shots", 1))
if shots <= 0:
return 0.0
return clampf(float(_shots_taken) / float(shots), 0.0, 1.0)
func _result_stats() -> String:
return "УДАЧНЫХ %d ИЗ %d" % [_captures, int(params.get("shots", 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 base: Dictionary = (TIERS.get(clampi(p_tier, 1, 3), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("photo", p_tier)
if row.is_empty():
return base
@@ -289,11 +302,6 @@ 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)
# Мягкий фон скана: окно/стол, на игру не влияет.
@@ -303,6 +311,12 @@ func _draw_game() -> void:
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))
# Статус рисуется поверх тёмного скана, иначе его закрывает фон.
_draw_label("СКАН КЛИЕНТА · КАДР %d / %d" % [_shots_taken, shots],
Vector2(40.0, 47.0), 10, Theme90.PAPER_HI, 280.0)
_draw_label("УДАЧНЫХ %d · ОСТАЛОСЬ %d" % [_captures, shots - _shots_taken],
Vector2(320.0, 47.0), 10, Theme90.ACCENT_LIGHT, 270.0,
HORIZONTAL_ALIGNMENT_RIGHT)
var bust_pos: Vector2 = state["pos"]
_draw_bust(bust_pos)
@@ -331,10 +345,8 @@ func _draw_game() -> void:
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,
Vector2(466.0, 344.0), 10, Theme90.DARK, 156.0,
HORIZONTAL_ALIGNMENT_RIGHT)
+23 -12
View File
@@ -1,13 +1,12 @@
extends MiniGame
## «Уровни» (sound). Вертикальные фейдеры, зелёные целевые зоны.
## T1–T4: 3/4/5/6 каналов, допуск 16/11/8/6%, на T3/T4 — синусоидальный дрейф.
## T1–T3: 4/5/6 каналов, допуск 11/8/6%, на T2/T3 — синусоидальный дрейф.
## Таймера нет; качество фиксируется кнопкой «ЗАПИСАТЬ».
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},
1: {"count": 4, "tolerance": 0.11, "drift_amp": 0.0, "drift_hz": 0.0},
2: {"count": 5, "tolerance": 0.08, "drift_amp": 0.02, "drift_hz": 0.15},
3: {"count": 6, "tolerance": 0.06, "drift_amp": 0.03, "drift_hz": 0.25},
}
const LABELS := [
["НЧ", "60"], ["НСЧ", "250"], ["СЧ", "1К"], ["ВСЧ", "4К"], ["ВЧ", "12К"],
@@ -46,10 +45,24 @@ func _setup() -> void:
_active = 0
func _goal_text() -> String:
return "СОВМЕСТИТЕ БЕГУНКИ С ЗЕЛЁНЫМИ ПОЛОСАМИ · ENTER — ЗАПИСАТЬ"
func _result_stats() -> String:
var in_zone := 0
for i in range(_values.size()):
var tol := float(state.get("tolerance", 0.11))
if absf(_values[i] - _current_target(i)) <= tol:
in_zone += 1
return "В ЗОНЕ %d/%d · ДОПУСК ±%d%%" % [in_zone, _values.size(),
roundi(float(state.get("tolerance", 0.11)) * 100.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 base: Dictionary = (TIERS.get(clampi(p_tier, 1, 3), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("sound", p_tier)
if row.is_empty():
return base
@@ -119,7 +132,7 @@ static func generate(p_tier: int, seed: int) -> Dictionary:
starts.append(start)
phases.append(rng.randf_range(0.0, TAU))
return {
"tier": clampi(p_tier, 1, 4),
"tier": clampi(p_tier, 1, 3),
"count": count,
"tolerance": tol,
"drift_amp": amp,
@@ -272,7 +285,7 @@ func _draw_game() -> void:
_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,
Theme90.SUCCESS if in_zone == _values.size() else Theme90.DARK, 320.0,
HORIZONTAL_ALIGNMENT_RIGHT)
var panel := Rect2(18.0, 58.0, 604.0, 250.0)
@@ -283,7 +296,7 @@ func _draw_game() -> void:
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,
"%d" % roundi(v * 100.0), 10, Theme90.DARK, 34.0,
HORIZONTAL_ALIGNMENT_RIGHT)
for i in range(_values.size()):
@@ -312,8 +325,6 @@ func _draw_game() -> void:
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)
10, Theme90.DARK, 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)
+37 -17
View File
@@ -5,10 +5,9 @@ extends MiniGame
## учитывается не более одного раза.
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"]},
1: {"words": 15, "aliens": 2, "lines": 2, "types": ["foreign", "deformed"]},
2: {"words": 19, "aliens": 3, "lines": 3, "types": ["foreign", "deformed", "semantic"]},
3: {"words": 24, "aliens": 4, "lines": 3, "types": ["foreign", "deformed", "semantic", "archaic"]},
}
const NORMAL := [
@@ -65,10 +64,26 @@ func _setup() -> void:
_build_layout()
func _goal_text() -> String:
return "НАЙДИТЕ ЧУЖИЕ СЛОВА И НАЖМИТЕ «ОТПРАВИТЬ»"
func _progress_ratio() -> float:
var aliens := int(generated.get("aliens", 0))
if aliens <= 0:
return 0.0
return clampf(float(_found) / float(aliens), 0.0, 1.0)
func _result_stats() -> String:
return "НАЙДЕНО %d/%d · ЛОЖНЫХ %d" % [_found,
int(generated.get("aliens", 0)), _false_positive_count()]
# --- параметры/чистая логика ----------------------------------------------
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 base: Dictionary = (TIERS.get(clampi(p_tier, 1, 3), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("text", p_tier)
if row.is_empty():
return base
@@ -166,7 +181,7 @@ static func generate(p_tier: int, seed: int) -> Dictionary:
if bool(w["alien"]):
alien_count += 1
return {
"tier": clampi(p_tier, 1, 4),
"tier": clampi(p_tier, 1, 3),
"words": words,
"count": words.size(),
"aliens": alien_count,
@@ -272,9 +287,16 @@ func _build_layout() -> void:
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
# Первая строка ниже заголовка записки, а не поверх него.
var header_h := 24.0
var top := panel.position.y + header_h + 2.0
var avail := panel.size.y - header_h - 10.0
var gap := 10.0
if rows_count > 1:
gap = clampf((avail - line_h * float(rows_count)) / float(rows_count - 1),
10.0, 46.0)
var block_h := line_h * float(rows_count) + gap * float(maxi(0, rows_count - 1))
var y := top + maxf(0.0, (avail - block_h) * 0.5)
for r in range(counts.size()):
var row_list := _row_words(r, counts)
var widths: Array = []
@@ -295,7 +317,7 @@ func _build_layout() -> void:
})
x += ww + 7.0
_layout.append(row_layout)
y += line_h + row_gap
y += line_h + gap
_layout.append(size_px)
@@ -374,19 +396,19 @@ 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)
Theme90.NAVY, 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,
Theme90.SUCCESS if _found >= int(generated.get("aliens", 1)) else Theme90.DARK,
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,
draw_rect(Rect2(panel.position.x + 18.0, panel.position.y + 23.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)
panel.position.y + 7.0), 10, Theme90.DARK, panel.size.x - 40.0)
var size_px := 12
if not _layout.is_empty() and typeof(_layout[_layout.size() - 1]) == TYPE_INT:
@@ -421,7 +443,5 @@ func _draw_game() -> void:
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)
Vector2(24.0, 300.0), 10, Theme90.DARK, 400.0)
_draw_action_button(ACTION_RECT, ACTION_LABEL, true, true)
+28 -14
View File
@@ -1,16 +1,15 @@
extends MiniGame
## «Раскадровка» (video). Разложить кадры по слотам.
##
## T1 3/3 (силуэт), T2 4/4 (таймкод), T3 5 карточек/4 слота (1 скрытый
## брак), T4 6 карточек/5 слотов (1 брак + 1 зеркальный кадр).
## T1 4/4 (таймкод), T2 5 карточек/4 слота (1 скрытый брак),
## T3 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"},
1: {"cards": 4, "slots": 4, "decoy": false, "mirror": false, "hint": "timecode"},
2: {"cards": 5, "slots": 4, "decoy": true, "mirror": false, "hint": "ghost"},
3: {"cards": 6, "slots": 5, "decoy": true, "mirror": true, "hint": "timecode"},
}
const SHAPES := ["circle", "triangle", "square", "diamond", "cross"]
const COLORS := ["accent", "warn", "success", "error", "navy"]
@@ -46,10 +45,27 @@ func _setup() -> void:
_build_slot_rects()
func _goal_text() -> String:
return "РАЗЛОЖИТЕ КАДРЫ ПО СЛОТАМ · КАДР → СЛОТ · ОТПРАВИТЬ"
func _progress_ratio() -> float:
var slots := int(generated.get("slots", 0))
if slots <= 0:
return 0.0
return clampf(float(_occupied_slots()) / float(slots), 0.0, 1.0)
func _result_stats() -> String:
var c := _counts()
return "ВЕРНО %d ИЗ %d · ОШИБОК %d · БРАК %d" % [int(c["correct"]),
int(generated.get("slots", 0)), int(c["wrong"]), int(c["decoys"])]
# --- параметры/чистая логика ----------------------------------------------
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 base: Dictionary = (TIERS.get(clampi(p_tier, 1, 3), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("video", p_tier)
if row.is_empty():
return base
@@ -147,7 +163,7 @@ static func generate(p_tier: int, seed: int) -> Dictionary:
tray[safe_idx] = tmp
return {
"tier": clampi(p_tier, 1, 4),
"tier": clampi(p_tier, 1, 3),
"slots": slots,
"cards": tray,
"card_count": tray.size(),
@@ -206,7 +222,7 @@ static func decoy_info(data: Dictionary) -> Dictionary:
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 area := Rect2(40.0, 62.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):
@@ -314,7 +330,7 @@ func _draw_game() -> void:
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,
Vector2(300.0, 46.0), 10, Theme90.DARK, 300.0,
HORIZONTAL_ALIGNMENT_RIGHT)
var slot_shapes: Array = generated.get("slot_shapes", [])
@@ -334,7 +350,7 @@ func _draw_game() -> void:
_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)
10, Theme90.DARK, 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)
@@ -353,7 +369,7 @@ func _draw_game() -> void:
else:
_draw_centered(str(ghost.get("timecode", "")),
Rect2(rect.position.x, rect.end.y - 30.0, rect.size.x, 20.0),
10, Theme90.SHADE)
10, Theme90.DARK)
_recompute_tray()
for entry in _tray_rects:
@@ -365,8 +381,6 @@ func _draw_game() -> void:
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)
-528
View File
@@ -1,528 +0,0 @@
extends MiniGame
## «Провода» (wires). FIX M11: порты не накладываются.
##
## Геометрия 640×360: верх стойки 56, порт 34×34, шаг 40 (34+6),
## высота стойки n*34 + (n-1)*6 + 16, низ <= 320. Левый центр x=140,
## правый x=466: 326 px между центрами, чистый коридор 292 px.
## Провода — квадратичные Безье с отдельной «полосой» на каждый провод
## (шаг 12 px), поэтому линии не сливаются.
const COLORS := {
"accent": Color(0.184314, 0.498039, 0.560784),
"navy": Color(0.172549, 0.360784, 0.407843),
"warn": Color(0.772549, 0.541176, 0.180392),
"error": Color(0.760784, 0.356863, 0.290196),
"success": Color(0.247059, 0.560784, 0.368627),
}
const COLOR_ORDER := ["accent", "navy", "warn", "error", "success"]
const SHAPES := ["circle", "triangle", "square", "diamond", "cross"]
const TIER_SPEC := {
1: {"wires": 3, "sockets": 3, "empty": 0},
2: {"wires": 4, "sockets": 4, "empty": 0},
3: {"wires": 5, "sockets": 6, "empty": 1},
4: {"wires": 6, "sockets": 6, "empty": 0},
}
const BOX := 34.0
const PITCH := 40.0
const LEFT_X := 140.0
const RIGHT_X := 466.0
const RACK_TOP := 56.0
const LANE_STEP := 12.0
var generated: Dictionary = {}
var geo: Dictionary = {}
var _wire_sockets: Array[int] = []
var _socket_owner: Array[int] = []
var _wrong: int = 0
var _drag_wire: int = -1
var _drag_pos: Vector2 = Vector2.ZERO
var _hover_socket: int = -1
var _status: String = ""
var _status_bad: bool = false
var _key_wire: int = 0
var _key_socket: int = 0
var _keyboard_on: bool = false
func _own_game_id() -> String:
return "wires"
func _setup() -> void:
generated = generate(tier, seed_value)
geo = geometry(tier)
_wire_sockets.clear()
_socket_owner.clear()
for i in range(generated.get("wires", []).size()):
_wire_sockets.append(-1)
for i in range(generated.get("sockets", []).size()):
_socket_owner.append(-1)
_wrong = 0
_drag_wire = -1
_hover_socket = -1
_status = ""
_status_bad = false
_key_wire = 0
_key_socket = 0
_keyboard_on = false
# --- параметры/чистая логика ----------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIER_SPEC.get(clampi(p_tier, 1, 4), TIER_SPEC[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("wires", p_tier)
if row.is_empty():
return base
for key in ["wires", "sockets"]:
if row.has(key):
base[key] = int(row[key])
if row.has("empty"):
base["empty"] = int(row["empty"])
if row.has("empty_sockets"):
base["empty"] = int(row["empty_sockets"])
return base
static func generate(p_tier: int, seed: int) -> Dictionary:
var p := tier_params(p_tier)
var wire_count := clampi(int(p.get("wires", 3)), 2, 6)
var socket_count := clampi(int(p.get("sockets", wire_count)), wire_count, 6)
var rng := RandomNumberGenerator.new()
rng.seed = seed
var colors := COLOR_ORDER.duplicate()
var shapes := SHAPES.duplicate()
_shuffle(colors, rng)
_shuffle(shapes, rng)
var combos: Array = []
match clampi(p_tier, 1, 4):
1:
for i in range(wire_count):
combos.append({"color": colors[i % colors.size()],
"shape": shapes[i % shapes.size()]})
2:
# Две пары похожих (повторяющихся) цветов, формы внутри пар разные.
var c0: String = colors[0]
var c1: String = colors[1]
var s: Array = shapes.slice(0, 4)
combos = [
{"color": c0, "shape": s[0]},
{"color": c0, "shape": s[1]},
{"color": c1, "shape": s[2]},
{"color": c1, "shape": s[3]},
]
3:
# Один общий цвет и одна общая форма у разных пар.
var s3: Array = shapes.slice(0, 5)
combos = [
{"color": colors[0], "shape": s3[0]},
{"color": colors[0], "shape": s3[1]},
{"color": colors[1], "shape": s3[0]},
{"color": colors[2], "shape": s3[2]},
{"color": colors[3], "shape": s3[3]},
]
_:
# T4: 2 общих цвета + 2 общие формы, все пары уникальны.
var s4: Array = shapes.slice(0, 4)
combos = [
{"color": colors[0], "shape": s4[0]},
{"color": colors[0], "shape": s4[1]},
{"color": colors[1], "shape": s4[0]},
{"color": colors[1], "shape": s4[2]},
{"color": colors[2], "shape": s4[2]},
{"color": colors[3], "shape": s4[3]},
]
combos = combos.slice(0, wire_count)
_shuffle(combos, rng)
var wires: Array = []
for i in range(combos.size()):
wires.append({
"id": i,
"color": str(combos[i]["color"]),
"shape": str(combos[i]["shape"]),
})
var socket_defs: Array = []
for c in combos:
socket_defs.append({"color": str(c["color"]), "shape": str(c["shape"]),
"empty": false})
while socket_defs.size() < socket_count:
socket_defs.append({"color": "", "shape": "", "empty": true})
_shuffle(socket_defs, rng)
var sockets: Array = []
for i in range(socket_defs.size()):
sockets.append({
"id": i,
"color": str(socket_defs[i]["color"]),
"shape": str(socket_defs[i]["shape"]),
"empty": bool(socket_defs[i]["empty"]),
})
return {
"tier": clampi(p_tier, 1, 4),
"wires": wires,
"sockets": sockets,
"wire_count": wires.size(),
"socket_count": sockets.size(),
}
static func geometry(p_tier: int) -> Dictionary:
var p := tier_params(p_tier)
var n := clampi(int(p.get("wires", 3)), 2, 6)
var s := clampi(int(p.get("sockets", n)), n, 6)
var rack_h := float(n) * BOX + float(n - 1) * 6.0 + 16.0
var right_h := float(s) * BOX + float(s - 1) * 6.0 + 16.0
var left_ports: Array[Vector2] = []
var right_ports: Array[Vector2] = []
for i in range(n):
left_ports.append(Vector2(LEFT_X, RACK_TOP + 8.0 + BOX * 0.5 + float(i) * PITCH))
for i in range(s):
right_ports.append(Vector2(RIGHT_X, RACK_TOP + 8.0 + BOX * 0.5 + float(i) * PITCH))
var left_rects: Array[Rect2] = []
var right_rects: Array[Rect2] = []
for center in left_ports:
left_rects.append(Rect2(center - Vector2(BOX, BOX) * 0.5, Vector2(BOX, BOX)))
for center in right_ports:
right_rects.append(Rect2(center - Vector2(BOX, BOX) * 0.5, Vector2(BOX, BOX)))
var lanes: Array[float] = []
for i in range(n):
lanes.append((float(i) - float(n - 1) * 0.5) * LANE_STEP)
return {
"box": BOX,
"pitch": PITCH,
"left_x": LEFT_X,
"right_x": RIGHT_X,
"top": RACK_TOP,
"left_rack": Rect2(LEFT_X - BOX * 0.5, RACK_TOP, BOX, rack_h),
"right_rack": Rect2(RIGHT_X - BOX * 0.5, RACK_TOP, BOX, right_h),
"rack_h": rack_h,
"right_rack_h": right_h,
"left_ports": left_ports,
"right_ports": right_ports,
"left_rects": left_rects,
"right_rects": right_rects,
"lanes": lanes,
"gap": RIGHT_X - LEFT_X,
"edge_gap": (RIGHT_X - BOX * 0.5) - (LEFT_X + BOX * 0.5),
}
static func matching_sockets(wire: Dictionary, sockets: Array) -> Array:
var out: Array = []
for socket in sockets:
if bool(socket.get("empty", false)):
continue
if str(socket.get("color", "")) == str(wire.get("color", "")) \
and str(socket.get("shape", "")) == str(wire.get("shape", "")):
out.append(int(socket.get("id", out.size())))
return out
static func validate_pairs(data: Dictionary) -> bool:
var wires: Array = data.get("wires", [])
var sockets: Array = data.get("sockets", [])
if wires.is_empty() or sockets.size() < wires.size():
return false
var seen_pairs := {}
for wire in wires:
var key := "%s|%s" % [str(wire.get("color", "")), str(wire.get("shape", ""))]
if seen_pairs.has(key):
return false
seen_pairs[key] = true
var matches := matching_sockets(wire, sockets)
if matches.size() != 1:
return false
return true
static func quality_for(connected: int, wrong: int, total: int) -> float:
if total <= 0:
return 0.0
return clampf((float(connected) - 0.25 * float(maxi(0, wrong))) / float(total),
0.0, 1.0)
static func _shuffle(list: Array, p_rng: RandomNumberGenerator) -> void:
for i in range(list.size() - 1, 0, -1):
var j := p_rng.randi_range(0, i)
var tmp = list[i]
list[i] = list[j]
list[j] = tmp
# --- игра ------------------------------------------------------------------
func _left_center(i: int) -> Vector2:
var ports: Array = geo.get("left_ports", [])
return ports[i] if i >= 0 and i < ports.size() else Vector2(LEFT_X, RACK_TOP)
func _right_center(i: int) -> Vector2:
var ports: Array = geo.get("right_ports", [])
return ports[i] if i >= 0 and i < ports.size() else Vector2(RIGHT_X, RACK_TOP)
func _wire_at(local: Vector2) -> int:
var rects: Array = geo.get("left_rects", [])
for i in range(rects.size()):
if (rects[i] as Rect2).grow(6.0).has_point(local):
return i
return -1
func _socket_at(local: Vector2) -> int:
var rects: Array = geo.get("right_rects", [])
for i in range(rects.size()):
if (rects[i] as Rect2).grow(6.0).has_point(local):
return i
return -1
func _connected_count() -> int:
var n := 0
for s in _wire_sockets:
if s >= 0:
n += 1
return n
func _matches(wi: int, si: int) -> bool:
var wires: Array = generated.get("wires", [])
var sockets: Array = generated.get("sockets", [])
if wi < 0 or wi >= wires.size() or si < 0 or si >= sockets.size():
return false
var wire: Dictionary = wires[wi]
var socket: Dictionary = sockets[si]
if bool(socket.get("empty", false)):
return false
if _socket_owner[si] >= 0:
return false
return str(wire["color"]) == str(socket["color"]) \
and str(wire["shape"]) == str(socket["shape"])
func _connect(wi: int, si: int) -> bool:
if _matches(wi, si):
if _wire_sockets[wi] >= 0:
_socket_owner[_wire_sockets[wi]] = -1
_wire_sockets[wi] = si
_socket_owner[si] = wi
_status = "ПОДКЛЮЧЕНО: %d ИЗ %d" % [_connected_count(), _wire_sockets.size()]
_status_bad = false
_play_sfx(&"minigame_win")
queue_redraw()
if _connected_count() == _wire_sockets.size():
_finish(quality_for(_connected_count(), _wrong, _wire_sockets.size()))
return true
_wrong += 1
_status_bad = true
var sockets: Array = generated.get("sockets", [])
if si >= 0 and si < sockets.size() and bool((sockets[si] as Dictionary).get("empty", false)):
_status = "ГНЕЗДО ПУСТОЕ · +1 ОШИБКА"
elif si >= 0 and si < _socket_owner.size() and _socket_owner[si] >= 0:
_status = "ГНЕЗДО ЗАНЯТО · +1 ОШИБКА"
else:
_status = "НЕ СОВПАЛО: НУЖНЫ ТОТ ЖЕ ЦВЕТ И ФОРМА · +1 ОШИБКА"
_play_sfx(&"minigame_fail")
queue_redraw()
return false
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
var wi := _wire_at(local)
if wi < 0:
return false
_keyboard_on = false
if _wire_sockets[wi] >= 0:
_socket_owner[_wire_sockets[wi]] = -1
_wire_sockets[wi] = -1
_drag_wire = wi
_drag_pos = local
_hover_socket = -1
_play_sfx(&"tick")
queue_redraw()
return true
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if _drag_wire < 0:
return
_drag_pos = local
_hover_socket = _socket_at(local)
queue_redraw()
func _on_pointer_released(local: Vector2, _event: InputEvent) -> void:
if _drag_wire < 0:
return
var wi := _drag_wire
_drag_wire = -1
_hover_socket = -1
var si := _socket_at(local)
if si < 0:
# Отпускание в пустоту — бесплатно.
queue_redraw()
return
_connect(wi, si)
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if keycode == KEY_E or keycode == KEY_ENTER:
if _keyboard_on:
_connect(_key_wire, _key_socket)
else:
var wi := _wire_at(get_local_mouse_position())
var si := _socket_at(get_local_mouse_position())
if wi >= 0 and si >= 0:
_connect(wi, si)
return true
if keycode == KEY_UP or keycode == KEY_W or keycode == KEY_TAB:
_keyboard_on = true
_key_wire = posmod(_key_wire - 1, maxi(1, _wire_sockets.size()))
queue_redraw()
return true
if keycode == KEY_DOWN or keycode == KEY_S:
_keyboard_on = true
_key_wire = (_key_wire + 1) % maxi(1, _wire_sockets.size())
queue_redraw()
return true
if keycode == KEY_LEFT or keycode == KEY_A:
_keyboard_on = true
_key_socket = posmod(_key_socket - 1, maxi(1, _socket_owner.size()))
queue_redraw()
return true
if keycode == KEY_RIGHT or keycode == KEY_D:
_keyboard_on = true
_key_socket = (_key_socket + 1) % maxi(1, _socket_owner.size())
queue_redraw()
return true
return false
# --- отрисовка -------------------------------------------------------------
func _draw_game() -> void:
var left_rack: Rect2 = geo.get("left_rack", Rect2())
var right_rack: Rect2 = geo.get("right_rack", Rect2())
_draw_label("ПОДКЛЮЧЕНО %d / %d" % [_connected_count(), _wire_sockets.size()],
Vector2(24.0, 46.0), 10, Theme90.NAVY, 240.0)
_draw_label("ОШИБОК %d · ПРОВОДОВ %d · ГНЁЗД %d" % [_wrong,
_wire_sockets.size(), _socket_owner.size()],
Vector2(320.0, 46.0), 10, Theme90.SHADE, 280.0,
HORIZONTAL_ALIGNMENT_RIGHT)
draw_style_box(Theme90.box(Theme90.PAPER_LO, Theme90.BORDER_SOFT, 4, 1),
left_rack.grow(6.0))
draw_style_box(Theme90.box(Theme90.PAPER_LO, Theme90.BORDER_SOFT, 4, 1),
right_rack.grow(6.0))
# Сначала уже подключённые кабели.
var wires: Array = generated.get("wires", [])
var sockets: Array = generated.get("sockets", [])
var lanes: Array = geo.get("lanes", [])
for wi in range(_wire_sockets.size()):
var si := _wire_sockets[wi]
if si < 0:
continue
var wire: Dictionary = wires[wi]
var lane := float(lanes[wi]) if wi < lanes.size() else 0.0
_draw_cable(_left_center(wi) + Vector2(BOX * 0.5, 0.0),
_right_center(si) - Vector2(BOX * 0.5, 0.0),
_color_of(str(wire["color"])), lane)
if _drag_wire >= 0 and _drag_wire < wires.size():
var wire: Dictionary = wires[_drag_wire]
var lane := float(lanes[_drag_wire]) if _drag_wire < lanes.size() else 0.0
_draw_cable(_left_center(_drag_wire) + Vector2(BOX * 0.5, 0.0),
_drag_pos, _color_of(str(wire["color"])), lane * 0.8)
for wi in range(wires.size()):
var wire: Dictionary = wires[wi]
var center := _left_center(wi)
var rect := Rect2(center - Vector2(BOX, BOX) * 0.5, Vector2(BOX, BOX))
var col := _color_of(str(wire["color"]))
var connected := _wire_sockets[wi] >= 0
draw_style_box(Theme90.box(col.lightened(0.70), col, 4, 1,
Theme90.SHADOW), rect)
_draw_shape(center, 18.0, str(wire["shape"]), col)
if connected:
draw_circle(Vector2(rect.end.x - 5.0, rect.position.y + 5.0), 3.0,
Theme90.SUCCESS)
if wi == _drag_wire:
draw_rect(rect.grow(3.0), Theme90.NAVY_DEEP, false, 2.0)
if _keyboard_on and wi == _key_wire:
Theme90.draw_dashed_box(self, rect.grow(4.0), Theme90.NAVY, 3.0, 1.0)
for si in range(sockets.size()):
var socket: Dictionary = sockets[si]
var center := _right_center(si)
var rect := Rect2(center - Vector2(BOX, BOX) * 0.5, Vector2(BOX, BOX))
if bool(socket.get("empty", false)):
Theme90.draw_dashed_box(self, rect, Theme90.SHADE, 3.0, 1.0)
_draw_label("—", Vector2(center.x - 5.0, center.y - 7.0), 16,
Theme90.SHADE)
continue
var col := _color_of(str(socket["color"]))
var hover := si == _hover_socket and _drag_wire >= 0
draw_style_box(Theme90.box(Theme90.PAPER_HI if not hover else Theme90.ACCENT_PALE,
col if not hover else Theme90.ACCENT, 4, 2 if hover else 1), rect)
_draw_shape(center, 18.0, str(socket["shape"]), col.darkened(0.05))
if _socket_owner[si] >= 0:
draw_circle(Vector2(rect.end.x - 5.0, rect.position.y + 5.0), 3.0,
Theme90.SUCCESS)
if _keyboard_on and si == _key_socket:
Theme90.draw_dashed_box(self, rect.grow(4.0), Theme90.NAVY, 3.0, 1.0)
if _status.is_empty():
_status = "ТЯНИТЕ ПРОВОД СЛЕВА В ПРАВОЕ ГНЕЗДО: ЦВЕТ + ФОРМА"
_draw_label(_status, Vector2(24.0, 344.0), 10,
Theme90.ERROR if _status_bad else Theme90.SHADE, 430.0)
if _status_bad:
_draw_label("+0.25 ШТРАФА", Vector2(466.0, 344.0), 10, Theme90.ERROR,
156.0, HORIZONTAL_ALIGNMENT_RIGHT)
func _draw_cable(from: Vector2, to: Vector2, color: Color, lane: float) -> void:
var mid := (from + to) * 0.5
var control := Vector2(mid.x, mid.y + lane)
var points := PackedVector2Array()
for i in range(25):
var t := float(i) / 24.0
var p := from.lerp(control, t).lerp(control.lerp(to, t), t)
points.append(p)
draw_polyline(points, color, 3.0, true)
func _draw_shape(center: Vector2, size: float, shape: String, color: Color) -> void:
match shape:
"circle":
draw_circle(center, size * 0.55, color)
"triangle":
draw_colored_polygon(PackedVector2Array([
center + Vector2(0, -size * 0.62),
center + Vector2(size * 0.60, size * 0.42),
center + Vector2(-size * 0.60, size * 0.42)]), color)
"square":
draw_rect(Rect2(center - Vector2(size, size) * 0.48,
Vector2(size, size) * 0.96), color)
"diamond":
draw_colored_polygon(PackedVector2Array([
center + Vector2(0, -size * 0.66),
center + Vector2(size * 0.66, 0),
center + Vector2(0, size * 0.66),
center + Vector2(-size * 0.66, 0)]), color)
_:
draw_rect(Rect2(center - Vector2(size * 0.16, size * 0.62),
Vector2(size * 0.32, size * 1.24)), color)
draw_rect(Rect2(center - Vector2(size * 0.62, size * 0.16),
Vector2(size * 1.24, size * 0.32)), color)
func _color_of(id: String) -> Color:
return COLORS.get(id, Theme90.ACCENT)
-1
View File
@@ -1 +0,0 @@
uid://b2cghsoxycui0
+11 -11
View File
@@ -34,28 +34,28 @@ const DEFAULT_RULES := {
## Резерв, если 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],
"max_level": 4, "prices": [90, 200, 400, 700],
"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],
{"id": "lot_speed", "name_ru": "Оптоволокно", "effect": "transfer_speed", "value": 0.10,
"max_level": 6, "prices": [55, 75, 100, 135, 180, 245],
"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],
{"id": "lot_sla", "name_ru": "Резервный ИБП", "effect": "sla_cap", "value": 40,
"max_level": 4, "prices": [80, 160, 270, 430],
"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],
"max_level": 3, "prices": [100, 220, 410],
"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],
"max_level": 8, "prices": [70, 95, 130, 170, 220, 285, 365, 465],
"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],
"max_level": 5, "prices": [50, 100, 180, 290, 440],
"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],
"buffer_value": 1, "max_level": 6, "prices": [45, 70, 95, 130, 175, 230],
"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],
{"id": "lot_timer", "name_ru": "Регламент обработки", "effect": "request_timer", "value": 7,
"max_level": 5, "prices": [45, 75, 115, 165, 230],
"weight_by_hour": [1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.7, 0.7, 0.7]},
]
+536 -39
View File
@@ -1,8 +1,59 @@
extends SceneTree
## Dev-инструмент (не часть игры): снимает кадр сцены в PNG.
##
## godot --path . --script scripts/ui/dev_capture.gd -- --scene=res://scenes/Main.tscn --out=/tmp/opencode/peak.png
## godot --path . --script scripts/ui/dev_capture.gd -- --probe --out=/tmp/opencode/font_probe.png
## Базовые примеры:
## godot --path . --script scripts/ui/dev_capture.gd -- --scene=res://scenes/Main.tscn --out=/tmp/opencode/peak.png
## godot --path . --script scripts/ui/dev_capture.gd -- --probe --out=/tmp/opencode/font_probe.png
##
## Экраны игры (флаги сами запускают новый забег, если это нужно):
## ... -- --run --out=/tmp/opencode/prep.png
## ... -- --hour --seconds=8 --spawn=packet,request --out=/tmp/opencode/hour.png
## ... -- --minigame=dots --tier=2 --out=/tmp/opencode/dots.png
## ... -- --shop --out=/tmp/opencode/shop.png
## ... -- --tutorial --out=/tmp/opencode/tutorial.png
##
## Флаги:
## --scene=PATH сцена для загрузки (по умолчанию res://scenes/Main.tscn)
## --out=PATH куда сохранить PNG (по умолчанию /tmp/opencode/peak.png)
## --wait=N сколько кадров ждать перед снимком (по умолчанию 10)
## --probe отрисовать шрифтовой probe вместо Main
## --run нажать «НОВЫЙ ЗАБЕГ» (Main._start_new_run)
## --hour после --run начать час 1 (Main._on_start_hour)
## --seconds=N сколько реальных секунд крутить час (по умолчанию 6) — нужен --hour
## --spawn=A,B через HourDirector спавнить packet и/или request (нужен --hour)
## --folder=ID открыть папку по id (как раньше); с --run без --folder
## автоматически открывается первая папка
## --minigame=ID dots|sound|text|video|photo|maze|junk|ddos|phishing|ransomware
## --tier=N сложность мини-игры, 1..4 (по умолчанию 2)
## --seed=N сид мини-игры (по умолчанию 12345)
## --shop свернуть офферы для часа 2 и открыть ShopModal
## --tutorial запустить забег и показать маскота-туториал
## --menu контекстное меню 90-х (как раньше)
## --filemenu выпадающее меню «ФАЙЛ» (как раньше)
## --toast два демо-тоста (как раньше)
## --hover=x,y навести курсор
## --press=x,y нажать ЛКМ в точке (без отпускания)
## --drag=x1,y1,x2,y2 настоящий drag: press → motion ×8 → release
## --hold с --drag: остановиться до release (кадр «в процессе»)
## --raw-coords координаты --hover/--press/--drag заданы в пикселях окна,
## а не в логических 1280×720 (аварийный режим совместимости)
##
## ПРОСТРАНСТВО КООРДИНАТ:
## Все координаты указателя (--hover/--press/--drag) — ЛОГИЧЕСКИЕ 1280×720,
## как в макете. Внутри они умножаются на реальный размер окна / 1280×720
## (обычно 1920×1080 → ×1.5). Скриншот всегда сохраняется в пикселях окна
## (обычно 1920×1080). --raw-coords отключает пересчёт.
##
## При ошибке (нет метода Main, пустая папка, мини-игра не создалась) скрипт
## печатает причину и завершается с кодом 1, а не снимает меню молча.
const LOGICAL_SIZE := Vector2(1280.0, 720.0)
const SEND_GAMES: Array[String] = [
"dots", "sound", "text", "video", "photo", "maze",
]
const ATTACK_GAMES: Array[String] = [
"junk", "ddos", "phishing", "ransomware",
]
var _scene_path := "res://scenes/Main.tscn"
var _out := "/tmp/opencode/peak.png"
@@ -16,6 +67,23 @@ var _folder := ""
var _filemenu := false
var _main: Node = null
var _run := false
var _hour := false
var _seconds := 6.0
var _spawn: Array[String] = []
var _minigame := ""
var _tier := 2
var _seed := 12345
var _shop := false
var _tutorial := false
## [start, end] в логических координатах; пустой массив — drag не нужен.
var _drag: Array[Vector2] = []
var _hold := false
var _raw_coords := false
## Снимок делается только после того, как сцена и запрошенные экраны готовы.
var _setup_done := false
func _initialize() -> void:
for a in OS.get_cmdline_user_args():
@@ -33,51 +101,153 @@ func _initialize() -> void:
_filemenu = true
elif a == "--toast":
_toast = true
elif a.begins_with("--hover="):
var hp := a.substr(8).split(",")
_hover = Vector2(float(hp[0]), float(hp[1]))
elif a == "--run":
_run = true
elif a == "--hour":
_hour = true
elif a.begins_with("--seconds="):
_seconds = maxf(0.0, float(a.substr(10)))
elif a.begins_with("--spawn="):
_spawn.clear()
for raw in a.substr(8).split(",", false):
var token := raw.strip_edges().to_lower()
if token.is_empty():
continue
if token != "packet" and token != "request":
_fail("--spawn: неизвестный тип '%s' (ожидается packet или request)" % token)
return
_spawn.append(token)
elif a.begins_with("--folder="):
_folder = a.substr(9)
elif a.begins_with("--minigame="):
_minigame = a.substr(11).strip_edges().to_lower()
elif a.begins_with("--tier="):
_tier = clampi(int(a.substr(7)), 1, 4)
elif a.begins_with("--seed="):
_seed = int(a.substr(7))
elif a == "--shop":
_shop = true
elif a == "--tutorial":
_tutorial = true
elif a.begins_with("--hover="):
_hover = _parse_pair(a.substr(8))
if _hover.x < 0.0:
_fail("--hover: нужен формат x,y")
return
elif a.begins_with("--press="):
var pp := a.substr(8).split(",")
_press = Vector2(float(pp[0]), float(pp[1]))
_press = _parse_pair(a.substr(8))
if _press.x < 0.0:
_fail("--press: нужен формат x,y")
return
elif a.begins_with("--drag="):
var parts := a.substr(7).split(",", false)
if parts.size() != 4:
_fail("--drag: нужен формат x1,y1,x2,y2")
return
var p1 := _parse_pair("%s,%s" % [parts[0], parts[1]])
var p2 := _parse_pair("%s,%s" % [parts[2], parts[3]])
if p1.x < 0.0 or p2.x < 0.0:
_fail("--drag: координаты должны быть числами x1,y1,x2,y2")
return
_drag = [p1, p2]
elif a == "--hold":
_hold = true
elif a == "--raw-coords":
_raw_coords = true
elif a == "--help" or a == "-h":
_print_help()
quit(0)
return
if not _minigame.is_empty() and not SEND_GAMES.has(_minigame) \
and not ATTACK_GAMES.has(_minigame):
_fail("--minigame: '%s' не поддерживается (см. --help)" % _minigame)
return
if _hold and _drag.is_empty():
_fail("--hold имеет смысл только вместе с --drag")
return
if _probe:
root.add_child(Probe.new())
else:
var ps: PackedScene = load(_scene_path)
if ps == null:
push_error("cannot load " + _scene_path)
quit(1)
return
_main = ps.instantiate()
root.add_child(_main)
call_deferred("_after_ready")
_setup_done = true
return
var ps: PackedScene = load(_scene_path)
if ps == null:
_fail("cannot load " + _scene_path)
return
_main = ps.instantiate()
root.add_child(_main)
call_deferred("_after_ready")
func _after_ready() -> void:
if _toast and _main != null and _main.has_method("_toast"):
# Дадим Main один-два кадра на _ready/_process и ленивые модалки.
await _frames(2)
if _main == null:
_fail("Main не загрузился")
return
if _toast and _main.has_method("_toast"):
_main.call("_toast", "СОЕДИНЕНИЕ УСТАНОВЛЕНО · договор_11.doc", "success")
_main.call("_toast", "КАПАЛ 45% · БУФЕР 3/4", "warn")
if not _folder.is_empty() and _main != null:
if _main.has_method("_on_folder_selected"):
_main.call("_on_folder_selected", _folder)
if _hover.x >= 0.0:
await create_timer(0.15).timeout
Input.warp_mouse(_hover)
if _press.x >= 0.0:
await create_timer(0.15).timeout
var ev := InputEventMouseButton.new()
ev.button_index = MOUSE_BUTTON_LEFT
ev.pressed = true
ev.position = _press
ev.global_position = _press
Input.parse_input_event(ev)
# Игровые экраны невозможны без забега: --hour/--shop/--tutorial/--minigame
# и --spawn сами включают его, чтобы команды из документации работали.
var need_run: bool = _run or _hour or _shop or _tutorial \
or not _minigame.is_empty() or not _spawn.is_empty()
if need_run:
if not _invoke_main("_start_new_run", [], "start_new_run"):
return
await _frames(4)
# --folder имеет приоритет; иначе после --run открываем первую папку,
# чтобы в кадре была сетка файлов, а не пустая панель.
if not _folder.is_empty():
if not _invoke_main("_on_folder_selected", [_folder]):
return
await _frames(2)
elif need_run:
if not _select_first_folder():
return
await _frames(2)
if _hour or not _spawn.is_empty():
# Новый забег начинается с пустого склада: без файлов HourDirector
# не сможет создать запросы. Кладём демо-файлы в первую папку.
if not _seed_demo_files():
return
if not _invoke_main("_on_start_hour"):
return
await _frames(3)
if not _spawn.is_empty() and not _spawn_cards():
return
if _seconds > 0.0:
# Настоящее время: HourDirector тикает из Main._process, пакеты и
# запросы успевают появиться и отрисоваться.
await create_timer(_seconds).timeout
await _frames(3)
if _shop:
if not _open_shop():
return
await _frames(3)
if _tutorial:
if not _show_tutorial():
return
await _frames(2)
if not _minigame.is_empty():
if not _show_minigame():
return
await _frames(6)
# Прежние сценарии меню/тостов/файлового меню.
if _filemenu and _main != null:
await create_timer(0.1).timeout
var menus: Dictionary = _main.get("_menus")
if menus.has("ФАЙЛ"):
var pm: PopupMenu = menus["ФАЙЛ"]
pm.popup(Rect2i(Vector2i(6, 44), Vector2i.ZERO))
if _menu:
await create_timer(0.1).timeout
ContextMenu90.show_items(_main, [
@@ -88,18 +258,346 @@ func _after_ready() -> void:
{"label": "Уничтожить содержимое", "id": "destroy", "danger": true},
], Vector2(120, 220), func(_id: String) -> void: pass)
if _hover.x >= 0.0:
await create_timer(0.15).timeout
Input.warp_mouse(_window_point(_hover))
if _press.x >= 0.0:
await create_timer(0.15).timeout
_send_button(_window_point(_press), true)
if not _drag.is_empty():
await _do_drag()
_setup_done = true
func _process(_delta: float) -> bool:
_wait -= 1
if _wait > 0:
return false
var img := root.get_texture().get_image()
img.save_png(_out)
print("saved ", _out, " ", img.get_size())
quit()
if not _setup_done:
return false
_wait -= 1
if _wait > 0:
return false
if DisplayServer.get_name() == "headless":
_fail("headless-режим: скриншот невозможен, нужен реальный дисплей")
return true
var tex := root.get_texture()
if tex == null:
_fail("нет текстуры окна (headless? нужен реальный дисплей)")
return true
var img := tex.get_image()
if img == null:
_fail("не удалось получить изображение кадра")
return true
var err := img.save_png(_out)
if err != OK:
_fail("не удалось сохранить %s (ошибка %d)" % [_out, err])
return true
print("saved ", _out, " ", img.get_size())
quit()
return true
# --------------------------------------------------------------------------
# Шаги сценария
# --------------------------------------------------------------------------
## Вызвать метод Main; при отсутствии — внятная ошибка и код 1.
func _invoke_main(method: String, args: Array = [], fallback: String = "") -> bool:
if _main == null:
_fail("Main не загружен")
return false
var target := method
if not _main.has_method(target):
if fallback.is_empty() or not _main.has_method(fallback):
_fail("Main не имеет метода %s()" % method)
return false
target = fallback
_main.callv(target, args)
return true
func _select_first_folder() -> bool:
var folder_id := _first_folder_id()
if folder_id.is_empty():
_fail("в подготовке нет ни одной папки для --run")
return false
return _invoke_main("_on_folder_selected", [folder_id])
func _first_folder_id() -> String:
var ctrl = _main.get("controller")
if ctrl == null or not ctrl.has_method("prep_data"):
return ""
var d: Dictionary = ctrl.call("prep_data")
for sec in d.get("sections", []):
var folders: Array = sec.get("folders", [])
if folders.is_empty():
continue
var folder_id := str(folders[0].get("id", ""))
if not folder_id.is_empty():
return folder_id
return ""
func _folder_exists(folder_id: String) -> bool:
var ctrl = _main.get("controller")
if ctrl == null or not ctrl.has_method("prep_data"):
return false
var d: Dictionary = ctrl.call("prep_data")
for sec in d.get("sections", []):
for fol in sec.get("folders", []):
if str(fol.get("id", "")) == folder_id:
return true
return false
## Демо-файлы для часа: без них запросы не спавнятся (реестр пуст).
func _seed_demo_files() -> bool:
var game_node := root.get_node_or_null("Game")
if game_node == null:
_fail("автолоад Game недоступен")
return false
var registry = game_node.get("registry")
if registry == null or not registry.has_method("create_file"):
_fail("Game.registry.create_file() недоступен")
return false
var folder_id := _folder
if folder_id.is_empty() or not _folder_exists(folder_id):
folder_id = _first_folder_id()
if folder_id.is_empty():
_fail("некуда положить демо-файлы: нет папок")
return false
var demo := [["договор_97", "text"], ["смета_демо", "photo"],
["отчёт_демо", "video"]]
for pair in demo:
var file_name := str(pair[0])
if registry.has_method("find_by_name") \
and registry.call("find_by_name", file_name) != null:
continue
registry.call("create_file", file_name, str(pair[1]), folder_id)
if _main.has_method("_refresh_prep"):
_main.call("_refresh_prep")
return true
func _spawn_cards() -> bool:
var ctrl = _main.get("controller")
if ctrl == null:
_fail("Main.controller недоступен")
return false
var director = ctrl.get("hour_director")
if director == null:
_fail("HourDirector недоступен")
return false
for token in _spawn:
if token == "packet":
if not director.has_method("spawn_packet_now"):
_fail("HourDirector.spawn_packet_now() отсутствует")
return false
var spawned := 0
for i in 3:
if not str(director.call("spawn_packet_now")).is_empty():
spawned += 1
if spawned == 0:
_fail("HourDirector не смог создать ни одного пакета")
return false
elif token == "request":
if not director.has_method("spawn_request_now"):
_fail("HourDirector.spawn_request_now() отсутствует")
return false
var spawned_req := 0
for i in 2:
var req_id := str(director.call("spawn_request_now"))
if req_id.is_empty():
# Страховка: один файл на запрос, если обычный ролл не прошёл.
req_id = str(director.call("spawn_request_now", 1))
if not req_id.is_empty():
spawned_req += 1
if spawned_req == 0:
_fail("HourDirector не смог создать ни одного запроса")
return false
if _main.has_method("_refresh_hour"):
_main.call("_refresh_hour")
return true
func _open_shop() -> bool:
var ctrl = _main.get("controller")
if ctrl == null:
_fail("Main.controller недоступен")
return false
var shop = ctrl.get("shop")
if shop == null:
_fail("GameController.shop недоступен")
return false
var modal = _main.get("shop_modal")
if modal == null:
_fail("Main.shop_modal недоступен")
return false
if not modal.has_method("open_prep"):
_fail("ShopModal.open_prep() отсутствует")
return false
# Магазин в игре открывается в подготовке ПОСЛЕ часа 1. Main._on_shop()
# на часе 1 отказывает, поэтому крутим офферы для часа 2 и открываем
# модалку напрямую тем же API, что и игра.
if shop.has_method("refresh"):
shop.call("refresh", 2, _game_money())
var offers: Array = []
if shop.has_method("offers"):
offers = shop.call("offers")
if offers.is_empty():
_fail("магазин не выдал офферы для часа 2")
return false
modal.call("open_prep", shop, _game_money(), 2)
if not bool(modal.get("visible")):
_fail("ShopModal не открылся")
return false
return true
func _show_tutorial() -> bool:
var mascot = _main.get("mascot")
if mascot == null or not mascot.has_method("show_message"):
_fail("Main.mascot/TutorialMascot.show_message() недоступен")
return false
var phase := ""
var ctrl = _main.get("controller")
if ctrl != null:
phase = str(ctrl.get("phase"))
if phase == "hour" and _main.has_method("_show_tutorial_step"):
_main.call("_show_tutorial_step", 0)
elif _main.has_method("_maybe_show_prep_tutorial"):
_main.call("_maybe_show_prep_tutorial")
if not bool(mascot.get("visible")):
# В headless авто-туториал выключен; для инструмента показываем явно.
mascot.call("show_message",
"Привет, я БАЙТ. Час пик начинается с приёма пакетов.", -1.0)
if not bool(mascot.get("visible")):
_fail("маскот-туториал не стал видимым")
return false
return true
func _show_minigame() -> bool:
var game = null
if SEND_GAMES.has(_minigame):
game = MiniGameFactory.create_send(_minigame, _tier, _seed)
elif ATTACK_GAMES.has(_minigame):
game = MiniGameFactory.create_attack(_minigame, _tier, _seed)
if game == null:
_fail("MiniGameFactory не создал '%s' (tier %d)" % [_minigame, _tier])
return false
var host: Node = _main
var mr = _main.get("modal_root")
if mr != null:
host = mr
host.add_child(game)
if game is Control:
# См. Main._show_modal_game: фиксируем min-size, затем центрируем KEEP_SIZE.
var ctl := game as Control
ctl.size = ctl.get_combined_minimum_size()
ctl.set_anchors_and_offsets_preset(Control.PRESET_CENTER, Control.PRESET_MODE_KEEP_SIZE)
if game.has_method("start"):
game.call("start", _minigame, _tier, _seed)
if game is CanvasItem and not (game as CanvasItem).visible:
_fail("мини-игра '%s' создана, но не видима" % _minigame)
return false
return true
# --------------------------------------------------------------------------
# Ввод: логические 1280×720 → пиксели окна
# --------------------------------------------------------------------------
func _window_point(logical: Vector2) -> Vector2:
if _raw_coords:
return logical
var win := Vector2(DisplayServer.window_get_size())
if win.x <= 0.0 or win.y <= 0.0:
return logical
return Vector2(logical.x * win.x / LOGICAL_SIZE.x,
logical.y * win.y / LOGICAL_SIZE.y)
func _send_button(pos: Vector2, pressed: bool) -> void:
var ev := InputEventMouseButton.new()
ev.button_index = MOUSE_BUTTON_LEFT
ev.pressed = pressed
ev.position = pos
ev.global_position = pos
ev.button_mask = MOUSE_BUTTON_MASK_LEFT if pressed else 0
Input.parse_input_event(ev)
func _do_drag() -> void:
var start := _window_point(_drag[0])
var finish := _window_point(_drag[1])
_send_button(start, true)
await _frames(2)
var steps := 8
var prev := start
for i in range(1, steps + 1):
var p := start.lerp(finish, float(i) / float(steps))
if DisplayServer.get_name() != "headless":
# Держим и системный курсор рядом: hover-open папок смотрит на него.
Input.warp_mouse(p)
var motion := InputEventMouseMotion.new()
motion.position = p
motion.global_position = p
motion.relative = p - prev
motion.button_mask = MOUSE_BUTTON_MASK_LEFT
Input.parse_input_event(motion)
prev = p
await process_frame
if not _hold:
# --hold оставляет кнопку зажатой: снимок ловит середину drag'а.
_send_button(finish, false)
await _frames(2)
func _frames(count: int) -> void:
for i in count:
await process_frame
func _parse_pair(text: String) -> Vector2:
var parts := text.split(",", false)
if parts.size() != 2:
return Vector2(-1, -1)
if not parts[0].strip_edges().is_valid_float() \
or not parts[1].strip_edges().is_valid_float():
return Vector2(-1, -1)
return Vector2(float(parts[0]), float(parts[1]))
func _game_money() -> int:
var node := root.get_node_or_null("Game")
if node == null:
return 0
return int(node.get("money"))
func _fail(message: String) -> void:
printerr("dev_capture: ERROR: ", message)
push_error("dev_capture: " + message)
quit(1)
func _print_help() -> void:
print("dev_capture.gd — снимок экрана PEAK HOUR 97")
print(" --run --hour --seconds=N --spawn=packet,request")
print(" --minigame=ID --tier=N --seed=N --shop --tutorial")
print(" --folder=ID --hover=x,y --press=x,y --drag=x1,y1,x2,y2 --hold")
print(" --menu --filemenu --toast --probe --out=PATH --wait=N")
print(" Координаты указателя — логические 1280x720; --raw-coords для пикселей окна.")
print(" Мини-игры: ", ", ".join(SEND_GAMES + ATTACK_GAMES))
# --------------------------------------------------------------------------
# Font probe
# --------------------------------------------------------------------------
class Probe:
extends Control
@@ -161,4 +659,3 @@ class Probe:
var i := 0 if eg == 1.0 else 1
var j := [0, 2, 8].find(esh)
return 360.0 + (i * 3 + j) * 26.0
+67 -1
View File
@@ -12,12 +12,17 @@ extends Node
signal hover_open(folder_id: String)
signal drag_ended
## Пользователь повернул фигуру (R / ПКМ). Слушают все сетки, чтобы ghost
## обновлялся даже у той, которая не получила событие ввода.
signal rotation_changed(rotation: int)
const HOVER_DELAY := 0.25
const PREVIEW_OFFSET := Vector2(9, 9)
var _preview: Control = null
var _mode: String = "shape"
var _rotation: int = 0
var _rotate_frame: int = -1
var _hover_id: String = ""
var _hover_left: float = 0.0
var _hover_fired: bool = false
@@ -29,10 +34,20 @@ func _ready() -> void:
set_process(false)
func begin(preview_control: Control, p_mode: String = "shape") -> void:
func begin(preview_control: Control, p_mode: String = "shape",
p_initial_rotation: int = 0) -> void:
end()
_preview = preview_control
_mode = _normalize(p_mode)
# Пакет создаёт превью со своим поворотом, но зовёт begin() без третьего
# аргумента. Берём поворот из превью, чтобы drop не сбрасывал его в 0.
var seeded := posmod(int(p_initial_rotation), 4)
if seeded == 0 and is_instance_valid(preview_control):
var preview_rotation = preview_control.get("shape_rotation")
if preview_rotation != null:
seeded = posmod(int(preview_rotation), 4)
_rotation = seeded
_rotate_frame = -1
if is_instance_valid(_preview):
_preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
_preview.top_level = true
@@ -44,6 +59,7 @@ func begin(preview_control: Control, p_mode: String = "shape") -> void:
else:
_preview.set("mode", _mode)
_preview.visible = true
_sync_preview_rotation()
_preview.queue_redraw()
_place()
set_process(true)
@@ -69,6 +85,8 @@ func end() -> void:
parent.remove_child(_preview)
_preview.queue_free()
_preview = null
_rotation = 0
_rotate_frame = -1
reset_hover()
if had:
drag_ended.emit()
@@ -83,6 +101,54 @@ func mode() -> String:
return _mode
# --- поворот ---------------------------------------------------------------
## Текущий поворот фигуры (0..3). Авторитетное состояние на время drag.
func rotation() -> int:
return _rotation
func set_rotation(value: int) -> void:
var next := posmod(int(value), 4)
if next == _rotation:
return
_rotation = next
_sync_preview_rotation()
rotation_changed.emit(_rotation)
## +90° по часовой. Повторный вызов в том же кадре игнорируется: R/ПКМ
## приходят и источнику, и цели drop, а повернуть нужно один раз.
func rotate_cw() -> int:
var frame := Engine.get_process_frames()
if _rotate_frame == frame:
return _rotation
_rotate_frame = frame
set_rotation(_rotation + 1)
return _rotation
## Есть ли активное превью — то есть drag действительно идёт.
func is_active() -> bool:
return is_instance_valid(_preview)
func _sync_preview_rotation() -> void:
if not is_instance_valid(_preview):
return
if _preview.has_method("setup"):
_preview.setup(str(_preview.get("file_type")), str(_preview.get("shape_id")),
_rotation, str(_preview.get("file_name")), str(_preview.get("ext")),
float(_preview.get("cell_size")))
if _preview.has_method("set_mode"):
_preview.set_mode(_mode)
else:
_preview.set("shape_rotation", _rotation)
_preview.queue_redraw()
# setup() меняет custom_minimum_size — превью должно остаться под курсором.
_place()
func hover_folder(folder_id: String) -> void:
if folder_id.is_empty():
reset_hover()
+154 -36
View File
@@ -61,8 +61,10 @@ 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_target: Vector2i = Vector2i(-1, -1)
var _ghost_rotation: int = 0
var _ghost_ok: bool = false
var _ghost_auto: bool = false
func _ready() -> void:
@@ -183,10 +185,12 @@ func stats() -> Dictionary:
}
## Подвал: счётчик файлов и размер сетки. Диск-«занято X из Y» убран.
func footer_text() -> String:
var s := stats()
return "занято %d из %d · свободно %d" % [
int(s["occupied"]), int(s["total"]), int(s["free"])]
var files := int(s["files"])
return "%d %s · %d %s" % [
files, plural_files(files), int(s["total"]), plural_cells(int(s["total"]))]
func grid_metrics() -> Dictionary:
@@ -270,6 +274,38 @@ static func cell_rect_of(origin: Vector2, cell: float, gap: float,
return Rect2(origin + Vector2(float(c) * step, float(r) * step), Vector2(cell, cell))
## Пиксельная позиция bbox-угла фигуры (её origin) внутри сетки. Раньше
## FileShape.paint() получал _grid_origin и все файлы рисовались в (0,0).
static func shape_origin(grid_origin: Vector2, cell: float, gap: float,
cell_pos: Vector2i) -> Vector2:
var step := cell + gap
return grid_origin + Vector2(float(cell_pos.x) * step, float(cell_pos.y) * step)
static func shape_origin_pair(grid_origin: Vector2, cell: Vector2, gap: float,
cell_pos: Vector2i) -> Vector2:
var step := cell + Vector2(gap, gap)
return grid_origin + Vector2(float(cell_pos.x) * step.x,
float(cell_pos.y) * step.y)
## R (физическая клавиша — работает и в русской раскладке) / ПКМ.
static func is_rotate_key(event: InputEvent) -> bool:
if not (event is InputEventKey):
return false
var key := event as InputEventKey
if not key.pressed or key.echo:
return false
return key.physical_keycode == KEY_R or key.keycode == KEY_R
static func is_rotate_click(event: InputEvent) -> bool:
if not (event is InputEventMouseButton):
return false
var mouse := event as InputEventMouseButton
return mouse.pressed and mouse.button_index == MOUSE_BUTTON_RIGHT
## Прямоугольная клетка стола: ширина и высота считаются независимо
## (416×548 → 99×86), каждая зажата в [lo, hi].
static func cell_size_pair(view: Vector2, cols: int, rows: int, gap: float = 6.0,
@@ -431,6 +467,16 @@ static func file_state(file) -> String:
return "stored"
static func plural_files(n: int) -> String:
var n10 := n % 10
var n100 := n % 100
if n10 == 1 and n100 != 11:
return "файл"
if n10 >= 2 and n10 <= 4 and (n100 < 12 or n100 > 14):
return "файла"
return "файлов"
static func plural_cells(n: int) -> String:
var n10 := n % 10
var n100 := n % 100
@@ -460,6 +506,10 @@ func _cell_rect(c: int, r: int) -> Rect2:
return cell_rect_of(_grid_origin, _cell, GAP, c, r)
func _shape_pos(cell_pos: Vector2i) -> Vector2:
return shape_origin(_grid_origin, _cell, GAP, cell_pos)
# --- hit test / hover ------------------------------------------------------
func _file_at(pos: Vector2) -> String:
@@ -526,6 +576,10 @@ func _gui_input(event: InputEvent) -> void:
file_activated.emit(id)
accept_event()
elif event.button_index == MOUSE_BUTTON_RIGHT:
# ПКМ во время drag — это поворот, а не контекстное меню.
if _drag_in_progress():
accept_event()
return
var id := _file_at(event.position)
if not id.is_empty():
context_requested.emit(id)
@@ -533,31 +587,58 @@ func _gui_input(event: InputEvent) -> void:
accept_event()
## Идёт ли drag (источник или цель). Опираемся на DragController, а не только
## на локальный _drag_active: у цели drop он вообще не выставлялся.
func _drag_in_progress() -> bool:
if _drag_active:
return true
var dc := _existing_drag_controller()
if dc != null and dc.has_method("is_active"):
return bool(dc.is_active())
return _drop_active
func _input(event: InputEvent) -> void:
if not _drag_active:
var vp := get_viewport()
if vp != null and vp.is_input_handled():
return
if event is InputEventKey and event.pressed and not event.echo \
and event.keycode == KEY_R:
var dc := _existing_drag_controller()
# R/ПКМ работают и у цели drop: локальный _drag_active выставляет только
# источник, а пакетный drag его вообще не трогает.
if dc == null or not dc.has_method("is_active") or not bool(dc.is_active()):
return
if is_rotate_key(event) or is_rotate_click(event):
_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()
vp.set_input_as_handled()
func _rotate_drag() -> void:
_drag_rotation = posmod(_drag_rotation + 1, 4)
var dc := _drag_controller()
if dc != null and dc.has_method("rotate_cw"):
_drag_rotation = int(dc.rotate_cw())
else:
_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()
## DragController — единый источник поворота. Ghost пересчитывается даже
## если событие R получила другая сетка.
func _on_drag_rotation_changed(value: int) -> void:
_drag_rotation = posmod(int(value), 4)
if _drop_active and not _drag_payload.is_empty():
_refresh_ghost(_last_drop_pos)
queue_redraw()
func _bind_rotation_signal(dc: Node) -> void:
if dc == null or not dc.has_signal("rotation_changed"):
return
if not dc.is_connected("rotation_changed", Callable(self, "_on_drag_rotation_changed")):
dc.connect("rotation_changed", Callable(self, "_on_drag_rotation_changed"))
# --- drag source -----------------------------------------------------------
func _get_drag_data(at_position: Vector2) -> Variant:
@@ -580,13 +661,14 @@ func _get_drag_data(at_position: Vector2) -> Variant:
preview.setup(str(file.type), str(file.shape_id), int(file.rotation),
str(file.name), str(file.ext), clampf(_cell * 0.55, 28.0, 56.0))
preview.set_mode("shape")
dc.begin(preview, "shape")
dc.begin(preview, "shape", int(file.rotation))
queue_redraw()
return payload
func _drag_controller() -> Node:
if drag_controller != null and is_instance_valid(drag_controller):
_bind_rotation_signal(drag_controller)
return drag_controller
if not is_inside_tree():
return null
@@ -596,15 +678,18 @@ func _drag_controller() -> Node:
dc.name = "DragControllerAuto"
get_tree().root.add_child(dc)
drag_controller = dc
_bind_rotation_signal(dc)
return dc
func _existing_drag_controller() -> Node:
var dc: Node = null
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")
dc = drag_controller
elif is_inside_tree():
dc = get_tree().get_first_node_in_group("drag_controller")
_bind_rotation_signal(dc)
return dc
# --- drop target -----------------------------------------------------------
@@ -616,15 +701,25 @@ func _can_drop_data(at_position: Vector2, data: Variant) -> bool:
var kind := str(payload.get("kind", ""))
if kind != "file" and kind != "packet":
return false
var dc := _drag_controller()
var key := kind + ":" + str(payload.get("id", ""))
if key != _drag_key:
_drag_key = key
_drag_rotation = posmod(int(payload.get("rotation", 0)), 4)
# Поворот — состояние DragController: после R/ПКМ payload уже устарел.
# Если контроллер пуст (пакет без превью), засеваем из payload.
if dc != null and dc.has_method("is_active") and bool(dc.is_active()):
_drag_rotation = int(dc.rotation()) if dc.has_method("rotation") else \
posmod(int(payload.get("rotation", 0)), 4)
else:
_drag_rotation = posmod(int(payload.get("rotation", 0)), 4)
if dc != null and dc.has_method("set_rotation"):
dc.set_rotation(_drag_rotation)
elif dc != null and dc.has_method("rotation"):
_drag_rotation = int(dc.rotation())
_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
@@ -643,32 +738,42 @@ func _refresh_ghost(pos: Vector2) -> void:
_ghost_rotation = int(fit.get("rotation", _drag_rotation))
_ghost_origin = fit.get("origin", target)
_ghost_ok = bool(fit.get("ok", false))
_ghost_auto = bool(fit.get("auto", false))
_ghost_target = target
_ghost_cells = FR.shape_cells(shape_id, _ghost_rotation)
queue_redraw()
## Сначала пробуем ВЫБРАННЫЙ пользователем поворот. Остальные — только если
## он не влезает; тогда fit.auto = true, и ghost честно пишет про автоповорот.
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 preferred := posmod(int(rotation), 4)
var ignore := ""
if str(payload.get("kind", "")) == "file":
ignore = str(payload.get("id", ""))
if target.x < 0 or target.y < 0:
var auto := _find_fit(shape_id, rotation, ignore)
var auto := _find_fit(shape_id, preferred, 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)}
var auto_rot := int(auto["rotation"])
return {"ok": true, "origin": auto["origin"], "rotation": auto_rot,
"auto": auto_rot != preferred}
return {"ok": false, "origin": Vector2i(-1, -1), "rotation": preferred,
"auto": false}
for i in 4:
var rot := posmod(rotation + i, 4)
var rot := posmod(preferred + i, 4)
var origin := anchor_origin(target, shape_id, rot)
if _can_place(shape_id, origin, rot, ignore):
return {"ok": true, "origin": origin, "rotation": rot}
return {"ok": true, "origin": origin, "rotation": rot,
"auto": rot != preferred}
return {
"ok": false,
"origin": anchor_origin(target, shape_id, posmod(rotation, 4)),
"rotation": posmod(rotation, 4),
"origin": anchor_origin(target, shape_id, preferred),
"rotation": preferred,
"auto": false,
}
@@ -702,7 +807,16 @@ func _drop_data(at_position: Vector2, data: Variant) -> void:
var payload: Dictionary = data
var kind := str(payload.get("kind", ""))
var target := nearest_cell(at_position, _grid_origin, _cell, GAP, _cols, _rows)
var fit := _resolve_placement(payload, target, _drag_rotation)
var dc := _existing_drag_controller()
if dc != null and dc.has_method("rotation"):
_drag_rotation = int(dc.rotation())
# Роняем ровно туда и с тем поворотом, который показал ghost.
var fit: Dictionary
if _ghost_target == target and not _ghost_cells.is_empty():
fit = {"ok": _ghost_ok, "origin": _ghost_origin,
"rotation": _ghost_rotation, "auto": _ghost_auto}
else:
fit = _resolve_placement(payload, target, _drag_rotation)
if kind == "packet":
if not bool(fit.get("ok", false)):
_reject("НЕТ МЕСТА В ПАПКЕ")
@@ -756,7 +870,10 @@ func _notify(text: String) -> void:
func _end_drop() -> void:
_drop_active = false
_drag_key = ""
_drag_rotation = 0
_ghost_cells.clear()
_ghost_target = Vector2i(-1, -1)
_ghost_auto = false
mouse_default_cursor_shape = Control.CURSOR_ARROW
queue_redraw()
@@ -820,7 +937,7 @@ func _draw_file(file) -> void:
if not _filter.is_empty() and not _matches_filter(file):
return
var cells := FR.shape_cells(str(file.shape_id), int(file.rotation))
FILE_SHAPE.paint(self, _grid_origin, cells, _cell, {
FILE_SHAPE.paint(self, _shape_pos(Vector2i(file.origin)), cells, _cell, {
"type": str(file.type),
"name": str(file.name),
"ext": str(file.ext),
@@ -839,7 +956,7 @@ func _draw_reservation(res: Dictionary) -> void:
var origin: Vector2i = res.get("origin", Vector2i.ZERO)
var cells := FR.shape_cells(shape_id, rot)
var fill := Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, 0.10)
FILE_SHAPE.paint(self, _grid_origin, cells, _cell, {
FILE_SHAPE.paint(self, _shape_pos(origin), cells, _cell, {
"type": "text",
"state": "stored",
"alpha": 0.9,
@@ -862,7 +979,7 @@ func _draw_ghost(f: Font) -> void:
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, {
FILE_SHAPE.paint(self, _shape_pos(_ghost_origin), _ghost_cells, _cell, {
"type": "text",
"alpha": 0.9,
"show_label": false,
@@ -872,11 +989,12 @@ func _draw_ghost(f: Font) -> void:
})
var b := FR.shape_bounds(_ghost_cells)
var step := _cell + GAP
var box := Rect2(_grid_origin + Vector2(float(_ghost_origin.x) * step,
float(_ghost_origin.y) * step), Vector2(
var box := Rect2(_shape_pos(_ghost_origin), Vector2(
float(b.x) * step - GAP, float(b.y) * step - GAP))
Theme90.draw_dashed_box(self, box.grow(2.0), color, 4.0, 1.2)
var hint := "ОТПУСТИТЕ · R ПОВОРОТ" if _ghost_ok else "НЕ ВЛЕЗАЕТ · R ПОВОРОТ"
if _ghost_ok and _ghost_auto:
hint = "ОТПУСТИТЕ · АВТОПОВОРОТ · 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)
+199 -32
View File
@@ -273,6 +273,138 @@ static func paint(ci: CanvasItem, pos: Vector2, cells: Array, cell_px,
alpha, locked, state)
## Кегль подписи файла по размеру клетки: 12 / 14 / 16 px.
## Используется и в сетке, и в карточке запроса (square), чтобы имена
## не были микроскопическими на больших клетках.
## Это БАЗОВЫЙ кегль: если имя не влезает в отведённые строки, painter
## уменьшает его на 1 px (не ниже 11) прежде чем обрезать многоточием.
static func label_font_size(cell_px: float) -> int:
if cell_px > 96.0:
return 16
if cell_px >= 72.0:
return 14
return 12
## Сколько строк имени даёт клетка: 3 при >= 88, 2 при 72–87, 1 ниже 72.
## Больше строк — длинные имена читаются целиком, а не «запас_е…».
static func label_max_lines(cell_px: float) -> int:
if cell_px >= 88.0:
return 3
if cell_px >= 72.0:
return 2
return 1
## Перенос по словам с РАЗНОЙ шириной строк: первая уже (в ней иконка),
## последующие занимают всю клетку за вычетом полей.
static func _wrap_name(f: Font, text: String, size: int, first_w: float,
rest_w: float) -> PackedStringArray:
var out := PackedStringArray()
if text.is_empty():
return out
var cur := ""
for raw in text.split(" ", false):
var word := str(raw)
var width := first_w if out.is_empty() else rest_w
var cand := word if cur.is_empty() else cur + " " + word
if f.get_string_size(cand, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x <= width:
cur = cand
else:
if not cur.is_empty():
out.append(cur)
cur = word
if not cur.is_empty():
out.append(cur)
return out
## Имя целиком помещается в max_lines при данном кегле (без многоточия)?
static func _name_fits(f: Font, text: String, size: int, first_w: float,
rest_w: float, max_lines: int) -> bool:
var lines := _wrap_name(f, text, size, first_w, rest_w)
if lines.size() > max_lines:
return false
for i in lines.size():
var width := first_w if i == 0 else rest_w
if f.get_string_size(lines[i], HORIZONTAL_ALIGNMENT_LEFT, -1, size).x > width:
return false
return true
## Самый крупный кегль от базового, при котором имя ещё влезает целиком.
## Ниже 11 px не опускаемся: лучше многоточие, чем нечитаемая строка.
static func name_font_size(cell_px: float, text: String, first_w: float,
rest_w: float, max_lines: int) -> int:
return _max_fit_size(Theme90.ui(), text, cell_px, first_w, rest_w, max_lines)
static func _max_fit_size(f: Font, text: String, cell_px: float, first_w: float,
rest_w: float, max_lines: int) -> int:
var base := label_font_size(cell_px)
var lo := mini(11, base)
for size in range(base, lo - 1, -1):
if _name_fits(f, text, size, first_w, rest_w, max_lines):
return size
return lo
## Готовые строки для отрисовки. Если имя не влезает — последняя строка
## получает хвост и многоточие; отдельные слишком длинные слова режутся
## по ширине своей строки.
static func _build_lines(f: Font, text: String, size: int, first_w: float,
rest_w: float, max_lines: int) -> PackedStringArray:
var lines := _wrap_name(f, text, size, first_w, rest_w)
if lines.size() > max_lines:
var kept := PackedStringArray()
for i in max_lines:
kept.append(lines[i])
kept[max_lines - 1] = Theme90.fit(f,
kept[max_lines - 1] + " " + lines[max_lines], size,
first_w if max_lines == 1 else rest_w)
lines = kept
for i in lines.size():
var lw := first_w if i == 0 else rest_w
if f.get_string_size(lines[i], HORIZONTAL_ALIGNMENT_LEFT, -1, size).x > lw:
lines[i] = Theme90.fit(f, lines[i], size, lw)
return lines
## Сколько символов имени реально видно (без многоточий и пробелов).
static func _visible_chars(lines: PackedStringArray) -> int:
var total := 0
for line in lines:
total += str(line).replace("…", "").replace(" ", "").length()
return total
## Раскладка подписи. Обычно иконка стоит в верхнем левом углу, а первая
## строка начинается справа от неё (icon_inline = true).
## Если первое слово в такую строку не помещается даже после сжатия кегля,
## текст переносится ПОД иконку и все строки получают полную ширину:
## иначе длинное слово теряло бы начало («запас_енер…» вместо имени целиком).
## Возвращает {"size": int, "lines": PackedStringArray, "icon_inline": bool}.
static func _label_layout(f: Font, name: String, cell_px: float, first_w: float,
rest_w: float, max_lines: int) -> Dictionary:
var inline_size := name_font_size(cell_px, name, first_w, rest_w, max_lines)
if _name_fits(f, name, inline_size, first_w, rest_w, max_lines):
return {"size": inline_size, "icon_inline": true,
"lines": _build_lines(f, name, inline_size, first_w, rest_w, max_lines)}
var below_size := _max_fit_size(f, name, cell_px, rest_w, rest_w, max_lines)
if _name_fits(f, name, below_size, rest_w, rest_w, max_lines):
return {"size": below_size, "icon_inline": false,
"lines": _build_lines(f, name, below_size, rest_w, rest_w, max_lines)}
# Не влезает ни так, ни так — оставляем раскладку, где видно больше имени.
var inline_lines := _build_lines(f, name, inline_size, first_w, rest_w, max_lines)
var below_lines := _build_lines(f, name, below_size, rest_w, rest_w, max_lines)
var below_better := below_size > inline_size \
or (below_size == inline_size
and _visible_chars(below_lines) > _visible_chars(inline_lines))
if below_better:
return {"size": below_size, "icon_inline": false, "lines": below_lines}
return {"size": inline_size, "icon_inline": true, "lines": inline_lines}
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:
@@ -284,30 +416,59 @@ static func _paint_cell_label(ci: CanvasItem, pos: Vector2, lbl: Vector2i,
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)
# Поля: строки после первой идут во всю ширину клетки (cell − 10 px).
const PAD := 5.0
var base_size := label_font_size(cell_min)
var ext_size := maxi(9, base_size - 3)
var max_lines := label_max_lines(cell_min)
var icon_size := clampf(cs.x * 0.16, 10.0, 16.0)
var ty := lr.position.y + 3.0
# Иконка — в верхнем левом углу. Первая строка идёт справа от неё,
# остальные — от левого поля, во всю ширину; если имя не влезает рядом
# с иконкой, весь текст уходит под неё (см. _label_layout).
var text_x := lr.position.x + PAD
var first_x := text_x + icon_size + 2.0
var badge_room := 10.0 if state == "attached" or state == "malicious" \
or state == "failed" else 0.0
var first_w := maxf(8.0, lr.end.x - first_x - PAD - badge_room)
var rest_w := maxf(8.0, lr.size.x - PAD * 2.0)
var layout := _label_layout(f, name, cell_min, first_w, rest_w, max_lines)
var name_size: int = int(layout["size"])
var lines: PackedStringArray = layout["lines"]
var icon_inline: bool = bool(layout["icon_inline"])
var line_h := float(name_size) + 1.0
# Строка с иконкой занимает не меньше самой иконки — иначе вторая строка
# наехала бы на неё.
var first_h := maxf(line_h, icon_size + 2.0)
var text_top := ty if icon_inline else ty + first_h
var icon := Rect2(Vector2(text_x, ty + maxf(0.0, (line_h - icon_size) * 0.5))
.round(), Vector2(icon_size, icon_size))
if not Theme90.draw_file_icon(ci, icon, file_type, _with_alpha(Color.WHITE, alpha)):
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)
var last_bottom := ty
for i in lines.size():
var lx := text_x
var lw := rest_w
var ly := text_top + float(i) * line_h
if icon_inline:
if i == 0:
lx = first_x
lw = first_w
ly = ty
else:
ly = ty + first_h + float(i - 1) * line_h
Theme90.text(ci, f, Vector2(lx, ly), lines[i], name_size,
_with_alpha(ink, alpha), lw)
last_bottom = ly + line_h
if not ext_value.is_empty():
var ey := (last_bottom if not lines.is_empty() else ty) + 1.0
# Расширение — отдельной мелкой строкой, если в клетке осталось место.
if ey + float(ext_size) + 2.0 <= lr.end.y:
Theme90.text(ci, f, Vector2(text_x, ey),
Theme90.fit(f, ext_value, ext_size, rest_w),
ext_size, _with_alpha(shade, alpha), rest_w)
# состояния в верхнем правом углу label-клетки
var badge := Vector2(lr.end.x - 8.0, lr.position.y + 8.0)
@@ -330,19 +491,25 @@ static func _paint_square_label(ci: CanvasItem, pos: Vector2, lbl: Vector2i,
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 name_size := label_font_size(cell_min)
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
var icon_size := clampf(cell_min * 0.30, 14.0, 18.0)
var name_y := lr.end.y - float(name_size) - 4.0
var icon := Rect2(lr.get_center().x - icon_size * 0.5,
lr.get_center().y - icon_size * 0.5, icon_size, icon_size)
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)
# Иконка слева от имени, выровнена по строке подписи.
icon = Rect2(lr.position.x + 3.0,
name_y + (float(name_size) - icon_size) * 0.5,
icon_size, icon_size)
if not Theme90.draw_file_icon(ci, icon, file_type, _with_alpha(Color.WHITE, alpha)):
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)
var nx := icon.end.x + 2.0
Theme90.text(ci, f, Vector2(nx, name_y),
Theme90.fit(f, name, name_size, lr.end.x - nx - 3.0), name_size,
_with_alpha(col, alpha), lr.end.x - nx - 3.0)
## 8×8 иконка типа: строки (text), волна (sound), горы (photo), play (video).
+11 -2
View File
@@ -9,6 +9,7 @@ extends Control
signal new_run
signal continue_run
signal tutorial_requested
signal settings
signal about
@@ -29,6 +30,7 @@ var vhs = null
var _btn_continue: Button90
var _btn_new: Button90
var _btn_tutorial: Button90
var _btn_settings: Button90
var _btn_about: Button90
@@ -73,6 +75,12 @@ func _build() -> void:
_btn_new.accent = true
_btn_new.pressed.connect(func() -> void: new_run.emit())
# Обучение — вторичное действие сразу под «Новый забег»: та же форма,
# чуть меньше кегль и высота, чтобы не спорить с главным CTA.
_btn_tutorial = _make_button("ОБУЧЕНИЕ")
_btn_tutorial.caption_size = 12
_btn_tutorial.pressed.connect(func() -> void: tutorial_requested.emit())
_btn_settings = _make_button(_tr("btn.settings", "Настройки"))
# визуально «активная» кнопка: логику подключит main.gd
_btn_settings.active = true
@@ -125,11 +133,12 @@ func _layout() -> void:
var w := _window()
var px := w.position.x + 660.0
var py := w.position.y + 112.0
var buttons: Array[Button90] = [_btn_continue, _btn_new, _btn_settings, _btn_about]
var buttons: Array[Button90] = [_btn_continue, _btn_new, _btn_tutorial,
_btn_settings, _btn_about]
for b in buttons:
if b != null:
b.position = Vector2(px, py)
b.size = Vector2(300, 38)
b.size = Vector2(300, 34 if b == _btn_tutorial else 38)
py += 52.0
+8 -7
View File
@@ -120,11 +120,8 @@ func _display_name() -> String:
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]
# Полное имя одной строкой (без переноса): ОС-подсказка не обрезает его.
tooltip_text = "%s · %s · %s" % [_display_name(), FileCard.type_title(type), protocol]
func _process(delta: float) -> void:
@@ -149,8 +146,12 @@ func _draw() -> void:
_draw_shape_preview(Rect2(4, 10, 30, 30))
Theme90.text(self, f, Vector2(40, 2), Theme90.fit(f, _display_name(), 12, 168), 12,
Theme90.INK, 168)
# Иконка типа 14×14 сразу слева от имени файла.
if not type.is_empty():
Theme90.draw_file_icon(self, Rect2(40, 2, 14, 14), type)
Theme90.text(self, f, Vector2(57, 2), Theme90.fit(f, _display_name(), 13, 151), 13,
Theme90.INK, 151)
# бейдж протокола
var badge := Rect2(214, 4, 30, 13)
+470 -129
View File
@@ -1,11 +1,12 @@
class_name RequestCard
extends Control
## Карточка запроса клиента 400×108 (v2).
## Карточка запроса клиента 400×128 (v3).
##
## Показывает квадратные чипы прикреплённых файлов, счётчик
## «ПРИКРЕПЛЕНО X/Y», предсказанный выбор мини-игры (S → игра · T),
## переключатель TCP/UDP, явную кнопку ОТПРАВИТЬ, ОТКРЕПИТЬ и ОТКЛОНИТЬ.
## Атак/фишинга в карточке больше нет.
## Сетка 2×2 обязательных файлов (конкретные REQUIRED-id из main), счётчик
## «ПРИКРЕПЛЕНО X/Y», прогноз мини-игры (Economy.send_minigame_for по типам
## обязательных файлов) и явные кнопки СОЕДИНИТЬСЯ/ОТПРАВИТЬ, ОТКРЕПИТЬ,
## ОТКЛОНИТЬ. Наведение на чип показывает полное имя файла и папку, где он
## лежит, в подсказке и строке внутри карточки. Атак/фишинга в карточке нет.
signal send_pressed(id: String)
signal protocol_toggle_requested(id: String)
@@ -15,21 +16,32 @@ signal detach_pressed(request_id: String, slot_index: int)
## Legacy: соединение удалено. Сигнал оставлен, чтобы старые подписи не падали.
signal connect_pressed(id: String)
const CARD := Vector2(400, 108)
const CARD := Vector2(400, 128)
const ECONOMY := preload("res://scripts/core/economy.gd")
const SLOT_MAX := 4
## Сетка 2×2: шаг 90×28, чип 86×26. Правый столбец (196..392) — счётчик,
## прогноз и строка состояния.
const SLOT_X := 8.0
const SLOT_STEP := 90.0
const SLOT_STEP_X := 90.0
const SLOT_Y := 30.0
const SLOT_STEP_Y := 28.0
const SLOT_W := 86.0
const SLOT_H := 26.0
const PROTO_RECT := Rect2(178, 6, 44, 16)
## Полоса полного имени при наведении на чип: правый верх, над счётчиком.
const HOVER_STRIP := Rect2(196, 17, 196, 13)
## Если имя и папка не влезают в одну строку — полоса выше на 9 px, папка
## идёт второй мелкой строкой. Счётчик «ПРИКРЕПЛЕНО» в этот момент не рисуем:
## на его месте оказывается вторая строка полосы.
const HOVER_STRIP_TALL := 22.0
const HOVER_FOLDER_SIZE := 7
## Пояснение к исчезновению файлов после отправки (§2.8).
const SEND_HINT := "После успешной отправки файлы списываются."
const COEFF := {"text": 1, "sound": 2, "photo": 4, "video": 8}
const SEND_ORDER := ["dots", "sound", "wires", "text", "video", "photo", "maze"]
const GAME_NAMES := {
## Имена игр — только запасной вариант, если Data.minigame_def недоступен.
const FALLBACK_NAMES := {
"dots": "ОДНА ЛИНИЯ",
"sound": "УРОВНИ",
"wires": "ПРОВОДА",
"text": "ЧУЖОЕ СЛОВО",
"video": "РАСКАДРОВКА",
"photo": "КАДР",
@@ -48,6 +60,9 @@ var progress := 0.0
var queued := false
var sending := false
var minigame_active := false
## TCP-соединение: идёт мини-игра соединения / соединение уже установлено.
var connecting := false
var connected := false
## Готовность контейнера (HourDirector.is_request_ready): файлы собраны.
var send_ready := false
var send_sum := 0
@@ -70,6 +85,7 @@ var _drop_slot := -1
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_PASS
clip_contents = false
custom_minimum_size = CARD
if size == Vector2.ZERO:
size = CARD
@@ -77,21 +93,22 @@ func _ready() -> void:
mouse_entered.connect(func() -> void: hovered = true)
mouse_exited.connect(func() -> void:
hovered = false
_hover_slot = -1)
_set_hover_slot(-1))
_send_btn = _make_button("ОТПРАВИТЬ", Vector2(8, 86), Vector2(92, 18), true)
_send_btn = _make_button("ОТПРАВИТЬ", Vector2(8, 104), 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 = _make_button("ОТКРЕПИТЬ", Vector2(104, 104), 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 = _make_button("ОТКЛОНИТЬ", Vector2(194, 104), Vector2(90, 18), false)
_reject_btn.danger = true
_reject_btn.pressed.connect(func() -> void: reject_pressed.emit(id))
set_process(true)
_update_buttons()
func _make_button(caption: String, pos: Vector2, sz: Vector2, accent: bool) -> Button90:
@@ -101,16 +118,18 @@ func _make_button(caption: String, pos: Vector2, sz: Vector2, accent: bool) -> B
b.accent = accent
b.position = pos
b.size = sz
b.mouse_entered.connect(func() -> void: _set_hover_slot(-1))
add_child(b)
return b
## Совместимые первые 8 аргументов v1 + v2-поля.
## Совместимые первые 16 аргументов v2 + TCP-состояние.
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_minigame_active: bool = false, p_ready: bool = false,
p_connecting: bool = false, p_connected: bool = false) -> void:
id = p_id
client_name = p_client
client_id = p_client_id
@@ -118,36 +137,65 @@ func setup(p_id: String, p_client: String, p_client_id: String, p_proto: String,
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()
var req := clampi(int(p_required), 0, SLOT_MAX)
if req > 0:
required_count = req
elif not slots.is_empty():
required_count = mini(slots.size(), SLOT_MAX)
else:
required_count = 0
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
connecting = p_connecting
connected = p_connected
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]
custom_minimum_size = Vector2(CARD.x, 0)
# Слот наведения переживает ежеминутные (покадровые) обновления карточки:
# иначе полоса с именем/папкой успевала показаться лишь на один кадр.
if _hover_slot >= required_count:
_hover_slot = -1
_refresh_tooltip()
custom_minimum_size = CARD
if size == Vector2.ZERO:
size = CARD
_update_buttons()
queue_redraw()
func _update_buttons() -> void:
if _send_btn != null:
var caption := _send_caption()
if _send_btn.caption != caption:
_send_btn.caption = caption
_send_btn.disabled = not can_send()
_send_btn.tooltip_text = "Отправить контейнер" if can_send() \
else _send_disabled_reason()
if can_send():
_send_btn.tooltip_text = ("Провести соединение, затем отправить" \
if protocol == "TCP" and not connected else "Отправить контейнер") \
+ "\n" + SEND_HINT
else:
_send_btn.tooltip_text = _send_disabled_reason() + "\n" + SEND_HINT
if _detach_btn != null:
_detach_btn.disabled = attached_count() <= 0 or sending or minigame_active or queued
_detach_btn.disabled = attached_count() <= 0 or sending or minigame_active \
or queued or connecting
if _reject_btn != null:
_reject_btn.disabled = sending
## Текст главной кнопки: TCP без соединения сначала соединяется.
func _send_caption() -> String:
if connecting:
return "СОЕДИНЕНИЕ…"
if protocol == "TCP" and not connected:
return "СОЕДИНИТЬСЯ"
return "ОТПРАВИТЬ"
func ratio() -> float:
if time_total <= 0.0:
return 0.0
@@ -156,18 +204,29 @@ func ratio() -> float:
func attached_count() -> int:
var n := 0
for slot in slots:
if slot is Dictionary and bool(slot.get("attached", false)):
for i in mini(required_count, slots.size()):
if _slot_attached(i):
n += 1
return n
func _lost_count() -> int:
var n := 0
for i in required_count:
if _slot_missing(i):
n += 1
return n
## Все обязательные файлы прикреплены и нет блокирующего состояния.
## TCP-соединение проверяет backend: кнопка остаётся доступной, чтобы
## игрок мог нажать её и пройти мини-игру соединения.
func can_send() -> bool:
if required_count <= 0:
return false
if sending or queued or minigame_active:
if connecting or sending or queued or minigame_active:
return false
return attached_count() >= required_count and send_ready
return attached_count() >= required_count
func send_disabled() -> bool:
@@ -175,41 +234,52 @@ func send_disabled() -> bool:
func _send_disabled_reason() -> String:
if connecting:
return "Идёт соединение…"
if sending:
return "Отправка уже идёт"
if queued:
return "Ожидание свободного слота"
if minigame_active:
return "Сначала завершите мини-игру"
if required_count <= 0:
return "Нет обязательных файлов"
if _lost_count() > 0:
return "Обязательный файл потерян"
if attached_count() < required_count:
return "Прикрепите файлы: %d из %d" % [attached_count(), required_count]
return "Отправка недоступна"
## Предсказание мини-игры: сумма коэффициентов → игра и ступень (§6).
## Пока мини-игра не запущена, игра/ступень считаются тем же плотным
## маппингом B4 (Economy.send_minigame_for), что и при реальной отправке.
## Считается по типам ОБЯЗАТЕЛЬНЫХ файлов, поэтому видно ещё до прикрепления.
## Если main передал send_sum/minigame_id, они приоритетнее локального расчёта.
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)
for i in required_count:
if _slot_missing(i):
continue
var data: Variant = _slot_data(i)
if data is Dictionary:
var t := str((data as Dictionary).get("type", ""))
if not t.is_empty():
types.append(t)
var pick: Dictionary = ECONOMY.selection_for_sum(send_sum) if send_sum > 0 \
else 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))
# В карточке только игры ОТПРАВКИ: у них три ступени (атаки не показываем).
var tier := clampi(int(pick.get("tier", 1)), 1, 3)
var sum := int(pick.get("sum", 0))
if not minigame_id.is_empty():
# Мини-игра уже активна: показываем выбор директора, не пересчитываем.
game = minigame_id
if minigame_tier > 0:
tier = clampi(minigame_tier, 1, 4)
tier = clampi(minigame_tier, 1, 3)
if send_sum > 0:
sum = send_sum
return {
"sum": sum,
"index": index,
"index": int(pick.get("index", 0)),
"id": game,
"name": _game_name(game),
"tier": tier,
@@ -218,13 +288,16 @@ func prediction() -> Dictionary:
func minigame_text() -> String:
var p := prediction()
if int(p.get("sum", 0)) <= 0:
if int(p.get("sum", 0)) <= 0 and minigame_id.is_empty():
return "МИНИ-ИГРА: —"
return "МИНИ-ИГРА: %s · T%d" % [str(p.get("name", "")), int(p.get("tier", 1))]
return "МИНИ-ИГРА: %s · T%d" % [str(p.get("name", "")),
clampi(int(p.get("tier", 1)), 1, 3)]
## Сумма коэффициентов типов обязательных файлов — «чем больше, тем сложнее».
## Не путать со ступенью T1..T3: она уже показана в строке мини-игры выше.
func difficulty_text() -> String:
return "СЛОЖНОСТЬ: %d" % int(prediction().get("sum", 0))
return "СУММА: %d" % int(prediction().get("sum", 0))
func _game_name(game_id: String) -> String:
@@ -234,7 +307,7 @@ func _game_name(game_id: String) -> String:
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()))
return str(FALLBACK_NAMES.get(game_id, game_id.to_upper()))
func _process(delta: float) -> void:
@@ -245,9 +318,133 @@ func _process(delta: float) -> void:
queue_redraw()
func _slot_rect(i: int) -> Rect2:
return Rect2(SLOT_X + i * SLOT_STEP, SLOT_Y, SLOT_W, SLOT_H)
# --- слоты -----------------------------------------------------------------
func _slot_data(i: int) -> Variant:
if i < 0 or i >= slots.size():
return null
return slots[i]
func _slot_attached(i: int) -> bool:
var data: Variant = _slot_data(i)
return data is Dictionary and bool((data as Dictionary).get("attached", false))
## Слот обязателен, но файла в реестре нет. null внутри required-диапазона —
## тоже потерянный файл (main может отдать null либо name = "ПОТЕРЯН").
func _slot_missing(i: int) -> bool:
if i < 0 or i >= required_count:
return false
var data: Variant = _slot_data(i)
if data == null:
return true
if data is Dictionary:
var d: Dictionary = data
if bool(d.get("missing", false)):
return true
return str(d.get("name", "")) == "ПОТЕРЯН"
return true
func _slot_status(i: int) -> String:
if _slot_missing(i):
return "ПОТЕРЯН"
if _slot_attached(i):
return "ПРИКРЕПЛЁН"
return "ОБЯЗАТЕЛЕН"
func _display_name(data: Dictionary) -> String:
var nm := str(data.get("name", ""))
var e := str(data.get("ext", ""))
if e.is_empty():
return nm
return "%s.%s" % [nm, e]
func _slot_full_label(i: int) -> String:
if _slot_missing(i):
return "ПОТЕРЯН · ФАЙЛ · ПОТЕРЯН · папка: —"
var data: Variant = _slot_data(i)
if not (data is Dictionary):
return ""
var d: Dictionary = data
var line := "%s · %s · %s" % [_display_name(d),
FileCard.type_title(str(d.get("type", "text"))), _slot_status(i)]
var folder := _slot_folder(i)
if not folder.is_empty():
line += " · папка: " + folder
return line
## Папка, где сейчас лежит обязательный файл ("" — неизвестна или файла нет).
func _slot_folder(i: int) -> String:
if _slot_missing(i):
return ""
var data: Variant = _slot_data(i)
if not (data is Dictionary):
return ""
return str((data as Dictionary).get("folder", "")).strip_edges()
## Только имя файла — для полосы внутри карточки (тип/статус остаются в tooltip).
func _slot_name_only(i: int) -> String:
if _slot_missing(i):
return "ПОТЕРЯН"
var data: Variant = _slot_data(i)
if not (data is Dictionary):
return ""
return _display_name(data)
func _slot_rect(i: int) -> Rect2:
return Rect2(SLOT_X + float(i % 2) * SLOT_STEP_X,
SLOT_Y + floorf(float(i) / 2.0) * SLOT_STEP_Y, SLOT_W, SLOT_H)
## Кегль имени в чипе: 10 px; 9 — только если это позволяет показать имя
## целиком. Длинное имя остаётся 10 px одной строкой с многоточием (полное
## имя показывает полоса наведения).
func _chip_name_size(f: Font, name_text: String, width: float) -> int:
if f.get_string_size(name_text, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x <= width:
return 10
if f.get_string_size(name_text, HORIZONTAL_ALIGNMENT_LEFT, -1, 9).x <= width:
return 9
return 10
## Кегль подписи типа/статуса: 8 px, если влезает, иначе прежние 7.
func _chip_sub_size(f: Font, text: String, width: float) -> int:
if f.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, 8).x <= width:
return 8
return 7
func _slot_at(pos: Vector2) -> int:
for i in required_count:
if _slot_rect(i).has_point(pos):
return i
return -1
## Первый слот, в который реально можно прикрепить файл: обязательный, ещё
## не занятый и не потерянный. -1 — свободных нет (подсветку не рисуем).
func _first_empty() -> int:
for i in required_count:
if _slot_data(i) != null and not _slot_attached(i) and not _slot_missing(i):
return i
return -1
func _last_attached_index() -> int:
for i in range(mini(required_count, slots.size()) - 1, -1, -1):
if _slot_attached(i):
return i
return -1
# --- отрисовка -------------------------------------------------------------
func _draw() -> void:
var r := Rect2(Vector2.ZERO, size)
@@ -261,17 +458,23 @@ func _draw() -> void:
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.text(self, f, Vector2(32, 18), Theme90.fit(f, client_id, 9, 140), 9,
Theme90.SHADE, 140)
_draw_protocol_chip(f)
_draw_timer(f)
for i in maxi(required_count, 1):
var count := required_count if required_count > 0 else 1
for i in count:
_draw_slot(i, f)
_draw_counter(f)
# Полное имя файла и папка — поверх правой части шапки, только при наведении на чип.
var tall_strip := _hover_slot >= 0 and _hover_strip_tall(f)
if not tall_strip:
_draw_counter(f)
_draw_prediction(f)
if _hover_slot >= 0:
_draw_hover_strip(f, tall_strip)
if hovered and _drop_slot < 0:
draw_style_box(Theme90.box(Color(0, 0, 0, 0), Theme90.ACCENT_LIGHT, 4, 1),
@@ -282,20 +485,33 @@ func _draw() -> void:
func _draw_protocol_chip(f: Font) -> void:
var pcol := Theme90.NAVY if protocol == "TCP" else Theme90.WARN
var locked := connected
var pcol := Theme90.SHADE if locked else (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)
if locked:
_draw_lock(Vector2(PROTO_RECT.end.x - 5, PROTO_RECT.get_center().y), pcol)
else:
Theme90.draw_chevron(self, Vector2(PROTO_RECT.end.x - 5, PROTO_RECT.get_center().y),
2, pcol, 2.0, 1.0)
func _draw_lock(center: Vector2, col: Color) -> void:
draw_rect(Rect2(center.x - 2.5, center.y, 5.0, 4.0), col, true)
draw_arc(Vector2(center.x, center.y), 2.0, PI, TAU, 6, col, 1.0, true)
func _draw_timer(f: Font) -> void:
var label := "ОСТАЛОСЬ"
var value := "%d с" % int(ceilf(time_left))
var col := _time_color()
if sending:
if connecting:
label = "СОСТОЯНИЕ"
value = "СВЯЗЬ…"
col = Theme90.WARN
elif sending:
label = "ОТПРАВКА"
value = "%d%%" % int(roundf(progress * 100.0))
col = Theme90.ACCENT
@@ -316,30 +532,108 @@ func _draw_timer(f: Font) -> void:
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)
var col := Theme90.SUCCESS if cap > 0 and have >= cap else Theme90.DARK
if _lost_count() > 0:
col = Theme90.ERROR
Theme90.text(self, f, Vector2(196, 32), "ПРИКРЕПЛЕНО %d/%d" % [have, cap], 11,
col, 196)
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)
Theme90.text(self, f, Vector2(196, 50), Theme90.fit(f, minigame_text(), 10, 196), 10,
Theme90.NAVY, 196)
Theme90.text(self, f, Vector2(196, 64), "СУММА: %d" % int(p.get("sum", 0)), 9,
Theme90.DARK, 196)
var hint := ""
var hcol := Theme90.WARN
if connecting:
hint = "УСТАНОВКА СОЕДИНЕНИЯ"
hcol = Theme90.WARN
elif queued:
hint = "ОЖИДАНИЕ СЛОТА"
elif sending:
Theme90.text(self, f, Vector2(236, 73), "ПЕРЕДАЧА ИДЁТ", 9, Theme90.ACCENT, 156)
hint = "ПЕРЕДАЧА ИДЁТ"
hcol = Theme90.ACCENT
elif minigame_active:
Theme90.text(self, f, Vector2(236, 73), "ИДЁТ МИНИ-ИГРА", 9, Theme90.ACCENT, 156)
hint = "ИДЁТ МИНИ-ИГРА"
hcol = Theme90.ACCENT
elif _lost_count() > 0:
hint = "ПОТЕРЯН ФАЙЛ"
hcol = Theme90.ERROR
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)
hint = "НУЖНО ФАЙЛОВ: %d" % maxi(0, required_count - attached_count())
elif protocol == "TCP" and not connected:
hint = "СНАЧАЛА СОЕДИНЕНИЕ"
hcol = Theme90.SHADE
if not hint.is_empty():
Theme90.text(self, f, Vector2(196, 76), Theme90.fit(f, hint, 9, 196), 9, hcol, 196)
## Кегль имени в полосе: длинные имена уменьшаем на 1 px, чтобы показать целиком.
func _hover_text_size(f: Font, name_text: String) -> int:
var inner := HOVER_STRIP.size.x - 10.0
if f.get_string_size(name_text, HORIZONTAL_ALIGNMENT_LEFT, -1, 9).x > inner:
return 8
return 9
## Влезают ли имя и папка в одну строку полосы при текущем кегле.
func _hover_strip_tall(f: Font) -> bool:
var folder := _slot_folder(_hover_slot)
if folder.is_empty():
return false
var name_text := _slot_name_only(_hover_slot)
if name_text.is_empty():
return false
var inner := HOVER_STRIP.size.x - 10.0
var line := "%s · %s" % [name_text, folder]
return f.get_string_size(line, HORIZONTAL_ALIGNMENT_LEFT, -1,
_hover_text_size(f, name_text)).x > inner
func _draw_hover_strip(f: Font, tall: bool) -> void:
var name_text := _slot_name_only(_hover_slot)
if name_text.is_empty():
return
var border := Theme90.ACCENT_LIGHT
if _slot_missing(_hover_slot):
border = Theme90.ERROR
elif _slot_attached(_hover_slot):
border = Theme90.SUCCESS
var text_size := _hover_text_size(f, name_text)
var inner := HOVER_STRIP.size.x - 10.0
var folder := _slot_folder(_hover_slot)
if not tall:
# Всё влезло в одну строку: имя и папка через « · ».
var line := name_text if folder.is_empty() \
else "%s · %s" % [name_text, folder]
draw_style_box(Theme90.box(Theme90.WHITE, border, 3, 1), HOVER_STRIP)
Theme90.text_vcenter(self, f, HOVER_STRIP,
Theme90.fit(f, line, text_size, inner), text_size, Theme90.INK,
inner, HORIZONTAL_ALIGNMENT_LEFT, 5.0)
return
# Полоса на строку выше: имя сверху, папка — мелкой второй строкой. Счётчик
# «ПРИКРЕПЛЕНО» на это время не рисуется (_draw), иначе строки наложатся.
var strip := Rect2(HOVER_STRIP.position,
Vector2(HOVER_STRIP.size.x, HOVER_STRIP_TALL))
draw_style_box(Theme90.box(Theme90.WHITE, border, 3, 1), strip)
var name_rect := Rect2(strip.position, Vector2(strip.size.x, HOVER_STRIP.size.y))
Theme90.text_vcenter(self, f, name_rect,
Theme90.fit(f, name_text, text_size, inner), text_size, Theme90.INK,
inner, HORIZONTAL_ALIGNMENT_LEFT, 5.0)
var folder_rect := Rect2(strip.position + Vector2(0, 12),
Vector2(strip.size.x, HOVER_STRIP_TALL - 12.0))
Theme90.text_vcenter(self, f, folder_rect,
Theme90.fit(f, "папка: " + folder, HOVER_FOLDER_SIZE, inner),
HOVER_FOLDER_SIZE, Theme90.SHADE, inner, HORIZONTAL_ALIGNMENT_LEFT, 5.0)
func _time_color() -> Color:
if queued:
if queued or connecting:
return Theme90.WARN
if ratio() < 0.2:
return Theme90.ERROR
@@ -370,80 +664,132 @@ func _monogram() -> String:
func _draw_slot(i: int, f: Font) -> void:
var cell := _slot_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)
if _blink_on:
Theme90.text(self, f, Vector2(cell.position.x + 16, cell.position.y + 7),
"ожидается", 9, Theme90.WARN, SLOT_W - 18.0)
if drop:
Theme90.draw_dashed_box(self, cell.grow(1), Theme90.ACCENT, 2.5, 1.4)
if required_count <= 0:
_draw_empty_slot(cell, i)
return
var d: Dictionary = data
var t := str(d.get("type", "text"))
if _slot_missing(i):
_draw_missing_slot(cell, i, f)
return
var data: Dictionary = _slot_data(i)
var t := str(data.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),
cell)
# квадратный чип: только иконка типа + имя + тип, без фигуры
FileShape.draw_type_icon(self, Rect2(cell.position + Vector2(4, 6), Vector2(12, 12)),
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)
if bool(d.get("attached", false)):
var attached := _slot_attached(i)
var hov := _hover_slot == i
var border: Color
var fill: Color
if attached:
fill = Theme90.WHITE
border = Theme90.ACCENT.lightened(0.16) if hov else Theme90.ACCENT
draw_style_box(Theme90.box(fill, border, 3, 1), cell)
else:
# Ждём файл: приглушённая заливка и пунктир в цвете типа.
fill = Theme90.PAPER
border = Theme90.ACCENT_LIGHT if hov else Color(col.r, col.g, col.b, 0.62)
draw_style_box(Theme90.box(fill, Color(0, 0, 0, 0), 3, 0), cell)
Theme90.draw_dashed_box(self, cell, border, 3.0, 1.0)
# Иконка файла слева; если текстуры нет — прежняя цветная полоса типа.
var icon_drawn := false
if not t.is_empty():
icon_drawn = Theme90.draw_file_icon(self, Rect2(
cell.position + Vector2(4, 4), Vector2(12, 12)), t)
if not icon_drawn:
draw_rect(Rect2(cell.position.x + 1, cell.position.y + 4, 3, cell.size.y - 8), col)
var text_x := 8.0 + (16.0 if icon_drawn else 0.0)
var name_w := SLOT_W - text_x - 6.0
if attached:
name_w -= 8.0
var full_name := _display_name(data)
var name_size := _chip_name_size(f, full_name, name_w)
var nm := Theme90.fit(f, full_name, name_size, name_w)
Theme90.text(self, f, Vector2(cell.position.x + text_x, cell.position.y + 2), nm,
name_size, Theme90.INK, name_w)
var sub := FileCard.type_title(t)
var sub_col := Theme90.SHADE
if not attached:
sub = "ЖДЁМ"
sub_col = Theme90.WARN
var sub_w := SLOT_W - text_x - 4.0
Theme90.text(self, f, Vector2(cell.position.x + text_x, cell.position.y + 15), sub,
_chip_sub_size(f, sub, sub_w), sub_col, sub_w)
if attached:
Theme90.draw_check_badge(self, Vector2(cell.end.x - 7, cell.position.y + 7), 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,
Theme90.ERROR.b, 0.92))
Theme90.draw_x(self, xr, Theme90.WHITE, 1.0)
if drop:
if _drop_slot == i:
Theme90.draw_dashed_box(self, cell.grow(1), Theme90.ACCENT, 2.5, 1.4)
func _draw_missing_slot(cell: Rect2, i: int, f: Font) -> void:
var hov := _hover_slot == i
var border := Theme90.ERROR.lightened(0.2) if hov else Theme90.ERROR
draw_style_box(Theme90.box(Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b, 0.06),
border, 3, 1), cell)
draw_rect(Rect2(cell.position.x + 1, cell.position.y + 4, 3, cell.size.y - 8),
Theme90.ERROR)
Theme90.text(self, f, Vector2(cell.position.x + 8, cell.position.y + 2), "ПОТЕРЯН",
_chip_name_size(f, "ПОТЕРЯН", SLOT_W - 14.0), Theme90.ERROR, SLOT_W - 14.0)
Theme90.text(self, f, Vector2(cell.position.x + 8, cell.position.y + 15), "НЕТ ФАЙЛА",
_chip_sub_size(f, "НЕТ ФАЙЛА", SLOT_W - 14.0),
Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b, 0.8), SLOT_W - 14.0)
if _drop_slot == i:
Theme90.draw_dashed_box(self, cell.grow(1), Theme90.ACCENT, 2.5, 1.4)
func _draw_empty_slot(cell: Rect2, i: int) -> void:
var f := Theme90.ui()
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)
if _blink_on:
Theme90.text(self, f, Vector2(cell.position.x + 16, cell.position.y + 7),
"ожидается", 9, Theme90.WARN, SLOT_W - 18.0)
if _drop_slot == i:
Theme90.draw_dashed_box(self, cell.grow(1), Theme90.ACCENT, 2.5, 1.4)
# --- подсказки и ввод ------------------------------------------------------
func _default_tooltip() -> String:
return "%s · %s\n%s" % [client_name, client_id, SEND_HINT]
func _refresh_tooltip() -> void:
if _hover_slot >= 0 and _hover_slot < required_count:
var line := _slot_full_label(_hover_slot)
if not line.is_empty():
tooltip_text = line
return
tooltip_text = _default_tooltip() + \
"\nСумма коэффициентов файлов: чем выше, тем сложнее мини-игра"
func _set_hover_slot(index: int) -> void:
if index == _hover_slot:
return
_hover_slot = index
_refresh_tooltip()
queue_redraw()
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
var prev := _hover_slot
_hover_slot = _slot_at(event.position)
if prev != _hover_slot:
queue_redraw()
_set_hover_slot(_slot_at(event.position))
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)
if not connected and not connecting:
protocol_toggle_requested.emit(id)
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)):
if event.double_click and _slot_attached(i):
detach_pressed.emit(id, i)
accept_event()
return
accept_event()
func _slot_at(pos: Vector2) -> int:
for i in maxi(required_count, 1):
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
@@ -455,7 +801,9 @@ func _can_drop_data(at_position: Vector2, data: Variant) -> bool:
var i := _slot_at(at_position)
if i < 0:
i = _first_empty()
_drop_slot = i
# Свободного слота может не быть (-1): подсветку не рисуем, но дроп
# разрешаем — соответствие обязательному файлу проверит backend.
_drop_slot = i if i >= 0 else -1
var dc := _drag_controller()
if dc != null:
dc.set_mode("square")
@@ -472,13 +820,6 @@ func _drop_data(at_position: Vector2, data: Variant) -> void:
file_dropped.emit(id, i, data)
func _first_empty() -> int:
for i in maxi(required_count, 1):
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
+8 -8
View File
@@ -38,7 +38,7 @@ const FOOTER_Y := H - 52.0
const FOOTER_H := 28.0
const MAX_CARDS := 3
const FALLBACK_DIMS := [[5, 4], [6, 4], [6, 5], [7, 5], [7, 6], [8, 6]]
const FALLBACK_DIMS := [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]]
## Текстовые fallback'и (ключи shop.* есть в data/strings_ru.json).
const FALLBACK := {
@@ -416,13 +416,13 @@ func _effect_values(item: Dictionary, level: int) -> Dictionary:
var now_slots := int(game.connection_slots()) if game != null else 1 + level
return {"now": str(now_slots), "after": str(now_slots + maxi(1, int(value)))}
"transfer_speed":
var now_time := float(game.speed_mult()) if game != null else pow(0.92, float(level))
var now_time := float(game.speed_mult()) if game != null else pow(0.90, float(level))
var after_time := now_time * (1.0 - clampf(value, 0.01, 0.5))
return {"now": "×%.2f" % (1.0 / now_time), "after": "×%.2f" % (1.0 / after_time)}
"sla_cap":
var now_cap := int(game.sla_cap()) if game != null else 60 + 35 * level
var now_cap := int(game.sla_cap()) if game != null else 60 + 40 * level
var current := int(minf(float(game.sla), float(now_cap))) if game != null else now_cap
var after_cap := mini(200, now_cap + int(value))
var after_cap := mini(220, now_cap + int(value))
var after_current := mini(after_cap, current + int(value))
return {"now": "%d / %d" % [current, now_cap],
"after": "%d / %d" % [after_current, after_cap]}
@@ -456,7 +456,7 @@ func _effect_values(item: Dictionary, level: int) -> Dictionary:
return {"now": "%d / %d" % [now_channel, now_buffer],
"after": "%d / %d" % [now_channel + add_channel, now_buffer + add_buffer]}
"request_timer":
var now_timer := int(game.request_timer()) if game != null else 30 + 5 * level
var now_timer := int(game.request_timer()) if game != null else 30 + 7 * level
return {"now": "%d с" % now_timer, "after": "%d с" % (now_timer + maxi(1, int(value)))}
return {"now": "—", "after": "—"}
@@ -799,11 +799,11 @@ func _effect_fallback_desc(effect: String) -> String:
"connection_slots":
return "Ещё один порт: приём и отправка могут идти параллельно."
"transfer_speed":
return "Скорость приёма и отправки выше на 8% за уровень."
return "Скорость приёма и отправки выше на 10% за уровень."
"sla_cap":
return "Держит узел живым: кап SLA +35 и сразу +35 SLA."
return "Держит узел живым: кап SLA +40 и сразу +40 SLA."
"shield_charge":
return "Автоматически гасит атаку: 50% награды, без штрафа."
return "Автоматически гасит атаку: 70% награды, без штрафа."
"money_mult":
return "Оплата за контейнеры +5% за уровень."
"folder_cells":
+98 -35
View File
@@ -49,8 +49,10 @@ 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_target: Vector2i = Vector2i(-1, -1)
var _ghost_rotation: int = 0
var _ghost_ok: bool = false
var _ghost_auto: bool = false
func _ready() -> void:
@@ -115,7 +117,7 @@ func file_count() -> int:
func footer_text() -> String:
var total := _cols * _rows
return "%d %s · занято %d" % [total, FG.plural_cells(total), _cells_owner.size()]
return "%d %s" % [total, FG.plural_cells(total)]
func grid_metrics() -> Dictionary:
@@ -183,6 +185,10 @@ func _cell_rect(c: int, r: int) -> Rect2:
return FG.cell_rect_pair(_grid_origin, _cell, GAP, c, r)
func _shape_pos(cell_pos: Vector2i) -> Vector2:
return FG.shape_origin_pair(_grid_origin, _cell, GAP, cell_pos)
func _file_at(pos: Vector2) -> String:
var c := FG.cell_at_rect(pos, _grid_origin, _cell, GAP, _cols, _rows)
if c.x < 0:
@@ -235,37 +241,61 @@ func _gui_input(event: InputEvent) -> void:
queue_redraw()
accept_event()
elif event.button_index == MOUSE_BUTTON_RIGHT:
if _drag_in_progress():
accept_event()
return
var id := _file_at(event.position)
if not id.is_empty():
context_requested.emit(id)
accept_event()
func _drag_in_progress() -> bool:
if _drag_active:
return true
var dc := _existing_drag_controller()
if dc != null and dc.has_method("is_active"):
return bool(dc.is_active())
return _drop_active
func _input(event: InputEvent) -> void:
if not _drag_active:
var vp := get_viewport()
if vp != null and vp.is_input_handled():
return
if event is InputEventKey and event.pressed and not event.echo \
and event.keycode == KEY_R:
var dc := _existing_drag_controller()
if dc == null or not dc.has_method("is_active") or not bool(dc.is_active()):
return
if FG.is_rotate_key(event) or FG.is_rotate_click(event):
_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()
vp.set_input_as_handled()
func _rotate_drag() -> void:
_drag_rotation = posmod(_drag_rotation + 1, 4)
var dc := _drag_controller()
if dc != null and dc.has_method("rotate_cw"):
_drag_rotation = int(dc.rotate_cw())
else:
_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()
func _on_drag_rotation_changed(value: int) -> void:
_drag_rotation = posmod(int(value), 4)
if _drop_active and not _drag_payload.is_empty():
_refresh_ghost(_last_drop_pos)
queue_redraw()
func _bind_rotation_signal(dc: Node) -> void:
if dc == null or not dc.has_signal("rotation_changed"):
return
if not dc.is_connected("rotation_changed", Callable(self, "_on_drag_rotation_changed")):
dc.connect("rotation_changed", Callable(self, "_on_drag_rotation_changed"))
# --- drag source -----------------------------------------------------------
func _get_drag_data(at_position: Vector2) -> Variant:
@@ -289,13 +319,14 @@ func _get_drag_data(at_position: Vector2) -> Variant:
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")
dc.begin(preview, "shape", int(file.rotation))
queue_redraw()
return payload
func _drag_controller() -> Node:
if drag_controller != null and is_instance_valid(drag_controller):
_bind_rotation_signal(drag_controller)
return drag_controller
if not is_inside_tree():
return null
@@ -305,15 +336,18 @@ func _drag_controller() -> Node:
dc.name = "DragControllerAuto"
get_tree().root.add_child(dc)
drag_controller = dc
_bind_rotation_signal(dc)
return dc
func _existing_drag_controller() -> Node:
var dc: Node = null
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")
dc = drag_controller
elif is_inside_tree():
dc = get_tree().get_first_node_in_group("drag_controller")
_bind_rotation_signal(dc)
return dc
# --- drop target -----------------------------------------------------------
@@ -324,15 +358,23 @@ func _can_drop_data(at_position: Vector2, data: Variant) -> bool:
var payload: Dictionary = data
if str(payload.get("kind", "")) != "file":
return false
var dc := _drag_controller()
var key := "file:" + str(payload.get("id", ""))
if key != _drag_key:
_drag_key = key
_drag_rotation = posmod(int(payload.get("rotation", 0)), 4)
if dc != null and dc.has_method("is_active") and bool(dc.is_active()):
_drag_rotation = int(dc.rotation()) if dc.has_method("rotation") else \
posmod(int(payload.get("rotation", 0)), 4)
else:
_drag_rotation = posmod(int(payload.get("rotation", 0)), 4)
if dc != null and dc.has_method("set_rotation"):
dc.set_rotation(_drag_rotation)
elif dc != null and dc.has_method("rotation"):
_drag_rotation = int(dc.rotation())
_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
@@ -351,6 +393,8 @@ func _refresh_ghost(pos: Vector2) -> void:
_ghost_rotation = int(fit.get("rotation", _drag_rotation))
_ghost_origin = fit.get("origin", target)
_ghost_ok = bool(fit.get("ok", false))
_ghost_auto = bool(fit.get("auto", false))
_ghost_target = target
_ghost_cells = FR.shape_cells(shape_id, _ghost_rotation)
queue_redraw()
@@ -360,21 +404,27 @@ func _resolve_placement(payload: Dictionary, target: Vector2i,
var shape_id := str(payload.get("shape_id", "O1"))
if shape_id.is_empty():
shape_id = "O1"
var preferred := posmod(int(rotation), 4)
var ignore := str(payload.get("id", ""))
if target.x < 0 or target.y < 0:
var auto := _find_fit(shape_id, rotation, ignore)
var auto := _find_fit(shape_id, preferred, 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)}
var auto_rot := int(auto["rotation"])
return {"ok": true, "origin": auto["origin"], "rotation": auto_rot,
"auto": auto_rot != preferred}
return {"ok": false, "origin": Vector2i(-1, -1), "rotation": preferred,
"auto": false}
for i in 4:
var rot := posmod(rotation + i, 4)
var rot := posmod(preferred + 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": true, "origin": origin, "rotation": rot,
"auto": rot != preferred}
return {
"ok": false,
"origin": FG.anchor_origin(target, shape_id, posmod(rotation, 4)),
"rotation": posmod(rotation, 4),
"origin": FG.anchor_origin(target, shape_id, preferred),
"rotation": preferred,
"auto": false,
}
@@ -411,8 +461,17 @@ func _drop_data(at_position: Vector2, data: Variant) -> void:
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)
var target := FG.nearest_cell_rect(at_position, _grid_origin, _cell, GAP,
_cols, _rows)
var dc := _existing_drag_controller()
if dc != null and dc.has_method("rotation"):
_drag_rotation = int(dc.rotation())
var fit: Dictionary
if _ghost_target == target and not _ghost_cells.is_empty():
fit = {"ok": _ghost_ok, "origin": _ghost_origin,
"rotation": _ghost_rotation, "auto": _ghost_auto}
else:
fit = _resolve_placement(payload, target, _drag_rotation)
if not bool(fit.get("ok", false)):
_reject("СТОЛ ЗАПОЛНЕН")
_end_drop()
@@ -454,7 +513,10 @@ func _notify(text: String) -> void:
func _end_drop() -> void:
_drop_active = false
_drag_key = ""
_drag_rotation = 0
_ghost_cells.clear()
_ghost_target = Vector2i(-1, -1)
_ghost_auto = false
mouse_default_cursor_shape = Control.CURSOR_ARROW
queue_redraw()
@@ -511,7 +573,7 @@ func _draw_grid_lines() -> void:
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, {
FILE_SHAPE.paint(self, _shape_pos(Vector2i(file.origin)), cells, _cell, {
"type": str(file.type),
"name": str(file.name),
"ext": str(file.ext),
@@ -529,7 +591,7 @@ func _draw_ghost(f: Font) -> void:
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, {
FILE_SHAPE.paint(self, _shape_pos(_ghost_origin), _ghost_cells, _cell, {
"type": "text",
"alpha": 0.9,
"show_label": false,
@@ -539,11 +601,12 @@ func _draw_ghost(f: Font) -> void:
})
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(
var box := Rect2(_shape_pos(_ghost_origin), 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 ПОВОРОТ"
if _ghost_ok and _ghost_auto:
hint = "ОТПУСТИТЕ · АВТОПОВОРОТ · 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)
+75 -15
View File
@@ -3,8 +3,9 @@ extends RefCounted
## Палитра, шрифты и примитивы отрисовки UI. Версия 2 — «тёплая бумага»:
## тонкие 1 px контуры вместо 2 px фасок, мягкие тени, скругления 3–4 px.
##
## Никаких внешних текстур: всё рисуется кодом. Единый источник цветов,
## кеглей и StyleBox для всех UI-компонентов.
## Почти всё рисуется кодом; исключение — пиксельные иконки файлов из
## art/icons (их можно заменить или перегенерировать generate_icons.py).
## Единый источник цветов, кеглей и StyleBox для всех UI-компонентов.
# --- Палитра ---------------------------------------------------------------
const PAPER := Color(0.913725, 0.901961, 0.866667) # #E9E6DD
@@ -38,20 +39,25 @@ const BORDER_SOFT := Color(0.254902, 0.239216, 0.203922, 0.12)
const HAIRLINE := Color(0.254902, 0.239216, 0.203922, 0.10)
# --- Кегли ----------------------------------------------------------------
const FS_MICRO := 10
const FS_BODY := 12
const FS_HEAD := 16
const FS_LOGO := 24
const FS_MICRO := 11
const FS_BODY := 13
const FS_HEAD := 17
const FS_LOGO := 26
const FONT_HANDJET := "res://art/fonts/Handjet-VF.ttf"
## Основной шрифт — Golos Text: гуманистический, с полной кириллицей
## (О/П/Ё/№), хорошо читается в мелких кеглях 10–14 px.
const FONT_UI := "res://art/fonts/GolosText-VF.ttf"
const FONT_LOGO := "res://art/fonts/PressStart2P-Regular.ttf"
const ICON_DIR := "res://art/icons"
const H := 24.0 # высота строки дерева / кнопки
const SCROLLBAR := 14.0 # ширина скроллбара
const BEVEL := 1.0 # оставлено для совместимости: толщина линии
const RADIUS := 3 # базовое скругление карточек и кнопок
static var _fonts: Dictionary = {}
static var _icons: Dictionary = {}
static var _dither: ImageTexture = null
static var _boxes: Dictionary = {}
@@ -62,18 +68,19 @@ static func tag(name: String) -> int:
return TextServerManager.get_primary_interface().name_to_tag(name)
## Handjet с фиксированными осями: wght 700, ELGR 2, ELSH 2.
static func ui(weight: float = 700.0, elgr: float = 2.0, elsh: float = 2.0) -> FontVariation:
var key := "%d_%d_%d" % [weight, elgr, elsh]
## Golos Text с осью wght (400–900), по умолчанию 600.
## elgr/elsh оставлены в сигнатуре для совместимости со старыми вызовами
## Handjet — у Golos Text таких осей нет, значения игнорируются.
static func ui(weight: float = 600.0, _elgr: float = 1.0, _elsh: float = 1.0) -> FontVariation:
var w := clampf(weight, 400.0, 900.0)
var key := "ui_%d" % int(roundf(w))
if _fonts.has(key):
return _fonts[key]
var base: FontFile = load(FONT_HANDJET)
var base: FontFile = load(FONT_UI)
var fv := FontVariation.new()
fv.base_font = base
fv.variation_opentype = {
tag("wght"): weight,
tag("ELGR"): elgr,
tag("ELSH"): elsh,
tag("wght"): w,
}
_fonts[key] = fv
return fv
@@ -372,6 +379,59 @@ static func ext_color(cat: String) -> Color:
return ACCENT
## Пиксельные иконки 32×32 из art/icons (generate_icons.py). Ключи — те же
## имена типов, что и в FileShape (text/sound/photo/video), плюс folder/packet.
const ICON_FILES := {
"text": "file_text",
"doc": "file_text",
"pdf": "file_text",
"sound": "file_sound",
"audio": "file_sound",
"photo": "file_photo",
"image": "file_photo",
"video": "file_video",
"folder": "folder",
"packet": "packet",
}
## Текстура иконки по типу (кэшируется). null — файла нет или тип неизвестен.
static func file_icon(kind: String) -> Texture2D:
var key := kind.strip_edges().to_lower()
if _icons.has(key):
return _icons[key]
var tex: Texture2D = null
var file_name: String = str(ICON_FILES.get(key, ""))
if not file_name.is_empty():
var path := ICON_DIR + "/" + file_name + ".png"
if ResourceLoader.exists(path):
tex = load(path) as Texture2D
_icons[key] = tex
return tex
## Рисует иконку по центру rect с ближайшей фильтрацией (пиксели остаются
## резкими при 16/24/32 px). false — если текстуры нет, рисуйте draw_doc_icon.
static func draw_file_icon(ci: CanvasItem, rect: Rect2, kind: String,
modulate: Color = Color.WHITE) -> bool:
var tex := file_icon(kind)
if tex == null:
return false
var s := minf(rect.size.x, rect.size.y)
if s <= 0.0:
return false
var side := roundf(s)
var top_left := (rect.position + (rect.size - Vector2(side, side)) * 0.5).round()
var target := Rect2(top_left, Vector2(side, side))
var prev := ci.texture_filter
if prev != CanvasItem.TEXTURE_FILTER_NEAREST:
ci.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
ci.draw_texture_rect(tex, target, false, modulate)
if prev != CanvasItem.TEXTURE_FILTER_NEAREST:
ci.texture_filter = prev
return true
## Документ с цветным глифом. ext (необязательный) выбирает значок по
## расширению: строки, горы, волна, play, молния, терминал, сетка.
## Пустое/неизвестное расширение — прежний глиф типа (text/sound/photo/video).
@@ -983,7 +1043,7 @@ class DragGhost:
10, Theme90.SHADE)
## Числовая строка: цифры — Press Start 2P (чистые 0), суффикс — Handjet.
## Числовая строка: цифры — Press Start 2P (чистые 0), суффикс — Golos Text.
## Базовая линия суффикса совпадает с базовой линией цифр.
static func num_line(ci: CanvasItem, top_left: Vector2, number: String, size: int, color: Color,
suffix: String = "", suffix_size: int = -1) -> float:
+21 -8
View File
@@ -1,12 +1,21 @@
class_name ToastLayer
extends Control
## Очередь сообщений внизу справа. Живут над статус-баром, не перекрывая его.
## Светлые карточки с цветной точкой и мягкой тенью.
## Очередь сообщений сверху по центру холста. Стопка растёт ВНИЗ от верхней
## кромки рабочей области: не перекрывает карточки ОТПРАВКИ и их кнопки
## ОТПРАВИТЬ/ОТКЛОНИТЬ. Светлые карточки с цветной точкой и мягкой тенью.
##
## Координаты — логические 1280×720 (canvas_items): тост шириной 400 стоит
## ровно по центру (440…840) и не заходит в правое окно ОТПРАВКА (856…1272).
const W := 400.0
const H := 30.0
const GAP := 4.0
const BOTTOM := 650.0
## Первый тост: сразу под строкой меню, над заголовками ДИСК/ФАЙЛЫ.
const TOP := 44.0
## Точка входа: над кадром, карточка «въезжает» сверху вниз.
const ENTER_Y := -40.0
## Центр холста 1280 px.
const LEFT := (1280.0 - W) * 0.5
var _items: Array[Control] = []
@@ -26,12 +35,15 @@ func toast(text: String, kind: String = "info") -> void:
old.queue_free()
var item := ToastItem.new(text, kind)
item.size = Vector2(W, H)
item.position = Vector2(1276.0, _slot_y(_items.size()))
# Новый тост — последний в стопке, то есть самый нижний.
item.position = Vector2(LEFT, ENTER_Y)
item.modulate.a = 0.0
add_child(item)
_items.append(item)
var target := _target_pos(_items.size() - 1)
var tw := create_tween()
tw.tween_property(item, "position", target, 0.22).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
var tw := create_tween().set_parallel(true)
tw.tween_property(item, "position", target, 0.24).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tw.tween_property(item, "modulate:a", 1.0, 0.16)
var life := create_tween()
life.tween_interval(3.0)
life.tween_property(item, "modulate:a", 0.0, 0.45)
@@ -57,12 +69,13 @@ func _stop_life(item: Control) -> void:
(tw as Tween).kill()
## Слот стопки: 44 / 78 / 112 — сверху вниз, свежий тост ниже старых.
func _slot_y(index: int) -> float:
return BOTTOM - H - (index) * (H + GAP)
return TOP + float(index) * (H + GAP)
func _target_pos(index: int) -> Vector2:
return Vector2(1272.0 - W, _slot_y(index))
return Vector2(LEFT, _slot_y(index))
func _relayout() -> void:
+34 -5
View File
@@ -127,13 +127,44 @@ func set_data(sections: Array) -> void:
call_deferred("_bind_drag_controller")
func _plural_folders(n: int) -> String:
var n10 := n % 10
var n100 := n % 100
if n10 == 1 and n100 != 11:
return "папка"
if n10 >= 2 and n10 <= 4 and (n100 < 12 or n100 > 14):
return "папки"
return "папок"
func _plural_files(n: int) -> String:
var n10 := n % 10
var n100 := n % 100
if n10 == 1 and n100 != 11:
return "файл"
if n10 >= 2 and n10 <= 4 and (n100 < 12 or n100 > 14):
return "файла"
return "файлов"
func _plural_cells(n: int) -> String:
var n10 := n % 10
var n100 := n % 100
if n10 == 1 and n100 != 11:
return "клетка"
if n10 >= 2 and n10 <= 4 and (n100 < 12 or n100 > 14):
return "клетки"
return "клеток"
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)
var free_cells := int(cells_raw)
return "свободно %d %s" % [free_cells, _plural_cells(free_cells)]
if cells_raw is String:
return str(cells_raw)
return ""
@@ -161,7 +192,7 @@ func _pick_int(fol: Dictionary, keys: Array, fallback: int) -> int:
func _folder_tooltip(files: int, occupied: int, total: int) -> String:
return "%d файлов · %d из %d клеток" % [files, occupied, total]
return "%d %s · %d из %d клеток" % [files, _plural_files(files), occupied, total]
func _apply_layout() -> void:
@@ -235,14 +266,12 @@ func update_stats(folder_id: String, files: int, occupied: int, total: int) -> v
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]
return "%d %s · %d %s" % [folders, _plural_folders(folders), files, _plural_files(files)]
func _on_toggle(folder_id: String) -> void:
+11 -5
View File
@@ -277,8 +277,14 @@ func _check_hour() -> void:
var recv: WindowFrame = _main.get_node("ReceiveWindow")
var send: WindowFrame = _main.get_node("SendWindow")
_check(recv.title_hint.contains("ПАКЕТОВ"), "ПРИЁМ переключён на час: «%s»" % recv.title_hint)
_check(send.title_hint.contains("ЗАПРОСОВ"), "ОТПРАВКА переключена на час: «%s»" % send.title_hint)
var packet_hint: String = str(_main.call("_plural_ru",
(_main.get("packets") as Array).size(), "ПАКЕТ", "ПАКЕТА", "ПАКЕТОВ"))
var request_hint: String = str(_main.call("_plural_ru",
(_main.get("requests") as Array).size(), "ЗАПРОС", "ЗАПРОСА", "ЗАПРОСОВ"))
_check(recv.title_hint == packet_hint,
"ПРИЁМ переключён на час: «%s» (ожидалось «%s»)" % [recv.title_hint, packet_hint])
_check(send.title_hint == request_hint,
"ОТПРАВКА переключена на час: «%s» (ожидалось «%s»)" % [send.title_hint, request_hint])
var toast: ToastLayer = _main.get_node("ToastLayer")
_check(toast.get_child_count() >= 1, "тост «ЧАС N НАЧАЛСЯ» показан")
@@ -319,13 +325,13 @@ func _check_fonts() -> void:
missing += cyr[i]
_check(missing.is_empty(), "Handjet содержит кириллицу (%s)" % ("нет: " + missing if not missing.is_empty() else "ок"))
_check(Theme90.logo().has_char(48), "Press Start 2P содержит цифру 0")
_check([Theme90.FS_MICRO, Theme90.FS_BODY, Theme90.FS_HEAD, Theme90.FS_LOGO] == [10, 12, 16, 24],
"кегли 10/12/16/24")
_check([Theme90.FS_MICRO, Theme90.FS_BODY, Theme90.FS_HEAD, Theme90.FS_LOGO] == [11, 13, 17, 26],
"кегли 11/13/17/26")
var th: Theme = load("res://art/fonts/ui_theme.tres")
_check(th != null, "art/fonts/ui_theme.tres загружается")
if th != null:
_check(th.default_font is FontVariation, "тема: default_font = FontVariation")
_check(th.default_font_size == 12, "тема: базовый кегль 12")
_check(th.default_font_size == 13, "тема: базовый кегль 13")
# --------------------------------------------------------------------------
+44 -10
View File
@@ -1,16 +1,50 @@
# tests/
Каталог под автотесты. Пока пусто: реализации, которую стоит тестировать, нет.
Headless-автотесты и симулятор баланса.
План:
- чистая логика (`scripts/core`, `scripts/systems`) — юнит-тесты на GDScript,
запуск headless;
- данные (`data/*.json`) — валидация схемы/ключей;
- дымовой тест — `--headless --quit` без ошибок парсинга (см. `docs/build.md`).
Запуск заготовки (когда появится фреймворк, например GUT/GdUnit4):
## Полный прогон
```bash
/home/eq/techlb/tools/godot/Godot_v4.7.2-stable_linux.x86_64 --headless --path . --quit
bash scripts/run_all_tests.sh
# Windows:
powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1
```
Скрипт запускает **25 headless-наборов + parse-check** (итого 26 проверок),
каждый в отдельном процессе Godot с таймаутом и в изолированных временных
XDG-каталогах. Отсутствующие файлы тестов помечаются `SKIP`, а не `FAIL`.
Путь к Godot задаётся через `GODOT_BIN` (иначе ищется `godot4`/`godot` в
`PATH`). Подробности — в `README` §Тесты и в самом `scripts/run_all_tests.sh`.
## Что покрыто
- **Ядро и экономика:** `run_tests.gd`, `test_hour.gd`, `test_slots.gd`,
`test_shop.gd`, `test_attacks.gd`, `test_fuzz.gd`, `test_integration.gd`,
`test_controller.gd`.
- **UI:** `scripts/ui/ui_smoke.gd`, `test_file_grid.gd`,
`test_shop_modal.gd`, `test_toast.gd`.
- **Мини-игры:** `test_minigame.gd`, `test_send_games.gd`,
`test_attack_games.gd`.
- **Сцены и полный забег:** `test_main_*.gd`, `test_full_playthrough.gd`.
- **Аудио:** `test_audio.gd`, `test_ambient.gd`.
- **Баланс:** `sim_balance.py` — 240 сидов, 9 часов; отчёт
`tests/SIM_BALANCE_REPORT.md`.
Полный список наборов — в массиве `TESTS` внутри `scripts/run_all_tests.sh`.
## Одиночный запуск
```bash
export XDG_DATA_HOME="$(mktemp -d)"
"$GODOT" --headless --path . --script tests/run_tests.gd
```
Используйте уникальный `XDG_DATA_HOME` на каждый запуск: сейвы и настройки
Godot иначе пересекаются с реальным профилем и между прогонами, что даёт
нестабильные результаты.
Симулятор баланса запускается обычным Python 3 без зависимостей:
```bash
python3 tests/sim_balance.py
```
+42 -50
View File
@@ -1,90 +1,82 @@
# PEAK HOUR v2 - balance simulation report
# PEAK HOUR v3 - 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.
- Spawn: file budget from `hours[].files` (+ carry), spawn only during 75% of the hour (225 s), interval 225/budget with +/-20% jitter; H1 spawns its first packet at t=0. 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.
- Player: competent, attaches after 1.5 s + 1 s/file, plays a no-timer send minigame for 7 + 1.5*(tier+1) seconds (3 tiers). TCP adds 4.2 s connection time and 4% of attempts miss it (-4 SLA). The minigame pauses packet spawn/loading but not other request timers (production order).
- Timers: request `30 + 7*timer_level` (cap 65), 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.90^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)`; TCP x1.15, UDP x0.85; recv_fee is added on receive and is not multiplied.
- Folder: `grid.folder_dims[folder_level]`, 2 free folders at start; each extra folder costs `constants.folder_new_cost` (180 $) in prep, up to `constants.folders_max` (6). `lot_folder` buys more cells; 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.
- ROI proxy: `money` = next `shop[].value` step 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).
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 4 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 |
| 1 | 7 | 6 | 2 | 1.24 | 21.6 | 26.8 | 5 | 134 | 163 | 177 | 197 | 210 | 308 | 150 | ok |
| 2 | 8 | 6 | 2 | 1.42 | 25.5 | 36.2 | 5 | 181 | 222 | 240 | 267 | 352 | 525 | 220 | ok |
| 3 | 12 | 5 | 2 | 1.57 | 31.4 | 49.3 | 5 | 246 | 312 | 336 | 373 | 828 | 1231 | 380 | ok |
| 4 | 16 | 6 | 3 | 1.66 | 37.0 | 61.3 | 6 | 368 | 470 | 507 | 562 | 1104 | 1642 | 560 | ok |
| 5 | 20 | 6 | 3 | 1.82 | 42.0 | 76.4 | 6 | 458 | 596 | 642 | 711 | 1380 | 2052 | 760 | expected low |
| 6 | 20 | 7 | 3 | 1.87 | 46.8 | 87.4 | 7 | 612 | 783 | 844 | 936 | 1380 | 2052 | 980 | expected low |
| 7 | 20 | 7 | 4 | 2.03 | 50.5 | 102.5 | 7 | 717 | 911 | 983 | 1090 | 1380 | 2052 | 1200 | expected low |
| 8 | 20 | 7 | 4 | 2.10 | 54.8 | 115.1 | 7 | 806 | 1022 | 1102 | 1223 | 1380 | 2052 | 1450 | expected low |
| 9 | 20 | 7 | 4 | 2.20 | 57.9 | 127.4 | 7 | 892 | 1126 | 1215 | 1349 | 1380 | 2052 | 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 |
| 1 | 150 | 149 | 151 | 125 | 167 | -0.9% | 6.0 | 0.0 | 6.0/1.0 | 2/40/38 | 59 | 0.0/0.0 | 0 | PASS |
| 2 | 220 | 216 | 210 | 174 | 266 | -1.9% | 6.0 | 0.0 | 6.1/1.9 | 4/40/36 | 59 | 1.0/0.0 | 142 | PASS |
| 3 | 380 | 342 | 337 | 244 | 445 | -9.9% | 5.0 | 0.0 | 7.1/6.4 | 13/40/27 | 59 | 1.0/0.0 | 197 | PASS |
| 4 | 560 | 629 | 614 | 449 | 837 | +12.3% | 6.0 | 0.0 | 10.1/11.7 | 28/42/15 | 59 | 2.0/0.0 | 324 | PASS |
| 5 | 760 | 789 | 760 | 568 | 1 064 | +3.8% | 6.0 | 0.0 | 10.8/17.1 | 41/49/8 | 47 | 2.0/0.0 | 561 | PASS |
| 6 | 980 | 1 052 | 1 044 | 758 | 1 386 | +7.3% | 6.8 | 0.0 | 12.5/20.1 | 56/76/22 | 46 | 2.9/0.0 | 632 | PASS |
| 7 | 1200 | 1 331 | 1 327 | 950 | 1 706 | +10.9% | 6.9 | 0.0 | 13.9/22.6 | 70/113/43 | 55 | 3.0/0.0 | 738 | PASS |
| 8 | 1450 | 1 595 | 1 595 | 1 192 | 2 003 | +10.0% | 6.9 | 0.0 | 14.3/24.4 | 81/140/58 | 60 | 4.0/0.0 | 347 | PASS |
| 9 | 1700 | 1 920 | 1 904 | 1 432 | 2 375 | +13.0% | 6.9 | 0.0 | 15.4/24.7 | 90/193/103 | 70 | 4.0/0.0 | 2064 | 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**.
- Mean total income: **7 533 $** (payout 6 495 + recv_fee 410 + attacks 628). Spec band: 7 400 - 8 500 $.
- Mean shop spend: **4 681 $** = **57.9%** of the 8 085 $ tree (target 55-65%). Mean money left after H9: **2 392 $**. Mean folder spend: **510 $**.
- Mean requests done: **54.4**; files received **115.7**, sent **91.6**, left unsent **24.1**.
- Mean SLA at end of H9: **62.2**; defeated runs: **28**.
- Attacks blocked/failed/shielded per run: **18.7/0.0/4.4**.
## 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% |
| lot_money | money_mult | 6.5 | 6.5 | 1 267 | 1 481 | 1.17 | 27.1% |
| lot_speed | transfer_speed | 5.2 | 5.2 | 637 | 712 | 1.12 | 13.6% |
| lot_channel | channel_capacity | 5.3 | 5.3 | 621 | 8 431 | 13.58 | 13.3% |
| lot_slots | connection_slots | 2.3 | 2.3 | 522 | 405 | 0.77 | 11.2% |
| lot_folder | folder_cells | 2.6 | 2.6 | 486 | 705 | 1.45 | 10.4% |
| lot_sla | sla_cap | 2.0 | 2.0 | 443 | 219 | 0.50 | 9.5% |
| lot_shield | shield_charge | 1.9 | 1.9 | 382 | 152 | 0.40 | 8.2% |
| lot_timer | request_timer | 3.2 | 3.2 | 324 | 219 | 0.68 | 6.9% |
## 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 $).
- Best ROI proxy is `lot_channel` (channel_capacity) at **13.58**, spend share 13.3%. `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.17** (spend share 27.1%). It is a good buy but not front-loaded enough to justify a new price curve from this run.
## Verdict
+146 -115
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""PEAK HOUR v2 balance simulator (pure Python 3, no third-party deps).
"""PEAK HOUR v3 balance simulator (pure Python 3, no third-party deps).
Reads ``data/balance.json`` from the repository and models hours H1-H9:
file budget/spawn window, type weights, request cap/active limit/request mix,
@@ -68,12 +68,18 @@ HUMAN_ATTACH_PER_FILE = 1.0
MINIGAME_BASE = 7.0 # no timer: competent player still needs time
MINIGAME_PER_TIER = 1.5
PROTOCOL = "tcp" # production default; TCP is +15% payout
TCP_CONNECT_SEC = 4.2 # production TCP connection minigame, competent player
TCP_CONNECT_FAIL = 0.04 # 4% missed TCP connection
TCP_CONNECT_FAIL_SLA = 4.0 # and -4 SLA
CHECK_TOL = 0.20 # +-20% per hour
PURCHASE_ROI_MIN = 0.5 # regular prep: buy while predicted value >= 0.5x price
FINAL_PREP_ROI_MIN = 0.0 # before H9 there is no future shop: spend on any positive ROI
TARGETS = {int(h): float(HOURS[h]["income_target"]) for h in HOURS}
MAX_SLA = float(CONST.get("sla_hard_cap", 200))
BAND_LOW = int(sum(TARGETS.values()))
BAND_HIGH = 8500
BAND_TEXT = f"{BAND_LOW:,} - {BAND_HIGH:,}".replace(",", " ")
MAX_SLA = float(CONST.get("sla_hard_cap", 220))
SLA_REGEN = float(CONST.get("sla_regen_per_hour", 15))
# type helpers
@@ -87,6 +93,17 @@ MINIGAME_COEFF = {t: int(TYPES[t]["minigame_coeff"]) for t in TYPES}
ACHIEVABLE_SUMS = [int(v) for v in BALANCE["minigames"]["selection"]["achievable_sums"]]
SEND_ORDER = list(BALANCE["minigames"]["selection"]["order"])
SEL = BALANCE["minigames"]["selection"]
TIER_MAX_RANK = [int(v) for v in SEL.get("tier_max_rank", [7, 13])]
RANK_GAMES = list(SEL.get(
"rank_games",
["text", "sound", "dots", "photo", "maze", "dots", "photo", "video",
"dots", "text", "video", "maze", "photo", "sound", "video", "text",
"maze", "sound", "video", "sound", "photo", "dots", "dots", "maze",
"maze", "photo", "video"],
))
FOLDER_NEW_COST = int(CONST.get("folder_new_cost", 180))
SHIELD_REWARD_SCALE = float(ATTACKS.get("shield_reward_scale", 0.7))
def clamp(value: float, low: float, high: float) -> float:
@@ -140,18 +157,24 @@ def weighted_index(rng: random.Random, values: list[str], weights: list[float])
def send_minigame_for(types: list[str]) -> dict[str, int | str]:
"""Dense-sum selection from balance.json (B4), not the old idx = S - 1."""
total = sum(MINIGAME_COEFF.get(t, 1) for t in types)
"""Economy.selection_for_sum: 27 dense sums, 3 tiers, direct rank table."""
total = sum(MINIGAME_COEFF.get(str(t).lower(), 1) for t in types)
if total <= 0:
return {"id": "dots", "tier": 1, "index": 0, "sum": 0}
rank = 0
for i, value in enumerate(ACHIEVABLE_SUMS):
if value <= total:
rank = i
else:
break
count = len(ACHIEVABLE_SUMS)
tier = int(clamp(1 + math.floor(rank * 4.0 / count), 1, 4))
if rank > TIER_MAX_RANK[1]:
tier = 3
elif rank > TIER_MAX_RANK[0]:
tier = 2
else:
tier = 1
return {
"id": SEND_ORDER[rank % len(SEND_ORDER)],
"id": RANK_GAMES[min(rank, len(RANK_GAMES) - 1)],
"tier": tier,
"index": rank,
"sum": total,
@@ -273,6 +296,7 @@ class Simulation:
self.levels: dict[str, int] = {str(lot["id"]): 0 for lot in SHOP}
self.lot_stats: dict[str, LotStat] = {str(lot["id"]): LotStat() for lot in SHOP}
self.total_spent = 0
self.folder_spend = 0
self.earned = 0
self.carry = 0
self.stored_types: list[str] = []
@@ -305,32 +329,35 @@ class Simulation:
def slot_limit(self) -> int:
return int(clamp(1 + self.levels["lot_slots"], 1, int(CONST.get("slots_max", 5))))
def lot_value(self, lot_id: str, fallback: float) -> float:
return float(SHOP_BY_ID[lot_id].get("value", fallback))
def transfer_speed_mult(self) -> float:
return float(0.92) ** self.levels["lot_speed"]
cut = float(clamp(self.lot_value("lot_speed", 0.10), 0.01, 0.9))
return float(1.0 - cut) ** self.levels["lot_speed"]
def channel_capacity(self) -> float:
return float(CONST.get("channel_capacity", 10)) + \
float(CONST.get("channel_per_level", 6)) * self.levels["lot_channel"]
self.lot_value("lot_channel", 6.0) * self.levels["lot_channel"]
def buffer_limit(self) -> int:
return int(clamp(int(CONST.get("buffer_base", 6)) + self.levels["lot_channel"],
int(CONST.get("buffer_base", 6)),
int(CONST.get("buffer_max", 12))))
base = int(CONST.get("buffer_base", 6))
step = int(self.lot_value("lot_channel", 1.0))
return int(clamp(base + step * self.levels["lot_channel"],
base, int(CONST.get("buffer_max", 12))))
def request_timer(self) -> float:
return float(clamp(float(CONST.get("request_timer", 30)) +
float(CONST.get("request_timer_per_level", 5)) *
self.levels["lot_timer"],
float(CONST.get("request_timer", 30)),
float(CONST.get("request_timer_max", 55))))
base = float(CONST.get("request_timer", 30))
return float(clamp(base + self.lot_value("lot_timer", 7.0) * self.levels["lot_timer"],
base, float(CONST.get("request_timer_max", 65))))
def sla_cap(self) -> float:
return float(clamp(float(CONST.get("sla_base", 60)) +
float(CONST.get("sla_per_level", 35)) * self.levels["lot_sla"],
float(CONST.get("sla_base", 60)), MAX_SLA))
base = float(CONST.get("sla_base", 60))
return float(clamp(base + self.lot_value("lot_sla", 40.0) * self.levels["lot_sla"],
base, MAX_SLA))
def money_mult(self) -> float:
return 1.0 + 0.05 * self.levels["lot_money"]
return 1.0 + self.lot_value("lot_money", 0.05) * self.levels["lot_money"]
def folder_cells(self) -> int:
dims = GRID.get("folder_dims") or [[5, 4]]
@@ -351,7 +378,7 @@ class Simulation:
# --- prep -------------------------------------------------------------
def prepare_folders(self, hour: int, files_budget: int, occupied: int = 0) -> None:
"""Competent player pre-creates free folders (spec 2.1: up to 12)."""
"""Competent player buys free folders for folder_new_cost (spec 2.1)."""
weights = HOURS[hour]["type_weights"]
avg_cells = sum(float(weights.get(t, 0.0)) * CELLS[t] for t in TYPE_ORDER)
expected = occupied + files_budget * max(1.0, avg_cells) * 0.9
@@ -359,6 +386,10 @@ class Simulation:
max_folders = int(CONST.get("folders_max", 6))
guard = 0
while cap < expected * 1.15 and self.folders < max_folders and guard < 20:
if FOLDER_NEW_COST > self.money:
break
self.money -= FOLDER_NEW_COST
self.folder_spend += FOLDER_NEW_COST
self.folders += 1
cap = self.cell_capacity()
guard += 1
@@ -422,11 +453,12 @@ class Simulation:
penalty = max(0.0, self.last_metrics["channel_penalty"])
if effect == "money_mult":
# Relative payout gain of the next +5% level.
return 0.05 * proj / (1.0 + 0.05 * level)
# Relative payout gain of the next level (shop[].value).
step = self.lot_value("lot_money", 0.05)
return step * proj / (1.0 + step * level)
if effect == "transfer_speed":
# 8% shorter transfers only matter when the pipe is the bottleneck.
return 0.08 * proj * busy * 0.5
# 10% shorter transfers only matter when the pipe is the bottleneck.
return self.lot_value("lot_speed", 0.10) * proj * busy * 0.5
if effect == "connection_slots":
base = 0.06 if wait_sec > 0.5 else 0.02
return proj * base / (1.0 + 0.35 * (self.slot_limit() - 1))
@@ -439,11 +471,12 @@ class Simulation:
if effect == "sla_cap":
return proj * (0.12 if self.last_metrics["sla_ratio"] < 0.5 else 0.003)
if effect == "shield_charge":
# One auto-block per hour, 50% reward, no fail penalty.
# One auto-block per hour, shield_reward_scale of the reward, no penalty.
remaining_attacks = sum(int(HOURS[h].get("attacks", 0))
for h in range(hour, RUN_HOURS + 1))
avg_fail = 25.0 + 5.0 * max(0, hour - 2) / 2.0
return min(float(remaining_attacks), float(hours_left)) * 0.5 * avg_fail
return min(float(remaining_attacks), float(hours_left)) * \
SHIELD_REWARD_SCALE * avg_fail
return 0.0
def buy_best_roi(self, hour: int, offers: list[str]) -> None:
@@ -534,7 +567,9 @@ class Simulation:
requests_spawned = 0
next_spawn_t = 0.0
spawn_interval = 225.0 / max(1, files_budget)
next_spawn_t = spawn_interval * self.rng.uniform(1.0 - SPAWN_JITTER, 1.0 + SPAWN_JITTER)
if hour > 1:
next_spawn_t = spawn_interval * self.rng.uniform(
1.0 - SPAWN_JITTER, 1.0 + SPAWN_JITTER)
request_accum = 0.0
request_interval = max(0.5, float(hour_def.get("send_interval", 20.0)))
@@ -633,6 +668,11 @@ class Simulation:
duration += RECV_TIME.get(file.type, 3.0) * self.transfer_speed_mult() * factor
if str(request.protocol).lower() == "udp":
duration *= float(PAYOUT.get("udp_time_mult", 0.8))
if str(request.protocol).lower() == "tcp":
# Production TCP: connection minigame before the send minigame.
duration += TCP_CONNECT_SEC
if self.rng.random() < TCP_CONNECT_FAIL:
self.sla = max(0.0, self.sla - TCP_CONNECT_FAIL_SLA)
request.state = "sending"
request.send_left = max(0.1, duration)
request.send_start_left = request.timer_left
@@ -676,8 +716,8 @@ class Simulation:
self.shield_charges -= 1
self.attacks_shielded += 1
self.attacks_blocked += 1
reward = int(round(float(row["reward_money"]) * 0.5))
reward_sla = float(row["reward_sla"]) * 0.5
reward = int(round(float(row["reward_money"]) * SHIELD_REWARD_SCALE))
reward_sla = float(row["reward_sla"]) * SHIELD_REWARD_SCALE
self.money += reward
self.earned += reward
attack_income += reward
@@ -715,7 +755,7 @@ class Simulation:
if mode is not None and mode.state == "attaching" and now >= mode.attach_end:
request = mode
request.state = "minigame"
request.mini_end = now + MINIGAME_BASE + MINIGAME_PER_TIER * request.tier
request.mini_end = now + MINIGAME_BASE + MINIGAME_PER_TIER * (request.tier + 1)
if mode is not None and mode.state == "minigame" and now >= mode.mini_end:
request = mode
mode = None
@@ -948,8 +988,8 @@ def analytical_rows() -> list[dict[str, float]]:
allowed = [t for t in TYPE_ORDER if weights.get(t, 0.0) > 0.0]
max_base_file = max(BASE_PAY[t] for t in allowed)
max_recv_file = max(RECV_FEE[t] for t in allowed)
# Upper bound: request cap x 3 files, limited by the hour file budget.
max_files = min(files_budget, cap * 3)
# Upper bound: request cap x 4 files, limited by the hour file budget.
max_files = min(files_budget, cap * 4)
max_base = max_files * max_base_file
max_income = max_base * 1.4 + max_files * max_recv_file
rows.append({
@@ -997,12 +1037,12 @@ def fmt_money(value: float) -> str:
def suggested_caps(hour_means: dict[int, float]) -> dict[int, int]:
"""Linear cap correction; H1 keeps cap 2 because the spec allows 1-2 requests."""
"""Linear cap correction for every hour outside +/-20%."""
caps: dict[int, int] = {}
for hour in range(1, RUN_HOURS + 1):
cap = int(HOURS[hour]["request_cap"])
mean = hour_means.get(hour)
if hour == 1 or not mean:
if not mean:
caps[hour] = cap
continue
if abs(mean - TARGETS[hour]) / TARGETS[hour] > CHECK_TOL:
@@ -1063,7 +1103,7 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
for row in sim.hourly:
per_hour[row.hour].append(row)
lines.append("# PEAK HOUR v2 - balance simulation report")
lines.append("# PEAK HOUR v3 - balance simulation report")
lines.append("")
lines.append(f"Generated by `tests/sim_balance.py` from `data/balance.json`.")
lines.append(f"Seeds: **{len(runs)}**, hours: **{RUN_HOURS}**, dt: **{DT} s**, "
@@ -1073,8 +1113,9 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
lines.append("## Model assumptions")
lines.append("")
lines.append("- 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.")
"75% of the hour (225 s), interval 225/budget with +/-20% jitter; H1 "
"spawns its first packet at t=0. A full buffer freezes spawns without "
"burning the budget.")
lines.append("- Types: `hours[].type_weights` only; zero weight never spawns.")
lines.append("- Unsent files persist across hours (the folder is not cleared); only "
"files consumed by a successful send disappear. Unspawned budget is carried "
@@ -1085,33 +1126,38 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
"when no files exist.")
lines.append(f"- Player: competent, attaches after {HUMAN_ATTACH_BASE:g} s + "
f"{HUMAN_ATTACH_PER_FILE:g} s/file, plays a no-timer send minigame for "
f"{MINIGAME_BASE:g} + {MINIGAME_PER_TIER:g}*tier seconds. The minigame "
"pauses packet spawn/loading but not other request timers (production order).")
lines.append("- Timers: request `30 + 5*timer_level` (cap 55), packet 20 s. "
f"{MINIGAME_BASE:g} + {MINIGAME_PER_TIER:g}*(tier+1) seconds "
f"(3 tiers). TCP adds {TCP_CONNECT_SEC:g} s connection time and "
f"{TCP_CONNECT_FAIL * 100:.0f}% of attempts miss it "
f"(-{TCP_CONNECT_FAIL_SLA:g} SLA). "
"The minigame pauses packet spawn/loading but not other request timers "
"(production order).")
lines.append("- Timers: request `30 + 7*timer_level` (cap 65), 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.")
lines.append("- Transfers: `base_time_sec * 0.92^speed_level * max(1, load/capacity)`; "
lines.append("- Transfers: `base_time_sec * 0.90^speed_level * max(1, load/capacity)`; "
"slots are shared by receive and send; files are consumed on successful send.")
lines.append("- Payout: exact `round(base_sum * speed * quality * multi * protocol * "
"streak * money_mult)`; recv_fee is added on receive and is not multiplied.")
lines.append("- 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.")
"streak * money_mult)`; TCP x1.15, UDP x0.85; recv_fee is added on receive "
"and is not multiplied.")
lines.append("- Folder: `grid.folder_dims[folder_level]`, 2 free folders at start; "
f"each extra folder costs `constants.folder_new_cost` ({FOLDER_NEW_COST} $) "
"in prep, up to `constants.folders_max` (6). `lot_folder` buys more cells; "
"fragmentation is not modelled, cell capacity/occupancy is.")
lines.append("- 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).")
lines.append("- 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.")
lines.append("- ROI proxy: `money` = next `shop[].value` step of projected remaining "
"income; `slots`/`speed`/`channel`/`timer`/`folder`/`sla`/`shield` use "
"measured bottlenecks from the previous hour. It is deterministic, not an "
"oracle.")
lines.append("")
lines.append("## Analytical check (B1)")
lines.append("")
lines.append("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).")
"bound (cap x 4 files, best allowed type, multiplier 1.4, recv fees).")
lines.append("")
lines.append("| 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 |")
@@ -1189,6 +1235,7 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
total_recv = statistics.fmean([sum(r.recv_income for r in sim.hourly) for sim in runs])
total_attack = statistics.fmean([sum(r.attack_income for r in sim.hourly) for sim in runs])
total_spent = statistics.fmean([sim.total_spent for sim in runs])
folder_spend = statistics.fmean([sim.folder_spend for sim in runs])
money_end = statistics.fmean([sim.money for sim in runs])
total_done = statistics.fmean([sim.requests_done for sim in runs])
total_received = statistics.fmean([sim.files_received for sim in runs])
@@ -1205,10 +1252,11 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
lines.append("")
lines.append(f"- Mean total income: **{fmt_money(total_income)} $** "
f"(payout {fmt_money(total_payout)} + recv_fee {fmt_money(total_recv)} "
f"+ attacks {fmt_money(total_attack)}). Spec band: 7 300-8 500 $.")
f"+ attacks {fmt_money(total_attack)}). Spec band: {BAND_TEXT} $.")
lines.append(f"- Mean shop spend: **{fmt_money(total_spent)} $** = "
f"**{tree_pct:.1f}%** of the 13 100 $ tree (target 55-65%). "
f"Mean money left after H9: **{fmt_money(money_end)} $**.")
f"**{tree_pct:.1f}%** of the {fmt_money(TOTAL_TREE)} $ tree (target 55-65%). "
f"Mean money left after H9: **{fmt_money(money_end)} $**. "
f"Mean folder spend: **{fmt_money(folder_spend)} $**.")
lines.append(f"- Mean requests done: **{total_done:.1f}**; files received "
f"**{total_received:.1f}**, sent **{total_sent:.1f}**, left unsent "
f"**{total_left:.1f}**.")
@@ -1255,15 +1303,6 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
cap = int(HOURS[hour]["request_cap"])
mean = stats["mean"]
target = stats["target"]
if hour == 1:
lines.append(f"- **H1**: mean {mean:.0f} $ vs target {target:.0f} $ "
f"({stats['dev']:+.1f}%). The 2-request cap is the spec "
"constraint; 2 requests x ~1.3 files average cannot reach 90 $. "
"Suggest lowering `hours[1].income_target` **90 -> 65** "
"(still inside the §0.1 60-120 band) or raising H1 "
"text/sound `base_pay` by ~1.3x. Do not raise `request_cap`: "
"3 completed requests violates the H1 acceptance rule.")
continue
suggested = int(clamp(round(cap * target / max(1.0, mean)), 1,
int(HOURS[hour]["files"])))
if caps:
@@ -1292,22 +1331,21 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
f"H{h}={int(HOURS[h]['request_cap'])}" for h in range(1, RUN_HOURS + 1)) + ".")
lines.append("- Suggested caps from this run: " + ", ".join(
f"H{h}={local_caps[h]}" for h in range(1, RUN_HOURS + 1)) + ".")
lines.append("- H2 stays 5 and H3 uses 5, above the §2.4 reference 3/4: "
"the spec caps produce roughly 126 $ / 260 $ in this model, far "
"below the 220 $ / 380 $ targets. H4=5 matches the spec.")
lines.append("- H2-H4 caps are the numbers from `hours[].request_cap`; the "
"simulated means above show whether the spec reference caps "
"[2, 3, 4, 5, 6, 7, 8, 9, 10] should be revisited.")
else:
lines.append("- H2-H4 are inside +/-20%; no early-cap change required by this run.")
lines.append("")
if total_income < 7300.0 or total_income > 8500.0:
cap_failures = [h for h in hour_stats if h != 1 and not hour_stats[h]["ok"]]
if total_income < BAND_LOW or total_income > BAND_HIGH:
cap_failures = [h for h in hour_stats if not hour_stats[h]["ok"]]
if cap_failures:
lines.append(f"- Total income {total_income:.0f} $ is outside 7 300-8 500 $, "
"but most of the gap is explained by the inflated H3-H8 caps "
"above. Re-run after applying those caps before touching "
"`base_pay`.")
lines.append(f"- Total income {total_income:.0f} $ is outside {BAND_TEXT} $, "
"but most of the gap is explained by the hourly caps above. "
"Re-run after applying those caps before touching `base_pay`.")
else:
scale = 7800.0 / max(1.0, total_income)
lines.append(f"- Total income {total_income:.0f} $ is outside 7 300-8 500 $. "
scale = (BAND_LOW + BAND_HIGH) * 0.5 / max(1.0, total_income)
lines.append(f"- Total income {total_income:.0f} $ is outside {BAND_TEXT} $. "
f"Global `base_pay` scale for the band: **x{scale:.2f}** "
f"(or adjust `income_target` instead).")
if tree_pct < 55.0 or tree_pct > 65.0:
@@ -1329,11 +1367,9 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
lines.append("### Money lot price curve")
lines.append("")
lines.append(f"- `lot_money` ROI proxy is **{money[6]:.2f}** and it takes "
f"**{money[7]:.1f}%** of spend. The early 80/110/150 prices are "
"front-loaded: +5% of remaining income is worth far more than the "
"price. Suggested curve if you want later levels to compete: "
"**[120, 170, 230, 300, 390, 500, 640, 800]** (total 3 150 $ instead "
"of 2 140 $). This is a recommendation only.")
f"**{money[7]:.1f}%** of spend. The early prices are front-loaded: "
"one `+money_mult` step of remaining income is worth more than the "
"first levels cost. This is a recommendation only.")
lines.append("")
# Dead-lot checks.
@@ -1347,14 +1383,15 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
lines.append("")
for lot_id, effect, levels, _buys, _spend, _value, roi, _share in dead:
if effect == "folder_cells":
hint = ("Cap free folders or make the grid the binding resource; with 2-6 "
"free folders the lot has almost no marginal value.")
hint = ("Each extra folder costs folder_new_cost and the lot raises the "
"cell ceiling; check whether cells are actually the bottleneck.")
elif effect == "sla_cap":
hint = ("SLA rarely binds for a competent player (2/240 defeats). Either "
"lower the price/raise the effect or make timeouts hurt more.")
hint = (f"SLA binds only in {defeated}/{len(runs)} runs. Either lower the "
"price/raise the effect or make timeouts hurt more.")
elif effect == "shield_charge":
hint = ("q=0.85 clears every attack threshold, so auto-blocks only add "
"50% rewards. Lower the price or raise attack rewards/fail costs.")
f"{SHIELD_REWARD_SCALE * 100:.0f}% rewards. Lower the price or raise "
"attack rewards/fail costs.")
else:
hint = "Strengthen the effect or lower the price."
lines.append(f"- `{lot_id}` ({effect}): mean level {levels:.2f}, ROI {roi:.2f}. "
@@ -1373,9 +1410,7 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
if money_row:
lines.append(f"- `lot_money` ROI is **{money_row[6]:.2f}** "
f"(spend share {money_row[7]:.1f}%). 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 $).")
"front-loaded enough to justify a new price curve from this run.")
lines.append("")
if projection and caps:
@@ -1383,17 +1418,16 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
lines.append("")
lines.append("Suggested `request_cap` vector: **[" +
", ".join(str(caps[h]) for h in range(1, RUN_HOURS + 1)) + "]**; "
"H1 `income_target` **90 -> 65** (marked *). "
f"Projection seeds: {projection['seeds']}.")
lines.append("")
lines.append("| H | current cap | suggested cap | projected mean | target | dev | verdict |")
lines.append("|---:|---:|---:|---:|---:|---:|---|")
for hour in range(1, RUN_HOURS + 1):
mean = float(projection["means"].get(hour, 0.0))
target_disp = 65.0 if hour == 1 else TARGETS[hour]
target_disp = TARGETS[hour]
dev = (mean - target_disp) / target_disp * 100.0
ok = abs(dev) <= CHECK_TOL * 100.0
target_text = f"{target_disp:.0f}" + ("*" if hour == 1 else "")
target_text = f"{target_disp:.0f}"
lines.append(f"| {hour} | {int(HOURS[hour]['request_cap'])} | {caps[hour]} | "
f"{fmt_money(mean)} | {target_text} | {dev:+.1f}% | "
f"{'PASS' if ok else 'FAIL'} |")
@@ -1411,8 +1445,8 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
"55-65% is reachable only near this ceiling."))
lines.append("Note: recommended H9 cap can be lower than H8 because unsent files "
"persist across hours and raise the effective supply in H9.")
if abs(float(projection["total"]) - 7800.0) < 500.0:
lines.append("The projected total is inside the 7 300-8 500 $ band.")
if BAND_LOW <= float(projection["total"]) <= BAND_HIGH:
lines.append(f"The projected total is inside the {BAND_TEXT} $ band.")
lines.append("")
lines.append("## Verdict")
@@ -1424,14 +1458,13 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
lines.append(f"- {failure}")
if projection:
projected_ok = all(
abs(float(projection["means"].get(h, 0.0)) -
(65.0 if h == 1 else TARGETS[h])) /
(65.0 if h == 1 else TARGETS[h]) <= CHECK_TOL
abs(float(projection["means"].get(h, 0.0)) - TARGETS[h]) /
TARGETS[h] <= CHECK_TOL
for h in range(1, RUN_HOURS + 1))
if projected_ok and abs(float(projection["total"]) - 7800.0) < 500.0:
if projected_ok and BAND_LOW <= float(projection["total"]) <= BAND_HIGH:
lines.append("")
lines.append("The in-memory projection with the recommended caps and H1 "
"target passes all nine hours and lands in the income band. "
lines.append("The in-memory projection with the recommended caps "
"passes all nine hours and lands in the income band. "
"No data file was modified.")
else:
lines.append("**PASS** - every hour is inside +/-20% and no analytical cap is below "
@@ -1441,7 +1474,7 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
def main() -> int:
print("PEAK HOUR v2 - balance simulation (no Godot)")
print("PEAK HOUR v3 - balance simulation (no Godot)")
print(f"data: {BALANCE_PATH}")
print(f"seeds: {SEEDS}, hours: {RUN_HOURS}, dt: {DT}s, "
f"q_send={Q_SEND}, q_attack={Q_ATTACK}, protocol={PROTOCOL.upper()}")
@@ -1513,6 +1546,7 @@ def main() -> int:
total_income = statistics.fmean([sum(r.income for r in sim.hourly) for sim in runs])
total_spent = statistics.fmean([sim.total_spent for sim in runs])
folder_spend = statistics.fmean([sim.folder_spend for sim in runs])
money_end = statistics.fmean([sim.money for sim in runs])
total_done = statistics.fmean([sim.requests_done for sim in runs])
total_received = statistics.fmean([sim.files_received for sim in runs])
@@ -1525,9 +1559,9 @@ def main() -> int:
attacks_shielded = statistics.fmean([sim.attacks_shielded for sim in runs])
tree_pct = total_spent / TOTAL_TREE * 100.0
print("")
print(f"total income: {total_income:.0f} $ (spec 7300-8500)")
print(f"shop spend: {total_spent:.0f} $ = {tree_pct:.1f}% of 13100 $ tree (target 55-65%); "
f"money left {money_end:.0f} $")
print(f"total income: {total_income:.0f} $ (spec {BAND_TEXT})")
print(f"shop spend: {total_spent:.0f} $ = {tree_pct:.1f}% of {TOTAL_TREE} $ tree "
f"(target 55-65%); folder spend {folder_spend:.0f} $; money left {money_end:.0f} $")
print(f"requests done: {total_done:.1f}; files received {total_received:.1f}, "
f"sent {total_sent:.1f}, left unsent {total_left:.1f}")
print(f"SLA end: {sla_end:.1f}; defeated runs: {defeated}")
@@ -1545,11 +1579,11 @@ def main() -> int:
print(" caps: " + ", ".join(f"H{h}={caps[h]}" for h in range(1, RUN_HOURS + 1)))
for hour in range(1, RUN_HOURS + 1):
mean = float(projection["means"].get(hour, 0.0))
target_disp = 65.0 if hour == 1 else TARGETS[hour]
target_disp = TARGETS[hour]
dev = (mean - target_disp) / target_disp * 100.0
ok = abs(dev) <= CHECK_TOL * 100.0
print(f" H{hour}: mean {mean:.0f} vs target {target_disp:.0f}"
f"{'*' if hour == 1 else ''} ({dev:+.1f}%) {'PASS' if ok else 'FAIL'}")
f" ({dev:+.1f}%) {'PASS' if ok else 'FAIL'}")
print(f" total {float(projection['total']):.0f} $; "
f"spend {float(projection['spend']):.0f} $ "
f"({float(projection['spend']) / TOTAL_TREE * 100.0:.1f}% of tree)")
@@ -1572,23 +1606,20 @@ def main() -> int:
if abs(mean - target) / target <= CHECK_TOL:
continue
cap = int(HOURS[hour]["request_cap"])
if hour == 1:
print(f" H1: keep cap 2, income_target 90 -> 65 (or +30% H1 base_pay); "
f"mean {mean:.0f} vs target {target:.0f}")
continue
suggested = int(clamp(round(cap * target / max(1.0, mean)), 1,
int(HOURS[hour]["files"])))
if caps:
suggested = int(caps.get(hour, suggested))
print(f" H{hour}: cap {cap} -> {suggested} (mean {mean:.0f} vs target {target:.0f})")
if total_income < 7300.0 or total_income > 8500.0:
cap_failures = [h for h in hour_means if h != 1 and
abs(hour_means[h] - TARGETS[h]) / TARGETS[h] > CHECK_TOL]
if total_income < BAND_LOW or total_income > BAND_HIGH:
cap_failures = [h for h in hour_means
if abs(hour_means[h] - TARGETS[h]) / TARGETS[h] > CHECK_TOL]
if cap_failures:
print(" total: outside 7300-8500 mainly due to H3-H8 caps; "
print(f" total: outside {BAND_TEXT} mainly due to hourly caps; "
"re-run after cap changes before scaling base_pay")
else:
print(f" total: scale base_pay by x{7800.0 / max(1.0, total_income):.2f} "
print(f" total: scale base_pay by "
f"x{(BAND_LOW + BAND_HIGH) * 0.5 / max(1.0, total_income):.2f} "
f"or adjust targets")
if tree_pct < 55.0 or tree_pct > 65.0:
ceiling = (total_income - statistics.fmean(
+15 -4
View File
@@ -220,7 +220,8 @@ func _test_shield_auto_block() -> void:
_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 shield_scale := _shield_scale()
var expected := int(round(float(row.get("reward_money", 0)) * _money_scale(2) * shield_scale))
var money_before: int = _game.money
var sla_before: float = _game.sla
var id := str(ad.spawn_attack("ddos"))
@@ -230,9 +231,10 @@ func _test_shield_auto_block() -> void:
_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-награды")
_eq(_game.money, money_before + expected,
"награда щита: x%.2f от денег атаки" % shield_scale)
_approx(_game.sla, sla_before + float(row.get("reward_sla", 0)) * shield_scale, 0.001,
"награда щита: x%.2f от SLA атаки" % shield_scale)
_ok(_has_toast("ЩИТ ПОГЛОТИЛ АТАКУ"), "тост «ЩИТ ПОГЛОТИЛ АТАКУ»")
_ok(_resolved_events.size() >= 1, "resolved(true) при щите")
if not _resolved_events.is_empty():
@@ -400,6 +402,15 @@ func _money_scale(hour: int) -> float:
return scale
## Множитель награды щита — из balance.json → attacks.shield_reward_scale.
func _shield_scale() -> float:
if _data != null and _data.get("balance") is Dictionary:
var attacks = (_data.balance as Dictionary).get("attacks", null)
if attacks is Dictionary:
return maxf(0.0, float((attacks as Dictionary).get("shield_reward_scale", 0.5)))
return 0.5
func _new_file(name: String, type: String):
var section = _game.registry.sections.get("C")
if section == null or section.folder_ids.is_empty():
+236 -2
View File
@@ -9,6 +9,19 @@ extends SceneTree
const GC := preload("res://scripts/game_controller.gd")
const SM := preload("res://scripts/systems/save_system.gd")
## Минимальная заглушка мини-игры отправки: только сигнал finished.
class FakeSend:
extends Node
signal finished(quality: float)
## Заглушка ConnectionGame для проверки промаха: Main читает `missed`.
class FakeConnection:
extends Node
var missed := true
var _checks: int = 0
var _fails: int = 0
var _case: String = ""
@@ -52,6 +65,7 @@ func _init() -> void:
_test_empty_continue()
_test_new_run()
_test_second_new_run_seed()
_test_table_return_success()
_test_table_return_block()
_test_hour_start_and_modal()
@@ -61,6 +75,9 @@ func _init() -> void:
_test_reroll_reset()
_test_full_run()
_test_save_load()
_test_send_modal_freeze()
await _test_destroy_reserved_folder()
await _test_connection_failure_toast()
_finish()
@@ -93,6 +110,48 @@ func _test_new_run() -> void:
_ok(sections is Array and not (sections as Array).is_empty(), "есть секции")
## BUG A: второй «Новый забег» должен иметь seed в подготовке, как первый.
## Регрессия: reset_for_prep() сбрасывал requests_spawned ПОСЛЕ start_hour(1),
## поэтому seed H1 создавался только в первом забеге.
func _test_second_new_run_seed() -> void:
_set_case("second_new_run_seed")
_c.new_run(9001)
_eq(String(_c.phase), "prep", "забег 1: new_run → prep")
_eq(_game.registry.files.size(), 1, "забег 1: в подготовке 1 seed-файл")
_ok(_c.start_hour(), "забег 1: старт H1")
var hd = _c.hour_director
hd.set_time_scale(1.0)
var h1_req := false
for i in range(50):
_c.tick(0.1)
if not hd.active_requests.is_empty():
h1_req = true
break
_ok(h1_req, "забег 1: H1 выдал запрос")
_ok(int(hd.requests_spawned) > 0, "забег 1: requests_spawned > 0 после часа")
_set_case("second_new_run_seed/run2")
_c.new_run(9002)
_eq(String(_c.phase), "prep", "забег 2: new_run → prep")
_eq(_game.registry.files.size(), 1, "забег 2: в подготовке 1 seed-файл")
var seed_files: Array = _game.registry.files.values()
_ok(seed_files.size() == 1 and str(seed_files[0].required_by) == "",
"забег 2: seed без required_by")
_ok(hd.active_requests.is_empty(), "забег 2: нет активных запросов")
_ok(seed_files.size() == 1 and String(seed_files[0].location) == "folder",
"забег 2: seed лежит в папке")
_ok(_c.start_hour(), "забег 2: старт H1")
_eq(_game.registry.files.size(), 1, "забег 2: старт H1 не создал второй seed")
var h1_req2 := false
for i in range(50):
_c.tick(0.1)
if not hd.active_requests.is_empty():
h1_req2 = true
break
_ok(h1_req2, "забег 2: H1 снова выдал запрос")
func _test_table_return_success() -> void:
_set_case("table_return_ok")
_c.new_run(11)
@@ -157,8 +216,9 @@ func _test_hour_start_and_modal() -> void:
_ok(not packet_id.is_empty(), "пакет создан")
_c.tick(0.1)
var hd_data: Dictionary = _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 запрос")
# H1-бутстрап: к этому моменту уже есть стартовый пакет и запрос.
_eq((hd_data.get("packets", []) as Array).size(), 2, "hour_data: 2 пакета (ботстрап + тест)")
_eq((hd_data.get("requests", []) as Array).size(), 2, "hour_data: 2 запроса (ботстрап + тест)")
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")
@@ -301,6 +361,169 @@ func _test_save_load() -> void:
_ok(int(_game.hour) >= 1, "continue_run восстановил час")
## BUG 1: открытие мини-игры отправки через контроллер обязано поднять
## modal_open и в HourDirector; пакетный таймер стоит под модалкой.
func _test_send_modal_freeze() -> void:
_set_case("send_modal_freeze")
_c.new_run(731)
_ok(_c.start_hour(), "старт часа для модалки отправки")
var hd = _c.hour_director
_c.tick(0.1)
var packet_id := ""
if not hd.active_packets.is_empty():
packet_id = str(hd.active_packets.keys()[0])
_ok(not packet_id.is_empty(), "пакет для проверки таймера есть")
var packet = hd.get_packet(packet_id)
var folder_id := _first_folder_id()
var file = _game.registry.create_file("Модалка отправки", "text", folder_id)
_ok(file != null, "файл создан")
if file == null or packet == null:
return
var req_id := str(hd._create_request(1, "udp", [str(file.id)], true))
_ok(not req_id.is_empty(), "UDP-запрос создан")
_ok(hd.attach_file(req_id, str(file.id)), "файл прикреплён")
var holder := {"game": null}
hd.send_game_factory = func(_id, _tier, _seed, _ctx):
var g := FakeSend.new()
holder["game"] = g
return g
_ok(hd.start_send(req_id), "мини-игра отправки открыта")
_ok(bool(_c.modal_open), "controller.modal_open = true")
_ok(bool(hd.modal_open), "director.modal_open = true (BUG 1)")
var t_before := float(packet.time_left)
for i in range(10):
_c.tick(0.1)
_approx(float(packet.time_left), t_before, 0.0001,
"пакетный таймер заморожен под мини-игрой отправки")
var fake = holder.get("game")
_ok(fake != null, "подменная мини-игра создана")
if fake != null:
fake.finished.emit(0.8)
await process_frame
_ok(not bool(_c.modal_open), "controller.modal_open = false после закрытия")
_ok(not bool(hd.modal_open), "director.modal_open = false после закрытия")
t_before = float(packet.time_left)
for i in range(10):
_c.tick(0.1)
_ok(float(packet.time_left) < t_before, "пакетный таймер снова идёт после закрытия")
hd._cleanup_request(req_id, true)
hd._remove_packet(packet_id)
hd.send_game_factory = Callable()
## BUG 3c: папка, где остался только зарезервированный файл, не уничтожается
## и НЕ списывает SLA. Обычные файлы в той же папке по-прежнему удаляются.
func _test_destroy_reserved_folder() -> void:
_set_case("destroy_reserved_folder")
var packed := load("res://scenes/Main.tscn") as PackedScene
if packed == null:
_ok(false, "Main.tscn загружается")
return
var main = packed.instantiate()
root.add_child(main)
await process_frame
main._start_new_run()
await process_frame
var folder_id := ""
var section = _game.registry.sections.get("C")
if section != null and section.folder_ids.size() >= 2:
folder_id = str(section.folder_ids[1])
_ok(not folder_id.is_empty(), "вторая папка для теста найдена")
if folder_id.is_empty():
return
var reserved = _game.registry.create_file("Резерв destroy", "text", folder_id)
_ok(reserved != null, "зарезервированный файл создан")
if reserved == null:
return
var rid := str(reserved.id)
reserved.required_by = "req_destroy_test"
var sla_before := float(_game.sla)
main._destroy_folder_contents(folder_id)
_ok(_game.registry.files.has(rid), "зарезервированный файл пережил уничтожение папки")
_approx(float(_game.sla), sla_before, 0.001,
"папка только из резерва: SLA не списан")
_eq(str(_game.registry.files[rid].location), "folder",
"зарезервированный файл не помечен malicious")
# Контроль: обычный файл рядом — уничтожается, SLA −5.
var normal = _game.registry.create_file("Обычный destroy", "text", folder_id)
_ok(normal != null, "обычный файл создан")
if normal == null:
return
main._destroy_folder_contents(folder_id)
_ok(not _game.registry.files.has(str(normal.id)), "обычный файл уничтожен")
_ok(_game.registry.files.has(rid), "резерв остался нетронутым")
_approx(float(_game.sla), sla_before - 5.0, 0.001,
"реальное удаление списало SLA −5")
main.queue_free()
await process_frame
## BUG B: неуспешное TCP-соединение даёт РОВНО один тост
## «СОЕДИНЕНИЕ НЕ УСТАНОВЛЕНО» (от HourDirector), а не два (директор + Main).
func _test_connection_failure_toast() -> void:
_set_case("connection_failure_toast")
var packed := load("res://scenes/Main.tscn") as PackedScene
if packed == null:
_ok(false, "Main.tscn загружается")
return
var main = packed.instantiate()
root.add_child(main)
await process_frame
main._start_new_run()
await process_frame
var director = main.controller.hour_director
if director == null:
_ok(false, "hour_director доступен")
main.queue_free()
return
var folder_id := ""
for section in _game.registry.sections.values():
if not section.folder_ids.is_empty():
folder_id = str(section.folder_ids[0])
break
_ok(not folder_id.is_empty(), "папка для TCP-запроса найдена")
if folder_id.is_empty():
main.queue_free()
return
main._on_start_hour()
await process_frame
_eq(String(main.controller.phase), "hour", "час запущен (сигналы UI подключены)")
var file = _game.registry.create_file("conn_fail", "text", folder_id)
_ok(file != null, "файл для TCP-запроса создан")
if file == null:
main.queue_free()
return
var rid := str(director._create_request(1, "tcp", [str(file.id)], false))
_ok(not rid.is_empty(), "TCP-запрос создан")
var req = director.active_requests.get(rid)
if req == null:
_ok(false, "TCP-запрос активен")
main.queue_free()
return
req.connecting = true
var fail_before := _count_toasts(main, "СОЕДИНЕНИЕ НЕ УСТАНОВЛЕНО")
var sla_before := float(_game.sla)
var fake := FakeConnection.new()
main._on_connection_finished(false, rid, fake)
await process_frame
_eq(_count_toasts(main, "СОЕДИНЕНИЕ НЕ УСТАНОВЛЕНО") - fail_before, 1,
"неуспешное соединение: ровно 1 тост (BUG B)")
_approx(float(_game.sla), sla_before - 4.0, 0.001,
"штраф за промах −4 SLA сохранён")
# Успешный тост Main остаётся (директор его не дублирует).
req.connecting = true
var ok_before := _count_toasts(main, "СОЕДИНЕНИЕ УСТАНОВЛЕНО")
main._on_connection_finished(true, rid, null)
await process_frame
_eq(_count_toasts(main, "СОЕДИНЕНИЕ УСТАНОВЛЕНО") - ok_before, 1,
"успешное соединение: тост Main сохранён")
director._cleanup_request(rid, true)
main.queue_free()
await process_frame
# --- служебное -------------------------------------------------------------
func _finish_current_hour() -> void:
@@ -344,6 +567,17 @@ func _has_toast(fragment: String) -> bool:
return false
## Считает реально показанные UI-тосты Main, содержащие fragment.
func _count_toasts(main, fragment: String) -> int:
var count := 0
if main == null or main.toast_layer == null:
return count
for item in main.toast_layer._items:
if is_instance_valid(item) and str(item.txt).contains(fragment):
count += 1
return count
# --- обработчики -----------------------------------------------------------
func _on_hour_finished(report: Dictionary) -> void:
+94 -67
View File
@@ -43,7 +43,7 @@ func _test_data() -> void:
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("base_pay", 0.0)), 69.0, EPS, "video: base_pay 69")
_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")
@@ -52,23 +52,25 @@ func _test_data() -> void:
_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("files", 0)), 7, "H1: 7 файлов")
_eq(int(h1.get("request_cap", 0)), 6, "H1: кап 6 запросов")
_eq(int(h1.get("active_requests", 0)), 2, "H1: 2 активных запроса")
_eq(int(h1.get("attacks", 0)), 0, "H1: 0 атак")
_approx(float(h1.get("income_target", 0.0)), 65.0, EPS, "H1: цель дохода 65")
_approx(float(h1.get("income_target", 0.0)), 150.0, EPS, "H1: цель дохода 150")
_approx(float(h1.get("send_interval", 0.0)), 12.0, EPS, "H1: send_interval 12")
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("1", 0.0)), 0.76, EPS, "H1: 1 файл 76%")
_approx(float(m1.get("2", 0.0)), 0.24, EPS, "H1: 2 файла 24%")
_approx(float(m1.get("3", 0.0)), 0.0, EPS, "H1: 3 файла 0%")
_approx(float(m1.get("4", 0.0)), 0.0, EPS, "H1: 4 файла 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(9).get("request_cap", 0)), 7, "H9: кап 7 запросов")
_eq(int(data.hour_def(100).get("hour", 0)), 9, "hour > 9 → последняя строка")
_eq(data.hours.size(), 9, "9 строк бюджета часов")
@@ -94,7 +96,7 @@ func _test_data() -> void:
_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(prices_sum, 8085, "сумма цен всех лотов = 8 085")
_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(), "неизвестный лот → {}")
@@ -105,13 +107,17 @@ func _test_data() -> void:
_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"]:
_eq((dots.get("tiers", []) as Array).size(), 3, "dots: 3 ступени")
for gid in ["dots", "sound", "text", "video", "photo", "maze", "junk", "ddos", "phishing", "ransomware"]:
_ok(not data.minigame_def(gid).is_empty(), "мини-игра %s есть" % gid)
_ok(not (data.minigames.get("games", {}) as Dictionary).has("wires"), "wires удалена")
var sel: Dictionary = data.minigame_selection()
_eq((sel.get("order", []) as Array).size(), 7, "send order: 7 игр")
_eq((sel.get("order", []) as Array).size(), 6, "send order: 6 игр")
var sums: Array = sel.get("achievable_sums", [])
_eq(sums.size(), 19, "19 достижимых сумм")
_eq(sums.size(), 27, "27 достижимых сумм (1-4 файла)")
_eq(int(sel.get("max_index", -1)), 26, "max_index = 26")
_eq((sel.get("rank_games", []) as Array).size(), 27, "rank_games: 27 значений")
_eq((sel.get("tier_max_rank", []) as Array).size(), 2, "tier_max_rank: 2 границы")
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 ступени")
@@ -169,14 +175,14 @@ func _test_levels_and_caps() -> void:
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) без ^")
_approx(game.speed_mult(), 0.531441, 0.0005, "pow(0.90, 6) ≈ 0.531")
_approx(E.speed_mult(6), pow(0.90, 6.0), EPS, "speed_mult = pow(0.90, 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)")
_approx(game.sla_cap(), 220.0, EPS, "4 уровня ИБП → кап 220")
_approx(game.sla, 220.0, EPS, "ИБП сразу добавляет +40 за уровень (60 → 220)")
game.add_sla(1000.0)
_approx(game.sla, 200.0, EPS, "add_sla не выше динамического капа 200")
_approx(game.sla, 220.0, EPS, "add_sla не выше динамического капа 220")
game.set_lot_level("lot_money", 8)
_approx(game.money_mult(), 1.4, EPS, "8 уровней контракта → ×1.4")
@@ -184,7 +190,7 @@ func _test_levels_and_caps() -> void:
game.set_lot_level("lot_folder", 5)
var dims: Vector2i = game.folder_dims()
_eq(dims.x, 8, "folder level 5: 8 столбцов")
_eq(dims.x, 9, "folder level 5: 9 столбцов")
_eq(dims.y, 6, "folder level 5: 6 строк")
game.set_lot_level("lot_folder", 1)
dims = game.folder_dims()
@@ -196,7 +202,7 @@ func _test_levels_and_caps() -> void:
_eq(game.buffer_limit(), 12, "6 уровней канала → буфер 12")
game.set_lot_level("lot_timer", 5)
_approx(game.request_timer(), 55.0, EPS, "5 уровней таймера → 55")
_approx(game.request_timer(), 65.0, EPS, "5 уровней таймера → 65")
game.set_lot_level("lot_timer", 99)
_eq(game.level("lot_timer"), 5, "уровень клампится max_level")
@@ -205,7 +211,7 @@ func _test_levels_and_caps() -> void:
_eq(game.shield_charges, 3, "покупка сразу заряжает щит")
# Сетка уровней 0..5 из таблицы.
var expected := [[5, 4], [6, 4], [6, 5], [7, 5], [7, 6], [8, 6]]
var expected := [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]]
for lvl in range(6):
game.set_lot_level("lot_folder", lvl)
var d: Vector2i = game.folder_dims()
@@ -229,9 +235,9 @@ func _test_sla_rules() -> void:
game.sla = 90.0
game.hour = 2
game.regen_sla_for_hour()
_approx(float(game.sla), 95.0, EPS, "реген не превышает кап 95")
_approx(float(game.sla), 100.0, EPS, "реген не превышает кап 100")
game.regen_sla_for_hour()
_approx(float(game.sla), 95.0, EPS, "повторный реген в том же часу — no-op")
_approx(float(game.sla), 100.0, EPS, "повторный реген в том же часу — no-op")
# SLA 0 во время часа = поражение забега.
var reasons: Array = []
@@ -266,10 +272,10 @@ func _test_channel_and_timers() -> void:
_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 лота")
_approx(E.request_timer(5, {"timer_level": 1}), 37.0, EPS, "request timer +1 = 37")
_approx(E.request_timer(9, {"timer_level": 5}), 65.0, EPS, "request timer max 65")
_approx(E.request_timer(9, {"timer_level": 99}), 65.0, EPS, "request timer cap 65")
_approx(E.request_timer(1, {"lot_timer": 2}), 44.0, EPS, "request timer по id лота")
func _test_transfer_time() -> void:
@@ -278,14 +284,14 @@ func _test_transfer_time() -> void:
_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}, 1.0, 1), 12.0 * 0.90, EPS, "speed 1 → ×0.90")
_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.recv_transfer_time({"base_time_sec": 8}, 1.0, 2), 8.0 * pow(0.90, 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({"base_time_sec": 8}, 1.0, {"speed_level": 2}), 8.0 * pow(0.90, 2.0), EPS, "legacy recv_time + speed")
_approx(E.send_time({"base_time_sec": 12}, 1.0, {"speed_level": 1}), 12.0 * 0.90, EPS, "legacy send_time")
_approx(E.recv_time({"recv_time": 2.0}, 1.0, {}), 2.0, EPS, "legacy поле recv_time")
@@ -293,76 +299,97 @@ 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, 0.5, "udp", 0, {}), 64, "quality 0.5, udp ×0.85")
_eq(E.payout(100.0, 3, 30.0, 30.0, 1.0, "udp", 0, {}), 111, "multi n=3 (1.30) × udp 0.85")
_eq(E.payout(100.0, 1, 0.0, 30.0, 1.0, "udp", 0, {}), 51, "speed минимум 0.6")
_eq(E.payout(100.0, 1, 60.0, 30.0, 1.0, "udp", 0, {}), 85, "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, 1, 30.0, 30.0, 1.0, &"udp", 10, {}), 102, "streak +20% × udp 0.85")
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "udp", 50, {}), 102, "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 лота")
_eq(E.payout(100.0, 1, 30.0, 0.0, 1.0, "udp", 0, {}), 85, "timer_total 0 → полная скорость")
_eq(E.payout(100.0, 1, 30.0, 30.0, 2.0, "udp", 0, {}), 85, "quality клампится 1")
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "udp", 0, {"money_level": 4}), 102, "money_mult +4 = ×1.2")
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "udp", 0, {"money_level": 8}), 119, "money_mult +8 = ×1.4")
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "udp", 0, {"lot_money": 2}), 94, "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")
# 100 × (0.6 + 0.4/3) × 0.5 × 0.85 = 31.167 → 31.
_eq(E.payout(100.0, 1, 1.0, 3.0, 0.0, "udp", 0, {}), 31, "time ratio 1/3 — float")
# 100 × (0.6 + 0.8/3) × 0.5 × 0.85 = 36.833 → 37.
_eq(E.payout(100.0, 1, 2.0, 3.0, 0.0, "udp", 0, {}), 37, "time ratio 2/3 — float")
func _test_send_minigame() -> void:
_set_case("send_minigame")
var m: Dictionary = E.send_minigame_for(["text"])
var m: Dictionary = E.send_minigame_for([])
_eq(int(m.get("sum", -1)), 0, "S=0")
_eq(str(m.get("id", "")), "dots", "S=0 → dots")
_eq(int(m.get("tier", 0)), 1, "S=0 → T1")
m = E.send_minigame_for(["text"])
_eq(int(m.get("sum", 0)), 1, "S=1")
_eq(str(m.get("id", "")), "dots", "S=1 → dots")
_eq(str(m.get("id", "")), "text", "S=1 → text")
_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")
m = E.send_minigame_for(["TEXT", "Sound"])
_eq(int(m.get("sum", 0)), 3, "S=3 (types lowercase)")
_eq(str(m.get("id", "")), "dots", "S=3 → dots")
_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(str(m.get("id", "")), "photo", "S=4 → photo")
_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(str(m.get("id", "")), "video", "S=8 → video")
_eq(int(m.get("tier", 0)), 1, "S=8 → T1 (rank 7)")
_eq(int(m.get("index", -1)), 7, "S=8 → rank 7")
m = E.send_minigame_for(["video", "text"])
_eq(int(m.get("sum", 0)), 9, "S=9")
_eq(int(m.get("tier", 0)), 2, "S=9 → T2")
_eq(str(m.get("id", "")), "dots", "S=9 → dots (rank 8)")
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")
_eq(int(m.get("tier", 0)), 3, "S=24 → T3")
_eq(str(m.get("id", "")), "dots", "S=24 → dots (rank 22)")
_eq(int(m.get("index", -1)), 22, "S=24 → rank 22")
m = E.send_minigame_for(["video", "video", "video", "video"])
_eq(int(m.get("sum", 0)), 32, "S=32")
_eq(int(m.get("tier", 0)), 3, "S=32 → T3")
_eq(int(m.get("index", -1)), 26, "S=32 → rank 26")
# ACHIEVABLE_SUMS: ровно 27 значений для 1–4 файлов.
_eq(E.achievable_sums().size(), 27, "27 достижимых сумм")
var alias: Dictionary = E.selection_for_sum(8)
_eq(str(alias.get("id", "")), "video", "selection_for_sum(8) → video")
_eq(int(alias.get("tier", 0)), 1, "selection_for_sum(8) → T1")
_eq(int(alias.get("index", -1)), 7, "selection_for_sum(8) → rank 7")
# Все 7 игр и все 4 ступени достижимы при максимум 3 файлах.
# Все 6 игр и все 3 ступени достижимы при 1–4 файлах.
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]:
for d in range(pool.size()):
for combo in [[pool[a]], [pool[a], pool[b]],
[pool[a], pool[b], pool[c]],
[pool[a], pool[b], pool[c], pool[d]]]:
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(), 6, "достижимы все 6 игр отправки")
for t in [1, 2, 3]:
_ok(tiers.has(t), "достижима ступень T%d" % t)
_ok(not ids.has("junk"), "атакующие игры не выбираются для отправки")
_ok(not ids.has("wires"), "wires больше не выбирается")
func _test_attack_tier() -> void:
+165 -8
View File
@@ -27,6 +27,8 @@ func _init() -> void:
return
_test_cell_sizes()
_test_hit_test()
_test_draw_offsets()
_test_input_helpers()
_test_labels_and_rotation()
_test_payload_and_edges()
_test_plural()
@@ -39,6 +41,8 @@ func _init() -> void:
_finish()
return
await _test_file_grid(registry, folder_id)
await _test_rotation_preference()
await _test_ghost_rotation()
await _test_sorting_table(registry)
await _test_drag_controller(folder_id)
await _test_file_shape()
@@ -92,6 +96,40 @@ func _test_hit_test() -> void:
_eq(r, Vector2i(0, 1), "прямоугольный hit-test стола")
func _test_draw_offsets() -> void:
_set_case("draw offsets")
# BUG A: раньше paint() получал _grid_origin и все файлы падали в (0,0).
var origin := Vector2(12, 20)
var zero := FG.shape_origin(origin, 100.0, 6.0, Vector2i.ZERO)
var at_32 := FG.shape_origin(origin, 100.0, 6.0, Vector2i(3, 2))
_eq(zero, Vector2(12, 20), "origin (0,0) = grid origin")
_eq(at_32, Vector2(330, 232), "origin (3,2) = grid origin + 3/2 шага")
_ok(zero != at_32, "разные origin дают разные позиции рисования")
var cell_rect := FS.cell_pixel_rect(at_32, Vector2i.ZERO, 100.0, 6.0)
_eq(cell_rect, Rect2(327, 229, 106, 106),
"файл в (3,2) рисует клетку (3,2), а не (0,0)")
var table_pos := FG.shape_origin_pair(Vector2(9, 11), Vector2(99, 86), 6.0,
Vector2i(2, 4))
_eq(table_pos, Vector2(219, 379), "стол: origin (2,4) с прямоугольной клеткой")
func _test_input_helpers() -> void:
_set_case("rotate input")
var key := InputEventKey.new()
key.physical_keycode = KEY_R
key.keycode = KEY_K # русская раскладка даёт другую букву
key.pressed = true
_ok(FG.is_rotate_key(key), "R распознаётся по physical_keycode")
key.echo = true
_ok(not FG.is_rotate_key(key), "echo-повтор R игнорируется")
var click := InputEventMouseButton.new()
click.button_index = MOUSE_BUTTON_RIGHT
click.pressed = true
_ok(FG.is_rotate_click(click), "ПКМ распознаётся как поворот во время drag")
click.pressed = false
_ok(not FG.is_rotate_click(click), "отпускание ПКМ не вращает")
func _test_labels_and_rotation() -> void:
_set_case("label cell")
_eq(FR.label_cell(FR.shape_cells("S4", 0)), Vector2i(1, 0),
@@ -165,7 +203,9 @@ func _test_file_grid(registry, folder_id: String) -> void:
_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", "пустой подвал")
_eq(grid.footer_text(), _footer(grid.stats()), "пустой подвал")
_eq(grid.footer_text(), "0 файлов · 20 клеток",
"подвал: файлы и клетки (с плюрализацией)")
var m: Dictionary = grid.grid_metrics()
_eq(float(m["cell"]), 104.0, "cell при 558×470 → 104")
_eq(int(m["cols"]), 5, "cols = 5")
@@ -180,8 +220,10 @@ func _test_file_grid(registry, folder_id: String) -> void:
grid.refresh()
_eq(grid.file_count(), 1, "после создания 1 файл")
_eq(grid.occupied_cells(), 1, "текст занимает 1 клетку")
_eq(grid.footer_text(), "занято 1 из 20 · свободно 19",
_eq(grid.footer_text(), _footer(grid.stats()),
"подвал после создания")
_eq(grid.footer_text(), "1 файл · 20 клеток",
"подвал: 1 файл, 20 клеток")
_eq(grid.hidden_count(), 0, "опасных файлов нет")
var selected: Array = []
@@ -229,6 +271,85 @@ func _test_file_grid(registry, folder_id: String) -> void:
grid.queue_free()
## Поворот пользователя главнее: сначала requested, авто — только если не влез.
func _test_rotation_preference() -> void:
_set_case("rotation preference")
var grid = FG.new()
grid.name = "TestRotationGrid"
grid.size = Vector2(558, 470)
root.add_child(grid)
await process_frame
grid._folder = null
var payload := {"kind": "file", "id": "probe", "shape_id": "I4H",
"rotation": 0, "from": "folder"}
var exact: Dictionary = grid._resolve_placement(payload, Vector2i(0, 0), 0)
_ok(bool(exact.get("ok", false)), "I4H влезает в (0,0) горизонтально")
_eq(int(exact.get("rotation", -1)), 0, "выбранный поворот сохранён")
_ok(not bool(exact.get("auto", true)), "без автоповорота auto = false")
var turned: Dictionary = grid._resolve_placement(payload, Vector2i(4, 0), 0)
_ok(bool(turned.get("ok", false)), "у правого края находится поворот")
_eq(int(turned.get("rotation", -1)), 1, "авто-поворот выбрал вертикаль")
_ok(bool(turned.get("auto", false)), "автоповорот помечен auto = true")
var kept: Dictionary = grid._resolve_placement(payload, Vector2i(0, 0), 1)
_eq(int(kept.get("rotation", -1)), 1, "requested=1 уважается, если влезает")
root.remove_child(grid)
grid.queue_free()
## Ghost и drop используют поворот из DragController, а не из payload.
func _test_ghost_rotation() -> void:
_set_case("ghost rotation")
var grid = FG.new()
grid.name = "TestGhostGrid"
grid.size = Vector2(558, 470)
root.add_child(grid)
await process_frame
var dc = DC.new()
dc.name = "TestGhostDragController"
root.add_child(dc)
await process_frame
grid.drag_controller = dc
var preview = FS.new()
preview.setup("text", "I4H", 0, "Договор", "txt", 20.0)
dc.begin(preview, "shape", 0)
var payload := {"kind": "file", "id": "probe", "shape_id": "I4H",
"rotation": 0, "from": "folder"}
var pos: Vector2 = grid._cell_rect(4, 0).get_center()
_ok(grid._can_drop_data(pos, payload), "can_drop у правого края")
_eq(int(grid._ghost_rotation), 1, "ghost сам повернулся у края")
_eq(int(grid._drag_rotation), 0, "requested поворот ещё 0")
dc.set_rotation(1)
_eq(int(grid._drag_rotation), 1, "rotation_changed обновил сетку")
_eq(int(grid._ghost_rotation), 1, "ghost показывает выбранный поворот")
_ok(not bool(grid._ghost_auto), "ручной поворот без auto")
grid._can_drop_data(pos, payload)
_eq(int(grid._drag_rotation), 1, "can_drop не сбрасывает поворот из payload")
# Ghost обязан честно показывать каждый из 4 выбранных поворотов.
var square_payload := {"kind": "file", "id": "probe2", "shape_id": "L3a",
"rotation": 0, "from": "folder"}
var center: Vector2 = grid._cell_rect(2, 2).get_center()
for rot in 4:
dc.set_rotation(rot)
_ok(grid._can_drop_data(center, square_payload),
"can_drop для поворота %d" % rot)
_eq(int(grid._ghost_rotation), rot, "ghost показывает поворот %d" % rot)
_eq(int(grid._drag_rotation), rot, "сетка приняла поворот %d" % rot)
var expected_rot := int(grid._ghost_rotation)
var dropped: Array = []
grid.packet_dropped.connect(func(_p: Dictionary, _origin: Vector2i,
rot: int) -> void: dropped.append(rot))
grid._drop_data(pos, {"kind": "packet", "id": "p", "shape_id": "I4H",
"rotation": 0, "from": "packet"})
_eq(dropped.size(), 1, "drop пакета эмитится")
if dropped.size() > 0:
_eq(int(dropped[0]), expected_rot, "drop сохранил поворот ghost")
dc.end()
root.remove_child(grid)
grid.queue_free()
root.remove_child(dc)
dc.queue_free()
func _test_sorting_table(registry) -> void:
_set_case("SortingTable")
var table = ST.new()
@@ -240,7 +361,7 @@ func _test_sorting_table(registry) -> void:
await process_frame
_eq(table.total_cells(), 24, "стол 4×6 = 24 клетки")
_eq(table.occupied_cells(), 0, "пустой стол")
_eq(table.footer_text(), "24 клетки · занято 0", "подвал пустого стола")
_eq(table.footer_text(), "24 клетки", "подвал пустого стола")
var m: Dictionary = table.grid_metrics()
_eq(m["cell"], Vector2(99, 86), "cell стола 99×86")
_eq(int(m["cols"]), 4, "стол: 4 колонки")
@@ -252,7 +373,7 @@ func _test_sorting_table(registry) -> void:
table.refresh()
_eq(table.file_count(), 1, "на столе 1 файл")
_eq(table.occupied_cells(), 1, "на столе 1 клетка")
_eq(table.footer_text(), "24 клетки · занято 1", "подвал стола с файлом")
_eq(table.footer_text(), "24 клетки", "подвал стола с файлом")
var fit: Dictionary = table._resolve_placement({
"kind": "file", "id": str(file.id), "shape_id": "O1",
"rotation": 0, "from": "table",
@@ -320,12 +441,40 @@ func _test_drag_controller(folder_id: String) -> void:
_ok(dc.preview() == null, "end() освобождает превью")
dc.end()
_ok(dc.preview() == null, "повторный end() не падает")
var spin = FS.new()
spin.setup("text", "I4H", 2, "Договор", "txt", 20.0)
dc.begin(spin, "shape", 2)
_ok(dc.is_active(), "is_active() при живом превью")
_eq(dc.rotation(), 2, "begin(initial_rotation) сохраняет поворот")
var r1: int = int(dc.rotate_cw())
_eq(r1, 3, "rotate_cw() → 3")
await process_frame
_eq(int(dc.rotate_cw()), 0, "rotate_cw() 3 → 0 (posmod)")
_eq(spin.shape_rotation, 0, "поворот сразу применяется к превью")
dc.set_rotation(5)
_eq(dc.rotation(), 1, "set_rotation нормализует 5 → 1")
_eq(spin.shape_rotation, 1, "set_rotation обновляет превью")
# Пакет (PacketCard) зовёт begin() без третьего аргумента — поворот
# должен подхватиться из shape_rotation превью.
var packet_preview = FS.new()
packet_preview.setup("photo", "L3a", 3, "Пакет", "jpg", 20.0)
dc.begin(packet_preview, "shape")
_eq(dc.rotation(), 3, "пакет: поворот засеян из превью")
dc.end()
_eq(dc.rotation(), 0, "end() сбрасывает поворот")
_ok(not dc.is_active(), "is_active() false без превью")
root.remove_child(dc)
dc.queue_free()
func _test_file_shape() -> void:
_set_case("FileShape")
_eq(FS.label_font_size(56.0), 12, "подпись < 72 px → 12")
_eq(FS.label_font_size(71.9), 12, "подпись 71.9 px → 12")
_eq(FS.label_font_size(72.0), 14, "подпись 72 px → 14")
_eq(FS.label_font_size(96.0), 14, "подпись 96 px → 14")
_eq(FS.label_font_size(96.1), 16, "подпись > 96 px → 16")
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")
@@ -359,8 +508,8 @@ func _test_tree_panel() -> void:
],
}])
await process_frame
_eq(tree.footer_text(), "2 папок · 2 файлов · 5 клеток занято",
"подвал дерева: папки/файлы/клетки")
_eq(tree.footer_text(), "2 папки · 2 файла",
"подвал дерева: папки/файлы")
var section_row = null
var folder_row = null
for e in tree.get("_rows"):
@@ -377,7 +526,7 @@ func _test_tree_panel() -> void:
_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 клеток",
_eq(folder_row.tooltip_text, "2 файла · 5 из 20 клеток",
"тултип папки в клетках")
var opened: Array = []
tree.folder_selected.connect(func(id: String) -> void: opened.append(id))
@@ -395,7 +544,7 @@ func _test_tree_panel() -> void:
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 клеток занято",
_eq(tree.footer_text(), "2 папки · 3 файла",
"подвал после update_stats")
root.remove_child(tree)
tree.queue_free()
@@ -403,6 +552,14 @@ func _test_tree_panel() -> void:
# --- helpers ---------------------------------------------------------------
## Подвал FileGrid v3: «N файлов · M клеток» (плюрализация клеток).
func _footer(stats: Dictionary) -> String:
var total := int(stats.get("total", 0))
var files := int(stats.get("files", 0))
return "%d %s · %d %s" % [files, FG.plural_files(files), total,
FG.plural_cells(total)]
func _first_folder_id(registry) -> String:
if registry == null:
return ""
+10 -11
View File
@@ -188,24 +188,23 @@ func _play_tick() -> void:
continue
_try_receive(hd, str(packet_id))
# 4. Запросы: докладываем файлы, затем явный start_send.
# 4. Запросы: докладываем ИМЕННО required-файлы, затем явный start_send.
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.sending or req.minigame_active or req.connecting or hd.is_request_queued(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():
var missing: Array = hd.request_missing(req_id)
for fid in missing:
if not hd.attach_file(req_id, str(fid)):
break
if not hd.attach_file(req_id, fid):
break
if req.attached.size() >= need:
if hd.request_missing(req_id).is_empty():
if hd.start_send(req_id):
# Бот мгновенно проходит мини-игру соединения (UI-слой не подключён).
var fresh = hd.active_requests.get(req_id)
if fresh != null and bool(fresh.connecting):
hd.connection_finished(req_id, true)
if hd.is_minigame_active():
hd._on_send_minigame_finished(0.8)
elif float(req.time_left) < 2.0:
+617 -70
View File
@@ -23,7 +23,11 @@ var _minigame_shows: int = 0
var _minigame_cancels: int = 0
var _minigame_closes: int = 0
var _connection_shows: int = 0
var _connection_request_id: String = ""
var _connection_hour: int = 0
var _connection_successes: int = -1
var _frozen_events: int = 0
var _toasts: Array[String] = []
func _init() -> void:
@@ -42,6 +46,7 @@ func _init() -> void:
_hd.minigame_closed.connect(_on_minigame_closed)
_hd.spawn_frozen.connect(_on_spawn_frozen)
_hd.show_connection.connect(_on_show_connection)
_hd.toast.connect(_on_toast)
_test_start_hour()
_test_budgets_and_weights()
@@ -50,6 +55,7 @@ func _init() -> void:
_test_receive()
_test_receive_cancel_and_no_space()
_test_request_caps()
_test_starvation_guard()
_test_attach_detach()
_test_protocol()
_test_send_minigame_pause()
@@ -58,8 +64,14 @@ func _init() -> void:
_test_send_timer_freeze()
_test_timeout_preserves_files()
_test_hour_end_during_minigame()
_test_no_connection_game()
_test_connection_flow()
_test_lost_required()
_test_modal_guard()
_test_required_types_stable()
_test_no_substitute_while_sending()
_test_report()
_test_reset_for_prep()
_test_move_required_file()
_finish()
@@ -67,24 +79,44 @@ func _init() -> void:
func _test_start_hour() -> void:
_set_case("start_hour")
_fresh(1)
_fresh(1, true)
_eq(_hd.hour, 1, "hour = 1")
_approx(_hd.time_left, 300.0, 0.0001, "длительность часа 300 с")
_ok(_hd.running, "час запущен")
_eq(_hd.files_budget, 4, "H1: бюджет 4 файла")
# v3: H1 плотнее — значения берём из data/balance.json → hours[0].
var h1: Dictionary = _hour_row(1)
_eq(_hd.files_budget, int(h1.get("files", -1)), "H1: бюджет файлов из данных")
_eq(_hd.files_budget, 7, "H1: 7 файлов")
_eq(_hd.files_spawned, 0, "H1: файлы ещё не спавнились")
_eq(_hd.request_cap, 2, "H1: кап запросов 2")
_eq(_hd.active_request_limit, 1, "H1: 1 активный запрос")
_approx(_hd.spawn_window, 225.0, 0.0001, "окно спавна 75% часа = 225 с")
_eq(_hd.request_cap, int(h1.get("request_cap", -1)), "H1: кап запросов из данных")
_eq(_hd.request_cap, 6, "H1: кап запросов 6")
_eq(_hd.active_request_limit, int(h1.get("active_requests", -1)),
"H1: active_requests из данных")
_eq(_hd.active_request_limit, 2, "H1: 2 активных запроса")
_approx(_hd.spawn_window, 300.0 * 0.75, 0.0001, "окно спавна 75% часа = 225 с")
_eq(_hd.slots.max_slots(), 1, "H1: 1 слот")
_eq(_hd.buffer_limit(), 6, "H1: буфер 6")
_approx(_hd._recv_interval(), 12.0, 0.0001, "legacy recv_interval H1 = 12")
_approx(_hd._receive_interval(), 225.0 / 4.0, 0.0001, "спавн-интервал 225/4")
_approx(_hd._send_interval(), 22.0, 0.0001, "send_interval H1 = 22")
_eq(_hd._max_requests(), 1, "max concurrent requests H1 = 1")
_approx(_hd._recv_interval(), float(_difficulty_row(1).get("recv_interval", -1)),
0.0001, "legacy recv_interval H1 из данных")
_approx(_hd._receive_interval(), _hd.spawn_window / float(_hd.files_budget),
0.0001, "спавн-интервал = окно / бюджет")
_approx(_hd._send_interval(), float(h1.get("send_interval", -1)), 0.0001,
"send_interval H1 из данных")
_eq(_hd._max_requests(), int(h1.get("active_requests", -1)),
"max concurrent requests H1 = active_requests")
_eq(_hd._type_pool(), ["text", "sound"], "H1: только text/sound")
_eq(_hd.active_packets.size(), 0, "активных пакетов нет")
_eq(_hd.active_requests.size(), 0, "активных запросов нет")
_eq(_hd.active_requests.size(), 1, "H1: стартовый запрос создан сразу")
_eq(_hd.requests_spawned, 1, "H1: стартовый запрос учтён в капе")
_eq(_game.registry.file_count(), 1, "H1: создан стартовый файл")
var boot_req := str(_hd.active_requests.keys()[0])
_eq(_hd.request_required_count(boot_req), 1, "H1: стартовый запрос на 1 файл")
var boot_ids: Array = _hd.request_required_ids(boot_req)
var boot_file = _game.registry.files.get(str(boot_ids[0]))
_ok(boot_file != null and str(boot_file.type) == "text", "стартовый файл — text")
if boot_file != null:
_eq(str(boot_file.required_by), boot_req, "файл зарезервирован под запрос")
_approx(float(_hd._next_spawn_at), 0.0, 0.0001, "H1: первый пакет в t=0")
_ok(_hd.attack_director != null, "AttackDirector создан")
_eq(_hd.attack_director._schedule.size(), 0, "H1: атак нет")
_ok(_hd._minigame == null, "мини-игра не открыта")
@@ -94,9 +126,15 @@ func _test_start_hour() -> void:
func _test_budgets_and_weights() -> void:
_set_case("budgets_weights")
for pair in [[1, 4], [2, 8], [3, 12], [4, 16], [5, 20], [6, 20], [9, 20]]:
for h in [1, 2, 3, 4, 5, 6, 9]:
_fresh(int(h))
_eq(_hd.files_budget, int(_hour_row(int(h)).get("files", -1)),
"H%d: бюджет из данных" % int(h))
_eq(_hd.files_budget, 20, "H9: бюджет 20 файлов")
for pair in [[1, 7], [2, 8], [3, 12], [4, 16], [5, 20], [6, 20], [9, 20]]:
_fresh(int(pair[0]))
_eq(_hd.files_budget, int(pair[1]), "H%d: бюджет %d" % [int(pair[0]), int(pair[1])])
_eq(_hd.files_budget, int(pair[1]),
"H%d: бюджет %d" % [int(pair[0]), int(pair[1])])
_fresh(1)
var seen_h1: Dictionary = {}
@@ -126,6 +164,7 @@ func _test_budgets_and_weights() -> void:
for i in range(400):
seen_9[_hd._roll_required_count()] = true
_ok(seen_9.has(1) and seen_9.has(2) and seen_9.has(3), "H9: request_mix даёт 1/2/3")
_ok(seen_9.has(4), "H9: request_mix даёт 4 файла")
# --- перенос файлов --------------------------------------------------------
@@ -137,13 +176,21 @@ func _test_spawn_window_budget() -> void:
_hd.spawned_this_hour = 0
_hd.requests_spawned = _hd.request_cap
_hd.spawn_recv_accum = 0.0
var folder_id := _folder_id()
# Буфер H1 (6) меньше бюджета файлов (7): забираем пакеты в папку по мере
# спавна, иначе седьмой файл законно ждёт освобождения места.
for i in range(3000):
_hd.tick(0.1)
for pid in _hd.active_packets.keys().duplicate():
var packet = _hd.active_packets.get(pid)
if packet != null and packet.state == "waiting":
_hd.receive_packet(str(pid), folder_id)
if _hd.hour_elapsed >= _hd.spawn_window + 0.4:
break
_eq(_hd.files_spawned, 4, "H1: все 4 файла успевают в окно 225 с")
_eq(_hd.spawned_this_hour, 4, "H1: счётчик спавна = 4")
_ok(_hd.active_packets.size() <= 4, "в буфере не больше бюджета файлов")
_eq(_hd.files_spawned, _hd.files_budget, "H1: все 7 файлов успевают в окно")
_eq(_hd.spawned_this_hour, _hd.files_budget, "H1: счётчик спавна = бюджет")
_ok(_hd.active_packets.size() <= _hd.buffer_limit(),
"в буфере не больше лимита буфера")
_ok(_hd.hour_elapsed >= _hd.spawn_window and _hd.hour_elapsed <= _hd.spawn_window + 0.5,
"спавн остановился у границы окна")
_eq(_hd.carry_files, 0, "перенос ещё не рассчитан (час идёт)")
@@ -156,12 +203,15 @@ func _test_carry() -> void:
_hd.files_spawned = 1
_hd.carry_files = 0
_hd._finish_hour()
_eq(_hd.carry_files, 3, "недоспавненные 3 файла переносятся")
_eq(int(_hd.report().get("files_carry", -1)), 3, "отчёт: files_carry = 3")
_eq(_hd.carry_files, _hd.files_budget - 1, "недоспавненные файлы переносятся")
_eq(int(_hd.report().get("files_carry", -1)), _hd.files_budget - 1,
"отчёт: files_carry = бюджет − 1")
_game.hour = 2
_hd.start_hour(2)
_eq(_hd.files_budget, 11, "H2: 8 базовых + 3 переноса")
_eq(_hd.files_budget, mini(20, int(_hour_row(2).get("files", 8))
+ int(_hour_row(1).get("files", 7)) - 1),
"H2: базовые файлы + перенос")
_eq(_hd.carry_files, 0, "перенос применён один раз")
_hd.carry_files = 5
@@ -268,6 +318,10 @@ func _test_receive_cancel_and_no_space() -> void:
# --- запросы ---------------------------------------------------------------
func _test_request_caps() -> void:
var h1: Dictionary = _hour_row(1)
var cap := int(h1.get("request_cap", 6))
var active_limit := int(h1.get("active_requests", 2))
_set_case("request_no_files")
_fresh(1)
_hd.requests_spawned = 0
@@ -286,43 +340,112 @@ func _test_request_caps() -> void:
_hd.requests_spawned = 0
_hd._send_interval_current = 0.1
_hd.spawn_send_accum = 0.0
_hd.tick(0.15)
_eq(_hd.active_requests.size(), 1, "H1: одновременно 1 запрос")
_eq(_hd.requests_spawned, 1, "H1: запрос №1 создан")
_hd.tick(0.1)
_eq(_hd.active_requests.size(), 1, "H1: первый запрос создан")
_eq(_hd.requests_spawned, 1, "H1: первый запрос учтён в бюджете")
var req_id := str(_hd.active_requests.keys()[0])
var req = _hd.active_requests[req_id]
var required_ids: Array = _hd.request_required_ids(req_id)
_ok(req.attached.is_empty(), "файлы не прикрепляются автоматически")
_ok(req.required_count() == _hd.request_required_count(req_id),
"required_count() = число слотов контейнера")
for placeholder in req.required:
_ok(not _game.registry.files.has(str(placeholder)), "в required нет заранее выбранных файлов")
_ok(_hd.request_required_count(req_id) >= 1 and _hd.request_required_count(req_id) <= 2,
_ok(required_ids.size() >= 1 and required_ids.size() <= 2,
"H1: нужно 1–2 файла")
for file_id in required_ids:
_ok(_game.registry.files.has(file_id), "required — конкретный файл реестра")
_eq(req.required_types.size(), required_ids.size(),
"required_types заполнены при создании")
_ok(not req.connected and not req.connecting, "новый TCP ещё не подключён")
_ok(_hd.request_missing(req_id).size() == required_ids.size(),
"request_missing = все required до прикрепления")
_ok(_hd.request_required_names(req_id).size() == required_ids.size(),
"request_required_names резолвит имена")
_ok(not _hd.is_request_ready(req_id), "пустой запрос не готов")
_ok(not req.is_ready(), "legacy Request.is_ready тоже false")
_approx(req.time_left, req.timer_total, 0.0001, "таймер запроса стартовал сразу")
_approx(req.time_left, req.timer_total, 0.0001,
"таймер запроса стартует с полного значения")
# Добираем активный лимит: после спавна интервал снова H1-й (12 с).
for i in range(140):
_hd.tick(0.1)
_eq(_hd.active_requests.size(), active_limit,
"H1: одновременно %d запроса" % active_limit)
_eq(_hd.requests_spawned, active_limit, "H1: создано запросов = активный лимит")
_hd._send_interval_current = 0.1
_hd.spawn_send_accum = 0.0
for i in range(20):
_hd.tick(0.1)
_eq(_hd.requests_spawned, 1, "при активном лимите новый запрос не появляется")
_eq(_hd.requests_spawned, active_limit,
"при активном лимите новый запрос не появляется")
_hd._cleanup_request(req_id, true)
_hd._send_interval_current = 0.1
_hd.spawn_send_accum = 0.0
_hd.tick(0.15)
_eq(_hd.active_requests.size(), 1, "после освобождения слота создан второй запрос")
_eq(_hd.requests_spawned, 2, "H1: два запроса — кап")
var second_id := str(_hd.active_requests.keys()[0])
_hd._cleanup_request(second_id, true)
_eq(_hd.active_requests.size(), active_limit,
"после освобождения слота создан следующий запрос")
_eq(_hd.requests_spawned, active_limit + 1,
"освободившийся слот потратил бюджет запросов")
# Добираем бюджет до капа: каждый новый запрос сразу освобождаем.
for i in range(40):
for rid in _hd.active_requests.keys().duplicate():
_hd._cleanup_request(str(rid), true)
_hd._send_interval_current = 0.1
_hd.spawn_send_accum = 0.0
_hd.tick(0.15)
if _hd.requests_spawned >= cap:
break
_eq(_hd.requests_spawned, cap, "H1: кап запросов = %d" % cap)
for rid in _hd.active_requests.keys().duplicate():
_hd._cleanup_request(str(rid), true)
_hd._send_interval_current = 0.1
_hd.spawn_send_accum = 0.0
_hd.tick(0.15)
_eq(_hd.requests_spawned, 2, "H1: третий запрос не создаётся (кап 2)")
_eq(_hd.requests_spawned, cap, "сверх капа запрос не создаётся")
_eq(_hd.active_requests.size(), 0, "лишних запросов нет")
_set_case("request_clamp")
_fresh(1)
_new_file("Один", "text", _folder_id())
var clamped_id := str(_hd.spawn_request_now(4))
_ok(not clamped_id.is_empty(), "запрос создан при одном доступном файле")
_eq(_hd.request_required_count(clamped_id), 1,
"запрос на 4 файла зажат до размера пула (1)")
_eq(_hd.request_missing(clamped_id).size(), 1, "нужен ровно один файл")
# --- анти-голодание --------------------------------------------------------
func _test_starvation_guard() -> void:
_set_case("starvation_guard")
_fresh(1)
_eq(_hd.next_request_hint(), "Нет свободных файлов для запроса",
"без файлов подсказка объясняет простой")
var file = _new_file("Голод", "text", _folder_id())
_ok(file != null, "файл создан")
_eq(_hd.next_request_hint(), "Следующий запрос ~0 с",
"с файлом запрос может появиться сразу")
# send_interval H1 = 12 с; спавн должен случиться на первом же тике.
_hd.tick(0.1)
_eq(_hd.active_requests.size(), 1, "запрос создан без ожидания интервала")
_eq(_hd.requests_spawned, 1, "бюджет запросов потрачен")
# Второй свободный файл + анти-голодание: добираемся до активного лимита.
var file2 = _new_file("Голод 2", "text", _folder_id())
_ok(file2 != null, "второй файл создан")
for i in range(140):
_hd.tick(0.1)
_eq(_hd.active_requests.size(), _hd.active_request_limit,
"активный лимит заполнен")
_eq(_hd.next_request_hint(), "",
"при занятом лимите активных запросов подсказка не нужна")
# Даже при малом пуле запрос создаётся на 1 файл (clamp).
var rid := str(_hd.active_requests.keys()[0])
_eq(_hd.request_required_count(rid), 1, "запрос собрался из одного файла")
for active_id in _hd.active_requests.keys().duplicate():
_hd._cleanup_request(str(active_id), true)
# --- attach/detach ---------------------------------------------------------
@@ -340,30 +463,72 @@ func _test_attach_detach() -> void:
_ok(not req_id.is_empty(), "запрос на 2 файла создан")
_eq(_hd.request_required_count(req_id), 2, "required_count = 2")
var req = _hd.active_requests[req_id]
var ids: Array = _hd.request_required_ids(req_id)
_eq(ids.size(), 2, "required содержит 2 конкретных файла")
_ok(_hd.attach_file(req_id, str(f1.id)), "файл 1 прикреплён")
_eq(str(f1.location), "attached", "файл 1 остался в папке (location attached)")
_ok(_hd.attach_file(req_id, ids[0]), "файл 1 прикреплён")
_eq(str(_game.registry.files[ids[0]].location), "attached",
"файл 1 остался в папке (location attached)")
_eq(req.attached.size(), 1, "attached = 1")
_ok(not req.sending, "attach не запускает отправку")
_ok(_hd.attach_file(req_id, str(f2.id)), "файл 2 прикреплён")
_ok(_hd.attach_file(req_id, ids[1]), "файл 2 прикреплён")
_eq(req.attached.size(), 2, "attached = 2")
_ok(not req.sending, "после заполнения автоотправки нет")
_ok(_hd.is_request_ready(req_id), "собранный запрос готов")
_ok(not _hd.attach_file(req_id, str(f3.id)), "третий файл сверх лимита отклонён")
_eq(_hd.request_missing(req_id).size(), 0, "missing = 0 у собранного")
_ok(_hd.detach_file(req_id, str(f1.id)), "файл 1 откреплён")
# Лишний файл, которого нет в required, отклоняется.
var extra_id := ""
for file in [f1, f2, f3]:
if not ids.has(str(file.id)):
extra_id = str(file.id)
break
_ok(not extra_id.is_empty(), "есть файл вне required")
_toasts.clear()
_ok(not _hd.attach_file(req_id, extra_id), "файл вне required отклонён")
_ok(_has_toast("Нужен файл:"), "тост «Нужен файл: …»")
_ok(_hd.detach_file(req_id, ids[0]), "файл 1 откреплён")
_eq(req.attached.size(), 1, "attached = 1 после detach")
_eq(str(f1.location), "folder", "detach вернул файл в папку")
_ok(not _hd.detach_file(req_id, str(f3.id)), "неприкреплённый файл не открепить")
_eq(str(_game.registry.files[ids[0]].location), "folder",
"detach вернул файл в папку")
_eq(_hd.request_required_ids(req_id), ids, "detach не меняет required")
_ok(not _hd.is_request_ready(req_id), "после detach запрос не готов")
_ok(_hd.attach_file(req_id, ids[0]), "файл 1 прикреплён повторно")
_ok(not _hd.detach_file(req_id, extra_id), "неприкреплённый файл не открепить")
var req2: String = str(_hd.spawn_request_now(1))
_ok(_hd.attach_file(req2, str(f3.id)), "файл 3 прикреплён к другому запросу")
_ok(not _hd.attach_file(req_id, str(f3.id)), "занятый файл нельзя прикрепить дважды")
_ok(not req2.is_empty(), "второй запрос создан")
var ids2: Array = _hd.request_required_ids(req2)
_ok(ids2.size() == 1 and not ids.has(ids2[0]),
"второй запрос берёт файл, не занятый первым")
_ok(_hd.attach_file(req2, ids2[0]), "свободный файл прикреплён к другому запросу")
_ok(not _hd.attach_file(req2, ids[0]),
"файл, требуемый другим запросом, нельзя прикрепить")
var f4 = _new_file("Стол", "text", folder_id)
var f4 = _new_file("Резерв", "text", folder_id)
if f4 != null:
_ok(_game.registry.move_file_to_table(str(f4.id)), "файл убран на стол")
_ok(not _hd.attach_file(req_id, str(f4.id)), "файл со стола нельзя прикрепить")
var fid4 := str(f4.id)
var req3: String = str(_hd._create_request(1, "tcp", [fid4], false))
_ok(not req3.is_empty(), "третий запрос создан")
_eq(str(_game.registry.files[fid4].required_by), req3,
"required_by выставлен при создании запроса")
_ok(not _game.registry.move_file_to_table(fid4),
"required-файл нельзя унести на стол")
_ok(not _game.registry.remove_file(fid4, true),
"required-файл нельзя удалить")
_ok(_hd.attach_file(req3, fid4), "свой запрос может прикрепить резерв")
_hd._cleanup_request(req3, true)
_eq(str(_game.registry.files[fid4].required_by), "",
"cleanup снял required_by")
var f5 = _new_file("Стол", "text", folder_id)
if f5 != null:
var fid5 := str(f5.id)
_ok(_game.registry.move_file_to_table(fid5), "свободный файл убран на стол")
var req4: String = str(_hd._create_request(1, "tcp", [fid5], false))
_ok(not _hd.attach_file(req4, fid5), "файл со стола нельзя прикрепить")
_hd._cleanup_request(req4, true)
_hd._cleanup_request(req_id, true)
_hd._cleanup_request(req2, true)
@@ -374,17 +539,25 @@ func _test_protocol() -> void:
_set_case("protocol")
_fresh(1)
var file = _new_file("Протокол", "text", _folder_id())
var req_id: String = str(_hd.spawn_request_now(1))
var req_id: String = str(_hd._create_request(1, "tcp", [str(file.id)], false))
_eq(str(_hd.active_requests[req_id].protocol), "tcp", "по умолчанию tcp")
_ok(_hd.set_request_protocol(req_id, "udp"), "tcp → udp разрешён")
_ok(_hd.set_request_protocol(req_id, "udp"), "неподключённый tcp → udp разрешён")
_eq(str(_hd.active_requests[req_id].protocol), "udp", "протокол udp")
_ok(not _hd.set_request_protocol(req_id, "smtp"), "неизвестный протокол отклонён")
_ok(_hd.attach_file(req_id, str(file.id)), "файл прикреплён")
_ok(_hd.start_send(req_id), "мини-игра отправки открыта")
_ok(_hd.start_send(req_id), "UDP: мини-игра отправки открыта")
_ok(not _hd.set_request_protocol(req_id, "tcp"), "после старта мини-игры протокол не меняется")
_hd.force_close_send_minigame()
_hd._cleanup_request(req_id, true)
_set_case("protocol_connected")
var file2 = _new_file("Подключён", "text", _folder_id())
var req2: String = str(_hd._create_request(1, "tcp", [str(file2.id)], true))
_eq(str(_hd.active_requests[req2].protocol), "tcp", "TCP-запрос создан подключённым")
_ok(not _hd.set_request_protocol(req2, "udp"),
"connected-запрос нельзя переключить на UDP")
_hd._cleanup_request(req2, true)
# --- мини-игра отправки ----------------------------------------------------
@@ -392,7 +565,7 @@ func _test_send_minigame_pause() -> void:
_set_case("send_minigame_pause")
_fresh(1)
var file = _new_file("Мини", "text", _folder_id())
var req_id: String = str(_hd.spawn_request_now(1))
var req_id: String = str(_hd._make_request([file], "tcp", false, "", ""))
_ok(_hd.attach_file(req_id, str(file.id)), "файл прикреплён")
_minigame_shows = 0
_ok(_hd.start_send(req_id), "start_send → true")
@@ -417,7 +590,7 @@ func _test_out_slot_queue() -> void:
_set_case("out_slot_queue")
_fresh(1)
var file = _new_file("Очередь", "text", _folder_id())
var req_id: String = str(_hd.spawn_request_now(1))
var req_id: String = str(_hd._make_request([file], "tcp", false, "", ""))
_ok(_hd.attach_file(req_id, str(file.id)), "файл прикреплён")
var blocker := int(_hd.slots.reserve("out", "blocker"))
_ok(blocker >= 0, "слот занят другим запросом")
@@ -451,7 +624,7 @@ func _test_file_consumption() -> void:
_fresh(1)
var file = _new_file("Расход", "text", _folder_id())
var file_id := str(file.id)
var req_id: String = str(_hd.spawn_request_now(1))
var req_id: String = str(_hd._make_request([file], "tcp", false, "", ""))
_ok(_hd.attach_file(req_id, file_id), "файл прикреплён")
_ok(_hd.start_send(req_id), "мини-игра открыта")
_hd._on_send_minigame_finished(1.0)
@@ -478,7 +651,7 @@ func _test_send_timer_freeze() -> void:
_fresh(1)
var file = _new_file("Заморозка", "text", _folder_id())
var file_id := str(file.id)
var req_id: String = str(_hd.spawn_request_now(1))
var req_id: String = str(_hd._make_request([file], "tcp", false, "", ""))
_ok(_hd.attach_file(req_id, file_id), "файл прикреплён")
_ok(_hd.start_send(req_id), "мини-игра открыта")
_hd._on_send_minigame_finished(1.0)
@@ -509,7 +682,7 @@ func _test_timeout_preserves_files() -> void:
_fresh(1)
var file = _new_file("Таймаут", "text", _folder_id())
var file_id := str(file.id)
var req_id: String = str(_hd.spawn_request_now(1))
var req_id: String = str(_hd._make_request([file], "tcp", false, "", ""))
_ok(_hd.attach_file(req_id, file_id), "файл прикреплён")
var req = _hd.active_requests.get(req_id)
_ok(req != null and not req.sending, "запрос ещё НЕ отправляется")
@@ -534,7 +707,7 @@ func _test_hour_end_during_minigame() -> void:
_fresh(1)
var file = _new_file("Конец", "text", _folder_id())
var file_id := str(file.id)
var req_id: String = str(_hd.spawn_request_now(1))
var req_id: String = str(_hd._make_request([file], "tcp", false, "", ""))
_ok(_hd.attach_file(req_id, file_id), "файл прикреплён")
_hd.time_left = 5.0
_ok(_hd.start_send(req_id), "мини-игра открыта на 295-й секунде")
@@ -557,22 +730,265 @@ func _test_hour_end_during_minigame() -> void:
_ok(_hour_report.has("packets_left"), "отчёт содержит packets_left")
# --- соединение удалено ----------------------------------------------------
# --- TCP-соединение --------------------------------------------------------
func _test_no_connection_game() -> void:
_set_case("no_connection")
func _test_connection_flow() -> void:
_set_case("connection")
_fresh(1)
var folder_id := _folder_id()
var file = _new_file("Соединение", "text", folder_id)
_ok(file != null, "файл создан")
if file == null:
return
var file_id := str(file.id)
var req_id := str(_hd._create_request(1, "tcp", [file_id], false))
_ok(not req_id.is_empty(), "TCP-запрос создан")
var req = _hd.active_requests.get(req_id)
_ok(req != null and not req.connected and not req.connecting,
"новый TCP не подключён")
_ok(not _hd.is_request_connected(req_id), "is_request_connected = false")
_ok(_hd.attach_file(req_id, file_id), "файл прикреплён")
var money_before := int(_game.money)
_connection_shows = 0
var packet_id := str(_hd.spawn_packet_now("text"))
_ok(not _hd.connect_packet(packet_id), "connect_packet — заглушка false")
_ok(not _hd.connect_request("req_x"), "connect_request — заглушка false")
_ok(_hd.receive_packet(packet_id, _folder_id()), "приём работает без соединения")
for i in range(100):
_ok(_hd.start_send(req_id), "start_send запускает соединение")
_eq(_connection_shows, 1, "show_connection эмитится один раз")
_eq(_connection_request_id, req_id, "show_connection: request_id")
_eq(_connection_hour, _hd.hour, "show_connection: hour")
_eq(_connection_successes, 0, "show_connection: successes за забег")
_ok(req.connecting and not req.minigame_active,
"connecting=true, мини-игра отправки не открыта")
_eq(req.connect_attempts, 1, "connect_attempts = 1")
_ok(not _hd.start_send(req_id), "повторный start_send во время connecting → false")
_ok(not _hd.connect_request(req_id), "connect_request во время connecting → false")
var req_time := float(req.time_left)
var hour_time := float(_hd.time_left)
for i in range(10):
_hd.tick(0.1)
if not _hd.active_packets.has(packet_id):
break
_eq(_connection_shows, 0, "show_connection ни разу не эмитится")
_ok(_hd._minigame == null, "Connection/MiniGame не создаётся для приёма")
_approx(float(req.time_left), req_time, 0.0001,
"таймер запроса заморожен во время соединения")
_approx(float(_hd.time_left), hour_time - 1.0, 0.02, "время часа идёт")
var sla_before_fail := float(_game.sla)
_toasts.clear()
_ok(_hd.connection_finished(req_id, false, true),
"connection_finished(false, missed=true) принят")
_ok(not req.connecting and not req.connected, "неудача сбрасывает connecting")
_ok(_has_toast("СОЕДИНЕНИЕ НЕ УСТАНОВЛЕНО"), "тост о неустановленном соединении")
_eq(int(_game.money), money_before, "неудача соединения без денежного штрафа")
_approx(float(_game.sla), sla_before_fail - 4.0, 0.001,
"промах соединения: −4 SLA")
_approx(float(req.connect_cooldown_until), float(_hd.hour_elapsed) + 8.0, 0.01,
"промах: кулдаун 8 с")
_ok(_hd.is_request_ready(req_id), "после неудачи запрос готов к повтору")
_toasts.clear()
_ok(not _hd.start_send(req_id), "start_send во время кулдауна → false")
_ok(_has_toast("ПОВТОР ЧЕРЕЗ"), "тост о кулдауне соединения")
_hd.hour_elapsed = float(req.connect_cooldown_until) + 0.01
var sla_after_miss := float(_game.sla)
_connection_shows = 0
_ok(_hd.connect_request(req_id), "connect_request запускает повтор")
_eq(req.connect_attempts, 2, "connect_attempts = 2")
_eq(_connection_shows, 1, "повторный show_connection")
_ok(_hd.connection_finished(req_id, false, false),
"connection_finished(false, missed=false) — отмена")
_approx(float(_game.sla), sla_after_miss, 0.001, "отмена соединения без SLA")
_approx(float(req.connect_cooldown_until), float(_hd.hour_elapsed) + 4.0, 0.01,
"отмена: кулдаун 4 с")
_hd.hour_elapsed = float(req.connect_cooldown_until) + 0.01
_connection_shows = 0
_ok(_hd.connect_request(req_id), "третья попытка соединения")
_eq(req.connect_attempts, 3, "connect_attempts = 3")
_eq(_connection_shows, 1, "show_connection на третью попытку")
_minigame_shows = 0
_ok(_hd.connection_finished(req_id, true), "connection_finished(true) принят")
_ok(req.connected, "connected = true после успеха")
_approx(float(req.connect_cooldown_until), 0.0, 0.001, "успех снял кулдаун")
_eq(_hd.connect_successes, 1, "connect_successes = 1")
_eq(_minigame_shows, 1, "после соединения открылась мини-игра отправки")
_ok(req.minigame_active, "мини-игра отправки активна")
_hd.force_close_send_minigame()
_hd._cleanup_request(req_id, true)
# UDP не требует соединения: сразу мини-игра отправки.
var file2 = _new_file("УДП", "text", folder_id)
if file2 != null:
var rid2 := str(_hd._create_request(1, "udp", [str(file2.id)], false))
_ok(_hd.attach_file(rid2, str(file2.id)), "UDP-файл прикреплён")
_connection_shows = 0
_ok(_hd.start_send(rid2), "UDP стартует отправку")
_eq(_connection_shows, 0, "UDP не показывает соединение")
_ok(bool(_hd.active_requests[rid2].minigame_active), "UDP: мини-игра отправки")
_hd.force_close_send_minigame()
_hd._cleanup_request(rid2, true)
# Исчезновение required-файла: запрос остаётся отклоняемым.
var file3 = _new_file("Пропажа", "text", folder_id)
if file3 != null:
var fid3 := str(file3.id)
var rid3 := str(_hd._create_request(1, "udp", [fid3], true))
_ok(_hd.attach_file(rid3, fid3), "файл прикреплён")
_game.registry.consume_file(fid3)
_hd.tick(0.1)
_eq(_hd.request_missing(rid3), [fid3], "исчезнувший файл снова в missing")
_eq(_hd.request_missing_names(rid3), ["ПОТЕРЯН"], "имя потерянного файла")
_ok(not _hd.start_send(rid3), "запрос без required-файла не отправить")
_ok(_hd.reject_request(rid3), "запрос можно отклонить")
_ok(not _hd.connect_packet("pkt_x"), "connect_packet — заглушка false")
_eq(_connection_shows, 0, "приём пакетов не требует соединения")
# --- потерянные required-файлы ---------------------------------------------
func _test_lost_required() -> void:
_set_case("lost_required_substitute")
_fresh(1)
var folder_id := _folder_id()
var f1 = _new_file("Потеря", "text", folder_id)
var f2 = _new_file("Замена", "text", folder_id)
_ok(f1 != null and f2 != null, "два файла созданы")
if f1 == null or f2 == null:
return
var fid1 := str(f1.id)
var fid2 := str(f2.id)
var req_id := str(_hd._create_request(1, "tcp", [fid1], false))
_eq(str(_game.registry.files[fid1].required_by), req_id, "резерв на первом файле")
_game.registry.mark_failed(fid1)
for i in range(6):
_hd.tick(0.1)
var ids: Array = _hd.request_required_ids(req_id)
_eq(ids.size(), 1, "required не раздулся")
_eq(str(ids[0]), fid2, "потерянный файл заменён однотипным")
_eq(str(_game.registry.files[fid1].required_by), "", "required_by старого снят")
_eq(str(_game.registry.files[fid2].required_by), req_id, "required_by нового выставлен")
_eq(_hd.request_missing(req_id), [fid2], "новый файл ожидает прикрепления")
_ok(_hd.attach_file(req_id, fid2), "замену можно прикрепить")
_hd._cleanup_request(req_id, true)
_set_case("lost_required_none")
_fresh(1)
folder_id = _folder_id()
var only = _new_file("Единственный", "text", folder_id)
if only == null:
return
var fid_only := str(only.id)
var rid2 := str(_hd._create_request(1, "udp", [fid_only], true))
_game.registry.consume_file(fid_only)
for i in range(6):
_hd.tick(0.1)
_eq(_hd.request_required_ids(rid2), [fid_only], "без замены required остаётся прежним")
_eq(_hd.request_missing(rid2), [fid_only], "файл числится missing")
_eq(_hd.request_missing_names(rid2), ["ПОТЕРЯН"], "имя потерянного файла")
_ok(not _hd.start_send(rid2), "запрос без файла не отправить")
_ok(_hd.reject_request(rid2), "запрос можно отклонить")
# --- C1/C2: модалка блокирует второй старт --------------------------------
func _test_modal_guard() -> void:
_set_case("modal_guard")
_fresh(1)
var file = _new_file("Модалка", "text", _folder_id())
if file == null:
return
var req_id := str(_hd._make_request([file], "udp", false, "", ""))
_ok(_hd.attach_file(req_id, str(file.id)), "файл прикреплён")
_minigame_shows = 0
_hd.set_modal_open(true)
_ok(not _hd.start_send(req_id), "C2: start_send при modal_open → false")
_ok(_hd._minigame == null, "C2: вторая мини-игра не создана")
_ok(str(_hd._minigame_request_id).is_empty(), "C2: request_id мини-игры пуст")
var req = _hd.active_requests.get(req_id)
_ok(req != null and not bool(req.minigame_active),
"C2: minigame_active не выставлен")
_eq(_minigame_shows, 0, "C2: show_minigame не эмитился")
_ok(_has_toast("закройте текущее окно"), "C2: тост о занятом окне")
_hd.set_modal_open(false)
_ok(_hd.start_send(req_id), "после закрытия модалки отправка стартует")
_hd.force_close_send_minigame()
_hd._cleanup_request(req_id, true)
# То же для TCP-соединения: модалка не даёт начать connecting.
_fresh(1)
var file2 = _new_file("Модалка TCP", "text", _folder_id())
if file2 == null:
return
var req2 := str(_hd._make_request([file2], "tcp", false, "", ""))
_ok(_hd.attach_file(req2, str(file2.id)), "TCP-файл прикреплён")
_connection_shows = 0
_hd.set_modal_open(true)
_ok(not _hd.connect_request(req2), "C2: connect_request при modal_open → false")
var req2_live = _hd.active_requests.get(req2)
_ok(req2_live != null and not bool(req2_live.connecting),
"C2: connecting не выставлен")
_eq(_connection_shows, 0, "C2: show_connection не эмитился")
_hd.set_modal_open(false)
_hd._cleanup_request(req2, true)
# --- M4: required_types принадлежат слотам, а не порядку вложений ----------
func _test_required_types_stable() -> void:
_set_case("required_types_stable")
_fresh(1)
var folder := _folder_id()
var f_text = _new_file("Слот текст", "text", folder)
var f_sound = _new_file("Слот звук", "sound", folder)
if f_text == null or f_sound == null:
return
var t_id := str(f_text.id)
var s_id := str(f_sound.id)
var req_id := str(_hd._create_request(2, "udp", [t_id, s_id], true))
var req = _hd.active_requests.get(req_id)
if req == null:
return
var expected: Array = req.required_types.duplicate()
_eq(expected, ["text", "sound"], "required_types по слотам при создании")
# Прикрепляем в обратном порядке: раньше это перезаписывало типы слотов.
_ok(_hd.attach_file(req_id, s_id), "прикреплён sound")
_ok(_hd.attach_file(req_id, t_id), "прикреплён text")
_eq(req.required_types, expected,
"M4: attach не перезаписывает required_types порядком вложений")
_ok(_hd.start_send(req_id), "UDP-мини-игра открыта")
_eq(req.required_types, expected,
"M4: start_send не перезаписывает required_types вложенными типами")
_hd.force_close_send_minigame()
_hd._cleanup_request(req_id, true)
# --- M5: во время передачи потеря не подменяется ---------------------------
func _test_no_substitute_while_sending() -> void:
_set_case("no_substitute_while_sending")
_fresh(1)
var folder := _folder_id()
var f1 = _new_file("В пути", "text", folder)
var f2 = _new_file("Резерв замены", "text", folder)
if f1 == null or f2 == null:
return
var fid := str(f1.id)
var req_id := str(_hd._make_request([f1], "udp", false, "", ""))
_ok(_hd.attach_file(req_id, fid), "файл прикреплён")
_ok(_hd.start_send(req_id), "мини-игра открыта")
_hd._on_send_minigame_finished(1.0)
var req = _hd.active_requests.get(req_id)
if req == null:
return
_ok(bool(req.sending), "передача началась")
req.send_duration = 100.0
req.send_progress = 0.0
_game.registry.mark_failed(fid)
for i in range(12):
_hd.tick(0.1)
_eq(_hd.request_required_ids(req_id), [fid],
"M5: во время отправки потерянный required не подменяется")
_ok(not _hd.request_required_ids(req_id).has(str(f2.id)),
"M5: резервный файл не встал в required")
_hd._cleanup_request(req_id, true)
# --- отчёт -----------------------------------------------------------------
@@ -580,10 +996,12 @@ func _test_no_connection_game() -> void:
func _test_report() -> void:
_set_case("report")
_fresh(2)
_eq(_hd.request_cap, 5, "H2: кап запросов из данных = 5")
_eq(_hd.active_request_limit, 2, "H2: 2 активных запроса")
_eq(_hd.request_cap, int(_hour_row(2).get("request_cap", -1)),
"H2: кап запросов из данных")
_eq(_hd.active_request_limit, int(_hour_row(2).get("active_requests", -1)),
"H2: активный лимит из данных")
var file = _new_file("Отчёт", "sound", _folder_id())
var req_id: String = str(_hd.spawn_request_now(1))
var req_id: String = str(_hd._make_request([file], "tcp", false, "", ""))
_ok(_hd.attach_file(req_id, str(file.id)), "файл прикреплён")
_ok(_hd.start_send(req_id), "мини-игра открыта")
_minigame_shows = 0
@@ -604,9 +1022,108 @@ func _test_report() -> void:
_ok(int(_hour_report.get("files_budget", 0)) == 8, "отчёт: files_budget H2 = 8")
# --- подготовка перед H1 ---------------------------------------------------
## reset_for_prep() оставляет seed-файл, но без активных запросов и резерваций;
## старт H1 после этого всё равно выдаёт запрос (starvation guard / bootstrap).
func _test_reset_for_prep() -> void:
_set_case("reset_for_prep")
_game.start_new_run(515)
_game.hour = 1
_game.phase = &"hour"
_hd.reset_carry()
_hd.reset_for_prep()
_eq(_hd.hour, 1, "reset_for_prep: час 1")
_eq(_hd.active_requests.size(), 0, "reset_for_prep: нет активных запросов")
_eq(_hd.active_packets.size(), 0, "reset_for_prep: нет активных пакетов")
_eq(_hd.requests_spawned, 0, "reset_for_prep: requests_spawned = 0")
_ok(not _hd.running, "reset_for_prep: директор не запущен")
_eq(_game.registry.file_count(), 1, "reset_for_prep: seed-файл остался")
var reserved: Array = []
for file in _game.registry.files.values():
if file != null and not str(file.required_by).is_empty():
reserved.append(str(file.id))
_eq(reserved.size(), 0, "reset_for_prep: ни один файл не зарезервирован")
# Старт реального часа: seed уже есть → bootstrap не дублируется,
# запрос создаёт starvation guard на первом же тике.
_hd.start_hour(1)
_eq(_game.registry.file_count(), 1, "старт H1: второй seed не создан")
var spawned_at := -1.0
for i in range(50):
_hd.tick(0.1)
if not _hd.active_requests.is_empty():
spawned_at = float(_hd.hour_elapsed)
break
_eq(_hd.active_requests.size(), 1, "старт H1: запрос появился")
_ok(spawned_at >= 0.0 and spawned_at <= 0.5,
"H1: запрос сразу на старте (t=%.1f)" % spawned_at)
if not _hd.active_requests.is_empty():
var req_id := str(_hd.active_requests.keys()[0])
var ids: Array = _hd.request_required_ids(req_id)
_ok(ids.size() >= 1, "H1: запрос требует файл")
for fid in ids:
var file = _game.registry.files.get(str(fid))
_ok(file != null and str(file.required_by) == req_id,
"H1: required_by снова выставлен")
# --- перемещение зарезервированного файла -----------------------------------
## Решение (BUG 4): зарезервированный файл можно переносить между папками.
## Запрос ищет файл по id, поэтому остаётся выполнимым из любой папки.
func _test_move_required_file() -> void:
_set_case("move_required_file")
_game.start_new_run(616)
_game.hour = 1
_game.phase = &"hour"
_hd.reset_carry()
_hd.reset_for_prep()
var folder_ids: Array = _game.registry.folders.keys()
if folder_ids.size() < 2:
_ok(false, "нужно минимум 2 папки")
return
var from_id := str(folder_ids[0])
var to_id := str(folder_ids[1])
var file = _new_file("Перемещаемый резерв", "text", from_id)
_ok(file != null, "файл создан")
if file == null:
return
var fid := str(file.id)
var req_id := str(_hd._create_request(1, "udp", [fid], true))
_ok(not req_id.is_empty(), "запрос создан")
if req_id.is_empty():
return
_eq(str(file.required_by), req_id, "файл зарезервирован")
_ok(bool(_game.registry.move_file(fid, to_id)),
"move_file зарезервированного файла разрешён")
_eq(str(_game.registry.files[fid].folder_id), to_id,
"файл переехал в другую папку")
_eq(str(_game.registry.files[fid].required_by), req_id,
"резерв сохранился после перемещения")
_ok(_hd.attach_file(req_id, fid), "запрос всё ещё может прикрепить файл")
_ok(_hd.is_request_ready(req_id), "запрос готов к отправке после перемещения")
_hd._cleanup_request(req_id, true)
# --- служебное -------------------------------------------------------------
func _fresh(hour: int) -> void:
## Строка hours[] из data/balance.json (null-safe для --script прогона).
func _hour_row(hour: int) -> Dictionary:
var data := root.get_node_or_null("Data")
if data != null and data.has_method("hour_def"):
return data.hour_def(int(hour))
return {}
func _difficulty_row(hour: int) -> Dictionary:
var data := root.get_node_or_null("Data")
if data != null and data.has_method("difficulty_for"):
return data.difficulty_for(int(hour))
return {}
func _fresh(hour: int, keep_seed: bool = false) -> void:
_game.start_new_run(1000 + int(hour) * 17)
_game.hour = int(hour)
_game.phase = &"hour"
@@ -616,12 +1133,28 @@ func _fresh(hour: int) -> void:
_hd.reset_carry()
_hd.start_hour(int(hour))
_hd.set_time_scale(1.0)
if hour == 1 and not keep_seed:
_drop_bootstrap_seed()
if _hd.attack_director != null:
_hd.attack_director._schedule.clear()
_hd.attack_director._next_index = 0
_hour_events = 0
_hour_report = {}
_minigame_cancels = 0
_toasts.clear()
## Убирает H1-затравку (файл+запрос), чтобы старые кейсы стартовали с пустого склада.
func _drop_bootstrap_seed() -> void:
for req_id in _hd.active_requests.keys().duplicate():
_hd._cleanup_request(str(req_id), true)
for file in _game.registry.files.values().duplicate():
_game.registry.consume_file(str(file.id))
_hd.requests_spawned = 0
_hd._last_request_attempt_t = -_hd.REQUEST_ATTEMPT_MIN_GAP
_hd.spawn_send_accum = 0.0
_hd.spawn_recv_accum = 0.0
_hd._next_spawn_at = 0.0
func _reset(hour: int) -> void:
@@ -677,8 +1210,22 @@ func _on_spawn_frozen(_reason: String) -> void:
_frozen_events += 1
func _on_show_connection(_game_node) -> void:
func _on_show_connection(request_id: String, hour: int, successes: int) -> void:
_connection_shows += 1
_connection_request_id = request_id
_connection_hour = hour
_connection_successes = successes
func _on_toast(text: String) -> void:
_toasts.append(text)
func _has_toast(fragment: String) -> bool:
for text in _toasts:
if str(text).contains(fragment):
return true
return false
# --- assert-хелперы --------------------------------------------------------
+4 -2
View File
@@ -68,7 +68,8 @@ func _test_full_hour() -> void:
_ok(report.has(key), "в отчёте есть %s" % key)
_ok(not is_nan(float(report.get("sla_delta", 0.0))), "sla_delta не NaN")
_ok(not is_nan(float(_game.sla)), "SLA не NaN")
_ok(float(_game.sla) <= 200.0 + 0.0001, "SLA ≤ 200")
_ok(float(_game.sla) <= float(_game.sla_cap()) + 0.0001,
"SLA ≤ кап (%.0f)" % float(_game.sla_cap()))
_eq(_hd.slots.used_count(), 0, "все слоты освобождены после конца часа")
_ok(_hd.active_packets.is_empty(), "после конца часа нет активных пакетов")
_ok(_hd.active_requests.is_empty(), "после конца часа нет активных запросов")
@@ -108,7 +109,8 @@ func _test_nine_hours() -> void:
_ok(report.has(key), "час %d: в отчёте есть %s" % [h, key])
_ok(not is_nan(float(report.get("sla_delta", 0.0))), "час %d: sla_delta не NaN" % h)
_ok(not is_nan(float(_game.sla)), "час %d: SLA не NaN" % h)
_ok(float(_game.sla) <= 200.0 + 0.0001, "час %d: SLA ≤ 200" % h)
_ok(float(_game.sla) <= float(_game.sla_cap()) + 0.0001,
"час %d: SLA ≤ кап (%.0f)" % [h, float(_game.sla_cap())])
_ok(int(_game.money) >= 0, "час %d: money >= 0" % h)
_eq(_attack_marks.size(), expected[h - 1], "час %d: атак по расписанию" % h)
for mark in _attack_marks:
+10 -10
View File
@@ -74,16 +74,14 @@ 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():
# v3: прикреплять можно только конкретный required-файл запроса.
var missing: Array = director.request_missing(rid)
_ok(not missing.is_empty(), "у запроса есть required-файл для прикрепления")
if missing.is_empty():
_finish()
return
_ok(director.attach_file(rid, fid), "файл прикреплён к запросу")
var fid := str(missing[0])
_ok(director.attach_file(rid, fid), "required-файл прикреплён к запросу")
_main._refresh_hour()
await process_frame
await process_frame # даём отработать deferred registry-refresh сетки
@@ -171,7 +169,8 @@ func _slot_attached(rid: String, fid: String) -> bool:
"required_count": hd.request_required_count(rid),
"attached": req.attached,
})
var index: int = (req.attached as Array).find(fid)
var required: Array = hd.request_required_ids(rid)
var index: int = required.find(fid)
if index < 0 or index >= slots.size() or slots[index] == null:
return false
return bool((slots[index] as Dictionary).get("attached", false))
@@ -188,7 +187,8 @@ 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 required: Array = _controller.hour_director.request_required_ids(rid)
var index: int = required.find(fid)
for card in _main.requests:
if card == null or not is_instance_valid(card) or str(card.id) != rid:
continue
+183 -30
View File
@@ -9,6 +9,7 @@ extends SceneTree
## godot --headless --path . --script tests/test_main_hour.gd
const MG := preload("res://scripts/minigames/minigame_base.gd")
const ECONOMY := preload("res://scripts/core/economy.gd")
var _checks := 0
var _fails := 0
@@ -65,6 +66,9 @@ func _init() -> void:
_ok(main.sorting_table != null and not main.sorting_table.visible,
"стол сортировки скрыт в час")
var director = main.controller.hour_director
_ok(director != null, "hour_director доступен")
# --- Стартовые статы H1 --------------------------------------------------
_eq(str(main.status_bar.get_metric("sla").get("value", "")), "60 / 60",
"статус SLA — 60 / 60")
@@ -72,11 +76,10 @@ func _init() -> void:
"статус слотов — 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 доступен")
# H1: первый пакет спавнится в t=0, поэтому буфер сверяем с директором.
_eq(str(main.status_bar.get_metric("buffer").get("value", "")),
"%d / %d" % [director.active_packets.size(), director.buffer_limit()],
"статус буфера — занято/лимит")
# --- Пакет: карточка с фигурой и клетками --------------------------------
var pid: String = director.spawn_packet_now("sound", "tcp")
@@ -92,18 +95,18 @@ func _init() -> void:
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 pcard = _packet_card(main, pid)
_ok(pcard != null, "карточка пакета найдена")
var pkt = director.active_packets.get(pid)
_ok(pkt != null, "пакет есть в директоре")
if pkt != null and pcard != 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,
@@ -118,13 +121,23 @@ func _init() -> void:
_eq(card.required_count, 1, "запросу нужен 1 файл")
_eq(card.attached_count(), 0, "прикреплено 0 файлов")
_ok(card.send_disabled(), "ОТПРАВИТЬ выключена без файлов")
_ok(card.minigame_text().contains("—"), "мини-игра не определена без файлов")
_ok(card.minigame_text().contains("МИНИ-ИГРА"),
"прогноз мини-игры виден до прикрепления: %s" % card.minigame_text())
_ok(not card.minigame_text().contains("—"),
"прогноз считается по типам required-файлов")
var pred_expected: Dictionary = ECONOMY.send_minigame_for(req0.required_types)
_eq(int(card.prediction().get("sum", -1)), int(pred_expected.get("sum", -2)),
"прогноз: сумма коэффициентов")
_eq(str(card.prediction().get("id", "")), str(pred_expected.get("id", "")),
"прогноз: игра из Economy")
_eq(int(card.prediction().get("tier", -1)), int(pred_expected.get("tier", -2)),
"прогноз: ступень из Economy")
_eq(card.protocol, "TCP", "протокол по умолчанию TCP")
# --- Раскладка v2: карточка 108 px, скролл вмещает 4 запроса -------------
# --- Раскладка v3: карточка 400×128, скролл вмещает 4 запроса ------------
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")
_eq(card.size, Vector2(400, 128), "RequestCard v3: 400×128")
_ok(card.position.y + 24.0 <= content.size.y + 0.5,
"карточка %s: заголовок/таймер в клипе" % str(card.name))
main._sync_send_scroll(4)
@@ -137,7 +150,13 @@ func _init() -> void:
main._layout_request_cards(main.requests.size())
# --- attach через drop карточки: без автостарта --------------------------
main._on_file_to_request(rid, 0, {"kind": "file", "id": str(file_ids[0]),
var missing: Array = director.request_missing(rid)
_ok(missing.size() == 1, "у запроса есть конкретный required-файл")
if missing.is_empty():
_finish()
return
var attach_id := str(missing[0])
main._on_file_to_request(rid, 0, {"kind": "file", "id": attach_id,
"from": "folder"})
await process_frame
main._refresh_hour()
@@ -145,13 +164,18 @@ func _init() -> void:
_ok(req != null, "запрос жив после прикрепления")
if req != null:
_eq(req.attached.size(), 1, "файл прикреплён")
_ok(req.attached.has(attach_id), "прикреплён именно required-файл")
_ok(not req.minigame_active, "attach НЕ запускает мини-игру")
_ok(not req.sending, "attach НЕ запускает отправку")
_ok(director.request_missing(rid).is_empty(), "required-файл больше не missing")
_ok(card.can_send(), "ОТПРАВИТЬ доступна после прикрепления")
_ok(not card.send_disabled(), "кнопка ОТПРАВИТЬ включена")
_ok(card.minigame_text().contains("МИНИ-ИГРА"), "подсказка мини-игры: %s"
% card.minigame_text())
_eq(card.difficulty_text(), "СЛОЖНОСТЬ: 1", "сложность текста = 1")
var attached_sum := int(ECONOMY.send_minigame_for(
[game.registry.files[attach_id].type]).get("sum", -1))
_eq(card.difficulty_text(), "СУММА: %d" % attached_sum,
"на карточке сумма коэффициентов")
_eq(card.attached_count(), 1, "карточка: прикреплено 1/1")
# --- TCP/UDP -------------------------------------------------------------
@@ -209,15 +233,29 @@ func _init() -> void:
"перегруженный канал помечен красным")
main._refresh_hour()
# --- Мини-игра отправки через подменную фабрику -------------------------
# --- TCP: кнопка СОЕДИНИТЬСЯ → ConnectionGame → мини-игра отправки ------
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)
_eq(card._send_btn.caption, "СОЕДИНИТЬСЯ",
"TCP без соединения: кнопка СОЕДИНИТЬСЯ")
card._send_btn.pressed.emit()
await process_frame
_ok(director.active_requests[rid].minigame_active, "ОТПРАВИТЬ открывает мини-игру")
var req_live = director.active_requests.get(rid)
_ok(req_live != null and req_live.connecting,
"нажатие запускает установку TCP-соединения")
_ok(main._modal_game != null, "модалка соединения показана")
_eq(str(main._modal_kind), "connection", "показана модалка соединения")
var conn_game = main._modal_game
_ok(conn_game != null, "ConnectionGame создан контроллером")
main._on_connection_finished(true, rid, conn_game)
await process_frame
await process_frame
_ok(req_live != null and req_live.connected, "соединение установлено")
_ok(req_live != null and req_live.minigame_active,
"после соединения открылась мини-игра отправки")
_ok(main._modal_game != null, "модалка мини-игры показана")
_ok(bool(main.controller.modal_open), "controller.modal_open = true на мини-игре")
_fake_game = holder.get("game")
@@ -294,16 +332,123 @@ func _init() -> void:
_ok(main._modal_game == null, "_modal_game снят после ПРОДОЛЖИТЬ")
_ok(not bool(main.controller.modal_open), "modal_open сброшен после ПРОДОЛЖИТЬ")
# --- M8: spawn_frozen подключён к UI и троттлится ----------------------
_ok(director.spawn_frozen.get_connections().size() > 0,
"M8: spawn_frozen подключён к тосту UI")
main._spawn_frozen_toast_ms = -100000
main._on_spawn_frozen_ui("buffer_full")
var frozen_ms := int(main._spawn_frozen_toast_ms)
main._on_spawn_frozen_ui("buffer_full")
_eq(int(main._spawn_frozen_toast_ms), frozen_ms,
"M8: повторный spawn_frozen в пределах 5 с не спамит")
# --- M2: шаг обучения «send» засчитывается на пути закрытия мини-игры ---
main._tutorial_reset()
main._tutorial_active = true
main._tutorial_shown = true
main._tutorial_step = 3 # «send» в TUTORIAL_STEPS
main._tutorial_step_elapsed = 0.0
main._tutorial_ack_left = 0.0
var tut_mg = MG.new()
tut_mg.start("dots", 1, 777, {})
_ok(main._show_modal_game(tut_mg, "send"), "M2: send-модалка открыта")
main._on_minigame_closed_ui("tut_req")
_eq(main._tutorial_step, 4,
"M2: шаг send завершён через _on_minigame_closed_ui")
main._on_minigame_closed_ui("tut_req")
_eq(main._tutorial_step, 4, "M2: повторный close не двигает обучение дважды")
main._on_modal_game_finished(null, tut_mg)
_eq(main._tutorial_step, 4, "M2: finished не дублирует уведомление send")
tut_mg.queue_free()
main._tutorial_reset()
main._modal_game = null
main._modal_kind = ""
main.controller.set_modal_open(false)
await process_frame
# --- C1: ввод под модалкой не открывает вторую мини-игру -----------------
var guard_modal := Node.new()
_ok(main._show_modal_game(guard_modal, "attack"),
"C1: тестовая модалка атаки открыта")
var guard_file = game.registry.create_file("guard_modal", "text", folder_id)
var guard_rid := ""
if guard_file != null:
guard_rid = str(director._create_request(
1, "udp", [str(guard_file.id)], true))
_ok(not guard_rid.is_empty(), "C1: тестовый UDP-запрос создан")
_ok(director.attach_file(guard_rid, str(guard_file.id)),
"C1: файл тестового запроса прикреплён")
main._on_request_send(guard_rid)
await process_frame
var guard_req = director.active_requests.get(guard_rid)
_ok(main._modal_game == guard_modal,
"C1: чужая (атакующая) модалка не освобождена")
_ok(director._minigame == null,
"C1: вторая мини-игра за модалкой не создана")
_ok(guard_req != null and not bool(guard_req.minigame_active),
"C1: minigame_active у заблокированного запроса не выставлен")
# Belt-and-braces: даже если директор всё же эмитит show_minigame,
# Main отказывает и откатывает мини-игру, не трогая чужую модалку.
_ok(director._launch_send_minigame(guard_rid),
"C1: директор принудительно эмитил мини-игру")
await process_frame
_ok(main._modal_game == guard_modal,
"C1: откат send не освободил чужую модалку")
_ok(director._minigame == null,
"C1: send-мини-игра откатана force_close_send_minigame")
_ok(guard_req != null and not bool(guard_req.minigame_active),
"C1: minigame_active снят откатом send")
# То же для соединения: эмитим show_connection в обход UI-гарда.
var conn_file = game.registry.create_file("guard_conn", "text", folder_id)
if conn_file != null:
var conn_rid := str(director._create_request(
1, "tcp", [str(conn_file.id)], false))
_ok(director.attach_file(conn_rid, str(conn_file.id)),
"C1: файл для отката соединения прикреплён")
var conn_req = director.active_requests.get(conn_rid)
director._begin_connection(conn_req)
await process_frame
_ok(main._modal_game == guard_modal,
"C1: откат connection не освободил чужую модалку")
_ok(conn_req != null and not bool(conn_req.connecting),
"C1: connecting снят connection_finished(false)")
director._cleanup_request(conn_rid, true)
director._cleanup_request(guard_rid, true)
guard_modal.queue_free()
await process_frame
main._clear_modal_if_dead()
main.controller.set_modal_open(false)
main._refresh_hour()
# --- H1: detach по required-индексу, а не по порядку вложений -----------
var det_a = game.registry.create_file("detach_a", "text", folder_id)
var det_b = game.registry.create_file("detach_b", "sound", folder_id)
if det_a != null and det_b != null:
var detach_rid := str(director._create_request(
2, "udp", [str(det_a.id), str(det_b.id)], true))
var detach_req = director.active_requests.get(detach_rid)
_ok(detach_req != null, "H1: запрос на 2 required создан")
# Прикрепляем ВТОРОЙ required первым: attached != required по порядку.
_ok(director.attach_file(detach_rid, str(det_b.id)),
"H1: второй required прикреплён первым")
_eq(detach_req.attached, [str(det_b.id)],
"H1: attached хранится в порядке вложения")
main._on_request_detach(detach_rid, 1)
await process_frame
_ok(detach_req != null and not detach_req.attached.has(str(det_b.id)),
"H1: X по required-индексу 1 снял второй файл")
_eq(detach_req.attached.size(), 0, "H1: attached пуст после detach")
director._cleanup_request(detach_rid, true)
# --- Конец часа при открытой мини-игре: не оставляем висячий _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)
break
var missing3: Array = director.request_missing(rid3) if not rid3.is_empty() else []
var leftover_id := str(missing3[0]) if not missing3.is_empty() else ""
if not rid3.is_empty() and not leftover_id.is_empty():
_ok(director.attach_file(rid3, leftover_id), "файл прикреплён к третьему запросу")
_ok(director.set_request_protocol(rid3, "udp"),
"третий запрос переведён на UDP (без соединения)")
var holder2 := {"game": null}
director.send_game_factory = func(_id, _tier, _seed, _ctx):
var g := FakeGame.new()
@@ -371,6 +516,14 @@ func _init() -> void:
_finish()
## Карточка пакета по id (в ПРИЁМЕ могут быть и автоспавненные пакеты).
func _packet_card(main, packet_id: String):
for c in main.packets:
if c != null and is_instance_valid(c) and str(c.id) == packet_id:
return c
return null
## Карточка запроса по id (живая, видимая).
func _request_card(main, req_id: String):
for c in main.requests:
+75 -22
View File
@@ -75,6 +75,7 @@ func _init() -> void:
await _test_reject()
_test_protocol_toggle()
_test_prediction()
await _test_four_slots()
await process_frame
print("")
@@ -130,14 +131,15 @@ func _test_attach_and_explicit_send() -> void:
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():
var missing: Array = _director.request_missing(rid)
_ok(missing.size() >= 1, "есть конкретный required-файл для attach")
if missing.is_empty():
return
var fid := str(missing[0])
_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(req.attached.has(fid), "req.attached содержит required-файл")
_ok(not req.sending, "attach НЕ запускает отправку")
_ok(not req.minigame_active, "attach НЕ открывает мини-игру")
_ok(_main._modal_game == null, "после attach модалки нет")
@@ -148,11 +150,14 @@ func _test_attach_and_explicit_send() -> void:
_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(), "кнопка ОТПРАВИТЬ включена")
_ok(card.get_node_or_null("ConnectButton") == null,
"у запроса нет legacy-кнопки ConnectButton")
_ok(card.can_send(), "кнопка доступна после attach")
_ok(not card.send_disabled(), "кнопка включена после attach")
_eq(card._send_btn.caption, "СОЕДИНИТЬСЯ",
"TCP без соединения: кнопка СОЕДИНИТЬСЯ")
# Подменная мини-игра: проверяем реальный путь show_minigame → modal_root.
# Подменная мини-игра: реальный путь connection → show_minigame → modal_root.
var holder := {"game": null}
_director.send_game_factory = func(_id, _tier, _seed, _ctx):
var g := FakeGame.new()
@@ -160,11 +165,20 @@ func _test_attach_and_explicit_send() -> void:
return g
card._send_btn.pressed.emit()
await process_frame
_ok(req.connecting, "нажатие запускает TCP-соединение")
_ok(_main._modal_game != null and str(_main._modal_kind) == "connection",
"показана модалка соединения")
var conn_game = _main._modal_game
_main._on_connection_finished(true, rid, conn_game)
await process_frame
await process_frame
_fake_game = holder.get("game")
_ok(req.connected, "соединение установлено")
_ok(_fake_game != null, "мини-игра отправки создана")
_ok(req.minigame_active, "ОТПРАВИТЬ включает мини-игру")
_ok(req.minigame_active, "после соединения включается мини-игра")
_ok(_main._modal_game == _fake_game, "Main запомнил модалку мини-игры")
_ok(_fake_game.get_parent() == _main.modal_root, "мини-игра лежит в modal_root")
if _fake_game != null:
_ok(_fake_game.get_parent() == _main.modal_root, "мини-игра лежит в modal_root")
_ok(bool(_controller.modal_open), "controller.modal_open = true на мини-игре")
# Пока мини-игра открыта, таймер запроса не идёт.
@@ -209,11 +223,14 @@ func _test_queue_pause_and_timeout() -> void:
_ok(req != null, "запрос для очереди создан")
if req == null:
return
var fid := _available_file()
_ok(not fid.is_empty(), "есть файл для запроса в очередь")
if fid.is_empty():
var missing_q: Array = _director.request_missing(rid)
_ok(missing_q.size() >= 1, "есть required-файл для запроса в очередь")
if missing_q.is_empty():
return
var fid := str(missing_q[0])
_ok(_director.attach_file(rid, fid), "файл прикреплён к запросу очереди")
_ok(_director.set_request_protocol(rid, "udp"),
"запрос очереди переведён на UDP")
var holder := {"game": null}
_director.send_game_factory = func(_id, _tier, _seed, _ctx):
@@ -272,9 +289,10 @@ func _test_reject() -> void:
_ok(req != null, "запрос для отказа создан")
if req == null:
return
var fid := _available_file()
var missing_r: Array = _director.request_missing(rid)
var fid := str(missing_r[0]) if not missing_r.is_empty() else ""
if not fid.is_empty():
_ok(_director.attach_file(rid, fid), "файл прикреплён перед отказом")
_ok(_director.attach_file(rid, fid), "required-файл прикреплён перед отказом")
var money_before := int(_game.money)
var sla_before := float(_game.sla)
_main._on_request_reject(rid)
@@ -299,15 +317,16 @@ func _test_protocol_toggle() -> void:
_director.reject_request(rid)
## Предсказание RequestCard совпадает с реальным выбором Economy (B4) для
## всех ключевых сумм, включая S=8 (dots/T2) и S=24 (video/T4).
## Предсказание RequestCard совпадает с реальным выбором Economy: сумма
## коэффициентов 1/2/4/8, 27 достижимых сумм, 3 ступени, без «wires».
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},
{"types": ["text"], "sum": 1, "id": "text", "tier": 1},
{"types": ["text", "sound"], "sum": 3, "id": "dots", "tier": 1},
{"types": ["photo"], "sum": 4, "id": "photo", "tier": 1},
{"types": ["video"], "sum": 8, "id": "video", "tier": 1},
{"types": ["video", "video", "video"], "sum": 24, "id": "dots", "tier": 3},
{"types": ["video", "video", "video", "video"], "sum": 32, "id": "video", "tier": 3},
]
for entry in cases:
var tc: Dictionary = entry
@@ -329,9 +348,43 @@ func _test_prediction() -> void:
"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"]))
_ok(str(p.get("id", "")) != "wires", "S=%d: wires не выбирается" % int(tc["sum"]))
card.free()
## v3: карточка держит контейнер до 4 обязательных файлов и сумму 4 text.
func _test_four_slots() -> void:
var ids: Array = []
for i in 4:
var f = _game.registry.create_file("слот_%d" % i, "text", _folder_id)
if f != null:
ids.append(str(f.id))
_eq(ids.size(), 4, "созданы 4 файла для контейнера")
if ids.size() < 4:
return
var rid := str(_director._create_request(4, "tcp", ids, true))
_ok(not rid.is_empty(), "запрос на 4 файла создан")
_main._refresh_hour()
await process_frame
var card = _request_card(rid)
_ok(card != null, "карточка 4-слотового запроса найдена")
if card == null:
_director._cleanup_request(rid, true)
return
_eq(card.required_count, 4, "контейнер на 4 обязательных файла")
_eq(card.slots.size(), 4, "в карточке 4 слота")
var expected: Dictionary = ECONOMY.send_minigame_for(["text", "text", "text", "text"])
_eq(int(card.prediction().get("sum", -1)), int(expected.get("sum", -2)),
"сумма 4 text = 4")
_eq(str(card.prediction().get("id", "")), str(expected.get("id", "")),
"игра для S=4 из Economy")
_eq(int(card.prediction().get("tier", -1)), int(expected.get("tier", -2)),
"ступень для S=4 из Economy")
_eq(card.difficulty_text(), "СУММА: %d" % int(expected.get("sum", 0)),
"подпись суммы на карточке")
_director._cleanup_request(rid, true)
# --- хелперы ----------------------------------------------------------------
func _tick(count: int) -> void:
+5
View File
@@ -110,6 +110,11 @@ func _test_receive_scroll() -> void:
_ok(director != null, "hour_director доступен")
if director == null:
return
# H1-затравка: первый пакет спавнится в t=0, поэтому очищаем буфер,
# чтобы проверка «ровно 4 пакета» не зависела от порядка кадров.
for pid in director.active_packets.keys().duplicate():
director._remove_packet(str(pid))
director.files_spawned = director.files_budget
# 4 пакета (базовый буфер 6) — всё влезает, скроллбар скрыт.
for i in 4:
+51 -23
View File
@@ -60,10 +60,12 @@ func _init() -> void:
_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)
var footer_files := int(grid_stats.get("files", 0))
_eq(str(main.grid.footer_text()),
"%d %s · 20 клеток" % [footer_files, _plural_files(footer_files)],
"подвал сетки: файлы и клетки")
_ok(main.files_window.title_hint.is_empty(),
"подвал ФАЙЛЫ без подписи занятости: %s" % main.files_window.title_hint)
# --- Статус-бар ----------------------------------------------------------
_eq(str(main.status_bar.get_metric("money").get("value", "")), "$ 50",
@@ -105,7 +107,8 @@ func _init() -> void:
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_count, int(grid_stats.get("occupied", 0)),
"дерево: папка показывает занятые клетки (H1-затравка)")
_eq(folder_row_capacity, 20, "дерево: вместимость папки 20 клеток")
# --- Плюрализация заголовка ДИСКИ ---------------------------------------
@@ -172,8 +175,8 @@ func _test_registry_autorefresh(main, game) -> void:
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"
_ok(main.files_window.title_hint.is_empty(),
"подвал ФАЙЛЫ не дублирует занятость: %s"
% main.files_window.title_hint)
var count_ok := false
for e in main.tree.get("_rows"):
@@ -240,12 +243,14 @@ func _test_search(main) -> void:
## Имена новых папок генерируются и остаются уникальными в секции.
## D2 (H5): папки №3–6 стоят `folder_new_cost` (100 $), №7 упирается в
## v3: папки №3–6 стоят `folder_new_cost` (180 $), №7 упирается в
## `folders_max` (6); без денег новая папка не создаётся.
func _test_folder_name_generation(main, game) -> void:
_eq(main._unique_folder_name("C", "Разное"), "Разное 2",
"занятое имя получает суффикс 2")
var cost := int(_const_value("folder_new_cost", 180))
var folders_max := int(_const_value("folders_max", 6))
var before: int = game.registry.folder_count()
_eq(before, 2, "на старте ровно 2 папки")
# Без денег третья папка не создаётся.
@@ -254,31 +259,36 @@ func _test_folder_name_generation(main, game) -> void:
_eq(game.registry.folder_count(), before, "без денег папка не создаётся")
_eq(int(game.money), 0, "неудачная покупка не уводит деньги в минус")
# 3-я папка: ровно 100 $ и списание.
game.add_money(100)
# 3-я папка: ровно folder_new_cost и списание.
game.add_money(cost)
main._new_folder_in("C")
_eq(game.registry.folder_count(), before + 1, "3-я папка создана за 100 $")
_eq(int(game.money), 0, "3-я папка списала 100 $")
_eq(game.registry.folder_count(), before + 1,
"3-я папка создана за %d $" % cost)
_eq(int(game.money), 0, "3-я папка списала %d $" % cost)
var third_id := str(main.grid.folder_id)
_ok(game.registry.rename_folder(third_id, "Разное 2"),
"третья папка переименована для проверки имён")
_eq(main._unique_folder_name("C", "Разное"), "Разное 3",
"следующий дубль получает суффикс 3")
# 4-я, 5-я, 6-я папки — тоже по 100 $.
for expected in [4, 5, 6]:
game.add_money(100)
# 4-я … folders_max-я папки — тоже по folder_new_cost.
var expected_count := before + 2
while expected_count <= folders_max:
game.add_money(cost)
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 папок")
_eq(game.registry.folder_count(), expected_count,
"%d-я папка создана за %d $" % [expected_count, cost])
_eq(int(game.money), 0, "%d-я папка списала %d $" % [expected_count, cost])
expected_count += 1
_ok(game.registry.folder_count() <= folders_max,
"всего не больше %d папок" % folders_max)
# 7-я папка запрещена лимитом, деньги не списываются.
game.add_money(100)
# Следующая папка запрещена лимитом, деньги не списываются.
game.add_money(cost)
main._new_folder_in("C")
_eq(game.registry.folder_count(), 6, "7-я папка заблокирована лимитом")
_eq(int(game.money), 100, "заблокированная 7-я папка не списала деньги")
_eq(game.registry.folder_count(), folders_max,
"%d-я папка заблокирована лимитом" % (folders_max + 1))
_eq(int(game.money), cost, "заблокированная папка не списала деньги")
main._refresh_prep()
@@ -445,6 +455,24 @@ func _test_shop_after_hour(main, game) -> void:
main._shop_btn.visible = true
## Плюрализация «файл/файла/файлов» — как в FileGrid.
func _plural_files(n: int) -> String:
var n10 := n % 10
var n100 := n % 100
if n10 == 1 and n100 != 11:
return "файл"
if n10 >= 2 and n10 <= 4 and (n100 < 12 or n100 > 14):
return "файла"
return "файлов"
func _const_value(key: String, fallback = null):
var data := root.get_node_or_null("Data")
if data != null and data.has_method("const_value"):
return data.const_value(key, fallback)
return fallback
func _finish() -> void:
if _fails == 0:
print("PASS")
+142 -39
View File
@@ -4,6 +4,8 @@ extends SceneTree
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")
const CONNECTION := preload("res://scripts/minigames/connection_game.gd")
var _checks: int = 0
var _fails: int = 0
@@ -22,6 +24,7 @@ func _init() -> void:
_test_selection()
_test_attack_placeholder()
_test_no_timers()
_test_connection_grace()
_finish()
@@ -42,6 +45,7 @@ func _test_base_contract() -> void:
_ok(mg.visible, "окно видимо")
_eq(str(mg.context.get("request_id", "")), "req_1", "context сохранён")
_approx(mg.quality(), 0.0, 0.0001, "quality стартует с 0")
_eq(str(mg.tier_label()), "СЛОЖНОСТЬ 2/3", "tier_label отправки 2/3")
# Два одинаковых сида дают одинаковую последовательность локального RNG.
var a = MG.new()
@@ -106,7 +110,7 @@ func _test_base_skip_and_esc() -> void:
_finish_count = 0
_last_q = -1.0
mg.finished.connect(_on_finished)
mg.start("wires", 1, 3)
mg.start("sound", 1, 3)
mg._on_skip_clicked()
_ok(mg._skip_armed, "первый клик только взводит пропуск")
_ok(not mg.is_finished(), "первый клик не завершает")
@@ -137,7 +141,7 @@ func _test_verdicts() -> void:
func _test_factory_send() -> void:
_set_case("factory_send")
_eq(FACTORY.send_ids().size(), 7, "зарегистрировано 7 игр")
_eq(FACTORY.send_ids().size(), 6, "зарегистрировано 6 игр")
for id in FACTORY.send_ids():
var game = FACTORY.create_send(str(id), 1, 123)
_ok(game != null, "create_send(%s) не null" % str(id))
@@ -150,11 +154,12 @@ func _test_factory_send() -> void:
_ok(game.visible, "игра показана после start")
game.free()
for id in ["dots", "sound", "wires", "text", "video", "photo", "maze"]:
for id in ["dots", "sound", "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)
_eq(hard.tier, 3, "tier 4 клампится в 3 у %s" % id)
_eq(str(hard.tier_label()), "СЛОЖНОСТЬ 3/3", "tier_label отправки %s" % id)
hard.free()
_ok(FACTORY.create_send("nope", 1, 1) == null, "неизвестная игра → null")
@@ -164,52 +169,107 @@ func _test_factory_send() -> void:
_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")
_eq(FACTORY.tier_for_hour(8), 3, "tier_for_hour 8 → 3")
_eq(FACTORY.tier_for_hour(9), 3, "tier_for_hour 9 → 3")
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 data = _data()
var selection: Dictionary = {}
if data != null and data.has_method("minigame_selection"):
selection = data.minigame_selection()
var sums: Array = selection.get("achievable_sums", ECONOMY.achievable_sums())
var rank_games: Array = selection.get("rank_games", ECONOMY.RANK_GAMES)
var tier_max: Array = selection.get("tier_max_rank", ECONOMY.TIER_MAX_RANK)
var coeff: Dictionary = selection.get("coeff", ECONOMY.MINIGAME_COEFF)
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")
# Схема §6: ровно 27 достижимых сумм коэффициентов 1/2/4/8 для 1–4 файлов.
_eq(ECONOMY.achievable_sums(),
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18,
19, 20, 21, 22, 24, 25, 26, 28, 32],
"27 достижимых сумм [1..22,24,25,26,28,32]")
_eq(sums.size(), 27, "в данных 27 сумм")
_eq(_ints(ECONOMY.achievable_sums()), _ints(sums),
"Economy и Data согласованы по суммам")
_eq(str(ECONOMY.RANK_GAMES), str(rank_games), "Economy и Data согласованы по rank_games")
_eq(_ints(ECONOMY.TIER_MAX_RANK), _ints(tier_max),
"Economy и Data согласованы по границам ступеней")
for type_id in ["text", "sound", "photo", "video"]:
_eq(int(coeff.get(type_id, -1)), int(ECONOMY.MINIGAME_COEFF[type_id]),
"коэффициент %s = %d" % [type_id, int(ECONOMY.MINIGAME_COEFF[type_id])])
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")
# Суммы действительно достижимы 1–4 файлами (и других нет).
var coeffs: Array = [1, 2, 4, 8]
var reachable: Dictionary = {}
for a in coeffs:
reachable[a] = true
for b in coeffs:
if b < a:
continue
reachable[a + b] = true
for c in coeffs:
if c < b:
continue
reachable[a + b + c] = true
for d in coeffs:
if d < c:
continue
reachable[a + b + c + d] = true
var reachable_sorted: Array = reachable.keys()
reachable_sorted.sort()
_eq(_ints(reachable_sorted), _ints(sums),
"список сумм = все суммы 1–4 коэффициентов")
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")
# rank → игра и ступень: 0–7 → T1, 8–13 → T2, 14–26 → T3.
for idx in range(sums.size()):
var total := int(sums[idx])
var pick: Dictionary = FACTORY.selection_for_sum(total)
_eq(int(pick.get("index", -1)), idx, "S=%d: rank %d" % [total, idx])
_eq(int(pick.get("sum", -1)), total, "S=%d: сумма сохранена" % total)
_eq(str(pick.get("id", "")), str(rank_games[idx]),
"S=%d: игра из GAME_BY_RANK[%d]" % [total, idx])
var expected_tier := 1
if idx > int(tier_max[1]):
expected_tier = 3
elif idx > int(tier_max[0]):
expected_tier = 2
_eq(int(pick.get("tier", 0)), expected_tier,
"S=%d: ступень %d" % [total, expected_tier])
_ok(FACTORY.send_ids().has(str(pick.get("id", ""))),
"S=%d: игра зарегистрирована" % total)
_ok(str(pick.get("id", "")) != "wires", "S=%d: wires не выбирается" % total)
var photo := FACTORY.send_for_types(["photo"])
_eq(str(photo.get("id", "")), "text", "S=4 → text")
# Коэффициенты типов дают ту же сумму/игру, что и прямой выбор по rank.
var combos := [
["text"], ["sound"], ["photo"], ["video"],
["text", "sound"], ["text", "text"], ["sound", "sound"],
["video", "video"], ["video", "video", "video"],
["video", "video", "video", "video"],
]
for types in combos:
var total := 0
for t in types:
total += int(coeff.get(str(t), 1))
var direct: Dictionary = FACTORY.selection_for_sum(total)
var pick: Dictionary = FACTORY.send_for_types(types)
_eq(str(pick.get("id", "")), str(direct.get("id", "")),
"%s: игра совпадает с выбором по сумме %d" % [str(types), total])
_eq(int(pick.get("tier", 0)), int(direct.get("tier", 0)),
"%s: ступень совпадает с суммой %d" % [str(types), total])
_eq(int(pick.get("sum", -1)), total, "%s: sum=%d" % [str(types), total])
_ok(str(pick.get("id", "")) != "wires", "%s: не wires" % str(types))
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)
# S=0 клампится в dots/T1, сверх максимума — последний rank.
var empty: Dictionary = 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")
_eq(int(empty.get("index", -1)), 0, "S=0 → rank 0")
var cap: Dictionary = FACTORY.selection_for_sum(99)
_eq(int(cap.get("index", -1)), sums.size() - 1, "idx клампится в последний rank")
_eq(int(cap.get("tier", 0)), 3, "последний rank → T3")
_eq(str(cap.get("id", "")), str(rank_games[rank_games.size() - 1]),
"последний rank → игра из таблицы")
func _test_attack_placeholder() -> void:
@@ -219,6 +279,7 @@ func _test_attack_placeholder() -> void:
_ok(true, "атакующий скрипт ещё не добавлен — null допустим")
else:
_ok(attack is MiniGame, "существующая атака — MiniGame")
_eq(str(attack.tier_label()), "СЛОЖНОСТЬ 1/4", "у атаки 4 ступени")
attack.free()
var missing = FACTORY.create_attack("not_a_real_attack", 1, 7)
_ok(missing == null, "неизвестная атака → null")
@@ -238,6 +299,36 @@ func _test_no_timers() -> void:
game.free()
func _test_connection_grace() -> void:
_set_case("connection_grace")
var game = CONNECTION.new()
root.add_child(game)
game.start(1, 0, 0, "req")
_ok(game._input_grace > 0.0, "start() взводит grace")
_ok(not game._armed, "до истечения grace попытки запрещены")
# Клик, который открыл модалку (и его release), не должен быть попыткой.
var press := InputEventMouseButton.new()
press.button_index = MOUSE_BUTTON_LEFT
press.pressed = true
press.position = game.global_position + Vector2(320.0, 140.0)
game._input(press)
_ok(not game.missed, "клик в grace не даёт промах")
_approx(game.flash_left, 0.0, 0.0001, "клик в grace не включает красную вспышку")
_eq(game.active_index, 0, "клик в grace не засчитывает попадание")
_ok(not game._emitted, "клик в grace не завершает игру")
# После grace палочка уходит из зелёной зоны — обычный клик штрафует.
game._process(0.4)
_ok(game._input_grace <= 0.0, "grace истекает по _process")
_ok(game._armed, "после grace попытки разрешены")
_ok(game.stick_positions[0] < game.zone_start_x(), "палочка вне зоны")
game._input(press)
_ok(game.missed, "клик вне зоны после grace = промах")
_ok(game.flash_left > 0.0, "промах включает красную вспышку")
game.free()
func _count_timers(node: Node) -> int:
var count := 0
for child in node.get_children():
@@ -249,6 +340,18 @@ func _count_timers(node: Node) -> int:
# --- assert-хелперы --------------------------------------------------------
func _data():
return root.get_node_or_null("Data")
## JSON парсит числа как float — нормализуем к int для сравнения таблиц.
func _ints(values: Array) -> Array:
var out: Array = []
for value in values:
out.append(int(value))
return out
func _set_case(name: String) -> void:
_case = name
+103 -118
View File
@@ -1,10 +1,9 @@
extends SceneTree
## Headless-юнит-тесты чистых функций семи игр отправки.
## 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")
@@ -18,8 +17,8 @@ var _case: String = ""
func _init() -> void:
await process_frame
_test_dots()
_test_dots_drag()
_test_sound()
_test_wires()
_test_text()
_test_video()
_test_photo()
@@ -32,12 +31,11 @@ func _init() -> void:
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},
1: {"cols": 4, "rows": 3, "obstacles": 2},
2: {"cols": 4, "rows": 4, "obstacles": 4},
3: {"cols": 5, "rows": 4, "obstacles": 6},
}
for tier in range(1, 5):
for tier in range(1, 4):
var spec: Dictionary = expected[tier]
var p := DOTS.tier_params(tier)
_eq(int(p["cols"]), int(spec["cols"]), "T%d cols" % tier)
@@ -84,15 +82,82 @@ func _test_dots() -> void:
_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")
_eq(int(DOTS.tier_params(4)["cols"]), int(DOTS.tier_params(3)["cols"]),
"dots tier 4 клампится в 3")
# --- dots: ввод ------------------------------------------------------------
func _test_dots_drag() -> void:
_set_case("dots_drag")
var mg = DOTS.new()
mg.start("dots", 1, 12345)
_ok(mg.board.has("path"), "dots drag: доска сгенерирована")
var path: Array = mg.board["path"]
var start := int(path[0])
mg._on_pointer_pressed(mg._cell_center(start), null)
_eq(mg._path.size(), 1, "press на «С» начинает линию")
_ok(mg._dragging, "press включает drag")
mg._on_pointer_released(mg._cell_center(int(path[1])), null)
_ok(not mg._dragging, "release выключает drag")
# Клик-по-клику продолжает работать.
mg._on_pointer_pressed(mg._cell_center(int(path[1])), null)
_eq(mg._path.size(), 2, "клик по соседней клетке продлевает линию")
# Клик по предыдущей клетке — откат.
mg._on_pointer_pressed(mg._cell_center(start), null)
_eq(mg._path.size(), 1, "клик по предыдущей клетке откатывает")
_eq(mg._undos, 1, "откат учтён один раз")
# Дальше ведём drag по всем клеткам пути.
for i in range(1, path.size()):
mg._on_pointer_moved(mg._cell_center(int(path[i])), null)
_ok(mg.is_finished(), "drag по всему пути завершает игру")
_approx(mg.quality(), 0.96, 0.0001, "1 откат без ошибок = 0.96")
mg.free()
# Быстрый проход: один motion сразу на две клетки вперёд по прямой.
var fast = DOTS.new()
var found := false
for seed in range(1, 61):
fast.start("dots", 1, seed)
var fp: Array = fast.board["path"]
if fp.size() < 3:
continue
var a: Vector2 = fast._cell_center(int(fp[0]))
var b: Vector2 = fast._cell_center(int(fp[1]))
var c: Vector2 = fast._cell_center(int(fp[2]))
if (b - a).is_equal_approx(c - b):
found = true
break
if not found:
_ok(true, "dots drag: прямой префикс не найден — проверка пропущена")
fast.free()
return
var fpath: Array = fast.board["path"]
var blocked: Array = fast.board.get("blocked", [])
fast._on_pointer_pressed(fast._cell_center(int(fpath[0])), null)
fast._on_pointer_moved(fast._cell_center(int(fpath[2])), null)
_ok(fast._path.size() >= 3,
"быстрый motion через клетку заполняет обе клетки (%d)" % fast._path.size())
# Движение над стеной/недопустимой клеткой не считается ошибкой.
var before_invalid: int = fast._invalid
if not blocked.is_empty():
fast._on_pointer_moved(fast._cell_center(int(blocked[0])), null)
_eq(fast._invalid, before_invalid, "motion по стене не увеличивает invalid")
# А осознанный клик по стене — считается.
fast._on_pointer_pressed(fast._cell_center(int(blocked[0])), null)
_eq(fast._invalid, before_invalid + 1, "клик по стене — реальная ошибка")
fast._on_pointer_released(Vector2.ZERO, null)
_ok(not fast._dragging, "release после drag сбрасывает флаг")
fast.free()
# --- 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):
var expected_counts := [4, 5, 6]
var expected_tol := [0.11, 0.08, 0.06]
for tier in range(1, 4):
for seed in [1, 7, 42, 99, 1234]:
var data := SOUND.generate(tier, seed)
var count := int(data["count"])
@@ -114,14 +179,14 @@ func _test_sound() -> void:
_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 Гц")
if tier == 1:
_approx(float(drift["drift_amp"]), 0.0, 0.0001, "sound T1 без дрейфа")
elif tier == 2:
_approx(float(drift["drift_amp"]), 0.02, 0.0001, "sound T2 дрейф 2%")
_approx(float(drift["drift_hz"]), 0.15, 0.0001, "sound T2 дрейф 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(float(drift["drift_amp"]), 0.03, 0.0001, "sound T3 дрейф 3%")
_approx(float(drift["drift_hz"]), 0.25, 0.0001, "sound T3 дрейф 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")
@@ -135,94 +200,16 @@ func _test_sound() -> void:
"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},
1: {"words": 15, "aliens": 2, "lines": 2},
2: {"words": 19, "aliens": 3, "lines": 3},
3: {"words": 24, "aliens": 4, "lines": 3},
}
for tier in range(1, 5):
for tier in range(1, 4):
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"]),
@@ -256,13 +243,12 @@ func _test_text() -> void:
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},
1: {"cards": 4, "slots": 4},
2: {"cards": 5, "slots": 4},
3: {"cards": 6, "slots": 5},
}
var seen_timecodes := {}
for tier in range(1, 5):
for tier in range(1, 4):
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"]),
@@ -282,7 +268,7 @@ func _test_video() -> void:
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:
if tier == 1:
_eq(decoys, 0, "video T%d без брака" % tier)
_eq(mirrored, 0, "video T%d без зеркала" % tier)
else:
@@ -295,8 +281,8 @@ func _test_video() -> void:
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 зеркальный кадр")
if tier == 3:
_eq(mirrored, 1, "video T3 ровно 1 зеркальный кадр")
else:
_eq(mirrored, 0, "video T%d без зеркала" % tier)
var tc_key := "|".join(data.get("slot_timecodes", []))
@@ -314,11 +300,11 @@ func _test_video() -> void:
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 speeds := [140.0, 200.0, 260.0]
var frame_w := [180.0, 150.0, 120.0]
var frame_h := [120.0, 100.0, 80.0]
var shots := [4, 5, 6]
for tier in range(1, 4):
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)
@@ -359,12 +345,11 @@ func _test_photo() -> void:
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},
1: {"cols": 11, "rows": 4, "field_w": 440.0, "field_h": 160.0, "corridor": 24.0, "speed": 470.0},
2: {"cols": 13, "rows": 4, "field_w": 494.0, "field_h": 152.0, "corridor": 20.0, "speed": 520.0},
3: {"cols": 15, "rows": 5, "field_w": 520.0, "field_h": 180.0, "corridor": 18.0, "speed": 560.0},
}
for tier in range(1, 5):
for tier in range(1, 4):
var p := MAZE.tier_params(tier)
var spec: Dictionary = expected[tier]
for key in ["cols", "rows"]:
+30 -26
View File
@@ -11,8 +11,8 @@ const IDS := [
"lot_money", "lot_folder", "lot_channel", "lot_timer",
]
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,
"lot_slots": 90, "lot_speed": 55, "lot_sla": 80, "lot_shield": 100,
"lot_money": 70, "lot_folder": 50, "lot_channel": 45, "lot_timer": 45,
}
const MAX_LEVELS := {
"lot_slots": 4, "lot_speed": 6, "lot_sla": 4, "lot_shield": 3,
@@ -64,7 +64,7 @@ func _test_catalog() -> void:
_eq(shop.level(id), 0, "начальный уровень %s" % id)
for p in item.get("prices", []):
total += int(p)
_eq(total, 13100, "сумма цен всех лотов = 13 100")
_eq(total, 8085, "сумма цен всех лотов = 8 085")
_ok(shop.get_item("scanner").is_empty(), "сканера нет")
_eq(shop.max_level("нет_товара"), 0, "неизвестный лот → max_level 0")
_eq(shop.price("нет_товара"), 0, "неизвестный лот → price 0")
@@ -145,18 +145,22 @@ func _test_affordable() -> void:
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)
_game.money = 49
shop.refresh(2, 49, true)
var ofs: Array = shop.offers()
var has_cheap: bool = ofs.has("lot_channel") or ofs.has("lot_timer")
var has_cheap := false
for id in ofs:
var cost := int(shop.price(str(id)))
if cost >= 0 and cost <= 49:
has_cheap = true
if not has_cheap:
_fail("при 71 $ нет доступного лота за 70 (офферы: %s)" % str(ofs))
_fail("при 49 $ нет доступного лота (офферы: %s)" % str(ofs))
break
_ok(true, "при 71 $ всегда есть доступный лот за 70")
_ok(true, "при 49 $ всегда есть доступный лот")
# Дорогие лоты не заменяются, если доступных нет.
_game.money = 60
shop.refresh(2, 60, true)
_eq(shop.offers().size(), 3, "при 60 $ предложения не ломаются")
_game.money = 40
shop.refresh(2, 40, true)
_eq(shop.offers().size(), 3, "при 40 $ предложения не ломаются")
func _test_reroll() -> void:
@@ -227,18 +231,18 @@ 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")
_eq(shop.price("lot_sla"), 80, "lot_sla цена 80")
_ok(shop.buy("lot_sla"), "покупка lot_sla")
_eq(int(_game.money), 9880, "списано 120")
_eq(int(_game.money), 9920, "списано 80")
_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")
_approx(_game.sla_cap(), 100.0, 0.0001, "кап SLA 60 → 100")
_approx(float(_game.sla), 100.0, 0.0001, "SLA сразу 60 → 100")
_eq(shop.price("lot_sla"), 160, "цена 2-го уровня 160")
_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")
_eq(shop.price("lot_sla"), 270, "цена 3-го уровня 270")
_eq(int(_game.money), 9760, "после второй покупки 9760")
_approx(float(_game.sla), 140.0, 0.0001, "SLA 100 → 140")
_ok(shop.buy("lot_slots"), "покупка lot_slots")
_eq(shop.level("lot_slots"), 1, "lot_slots = 1")
@@ -253,9 +257,9 @@ func _test_buy_and_effects() -> void:
_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")
_approx(_game.speed_mult(), 0.90, 0.0001, "скорость ×0.90")
_ok(shop.buy("lot_timer"), "покупка lot_timer")
_approx(_game.request_timer(), 35.0, 0.0001, "таймер запроса 30 → 35")
_approx(_game.request_timer(), 37.0, 0.0001, "таймер запроса 30 → 37")
_set_offers(["lot_shield", "lot_sla", "lot_money"])
_ok(shop.buy("lot_shield"), "покупка lot_shield")
@@ -271,10 +275,10 @@ func _test_buy_and_effects() -> void:
# Недостаточно денег.
shop = _reset(2, 10000)
_set_offers(["lot_slots"])
_game.money = 119
_ok(not shop.can_buy("lot_slots"), "119 $ мало для 120")
_game.money = 89
_ok(not shop.can_buy("lot_slots"), "89 $ мало для 90")
_ok(not shop.buy("lot_slots"), "buy без денег → false")
_eq(int(_game.money), 119, "деньги не списаны")
_eq(int(_game.money), 89, "деньги не списаны")
func _test_slot_and_folder_effects() -> void:
@@ -320,10 +324,10 @@ func _test_slot_and_folder_effects() -> void:
for i in range(4):
shop.buy("lot_folder")
dims = _game.folder_dims()
_eq(dims.x, 8, "folder max: 8 столбцов")
_eq(dims.x, 9, "folder max: 9 столбцов")
_eq(dims.y, 6, "folder max: 6 строк")
if folder != null:
_eq(int(folder.cols), 8, "существующая папка выросла до 8 столбцов")
_eq(int(folder.cols), 9, "существующая папка выросла до 9 столбцов")
_eq(int(folder.rows), 6, "существующая папка выросла до 6 строк")
if probe != null:
_eq(probe.origin, probe_origin, "origin файла не изменился на max")