Files
andrey 08a6b0195f Яндекс Игры (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.
2026-09-09 06:47:55 +03:00

181 lines
9.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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); });