Яндекс Игры (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:
@@ -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
|
||||
|
||||
@@ -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()` перед показом и после бонуса).
|
||||
|
||||
## Запуск и публикация
|
||||
|
||||
|
||||
@@ -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` при необходимости поправить).
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
/* =========================================================
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
@@ -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
@@ -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
@@ -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
@@ -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 в корне)`);
|
||||
Reference in New Issue
Block a user