Яндекс Игры (Y1–Y4): адаптация под требования каталога — SDK, монетизация, полировка, упаковка.

Y1 (SDK, треб. 1.1/1.3/1.19): секция «Y1: SDK Яндекс Игр» — обёртка YG: стаб при file:// без ?sdk=1 (скрипт не создаётся — чистая консоль), http(s) — динамический /sdk.js (retry/slow-warn 3 с, апгрейд стаб→SDK), LoadingAPI.ready() первым кадром + catch-up в init, GameplayAPI.start/stop (btnPlay/openShop/closeShop/btnReset/btnResetNo; карточка улова — без stop, осознанно: часть цикла улова), game_api_pause/resume + visibilitychange → YG.onPause/onResume (AC.suspend; при YG.paused в frame() заморожено ВСЁ игровое время; guard adShowing в резюме).

Y2 (монетизация, треб. 1.12/4.x): interstitial через showFullscreenAdv на закрытии карточки улова (логическая пауза 4.4; гейты: первый ≥2 мин сессии, кулдаун 3 мин; lastAdTs — осознанно «попытка», не показ — при троттлинге wasShown=false кулдаун сгорает; persist() ДО показа 4.2; YG.onPause — звук+процесс 4.7; watchdog 90 с 1.14, дедуп в YG._startAdWatchdog). RV-кнопка «📺 Реклама: ×2 за этот улов» (4.5.1 честная формулировка, title) через showRewardedVideo: onRewarded → +val (итого ×2), раз на улов (rvDone), бонус не блокирует игру 4.5.2, честная игровая кнопка — не имитация рекламного блока 1.16, .rvbtn min-height:44px (тап-таргет). Стаб: fullscreen → false (мгновенный resume), RV — синхронная симуляция (локальная разработка).

Y3 (полировка): -webkit-touch-callout:none в универсальном селекторе (1.6.1.8 лонгтап iOS).

Y4 (упаковка, треб. 1.21/1.22): tools/pack.mjs — сборка dist/fishing-yandex.zip (только index.html в корне; сборщики zip→python3→python→py) + самопроверка 5 пунктов (структура, <100MB, имена, sha256 byte-идентичность, node --check распакованного JS; встроенный zip-ридер). tests/pack-smoke.js — smoke играбельности КОПИИ из архива (6 чек: MENU, стаб, IDLE, shop, console/pageerror 0; авто-откат /tmp→$HOME — в этом чекауте /tmp невидим Chromium'у). .gitignore: dist/ + /*.pdf.

Тесты: 129→146 — §Y1 +7 (стаб, ready, gameplay-разметка, pause/visibilitychange), §Y2 +9 (RV-бонус ×2/no-op/закрытие, гейт 2 мин, вызов после, кулдаун, после кулдауна; дельты счётчиков, forceVariant=normal), §Y3 +1 (.rvbtn min-height). tests/shots.js: кадр card-rv (13→14). check.mjs OK; pack 5/5 (40.6 KB); pack-smoke 6/6.

Ревью: 4 gate-ревью по этапам (PASS, все findings F1–F7/F1–F5/low+nit/F1–F8 закрыты в лэйнах ревью) + визуальные: мобильные 13 кадров + card-rv, десктоп 1920×1080 (full-bleed, 1.6.2/1.10), рендер из zip-копии (меню/сцена/карточка, 0 ошибок) — все PASS.

Доки: README «Публикация в Яндекс Играх» (pack → smoke → консоль; ?sdk=1), AGENTS.md (п.5 «Проверка», строки Y1/Y2 в «Где крутить», YG/YG.counts в тест-глобалах, 146), DEVLOG этапы 24–27. screenshots/: logo.png (512×512, иконка для консоли), 1.png (1280×720, обложка).

Осознанные решения/риски: 1.20/1.23 — emoji-шрифт на старых Win ОС (квадраты, зафиксировано); 2.14 — игра одноязычная RU (язык черновика — ru); облачные сейвы НЕ используются → опция в консоли выключена; платформы — без Android TV.
This commit is contained in:
2026-09-09 06:47:55 +03:00
parent cacd956e31
commit 08a6b0195f
11 changed files with 975 additions and 15 deletions
+6
View File
@@ -7,10 +7,16 @@ tests/shot-*.png
*.log
.DS_Store
# Артефакт сборки для Яндекс Игр (node tools/pack.mjs)
dist/
# Локальные worktree спринтов (Orca)
sprint3-*/
sprint2-*/
sprint4-*/
# Локальная документация (защита от случайного git add -A)
/*.pdf
# Секреты (никогда не коммитить)
.gitea_token
+5 -2
View File
@@ -11,19 +11,20 @@
npm install # puppeteer-core
npm test # = node test.js
```
Ждать все PASS и exit 0. Сейчас проверок 129.
Ждать все PASS и exit 0. Сейчас проверок 146.
3. Браузерный плейтест (вкладка встроенного браузера Orca, через Orca CLI):
```bash
node tests/orca-playtest.js # из каталога репо; требует ЗАПУЩЕННЫЙ Orca app и orca-ide (или ORCA_CLI_COMMAND)
```
Проходит тот же геймплей «вживую» в браузере (не headless) и пишет `orca-*.png` в каталог запуска. Если Orca app не запущен — пропустить и НЕ блокировать (главный рубеж — пункт 2). Подробности и подводные камни: `docs/BROWSER-TESTING.md`.
4. Скриншоты: `node tests/shots.js` пишет `shot-*.png` в каталог запуска (запускать из `tests/`); README ссылается на `screenshots/` в корне — свежие скопировать туда. В headless нужен шрифт emoji (Noto Color Emoji), иначе в скриншотах квадраты.
5. Публикация в Яндекс Играх: `node tools/pack.mjs` (сборка dist/fishing-yandex.zip + самопроверка 5 пунктов) → `node tests/pack-smoke.js` (smoke играбельности КОПИИ из архива, 6 чек) → загрузить zip в консоль. Доки: раздел README «Публикация в Яндекс Играх», запись Этап 27 в DEVLOG.
## Подводные камни
- `tests/test.js` берёт браузер из `process.env.CHROMIUM || '/usr/bin/chromium-browser'`, а URL игры резолвит динамически от каталога репо (тот же паттерн, что в `shots.js`). Если тест падает на старте — поправьте имя/путь chromium под свой чекаут (переменная `CHROMIUM`).
- Граница полуночи: если прогон стартует в пределах 5 минут от 00:00, test.js сам ждёт безопасное окно (дата не должна смениться посреди прогона — падают date-ассерты квестов/стрика). Это нормально, не баг.
- Тесты гоняют игру через внутренние глобалы в `page.evaluate`: `state`, `save`, `biteTimer`, `reel`, `SPECIES`, `SPECIES_NIGHT`, `rod()`, `SHINY_CHANCE`, `forceBoss`, `forceBite`, `forceVariant`, `VARIANTS`, `rollVariant()`, `variantChance()`, `fishForce()`, `stateBoss`, `bossPhase`, `dayPhase`, `pickSpecies()`, `setDayTime()`, `todayStr()`, `dateShift()`, `isoWeek()`, `achBonus()`, `forceWeather`, `weather`, `weatherT`, `weatherGap`, `WEATHER`, `WEATHER_GAP_MIN`, `WEATHER_GAP_MAX`, `WEATHER_DURATION`, `weatherActive()`, `BOAT_SKINS`, `BOBBER_SKINS`, `LOCATIONS`, `RIVER_SPECIES`, `RIVER_SPECIES_NIGHT`, `LAKE_SPECIES`, `LAKE_SPECIES_NIGHT`, `SAVE_KEY`, `loadSave`, `freshSave`, `ACHIEVEMENTS`, `checkAchievements`, `questText` и имена состояний `MENU/IDLE/CHARGE/CAST/WAIT/APPROACH/BITE/REEL/SNAP/CARD`. Переименование любого из них ломает тесты молча — имена держать, либо обновлять тест в том же коммите.
- Тесты гоняют игру через внутренние глобалы в `page.evaluate`: `state`, `save`, `biteTimer`, `reel`, `SPECIES`, `SPECIES_NIGHT`, `rod()`, `SHINY_CHANCE`, `forceBoss`, `forceBite`, `forceVariant`, `VARIANTS`, `rollVariant()`, `variantChance()`, `fishForce()`, `stateBoss`, `bossPhase`, `dayPhase`, `pickSpecies()`, `setDayTime()`, `todayStr()`, `dateShift()`, `isoWeek()`, `achBonus()`, `forceWeather`, `weather`, `weatherT`, `weatherGap`, `WEATHER`, `WEATHER_GAP_MIN`, `WEATHER_GAP_MAX`, `WEATHER_DURATION`, `weatherActive()`, `BOAT_SKINS`, `BOBBER_SKINS`, `LOCATIONS`, `RIVER_SPECIES`, `RIVER_SPECIES_NIGHT`, `LAKE_SPECIES`, `LAKE_SPECIES_NIGHT`, `SAVE_KEY`, `loadSave`, `freshSave`, `ACHIEVEMENTS`, `checkAchievements`, `questText`, `YG` (и `YG.counts`) и имена состояний `MENU/IDLE/CHARGE/CAST/WAIT/APPROACH/BITE/REEL/SNAP/CARD`. Переименование любого из них ломает тесты молча — имена держать, либо обновлять тест в том же коммите.
- Ввод на Pointer Events (`pointerdown`/`pointerup`), а не touch/mouse — так тест управляет игрой мышью. Не заменять на тач-обработчики.
- Сейв: ключ localStorage `fishing-save-v1`. При изменении формата — миграцию добавлять в `loadSave()` (старые сейвы обязаны оставаться читаемыми).
@@ -39,6 +40,8 @@
- Сложность вываживания: `updateReel()`.
- Физика боя (спринт 4): `fishForce()` (сила рыбы: рывок `(16+34·st)·(0.5+0.9·ag)·(0.7+0.5·ov)`, затишье `5+7·ag`, уставшая ×0.45) и `updateReel()` — выносливость `st` (старт `st0`, флаг `tired` при `st≤0.12`: рывки ×0.45, втягивание быстрее до ×1.6), тормоз катушки (черта `drag` в `RODS`: в рывке выше тормоза леска скользит `over·0.22`), натяжение (`10+over·1.1+22·ag`, ×0.35 в затишье), втягивание `(11+3·reel)·(1+0.6·(1−st))`, свободный бег `F·0.32`, escape-бар растёт только при затишье без удержания (отпускание в рывок безопасно), near-miss `1.45·d0`, `d0=45+7·w`. Пустые клёвы: ролл `biteChance()` в ветке WAIT (тест-форс `forceBite`). Тесты (§10) завязаны на эти константы — при ребалансе гонять Monte-Carlo точными формулами (docs/PHYSICS-RESEARCH.md, §4.7) и править тесты в том же коммите.
- Награды (M2): шаблоны квестов `QUEST_TPLS`, награды стрика `STREAK_REWARDS`, достижения `ACHIEVEMENTS` — секция «M2. Награды» после сейва. Этап 22: ачивок 29 (идут `ACHIEVEMENTS`-записями по темам: снасти у экономики, варианты/погода/вес/ночь у рыболовных, скины/локации у магазинных); новые счётчики сейва `save.kgTotal`/`nightCatch`/`wxRainV`/`wxFog` (чтение через `||0` — без миграции) ставятся в `catchFish()` до `checkAchievements()` (rain-счётчик — только вариант ≠ normal, босс всегда normal); тест-пин §10c(8): сумма `achBonus().coinPct` (все 8 видов × 4 варианта) = 0.16.
- SDK Яндекс Игр (Y1): секция «Y1: SDK Яндекс Игр» перед «ЦИКЛ» — обёртка YG (стаб при file:// без ?sdk=1: скрипт не создаётся, no-op'ы + счётчики counts), динамическая загрузка /sdk.js (http(s)) с retry/warn, LoadingAPI.ready() первым кадром, GameplayAPI.start/stop в btnPlay/openShop/closeShop/btnReset/btnResetNo, game_api_pause/resume + visibilitychange → YG.onPause/onResume (AC.suspend, пауза всего игрового времени в frame()).
- Реклама (Y2): константы `AD_FIRST_MS`/`AD_COOLDOWN_MS` (секция Y1), точка interstitial — `tryInterstitial()` в хендлере `btnCardOk` (гейт: первый показ ≥2 мин сессии, кулдаун 3 мин; `lastAdTs` — осознанно «попытка», не показ — при троттлинге платформы wasShown=false кулдаун сгорает), RV-кнопка `#btnRv` «📺 Реклама: ×2 за этот улов» на карточке улова (`showCard` сбрасывает `rvVal`/`rvDone`), методы `YG.showFullscreen/showRewarded` (+watchdog 90 с), `endAd()`; карточка улова БЕЗ `gameplayStop()` — осознанно (часть цикла улова); реклама не персистится в сейв (только `persist()` перед показом и после бонуса).
## Запуск и публикация
+13 -2
View File
@@ -55,14 +55,25 @@ python3 -m http.server 8000
# → http://localhost:8000
```
## Публикация в Яндекс Играх
Архив для платформы (только `index.html` в корне архива, с самопроверкой — набор файлов, размер, имена, sha256, синтаксис JS):
```bash
node tools/pack.mjs # → dist/fishing-yandex.zip
node tests/pack-smoke.js # перед загрузкой в консоль: smoke упакованной копии (игра из архива, 6 проверок)
```
SDK подхватывается автоматически на площадке; локально (file://) игра работает без него (встроенный стаб). Локальная отладка с SDK: открыть игру с параметром `?sdk=1` и положить `sdk.js` рядом с игрой (или поднять локальный прокси SDK Яндекса). В консоли разработчика (games.yandex.ru): включить монетизацию, опция облачных сейвов — выключена (игра сама хранит сейв в `localStorage`), платформы — без Android TV, `debug-mode=16` — для проверки лоадера.
## Тесты
Автотест прогоняет весь геймплей (бросок, подсечка, все исходы вываживания, магазин, дневник, сохранение, варианты рыб (золотая/серебряная/радужная), near-miss, миграция сейва, квесты, стрик, достижения, день/ночь с фонариком, босс-рыба в 3 фазы) в headless Chromium:
Автотест прогоняет весь геймплей (бросок, подсечка, все исходы вываживания, магазин, дневник, сохранение, варианты рыб (золотая/серебряная/радужная), near-miss, миграция сейва, квесты, стрик, достижения, день/ночь с фонариком, босс-рыба в 3 фазы, SDK-стаб (YG), реклама: RV-бонус и гейты interstitial) в headless Chromium:
```bash
cd tests
npm install # ставит puppeteer-core
node test.js # 129 проверок
node test.js # 146 проверок
```
Нужен установленный Chromium (`executablePath` в `test.js` при необходимости поправить).
+72
View File
@@ -470,3 +470,75 @@ git add -A && git commit -m "что изменилось" && git push
**Заметки (осознанные решения и ловушки):**
- Закрывает камень #12 из Этапа T4: утешительные выплаты за сходы давали 43–47% раннего дохода (фармить сходы было выгоднее осторожной игры).
- `fishEscaped(sp)` — единственная точка исхода «рыба ушла»: обе ветки сходятся в неё, новые тексты/звук — править в одном месте.
## Этап 24. Y1: SDK Яндекс Игр
**Что сделано в `index.html`:**
1. **Обёртка YG** — секция «Y1: SDK Яндекс Игр» перед «ЦИКЛ», единственная точка контакта с платформой: стаб при file:// без `?sdk=1` (скрипт `/sdk.js` не создаётся — чистая консоль; no-op'ы + счётчики вызовов `YG.counts` для тестов) и http(s) — динамическая загрузка `/sdk.js` (один ретраи, warn, 3-сек slow-warn), апгрейд стаб → реальный SDK в `YG.init()` (однократно, catch-up `ready()`, если SDK доинициализировался позже).
2. **`LoadingAPI.ready()`** — первым кадром `frame()` + catch-up в `init()`.
3. **`GameplayAPI.start/stop`** — btnPlay/openShop/closeShop/btnReset/btnResetNo; карточка улова — без stop (часть цикла улова, осознанно).
4. **Пауза платформы** — `game_api_pause/resume` + `visibilitychange` → `YG.onPause/onResume` (`AC.suspend()`; при `YG.paused` в `frame()` заморожено всё игровое время).
**Тесты:** +7 проверок в `tests/test.js` (§Y1: стаб в file:// — SDK не создан и счётчики на месте, `ready()` ровно один раз первым кадром, start/stop-разметка btnPlay/btnShop/btnCloseShop/btnReset/btnResetNo по `YG.counts`, пауза/резюме — заморозка frame()). Итог **136/136 PASS, exit 0** (`node tools/check.mjs` — OK). Ревью F1–F7 закрыты (заморозка frame(), loader-ретраи, src по протоколу, guard btnPlay, доки).
**Monte-Carlo:** не требовался — физика вываживания не тронута; SDK не влияет на геймплей.
**Заметки (осознанные решения и ловушки):**
- Стаб при file:// без `?sdk=1` — осознанно: скрипт SDK не создаётся (чистая консоль), игра остаётся полностью играбельной вне платформы.
- Резюме (`YG.onResume`) не снимается, пока открыта реклама — снимает `endAd()` (guard `adShowing`; путь: visibilitychange/game_api_resume, а оверлей рекламы ещё открыт).
## Этап 25. Y2: Монетизация
**Что сделано в `index.html`:**
1. **Interstitial** через `ysdk.adv.showFullscreenAdv` — на закрытии карточки улова (`tryInterstitial()` в хендлере `btnCardOk`): логическая пауза (треб. 4.4), гейты `AD_FIRST_MS` (первый показ не раньше 2 мин сессии) / `AD_COOLDOWN_MS` (между показами 3 мин), `persist()` **ДО** показа (4.2 — улов не теряется при убийстве страницы платформой), `YG.onPause` — звук + процесс (4.7), watchdog 90 с (1.14) — если SDK не закрыл рекламу, паузу снимаем сами.
2. **Rewarded «монеты ×2»** — RV-кнопка `#btnRv` «📺 Реклама: ×2 за этот улов» на карточке улова через `ysdk.adv.showRewardedVideo`: `onRewarded` → +val (цена улова), раз на улов (`rvDone`), бонус не блокирует закрытие карточки (4.5.2); честная формулировка (4.5.1); простой текстовый button — НЕ имитация рекламного блока платформы (1.16).
3. **Стаб** (file:// без SDK): fullscreen → `false` (реклама не показана, мгновенный resume), RV — синхронная симуляция успешного просмотра (локальная разработка, all-в-одном-клике флоу).
**Тесты:** +9 проверок в `tests/test.js` (§Y2: RV-карточка (кнопка видна/включена, текст «×2», `rvVal` = цене улова), RV-клик (×2 монет, раз на улов, повторный клик no-op), interstitial-гейты 2 мин / кулдаун 3 мин по счётчику `fsAdv` (adShowing/paused=false), cleanup — возврат в MENU). Итог **145/145 PASS, exit 0** (`node tools/check.mjs` — OK). Кадр `card-rv` в `tests/shots.js`. Ревью F1–F5 закрыты (guard adShowing в резюме, watchdog, дельта-пин, текст кнопки).
**Monte-Carlo:** не требовался — физика вываживания не тронута; реклама не влияет на геймплей.
**Заметки (осознанные решения и ловушки):**
- Карточка улова БЕЗ `gameplayStop()` — осознанно (часть цикла улова, не выход из геймплея).
- `lastAdTs` — осознанно «попытка», не «показ»: при троттлинге платформы `wasShown=false` кулдаун всё равно сгорает.
- Реклама не персистится в сейв (как погода): `persist()` только перед показом и после бонуса.
## Этап 26. Y3: Полировка
**Что сделано в `index.html`:**
1. **`-webkit-touch-callout:none`** — в универсальный селектор (треб. 1.6.1.8: лонгтап не вызывает системный touch-callout на iOS; user-select и contextmenu были закрыты ранее).
2. **`.rvbtn{min-height:44px}`** — гарантия тап-таргета RV-кнопки ≥44px независимо от line-height/шрифта платформы (по замечанию UI-ревью).
3. **Дедуп watchdog** — тело watchdog вынесено в метод `YG._startAdWatchdog()`, вызов из `showFullscreen`/`showRewarded` после успешного `adv.*`; `endAd()` без изменений.
**Тесты:** +1 проверка в `tests/test.js` (§Y3: computed-style `.rvbtn` min-height 44px). Итог **146/146 PASS, exit 0** (`node tools/check.mjs` — OK).
**Monte-Carlo:** не требовался — изменения только в CSS и декомпозиции YG.
**Заметки (осознанные решения и ловушки):**
- Финальное gate-ревью Y1–Y3: PASS по таблице требований 1.1–8.x (частично 1.20/1.23 — emoji-шрифт на старых Win ОС, осознанно).
- Доки README/AGENTS.md синхронны с реальным числом проверок (146).
## Этап 27. Y4: Упаковка
**Что сделано:**
1. **`tools/pack.mjs`** (чистый Node, без зависимостей) — сборка `dist/fishing-yandex.zip` для Яндекс Игр (треб. 1.22): ТОЛЬКО `index.html` в корне архива, без docs/tests/screenshots/прочего. Сборщик: `zip -j` (основной, deflate; junk-путь — файл сразу в корне), при отсутствии — `python3 -m zipfile -c` (fallback; наличие обеих команд проверяется заранее, если обеих нет — понятная ошибка и exit 1). `dist/` создаётся автоматически, старый архив удаляется перед сборкой; `dist/` добавлен в `.gitignore`.
2. **Самопроверка в том же скрипте** — после сборки архив распаковывается в каталог tmp (`fs.mkdtemp`, встроенный zip-ридер: EOCD + центральная директория + `zlib.inflateRawSync`, `unzip` не нужен) и проверяются 5 пунктов, каждый с PASS/FAIL: (a) ровно один файл `index.html` в корне; (b) размер архива < 100 МБ (размер выводится); (c) имена без пробелов/кириллицы (regex); (d) sha256 распакованного файла == sha256 исходного `index.html` (byte-идентичность: игра в архиве = игра в репо); (e) синтаксис JS, вынутый из распакованного файла (тот же паттерн извлечения `<script>`-блоков, что в `tools/check.mjs`), через `node --check`. Любой FAIL — exit 1 и сообщение; в конце — путь к zip + размер.
3. **README** — раздел «Публикация в Яндекс Играх»: команда сборки, SDK-стаб локально, отладка с `?sdk=1` (sdk.js рядом или локальный прокси Яндекса), настройки консоли разработчика.
**Результат прогона:** все 5 пунктов PASS, exit 0. `dist/fishing-yandex.zip` — 40.6 KB (zip/deflate); python3-фолбэк тоже deflate (проверено на Python 3.12) — 40.7 KB, все 5 пунктов PASS. sha256 исходного `index.html` `824309ff20f3…` совпадает с распакованным.
**Что делать в консоли Яндекса (публикация):** загрузить `dist/fishing-yandex.zip`; включить монетизацию; опция облачных сейвов — выключена (игра сама хранит сейв в `localStorage`); платформы — без Android TV; `debug-mode=16` — для проверки лоадера.
**Тесты:** `node tools/check.mjs` — OK; `cd tests && npm test` — **146/146 PASS, exit 0** (код игры не тронут, число проверок не изменилось).
**Smoke упакованной копии (независимая верификация Y4):** повторный `node tools/pack.mjs` — 5 PASS, exit 0 (идемпотентно); архив распакован отдельно (`python3 -m zipfile -e`) — ровно один `index.html` в корне, sha256 распакованного совпадает с репо (`824309ff20f3…`), список записей вторым независимым ридером (`unzip -l`) — тот же один файл. Новый персистентный тест `tests/pack-smoke.js` гоняет игру с КОПИИ из архива (не из репо): MENU → YG-стаб (active=false, readySent=true) → #btnPlay (IDLE, меню скрыто) → #btnShop/#btnCloseShop → ноль JS-ошибок — **PACK-SMOKE: 6/6, exit 0** (3 прогона подряд). Подводка окружения: headless Chromium в этом чекауте не видит `/tmp` по `file://` (отдельный tmpfs, ERR_FILE_NOT_FOUND) — smoke при этом авто-откатывается во временный каталог под `$HOME`.
**Monte-Carlo:** не требовался — геймплей не тронут.
**Заметки (осознанные решения и ловушки):**
- Самопроверка распаковывает встроенным zip-ридером, а не `unzip` — у скрипта ноль бинарных зависимостей помимо самого сборщика (`zip` → fallback `python3`).
- python3-фолбэк тоже deflate (проверено на Python 3.12) — встроенный ридер самопроверки понимает и stored, и deflate; по размеру разница несущественна (40.6 против 40.7 KB).
+202 -9
View File
@@ -7,7 +7,7 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Рыбалка</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none}
html,body{width:100%;height:100%;overflow:hidden;background:#0d3b66;font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}
#game{position:fixed;inset:0;touch-action:none;display:block}
button{font-family:inherit;cursor:pointer}
@@ -155,6 +155,17 @@ button{font-family:inherit;cursor:pointer}
#card{animation:pop .3s ease}
@keyframes pop{from{transform:scale(.75);opacity:0}to{transform:scale(1);opacity:1}}
/* Y2: rewarded «монеты ×2» (карточка улова) — тёмно-синяя в духе карточки,
янтарная обводка «читается» как бонус; простой текстовый button (треб. 1.16 —
НЕ имитация рекламного блока платформы) */
.rvbtn{width:100%;min-height:44px;margin-top:10px;padding:11px 12px;font-size:15px;font-weight:800;color:#ffe9a8;
background:linear-gradient(#14406b,#0b2a49);border:1.5px solid #ffd75e;border-radius:12px;
box-shadow:0 0 12px rgba(255,215,94,.22),inset 0 1px 0 rgba(255,255,255,.08);
transition:transform .12s,box-shadow .12s,filter .12s}
.rvbtn:hover{filter:brightness(1.18);box-shadow:0 0 16px rgba(255,215,94,.4),inset 0 1px 0 rgba(255,255,255,.1)}
.rvbtn:active{transform:scale(.97)}
.rvbtn:disabled{opacity:.55;cursor:default;filter:none;transform:none}
.twobtns{display:flex;gap:10px;margin-top:16px}
.twobtns .big{margin-top:0;width:auto;flex:1}
@@ -261,6 +272,8 @@ button{font-family:inherit;cursor:pointer}
<div class="cname" id="cardName">Карась</div>
<div class="csub" id="cardSub">0.0 кг · 0 🪙/кг</div>
<div class="cval" id="cardVal">+0 🪙</div>
<!-- Y2: rewarded-видео «монеты ×2» (чистый бонус; 4.5.1 — что получим, написано прямо на кнопке) -->
<button id="btnRv" class="rvbtn hidden" title="Посмотрите рекламу — двойная цена за этот улов">📺 Реклама: ×2 за этот улов</button>
<div class="twobtns">
<button id="btnCardOk" class="big">Отлично!</button>
</div>
@@ -1873,18 +1886,141 @@ function draw(){
ctx.restore();
}
/* =========================================================
Y1: SDK Яндекс Игр
Обёртка YG — единственная точка SDK. Стаб (file:// без ?sdk=1) —
no-op + счётчики для тестов. SDK подгружается динамически ТОЛЬКО
по http(s) (или file://?sdk=1): при чистом file:// скрипт не
создаётся вообще (иначе 404/ERR_FILE_NOT_FOUND в консоли).
file://?sdk=1 — относительный sdk.js: работает, если лежит рядом
с index.html.
========================================================= */
const YG={
sdk:null, active:false, // ysdk или null (стаб)
paused:false, // глобальная пауза: реклама / скрытая вкладка / платформа
readySent:false,
counts:{ready:0,start:0,stop:0,fsAdv:0,rv:0}, // счётчики вызовов (для тестов)
adWatchdog:null, // Y2: watchdog — SDK не закрыл рекламу (нет onClose/onError) → снять паузу
_sdkReady(){ if(this.sdk){ try{ this.sdk.features.LoadingAPI&&this.sdk.features.LoadingAPI.ready&&this.sdk.features.LoadingAPI.ready(); }catch(e){} } },
init(ysdk){
if(this.active) return; // однократно (апгрейд стаб(null) → реальный SDK позже — разрешён)
this.sdk=ysdk||null; this.active=!!ysdk;
if(ysdk){
try{
const p=()=>this.onPause(), r=()=>this.onResume();
ysdk.on('game_api_pause',p);
ysdk.on('game_api_resume',r);
}catch(e){}
}
if(this.readySent) this._sdkReady(); // SDK доинициализировался после ready()
},
ready(){ this.readySent=true; this.counts.ready++; this._sdkReady(); },
gameplayStart(){ this.counts.start++; if(this.sdk){ try{ this.sdk.features.GameplayAPI&&this.sdk.features.GameplayAPI.start&&this.sdk.features.GameplayAPI.start(); }catch(e){} } },
gameplayStop(){ this.counts.stop++; if(this.sdk){ try{ this.sdk.features.GameplayAPI&&this.sdk.features.GameplayAPI.stop&&this.sdk.features.GameplayAPI.stop(); }catch(e){} } },
onPause(){
if(this.paused) return;
this.paused=true;
this.gameplayStop();
if(AC){ try{ AC.suspend(); }catch(e){} }
},
onResume(){
if(!this.paused) return;
// резюме не снимается, пока открыта реклама — снимет endAd()
// (пути сюда: visibilitychange / game_api_resume, а оверлей рекламы ещё открыт)
if(adShowing) return;
this.paused=false;
last=performance.now();
if(state!=='MENU'&&!uiOpen) this.gameplayStart();
},
// Y2: реклама — ТОЛЬКО через SDK Яндекса (треб. 4.1). Стаб: fullscreen —
// no-op (вернёт false — реклама не показана), rewarded — симуляция успешного
// просмотра (локальная разработка, all-в-одном-клике флоу).
// Y2: watchdog — если SDK не доставит onClose/onError (баг SDK, убитый iframe),
// паузу снимаем сами, иначе игра замрзнет навсегда (треб. 1.14)
_startAdWatchdog(){
clearTimeout(this.adWatchdog);
this.adWatchdog=setTimeout(()=>{ if(adShowing){ console.warn('Yandex SDK: реклама не закрылась (90 с) — снимаю паузу'); endAd(); } },90000);
},
showFullscreen(cb){
this.counts.fsAdv++;
if(this.sdk){
try{ this.sdk.adv.showFullscreenAdv({callbacks:cb}); }catch(e){ return false; } // сбой SDK — как стаб: endAd() на месте вызова
this._startAdWatchdog();
return true;
}
return false; // стаб: реклама не показана
},
showRewarded(cb){
this.counts.rv++;
if(this.sdk){
try{ this.sdk.adv.showRewardedVideo({callbacks:cb}); }catch(e){ return false; }
this._startAdWatchdog(); // стаб-ветка ниже — флоу синхронный, watchdog не ставится
return true;
}
// стаб: симуляция успешного просмотра (локальная разработка)
try{ cb.onOpen&&cb.onOpen(); cb.onRewarded&&cb.onRewarded(); cb.onClose&&cb.onClose(true); }catch(e){}
return true;
},
};
// Y2: константы и состояние рекламы. Interstitial — на закрытии карточки улова
// (логическая пауза, треб. 4.4): первый показ не раньше AD_FIRST_MS сессии,
// дальше не чаще AD_COOLDOWN_MS. Rewarded «монеты ×2» — на самой карточке
// (треб. 4.5.1/4.5.2). Реклама не персистится (как погода).
const AD_FIRST_MS=120000; // первый interstitial не раньше 2 мин сессии
const AD_COOLDOWN_MS=180000; // между interstitial'ами — 3 мин
let sessionStartTs=Date.now(); // старт сессии (момент загрузки страницы)
let lastAdTs=0; // время запуска последнего interstitial
let adShowing=false; // реклама прямо сейчас (interstitial или rewarded)
let rvVal=0, rvDone=false; // rewarded: цена улова для бонуса ×2 и флаг «уже получен»
// Y1: динамическая загрузка SDK (документированный Яндексом вариант):
// http(s) — /sdk.js, file://?sdk=1 — sdk.js рядом с index.html. Первый сбой
// — один ретраи, второй — стаб + warn. 3-секундный таймер не финализирует.
(function(){
const sdkEnabled=location.protocol!=='file:'||/[?&]sdk=1/.test(location.search);
if(!sdkEnabled){ YG.init(null); return; } // file:// без ?sdk=1 — сразу стаб, скрипт не создаётся
const finish=(ysdk)=>{ YG.init(ysdk||null); }; // единая точка (защита в init: идемпотентно)
let attempt=0, settled=false, slowWarned=false;
const load=()=>{
attempt++;
const s=document.createElement('script');
s.src=location.protocol==='file:'?'sdk.js':'/sdk.js'; // file:// — относительный путь
s.async=true;
s.onload=()=>{
settled=true;
if(window.YaGames){ try{ YaGames.init().then(y=>finish(y)).catch(()=>finish(null)); }catch(e){ finish(null); } }
else finish(null);
};
s.onerror=()=>{
if(attempt<2){ load(); return; } // первый сбой — один ретраи (новый скрипт с тем же src)
settled=true;
finish(null);
console.warn('Yandex SDK: загрузка не удалась, игра в стаб-режиме');
};
document.head.appendChild(s);
};
load();
setTimeout(()=>{ // страховка: не финализирует, только одноразовый warn
if(!settled&&!slowWarned){ slowWarned=true; console.warn('Yandex SDK: загрузка дольше 3 с'); }
},3000);
})();
// Y1: потеря фокуса (треб. 1.3) — пауза звука/гейма при скрытой вкладке
document.addEventListener('visibilitychange',()=>{ if(document.hidden) YG.onPause(); else YG.onResume(); });
/* =========================================================
ЦИКЛ
========================================================= */
let last=performance.now();
function frame(now){
if(!YG.readySent) YG.ready(); // Y1: LoadingAPI.ready() (треб. 1.19.2) — первый кадр = меню видно
const dt=Math.min(0.05,(now-last)/1000); last=now;
time+=dt;
setDayTime(dayTime+dt/DAY_LENGTH); // M3: день/ночь идут в реальном времени
updateWeather(dt); // M8: погода — в реальном времени (как день/ночь)
updateWeatherHud(); // M8: пилюля погоды в HUD (иконка + остаток секунд)
updateDecor(dt);
if(!uiOpen) updateGame(dt);
if(!YG.paused){ // Y1: под паузой (реклама/скрытая вкладка/платформа) замораживается всё игровое время
time+=dt;
setDayTime(dayTime+dt/DAY_LENGTH); // M3: день/ночь идут в реальном времени
updateWeather(dt); // M8: погода — в реальном времени (как день/ночь)
updateWeatherHud(); // M8: пилюля погоды в HUD (иконка + остаток секунд)
updateDecor(dt);
if(!uiOpen) updateGame(dt);
}
draw();
requestAnimationFrame(frame);
}
@@ -1912,8 +2048,8 @@ document.addEventListener('contextmenu',e=>e.preventDefault());
UI: МАГАЗИН / ДНЕВНИК / КАРТОЧКА
========================================================= */
let shopTab='tackle';
function openShop(){ uiOpen=true; $('shop').classList.remove('hidden'); renderShop(); sfx('click'); }
function closeShop(){ uiOpen=false; $('shop').classList.add('hidden'); sfx('click'); }
function openShop(){ uiOpen=true; $('shop').classList.remove('hidden'); renderShop(); sfx('click'); YG.gameplayStop(); } // Y1: геймплейная разметка
function closeShop(){ uiOpen=false; $('shop').classList.add('hidden'); sfx('click'); YG.gameplayStart(); } // Y1: геймплейная разметка
function setTab(t){ shopTab=t; renderShop();
$('tabTackle').classList.toggle('active',t==='tackle');
$('tabJournal').classList.toggle('active',t==='journal');
@@ -2143,6 +2279,11 @@ function showCard(sp,w,val,rec,variant,boss){
// M4: босс: бейдж 🏆 + золотое оформление карточки
$('cardBoss').classList.toggle('hidden',!boss);
$('card').classList.toggle('boss-card',!!boss);
// Y2: rewarded «монеты ×2» — бонус = полная цена улова (rvVal), сброс флага
rvVal=val; rvDone=false;
$('btnRv').classList.remove('hidden');
$('btnRv').disabled=false;
$('btnRv').textContent='📺 Реклама: ×2 за этот улов';
$('card').classList.remove('hidden');
}
@@ -2153,6 +2294,7 @@ $('btnPlay').addEventListener('click',()=>{
ac(); sfx('click');
$('menu').classList.add('hidden');
uiOpen=false; state='IDLE';
if(!YG.paused) YG.gameplayStart(); // Y1: геймплейная разметка (треб. 1.19.3; под паузой резюме поднимет сам)
});
$('btnShop').addEventListener('click',openShop);
$('btnCloseShop').addEventListener('click',closeShop);
@@ -2171,11 +2313,13 @@ $('btnReset').addEventListener('click',()=>{
sfx('click');
uiOpen=true; // пауза, пока идёт подтверждение
$('resetDlg').classList.remove('hidden');
YG.gameplayStop(); // Y1: геймплейная разметка (треб. 1.19.3)
});
$('btnResetNo').addEventListener('click',()=>{
sfx('click');
$('resetDlg').classList.add('hidden');
uiOpen=(state==='MENU');
if(!uiOpen) YG.gameplayStart(); // Y1: геймплей возобновился (не в меню и без оверлея)
});
$('btnResetYes').addEventListener('click',()=>{
const muted=save.muted; // настройка звука — не игровые данные, оставляем
@@ -2195,10 +2339,59 @@ $('btnResetYes').addEventListener('click',()=>{
sfx('no'); vib(50);
toast('Прогресс сброшен 🔄');
});
// Y2: interstitial на закрытии карточки улова (треб. 4.4 — логическая пауза).
// Гейт: первый показ не раньше AD_FIRST_MS сессии, затем — через AD_COOLDOWN_MS.
// persist() — ПЕРЕД паузой (треб. 4.2: процесс сохранён до перехода на рекламу);
// YG.onPause() — звук и весь игровой процесс (треб. 4.7). В стабе showFullscreen
// вернёт false — endAd() сразу, игра не зависает в adShowing=true.
function tryInterstitial(){
if(adShowing) return;
const now=Date.now();
if(now-sessionStartTs < AD_FIRST_MS) return;
if(now-lastAdTs < AD_COOLDOWN_MS) return;
lastAdTs=now; // отмечаем ПОПЫТКУ, а не показ: при троттлинге платформы (wasShown=false) кулдаун сгорает — это осознанно, ограничивает частоту вызовов (retry-storm)
adShowing=true;
persist(); // 4.2: процесс сохранён до перехода на рекламу
YG.onPause(); // 4.7: звук + весь игровой процесс на паузе
const ok=YG.showFullscreen({
onOpen(){},
onClose(){ endAd(); }, // onClose(wasShown) приходит и при троттлинге
onError(){ endAd(); }
});
if(!ok) endAd(); // стаб: не показана — сразу резюм
}
function endAd(){ clearTimeout(YG.adWatchdog); YG.adWatchdog=null; adShowing=false; YG.onResume(); } // Y2: реклама закрыта/ошибка — снимаем паузу и останавливаем watchdog
$('btnCardOk').addEventListener('click',()=>{
sfx('click');
$('card').classList.add('hidden');
state='IDLE';
tryInterstitial(); // Y2: после возврата в IDLE; гейт может пропустить (внутри return)
});
// Y2: rewarded «монеты ×2» (треб. 4.5.1 — что показываем и что даём, написано на
// кнопке и в title; 4.5.2 — чистый бонус, игру не блокирует: карточка остаётся,
// закрыть можно в любой момент). В стабе onRewarded срабатывает синхронно — весь
// флоу в одном клике; в реальном SDK onRewarded придёт после просмотра, onClose —
// позже (endAd именно через onClose, не раньше).
$('btnRv').addEventListener('click',()=>{
if(rvDone||adShowing) return;
sfx('click');
adShowing=true;
YG.onPause(); // 4.7
const ok=YG.showRewarded({
onOpen(){},
onRewarded(){
rvDone=true;
save.coins+=rvVal; // бонус = полная цена улова ещё раз
persist(); updateHUD();
$('cardVal').textContent='+'+(rvVal*2)+' 🪙';
$('btnRv').disabled=true;
$('btnRv').textContent='✓ Бонус получен';
sfx('coin'); vib(30);
},
onClose(){ endAd(); },
onError(){ endAd(); }
});
if(!ok) endAd();
});
/* =========================================================
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

+180
View File
@@ -0,0 +1,180 @@
'use strict';
/* Smoke-тест «упакованная игра работает» (этап Y4).
*
* Игра запускается НЕ из index.html в репо, а из СВЕЖЕЙ КОПИИ, распакованной
* из dist/fishing-yandex.zip во временный каталог (fs.mkdtemp, распаковка —
* python3 -m zipfile -e, fallback unzip). Это доказательство, что именно
* публикуемый артефакт (архив из dist/) запускается и прогоняет базовый
* сценарий:
* (1) после загрузки state==='MENU',
* (2) YG-стаб (file://, без ?sdk=1): YG.active===false и YG.readySent===true
* (LoadingAPI.ready() уходит на первом кадре),
* (3) клик #btnPlay → state==='IDLE' и меню скрыто,
* (4) клик #btnShop → #shop без hidden (магазин открыт),
* (5) клик #btnCloseShop → #shop с hidden (магазин закрыт),
* (6) ни одной JS-ошибки за прогон (console 'error' + pageerror).
*
* Копия размещается сначала в каталоге под /tmp (os.tmpdir). Если браузер по
* file:// не видит файл (в некоторых контейнерах /tmp — отдельный tmpfs,
* Chromium отвечает ERR_FILE_NOT_FOUND), автоматически перекладывается в
* каталог под $HOME — проверка «архив работает» от этого не меняется.
*
* Запуск (из каталога репо):
* node tools/pack.mjs # сначала собрать архив
* node tests/pack-smoke.js # smoke упакованной копии
*
* Коды выхода: 0 — все 6 пунктов PASS; 1 — есть FAIL (в т.ч. отсутствует архив);
* 2 — краш (в т.ч. не удалось распаковать архив или браузер не видит ни /tmp, ни $HOME).
*/
const puppeteer = require('puppeteer-core');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { spawnSync } = require('child_process');
const CHROMIUM = process.env.CHROMIUM || '/usr/bin/chromium-browser';
const ZIP_PATH = path.resolve(__dirname, '..', 'dist', 'fishing-yandex.zip');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const results = [];
function ok(name, cond, extra = '') {
results.push({ name, pass: !!cond, extra });
console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name + (extra ? ' | ' + extra : ''));
}
// Polling-проверка условия в странице: ~10 с, шаг 300 мс. Шаг грубее, чем
// в test.js (40 мс) — smoke проверяет «игра жива», а не точность кадров.
// Последняя ошибка evaluate в poll (для диагностики при таймауте проверки).
let lastEvalErr = null;
async function poll(page, expr, timeout = 10000, step = 300) {
const t0 = Date.now();
let cur;
while (Date.now() - t0 < timeout) {
try {
cur = await page.evaluate(expr);
lastEvalErr = null;
} catch (e) {
// evaluate мог упасть, пока браузер ещё добрасывает навигацию/контекст
// (ReferenceError «state is not defined» в первую сотню мс после load) —
// трактуем как «условие пока не выполнено» и продолжаем polling, а не падаем.
lastEvalErr = e;
cur = false;
}
if (cur) return cur;
await sleep(step);
}
return null;
}
// Безопасное чтение state (только для диагностики при сбое проверки).
async function safeState(page) {
try { return await page.evaluate(() => state); } catch (e) { return '<ошибка: ' + String(e.message || e) + '>'; }
}
// Распаковка архива в каталог: python3 -m zipfile -e, при отсутствии/ошибке — unzip.
function unzipTo(dir) {
const attempts = [
['python3', ['-m', 'zipfile', '-e', ZIP_PATH, dir]],
['unzip', ['-o', '-q', ZIP_PATH, '-d', dir]],
];
let lastErr = '';
for (const [cmd, args] of attempts) {
const r = spawnSync(cmd, args, { stdio: 'pipe' });
if (!r.error && r.status === 0) return;
lastErr = r.error ? (r.error.code || String(r.error)) : 'exit ' + r.status + (r.stderr ? ': ' + r.stderr.toString().trim() : '');
}
throw new Error('не удалось распаковать ' + ZIP_PATH + ' (python3/unzip: ' + lastErr + ')');
}
// Свежая распакованная копия в tmp-каталоге, ДОСТУПНОМ браузеру по file://.
// Пробуем базовые каталоги по очереди: /tmp, затем $HOME; доступность
// подтверждаем реальным goto (заодно это и есть шаг «загрузить игру»).
async function placeUnpackedCopy(page) {
const bases = [os.tmpdir(), os.homedir()];
const errs = [];
for (const base of bases) {
const tmp = fs.mkdtempSync(path.join(base, 'pack-smoke-'));
try {
unzipTo(tmp);
const gameFile = path.join(tmp, 'index.html');
if (!fs.existsSync(gameFile)) throw new Error('в распакованном архиве нет index.html');
const url = 'file://' + gameFile;
await page.goto(url, { waitUntil: 'load', timeout: 15000 });
if (base !== os.tmpdir()) console.log('pack-smoke: /tmp невидим браузеру (file://) — копия в ' + tmp);
return { tmp, url };
} catch (e) {
// Каталог невидим браузеру (отдельный tmpfs) или ошибка — пробовать дальше.
fs.rmSync(tmp, { recursive: true, force: true });
errs.push(e);
}
}
// Ни /tmp, ни $HOME браузер не видит (ERR_FILE_NOT_FOUND в обоих) — это проблема
// окружения (mount/права), а не архива: отдельное финальное сообщение, exit 2.
if (errs.length === bases.length &&
errs.every(e => /ERR_FILE_NOT_FOUND/.test(String((e && e.message) || e)))) {
throw new Error('браузер не видит ни /tmp, ни $HOME (ERR_FILE_NOT_FOUND) — проверь mount/права (tmpfs?)');
}
throw errs[errs.length - 1];
}
(async () => {
if (!fs.existsSync(ZIP_PATH)) {
console.error('pack-smoke: архив ' + ZIP_PATH + ' отсутствует — сначала node tools/pack.mjs');
process.exit(1);
}
let browser = null;
let tmp = null;
try {
browser = await puppeteer.launch({
executablePath: CHROMIUM, headless: true,
args: ['--no-sandbox', '--disable-gpu', '--hide-scrollbars', '--mute-audio'],
defaultViewport: { width: 390, height: 844, deviceScaleFactor: 2 },
});
const page = await browser.newPage();
const consoleErrors = [];
const pageErrors = [];
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', e => pageErrors.push(String(e)));
const placed = await placeUnpackedCopy(page);
tmp = placed.tmp;
console.log('pack-smoke: ' + ZIP_PATH + ' → ' + tmp + ' (играем на упакованной копии)');
// (1) после загрузки — меню
const st1 = await poll(page, () => state === 'MENU');
ok('state==="MENU" после загрузки', !!st1,
st1 ? '' : (lastEvalErr ? String(lastEvalErr.message || lastEvalErr) : 'timeout, state=' + (await safeState(page))));
// (2) YG-стаб (file://): SDK не загружен, но ready() ушёл на первом кадре
const y1 = await poll(page, () => !!YG && YG.active === false && YG.readySent === true);
ok('YG-стаб: active===false, readySent===true (file://)', !!y1);
// (3) старт игры: IDLE + меню скрыто
await page.click('#btnPlay');
const st3 = await poll(page, () => state === 'IDLE' && document.getElementById('menu').classList.contains('hidden'));
ok('#btnPlay: state==="IDLE", меню скрыто', !!st3);
// (4) магазин открывается
await page.click('#btnShop');
const st4 = await poll(page, () => !document.getElementById('shop').classList.contains('hidden'));
ok('#btnShop: #shop открыт (без hidden)', !!st4);
// (5) магазин закрывается
await page.click('#btnCloseShop');
const st5 = await poll(page, () => document.getElementById('shop').classList.contains('hidden'));
ok('#btnCloseShop: #shop закрыт (с hidden)', !!st5);
// Небольшой запас на «поздние» ошибки из игрового цикла после закрытия магазина.
await sleep(500);
// (6) чистая консоль
ok('нет ошибок JS (console error + pageerror)', consoleErrors.length === 0 && pageErrors.length === 0,
[...consoleErrors, ...pageErrors].join(' | '));
} finally {
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
if (browser) { try { await browser.close(); } catch (_) { /* уже закрыт */ } }
}
const passed = results.filter(r => r.pass).length;
console.log('\n===== PACK-SMOKE: ' + passed + '/' + results.length + ' =====');
process.exit(passed === results.length ? 0 : 1);
})().catch(e => { console.error('SMOKE CRASH:', e.message); process.exit(2); });
+8 -2
View File
@@ -7,7 +7,7 @@
* # пропускается только сохранение остальных файлов)
* node shots.js --list # список доступных кадров
*
* Кадры: menu charge wait bite reel card night weather-rain lake boss shop journal reset
* Кадры: menu charge wait bite reel card card-rv night weather-rain lake boss shop journal reset
*
* Стабильность: вместо фиксированных sleep на ожидание UI — waitForFunction
* (DOM-состояние) и двойной requestAnimationFrame (кадр реально отрисован).
@@ -20,7 +20,7 @@ const GAME_URL = 'file://' + path.resolve(__dirname, '..', 'index.html');
const CHROMIUM = process.env.CHROMIUM || '/usr/bin/chromium-browser';
const sleep = ms => new Promise(r => setTimeout(r, ms));
const FRAMES = ['menu', 'charge', 'wait', 'bite', 'reel', 'card', 'night', 'weather-rain', 'lake', 'boss', 'shop', 'journal', 'reset'];
const FRAMES = ['menu', 'charge', 'wait', 'bite', 'reel', 'card', 'card-rv', 'night', 'weather-rain', 'lake', 'boss', 'shop', 'journal', 'reset'];
// Какие кадры снимать: argv (имена через пробел) или все.
let want;
@@ -102,6 +102,12 @@ async function snap(page, name) {
await page.mouse.up();
await waitPaint(page);
await snap(page, 'card');
// Y2: rewarded «монеты ×2» — клик по #btnRv (стаб — синхронный бонус), пауза,
// кадр карточки с «✓ Бонус получен» и удвоенным cardVal
await page.click('#btnRv');
await sleep(300);
await waitPaint(page);
await snap(page, 'card-rv');
await page.click('#btnCardOk');
await waitState(page, 'IDLE');
+276
View File
@@ -243,6 +243,123 @@ const sleep = ms => new Promise(r => setTimeout(r, ms));
&& afterReset.menu, JSON.stringify(afterReset));
ok('сброс: localStorage перезаписан', afterReset.ls === null || JSON.parse(afterReset.ls).coins === 0);
// --- Y1. SDK (стаб) ---
// YG — обёртка SDK Яндекс Игр: в file:// (без ?sdk=1) скрипт /sdk.js не создаётся —
// YG в стабе (sdk=null, active=false), геймплейная разметка дёргает только счётчики
// YG.counts. На входе — MENU (гарантия секции 9: сброс сейва). Порядок важен: btnPlay
// до магазина — в MENU оверлей #menu (z-20) выше #hud (z-10), координатный клик по
// #btnShop не долетает до кнопки (после btnPlay HUD без оверлея).
// (1) стаб: YG активен, SDK недоступен в file://
const y1stub = await page.evaluate(() => ({
active: YG.active, sdk: YG.sdk,
gs: typeof YG.gameplayStart, gp: typeof YG.gameplayStop
}));
ok('Y1 стаб: YG.active===false, YG.sdk===null, gameplayStart/gameplayStop — функции',
y1stub.active === false && y1stub.sdk === null && y1stub.gs === 'function' && y1stub.gp === 'function',
JSON.stringify(y1stub));
// (2) LoadingAPI: ready() — ровно один раз, в первом кадре frame()
const y1ready = await page.evaluate(() => ({ readySent: YG.readySent, ready: YG.counts.ready }));
ok('Y1 LoadingAPI: readySent=true, counts.ready===1 (первый кадр frame)',
y1ready.readySent === true && y1ready.ready === 1, JSON.stringify(y1ready));
// (3) геймплей-разметка: btnPlay из MENU → start, state=IDLE
// (на входе state=MENU; страховка — флоу сброса btnReset → btnResetYes из секции 9)
{
if ((await page.evaluate(() => state)) !== 'MENU') {
await page.click('#btnReset');
await sleep(150);
await page.click('#btnResetYes');
await sleep(150);
}
}
const y1pB = await page.evaluate(() => ({ s: YG.counts.start, st: state }));
await page.click('#btnPlay');
await sleep(100);
const y1p = await page.evaluate(() => ({
s: YG.counts.start, st: state,
menu: document.getElementById('menu').classList.contains('hidden')
}));
ok('Y1 геймплей-разметка: #btnPlay из MENU → counts.start +1, state=IDLE, меню скрыто',
y1pB.st === 'MENU' && y1p.s === y1pB.s + 1 && y1p.st === 'IDLE' && y1p.menu === true,
JSON.stringify({ before: y1pB, after: y1p }));
// (4) геймплей-разметка: btnShop → stop, btnCloseShop → start (из IDLE — HUD без оверлея)
const y1sB = await page.evaluate(() => ({ s: YG.counts.start, t: YG.counts.stop }));
await page.click('#btnShop');
await sleep(100);
const y1sM = await page.evaluate(() => ({ s: YG.counts.start, t: YG.counts.stop, open: uiOpen }));
await page.click('#btnCloseShop');
await sleep(100);
const y1sE = await page.evaluate(() => ({ s: YG.counts.start, t: YG.counts.stop, open: uiOpen }));
ok('Y1 геймплей-разметка: #btnShop → counts.stop +1 (uiOpen=true), #btnCloseShop → counts.start +1 (uiOpen=false)',
y1sM.t === y1sB.t + 1 && y1sM.s === y1sB.s && y1sM.open === true
&& y1sE.s === y1sM.s + 1 && y1sE.t === y1sM.t && y1sE.open === false,
JSON.stringify({ before: y1sB, mid: y1sM, after: y1sE }));
// (5) пауза: YG.onPause() → paused=true, AC.suspend()
// жест для AudioContext — клики выше (sfx→ac): до паузы AC в running;
// AC.suspend() асинхронен — polling до 'suspended' (в IDLE updateGame без sfx —
// контекст сам не возобновится)
const y1paB = await page.evaluate(() => {
ac();
return { ac: AC ? AC.state : null, t: YG.counts.stop };
});
await page.evaluate(() => YG.onPause());
await waitCond(page, () => AC && AC.state === 'suspended');
const y1pa = await page.evaluate(() => ({ paused: YG.paused, ac: AC ? AC.state : null, t: YG.counts.stop }));
ok('Y1 пауза: YG.onPause() → paused=true, AC.state="suspended", counts.stop +1',
y1pa.paused === true && y1pa.ac === 'suspended' && y1pa.t === y1paB.t + 1,
JSON.stringify({ before: y1paB, after: y1pa }));
// (6) visibilitychange-путь: hidden → onPause, visible → onResume
// сначала сбрасываем паузу из (5) (иначе проверка hidden тривиальна);
// в конце возвращаем document.hidden (маску — свойство — снимаем: нативный
// getter в тестовой вкладке → false)
const y1v = await page.evaluate(() => {
const set = h => {
Object.defineProperty(document, 'hidden', { value: h, configurable: true });
document.dispatchEvent(new Event('visibilitychange'));
};
set(false);
const pre = YG.paused;
set(true);
const hid = YG.paused;
set(false);
const shw = YG.paused;
delete document.hidden;
return { pre, hid, shw, doc: document.hidden };
});
ok('Y1 visibilitychange: visible→hidden → paused=true, hidden→visible → paused=false; document.hidden возвращён (false)',
y1v.pre === false && y1v.hid === true && y1v.shw === false && y1v.doc === false,
JSON.stringify(y1v));
// AC после (5) остаётся suspended (YG.onResume звук не возобновляет — по следующему
// жесту): два клика по 🔊 — sfx→ac→resume, muted при этом не меняется (false→true→false).
// Клик по canvas нельзя: в IDLE это заброс (save.casts/стрик до секции 9a).
await page.click('#btnMute');
await page.click('#btnMute');
// (7) возврат в MENU: секция 9a заходит в игрой кликом по #btnPlay, а он лежит внутри
// меню (в IDLE меню hidden — кнопка не кликабельна). Флоу сброса btnReset →
// btnResetYes (паттерн секции 9): сейв уже обнулён — данные не ломаются, квесты
// дня перегенерируются (9a их сразу чистит)
await page.click('#btnReset');
await sleep(150);
await page.click('#btnResetYes');
await sleep(150);
const y1end = await page.evaluate(() => ({
st: state,
menu: !document.getElementById('menu').classList.contains('hidden'),
paused: YG.paused,
muted: save.muted,
doc: document.hidden
}));
ok('Y1 cleanup: возврат в MENU (меню видно, paused=false, muted=false, document.hidden=false)',
y1end.st === 'MENU' && y1end.menu === true && y1end.paused === false
&& y1end.muted === false && y1end.doc === false,
JSON.stringify(y1end));
// --- 9a. Золотая рыба: форсированный шанс — монеты ×3, счётчик sn, бейдж в карточке ---
await page.click('#btnPlay');
await sleep(150);
@@ -2034,6 +2151,165 @@ const sleep = ms => new Promise(r => setTimeout(r, ms));
&& a22m.fresh.rainV === 0 && a22m.fresh.fog === 0,
JSON.stringify(a22m));
// --- Y2. Реклама (стаб) ---
// В file:// (без ?sdk=1) YG-стаб: showFullscreen → false (реклама не показана —
// мгновенный endAd), showRewarded — синхронная симуляция onOpen→onRewarded→onClose(true).
// Interstitial — на закрытии карточки (btnCardOk → tryInterstitial), гейты: первый
// не раньше AD_FIRST_MS сессии (sessionStartTs), дальше не чаще AD_COOLDOWN_MS
// (lastAdTs). Rewarded «монеты ×2» — кнопка #btnRv на карточке (бонус = rvVal).
// Проверяем счётчики YG.counts (fsAdv/rv), гейты и состояние adShowing/YG.paused.
// ВХОД: IDLE после §10g. Уловы — стандартный цикл (forceBite + pointer), вариант
// форсим 'normal' (детерминированный val без множителей). В конце — сброс в MENU.
{
const y2st = await page.evaluate(() => state);
if (y2st === 'MENU') { await page.click('#btnPlay'); await sleep(150); }
await page.evaluate(() => {
setDayTime(0.3);
save.quests = { date: todayStr(), list: [] }; // квестные монеты не искажают проверки
save.streak = { last: todayStr(), days: 1, shieldWeek: null };
forceVariant = 'normal';
});
// улов до карточки (стандартный цикл)
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => { reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5; });
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
// (1) RV: карточка — btnRv виден и включён, rvVal = цене улова
const y2c0 = await page.evaluate(() => ({
st: state,
val: parseInt(document.getElementById('cardVal').textContent.replace(/[^\d]/g, ''), 10),
rvVal: rvVal,
hidden: document.getElementById('btnRv').classList.contains('hidden'),
dis: document.getElementById('btnRv').disabled,
txt: document.getElementById('btnRv').textContent,
}));
ok('RV карточка: btnRv виден и включён, текст «×2», rvVal = цене улова',
y2c0.st === 'CARD' && y2c0.hidden === false && y2c0.dis === false
&& y2c0.txt.includes('×2') && y2c0.rvVal === y2c0.val,
JSON.stringify(y2c0));
// (2) RV: клик → бонус ×2 (монеты +val, cardVal удвоен, кнопка disabled, rv=1)
const y2c1b = await page.evaluate(() => save.coins);
const rvBefore = await page.evaluate(() => YG.counts.rv); // rv до клика — проверяем дельту, а не абсолютный пин
await page.click('#btnRv');
const y2c1 = await page.evaluate(() => ({
cardVal: document.getElementById('cardVal').textContent,
coins: save.coins,
dis: document.getElementById('btnRv').disabled,
txt: document.getElementById('btnRv').textContent,
rv: YG.counts.rv, adShowing: adShowing, paused: YG.paused,
}));
ok('RV клик: +val монет (итого ×2), cardVal удвоен, «✓ Бонус получен» disabled, rv=1, adShowing/paused=false',
y2c1.coins === y2c1b + y2c0.val
&& y2c1.cardVal === '+' + (y2c0.val * 2) + ' 🪙'
&& y2c1.dis === true && y2c1.txt.includes('Бонус получен')
&& y2c1.rv - rvBefore === 1 && y2c1.adShowing === false && y2c1.paused === false,
JSON.stringify({ before: y2c1b, val: y2c0.val, after: y2c1 }));
// (3) RV: повторный клик — no-op (rvDone=true, монеты не изменились)
const y2c2b = await page.evaluate(() => save.coins);
await page.click('#btnRv');
const y2c2 = await page.evaluate(() => save.coins);
ok('RV повторный клик: no-op (монеты не изменились)', y2c2 === y2c2b,
'before=' + y2c2b + ' after=' + y2c2);
// (4)+(5) закрытие карточки при СВЕЖЕЙ сессии (<2 мин): state=IDLE,
// гейт AD_FIRST_MS блокирует showFullscreen (fsAdv не изменился)
await page.evaluate(() => { sessionStartTs = Date.now(); lastAdTs = 0; });
const y2i0 = await page.evaluate(() => YG.counts.fsAdv);
await page.click('#btnCardOk');
const y2i1 = await page.evaluate(() => ({ st: state, fsAdv: YG.counts.fsAdv }));
ok('RV закрытие карточки: state=IDLE', y2i1.st === 'IDLE', JSON.stringify(y2i1));
ok('interstitial гейт 2 мин: свежая сессия — showFullscreen не вызван (fsAdv не изменился)',
y2i1.fsAdv === y2i0, 'before=' + y2i0 + ' after=' + y2i1.fsAdv);
// (6) сессия старше 2 мин (sessionStartTs=now-130s), lastAdTs=0 → реклама: fsAdv +1
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => { reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5; });
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
await page.evaluate(() => { sessionStartTs = Date.now() - (AD_FIRST_MS + 10000); lastAdTs = 0; });
const y2i2 = await page.evaluate(() => YG.counts.fsAdv);
await page.click('#btnCardOk');
const y2i3 = await page.evaluate(() => ({ fsAdv: YG.counts.fsAdv, adShowing: adShowing, paused: YG.paused }));
ok('interstitial после 2 мин: showFullscreen вызван (fsAdv +1), endAd → adShowing/paused=false',
y2i3.fsAdv === y2i2 + 1 && y2i3.adShowing === false && y2i3.paused === false,
JSON.stringify({ before: y2i2, after: y2i3 }));
// (7) кулдаун 3 мин: улов сразу после рекламы (lastAdTs свежий из (6)) → повтор не вызван
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => { reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5; });
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
const y2i4 = await page.evaluate(() => YG.counts.fsAdv);
await page.click('#btnCardOk');
const y2i5 = await page.evaluate(() => ({ fsAdv: YG.counts.fsAdv }));
ok('interstitial кулдаун 3 мин: улов сразу после рекламы — showFullscreen не вызван (fsAdv не изменился)',
y2i5.fsAdv === y2i4, 'before=' + y2i4 + ' after=' + y2i5.fsAdv);
// (8) кулдаун истёк (lastAdTs=now-190s) → реклама: fsAdv +1
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => { reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5; });
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
await page.evaluate(() => { lastAdTs = Date.now() - (AD_COOLDOWN_MS + 10000); });
const y2i6 = await page.evaluate(() => YG.counts.fsAdv);
await page.click('#btnCardOk');
const y2i7 = await page.evaluate(() => ({ fsAdv: YG.counts.fsAdv, adShowing: adShowing, paused: YG.paused }));
ok('interstitial после истечения кулдауна: showFullscreen вызван (fsAdv +1), adShowing/paused=false',
y2i7.fsAdv === y2i6 + 1 && y2i7.adShowing === false && y2i7.paused === false,
JSON.stringify({ before: y2i6, after: y2i7 }));
}
// cleanup: сброс тест-форсов, возврат в MENU (btnReset → btnResetYes), adShowing/paused=false
await page.evaluate(() => { forceBite = false; forceVariant = null; });
await page.click('#btnReset');
await sleep(150);
await page.click('#btnResetYes');
await sleep(150);
const y2end = await page.evaluate(() => ({
st: state,
menu: !document.getElementById('menu').classList.contains('hidden'),
adShowing: adShowing,
paused: YG.paused,
}));
ok('Y2 cleanup: возврат в MENU (меню видно), adShowing=false, YG.paused=false',
y2end.st === 'MENU' && y2end.menu === true && y2end.adShowing === false && y2end.paused === false,
JSON.stringify(y2end));
// --- Y3. Полировка ---
ok('Y3 полировка: .rvbtn min-height 44px (тап-таргет)',
await page.evaluate(() => getComputedStyle(document.getElementById('btnRv')).minHeight === '44px'));
// --- 11. Ошибки консоли ---
ok('нет ошибок JS (pageerror)', pageErrors.length === 0, pageErrors.join(' | '));
ok('нет ошибок консоли', consoleErrors.length === 0, consoleErrors.join(' | '));
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env node
// Сборка архива для публикации в Яндекс Играх (треб. 1.22): dist/fishing-yandex.zip
// — ТОЛЬКО index.html в корне архива (без docs/tests/screenshots/прочего).
// Основной сборщик — `zip -j` (junk-путь, deflate), fallback — `python3 -m zipfile -c`
// (доступность обеих команд проверяется заранее; если обеих нет — понятная ошибка).
// После сборки — самопроверка: архив распаковывается в каталог tmp (встроенный
// zip-ридер: центральная директория + zlib.inflateRawSync, без unzip) и проверяются
// 5 пунктов: (a) ровно один файл index.html в корне; (b) размер < 100 МБ;
// (c) имена без пробелов/кириллицы; (d) sha256 распакованного файла == sha256
// исходного index.html (byte-идентичность); (e) синтаксис JS, вынутый из
// распакованного файла (тот же паттерн, что в tools/check.mjs), через node --check.
// При любом FAIL — exit 1. Чистый Node, без зависимостей.
// Запуск: node tools/pack.mjs
import { execFileSync, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { inflateRawSync } from 'node:zlib';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const htmlPath = join(root, 'index.html');
const distDir = join(root, 'dist');
const zipPath = join(distDir, 'fishing-yandex.zip');
const MAX_SIZE = 100 * 1024 * 1024; // 100 МБ — лимит требования 1.22
// ---------- Утилиты ----------
function fmtSize(bytes) {
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
// Доступность команды: есть бинарник и она завершается с кодом 0.
function cmdAvailable(cmd, args) {
const r = spawnSync(cmd, args, { stdio: 'ignore' });
return !r.error && r.status === 0;
}
// sha256 файла, в hex.
function sha256File(p) {
return createHash('sha256').update(readFileSync(p)).digest('hex');
}
// ---------- Минимальный zip-ридер (только чтение, для самопроверки) ----------
// Читает EOCD + центральную директорию; данные извлекает по локальным заголовкам.
// Поддерживает method 0 (stored) и 8 (deflate) — ровно то, что дают `zip` и `python3 -m zipfile`.
function readZipEntries(buf) {
// EOCD-запись (0x06054b50) ищем с конца файла (коммент EOCD — до 64 КБ).
let eocd = -1;
const from = Math.max(0, buf.length - 22 - 65535);
for (let i = buf.length - 22; i >= from; i--) {
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
}
if (eocd < 0) throw new Error('не ZIP-архив (EOCD не найден)');
const count = buf.readUInt16LE(eocd + 10);
let off = buf.readUInt32LE(eocd + 16);
const entries = [];
for (let n = 0; n < count; n++) {
if (buf.readUInt32LE(off) !== 0x02014b50) throw new Error('повреждённая центральная директория');
const method = buf.readUInt16LE(off + 10);
const csize = buf.readUInt32LE(off + 20);
const nameLen = buf.readUInt16LE(off + 28);
const extraLen = buf.readUInt16LE(off + 30);
const commentLen = buf.readUInt16LE(off + 32);
const localOff = buf.readUInt32LE(off + 42);
const name = buf.toString('utf8', off + 46, off + 46 + nameLen);
// Начало данных считаем от локального заголовка (имя/extra в нём могут отличаться от центральных).
if (buf.readUInt32LE(localOff) !== 0x04034b50) throw new Error('повреждённый локальный заголовок: ' + name);
const lNameLen = buf.readUInt16LE(localOff + 26);
const lExtraLen = buf.readUInt16LE(localOff + 28);
const dataOff = localOff + 30 + lNameLen + lExtraLen;
entries.push({ name, method, csize, dataOff });
off += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
function extractEntry(buf, e) {
const raw = buf.subarray(e.dataOff, e.dataOff + e.csize);
if (e.method === 0) return Buffer.from(raw); // stored
if (e.method === 8) return inflateRawSync(raw); // deflate
throw new Error(`неподдерживаемый метод сжатия ${e.method}: ${e.name}`);
}
// ---------- Извлечение JS-блоков (тот же паттерн, что в tools/check.mjs) ----------
const JS_TYPES = new Set(['', 'text/javascript', 'application/javascript', 'module']);
const SCRIPT_RE = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
function jsBlocks(html) {
const blocks = [];
for (const m of html.matchAll(SCRIPT_RE)) {
const attrs = m[1];
const code = m[2];
if (/\bsrc\s*=/i.test(attrs)) continue;
const type = (attrs.match(/\btype\s*=\s*["']?([^"'\s>]*)/i)?.[1] ?? '').toLowerCase();
if (!JS_TYPES.has(type)) continue;
if (!code.trim()) continue;
blocks.push({ code, isModule: type === 'module' });
}
return blocks;
}
// ---------- 1. Сборка ----------
if (!existsSync(htmlPath)) {
console.error(`pack: не найден ${htmlPath}`);
process.exit(1);
}
mkdirSync(distDir, { recursive: true });
if (existsSync(zipPath)) {
try {
rmSync(zipPath); // старый архив не оставляем — только свежая сборка
} catch (e) {
console.error(`pack: не удалось удалить старый архив ${zipPath}: ${e.message} (проверь права на файл/каталог) — сборка не запущена`);
process.exit(1);
}
}
// Проверяем сборщики заранее: основной — zip, fallback — Python (python3 → python → py, порядок важен для Windows).
const hasZip = cmdAvailable('zip', ['-v']);
const PY_ZIP_CANDIDATES = ['python3', 'python', 'py'];
const pyZip = PY_ZIP_CANDIDATES.find(c => cmdAvailable(c, ['-m', 'zipfile', '--help'])) || null;
let method;
if (hasZip) method = 'zip';
else if (pyZip) method = pyZip;
else {
console.error('pack: нет ни `zip`, ни Python (`python3`/`python`/`py`) — собрать архив нечем (установи один из инструментов)');
process.exit(1);
}
console.log(`pack: собираю ${zipPath} (способ: ${method})`);
try {
if (method === 'zip') {
// -j — junk-путь: в архив попадает только имя файла (index.html в корне, без каталогов).
execFileSync('zip', ['-q', '-j', zipPath, 'index.html'], { cwd: root });
} else {
execFileSync(pyZip, ['-m', 'zipfile', '-c', zipPath, 'index.html'], { cwd: root });
}
} catch (e) {
console.error(`pack: сборка завершилась ошибкой (${method}): ${e.message}`);
process.exit(1);
}
// ---------- 2. Самопроверка ----------
let failed = false;
const fail = (code, msg) => { failed = true; console.error(`FAIL (${code}) ${msg}`); };
const pass = (code, msg) => console.log(`PASS (${code}) ${msg}`);
const zipBuf = readFileSync(zipPath);
let entries;
try {
entries = readZipEntries(zipBuf);
} catch (e) {
console.error(`pack: не удалось прочитать архив: ${e.message}`);
process.exit(1);
}
const tmp = mkdtempSync(join(tmpdir(), 'yandex-pack-'));
try {
// (a) в архиве ровно один файл index.html в корне
if (entries.length === 1 && entries[0].name === 'index.html') {
pass('a', 'в архиве ровно один файл в корне: index.html');
} else {
fail('a', `ожидался ровно index.html в корне, а в архиве: ${entries.map(e => e.name).join(', ') || '(пусто)'}`);
}
// (b) размер архива < 100 МБ
const zipSize = statSync(zipPath).size;
if (zipSize < MAX_SIZE) pass('b', `размер архива ${fmtSize(zipSize)} < 100 МБ`);
else fail('b', `размер архива ${fmtSize(zipSize)} >= 100 МБ`);
// (c) имена без пробелов и кириллицы
const badName = entries.find(e => /[ ]/.test(e.name) || /\p{Script=Cyrillic}/u.test(e.name));
if (!badName) pass('c', 'имена без пробелов/кириллицы: ' + entries.map(e => e.name).join(', '));
else fail('c', `в имени есть пробел или кириллица: ${badName.name}`);
// (d)+(e) работаем с распакованным файлом в tmp
const unpacked = join(tmp, 'index.html');
writeFileSync(unpacked, extractEntry(zipBuf, entries[0]));
// (d) byte-идентичность: sha256 распакованного == sha256 исходного index.html
const srcHash = sha256File(htmlPath);
const zipHash = sha256File(unpacked);
if (zipHash === srcHash) pass('d', `sha256 распакованного index.html совпадает с исходным (${zipHash.slice(0, 12)}…)`);
else fail('d', `sha256 расходятся: исходный ${srcHash.slice(0, 12)}…, из архива ${zipHash.slice(0, 12)}…`);
// (e) синтаксис JS из распакованного файла — node --check (паттерн tools/check.mjs)
const blocks = jsBlocks(readFileSync(unpacked, 'utf8'));
if (!blocks.length) {
fail('e', 'в распакованном index.html не найдено <script>-блоков с JS');
} else {
let synOk = true;
for (const [i, b] of blocks.entries()) {
const file = join(tmp, `block-${i + 1}.${b.isModule ? 'mjs' : 'js'}`);
writeFileSync(file, b.code);
const r = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' });
if (r.status !== 0) {
synOk = false;
fail('e', `ОШИБКА синтаксиса в блоке ${i + 1} из ${blocks.length}: ${((r.stderr || r.stdout || String(r.error)).trim()).split('\n').slice(0, 3).join(' | ')}`);
}
}
if (synOk) pass('e', `синтаксис JS корректен (node --check, блоков: ${blocks.length})`);
}
} finally {
rmSync(tmp, { recursive: true, force: true });
}
// ---------- 3. Итог ----------
if (failed) {
console.error('pack: FAIL — архив НЕ готов к публикации (см. пункты выше)');
process.exit(1);
}
console.log(`pack: OK — ${zipPath} (${fmtSize(statSync(zipPath).size)}, только index.html в корне)`);