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

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