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.
214 lines
11 KiB
JavaScript
214 lines
11 KiB
JavaScript
#!/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 в корне)`);
|