'use strict'; /* Headless-суита игры «Life — территориальная борьба» (полная, фаза 4). Покрывает: каркас/загрузку, правила Conway и наследование цвета, инструменты, скрытие (только визуальное), FSM, «Сброс», финал, паритет с эталоном прототипа, персистентность/резюм, миграцию/битый сейв, resize, проводку DOM-кнопок и applyIntent. Молчаливые ошибки (pageerror/console.error) — провал. */ const puppeteer = require('puppeteer-core'); const path = require('path'); const { execFileSync } = require('child_process'); const GAME_URL = 'file://' + path.resolve(__dirname, '..', 'index.html'); // Фикстура паритета: кейсы эталона старого прототипа (harness — tools/old-ref/), // для каждого — clean (наша спецификация) и proto (порт с багом parent[]). const parityFixture = require('./parity-fixture.json'); const results = []; function ok(name, cond, extra = '') { results.push({ name, pass: !!cond, extra }); console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name + (extra ? ' | ' + extra : '')); } // Ожидание состояния FSM: поллинг 40 мс вместо фиксированных sleep. async function waitState(page, st, timeout = 8000) { const t0 = Date.now(); let cur; while (Date.now() - t0 < timeout) { cur = await page.evaluate(() => state); if (cur === st) return cur; await new Promise(r => setTimeout(r, 40)); } throw new Error('timeout waiting state "' + st + '", current=' + cur); } // Ожидание произвольного выражения в странице (тот же поллинг). // Доп. аргументы передаются в page.evaluate (для условий, зависящих от параметров). async function waitCond(page, expr, timeout = 5000, ...args) { const t0 = Date.now(); let cur; while (Date.now() - t0 < timeout) { cur = await page.evaluate(expr, ...args); if (cur) return cur; await new Promise(r => setTimeout(r, 40)); } throw new Error('timeout waiting condition, last=' + JSON.stringify(cur)); } const sleep = ms => new Promise(r => setTimeout(r, ms)); // Дождаться, пока resize-обработчик применит новый viewport (вместо фиксированного sleep). async function settleViewport(page, w, h) { await waitCond(page, (w, h) => viewW === w && viewH === h && board.size > 0, 5000, w, h); } // Перезагрузка страницы с ожиданием готовности страницы (резюм из localStorage). async function reload(page) { await page.reload({ waitUntil: 'load' }); await waitCond(page, () => typeof state === 'string' && fieldW > 0 && canvas.width > 0); } // Сброс глобального состояния между разделами тестов (wrap/бюджет/размер/state), // чтобы настройки одного кейса не «протекали» в следующий и не делали суиту флейкой. async function resetGlobals(page) { await page.evaluate(() => { setWrap(false); budget.total = DEFAULT_BUDGET; budget.used1 = 0; budget.used2 = 0; turnStartBudget = { used1: 0, used2: 0 }; selectTool('pencil'); // инструмент не течёт из кейса в кейс stamp = null; // активный паттерн-штамп тоже не течёт между кейсами stampRot = 0; // поворот штампа — тоже tapPlace = false; pressing = false; // режим «постановка по отпусканию» — тоже reason = null; winner = null; // финал предыдущего кейса не влияет на следующий suspended = null; // отложенная партия не течёт из кейса в кейс confirmAction = null; confirmResume = false; // Кампания: режим/уровень/зона/прогресс/ожидающий уровень не текут между кейсами. campaign = false; levelIndex = -1; zoneRect = null; campaignProgress = { unlocked: 1 }; pendingLevel = null; // Пользовательские настройки меню (D1) не текут между кейсами. menuFieldW = DEFAULT_FIELD; menuFieldH = DEFAULT_FIELD; menuWrap = false; menuBudget = DEFAULT_BUDGET; setFieldSize(60); forceState('MENU'); try { localStorage.clear(); } catch (e) {} }); } // Открыть экран «Игра на двоих» и начать партию: клик по #btnPlay (сплэш) открывает // #setup, затем #btnStartTwo стартует новую партию. Заменяет прежний прямой клик // #btnPlay → старт, т.к. настройки поля переехали на отдельный экран TWO_SETUP. async function startTwoPlayer(page) { await page.click('#btnPlay'); await page.click('#btnStartTwo'); } (async () => { const browser = await puppeteer.launch({ executablePath: process.env.CHROMIUM || '/usr/bin/chromium-browser', 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))); await page.goto(GAME_URL, { waitUntil: 'load' }); // Готовность страницы (скрипт инициализирован) — вместо фиксированной паузы. await waitCond(page, () => typeof state === 'string' && fieldW > 0 && canvas.width > 0); // Чистый старт: убираем возможный сейв от прошлого прогона и перезагружаемся, // чтобы базовые проверки шли с пустого MENU, а не с резюма. await page.evaluate(() => { try { localStorage.clear(); } catch (e) {} }); await reload(page); // --- 1. Загрузка документа --- const title = await page.title(); ok('страница загружена, заголовок «Life — территориальная борьба»', title.includes('Life'), title); // --- 2. Холст присутствует и имеет ненулевые размеры (DPR-масштаб) --- const cv = await page.evaluate(() => { const c = document.getElementById('game'); return c ? { w: c.width, h: c.height, cw: c.clientWidth, ch: c.clientHeight } : null; }); ok('canvas #game существует', !!cv); ok('canvas имеет ненулевые размеры', !!cv && cv.w > 0 && cv.h > 0, JSON.stringify(cv)); ok('canvas растянут на весь viewport (390×844 CSS)', !!cv && cv.cw === 390 && cv.ch === 844, JSON.stringify(cv)); // --- 3. Базовое состояние FSM и константы --- ok('typeof state === "string"', (await page.evaluate(() => typeof state)) === 'string'); ok('стартовое state=MENU', (await waitState(page, 'MENU', 2000)) === 'MENU'); ok('SAVE_KEY=life-save-v1', (await page.evaluate(() => SAVE_KEY)) === 'life-save-v1'); ok('дефолтное поле 60×60', await page.evaluate(() => fieldW === 60 && fieldH === 60)); // --- 4. Инфраструктурные заделы --- const ctxType = await page.evaluate(() => typeof ctx); ok('ctx доступен (2d-контекст)', ctxType === 'object', ctxType); ok('waitCond видит резолв условия', !!(await waitCond(page, () => viewW > 0 && viewH > 0, 2000))); // ========================================================================= // ФАЗА 3: ЯДРО ИГРЫ — правила Conway, наследование цвета, инструменты, // скрытие, FSM, «Сброс», финал, контракт глобалов. // Чистые тесты модели идут при state=MENU (RAF симуляцию не крутит). // ========================================================================= // --- 6. Правила Conway --- const blockStatic = await page.evaluate(() => { forceState('MENU'); setFieldASCII('11\n11', 2); const h0 = hashField(); stepN(1); return { h0, h1: hashField() }; }); ok('блок 2×2 неподвижен (хеш не меняется)', blockStatic.h0 === blockStatic.h1, JSON.stringify(blockStatic)); const blinker = await page.evaluate(() => { forceState('MENU'); setFieldASCII('...\n111\n...', 3); const h0 = hashField(); stepN(1); const h1 = hashField(); stepN(1); const h2 = hashField(); return { h0, h1, h2 }; }); ok('блинкер: период 2 (h0=h2, h1≠h0)', blinker.h0 === blinker.h2 && blinker.h0 !== blinker.h1, JSON.stringify(blinker)); const glider = await page.evaluate(() => { forceState('MENU'); const W = 12, H = 12, g = ['.1.', '..1', '111']; const grid = []; for (let y = 0; y < H; y++) { let row = ''; for (let x = 0; x < W; x++) row += (y < 3 && x < 3) ? g[y][x] : '.'; grid.push(row); } setFieldASCII(grid, W); const before = []; for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) if (field[y * W + x]) before.push([x, y]); stepN(4); const after = []; for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) if (field[y * W + x]) after.push([x, y]); return { before, after }; }); { const moved = glider.before.length > 0 && glider.before.length === glider.after.length && glider.before.every(p => glider.after.some(q => q[0] === p[0] + 1 && q[1] === p[1] + 1)); ok('глайдер за 4 шага сдвинулся на (1,1)', moved, JSON.stringify(glider)); } const rules = await page.evaluate(() => { forceState('MENU'); const out = {}; setFieldASCII('111\n...\n...', 3); stepN(1); out.born3 = field[1 * 3 + 1] === 1; // рождение при 3 setFieldASCII('1..\n...\n...', 3); stepN(1); out.dieLone = field[0] === 0; // смерть при <2 setFieldASCII('.1.\n111\n.1.', 3); stepN(1); out.dieOver = field[1 * 3 + 1] === 0; // смерть при >3 setFieldASCII('.1.\n.1.\n.1.', 3); stepN(1); out.surv2 = field[1 * 3 + 1] === 1; // выживание при 2 setFieldASCII('.1.\n111\n...', 3); stepN(1); out.surv3 = field[1 * 3 + 1] === 1; // выживание при 3 return out; }); ok('рождение при 3 соседях', rules.born3, JSON.stringify(rules)); ok('смерть при <2 соседях', rules.dieLone, JSON.stringify(rules)); ok('смерть при >3 соседях', rules.dieOver, JSON.stringify(rules)); ok('выживание при 2 соседях', rules.surv2, JSON.stringify(rules)); ok('выживание при 3 соседях', rules.surv3, JSON.stringify(rules)); // --- 7. Наследование цвета --- const colors = await page.evaluate(() => { forceState('MENU'); const m = [majorityColor(1, 1, 2), majorityColor(1, 2, 2), majorityColor(1, 2, 3)]; setFieldASCII('112\n...\n...', 3); stepN(1); const born1 = field[1 * 3 + 1]; setFieldASCII('122\n...\n...', 3); stepN(1); const born2 = field[1 * 3 + 1]; return { m, born1, born2 }; }); ok('наследование: 1,1,2 → 1', colors.m[0] === 1, JSON.stringify(colors)); ok('наследование: 1,2,2 → 2', colors.m[1] === 2, JSON.stringify(colors)); ok('tie-break: 1,2,3 → 1 (наименьший id)', colors.m[2] === 1, JSON.stringify(colors)); ok('рождение наследует большинство (1,1,2→1)', colors.born1 === 1, JSON.stringify(colors)); ok('рождение наследует большинство (1,2,2→2)', colors.born2 === 2, JSON.stringify(colors)); // --- 8. Границы без заворота --- const bounds = await page.evaluate(() => { forceState('MENU'); setFieldASCII('..1\n..1\n..1', 3); // живой только правый столбец return { n00: liveNeighbors(field, 0, 0) }; // при torus было бы 3 }); ok('границы жёсткие: правый столбец не сосед левому углу', bounds.n00 === 0, JSON.stringify(bounds)); // --- 9. Чистота step и детерминизм --- const purity = await page.evaluate(() => { forceState('MENU'); setFieldASCII('111\n.1.\n..1', 3); const h0 = hashField(field); step(field, next); return { h0, h1: hashField(field) }; }); ok('step не мутирует src (hash до/после)', purity.h0 === purity.h1, JSON.stringify(purity)); const det = await page.evaluate(() => { forceState('MENU'); setFieldASCII('.1.\n..1\n111', 5); stepN(6); const a = hashField(); setFieldASCII('.1.\n..1\n111', 5); stepN(6); const b = hashField(); return { a, b }; }); ok('stepN детерминирован (два прогона → один хеш)', det.a === det.b, JSON.stringify(det)); // --- 10. Инструменты --- const tools = await page.evaluate(() => { forceState('MENU'); const out = {}; setFieldASCII('....\n....\n....\n....', 4); applyBrush(field, 2, 2, TOOLS[0], 1); out.pencil = countByPlayer(field).p1; setFieldASCII('.....\n.....\n.....\n.....\n.....', 5); applyBrush(field, 2, 2, TOOLS[1], 1); out.brush = countByPlayer(field).p1; setFieldASCII('111\n111\n111', 3); applyBrush(field, 1, 1, TOOLS[2], 1); out.erase1 = countByPlayer(field).p1; setFieldASCII('11111\n11111\n11111\n11111\n11111', 5); applyBrush(field, 2, 2, TOOLS[3], 1); out.erase4 = countByPlayer(field).p1; setFieldASCII('....\n....\n....\n....', 4); applyBrush(field, 0, 0, TOOLS[1], 1); out.corner = countByPlayer(field).p1; return out; }); ok('карандаш 1×1 ставит 1 клетку', tools.pencil === 1, 'p1=' + tools.pencil); ok('кисть 3×3 ставит 9 клеток', tools.brush === 9, 'p1=' + tools.brush); ok('ластик 1×1 убирает 1 клетку', tools.erase1 === 8, 'p1=' + tools.erase1); ok('ластик 4×4 убирает 16 клеток', tools.erase4 === 9, 'p1=' + tools.erase4); ok('кламп у границ: кисть в углу не выходит за поле', tools.corner > 0 && tools.corner <= 9, 'corner=' + tools.corner); // --- 11. Скрытие (только визуальное) --- const hiding = await page.evaluate(() => { forceState('MENU'); setFieldASCII('11..\n..22\n11..\n..22', 4); forceState('PLACE_P1'); const mask1 = visibleMask().slice(); const h0 = hashField(); draw(); const h1 = hashField(); return { mask1, same: h0 === h1 }; }); ok('PLACE_P1: маска видимости = [1]', JSON.stringify(hiding.mask1) === '[1]', JSON.stringify(hiding.mask1)); ok('рендер не мутирует field (hash до/после draw)', hiding.same, JSON.stringify(hiding)); const pixels = await page.evaluate(() => { forceState('MENU'); setFieldASCII('2222\n2222\n2222\n2222', 4); const countGreen = () => { const img = ctx.getImageData(0, 0, canvas.width, canvas.height).data; let n = 0; for (let i = 0; i < img.length; i += 4) { if (img[i] === 46 && img[i + 1] === 204 && img[i + 2] === 113) n++; } return n; }; forceState('PLACE_P1'); const ha = hashField(); draw(); const inP1 = countGreen(); forceState('PLACE_P2'); const hb = hashField(); draw(); const inP2 = countGreen(); return { inP1, inP2, sameMask: ha === hb }; }); ok('в PLACE_P1 клетки игрока 2 не рисуются (нет пикселей)', pixels.inP1 === 0, 'green=' + pixels.inP1); ok('в PLACE_P2 клетки игрока 2 рисуются', pixels.inP2 > 0, 'green=' + pixels.inP2); ok('смена маски не мутирует field', pixels.sameMask); // --- 12. FSM: полный цикл --- const fsm = await page.evaluate(() => { newGame(); const t0 = { state: state, turn: turn }; commitTurn(); const t1 = state; handoffNext(); const t2 = { state: state, turn: turn }; setFieldASCII('11..\n....\n....\n..22', 4); // оба цвета → RUNNING переживёт шаг commitTurn(); const t3 = state; // P2 расставил → выбор старта handoffBack(); const t3b = { state: state, turn: turn }; // вернуться к P1 commitTurn(); const t3c = state; // P1 «Готов» → снова передача handoffNext(); const t3d = { state: state, turn: turn }; // → PLACE_P2 commitTurn(); const t3e = state; // P2 «Готов» → выбор старта handoffNext(); const t4 = state; // «Начать симуляцию» → RUNNING pauseGame(); const t5 = state; startEdit(); const t5b = state; // поле не стабильно → дорисовка недоступна fieldStable = true; // форсируем «стабильную» паузу (тест переходов) startEdit(); const t6 = { state: state, turn: turn }; // дорисовка красных commitTurn(); const t7 = state; // назад в паузу startEdit(2); const t8 = { state: state, turn: turn }; // дорисовка зелёных commitTurn(); const t9 = state; // назад в паузу resumeGame(); const t10 = state; // старт симуляции forceState('MENU'); return { t0, t1, t2, t3, t3b, t3c, t3d, t3e, t4, t5, t5b, t6, t7, t8, t9, t10 }; }); ok('newGame → PLACE_P1, turn=1', fsm.t0.state === 'PLACE_P1' && fsm.t0.turn === 1, JSON.stringify(fsm.t0)); ok('Готов (PLACE_P1) → HANDOFF_P1_P2', fsm.t1 === 'HANDOFF_P1_P2', fsm.t1); ok('Передать → PLACE_P2, turn=2', fsm.t2.state === 'PLACE_P2' && fsm.t2.turn === 2, JSON.stringify(fsm.t2)); ok('Готов (PLACE_P2) → HANDOFF_P2_START (выбор: назад к P1 или старт)', fsm.t3 === 'HANDOFF_P2_START', fsm.t3); ok('handoffBack() → PLACE_P1, turn=1 (возврат от P2 к P1)', fsm.t3b.state === 'PLACE_P1' && fsm.t3b.turn === 1, JSON.stringify(fsm.t3b)); ok('после возврата «Готов» (P1) → HANDOFF_P1_P2', fsm.t3c === 'HANDOFF_P1_P2', fsm.t3c); ok('повторная передача → PLACE_P2, turn=2', fsm.t3d.state === 'PLACE_P2' && fsm.t3d.turn === 2, JSON.stringify(fsm.t3d)); ok('повторный «Готов» (P2) → HANDOFF_P2_START', fsm.t3e === 'HANDOFF_P2_START', fsm.t3e); ok('«Начать симуляцию» (HANDOFF_P2_START) → RUNNING', fsm.t4 === 'RUNNING', fsm.t4); ok('Пауза → PAUSED', fsm.t5 === 'PAUSED', fsm.t5); ok('дорисовка на «живом» поле недоступна (startEdit — no-op)', fsm.t5b === 'PAUSED', fsm.t5b); ok('Дорисовать (P1) → EDIT_P1, turn=1', fsm.t6.state === 'EDIT_P1' && fsm.t6.turn === 1, JSON.stringify(fsm.t6)); ok('Готов (EDIT_P1) → PAUSED (не форсим ход P2)', fsm.t7 === 'PAUSED', fsm.t7); ok('Дорисовать (P2) → EDIT_P2, turn=2', fsm.t8.state === 'EDIT_P2' && fsm.t8.turn === 2, JSON.stringify(fsm.t8)); ok('Готов (EDIT_P2) → PAUSED', fsm.t9 === 'PAUSED', fsm.t9); ok('Старт симуляции → RUNNING', fsm.t10 === 'RUNNING', fsm.t10); // --- 12b. Возврат от P2 к P1: чужая половина сохраняется, снимок P1 «чистый» --- const back = await page.evaluate(() => { forceState('MENU'); setFieldASCII('11..\n....\n....\n..22', 4); // P1: (0,0),(1,0); P2: (2,3),(3,3) budget.total = 100; budget.used1 = 0; budget.used2 = 0; forceState('PLACE_P1'); budget.used1 = 2; // как после расстановки P1 commitTurn(); // HANDOFF_P1_P2 handoffNext(); // PLACE_P2 (снимок P2 «чистый») budget.used2 = 2; // как после расстановки P2 commitTurn(); // HANDOFF_P2_START const gate = state; handoffBack(); // → PLACE_P1 const st = { state: state, turn: turn }; const kept2 = field[3 * 4 + 2] === 2 && field[3 * 4 + 3] === 2; // правая половина цела const mask = visibleMask().slice(); selectTool('erase1'); paintTool(0, 0); // ластик P1 снимает свою клетку const erased = { v00: field[0], used1: budget.used1, used2: budget.used2 }; selectTool('pencil'); // не течём инструментом в следующие кейсы forceState('MENU'); return { gate, st, kept2, mask, erased }; }); ok('возврат от P2 к P1: расстановка соперника (правая половина) сохранена', back.st.state === 'PLACE_P1' && back.st.turn === 1 && back.kept2 === true, JSON.stringify(back)); ok('возврат от P2 к P1: чужая половина снова скрыта (маска [1])', JSON.stringify(back.mask) === '[1]', JSON.stringify(back.mask)); ok('возврат от P2 к P1: ластик снимает свою клетку и возвращает свой бюджет (не трогая P2)', back.erased.v00 === 0 && back.erased.used1 === 1 && back.erased.used2 === 2, JSON.stringify(back.erased)); // --- 13. «Сброс» восстанавливает поле к началу хода --- const reset = await page.evaluate(() => { forceState('MENU'); setFieldASCII('11..\n....\n....\n....', 4); forceState('PLACE_P1'); const h0 = hashField(); applyBrush(field, 3, 3, TOOLS[0], 1); const changed = hashField() !== h0; resetGame(); return { changed, back: hashField() === h0 }; }); ok('«Сброс»: дорисованное меняет хеш', reset.changed); ok('«Сброс»: возвращает поле к началу хода', reset.back); // --- 14. Финал: вымирание / ручное завершение / победитель --- const finals = await page.evaluate(() => { forceState('MENU'); setFieldASCII('1111\n....\n....', 4); budget.total = 100; budget.used1 = 0; budget.used2 = 100; // у P2 бюджет исчерпан forceState('RUNNING'); checkEnd(); const extinct = { state: state, reason: reason, winner: winner }; // Бюджет финал не отодвигает: цвет P2 исчез — партия окончена, даже если // у P2 ещё остался неистраченный запас клеток. forceState('MENU'); setFieldASCII('1111\n....\n....', 4); reason = null; winner = null; // сброс финала прошлого кейса budget.total = 100; budget.used1 = 0; budget.used2 = 0; // у P2 есть запас forceState('PAUSED'); checkEnd(); const kept = { state: state, reason: reason, winner: winner }; setFieldASCII('1...\n...2', 4); budget.used1 = 0; budget.used2 = 0; forceState('RUNNING'); endGame(); const manual = { state: state, reason: reason, winner: winner }; forceState('MENU'); return { extinct, kept, manual }; }); ok('вымирание (бюджет исчерпан) → GAME_OVER, reason=extinct', finals.extinct.state === 'GAME_OVER' && finals.extinct.reason === 'extinct', JSON.stringify(finals.extinct)); ok('вымирание: победил игрок 1', finals.extinct.winner === '1', JSON.stringify(finals.extinct)); ok('вымирание объявляется и при непустом бюджете исчезнувшей стороны (бюджет финал не отодвигает)', finals.kept.state === 'GAME_OVER' && finals.kept.reason === 'extinct' && finals.kept.winner === '1', JSON.stringify(finals.kept)); ok('ручное завершение → reason=manual', finals.manual.state === 'GAME_OVER' && finals.manual.reason === 'manual', JSON.stringify(finals.manual)); ok('ничья определяется (draw)', finals.manual.winner === 'draw', JSON.stringify(finals.manual)); // --- 14b. Стабильность поля: дорисовка только на «застывшем» поле --- // Фиксированная точка (блок 2×2): симуляция сама встаёт на паузу, дорисовка открыта. await page.evaluate(() => { forceState('MENU'); // Живые клетки ОБОИХ цветов: иначе вымирание завершит партию до авто-паузы. setFieldASCII('11..\n11..\n..22\n..22', 4); budget.total = 100; budget.used1 = 0; budget.used2 = 0; resetStability(); forceState('RUNNING'); }); await waitCond(page, () => state === 'PAUSED' && fieldStable === true, 3000); { const v = await page.evaluate(() => ({ state, stable: fieldStable, edit1: !document.getElementById('btnEditP1').classList.contains('hidden'), edit2: !document.getElementById('btnEditP2').classList.contains('hidden'), label: document.getElementById('turnLabel').textContent })); ok('стабильность: блок 2×2 → авто-пауза, fieldStable, кнопки дорисовки видны', v.state === 'PAUSED' && v.stable === true && v.edit1 && v.edit2 && /стабильно/.test(v.label), JSON.stringify(v)); } // Короткий цикл (блинкер, период 2) тоже считается стабильностью. Второй цвет — // устойчивый блок 2×2 вдалеке, чтобы поле не «вымерло» по дороге. await page.evaluate(() => { forceState('MENU'); setFieldASCII('........\n.111....\n........\n........\n......22\n......22', 8); budget.used1 = 0; budget.used2 = 0; resetStability(); forceState('RUNNING'); }); await waitCond(page, () => state === 'PAUSED' && fieldStable === true, 3000); ok('стабильность: блинкер (период 2) → авто-пауза + fieldStable', (await page.evaluate(() => state)) === 'PAUSED' && (await page.evaluate(() => fieldStable)) === true); // «Живое» поле (глайдер) не стабилизируется: RUNNING держится, дорисовка недоступна. // Второй цвет (блок 2×2) остаётся жив, чтобы партия не завершилась вымиранием. await page.evaluate(() => { forceState('MENU'); setFieldDim(60, 60); const g = [[1, 0], [2, 1], [0, 2], [1, 2], [2, 2]]; for (const c of g) applyBrush(field, 28 + c[0], 28 + c[1], TOOLS[0], 1); for (const c of [[0, 0], [1, 0], [0, 1], [1, 1]]) applyBrush(field, 10 + c[0], 10 + c[1], TOOLS[0], 2); resetStability(); forceState('RUNNING'); }); await sleep(500); { const s = await page.evaluate(() => ({ state, stable: fieldStable, edit1: !document.getElementById('btnEditP1').classList.contains('hidden') })); ok('нестабильное поле (глайдер): RUNNING не автопаузится, дорисовка скрыта', s.state === 'RUNNING' && s.stable === false && s.edit1 === false, JSON.stringify(s)); } // resumeGame сбрасывает признак стабильности (симуляция снова следит за полем). await page.evaluate(() => { pauseGame(); fieldStable = true; resumeGame(); }); ok('resumeGame сбрасывает fieldStable', (await page.evaluate(() => fieldStable)) === false); // Признак стабильности переживает reload в PAUSED. await page.evaluate(() => { forceState('MENU'); setFieldASCII('11..\n11..\n....\n....', 4); forceState('PAUSED'); // тест-форс: fieldStable = true persist(); }); await reload(page); { const s = await page.evaluate(() => ({ state, stable: fieldStable })); ok('fieldStable переживает reload (PAUSED)', s.state === 'PAUSED' && s.stable === true, JSON.stringify(s)); } // Отложенная партия в меню сохраняет признак стабильности. await page.evaluate(() => { forceState('MENU'); setFieldASCII('11..\n11..\n....\n....', 4); forceState('PAUSED'); toMenu(); }); ok('suspended хранит stable', (await page.evaluate(() => !!(suspended && suspended.stable))) === true); await page.evaluate(() => resumeFromMenu()); ok('resumeFromMenu восстанавливает stable', (await page.evaluate(() => state === 'PAUSED' && fieldStable === true))); await page.evaluate(() => { localStorage.clear(); forceState('MENU'); }); // --- 14c. Авто-финал в тупике: стабильное поле + у отстающего кончился бюджет --- // Когда симуляция сама встаёт на паузу (поле устойчиво), а отстающему игроку // нечем ходить (бюджет исчерпан), исход зафиксирован — партия завершается сама // (reason='stalemate'). Поле: P1 = блок 2×2 (4 клетки, слева), P2 = улей // (6 клеток, справа) — обе фигуры неподвижны, счёт P1 < P2 (отстаёт P1). const STALE = '11......\n11......\n.....22.\n....2..2\n.....22.'; // 1) Отстающий P1 без бюджета → авто-финал reason=stalemate, победа P2. await page.evaluate((ascii) => { forceState('MENU'); setFieldASCII(ascii, 8); reason = null; winner = null; budget.total = 100; budget.used1 = 100; budget.used2 = 0; // у P1 запаса нет resetStability(); forceState('RUNNING'); }, STALE); await waitCond(page, () => state === 'GAME_OVER', 3000); { const v = await page.evaluate(() => ({ state, reason, winner, sub: document.getElementById('gameoverSub').textContent })); ok('тупик: отстающий без бюджета на стабильном поле → GAME_OVER/stalemate', v.state === 'GAME_OVER' && v.reason === 'stalemate' && v.winner === '2', JSON.stringify(v)); ok('тупик: текст финала объясняет кончившийся бюджет', /бюджет/.test(v.sub), v.sub); } // 2) Отстающий P1 С бюджетом → авто-финала нет, остаётся стабильная пауза. await page.evaluate((ascii) => { forceState('MENU'); setFieldASCII(ascii, 8); reason = null; winner = null; budget.total = 100; budget.used1 = 0; budget.used2 = 100; // у P1 есть запас resetStability(); forceState('RUNNING'); }, STALE); await waitCond(page, () => state === 'PAUSED' && fieldStable === true, 3000); await sleep(150); // даём шанс ошибочному авто-финалу, если бы он был { const v = await page.evaluate(() => ({ state, reason, used1: budget.used1 })); ok('тупик: отстающий с бюджетом → игра не завершается (PAUSED, reason=null)', v.state === 'PAUSED' && v.reason === null, JSON.stringify(v)); } // 3) Ничья, оба без бюджета → авто-финал reason=stalemate, winner=draw. await page.evaluate(() => { forceState('MENU'); setFieldASCII('11..\n11..\n..22\n..22', 4); reason = null; winner = null; budget.total = 100; budget.used1 = 100; budget.used2 = 100; resetStability(); forceState('RUNNING'); }); await waitCond(page, () => state === 'GAME_OVER', 3000); { const v = await page.evaluate(() => ({ state, reason, winner, sub: document.getElementById('gameoverSub').textContent })); ok('тупик: ничья при обоих без бюджета → GAME_OVER/stalemate/draw', v.state === 'GAME_OVER' && v.reason === 'stalemate' && v.winner === 'draw', JSON.stringify(v)); ok('тупик: текст ничьей — «у обоих закончился бюджет»', /обоих/.test(v.sub), v.sub); } // 4) Ничья, у одного есть бюджет → авто-финала нет (он может разорвать ничью). await page.evaluate(() => { forceState('MENU'); setFieldASCII('11..\n11..\n..22\n..22', 4); reason = null; winner = null; budget.total = 100; budget.used1 = 100; budget.used2 = 0; resetStability(); forceState('RUNNING'); }); await waitCond(page, () => state === 'PAUSED' && fieldStable === true, 3000); await sleep(150); { const v = await page.evaluate(() => ({ state, reason })); ok('тупик: ничья, но у одного есть бюджет → игра не завершается', v.state === 'PAUSED' && v.reason === null, JSON.stringify(v)); } // 5) reason='stalemate' переживает нормализацию сейва (loadSave принимает значение). await page.evaluate(() => { const base = freshSave(); base.state = 'MENU'; base.reason = 'stalemate'; base.winner = 'draw'; base.field = null; base.turnStartField = null; base.suspended = null; localStorage.setItem(SAVE_KEY, JSON.stringify(base)); }); { const s = await page.evaluate(() => loadSave()); ok('сейв: reason=stalemate нормализуется (не сбрасывается в null)', s && s.reason === 'stalemate' && s.winner === 'draw', JSON.stringify(s && { reason: s.reason, winner: s.winner })); } await page.evaluate(() => { localStorage.clear(); forceState('MENU'); reason = null; winner = null; budget.total = DEFAULT_BUDGET; budget.used1 = 0; budget.used2 = 0; // не течём бюджетом в след. разделы }); // --- 15. Контракт: стабильные глобалы на window --- const globals = await page.evaluate(() => { const names = ['state', 'turn', 'mode', 'tool', 'TOOLS', 'winner', 'field', 'next', 'fieldW', 'fieldH', 'step', 'applyBrush', 'countByPlayer', 'isExtinct', 'liveNeighbors', 'majorityColor', 'hashField', 'newGame', 'commitTurn', 'handoffNext', 'handoffBack', 'pauseGame', 'resumeGame', 'startEdit', 'endGame', 'resetGame', 'checkEnd', 'save', 'SAVE_KEY', 'freshSave', 'loadSave', 'persist', 'forceState', 'setFieldASCII', 'stepN', 'getScores', 'ctx', 'canvas', 'viewW', 'viewH', 'dpr', 'visibleMask', 'applyIntent', 'wrap', 'budget', 'inZone', 'zoneSplit', 'setWrap', 'setBudgetTotal', 'remaining', 'paintTool', 'DEFAULT_BUDGET', 'PATTERNS', 'stamp', 'patternCells', 'findPattern', 'stampRot', 'stampCells', 'wheelAccum', 'tapPlace', 'theme', 'applyTheme', 'setTheme', 'fieldStable', // Кампания (C1–C4): режим/прогресс/данные/функции/предикат зоны. 'campaign', 'levelIndex', 'zoneRect', 'campaignProgress', 'pendingLevel', 'CAMPAIGN_LEVELS', 'CAMPAIGN_PLAYER', 'CAMPAIGN_ENEMY', 'startLevel', 'showLevelSelect', 'inZoneAt', 'campaignZoneFor', 'requestCampaignLevel', 'normalizeProgress', // Пользовательские настройки меню (D1): отдельно от активной игры. 'menuFieldW', 'menuFieldH', 'menuWrap', 'menuBudget']; const missing = names.filter(n => !(n in window)); return { missing }; }); ok('все стабильные глобалы есть на window', globals.missing.length === 0, 'missing=' + globals.missing.join(',')); // ========================================================================= // 16. ПАРИТЕТ С ЭТАЛОНОМ СТАРОГО ПРОТОТИПА (дифтест tools/old-ref/). // Фикстура tests/parity-fixture.json хранит для каждого кейса два эталона: // clean — чистое правило «большинство из 3 живых соседей» (наша спецификация); // proto — точный порт Life::nextStep() прототипа (с багом parent[]). // ВАЖНО: расхождения с proto на плотных двухцветных полях и на cc_3x3_stale // ОЖИДАЕМЫ — это баг parent[] старого кода, а НЕ провал (AGENTS.md требует // чистое правило). Провалом считается расхождение нашего step с clean. // ========================================================================= const parity = await page.evaluate((cases) => { function toAscii(f) { let s = ''; for (let y = 0; y < fieldH; y++) { for (let x = 0; x < fieldW; x++) { const v = f[y * fieldW + x]; s += v === 0 ? '.' : String(v); } s += '\n'; } return s; } const out = {}; for (const c of cases) { forceState('MENU'); setFieldASCII(c.clean[0], c.w); // шаг 0 одинаков у clean и proto const seq = [toAscii(field)]; for (let k = 1; k < c.clean.length; k++) { stepN(1); seq.push(toAscii(field)); } out[c.name] = seq; } return out; }, parityFixture); let cleanFields = 0, cleanMatch = 0; const classic = ['block2x2_5x5', 'blinker_h_5x5', 'glider_8x8']; for (const c of parityFixture) { const ours = parity[c.name]; for (let k = 1; k < c.clean.length; k++) { cleanFields++; if (ours[k] === c.clean[k]) cleanMatch++; } } ok('паритет: наш step == clean на всех фикстурных полях', cleanMatch === cleanFields, cleanMatch + '/' + cleanFields); // Классика: блок/блинкер/глайдер — наш результат совпадает и с clean, и с proto. for (const name of classic) { const c = parityFixture.find(x => x.name === name); const ours = parity[name]; const same = c.clean.every((f, k) => ours[k] === f && f === c.proto[k]); ok('классика == clean и proto: ' + name, same); } // Плотные смешанные двухцветные: наш == clean (у proto из-за бага иначе). for (const name of ['rnd_6x6_s1', 'rnd_8x8_s2']) { const c = parityFixture.find(x => x.name === name); const ours = parity[name]; const sameClean = c.clean.every((f, k) => ours[k] === f); const protoDiff = c.clean.some((f, k) => f !== c.proto[k]); ok('плотное смешанное поле: наш == clean (' + name + ')', sameClean); ok('плотное смешанное поле: proto ≠ clean, баг parent[] (' + name + ')', protoDiff); } // Минимальный контрпример бага: cc_3x3_stale (шаг 1, клетка (1,2): clean=1, proto=2). { const c = parityFixture.find(x => x.name === 'cc_3x3_stale'); const ours = parity[c.name]; const oursClean = c.clean.every((f, k) => ours[k] === f); ok('cc_3x3_stale: наш == clean (баг исправлен)', oursClean); ok('cc_3x3_stale: proto ≠ clean на шаге 1 — известный баг parent[]', c.proto[1] !== c.clean[1], JSON.stringify({ proto: c.proto[1], clean: c.clean[1] })); } // ========================================================================= // 17. applyIntent — реальный ввод в экранной точке (minor m5). // ========================================================================= const intent = await page.evaluate(() => { forceState('MENU'); setFieldASCII('....\n....\n....\n....', 4); forceState('PLACE_P1'); const cs = board.cell; const px = board.x + 1.5 * cs, py = board.y + 2.5 * cs; // клетка (1,2) const acted = applyIntent(px, py); const painted = field[2 * 4 + 1]; selectTool('erase1'); applyIntent(px, py); const erased = field[2 * 4 + 1]; forceState('RUNNING'); // вне фаз рисования const h0 = hashField(); const actedRun = applyIntent(px, py); const sameRun = hashField() === h0; forceState('PLACE_P1'); const actedOut = applyIntent(5, 5); // вне доски forceState('MENU'); return { acted, painted, erased, actedRun, sameRun, actedOut }; }); ok('applyIntent: красит клетку цветом текущего игрока', intent.acted === true && intent.painted === 1, JSON.stringify(intent)); ok('applyIntent: ластик убирает клетку', intent.erased === 0, JSON.stringify(intent)); ok('applyIntent: вне фаз рисования не действует', intent.actedRun === false && intent.sameRun === true, JSON.stringify(intent)); ok('applyIntent: клик вне доски не действует', intent.actedOut === false, JSON.stringify(intent)); // ========================================================================= // 18. Проводка DOM-кнопок и Pointer Events (minor m6): реальные page.click // и page.mouse — регресс в addEventListener должен ловиться. // ========================================================================= await page.evaluate(() => forceState('MENU')); await page.select('#optSize', '60x60'); ok('клик размера 60×60 из меню → fieldW=60', (await page.evaluate(() => fieldW)) === 60); await startTwoPlayer(page); ok('старт через #btnPlay/#btnStartTwo → PLACE_P1', (await page.evaluate(() => state)) === 'PLACE_P1'); // Инструменты по клику (доступны в фазе рисования). await page.click('#toolBrush'); ok('клик #toolBrush → tool=brush', (await page.evaluate(() => tool.id)) === 'brush'); await page.click('#toolErase4'); ok('клик #toolErase4 → tool=erase4', (await page.evaluate(() => tool.id)) === 'erase4'); await page.click('#toolPencil'); ok('клик #toolPencil → tool=pencil', (await page.evaluate(() => tool.id)) === 'pencil'); // Pointer Events: реальный клик мышью красит клетку под курсором. { const hEmpty = await page.evaluate(() => hashField()); const geom = await page.evaluate(() => ({ x: board.x, y: board.y, c: board.cell })); await page.mouse.click(geom.x + 5.5 * geom.c, geom.y + 5.5 * geom.c); const painted = await page.evaluate(() => field[5 * fieldW + 5]); const hPaint = await page.evaluate(() => hashField()); await page.click('#btnReset'); const hReset = await page.evaluate(() => hashField()); ok('page.mouse (pointerdown/up) красит клетку карандашом', painted === 1, 'v=' + painted); ok('клик #btnReset возвращает поле к началу хода', hPaint !== hEmpty && hReset === hEmpty, JSON.stringify({ hEmpty, hPaint, hReset })); } // Правая кнопка мыши — ластик: ставим клетку ЛКМ, стираем ПКМ (ластик действует // только на время удержания и не меняет активный инструмент). { const g = await page.evaluate(() => ({ x: board.x, y: board.y, c: board.cell })); await page.mouse.click(g.x + 5.5 * g.c, g.y + 5.5 * g.c); // ЛКМ: ставим клетку const painted = await page.evaluate(() => field[5 * fieldW + 5]); await page.mouse.down({ button: 'right' }); // ПКМ: стираем await page.mouse.up({ button: 'right' }); const erased = await page.evaluate(() => ({ v: field[5 * fieldW + 5], used: budget.used1, tool: tool.id })); ok('ПКМ стирает клетку (ластик на время удержания, инструмент не меняется)', painted === 1 && erased.v === 0 && erased.used === 0 && erased.tool === 'pencil', JSON.stringify({ painted: painted, erased: erased })); } // Основной цикл FSM — все переходы через клики (для живучести рисуем два блока). await page.evaluate(() => { applyBrush(field, 0, 0, TOOLS[0], 1); applyBrush(field, 1, 0, TOOLS[0], 1); applyBrush(field, 0, 1, TOOLS[0], 1); applyBrush(field, 1, 1, TOOLS[0], 1); }); await page.click('#btnReady'); ok('клик #btnReady (PLACE_P1) → HANDOFF_P1_P2', (await page.evaluate(() => state)) === 'HANDOFF_P1_P2'); { const v = await page.evaluate(() => ({ back: !document.getElementById('btnHandoffBack').classList.contains('hidden'), label: document.getElementById('btnHandoff').textContent, })); ok('HANDOFF_P1_P2: кнопка «Вернуться к игроку 1» скрыта (возврат — после P2)', v.back === false && v.label === 'Передать', JSON.stringify(v)); } await page.click('#btnHandoff'); { const s = await page.evaluate(() => ({ state, turn })); ok('клик #btnHandoff → PLACE_P2, turn=2', s.state === 'PLACE_P2' && s.turn === 2, JSON.stringify(s)); } await page.evaluate(() => { applyBrush(field, 20, 20, TOOLS[0], 2); applyBrush(field, 21, 20, TOOLS[0], 2); applyBrush(field, 20, 21, TOOLS[0], 2); applyBrush(field, 21, 21, TOOLS[0], 2); }); await page.click('#btnReady'); ok('клик #btnReady (PLACE_P2) → HANDOFF_P2_START (выбор)', (await page.evaluate(() => state)) === 'HANDOFF_P2_START'); { const v = await page.evaluate(() => ({ back: !document.getElementById('btnHandoffBack').classList.contains('hidden'), label: document.getElementById('btnHandoff').textContent, })); ok('HANDOFF_P2_START: видны «Начать симуляцию» и «Вернуться к игроку 1»', v.back === true && v.label === 'Начать симуляцию', JSON.stringify(v)); } // Возврат от P2 к P1: расстановка P1 сохранена, чужая половина снова скрыта. await page.click('#btnHandoffBack'); { const s = await page.evaluate(() => ({ state, turn, h0: field[0], h11: field[1 * 60 + 1], mask: visibleMask().slice() })); ok('клик #btnHandoffBack → PLACE_P1, turn=1 (расстановка P1 сохранена, чужая скрыта)', s.state === 'PLACE_P1' && s.turn === 1 && s.h0 === 1 && s.h11 === 1 && JSON.stringify(s.mask) === '[1]', JSON.stringify(s)); } await page.click('#btnReady'); ok('повторный #btnReady (PLACE_P1) → HANDOFF_P1_P2', (await page.evaluate(() => state)) === 'HANDOFF_P1_P2'); await page.click('#btnHandoff'); ok('повторный #btnHandoff → PLACE_P2', (await page.evaluate(() => state)) === 'PLACE_P2'); await page.click('#btnReady'); await page.click('#btnHandoff'); { const st = await page.evaluate(() => state); ok('клик #btnHandoff (HANDOFF_P2_START) → симуляция (RUNNING или авто-пауза)', st === 'RUNNING' || st === 'PAUSED', st); } // Поле из стабильных блоков 2×2: симуляция сама останавливается, дорисовка открывается. await waitCond(page, () => state === 'PAUSED' && fieldStable === true, 5000); ok('стабильное поле: авто-пауза + fieldStable', (await page.evaluate(() => state)) === 'PAUSED'); await page.click('#btnEditP1'); { const s = await page.evaluate(() => ({ state, turn })); ok('клик #btnEditP1 (PAUSED) → EDIT_P1, turn=1', s.state === 'EDIT_P1' && s.turn === 1, JSON.stringify(s)); } await page.click('#btnReady'); ok('клик #btnReady (EDIT_P1) → PAUSED', (await page.evaluate(() => state)) === 'PAUSED'); await page.click('#btnEditP2'); { const s = await page.evaluate(() => ({ state, turn })); ok('клик #btnEditP2 (PAUSED) → EDIT_P2, turn=2 (выбор игрока, без форс-цепочки)', s.state === 'EDIT_P2' && s.turn === 2, JSON.stringify(s)); } await page.click('#btnReady'); ok('клик #btnReady (EDIT_P2) → PAUSED', (await page.evaluate(() => state)) === 'PAUSED'); // «Живое» поле (глайдер поверх блоков): не стабилизируется — ручная пауза не открывает дорисовку. await page.evaluate(() => { const g = [[1, 0], [2, 1], [0, 2], [1, 2], [2, 2]]; for (const c of g) applyBrush(field, 28 + c[0], 28 + c[1], TOOLS[0], 1); }); await page.click('#btnResume'); ok('клик #btnResume (старт симуляции) → RUNNING', (await page.evaluate(() => state)) === 'RUNNING'); await page.click('#btnPause'); { const st = await page.evaluate(() => ({ state, stable: fieldStable, edit1: !document.getElementById('btnEditP1').classList.contains('hidden') })); ok('ручная пауза на «живом» поле: дорисовка скрыта', st.state === 'PAUSED' && st.stable === false && st.edit1 === false, JSON.stringify(st)); } await page.click('#btnResume'); ok('возобновление «живого» поля → RUNNING', (await page.evaluate(() => state)) === 'RUNNING'); // «Завершить партию» требует подтверждения (партию нельзя потерять одним кликом). await page.click('#btnEnd'); { const c = await page.evaluate(() => ({ state, act: confirmAction, vis: !document.getElementById('confirm').classList.contains('hidden'), head: document.getElementById('confirmHead').textContent, })); ok('клик #btnEnd → диалог подтверждения, партия ещё идёт (RUNNING→PAUSED)', c.act === 'end' && c.vis === true && c.state === 'PAUSED' && /Завершить/.test(c.head), JSON.stringify(c)); } await page.click('#btnConfirmCancel'); { const c = await page.evaluate(() => ({ state, act: confirmAction, running: state === 'RUNNING' })); ok('«Отмена» завершения возвращает идущую симуляцию (RUNNING)', c.act === null && c.running === true, JSON.stringify(c)); } await page.click('#btnEnd'); // На время диалога симуляция на паузе: поле за оверлеем не меняется. { const h1 = await page.evaluate(() => hashField()); await sleep(350); const h2 = await page.evaluate(() => hashField()); ok('диалог завершения: симуляция на паузе — поле за оверлеем не меняется', h1 === h2, JSON.stringify({ h1, h2 })); } await page.click('#btnConfirmOk'); ok('подтверждение «Завершить» → GAME_OVER, reason=manual', (await page.evaluate(() => state)) === 'GAME_OVER' && (await page.evaluate(() => reason)) === 'manual'); await page.click('#btnRevenge'); ok('клик #btnRevenge → PLACE_P1 (новая партия)', (await page.evaluate(() => state)) === 'PLACE_P1'); await page.click('#btnSettings'); ok('клик #btnSettings → MENU', (await page.evaluate(() => state)) === 'MENU'); // Оверлеи паузы/финала — кнопки по клику (состояние подготавливаем forceState). await page.evaluate(() => forceState('PAUSED')); await page.click('#btnPauseMenu'); ok('клик #btnPauseMenu → MENU', (await page.evaluate(() => state)) === 'MENU'); await page.evaluate(() => forceState('PAUSED')); await page.click('#btnEndPause'); ok('клик #btnEndPause → диалог подтверждения, пауза сохраняется', (await page.evaluate(() => confirmAction)) === 'end' && (await page.evaluate(() => state)) === 'PAUSED'); await page.click('#btnConfirmCancel'); ok('«Отмена» из PAUSED оставляет паузу', (await page.evaluate(() => state)) === 'PAUSED'); await page.click('#btnEndPause'); await page.click('#btnConfirmOk'); ok('подтверждение #btnEndPause → GAME_OVER', (await page.evaluate(() => state)) === 'GAME_OVER'); await page.click('#btnGameoverMenu'); ok('клик #btnGameoverMenu → MENU', (await page.evaluate(() => state)) === 'MENU'); // --- 18a. Пауза БЕЗ диалога поверх: кнопки инлайн, поле остаётся видно --- // Оверлей #pause удалён; управление паузой живёт в нижней панели/боковой зоне // (Пауза → Старт симуляции / Дорисовать красным / Дорисовать зелёным / Завершить / В меню). ok('#pause (модальный оверлей) удалён из разметки', (await page.evaluate(() => !!document.getElementById('pause'))) === false); await page.evaluate(() => { localStorage.clear(); forceState('MENU'); // «Живое» поле (глайдер) — симуляция НЕ стабилизируется, поэтому кнопки RUNNING // остаются доступны для кликов (стабильные блоки авто-паузятся новым правилом). // Второй цвет (блок 2×2) остаётся жив, чтобы партия не завершилась вымиранием. setFieldDim(60, 60); const g = [[1, 0], [2, 1], [0, 2], [1, 2], [2, 2]]; for (const c of g) applyBrush(field, 28 + c[0], 28 + c[1], TOOLS[0], 1); for (const c of [[0, 0], [1, 0], [0, 1], [1, 1]]) applyBrush(field, 10 + c[0], 10 + c[1], TOOLS[0], 2); forceState('RUNNING'); }); { const v = await page.evaluate(() => { const vis = id => !document.getElementById(id).classList.contains('hidden'); return { pause: vis('btnPause'), editP1: vis('btnEditP1'), editP2: vis('btnEditP2'), end: vis('btnEnd'), resume: vis('btnResume'), endPause: vis('btnEndPause'), menu: vis('btnPauseMenu'), actions: !document.getElementById('actions').classList.contains('hidden') }; }); ok('RUNNING: видны «Пауза» и «Завершить», кнопки дорисовки скрыты', v.pause && v.end && !v.editP1 && !v.editP2 && !v.resume && !v.endPause && !v.menu && v.actions, JSON.stringify(v)); } // Ряд скорости: 4 кнопки 1×/2×/4×/8×, выбор одной; виден только в RUNNING (транзиентно, не в сейв). ok('RUNNING: ряд скорости #speedRow виден', (await page.evaluate(() => !document.getElementById('speedRow').classList.contains('hidden'))) === true); { const s = await page.evaluate(() => { const a = id => document.getElementById(id).classList.contains('active'); return { m: speedMult, s1: a('speed1'), s2: a('speed2'), s4: a('speed4'), s8: a('speed8') }; }); ok('скорость по умолчанию 1×, активна только кнопка 1×', s.m === 1 && s.s1 && !s.s2 && !s.s4 && !s.s8, JSON.stringify(s)); } await page.click('#speed2'); { const s = await page.evaluate(() => { const a = id => document.getElementById(id).classList.contains('active'); return { m: speedMult, s1: a('speed1'), s2: a('speed2'), s4: a('speed4'), s8: a('speed8') }; }); ok('клик #speed2 → 2×, активна только кнопка 2×', s.m === 2 && !s.s1 && s.s2 && !s.s4 && !s.s8, JSON.stringify(s)); } await page.click('#speed4'); { const s = await page.evaluate(() => { const a = id => document.getElementById(id).classList.contains('active'); return { m: speedMult, s1: a('speed1'), s2: a('speed2'), s4: a('speed4'), s8: a('speed8') }; }); ok('клик #speed4 → 4×, активна только кнопка 4×', s.m === 4 && !s.s1 && !s.s2 && s.s4 && !s.s8, JSON.stringify(s)); } await page.click('#speed8'); { const s = await page.evaluate(() => { const a = id => document.getElementById(id).classList.contains('active'); return { m: speedMult, s1: a('speed1'), s2: a('speed2'), s4: a('speed4'), s8: a('speed8') }; }); ok('клик #speed8 → 8×, активна только кнопка 8×', s.m === 8 && !s.s1 && !s.s2 && !s.s4 && s.s8, JSON.stringify(s)); } await page.click('#speed1'); ok('клик #speed1 → возврат к 1×', (await page.evaluate(() => speedMult)) === 1); // На паузе — отдельные кнопки дорисовки каждого игрока и запуск симуляции. await page.evaluate(() => forceState('PAUSED')); { const v = await page.evaluate(() => { const vis = id => !document.getElementById(id).classList.contains('hidden'); return { pause: vis('btnPause'), end: vis('btnEnd'), resume: vis('btnResume'), editP1: vis('btnEditP1'), editP2: vis('btnEditP2'), endPause: vis('btnEndPause'), menu: vis('btnPauseMenu') }; }); ok('PAUSED: видны «Старт/Дорисовать красным/Дорисовать зелёным/Завершить/В меню»', !v.pause && !v.end && v.resume && v.editP1 && v.editP2 && v.endPause && v.menu, JSON.stringify(v)); } ok('PAUSED: ряд скорости #speedRow скрыт', (await page.evaluate(() => document.getElementById('speedRow').classList.contains('hidden'))) === true); // Выбор игрока для дорисовки не форсируется: можно дорисовать зелёным сразу. await page.click('#btnEditP2'); { const s = await page.evaluate(() => ({ state, turn })); ok('клик #btnEditP2 из PAUSED → EDIT_P2, turn=2', s.state === 'EDIT_P2' && s.turn === 2, JSON.stringify(s)); } await page.click('#btnReady'); ok('Готов из EDIT_P2 → PAUSED (можно снова выбрать или стартовать)', (await page.evaluate(() => state)) === 'PAUSED'); await page.evaluate(() => forceState('MENU')); // ========================================================================= // 19. Персистентность и резюм партии (localStorage life-save-v1). // ========================================================================= // R1: резюм в PLACE_P2 — поле восстанавливается ЦЕЛИКОМ (включая чужой цвет). const r1before = await page.evaluate(() => { localStorage.clear(); forceState('MENU'); setFieldSize(60); selectTool('pencil'); newGame(); // PLACE_P1 commitTurn(); // HANDOFF_P1_P2 handoffNext(); // PLACE_P2, turn=2 applyBrush(field, 3, 4, TOOLS[0], 2); applyBrush(field, 6, 6, TOOLS[1], 2); applyBrush(field, 0, 0, TOOLS[0], 1); // «чужой» цвет — тоже должен сохраниться persist(); return { state, turn, hash: hashField(), scores: getScores() }; }); await reload(page); const r1after = await page.evaluate(() => ({ state, turn, hash: hashField(), scores: getScores() })); ok('резюм: state PLACE_P2 восстановлен', r1after.state === 'PLACE_P2', JSON.stringify(r1after)); ok('резюм: turn=2 восстановлен', r1after.turn === 2, JSON.stringify(r1after)); ok('резюм: поле восстановлено целиком (hash совпал)', r1after.hash === r1before.hash, JSON.stringify({ before: r1before.hash, after: r1after.hash })); ok('резюм: счёт обеих сторон восстановлен', r1after.scores.p1 === r1before.scores.p1 && r1after.scores.p2 === r1before.scores.p2, JSON.stringify(r1after.scores)); { const play = await page.evaluate(() => { const cs = board.cell; const before = getScores().p2; // Зона игрока 2 — правая половина (поле 60: x>=30), поэтому x=45. const acted = applyIntent(board.x + 45.5 * cs, board.y + 9.5 * cs); // ход игрока 2 return { acted, before, after: getScores().p2 }; }); ok('резюм: партия играбельна (applyIntent красит от имени turn=2)', play.acted === true && play.after === play.before + 1, JSON.stringify(play)); } { await page.click('#btnReset'); // снимок начала хода был пустым const s = await page.evaluate(() => getScores()); ok('резюм: «Сброс» использует сохранённый снимок начала хода', s.p1 === 0 && s.p2 === 0, JSON.stringify(s)); } // R2: резюм в RUNNING — поле восстановлено до первого шага (скорость снижена). const r2before = await page.evaluate(() => { localStorage.clear(); forceState('MENU'); setFieldASCII('11..\n11..\n..22\n..22', 4); // два стабильных блока разных цветов STEPS_PER_SEC = 0.5; window.STEPS_PER_SEC = 0.5; // чтобы за время reload не было шага forceState('RUNNING'); persist(); return { hash: hashField(), scores: getScores() }; }); await reload(page); const r2after = await page.evaluate(() => ({ state, mode, hash: hashField(), scores: getScores(), sps: STEPS_PER_SEC })); ok('резюм RUNNING: state=RUNNING', r2after.state === 'RUNNING', JSON.stringify(r2after)); ok('резюм RUNNING: mode=run (симуляция продолжается)', r2after.mode === 'run', JSON.stringify(r2after)); ok('резюм RUNNING: поле восстановлено до первого шага (hash)', r2after.hash === r2before.hash, JSON.stringify({ before: r2before.hash, after: r2after.hash })); ok('резюм: STEPS_PER_SEC пережил reload', r2after.sps === 0.5, 'sps=' + r2after.sps); await page.evaluate(() => { STEPS_PER_SEC = 8; window.STEPS_PER_SEC = 8; localStorage.clear(); forceState('MENU'); }); // R3: настройки (размер, инструмент) применяются к новой партии и переживают reload. await reload(page); await page.select('#optSize', '40x40'); await startTwoPlayer(page); await page.click('#toolBrush'); { const s = await page.evaluate(() => ({ fw: fieldW, tool: tool.id, len: field.length })); ok('настройка: размер 40×40 применён к новой партии', s.fw === 40 && s.len === 1600, JSON.stringify(s)); ok('настройка: инструмент brush выбран кликом', s.tool === 'brush', JSON.stringify(s)); } await reload(page); { const s = await page.evaluate(() => ({ fw: fieldW, tool: tool.id, len: field.length, state })); ok('настройка: размер 40×40 пережил reload', s.fw === 40 && s.len === 1600, JSON.stringify(s)); ok('настройка: инструмент brush пережил reload', s.tool === 'brush', JSON.stringify(s)); ok('настройка: незавершённая партия (PLACE_P1) пережила reload', s.state === 'PLACE_P1', JSON.stringify(s)); } await page.evaluate(() => { STEPS_PER_SEC = 12; window.STEPS_PER_SEC = 12; persist(); }); await reload(page); ok('настройка: STEPS_PER_SEC пережил reload', (await page.evaluate(() => STEPS_PER_SEC)) === 12); await page.evaluate(() => { localStorage.clear(); STEPS_PER_SEC = 8; window.STEPS_PER_SEC = 8; forceState('MENU'); }); // ========================================================================= // 20. Миграция / битый сейв: loadSave() не падает, игра стартует в MENU. // ========================================================================= await page.evaluate(() => localStorage.setItem(SAVE_KEY, 'не json{{{')); await reload(page); ok('битый JSON → старт в MENU', (await page.evaluate(() => state)) === 'MENU'); ok('битый JSON → loadSave() возвращает null', (await page.evaluate(() => loadSave())) === null); await page.evaluate(() => localStorage.setItem(SAVE_KEY, JSON.stringify([1, 2, 3]))); await reload(page); ok('сейв-массив (битая форма) → старт в MENU', (await page.evaluate(() => state)) === 'MENU'); ok('сейв-массив (битая форма) → loadSave()=null', (await page.evaluate(() => loadSave())) === null); // «Старый» формат без версии/состояния: настройки читаются, партии нет → MENU. await page.evaluate(() => localStorage.setItem(SAVE_KEY, JSON.stringify({ fieldW: 80, fieldH: 80, tool: 'brush' }))); await reload(page); { const m = await page.evaluate(() => ({ state, fw: fieldW, tool: tool.id, norm: loadSave() })); ok('старый сейв без версии → старт в MENU', m.state === 'MENU', JSON.stringify({ state: m.state })); ok('старый сейв: размер 80 применён', m.fw === 80, 'fw=' + m.fw); ok('старый сейв: инструмент brush применён', m.tool === 'brush', 'tool=' + m.tool); ok('старый сейв нормализован (v=SAVE_VERSION, state=MENU)', m.norm && m.norm.v === 2 && m.norm.state === 'MENU', JSON.stringify(m.norm)); } // «Слишком новый» формат — не читаем (страховка от несовместимости). await page.evaluate(() => localStorage.setItem(SAVE_KEY, JSON.stringify({ v: 999, state: 'PLACE_P1', fieldW: 4, fieldH: 4, field: 'AA==' }))); ok('сейв новее SAVE_VERSION → loadSave()=null', (await page.evaluate(() => loadSave())) === null); await page.evaluate(() => { localStorage.clear(); forceState('MENU'); }); // ========================================================================= // 21. Resize: canvas пересчитывается под viewport и DPR (кап 2), без ошибок. // ========================================================================= async function checkResize(w, h, dsf, expectDpr) { await page.setViewport({ width: w, height: h, deviceScaleFactor: dsf }); await settleViewport(page, w, h); const r = await page.evaluate(() => ({ vw: viewW, vh: viewH, cw: canvas.width, ch: canvas.height, dpr, bs: board.size })); ok('resize ' + w + '×' + h + '@' + dsf + ': viewW/viewH соответствуют', r.vw === w && r.vh === h && r.bs > 0, JSON.stringify(r)); ok('resize ' + w + '×' + h + '@' + dsf + ': canvas = viewport×DPR (кап 2)', r.cw === Math.round(w * expectDpr) && r.ch === Math.round(h * expectDpr) && r.dpr === expectDpr, JSON.stringify(r)); } await checkResize(800, 600, 1, 1); await checkResize(320, 480, 3, 2); await checkResize(390, 844, 2, 2); // ========================================================================= // 22. R2-2: производительный offscreen-рендер + подсветка зон + HUD бюджета + // новые UI-элементы меню (чекбокс wrap, ввод/пресеты бюджета, сетка размеров). // ========================================================================= // --- 22a. Offscreen-слой: dirty-флаг и alpha видимых/скрытых клеток --- const off = await page.evaluate(() => { forceState('MENU'); setFieldASCII('2222\n2222\n2222\n2222', 4); const dirtyAfterSet = renderDirty; // setFieldASCII метит dirty forceState('PLACE_P1'); draw(); // пересборка + сброс dirty const dirtyAfterDraw = renderDirty; let opaque1 = 0; for (let i = 3; i < fieldImage.data.length; i += 4) if (fieldImage.data[i] > 0) opaque1++; forceState('PLACE_P2'); draw(); let opaque2 = 0; for (let i = 3; i < fieldImage.data.length; i += 4) if (fieldImage.data[i] > 0) opaque2++; forceState('MENU'); return { dirtyAfterSet, dirtyAfterDraw, opaque1, opaque2 }; }); ok('offscreen: renderDirty выставлен после setFieldASCII', off.dirtyAfterSet === true); ok('offscreen: renderDirty сброшен после draw', off.dirtyAfterDraw === false); ok('offscreen: в PLACE_P1 клетки цвета 2 скрыты (alpha=0)', off.opaque1 === 0, 'opaque=' + off.opaque1); ok('offscreen: в PLACE_P2 клетки цвета 2 отрисованы (alpha=255)', off.opaque2 === 16, 'opaque=' + off.opaque2); // --- 22b. Подсветка зон: неактивная половина темнее активной (оверлей под клетками) --- const zone = await page.evaluate(() => { forceState('MENU'); setFieldSize(300); forceState('PLACE_P1'); draw(); const sample = (cx, cy) => { const px = Math.round(cx * dpr), py = Math.round(cy * dpr); const im = ctx.getImageData(px, py, 1, 1).data; return im[0] + im[1] + im[2]; }; const left = sample(board.x + board.size * 0.25, board.y + board.size * 0.5); const right = sample(board.x + board.size * 0.75, board.y + board.size * 0.5); forceState('MENU'); setFieldSize(60); return { left, right }; }); ok('подсветка зон: неактивная половина в PLACE_P1 темнее активной', zone.right < zone.left, JSON.stringify(zone)); // --- 22c. HUD бюджета: текст/цвет в фазах рисования, скрытие в HANDOFF/RUNNING, «0» красным --- const hud = await page.evaluate(() => { const lab = document.getElementById('budgetLabel'); forceState('MENU'); setFieldASCII('..\n..', 2); setBudgetTotal(100); selectTool('pencil'); forceState('PLACE_P1'); syncDom(); const in1 = { hidden: lab.classList.contains('hidden'), text: lab.textContent, p1: lab.classList.contains('p1') }; paintTool(0, 0); // потратим 1 syncDom(); const after = lab.textContent; setBudgetTotal(1); // остаток станет 0 syncDom(); const zero = { text: lab.textContent, zero: lab.classList.contains('zero') }; forceState('HANDOFF_P1_P2'); syncDom(); const inHandoff = lab.classList.contains('hidden'); forceState('RUNNING'); syncDom(); const inRun = lab.classList.contains('hidden'); forceState('MENU'); setBudgetTotal(100); return { in1, after, zero, inHandoff, inRun }; }); ok('HUD бюджета виден в PLACE_P1 и окрашен в цвет игрока 1', hud.in1.hidden === false && hud.in1.p1 === true && /100/.test(hud.in1.text), JSON.stringify(hud.in1)); ok('HUD бюджета показывает остаток после траты', /99/.test(hud.after), hud.after); ok('HUD бюджета при нуле получает класс .zero', hud.zero.zero === true && /0/.test(hud.zero.text), JSON.stringify(hud.zero)); ok('HUD бюджета скрыт в HANDOFF', hud.inHandoff === true); ok('HUD бюджета скрыт в RUNNING', hud.inRun === true); // --- 22c-bis. Вёрстка HUD на узких экранах (M1, Oracle R2-2) --- // На 320/375/390 метка фазы должна быть целиком видна, а кнопка настроек — на экране. for (const w of [320, 375, 390]) { await page.setViewport({ width: w, height: 844, deviceScaleFactor: 2 }); await settleViewport(page, w, 844); const m = await page.evaluate(() => { forceState('MENU'); setBudgetTotal(100); setFieldASCII('..\n..', 2); forceState('PLACE_P1'); // фаза рисования — плашка бюджета видна syncDom(); const tl = document.getElementById('turnLabel'); const bs = document.getElementById('btnSettings'); return { iw: window.innerWidth, client: tl.clientWidth, scroll: tl.scrollWidth, settingsRight: bs.getBoundingClientRect().right, }; }); ok('HUD ' + w + ': метка фазы не обрезана (scrollWidth === clientWidth)', m.scroll === m.client, 'client=' + m.client + ', scroll=' + m.scroll); ok('HUD ' + w + ': кнопка настроек не уходит за экран', m.settingsRight <= m.iw, 'right=' + m.settingsRight.toFixed(1) + ', innerWidth=' + m.iw); } await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 }); await settleViewport(page, 390, 844); await page.evaluate(() => { setBudgetTotal(100); forceState('MENU'); }); // --- 22c-ter. stepN взводит renderDirty (контракт «мутация поля → dirty») --- const stepDirty = await page.evaluate(() => { forceState('MENU'); setFieldASCII('.1.\n..1\n111', 3); draw(); // пересборка + сброс dirty const before = renderDirty; stepN(1); // симуляция меняет поле const after = renderDirty; forceState('MENU'); return { before, after }; }); ok('stepN взводит renderDirty (мутация поля → dirty)', stepDirty.before === false && stepDirty.after === true, JSON.stringify(stepDirty)); // --- 22d. UI меню: дропдаун размеров (квадрат+прямоугольник), чекбокс wrap, комбобокс бюджета --- const sizes = await page.evaluate(() => { forceState('MENU'); return Array.from(document.querySelectorAll('#optSize option')).map(o => o.value); }); ok('дропдаун размеров содержит все FIELD_DIMS (квадраты + 60×100/100×60)', JSON.stringify(sizes) === JSON.stringify( [40, 60, 80, 100, 120, 150, 200, 250, 300].map(n => n + 'x' + n).concat(['60x100', '100x60'])), JSON.stringify(sizes)); // Настройки поля живут на экране «Игра на двоих» (TWO_SETUP) — открываем его, // чтобы клики по чекбоксу/селектам шли по видимым элементам. await page.evaluate(() => forceState('TWO_SETUP')); await page.click('#optWrap'); ok('клик #optWrap → wrap=true', (await page.evaluate(() => wrap)) === true); ok('syncDom отражает wrap в чекбоксе', (await page.evaluate(() => document.getElementById('optWrap').checked)) === true); await page.click('#optWrap'); ok('повторный клик #optWrap → wrap=false', (await page.evaluate(() => wrap)) === false); // Бюджет — комбобокс (input+datalist): свободный ввод числа, подсказки-пресеты. await page.evaluate(() => { forceState('MENU'); setBudgetTotal(100); }); await page.$eval('#optBudget', inp => { inp.value = '250'; inp.dispatchEvent(new Event('change', { bubbles: true })); }); ok('ввод #optBudget=250 → budget.total=250', (await page.evaluate(() => budget.total)) === 250); await page.$eval('#optBudget', inp => { inp.value = '-5'; inp.dispatchEvent(new Event('change', { bubbles: true })); }); ok('ввод #optBudget=-5 игнорируется (остаётся 250)', (await page.evaluate(() => budget.total)) === 250); { const dl = await page.evaluate(() => Array.from(document.querySelectorAll('#budgetOptions option')).map(o => +o.value)); ok('комбобокс бюджета имеет пресеты-подсказки [100,250,500]', JSON.stringify(dl) === JSON.stringify([100, 250, 500]), JSON.stringify(dl)); } await page.evaluate(() => forceState('MENU')); await page.select('#optSize', '300x300'); { const s = await page.evaluate(() => ({ fw: fieldW, len: field.length })); ok('выбор размера 300×300 → fieldW=300, поле 90000 клеток', s.fw === 300 && s.len === 90000, JSON.stringify(s)); } // Прямоугольный размер 60×100: W=60, H=100, зона делится по ширине. await page.select('#optSize', '60x100'); { const s = await page.evaluate(() => ({ fw: fieldW, fh: fieldH, len: field.length, bw: board.w, bh: board.h, split: zoneSplit().boundary, })); ok('выбор размера 60×100 → field 60×100 (6000), доска прямоугольная', s.fw === 60 && s.fh === 100 && s.len === 6000 && s.bw > 0 && s.bh > s.bw, JSON.stringify(s)); ok('прямоугольник 60×100: зона делится по ширине (граница 30)', s.split === 30, JSON.stringify(s)); } await page.select('#optSize', '60x60'); await page.evaluate(() => { setBudgetTotal(100); forceState('MENU'); }); // ========================================================================= // 23. wrap (torus): истинный заворот vs жёсткие границы, рождение через шов, // чистота/детерминизм step при wrap, влияние глобала setWrap, неизменность field. // (Закрывает пробел m1 гейта R2-1 по wrap.) // ========================================================================= await resetGlobals(page); const seam = await page.evaluate(() => { forceState('MENU'); const ascii = '1....\n1....\n1....\n.....\n.....'; // живой левый столбец (0,0),(0,1),(0,2) setFieldASCII(ascii, 5); const src = field.slice(); // ВАЖНО: useWrap выбирает ветку, но таблицы соседей строятся под глобал wrap, // поэтому режим выставляем через setWrap (он пересобирает таблицы). setWrap(false); const nHard = liveNeighbors(field, 4, 1, false); // жёсткие: соседей нет const dstF = new Uint8Array(field.length); step(src, dstF, false); const bornF = dstF[1 * 5 + 4]; setWrap(true); const nTor = liveNeighbors(field, 4, 1, true); // torus: соседи с левого края const dstT = new Uint8Array(field.length); step(src, dstT, true); const bornT = dstT[1 * 5 + 4]; // рождение через шов setWrap(false); forceState('MENU'); return { nTor, nHard, bornT, bornF }; }); ok('torus: liveNeighbors(4,1) видит 3 соседа через левый шов', seam.nTor === 3, JSON.stringify(seam)); ok('жёсткие границы: liveNeighbors(4,1) = 0 (за краем не считаются)', seam.nHard === 0, JSON.stringify(seam)); ok('torus: step рождает клетку через шов (цвет 1)', seam.bornT === 1, JSON.stringify(seam)); ok('жёсткие границы: step НЕ рождает клетку через шов', seam.bornF === 0, JSON.stringify(seam)); const wrapGlobal = await page.evaluate(() => { const ascii = '1....\n1....\n1....\n.....\n.....'; forceState('MENU'); setFieldASCII(ascii, 5); setWrap(false); stepN(1); // без явного useWrap — берётся глобал wrap const hard = field[1 * 5 + 4]; setFieldASCII(ascii, 5); setWrap(true); stepN(1); const tor = field[1 * 5 + 4]; setWrap(false); forceState('MENU'); return { hard, tor }; }); ok('setWrap(false) → stepN без параметра даёт жёсткие границы', wrapGlobal.hard === 0, JSON.stringify(wrapGlobal)); ok('setWrap(true) → stepN без параметра рождает через шов (torus)', wrapGlobal.tor === 1, JSON.stringify(wrapGlobal)); const wrapPurity = await page.evaluate(() => { const ascii = '11...\n..1..\n...11\n.....\n.1...'; forceState('MENU'); setFieldASCII(ascii, 5); setWrap(true); const h0 = hashField(); const dst = new Uint8Array(field.length); step(field, dst, true); // читает field, пишет dst const hAfter = hashField(field); setFieldASCII(ascii, 5); stepN(6); const a = hashField(); setFieldASCII(ascii, 5); stepN(6); const b = hashField(); setFieldASCII(ascii, 5); const hw0 = hashField(); setWrap(false); const hw1 = hashField(); setWrap(true); const hw2 = hashField(); setWrap(false); forceState('MENU'); return { h0, hAfter, a, b, sameWrap: hw0 === hw1 && hw1 === hw2 }; }); ok('torus: step не мутирует src (hash до/после)', wrapPurity.h0 === wrapPurity.hAfter, JSON.stringify(wrapPurity)); ok('torus: stepN детерминирован (два прогона → один хеш)', wrapPurity.a === wrapPurity.b, JSON.stringify(wrapPurity)); ok('смена wrap (setWrap) не мутирует field', wrapPurity.sameWrap, JSON.stringify(wrapPurity)); // ========================================================================= // 24. Зоны рисования (P1 — лево, P2 — право): applyIntent вне зоны, обрезка // кисти на границе, inZone на нечётной ширине, drag без протечки в чужую половину. // ========================================================================= await resetGlobals(page); const zoneIntent = await page.evaluate(() => { const blank = []; for (let i = 0; i < 10; i++) blank.push('..........'); forceState('MENU'); setFieldASCII(blank, 10); selectTool('pencil'); forceState('PLACE_P1'); const cs = board.cell; const h0 = hashField(); const actedRight = applyIntent(board.x + 7.5 * cs, board.y + 1.5 * cs); // x=7 — чужая половина const sameRight = hashField() === h0; const actedLeft = applyIntent(board.x + 2.5 * cs, board.y + 1.5 * cs); // x=2 — своя const cellLeft = field[1 * 10 + 2]; setFieldASCII(blank, 10); forceState('PLACE_P2'); const hP2 = hashField(); const actedLeftP2 = applyIntent(board.x + 2.5 * cs, board.y + 1.5 * cs); // левая — чужая для P2 const sameLeftP2 = hashField() === hP2; const actedRightP2 = applyIntent(board.x + 7.5 * cs, board.y + 1.5 * cs); const cellRightP2 = field[1 * 10 + 7]; forceState('MENU'); return { actedRight, sameRight, actedLeft, cellLeft, actedLeftP2, sameLeftP2, actedRightP2, cellRightP2 }; }); ok('зона P1: applyIntent в правой половине → false, поле не меняется', zoneIntent.actedRight === false && zoneIntent.sameRight === true, JSON.stringify(zoneIntent)); ok('зона P1: applyIntent в левой половине красит клетку', zoneIntent.actedLeft === true && zoneIntent.cellLeft === 1, JSON.stringify(zoneIntent)); ok('зона P2: applyIntent в левой половине → false, поле не меняется', zoneIntent.actedLeftP2 === false && zoneIntent.sameLeftP2 === true, JSON.stringify(zoneIntent)); ok('зона P2: applyIntent в правой половине красит клетку цветом 2', zoneIntent.actedRightP2 === true && zoneIntent.cellRightP2 === 2, JSON.stringify(zoneIntent)); const brushClip = await page.evaluate(() => { const blank = []; for (let i = 0; i < 10; i++) blank.push('..........'); forceState('MENU'); setFieldASCII(blank, 10); budget.total = 100; budget.used1 = 0; budget.used2 = 0; selectTool('brush'); forceState('PLACE_P1'); const changed = paintTool(4, 4); // кисть 3×3, центр у правой границы зоны P1 const counts = countByPlayer(field); let rightCol = 0; for (let y = 0; y < 10; y++) if (field[y * 10 + 5]) rightCol++; // x=5 — уже чужая половина let leftRegion = 0; for (let y = 3; y <= 5; y++) for (let x = 3; x <= 4; x++) if (field[y * 10 + x]) leftRegion++; const used = budget.used1; forceState('MENU'); return { changed, p1: counts.p1, rightCol, leftRegion, used }; }); ok('зона+кисть: центр (4,4) P1 → 6 клеток (x∈{3,4}), x=5 не закрашен', brushClip.changed === 6 && brushClip.p1 === 6 && brushClip.rightCol === 0 && brushClip.leftRegion === 6, JSON.stringify(brushClip)); ok('зона+кисть: бюджет списан ровно за 6 поставленных клеток', brushClip.used === 6, JSON.stringify(brushClip)); const oddZone = await page.evaluate(() => { const rows7 = []; for (let i = 0; i < 7; i++) rows7.push('.......'); forceState('MENU'); setFieldASCII(rows7, 7); // нечётная ширина const boundary7 = zoneSplit().boundary; // ceil(7/2) = 4 let overlap = false, full = true; for (let x = 0; x < 7; x++) { const a = inZone(x, 1), b = inZone(x, 2); if (a && b) overlap = true; if (!a && !b) full = false; } const rows10 = []; for (let i = 0; i < 10; i++) rows10.push('..........'); setFieldASCII(rows10, 10); // чётная ширина let exactlyOne = true; for (let x = 0; x < 10; x++) if (inZone(x, 1) === inZone(x, 2)) exactlyOne = false; forceState('MENU'); return { boundary7, overlap, full, exactlyOne }; }); ok('inZone ширина 7: полное покрытие без пересечений, граница = 4', oddZone.boundary7 === 4 && oddZone.overlap === false && oddZone.full === true, JSON.stringify(oddZone)); ok('inZone ширина 10 (чётная): у каждой клетки ровно один владелец', oddZone.exactlyOne === true, JSON.stringify(oddZone)); // Drag мышью из левой половины в правую при PLACE_P1 не должен красить правую половину. await resetGlobals(page); await page.evaluate(() => { newGame(); }); // PLACE_P1, turn=1, бюджет сброшен { const g = await page.evaluate(() => ({ x: board.x, y: board.y, c: board.cell })); const y = g.y + 10.5 * g.c; await page.mouse.move(g.x + 20.5 * g.c, y); await page.mouse.down(); await page.mouse.move(g.x + 40.5 * g.c, y, { steps: 20 }); await page.mouse.up(); const res = await page.evaluate(() => { let right = 0, left = 0; for (let yy = 0; yy < fieldH; yy++) for (let xx = 0; xx < fieldW; xx++) { if (field[yy * fieldW + xx]) { if (xx >= fieldW / 2) right++; else left++; } } return { right, left, st: state }; }); ok('drag P1 из левой половины в правую: правая половина не закрашена', res.st === 'PLACE_P1' && res.right === 0 && res.left > 0, JSON.stringify(res)); } await page.evaluate(() => forceState('MENU')); // ========================================================================= // 25. Бюджет клеток: списание за новые, своя клетка не тратит, перезапись чужого, // частичная нехватка, ластик возвращает только правки хода, исчерпание, // «Сброс», персистентность. (Закрывает пробел m1 гейта R2-1 по бюджету.) // ========================================================================= await resetGlobals(page); const spend = await page.evaluate(() => { const blank12 = []; for (let i = 0; i < 12; i++) blank12.push('............'); forceState('MENU'); setFieldASCII(blank12, 12); budget.total = 100; budget.used1 = 0; budget.used2 = 0; selectTool('pencil'); forceState('PLACE_P1'); paintTool(2, 2); const afterPencil = { used: budget.used1, p1: countByPlayer(field).p1 }; paintTool(2, 2); // повторно своя клетка — без траты const afterRepeat = { used: budget.used1, p1: countByPlayer(field).p1 }; setFieldASCII(blank12, 12); budget.used1 = 0; selectTool('brush'); paintTool(3, 3); // кисть 3×3 на пустом const afterBrush = { used: budget.used1, p1: countByPlayer(field).p1 }; forceState('MENU'); return { afterPencil, afterRepeat, afterBrush }; }); ok('бюджет: карандаш на пустом → used=1, поставлена 1 клетка', spend.afterPencil.used === 1 && spend.afterPencil.p1 === 1, JSON.stringify(spend.afterPencil)); ok('бюджет: повторная покраска своей клетки не тратит', spend.afterRepeat.used === 1 && spend.afterRepeat.p1 === 1, JSON.stringify(spend.afterRepeat)); ok('бюджет: кисть 3×3 на пустом → used=9, поставлено 9', spend.afterBrush.used === 9 && spend.afterBrush.p1 === 9, JSON.stringify(spend.afterBrush)); const overwrite = await page.evaluate(() => { forceState('MENU'); let a = ''; for (let i = 0; i < 10; i++) a += (i === 0 ? '2' : '.'); const blank = [a]; for (let i = 1; i < 10; i++) blank.push('..........'); setFieldASCII(blank, 10); budget.total = 100; budget.used1 = 0; budget.used2 = 0; selectTool('pencil'); forceState('PLACE_P1'); const before = getScores(); paintTool(0, 0); // чужая (2) в своей зоне → перезапись const after = getScores(); const used = budget.used1; forceState('MENU'); return { before, after, used }; }); ok('бюджет: перезапись чужого в своей зоне → свой цвет, чужой счёт −1, used=1', overwrite.before.p2 === 1 && overwrite.after.p1 === 1 && overwrite.after.p2 === 0 && overwrite.used === 1, JSON.stringify(overwrite)); const shortage = await page.evaluate(() => { const blank12 = []; for (let i = 0; i < 12; i++) blank12.push('............'); forceState('MENU'); setFieldASCII(blank12, 12); budget.total = 4; budget.used1 = 0; budget.used2 = 0; selectTool('brush'); forceState('PLACE_P1'); const changed = paintTool(3, 3); const p1 = countByPlayer(field).p1; const used = budget.used1; forceState('MENU'); return { changed, p1, used }; }); ok('бюджет: частичная нехватка (total=4, кисть) → поставлено 4, used=4', shortage.changed === 4 && shortage.p1 === 4 && shortage.used === 4, JSON.stringify(shortage)); const erase = await page.evaluate(() => { forceState('MENU'); const rows = ['2.........', '..........', '..1.......']; // (0,0)=2 (чужая), (2,2)=1 (своя) for (let i = 3; i < 10; i++) rows.push('..........'); setFieldASCII(rows, 10); budget.total = 100; budget.used1 = 0; budget.used2 = 0; forceState('EDIT_P1'); // снимок turnStartField = field const used0 = budget.used1; selectTool('pencil'); paintTool(0, 0); // перезапись чужой 2 → 1 const afterOverwrite = { used: budget.used1, v00: field[0] }; paintTool(4, 4); // новая своя клетка const afterNew = { used: budget.used1, v44: field[4 * 10 + 4] }; selectTool('erase1'); paintTool(0, 0); // ластик: снять перезапись → вернуть 2 const eraseOverwrite = { used: budget.used1, v00: field[0] }; paintTool(4, 4); // ластик: снять свою новую клетку const eraseNew = { used: budget.used1, v44: field[4 * 10 + 4] }; paintTool(2, 2); // пред-существующую свою 1 ластик НЕ трогает const preExisting = { used: budget.used1, v22: field[2 * 10 + 2] }; forceState('MENU'); return { used0, afterOverwrite, afterNew, eraseOverwrite, eraseNew, preExisting }; }); ok('ластик: перезапись чужого тратит 1 (used=1, цвет 1)', erase.afterOverwrite.used === 1 && erase.afterOverwrite.v00 === 1, JSON.stringify(erase)); ok('ластик: новая клетка тратит (used=2)', erase.afterNew.used === 2 && erase.afterNew.v44 === 1, JSON.stringify(erase)); ok('ластик: снятие перезаписи возвращает чужой цвет и бюджет', erase.eraseOverwrite.used === 1 && erase.eraseOverwrite.v00 === 2, JSON.stringify(erase)); ok('ластик: снятие своей новой клетки возвращает бюджет', erase.eraseNew.used === 0 && erase.eraseNew.v44 === 0, JSON.stringify(erase)); ok('ластик в EDIT_*: пред-существующая своя клетка НЕ стирается, used не меняется', erase.preExisting.used === 0 && erase.preExisting.v22 === 1, JSON.stringify(erase.preExisting)); const exhaust = await page.evaluate(() => { const blank = []; for (let i = 0; i < 10; i++) blank.push('..........'); forceState('MENU'); setFieldASCII(blank, 10); budget.total = 1; budget.used1 = 1; budget.used2 = 0; // остаток 0 selectTool('pencil'); forceState('PLACE_P1'); const cs = board.cell; const h0 = hashField(); const acted = applyIntent(board.x + 2.5 * cs, board.y + 2.5 * cs); const same = hashField() === h0; field[3 * 10 + 3] = 1; turnStartField[3 * 10 + 3] = 0; // «правка» для ластика selectTool('erase1'); const changed = paintTool(3, 3); const erased = field[3 * 10 + 3]; const used = budget.used1; forceState('MENU'); return { acted, same, changed, erased, used }; }); ok('бюджет исчерпан: новые клетки не ставятся (applyIntent не меняет поле)', exhaust.same === true, JSON.stringify(exhaust)); ok('бюджет исчерпан: ластик работает (снимает правку хода, возвращает бюджет)', exhaust.changed === 1 && exhaust.erased === 0 && exhaust.used === 0, JSON.stringify(exhaust)); const resetBudget = await page.evaluate(() => { const blank12 = []; for (let i = 0; i < 12; i++) blank12.push('............'); forceState('MENU'); setFieldASCII(blank12, 12); budget.total = 100; budget.used1 = 0; budget.used2 = 0; selectTool('brush'); forceState('PLACE_P1'); // turnStartBudget.used1 = 0 paintTool(3, 3); const afterPaint = budget.used1; resetGame(); const afterReset = budget.used1; forceState('MENU'); return { afterPaint, afterReset }; }); ok('«Сброс» возвращает бюджет к снимку начала хода (9 → 0)', resetBudget.afterPaint === 9 && resetBudget.afterReset === 0, JSON.stringify(resetBudget)); await resetGlobals(page); await page.evaluate(() => { forceState('MENU'); setFieldSize(60); setBudgetTotal(7); newGame(); // used=0 field[2 * 60 + 2] = 1; field[2 * 60 + 3] = 1; field[2 * 60 + 4] = 1; budget.used1 = 3; turnStartField = field.slice(); persist(); }); await reload(page); { const b = await page.evaluate(() => ({ total: budget.total, used1: budget.used1, state })); ok('бюджет переживает reload (total=7, used1=3, state=PLACE_P1)', b.total === 7 && b.used1 === 3 && b.state === 'PLACE_P1', JSON.stringify(b)); } // ========================================================================= // 26. Размеры поля: FIELD_SIZES до 300, валидация setFieldSize, дефолт 60. // ========================================================================= await resetGlobals(page); const sizes2 = await page.evaluate(() => { forceState('MENU'); const list = FIELD_SIZES.slice(); setFieldSize(120); const a = { fw: fieldW, len: field.length }; setFieldSize(70); // не из FIELD_SIZES — игнорируется const b = { fw: fieldW, len: field.length }; setFieldSize(300); const c = { fw: fieldW, len: field.length, board: board.size }; setFieldSize(60); forceState('MENU'); return { list, a, b, c }; }); ok('FIELD_SIZES = 40…300 (9 значений)', JSON.stringify(sizes2.list) === JSON.stringify([40, 60, 80, 100, 120, 150, 200, 250, 300]), JSON.stringify(sizes2.list)); ok('setFieldSize(120) применяется (поле 14400)', sizes2.a.fw === 120 && sizes2.a.len === 14400, JSON.stringify(sizes2.a)); ok('setFieldSize(70) игнорируется (не из FIELD_SIZES)', sizes2.b.fw === 120, JSON.stringify(sizes2.b)); ok('setFieldSize(300) применяется, доска валидна (90000, board>0)', sizes2.c.fw === 300 && sizes2.c.len === 90000 && sizes2.c.board > 0, JSON.stringify(sizes2.c)); // ========================================================================= // 27. Сейв v2: форма freshSave, миграция v1→v2, резюм v1-партии, v>SAVE_VERSION, // переносимость wrap/бюджета через reload. // ========================================================================= await resetGlobals(page); const fresh = await page.evaluate(() => { const s = freshSave(); return { v: s.v, wrap: s.wrap, state: s.state, total: s.budget.total, used1: s.budget.used1, used2: s.budget.used2, start1: s.budget.start1, start2: s.budget.start2 }; }); ok('freshSave(): v===2', fresh.v === 2, JSON.stringify(fresh)); ok('freshSave(): новые поля (wrap=false, бюджет 100/0/0)', fresh.wrap === false && fresh.total === 100 && fresh.used1 === 0 && fresh.used2 === 0 && fresh.start1 === 0 && fresh.start2 === 0, JSON.stringify(fresh)); const mig = await page.evaluate(() => { const f = new Uint8Array(16); f[0] = 1; f[5] = 2; const legacy = { v: 1, fieldW: 4, fieldH: 4, tool: 'brush', stepsPerSec: 8, state: 'PLACE_P1', turn: 1, field: bytesToB64(f) }; localStorage.setItem(SAVE_KEY, JSON.stringify(legacy)); const s = loadSave(); return { v: s.v, wrap: s.wrap, total: s.budget.total, used1: s.budget.used1, state: s.state }; }); ok('миграция v1→v2: v=2, wrap=false, свежий бюджет', mig.v === 2 && mig.wrap === false && mig.total === 100 && mig.used1 === 0 && mig.state === 'PLACE_P1', JSON.stringify(mig)); await page.evaluate(() => { const f = new Uint8Array(16); f[0] = 1; f[5] = 2; const legacy = { v: 1, fieldW: 4, fieldH: 4, tool: 'brush', stepsPerSec: 8, state: 'PLACE_P1', turn: 1, field: bytesToB64(f) }; localStorage.setItem(SAVE_KEY, JSON.stringify(legacy)); }); await reload(page); { const r = await page.evaluate(() => ({ state, fw: fieldW, v00: field[0], v15: field[5], wrap, total: budget.total })); ok('резюм v1-сейва после reload: state/поле восстановлены', r.state === 'PLACE_P1' && r.fw === 4 && r.v00 === 1 && r.v15 === 2, JSON.stringify(r)); ok('резюм v1-сейва: wrap=false и дефолтный бюджет', r.wrap === false && r.total === 100, JSON.stringify(r)); } await page.evaluate(() => localStorage.setItem(SAVE_KEY, JSON.stringify({ v: 3, state: 'PLACE_P1', fieldW: 4, fieldH: 4, field: 'AA==' }))); ok('сейв v=3 (> SAVE_VERSION) → loadSave()=null', (await page.evaluate(() => loadSave())) === null); await resetGlobals(page); await page.evaluate(() => forceState('TWO_SETUP')); await page.click('#optWrap'); // wrap=true await page.$eval('#optBudget', inp => { inp.value = '250'; inp.dispatchEvent(new Event('change', { bubbles: true })); }); { const b = await page.evaluate(() => ({ wrap, total: budget.total })); ok('настройки wrap=true и бюджет 250 применены', b.wrap === true && b.total === 250, JSON.stringify(b)); } await reload(page); { const b = await page.evaluate(() => ({ wrap, total: budget.total, checked: document.getElementById('optWrap').checked })); ok('wrap=true переживает reload (и отражён в чекбоксе)', b.wrap === true && b.checked === true, JSON.stringify(b)); ok('budget.total=250 переживает reload', b.total === 250, JSON.stringify(b)); } // Прямоугольный размер 60×100: применяется, доска прямоугольная, переживает reload. await page.evaluate(() => forceState('MENU')); await page.select('#optSize', '60x100'); await startTwoPlayer(page); // PLACE_P1 на поле 60×100 await page.evaluate(() => persist()); await reload(page); { const r = await page.evaluate(() => ({ state, fw: fieldW, fh: fieldH, len: field.length, bw: board.w, bh: board.h })); ok('прямоугольник 60×100: размер и состояние переживают reload', r.fw === 60 && r.fh === 100 && r.len === 6000 && r.state === 'PLACE_P1', JSON.stringify(r)); ok('прямоугольник 60×100: доска выше, чем шире', r.bh > r.bw, JSON.stringify(r)); } await resetGlobals(page); // ========================================================================= // 28. Крупное поле 300×300: рендер/резюм.resize без ошибок; подсветка зон // не искажает точный цвет живых клеток (пиксель центра клетки). // ========================================================================= await resetGlobals(page); await page.evaluate(() => { forceState('MENU'); setFieldSize(300); forceState('PLACE_P1'); }); for (const box of [[390, 844, 2], [320, 480, 2], [800, 600, 1]]) { await page.setViewport({ width: box[0], height: box[1], deviceScaleFactor: box[2] }); await settleViewport(page, box[0], box[1]); const r = await page.evaluate(() => { let drew = true; try { draw(); } catch (e) { drew = false; } return { drew, bs: board.size, fc: fieldCanvas.width + 'x' + fieldCanvas.height, fw: fieldW }; }); ok('300×300 @' + box[0] + ': draw() без ошибок, доска/offscreen валидны', r.drew && r.bs > 0 && r.fw === 300 && r.fc === '300x300', JSON.stringify(r)); } await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 }); await settleViewport(page, 390, 844); await page.evaluate(() => { setFieldSize(60); forceState('MENU'); }); const exact = await page.evaluate(() => { forceState('MENU'); const blank = []; for (let i = 0; i < 20; i++) blank.push('....................'); setFieldASCII(blank, 20); field[3 * 20 + 3] = 1; // активная (левая) половина field[3 * 20 + 15] = 1; // затемнённая (правая) половина: оверлей рисуется ПОД клетками forceState('PLACE_P1'); draw(); const sample = (cx, cy) => { const px = Math.round((board.x + (cx + 0.5) * board.cell) * dpr); const py = Math.round((board.y + (cy + 0.5) * board.cell) * dpr); const im = ctx.getImageData(px, py, 1, 1).data; return [im[0], im[1], im[2], im[3]]; }; const active = sample(3, 3); const dark = sample(15, 3); forceState('MENU'); return { active, dark }; }); const P1_RGB = [231, 76, 60, 255]; // COLORS.p1 = #e74c3c ok('подсветка зон не искажает цвет клетки в активной половине', exact.active[0] === P1_RGB[0] && exact.active[1] === P1_RGB[1] && exact.active[2] === P1_RGB[2] && exact.active[3] === 255, JSON.stringify(exact.active)); ok('подсветка зон: клетка под затемнением тоже точного цвета (оверлей под слоем)', exact.dark[0] === P1_RGB[0] && exact.dark[1] === P1_RGB[1] && exact.dark[2] === P1_RGB[2] && exact.dark[3] === 255, JSON.stringify(exact.dark)); // ========================================================================= // 28b. Акцент текущего игрока: 10% тинт своей половины + классы body/оверлеев. // ========================================================================= // Тинт 10% цветом игрока на СВОЕЙ половине (под клетками): на пустой клетке фон // окрашивается в оттенок игрока — у P1 красный канал доминирует, у P2 — зелёный. const tint = await page.evaluate(() => { const blank = []; for (let i = 0; i < 20; i++) blank.push('....................'); const sample = (cx, cy) => { const px = Math.round((board.x + (cx + 0.5) * board.cell) * dpr); const py = Math.round((board.y + (cy + 0.5) * board.cell) * dpr); const im = ctx.getImageData(px, py, 1, 1).data; return [im[0], im[1], im[2]]; }; setFieldASCII(blank, 20); forceState('PLACE_P1'); // своя половина — левая draw(); const p1 = sample(3, 3); forceState('PLACE_P2'); // своя половина — правая draw(); const p2 = sample(15, 3); forceState('MENU'); return { p1, p2 }; }); ok('тинт зоны P1: пустая клетка своей половины красноватая (доминирует r)', tint.p1[0] > tint.p1[1] && tint.p1[0] > tint.p1[2], JSON.stringify(tint.p1)); ok('тинт зоны P2: пустая клетка своей половины зеленоватая (g>b>r)', tint.p2[1] > tint.p2[2] && tint.p2[2] > tint.p2[0], JSON.stringify(tint.p2)); // Классы акцента на body — только в фазах рисования, по текущему игроку. const acc = await page.evaluate(() => { const b = document.body, snap = () => ({ p1: b.classList.contains('turn-p1'), p2: b.classList.contains('turn-p2') }); const out = {}; setFieldASCII('..\n..', 2); forceState('PLACE_P1'); syncDom(); out.place1 = snap(); forceState('PLACE_P2'); syncDom(); out.place2 = snap(); forceState('EDIT_P1'); syncDom(); out.edit1 = snap(); forceState('EDIT_P2'); syncDom(); out.edit2 = snap(); forceState('RUNNING'); syncDom(); out.run = snap(); forceState('PAUSED'); syncDom(); out.pause = snap(); forceState('MENU'); syncDom(); out.menu = snap(); return out; }); ok('акцент: PLACE_P1/EDIT_P1 → body.turn-p1, PLACE_P2/EDIT_P2 → body.turn-p2', acc.place1.p1 && !acc.place1.p2 && acc.place2.p2 && !acc.place2.p1 && acc.edit1.p1 && acc.edit2.p2, JSON.stringify(acc)); ok('акцент: вне фаз рисования (RUNNING/PAUSED/MENU) классов нет', !acc.run.p1 && !acc.run.p2 && !acc.pause.p1 && !acc.pause.p2 && !acc.menu.p1 && !acc.menu.p2, JSON.stringify(acc)); // Оверлеи: панель передачи — цветом игрока 2; панель финала — цветом победителя. const overlays = await page.evaluate(() => { const ho = document.getElementById('handoff'), go = document.getElementById('gameover'); setFieldASCII('..\n..', 2); forceState('HANDOFF_P1_P2'); syncDom(); const handoff = { toP2: ho.classList.contains('to-p2') }; forceState('GAME_OVER'); winner = '1'; syncDom(); const w1 = { p1: go.classList.contains('win-p1'), p2: go.classList.contains('win-p2') }; winner = '2'; syncDom(); const w2 = { p1: go.classList.contains('win-p1'), p2: go.classList.contains('win-p2') }; winner = 'draw'; syncDom(); const draw = { p1: go.classList.contains('win-p1'), p2: go.classList.contains('win-p2') }; winner = null; forceState('MENU'); return { handoff, w1, w2, draw }; }); ok('оверлей передачи: в HANDOFF_P1_P2 панель помечена to-p2', overlays.handoff.toP2 === true, JSON.stringify(overlays.handoff)); ok('панель финала: win-p1/win-p2 по победителю, при ничьей — без класса', overlays.w1.p1 && !overlays.w1.p2 && overlays.w2.p2 && !overlays.w2.p1 && !overlays.draw.p1 && !overlays.draw.p2, JSON.stringify(overlays)); // ========================================================================= // 29. Резидуальный minor R2-2: полоса 381–400px — метка фазы не обрезана, // кнопка настроек на экране (порог медиазапроса 400px). // ========================================================================= for (const w of [381, 385, 388, 400]) { await page.setViewport({ width: w, height: 844, deviceScaleFactor: 2 }); await settleViewport(page, w, 844); const m = await page.evaluate(() => { forceState('MENU'); setBudgetTotal(100); setFieldASCII('..\n..', 2); forceState('PLACE_P1'); syncDom(); const tl = document.getElementById('turnLabel'); const bs = document.getElementById('btnSettings'); return { iw: window.innerWidth, client: tl.clientWidth, scroll: tl.scrollWidth, settingsRight: bs.getBoundingClientRect().right }; }); ok('HUD ' + w + ': метка фазы не обрезана (scrollWidth === clientWidth)', m.scroll === m.client, 'client=' + m.client + ', scroll=' + m.scroll); ok('HUD ' + w + ': кнопка настроек не уходит за экран', m.settingsRight <= m.iw, 'right=' + m.settingsRight.toFixed(1) + ', innerWidth=' + m.iw); } await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 }); await settleViewport(page, 390, 844); await resetGlobals(page); // ========================================================================= // 30. Раскладка «доска во всю высоту + панель управления сбоку» (ландшафт). // В ландшафте доска прижата влево и занимает всю высоту, управление — в // свободной зоне справа (не пересекается с доской). В портрете/узком // ландшафте — прежняя центрированная раскладка. // ========================================================================= await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 1 }); await settleViewport(page, 1280, 720); const land = await page.evaluate(() => { forceState('PLACE_P1'); draw(); const ui = document.getElementById('ui').getBoundingClientRect(); const tb = document.getElementById('toolbar').getBoundingClientRect(); const ac = document.getElementById('actions').getBoundingClientRect(); const hud = document.getElementById('hud').getBoundingClientRect(); return { layoutSide, side: document.body.classList.contains('side'), bx: board.x, bs: board.size, by: board.y, uiLeft: ui.left, uiW: ui.width, vw: viewW, vh: viewH, tbVisible: tb.width > 0 && tb.left >= ui.left && tb.right <= ui.right, acVisible: ac.width > 0 && ac.left >= ui.left && ac.right <= ui.right, hudVisible: hud.width > 0 && hud.left >= ui.left && hud.right <= ui.right, }; }); ok('ландшафт: включена боковая раскладка', land.layoutSide === true && land.side === true, JSON.stringify(land)); ok('ландшафт: доска во всю высоту и прижата влево', land.bs === land.vh - 20 && land.bx === 10 && land.by === 10, JSON.stringify(land)); ok('ландшафт: панель управления справа не пересекается с доской', land.uiLeft >= land.bx + land.bs, JSON.stringify({ uiLeft: land.uiLeft, boardRight: land.bx + land.bs })); ok('ландшафт: тулбар/действия/HUD лежат внутри боковой зоны', land.tbVisible && land.acVisible && land.hudVisible, JSON.stringify(land)); // Портрет: раскладка прежняя (доска по центру, боковой панели нет). await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 }); await settleViewport(page, 390, 844); const port = await page.evaluate(() => { forceState('PLACE_P1'); draw(); return { layoutSide, side: document.body.classList.contains('side'), bx: board.x, bs: board.size, vw: viewW }; }); ok('портрет: боковая раскладка выключена', port.layoutSide === false && port.side === false, JSON.stringify(port)); ok('портрет: доска по центру (прежняя раскладка)', port.bx === Math.round((port.vw - port.bs) / 2), JSON.stringify(port)); // Узкий ландшафт: панели сбоку не хватает — откат к центрированной раскладке. await page.setViewport({ width: 480, height: 320, deviceScaleFactor: 1 }); await settleViewport(page, 480, 320); const narrow = await page.evaluate(() => { draw(); return { layoutSide, side: document.body.classList.contains('side'), bs: board.size }; }); ok('узкий ландшафт 480×320: откат к центрированной раскладке', narrow.layoutSide === false && narrow.side === false && narrow.bs > 0, JSON.stringify(narrow)); await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 }); await settleViewport(page, 390, 844); await resetGlobals(page); // ========================================================================= // 31. Меню не теряет партию: «В меню»/⚙️ откладывает игру (suspended), кнопка // «Продолжить партию» возвращает в неё; новая партия требует подтверждения. // ========================================================================= await resetGlobals(page); const beforeMenu = await page.evaluate(() => { setFieldASCII('11..\n11..\n..22\n..22', 4); selectTool('pencil'); newGame(); commitTurn(); handoffNext(); // PLACE_P2, turn=2 applyBrush(field, 3, 3, TOOLS[0], 2); budget.used1 = 2; budget.used2 = 3; persist(); return { state, turn, hash: hashField(), used1: budget.used1, used2: budget.used2 }; }); await page.click('#btnSettings'); // ⚙️ во время партии → меню { const m = await page.evaluate(() => ({ state, susp: !!suspended, contVis: !document.getElementById('btnContinue').classList.contains('hidden'), })); ok('⚙️ из партии → MENU, партия отложена, кнопка «Продолжить» видна', m.state === 'MENU' && m.susp === true && m.contVis === true, JSON.stringify(m)); } await page.click('#btnContinue'); { const r = await page.evaluate(() => ({ state, turn, hash: hashField(), used1: budget.used1, used2: budget.used2, susp: suspended, })); ok('«Продолжить партию» возвращает ровно ту же партию (state/turn/поле/бюджет)', r.state === beforeMenu.state && r.turn === beforeMenu.turn && r.hash === beforeMenu.hash && r.used1 === beforeMenu.used1 && r.used2 === beforeMenu.used2 && r.susp === null, JSON.stringify(r)); } // Новая партия при отложенной — сначала подтверждение. await page.click('#btnSettings'); await startTwoPlayer(page); { const c = await page.evaluate(() => ({ state, confirm: confirmAction, confirmVis: !document.getElementById('confirm').classList.contains('hidden'), susp: !!suspended, })); ok('«Игра на двоих» при отложенной партии → диалог подтверждения (партия не потеряна)', c.state === 'TWO_SETUP' && c.confirm === 'new' && c.confirmVis === true && c.susp === true, JSON.stringify(c)); } await page.click('#btnConfirmCancel'); { const c = await page.evaluate(() => ({ confirm: confirmAction, susp: !!suspended, vis: !document.getElementById('confirm').classList.contains('hidden') })); ok('«Отмена» закрывает подтверждение и сохраняет отложенную партию', c.confirm === null && c.vis === false && c.susp === true, JSON.stringify(c)); } await page.click('#btnStartTwo'); await page.click('#btnConfirmOk'); { const c = await page.evaluate(() => ({ state, susp: suspended, confirm: confirmAction, s: getScores() })); ok('«Начать новую» стартует новую партию и сбрасывает отложенную', c.state === 'PLACE_P1' && c.susp === null && c.confirm === null && c.s.p1 === 0 && c.s.p2 === 0, JSON.stringify(c)); } // Отложенная партия переживает reload. const susBefore = await page.evaluate(() => { localStorage.clear(); setFieldSize(40); forceState('MENU'); newGame(); commitTurn(); handoffNext(); // PLACE_P2 на поле 40×40 applyBrush(field, 3, 3, TOOLS[0], 2); toMenu(); // откладываем и пишем сейв return { fw: suspended.fieldW, hash: hashField(suspended.field) }; }); await reload(page); { const r = await page.evaluate(() => ({ state, contVis: !document.getElementById('btnContinue').classList.contains('hidden'), fw: suspended && suspended.fieldW, hash: suspended ? hashField(suspended.field) : 0, })); ok('отложенная партия переживает reload (меню + «Продолжить», размер/поле целы)', r.state === 'MENU' && r.contVis === true && r.fw === susBefore.fw && r.hash === susBefore.hash, JSON.stringify(r)); } await page.click('#btnContinue'); { const r = await page.evaluate(() => ({ state, fw: fieldW, v: field[3 * 40 + 3] })); ok('«Продолжить» после reload восстанавливает поле 40×40', r.state === 'PLACE_P2' && r.fw === 40 && r.v === 2, JSON.stringify(r)); } await resetGlobals(page); // ========================================================================= // 32. «Завершить партию» требует подтверждения; на время диалога симуляция // на паузе (поле не живёт за оверлеем); «Отмена» возвращает RUNNING. // Осциллятор-блинкер: без паузы он бы сменил хеш за время диалога. // ========================================================================= await resetGlobals(page); await page.evaluate(() => { forceState('MENU'); // Блинкер P1 + устойчивый блок P2 (чтобы партия не завершилась вымиранием). setFieldASCII('.....\n.111.\n.....\n...22\n...22', 5); budget.total = 100; budget.used1 = 0; budget.used2 = 0; forceState('RUNNING'); requestEndGame(); }); { const s = await page.evaluate(() => ({ act: confirmAction, state, h1: hashField() })); await sleep(400); const h2 = await page.evaluate(() => hashField()); ok('диалог завершения: RUNNING→PAUSED, осциллятор не шагает (поле за оверлеем неизменно)', s.act === 'end' && s.state === 'PAUSED' && s.h1 === h2, JSON.stringify({ act: s.act, state: s.state, h1: s.h1, h2 })); } await page.click('#btnConfirmCancel'); { const s = await page.evaluate(() => ({ state, act: confirmAction })); ok('«Отмена» завершения возвращает идущую симуляцию (RUNNING)', s.state === 'RUNNING' && s.act === null, JSON.stringify(s)); } // Пока диалог открыт, повторный «Завершить» не должен дублировать диалог/ломать состояние. await page.evaluate(() => { forceState('RUNNING'); requestEndGame(); requestEndGame(); }); { const s = await page.evaluate(() => ({ state, act: confirmAction })); ok('повторный запрос завершения не ломает диалог (один confirmAction, пауза)', s.act === 'end' && s.state === 'PAUSED', JSON.stringify(s)); } await page.click('#btnConfirmOk'); ok('подтверждение завершения → GAME_OVER, reason=manual', (await page.evaluate(() => state)) === 'GAME_OVER' && (await page.evaluate(() => reason)) === 'manual'); await resetGlobals(page); // ========================================================================= // 33. Светлая/тёмная тема: селект в меню, canvas-палитра, сейв, reload. // ========================================================================= await resetGlobals(page); { const d = await page.evaluate(() => ({ theme: theme, attr: document.documentElement.getAttribute('data-theme'), bg: COLORS.bg, meta: (document.querySelector('meta[name="theme-color"]') || {}).content, sel: document.getElementById('optTheme').value, })); ok('тема по умолчанию — тёмная (data-theme/COLORS/meta/селект)', d.theme === 'dark' && d.attr === 'dark' && d.bg === '#111418' && d.meta === '#111418' && d.sel === 'dark', JSON.stringify(d)); } // Переключение через селект меню (реальное событие change). await page.select('#optTheme', 'light'); { const l = await page.evaluate(() => ({ theme: theme, attr: document.documentElement.getAttribute('data-theme'), bg: COLORS.bg, meta: (document.querySelector('meta[name="theme-color"]') || {}).content, sel: document.getElementById('optTheme').value, saved: save.theme, })); ok('#optTheme=light → светлая тема (data-theme/COLORS/meta/сейв)', l.theme === 'light' && l.attr === 'light' && l.bg === '#eef1f5' && l.meta === '#eef1f5' && l.sel === 'light' && l.saved === 'light', JSON.stringify(l)); } // Светлая тема видна и на canvas: слой offscreen пересобирается при draw (bg-пиксель). { const px = await page.evaluate(() => { forceState('MENU'); draw(); const im = ctx.getImageData(1, 1, 1, 1).data; // угол холста: фон COLORS.bg return [im[0], im[1], im[2]]; }); ok('canvas-фон в светлой теме — светлый (#eef1f5)', px[0] === 238 && px[1] === 241 && px[2] === 245, JSON.stringify(px)); } // Тема переживает reload (сохраняется в life-save-v1 и применяется при старте). await reload(page); { const r = await page.evaluate(() => ({ theme: theme, attr: document.documentElement.getAttribute('data-theme'), bg: COLORS.bg, sel: document.getElementById('optTheme').value, })); ok('тема переживает reload (light сохраняется и применяется)', r.theme === 'light' && r.attr === 'light' && r.bg === '#eef1f5' && r.sel === 'light', JSON.stringify(r)); } // Обратное переключение через селект и сохранение. await page.select('#optTheme', 'dark'); { const d = await page.evaluate(() => ({ theme: theme, attr: document.documentElement.getAttribute('data-theme'), bg: COLORS.bg, saved: save.theme, })); ok('#optTheme=dark → снова тёмная тема', d.theme === 'dark' && d.attr === 'dark' && d.bg === '#111418' && d.saved === 'dark', JSON.stringify(d)); } // menu не теряет партию: переключение темы из меню при отложенной партии не трогает suspended. { await page.evaluate(() => { localStorage.clear(); setFieldSize(40); forceState('MENU'); newGame(); commitTurn(); handoffNext(); // PLACE_P2, партия активна toMenu(); // отложена в suspended, MENU }); const before = await page.evaluate(() => ({ susp: !!suspended, hash: hashField(suspended.field) })); await page.select('#optTheme', 'light'); const after = await page.evaluate(() => ({ susp: !!suspended, hash: hashField(suspended.field), theme: theme })); ok('смена темы в меню не теряет отложенную партию', after.susp === true && after.hash === before.hash && after.theme === 'light', JSON.stringify({ before, after })); } await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 }); await settleViewport(page, 390, 844); await resetGlobals(page); // Сброс темы к тёмной после reload (resetGlobals чистит localStorage, но тема в DOM). await reload(page); // ========================================================================= // 34. Клавиатура: пробел переключает паузу (RUNNING↔PAUSED); в прочих фазах // и при открытом диалоге подтверждения — инертен. // ========================================================================= await resetGlobals(page); { // Снимаем фокус с интерактивных элементов, чтобы пробел шёл «в тело» страницы. const pressSpace = async () => { await page.evaluate(() => { if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); }); await page.keyboard.press('Space'); }; // «Живое» поле (глайдер): не стабилизируется, поэтому RUNNING НЕ уходит в авто-паузу // — пробел действительно переключает паузу, а не совпадает с автоматической. await page.evaluate(() => { const g = [[1, 0], [2, 1], [0, 2], [1, 2], [2, 2]]; for (const c of g) applyBrush(field, 28 + c[0], 28 + c[1], TOOLS[0], 1); // Второй цвет (блок 2×2) остаётся жив — иначе вымирание завершило бы партию. for (const c of [[0, 0], [1, 0], [0, 1], [1, 1]]) applyBrush(field, 10 + c[0], 10 + c[1], TOOLS[0], 2); forceState('RUNNING'); }); await pressSpace(); ok('пробел в RUNNING → PAUSED', (await page.evaluate(() => state)) === 'PAUSED'); await pressSpace(); ok('пробел в PAUSED → RUNNING', (await page.evaluate(() => state)) === 'RUNNING'); // При открытом диалоге подтверждения пробел не переключает паузу/ход. await page.evaluate(() => requestEndGame()); const before = await page.evaluate(() => ({ act: confirmAction, st: state })); await pressSpace(); const after = await page.evaluate(() => ({ act: confirmAction, st: state })); ok('пробел при открытом диалоге подтверждения ничего не меняет', before.act === 'end' && after.act === 'end' && after.st === before.st && after.st === 'PAUSED', JSON.stringify({ before, after })); await page.evaluate(() => cancelConfirm()); // Вне RUNNING/PAUSED пробел инертен: MENU / PLACE_P1 / GAME_OVER. await page.evaluate(() => forceState('MENU')); await pressSpace(); ok('пробел в MENU → остаётся MENU', (await page.evaluate(() => state)) === 'MENU'); await page.evaluate(() => forceState('PLACE_P1')); await pressSpace(); ok('пробел в PLACE_P1 → остаётся PLACE_P1', (await page.evaluate(() => state)) === 'PLACE_P1'); await page.evaluate(() => forceState('GAME_OVER')); await pressSpace(); ok('пробел в GAME_OVER → остаётся GAME_OVER', (await page.evaluate(() => state)) === 'GAME_OVER'); } await resetGlobals(page); // ========================================================================= // 35. Готовые паттерны (штампы): разбор ASCII, постановка через paintTool, // зона/бюджет/кламп/«Сброс», корректность фигур на симуляции, дропдаун // #optPattern и проводка ввода (одно нажатие = один штамп, ПКМ-ластик), // транзиентность между сессиями. // ========================================================================= await resetGlobals(page); // 35a. Разбор ASCII-паттернов: размер bbox и число клеток. const pat = await page.evaluate(() => { const out = {}; PATTERNS.forEach(p => { const c = patternCells(p); out[p.id] = { n: c.length, w: p._w, h: p._h }; }); return out; }); ok('patternCells: блок 2×2/4', pat.block.n === 4 && pat.block.w === 2 && pat.block.h === 2, JSON.stringify(pat.block)); ok('patternCells: улей 4×3/6', pat.beehive.n === 6 && pat.beehive.w === 4 && pat.beehive.h === 3, JSON.stringify(pat.beehive)); ok('patternCells: мигалка 3×1/3', pat.blinker.n === 3 && pat.blinker.w === 3 && pat.blinker.h === 1, JSON.stringify(pat.blinker)); ok('patternCells: жаба 4×2/6', pat.toad.n === 6 && pat.toad.w === 4 && pat.toad.h === 2, JSON.stringify(pat.toad)); ok('patternCells: глайдер 3×3/5', pat.glider.n === 5 && pat.glider.w === 3 && pat.glider.h === 3, JSON.stringify(pat.glider)); ok('patternCells: R-пентамино 3×3/5', pat.rpentomino.n === 5 && pat.rpentomino.w === 3 && pat.rpentomino.h === 3, JSON.stringify(pat.rpentomino)); ok('patternCells: корабль LWSS 5×4/9', pat.lwss.n === 9 && pat.lwss.w === 5 && pat.lwss.h === 4, JSON.stringify(pat.lwss)); ok('patternCells: пульсар 13×13/48', pat.pulsar.n === 48 && pat.pulsar.w === 13 && pat.pulsar.h === 13, JSON.stringify(pat.pulsar)); ok('patternCells: ружьё Госпера 36×9/36', pat.gun.n === 36 && pat.gun.w === 36 && pat.gun.h === 9, JSON.stringify(pat.gun)); // Новые фигуры: стойкие. ok('patternCells: ванна 3×3/4', pat.tub.n === 4 && pat.tub.w === 3 && pat.tub.h === 3, JSON.stringify(pat.tub)); ok('patternCells: лодка 3×3/5', pat.boat.n === 5 && pat.boat.w === 3 && pat.boat.h === 3, JSON.stringify(pat.boat)); ok('patternCells: кораблик 3×3/6', pat.ship.n === 6 && pat.ship.w === 3 && pat.ship.h === 3, JSON.stringify(pat.ship)); ok('patternCells: каравай 4×4/7', pat.loaf.n === 7 && pat.loaf.w === 4 && pat.loaf.h === 4, JSON.stringify(pat.loaf)); ok('patternCells: пожиратель 4×4/7', pat.eater.n === 7 && pat.eater.w === 4 && pat.eater.h === 4, JSON.stringify(pat.eater)); ok('patternCells: пруд 4×4/8', pat.pond.n === 8 && pat.pond.w === 4 && pat.pond.h === 4, JSON.stringify(pat.pond)); // Новые фигуры: осцилляторы и долгожители. ok('patternCells: маяк 4×4/8', pat.beacon.n === 8 && pat.beacon.w === 4 && pat.beacon.h === 4, JSON.stringify(pat.beacon)); ok('patternCells: часы 4×4/6', pat.clock.n === 6 && pat.clock.w === 4 && pat.clock.h === 4, JSON.stringify(pat.clock)); ok('patternCells: пентадекатлон 10×3/12', pat.pentadecathlon.n === 12 && pat.pentadecathlon.w === 10 && pat.pentadecathlon.h === 3, JSON.stringify(pat.pentadecathlon)); ok('patternCells: жёлудь 7×3/7', pat.acorn.n === 7 && pat.acorn.w === 7 && pat.acorn.h === 3, JSON.stringify(pat.acorn)); ok('patternCells: Diehard 8×3/7', pat.diehard.n === 7 && pat.diehard.w === 8 && pat.diehard.h === 3, JSON.stringify(pat.diehard)); ok('patternCells: громовая птица 3×5/6', pat.thunderbird.n === 6 && pat.thunderbird.w === 3 && pat.thunderbird.h === 5, JSON.stringify(pat.thunderbird)); // Новые фигуры: стойкие, корабли, метуселаха, второе ружьё. ok('patternCells: авианосец 4×3/6', pat.aircraftcarrier.n === 6 && pat.aircraftcarrier.w === 4 && pat.aircraftcarrier.h === 3, JSON.stringify(pat.aircraftcarrier)); ok('patternCells: длинная лодка 4×4/7', pat.longboat.n === 7 && pat.longboat.w === 4 && pat.longboat.h === 4, JSON.stringify(pat.longboat)); ok('patternCells: каноэ 5×5/8', pat.canoe.n === 8 && pat.canoe.w === 5 && pat.canoe.h === 5, JSON.stringify(pat.canoe)); ok('patternCells: корабль MWSS 6×5/11', pat.mwss.n === 11 && pat.mwss.w === 6 && pat.mwss.h === 5, JSON.stringify(pat.mwss)); ok('patternCells: корабль HWSS 7×5/13', pat.hwss.n === 13 && pat.hwss.w === 7 && pat.hwss.h === 5, JSON.stringify(pat.hwss)); ok('patternCells: Loafer 9×9/20', pat.loafer.n === 20 && pat.loafer.w === 9 && pat.loafer.h === 9, JSON.stringify(pat.loafer)); ok('patternCells: кролики 8×4/9', pat.bunnies.n === 9 && pat.bunnies.w === 8 && pat.bunnies.h === 4, JSON.stringify(pat.bunnies)); ok('patternCells: ружьё Симкина 33×21/36', pat.simkingun.n === 36 && pat.simkingun.w === 33 && pat.simkingun.h === 21, JSON.stringify(pat.simkingun)); // 35b. Штамп через paintTool: ставит все клетки, якорь — левый верхний угол, // бюджет списывается по 1 за новую; повторная постановка своих клеток не тратит. const stampAct = await page.evaluate(() => { forceState('MENU'); setFieldSize(60); budget.total = 100; budget.used1 = 0; budget.used2 = 0; forceState('PLACE_P1'); stamp = findPattern('glider'); paintTool(10, 10); const cells = patternCells(stamp).map(c => [10 + c.x, 10 + c.y]); const allSet = cells.every(c => field[c[1] * fieldW + c[0]] === 1); const used1 = budget.used1, count = countByPlayer(field).p1; paintTool(10, 10); // повторно по тем же клеткам — своих не тратим const used2 = budget.used1; stamp = null; return { allSet, used1, used2, count }; }); ok('штамп глайдера: все 5 клеток встают, якорь (10,10), бюджет −5', stampAct.allSet && stampAct.count === 5 && stampAct.used1 === 5, JSON.stringify(stampAct)); ok('штамп по своим клеткам бюджет не тратит', stampAct.used2 === 5, 'used=' + stampAct.used2); // 35c. Обрезка по зоне рисования: клетки за половиной не ставятся (бюджет только // за поставленные), в превью-логике они помечаются отдельно (inZone=false). const stampClip = await page.evaluate(() => { forceState('MENU'); setFieldSize(60); budget.total = 100; budget.used1 = 0; budget.used2 = 0; forceState('PLACE_P1'); stamp = findPattern('glider'); const anchor = Math.ceil(fieldW / 2) - 2; // 28: часть фигуры (x=30) выйдет из половины paintTool(anchor, 10); const inHalf = field[10 * fieldW + (anchor + 1)] === 1; // x=29 — в зоне const outHalf = field[11 * fieldW + (anchor + 2)] === 0; // x=30 — вне зоны (не поставлена) const used = budget.used1; stamp = null; return { inHalf, outHalf, used }; }); ok('штамп обрезается по зоне: часть за половиной не ставится, бюджет −3', stampClip.inHalf && stampClip.outHalf && stampClip.used === 3, JSON.stringify(stampClip)); // 35d. Кламп по доске: нижний ряд блока у края поля не ставится. const stampClamp = await page.evaluate(() => { forceState('MENU'); setFieldASCII('.....\n.....\n.....\n.....\n.....', 5); budget.total = 100; budget.used1 = 0; budget.used2 = 0; forceState('PLACE_P1'); stamp = findPattern('block'); paintTool(0, 4); // нижний ряд (y=5) выйдет за доску const row4 = field[4 * 5 + 0] === 1 && field[4 * 5 + 1] === 1; const used = budget.used1; stamp = null; return { row4, used }; }); ok('штамп клампится по доске (нижний ряд не ставится), бюджет −2', stampClamp.row4 && stampClamp.used === 2, JSON.stringify(stampClamp)); // 35e. «Сброс» после штампа возвращает и поле, и бюджет к началу хода. const stampReset = await page.evaluate(() => { forceState('MENU'); setFieldSize(60); budget.total = 100; budget.used1 = 0; budget.used2 = 0; forceState('PLACE_P1'); const h0 = hashField(); stamp = findPattern('glider'); paintTool(10, 10); const h1 = hashField(), u1 = budget.used1; resetGame(); const h2 = hashField(), u2 = budget.used1; stamp = null; return { h0, h1, h2, u1, u2 }; }); ok('«Сброс» после штампа возвращает поле и бюджет', stampReset.h1 !== stampReset.h0 && stampReset.h2 === stampReset.h0 && stampReset.u1 === 5 && stampReset.u2 === 0, JSON.stringify(stampReset)); // 35f. Корректность фигур на симуляции: глайдер движется на (1,1), блок — // фиксированная точка, мигалка — период 2, пульсар — период 3, ружьё растит // население (эмиссия глайдеров). Это проверяет, что ASCII-формы записаны верно. const stampSim = await page.evaluate(() => { function stampAt(id, w, x, y) { forceState('MENU'); setFieldSize(w); budget.total = 1000; budget.used1 = 0; budget.used2 = 0; forceState('PLACE_P1'); stamp = findPattern(id); paintTool(x, y); stamp = null; } function live() { const a = []; for (let y = 0; y < fieldH; y++) for (let x = 0; x < fieldW; x++) if (field[y * fieldW + x] === 1) a.push([x, y]); return a; } stampAt('glider', 60, 20, 20); const g0 = live(); stepN(4); const g1 = live(); const moved = g0.length === 5 && g1.length === 5 && g0.every(p => g1.some(q => q[0] === p[0] + 1 && q[1] === p[1] + 1)); stampAt('block', 60, 10, 10); const b0 = hashField(); stepN(5); const blockFixed = hashField() === b0; stampAt('blinker', 60, 10, 10); const bl0 = hashField(); stepN(1); const bl1 = hashField(); stepN(1); const bl2 = hashField(); stampAt('pulsar', 60, 10, 10); const pu0 = hashField(); stepN(3); const pulsarPeriod = hashField() === pu0; stampAt('gun', 100, 5, 5); const n0 = countByPlayer(field).p1; stepN(90); const n90 = countByPlayer(field).p1; forceState('MENU'); return { moved, blockFixed, blinker: (bl1 !== bl0 && bl2 === bl0), pulsarPeriod, n0, n90 }; }); ok('штамп глайдера: за 4 шага сдвиг (1,1)', stampSim.moved, JSON.stringify(stampSim)); ok('штамп блока: неподвижен (фиксированная точка)', stampSim.blockFixed); ok('штамп мигалки: период 2', stampSim.blinker); ok('штамп пульсара: период 3', stampSim.pulsarPeriod); ok('штамп ружья Госпера: население растёт (36 → >36 за 90 шагов)', stampSim.n0 === 36 && stampSim.n90 > 36, 'n0=' + stampSim.n0 + ', n90=' + stampSim.n90); // 35f-bis. Поведение новых фигур: стойкие неподвижны, осцилляторы — периоды, // долгожители: жёлудь/громовая птица растут, Diehard полностью гибнет. Проверяет, // что ASCII-формы новых записей записаны верно. const newSim = await page.evaluate(() => { function stampAt(id, w, x, y) { forceState('MENU'); setFieldSize(w); budget.total = 5000; budget.used1 = 0; budget.used2 = 0; forceState('PLACE_P1'); stamp = findPattern(id); paintTool(x, y); stamp = null; } function pop() { return countByPlayer(field).p1; } const still = {}; ['tub', 'boat', 'ship', 'loaf', 'pond', 'eater'].forEach(function (id) { stampAt(id, 60, 10, 10); const a = hashField(); stepN(1); still[id] = (hashField() === a); }); // Новые стойкие: авианосец, длинная лодка, каноэ — тоже неподвижны. ['aircraftcarrier', 'longboat', 'canoe'].forEach(function (id) { stampAt(id, 60, 10, 10); const a = hashField(); stepN(1); still[id] = (hashField() === a); }); stampAt('beacon', 60, 10, 10); const be0 = hashField(); stepN(1); const be1 = hashField(); stepN(1); const be2 = hashField(); stampAt('clock', 60, 10, 10); const cl0 = hashField(); stepN(1); const cl1 = hashField(); stepN(1); const cl2 = hashField(); stampAt('pentadecathlon', 60, 10, 10); const pd0 = hashField(); stepN(15); const pd15 = hashField(); stampAt('acorn', 60, 10, 10); const ac0 = pop(); stepN(100); const ac1 = pop(); stampAt('thunderbird', 60, 10, 10); const tb0 = pop(); stepN(120); const tb1 = pop(); stampAt('diehard', 60, 10, 10); stepN(130); const dhLeft = countByPlayer(field).p1 + countByPlayer(field).p2; // Новые корабли: MWSS/HWSS — период 4 (смещение 2), Loafer — период 7 (смещение 1). function liveCells() { const a = []; for (let y = 0; y < fieldH; y++) for (let x = 0; x < fieldW; x++) if (field[y * fieldW + x] === 1) a.push([x, y]); return a; } function shift(id, w, period) { stampAt(id, w, 20, 20); const b = liveCells(); stepN(period); const a = liveCells(); const bmx = Math.min.apply(null, b.map(p => p[0])), bmy = Math.min.apply(null, b.map(p => p[1])); const amx = Math.min.apply(null, a.map(p => p[0])), amy = Math.min.apply(null, a.map(p => p[1])); const nb = b.map(p => (p[0] - bmx) + ',' + (p[1] - bmy)).sort().join(' '); const na = a.map(p => (p[0] - amx) + ',' + (p[1] - amy)).sort().join(' '); return { same: nb === na, dx: amx - bmx, dy: amy - bmy, n: a.length }; } const mw = shift('mwss', 60, 4); const hw = shift('hwss', 60, 4); const lf = shift('loafer', 60, 7); stampAt('bunnies', 150, 20, 20); const bn0 = pop(); stepN(200); const bn1 = pop(); stampAt('simkingun', 100, 5, 5); const sk0 = pop(); stepN(200); const sk1 = pop(); forceState('MENU'); return { still, beacon: be1 !== be0 && be2 === be0, clock: cl1 !== cl0 && cl2 === cl0, penta: pd15 === pd0, acorn: ac0 === 7 && ac1 > ac0, thunder: tb0 === 6 && tb1 > tb0, diehard: dhLeft === 0, mwss: mw.same && Math.abs(mw.dx) + Math.abs(mw.dy) === 2 && mw.n === 11, hwss: hw.same && Math.abs(hw.dx) + Math.abs(hw.dy) === 2 && hw.n === 13, loafer: lf.same && Math.abs(lf.dx) + Math.abs(lf.dy) === 1 && lf.n === 20, bunnies: bn0 === 9 && bn1 > bn0, simkin: sk0 === 36 && sk1 > sk0 }; }); ok('штампы стойких фигур неподвижны (фиксированная точка)', Object.values(newSim.still).every(Boolean), JSON.stringify(newSim.still)); ok('штамп маяка: период 2', newSim.beacon); ok('штамп часов: период 2', newSim.clock); ok('штамп пентадекатлона: период 15', newSim.penta); ok('штамп жёлудя: население растёт (7 → >7 за 100 шагов)', newSim.acorn); ok('штамп громовой птицы: население растёт (6 → >6 за 120 шагов)', newSim.thunder); ok('штамп Diehard: полностью гибнет за 130 шагов', newSim.diehard); ok('штамп MWSS: период 4, смещение 2 (11 клеток)', newSim.mwss); ok('штамп HWSS: период 4, смещение 2 (13 клеток)', newSim.hwss); ok('штамп Loafer: период 7, смещение 1 (20 клеток)', newSim.loafer); ok('штамп кроликов: население растёт (9 → >9 за 200 шагов)', newSim.bunnies); ok('штамп ружья Симкина: население растёт (36 → >36 за 200 шагов)', newSim.simkin); // 35f-ter. Группировка дропдауна: 5 optgroup (Стойкие/Осцилляторы/Корабли/ // Долгожители/Ружья), пустая опция — прямой ребёнок select, все id на месте. const groups = await page.evaluate(() => { const sel = document.getElementById('optPattern'); const ogs = Array.from(sel.querySelectorAll('optgroup')); return { n: ogs.length, labels: ogs.map(function (g) { return g.label; }), stillHasBlock: !!sel.querySelector('optgroup[label="Стойкие"] option[value="block"]'), growthHasAcorn: !!sel.querySelector('optgroup[label="Долгожители"] option[value="acorn"]'), directNone: !!sel.querySelector(':scope > option[value=""]'), }; }); ok('#optPattern: 5 групп (Стойкие/Осцилляторы/Корабли/Долгожители/Ружья)', groups.n === 5 && groups.labels.join(',') === 'Стойкие,Осцилляторы,Корабли,Долгожители,Ружья', JSON.stringify(groups.labels)); ok('#optPattern: пустая опция — прямой ребёнок, новые фигуры сгруппированы', groups.directNone && groups.stillHasBlock && groups.growthHasAcorn, JSON.stringify(groups)); ok('#optPattern: все id PATTERNS присутствуют в опциях ровно по разу', await page.evaluate(() => { const ids = Array.from(document.querySelectorAll('#optPattern option')).map(function (o) { return o.value; }).filter(Boolean); return PATTERNS.every(function (p) { return ids.includes(p.id); }) && ids.length === PATTERNS.length; })); // 35g. Дропдаун #optPattern: присутствует в тулбаре, выбор задаёт stamp, выбор // инструмента сбрасывает штамп, вне фаз рисования — disabled. await resetGlobals(page); await page.evaluate(() => forceState('PLACE_P1')); ok('#optPattern присутствует в тулбаре', await page.evaluate(() => !!document.querySelector('#toolbar #optPattern'))); await page.select('#optPattern', 'glider'); ok('выбор #optPattern=glider → активный штамп глайдера', (await page.evaluate(() => stamp && stamp.id)) === 'glider'); { const ui = await page.evaluate(() => ({ active: document.getElementById('optPattern').classList.contains('active'), toolActive: document.querySelectorAll('.toolbtn.active').length, })); ok('#optPattern подсвечен при активном штампе, кнопки инструментов — нет', ui.active === true && ui.toolActive === 0, JSON.stringify(ui)); } await page.click('#toolPencil'); ok('клик по инструменту сбрасывает штамп', (await page.evaluate(() => stamp === null)) && (await page.evaluate(() => document.getElementById('optPattern').value === ''))); await page.evaluate(() => forceState('MENU')); ok('#optPattern disabled вне фаз рисования', await page.evaluate(() => document.getElementById('optPattern').disabled === true)); // 35g2. Свой список паттернов #patternMenu: нативный popup остаётся // контрактом (value/change/optgroup) — см. тесты 35f-ter/35g выше. await resetGlobals(page); await page.evaluate(() => forceState('PLACE_P1')); { const menuState = () => page.evaluate(() => ({ open: !document.getElementById('patternMenu').classList.contains('hidden'), items: document.querySelectorAll('#patternMenu .patitem').length, groups: document.querySelectorAll('#patternMenu .patgroup').length, })); const selBox = await page.evaluate(() => { const b = document.getElementById('optPattern').getBoundingClientRect(); return { x: b.left + 14, y: b.top + b.height / 2 }; }); ok('#patternMenu изначально скрыт', (await menuState()).open === false); await page.mouse.click(selBox.x, selBox.y); const opened = await menuState(); ok('клик по #optPattern открывает свой список (не нативный popup)', opened.open === true, JSON.stringify(opened)); ok('#patternMenu: 30 фигур и 5 заголовков групп', opened.items === 30 && opened.groups === 5, JSON.stringify(opened)); const blockBox = await page.evaluate(() => { const b = Array.from(document.querySelectorAll('#patternMenu .patitem')) .find(x => x.getAttribute('data-id') === 'block').getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; }); await page.mouse.click(blockBox.x, blockBox.y); const picked = await page.evaluate(() => ({ open: !document.getElementById('patternMenu').classList.contains('hidden'), val: document.getElementById('optPattern').value, stamp: window.stamp ? window.stamp.id : null, active: document.getElementById('optPattern').classList.contains('active'), itemActive: !!document.querySelector('#patternMenu .patitem.active'), })); ok('выбор фигуры в #patternMenu задаёт stamp и закрывает список', picked.open === false && picked.val === 'block' && picked.stamp === 'block' && picked.active === true && picked.itemActive === true, JSON.stringify(picked)); // Повторное открытие → Escape закрывает. await page.mouse.click(selBox.x, selBox.y); ok('#patternMenu снова открывается', (await menuState()).open === true); await page.keyboard.press('Escape'); ok('Escape закрывает #patternMenu', (await menuState()).open === false); // Открыть и кликнуть вне списка — закрывается, stamp не меняется. // Клик — в верхнем левом углу (выше доски и выше списка), не по фигуре. await page.mouse.click(selBox.x, selBox.y); await page.mouse.click(5, 5); ok('клик вне #patternMenu закрывает список', (await menuState()).open === false); ok('stamp не сброшен кликом вне списка', (await page.evaluate(() => window.stamp && window.stamp.id)) === 'block'); // Прокрутка колесом НАД списком не закрывает его (листает фигуры). await page.mouse.click(selBox.x, selBox.y); const menuPoint = await page.evaluate(() => { const b = document.getElementById('patternMenu').getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + 50 }; }); await page.mouse.move(menuPoint.x, menuPoint.y); await page.mouse.wheel({ deltaY: 300 }); await sleep(150); const wheeled = await page.evaluate(() => ({ open: !document.getElementById('patternMenu').classList.contains('hidden'), scrollTop: document.getElementById('patternMenu').scrollTop, })); ok('колесо над #patternMenu не закрывает список и прокручивает его', wheeled.open === true && wheeled.scrollTop > 0, JSON.stringify(wheeled)); await page.evaluate(() => { stamp = null; forceState('MENU'); }); } // 35h. Ввод: одно нажатие = одна постановка (drag не заливает бюджет повторами). await resetGlobals(page); await page.evaluate(() => forceState('PLACE_P1')); await page.select('#optPattern', 'glider'); { const g = await page.evaluate(() => ({ x: board.x, y: board.y, c: board.cell })); await page.mouse.move(g.x + 5.5 * g.c, g.y + 5.5 * g.c); await page.mouse.down(); await page.mouse.move(g.x + 9.5 * g.c, g.y + 5.5 * g.c, { steps: 6 }); await page.mouse.up(); const r = await page.evaluate(() => ({ used: budget.used1, count: countByPlayer(field).p1 })); ok('штамп: одно нажатие = одна постановка (drag не повторяет)', r.used === 5 && r.count === 5, JSON.stringify(r)); } // 35i. ПКМ при активном штампе работает ластиком (штамп не ставится). { await page.evaluate(() => { forceState('MENU'); setFieldSize(60); budget.total = 100; budget.used1 = 0; budget.used2 = 0; forceState('PLACE_P1'); stamp = findPattern('glider'); paintTool(10, 10); stamp = null; }); const g = await page.evaluate(() => ({ x: board.x, y: board.y, c: board.cell })); await page.evaluate(() => { stamp = findPattern('glider'); }); await page.mouse.move(g.x + 11.5 * g.c, g.y + 10.5 * g.c); // клетка глайдера (11,10) await page.mouse.down({ button: 'right' }); await page.mouse.up({ button: 'right' }); const r = await page.evaluate(() => ({ v: field[10 * fieldW + 11], used: budget.used1 })); ok('ПКМ при активном штампе стирает клетку (штамп не ставится)', r.v === 0 && r.used === 4, JSON.stringify(r)); } await page.evaluate(() => { stamp = null; forceState('MENU'); }); // 35j. Транзиентность: выбранный паттерн НЕ сохраняется между сессиями (как speedMult). await resetGlobals(page); await page.evaluate(() => { forceState('PLACE_P1'); stamp = findPattern('glider'); }); await reload(page); ok('штамп не переживает reload (транзиентно, в сейв не пишется)', (await page.evaluate(() => stamp === null)) === true); await resetGlobals(page); // ========================================================================= // 36. Поворот паттерна колесом мыши (stampRot 0..3, 90° за шаг): stampCells // отдаёт повёрнутые offsets, 4 поворота = исходная форма; колесо над доской // вращает в фазах рисования при активном штампе; постановка — по повёрнутой // фигуре; поворот сбрасывается при смене паттерна/инструмента и транзиентен. // ========================================================================= await resetGlobals(page); // 36a. stampCells: поворот меняет bbox (квадрат не меняет), 4 шага → исходная форма. const rotCells = await page.evaluate(() => { forceState('MENU'); setFieldSize(60); forceState('PLACE_P1'); stamp = findPattern('glider'); stampRot = 0; const base = stampCells().map(c => c.x + ',' + c.y).sort().join(' '); const dims = []; for (let r = 0; r < 4; r++) { stampRot = r; let w = 0, h = 0; for (const o of stampCells()) { if (o.x + 1 > w) w = o.x + 1; if (o.y + 1 > h) h = o.y + 1; } dims.push(w + 'x' + h); } stampRot = 2; const rotated = stampCells().map(c => c.x + ',' + c.y).sort().join(' '); stampRot = 4 % 4; const back = stampCells().map(c => c.x + ',' + c.y).sort().join(' ') === base; stampRot = 1; const blinkerDims = (() => { stamp = findPattern('blinker'); let w = 0, h = 0; for (const o of stampCells()) { if (o.x + 1 > w) w = o.x + 1; if (o.y + 1 > h) h = o.y + 1; } return w + 'x' + h; })(); stamp = null; stampRot = 0; return { base, rotated, back, dims, blinkerDims }; }); ok('stampCells: глайдер при поворотах остаётся 3×3 (симметричный bbox)', JSON.stringify(rotCells.dims) === JSON.stringify(['3x3', '3x3', '3x3', '3x3']), JSON.stringify(rotCells.dims)); ok('stampCells: 4 поворота возвращают исходную форму', rotCells.back === true, JSON.stringify(rotCells)); ok('stampCells: поворот 180° отличается от исходного (глайдер)', rotCells.rotated !== rotCells.base); ok('stampCells: мигалка при повороте 90° становится 1×3', rotCells.blinkerDims === '1x3', rotCells.blinkerDims); // 36b. Колесо над доской: вниз — 90° CW, вверх — обратно, круг замыкается; // без активного штампа и вне фаз рисования — инертно. { await page.evaluate(() => { forceState('PLACE_P1'); stamp = findPattern('blinker'); stampRot = 0; }); const dim0 = await page.evaluate(() => { let w = 0, h = 0; for (const o of stampCells()) { if (o.x + 1 > w) w = o.x + 1; if (o.y + 1 > h) h = o.y + 1; } return w + 'x' + h; }); const wheel = (dy) => page.evaluate((d) => { canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: d, bubbles: true, cancelable: true })); }, dy); await wheel(120); const afterDown = await page.evaluate(() => { let w = 0, h = 0; for (const o of stampCells()) { if (o.x + 1 > w) w = o.x + 1; if (o.y + 1 > h) h = o.y + 1; } return { rot: stampRot, dim: w + 'x' + h }; }); ok('колесо вниз над доской поворачивает штамп (3×1 → 1×3)', dim0 === '3x1' && afterDown.rot === 1 && afterDown.dim === '1x3', JSON.stringify({ dim0, afterDown })); await wheel(-120); ok('колесо вверх поворачивает обратно (stampRot → 0)', (await page.evaluate(() => stampRot)) === 0); await page.evaluate(() => { for (let i = 0; i < 4; i++) canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true })); }); ok('4 прокрутки вниз возвращают stampRot=0', (await page.evaluate(() => stampRot)) === 0); await page.evaluate(() => { stamp = null; stampRot = 0; canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true })); }); ok('колесо без активного штампа ничего не меняет', (await page.evaluate(() => stampRot)) === 0); await page.evaluate(() => { forceState('PLACE_P1'); stamp = findPattern('glider'); stampRot = 0; forceState('MENU'); canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true })); }); ok('колесо вне фаз рисования не поворачивает', (await page.evaluate(() => stampRot)) === 0); // Тачпад: мелкие частые дельты накапливаются до порога — один поворот на «щелчок», // а не по 90° на каждое событие. await page.evaluate(() => { forceState('PLACE_P1'); stamp = findPattern('blinker'); stampRot = 0; wheelAccum = 0; }); await page.evaluate(() => { canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: 50, bubbles: true, cancelable: true })); }); const smallNoRot = await page.evaluate(() => stampRot); await page.evaluate(() => { canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: 60, bubbles: true, cancelable: true })); }); const accRot = await page.evaluate(() => stampRot); ok('колесо: мелкие дельты копятся (50 → без поворота, ещё +60 → 90°)', smallNoRot === 0 && accRot === 1, JSON.stringify({ smallNoRot, accRot })); } // 36c. Постановка повёрнутого штампа: paintTool ставит фигуру по stampCells. const rotPlace = await page.evaluate(() => { forceState('MENU'); setFieldSize(60); budget.total = 100; budget.used1 = 0; budget.used2 = 0; forceState('PLACE_P1'); stamp = findPattern('blinker'); stampRot = 1; // вертикальная мигалка 1×3 paintTool(10, 10); const vert = field[10 * fieldW + 10] === 1 && field[11 * fieldW + 10] === 1 && field[12 * fieldW + 10] === 1; const used = budget.used1; stamp = null; stampRot = 0; return { vert, used }; }); ok('повёрнутый штамп ставится вертикально (1×3) и тратит 3', rotPlace.vert === true && rotPlace.used === 3, JSON.stringify(rotPlace)); // 36d. Сброс поворота: выбор нового паттерна и выбор инструмента → stampRot=0. await resetGlobals(page); await page.evaluate(() => { forceState('PLACE_P1'); stamp = findPattern('glider'); stampRot = 2; }); await page.select('#optPattern', 'block'); { const r = await page.evaluate(() => ({ rot: stampRot, id: stamp && stamp.id })); ok('выбор нового паттерна сбрасывает поворот (stampRot=0)', r.rot === 0 && r.id === 'block', JSON.stringify(r)); } await page.evaluate(() => { stampRot = 3; }); await page.click('#toolPencil'); ok('выбор инструмента сбрасывает поворот (stampRot=0)', (await page.evaluate(() => stampRot)) === 0); await resetGlobals(page); // ========================================================================= // 36e–36j. Сенсорная эргономика: кнопка поворота «↻» и режим «постановка по // отпусканию» (кнопка «👆 тап») — нажатие только превью, постановка на отпускании. // ========================================================================= // 36e. Кнопка «↻»: поворот +90° за клик (без колеса мыши), видна только при штампе. await resetGlobals(page); await page.evaluate(() => { forceState('PLACE_P1'); syncDom(); }); ok('#btnRotate присутствует в тулбаре', await page.evaluate(() => !!document.querySelector('#toolbar #btnRotate'))); ok('#btnRotate скрыт без активного штампа', await page.evaluate(() => document.getElementById('btnRotate').classList.contains('hidden'))); await page.select('#optPattern', 'blinker'); { const dim = () => page.evaluate(() => { let w = 0, h = 0; for (const o of stampCells()) { if (o.x + 1 > w) w = o.x + 1; if (o.y + 1 > h) h = o.y + 1; } return { rot: stampRot, w: w, h: h }; }); const before = await dim(); await page.click('#btnRotate'); const after = await dim(); ok('клик #btnRotate поворачивает мигалку 3×1 → 1×3', before.w === 3 && before.h === 1 && after.w === 1 && after.h === 3 && after.rot === 1, JSON.stringify({ before, after })); const label = await page.evaluate(() => document.getElementById('rotDeg').textContent); ok('#btnRotate показывает текущий угол (90°)', label === '90°', label); for (let i = 0; i < 3; i++) await page.click('#btnRotate'); ok('4 клика #btnRotate возвращают исходный поворот (stampRot=0)', (await page.evaluate(() => stampRot)) === 0); } await page.evaluate(() => { stamp = null; syncDom(); }); ok('#btnRotate скрыт после сброса штампа', await page.evaluate(() => document.getElementById('btnRotate').classList.contains('hidden'))); await page.evaluate(() => { forceState('MENU'); syncDom(); }); ok('#btnRotate скрыт вне фаз рисования', await page.evaluate(() => document.getElementById('btnRotate').classList.contains('hidden'))); // 36f. Тап-режим карандашом: нажатие/движение не ставят клетку; постановка — на отпускании. await resetGlobals(page); await page.evaluate(() => { forceState('MENU'); setFieldSize(60); budget.total = 100; budget.used1 = 0; budget.used2 = 0; forceState('PLACE_P1'); }); ok('#btnTapPlace присутствует в тулбаре', await page.evaluate(() => !!document.querySelector('#toolbar #btnTapPlace'))); await page.click('#btnTapPlace'); ok('клик #btnTapPlace включает режим (tapPlace=true, .active)', await page.evaluate(() => tapPlace === true && document.getElementById('btnTapPlace').classList.contains('active'))); { const g = await page.evaluate(() => ({ x: board.x, y: board.y, c: board.cell })); await page.mouse.move(g.x + 5.5 * g.c, g.y + 5.5 * g.c); await page.mouse.down(); const during = await page.evaluate(() => ({ v: field[5 * fieldW + 5], used: budget.used1, pressing })); await page.mouse.move(g.x + 9.5 * g.c, g.y + 5.5 * g.c, { steps: 6 }); // ведём палец — линии нет const dragged = await page.evaluate(() => ({ count: countByPlayer(field).p1, used: budget.used1 })); await page.mouse.up(); const after = await page.evaluate(() => ({ cell5: field[5 * fieldW + 5], cell9: field[5 * fieldW + 9], used: budget.used1, count: countByPlayer(field).p1, })); ok('тап: нажатие только подсвечивает (клетка не поставлена)', during.v === 0 && during.used === 0 && during.pressing === true, JSON.stringify(during)); ok('тап: перетаскивание не рисует линию', dragged.count === 0 && dragged.used === 0, JSON.stringify(dragged)); ok('тап: постановка ровно в момент отпускания (одна клетка под пальцем)', after.cell9 === 1 && after.cell5 === 0 && after.used === 1 && after.count === 1, JSON.stringify(after)); } // 36g. Тап со штампом: фигура ставится по отпусканию. await page.evaluate(() => { forceState('MENU'); setFieldSize(60); budget.total = 100; budget.used1 = 0; budget.used2 = 0; tapPlace = true; forceState('PLACE_P1'); stamp = findPattern('glider'); }); { const g = await page.evaluate(() => ({ x: board.x, y: board.y, c: board.cell })); await page.mouse.move(g.x + 5.5 * g.c, g.y + 5.5 * g.c); await page.mouse.down(); const during = await page.evaluate(() => countByPlayer(field).p1); await page.mouse.up(); const after = await page.evaluate(() => ({ count: countByPlayer(field).p1, used: budget.used1 })); ok('тап со штампом: до отпускания фигура не поставлена', during === 0, 'during=' + during); ok('тап со штампом: по отпусканию фигура поставлена целиком (5 клеток)', after.count === 5 && after.used === 5, JSON.stringify(after)); } // 36h. pointercancel в тап-режиме отменяет постановку. await page.evaluate(() => { forceState('MENU'); setFieldSize(60); budget.total = 100; budget.used1 = 0; budget.used2 = 0; tapPlace = true; forceState('PLACE_P1'); stamp = null; selectTool('pencil'); }); { const g = await page.evaluate(() => ({ x: board.x, y: board.y, c: board.cell })); await page.mouse.move(g.x + 5.5 * g.c, g.y + 5.5 * g.c); await page.mouse.down(); await page.evaluate((x, y) => { canvas.dispatchEvent(new PointerEvent('pointercancel', { clientX: x, clientY: y, bubbles: true })); }, g.x + 5.5 * g.c, g.y + 5.5 * g.c); const after = await page.evaluate(() => ({ count: countByPlayer(field).p1, pressing })); ok('тап: pointercancel отменяет постановку (поле не меняется)', after.count === 0 && after.pressing === false, JSON.stringify(after)); await page.mouse.up(); } // 36i. ПКМ в тап-режиме стирает по отпусканию, активный инструмент не меняется. await page.evaluate(() => { forceState('MENU'); setFieldSize(60); budget.total = 100; budget.used1 = 0; budget.used2 = 0; tapPlace = true; forceState('PLACE_P1'); stamp = null; selectTool('pencil'); paintTool(5, 5); }); { const g = await page.evaluate(() => ({ x: board.x, y: board.y, c: board.cell })); await page.mouse.move(g.x + 5.5 * g.c, g.y + 5.5 * g.c); await page.mouse.down({ button: 'right' }); const during = await page.evaluate(() => ({ v: field[5 * fieldW + 5], used: budget.used1 })); await page.mouse.up({ button: 'right' }); const after = await page.evaluate(() => ({ v: field[5 * fieldW + 5], used: budget.used1, tool: tool.id })); ok('тап ПКМ: стирание по отпусканию, инструмент не меняется', during.v === 1 && after.v === 0 && after.tool === 'pencil', JSON.stringify({ during, after })); } // 36j. tapPlace транзиентен: не переживает reload (в сейв не пишется). await resetGlobals(page); await page.evaluate(() => { forceState('PLACE_P1'); tapPlace = true; }); await reload(page); ok('tapPlace не переживает reload (транзиентно)', (await page.evaluate(() => tapPlace)) === false); await resetGlobals(page); // ========================================================================= // 37. РЕЖИМ «КАМПАНИЯ»: уровни с врагом (цвет 2), зона-прямоугольник, свой // бюджет, запрет трогать врага, победа (0 врага) / поражение (бюджет исчерпан, // враг жив, авто-пауза), прогресс/разблокировка, сейв/резюм, suspended, HUD, // проводка UI. Ядро step/liveNeighbors не меняется — паритет сохраняется. // ========================================================================= // --- 37a. Схема CAMPAIGN_LEVELS (103 уровня: 7 ручных + 96 сгенерированных; // формат cells: [[x,y],...] — координаты клеток врага) --- const lvSchema = await page.evaluate(() => { // Геометрический классификатор типа зоны (для проверки разнообразия). function zoneType(lv) { const z = lv.zone, w = lv.w, h = lv.h; if (z.w === w && z.h === h) return 'full'; if (z.y === 0 && z.h === h && z.w <= w * 0.35) return 'vstrip'; if (z.x === 0 && z.w === w && z.h <= h * 0.35) return 'hstrip'; if (z.x === 0 && z.w === w) return 'half'; const cw = Math.max(6, Math.round(w * 0.55)), ch = Math.max(6, Math.round(h * 0.55)); if (z.w === cw && z.h === ch) return 'center'; if (z.x === 0 && z.y === 0) return 'cornerTL'; if (z.x + z.w === w && z.y + z.h === h) return 'cornerBR'; return 'rect'; } const out = CAMPAIGN_LEVELS.map(lv => { const zone = { x: lv.zone.x, y: lv.zone.y, w: lv.zone.w, h: lv.zone.h }; const seen = new Set(); let enemyCount = 0, outOfZone = 0, dup = 0, oob = 0; for (const [x, y] of lv.cells) { enemyCount++; if (x < 0 || y < 0 || x >= lv.w || y >= lv.h) oob++; if (seen.has(x + ',' + y)) dup++; else seen.add(x + ',' + y); const inZone = x >= zone.x && x < zone.x + zone.w && y >= zone.y && y < zone.y + zone.h; if (!inZone) outOfZone++; } return { id: lv.id, name: lv.name, w: lv.w, h: lv.h, wrap: lv.wrap, budget: lv.budget, zone, zoneType: zoneType(lv), isCellsArray: Array.isArray(lv.cells), enemyCount, outOfZone, dup, oob, l1at: (lv.id === 1) ? (seen.has('10,10') && seen.has('11,10') && seen.has('10,11') && seen.has('11,11') ? '22' : '??') : null, }; }); return { n: CAMPAIGN_LEVELS.length, out }; }); ok('кампания: 103 уровня в CAMPAIGN_LEVELS (7 ручных + 96 сгенерированных)', lvSchema.n === 103, 'n=' + lvSchema.n); ok('кампания: id уровней уникальны и покрывают 1..103', (() => { const ids = lvSchema.out.map(l => l.id); const set = new Set(ids); return set.size === 103 && Math.min(...ids) === 1 && Math.max(...ids) === 103; })(), JSON.stringify(lvSchema.out.map(l => l.id).slice(0, 12)) + '…'); ok('кампания: у каждого уровня cells — непустой массив координат в границах без дублей', lvSchema.out.every(l => l.isCellsArray && l.enemyCount > 0 && l.oob === 0 && l.dup === 0), JSON.stringify({ oob: lvSchema.out.filter(l => l.oob).length, dup: lvSchema.out.filter(l => l.dup).length })); ok('кампания: L1 «Разминка» — блок 2×2 (4 клетки)', lvSchema.out[0].enemyCount === 4 && lvSchema.out[0].l1at === '22', lvSchema.out[0].enemyCount + '/' + lvSchema.out[0].l1at); ok('кампания: L2 «Каравай» — 7 клеток, зона {5,5,15,15}, бюджет 15', lvSchema.out[1].enemyCount === 7 && lvSchema.out[1].zone.w === 15 && lvSchema.out[1].zone.h === 15 && lvSchema.out[1].zone.x === 5 && lvSchema.out[1].zone.y === 5 && lvSchema.out[1].budget === 15, JSON.stringify({ n: lvSchema.out[1].enemyCount, z: lvSchema.out[1].zone, b: lvSchema.out[1].budget })); ok('кампания: зона внутри поля у всех уровней', lvSchema.out.every(l => l.zone.x >= 0 && l.zone.y >= 0 && l.zone.x + l.zone.w <= l.w && l.zone.y + l.zone.h <= l.h), JSON.stringify(lvSchema.out.filter(l => !(l.zone.x >= 0 && l.zone.y >= 0 && l.zone.x + l.zone.w <= l.w && l.zone.y + l.zone.h <= l.h)).map(l => l.id))); // Порядок первых 10: id1..3, затем сгенерированные id5..id10 (индексы 3..8), // затем «Издалека» (id4) ВСТАВЛЕН на 10-е место (индекс 9). ok('кампания: порядок вставки — id5..id10 на индексах 3..8, «Издалека» (id4) на индексе 9', lvSchema.out.slice(0, 3).map(l => l.id).join(',') === '1,2,3' && lvSchema.out.slice(3, 9).map(l => l.id).join(',') === '5,6,7,8,9,10' && lvSchema.out[9].id === 4, JSON.stringify(lvSchema.out.slice(0, 10).map(l => l.id))); // «Издалека» ВСТАВЛЕН на 10-е место (индекс 9) и остался ровно прежним. ok('кампания: «Издалека» на 10-м месте (индекс 9), 60×60, зона {0,0,14,20}, бюджет 60', lvSchema.out[9].name === 'Издалека' && lvSchema.out[9].w === 60 && lvSchema.out[9].h === 60 && lvSchema.out[9].zone.x === 0 && lvSchema.out[9].zone.y === 0 && lvSchema.out[9].zone.w === 14 && lvSchema.out[9].zone.h === 20 && lvSchema.out[9].budget === 60, JSON.stringify(lvSchema.out[9])); ok('кампания: у «Издалека» фигуры ВНЕ зоны игрока (враг далеко)', lvSchema.out[9].outOfZone > 0, 'outOfZone=' + lvSchema.out[9].outOfZone); // Сразу после «Издалека» — ручные showcase-уровни id 101..103 (индексы 10..12). ok('кампания: showcase-уровни id 101..103 на индексах 10..12', lvSchema.out.slice(10, 13).map(l => l.id).join(',') === '101,102,103' && lvSchema.out[10].name === 'Флотилия' && lvSchema.out[11].name === 'Погоня' && lvSchema.out[12].name === 'Кролики', JSON.stringify(lvSchema.out.slice(10, 13).map(l => ({ id: l.id, name: l.name })))); ok('кампания: showcase-уровни — 80×80/90×90/100×100, зоны внутри поля, свой бюджет', lvSchema.out[10].w === 80 && lvSchema.out[11].w === 90 && lvSchema.out[12].w === 100 && lvSchema.out.slice(10, 13).every(l => l.w === l.h && l.budget > 0 && l.zone.w === l.w / 2), JSON.stringify(lvSchema.out.slice(10, 13).map(l => ({ id: l.id, wh: [l.w, l.h], b: l.budget, z: l.zone })))); ok('кампания: ≥1 уровень с врагом вне зоны (кроме ручного «Издалека»)', lvSchema.out.filter(l => l.id >= 5 && l.id <= 100 && l.outOfZone > 0).length >= 1, 'generated outside=' + lvSchema.out.filter(l => l.id >= 5 && l.id <= 100 && l.outOfZone > 0).length); // Размеры монотонны у СГЕНЕРИРОВАННЫХ уровней (id 5..100, от 60 до 300); у ручных // вставок («Издалека» и showcase 101..103) размер свой, в монотонность не входит. ok('кампания: размеры сгенерированных уровней (id 5..100) не убывают, последний == 300', (() => { const gen = lvSchema.out.filter(l => l.id >= 5 && l.id <= 100).map(l => l.w); const mono = gen.every((v, i) => i === 0 || v >= gen[i - 1]); return gen.length === 96 && mono && gen[gen.length - 1] === 300; })(), JSON.stringify(lvSchema.out.map(l => l.w))); // У СГЕНЕРИРОВАННЫХ уровней (id 5..100) зоны «всё поле» быть не должно; полная зона // осталась только у ручных «Разминка» (id1) и «Рой» (id3). const genLevels = lvSchema.out.filter(l => l.id >= 5 && l.id <= 100); const fullLevels = lvSchema.out.filter(l => l.zoneType === 'full'); ok('кампания: у сгенерированных уровней (id>=5) нет зоны «всё поле»', genLevels.length > 0 && genLevels.every(l => l.zoneType !== 'full'), 'full среди сгенерированных: ' + genLevels.filter(l => l.zoneType === 'full').map(l => l.id).join(',')); ok('кампания: зона «всё поле» только у ручных «Разминка»/«Рой»', fullLevels.length === 2 && fullLevels.map(l => l.id).sort((a, b) => a - b).join(',') === '1,3' && fullLevels.every(l => l.name === 'Разминка' || l.name === 'Рой'), JSON.stringify(fullLevels.map(l => ({ id: l.id, name: l.name })))); ok('кампания: среди сгенерированных есть все 7 типов зон (центр/половина/углы/полосы/прямоуг.)', (() => { const need = ['center', 'half', 'cornerTL', 'cornerBR', 'vstrip', 'hstrip', 'rect']; const have = new Set(genLevels.map(l => l.zoneType)); return need.every(k => have.has(k)); })(), JSON.stringify(genLevels.reduce((acc, l) => { acc[l.zoneType] = (acc[l.zoneType] || 0) + 1; return acc; }, {}))); // В наборе должны присутствовать все разрешённые фигуры (улей/мигалка/жаба/ // каравай/пульсар/ружьё). Ищем каждую фигуру как подмножество клеток врага на // любом уровне (форма берётся из PATTERNS; для каравая задана явно — так надёжнее // при переносе формы в генератор). // NB: проверка именно «фигура где-то есть», а не «есть отдельная фигура»: // пульсар/ружьё сами состоят из нескольких несвязных «полос», поэтому проверка // через связную компоненту неприменима (ружьё/пульсар ею не находятся). const shapePresence = await page.evaluate(() => { function cellsOf(id) { const p = findPattern(id); if (!p) return null; return patternCells(p).map(o => [o.x, o.y]); } const shapes = { beehive: cellsOf('beehive'), blinker: cellsOf('blinker'), toad: cellsOf('toad'), pulsar: cellsOf('pulsar'), gun: cellsOf('gun'), loaf: [[1, 0], [2, 0], [0, 1], [3, 1], [1, 2], [3, 2], [2, 3]], }; function presentIn(lv, cells) { const set = new Set(lv.cells.map(([x, y]) => x + ',' + y)); let maxx = 0, maxy = 0; for (const [x, y] of cells) { if (x > maxx) maxx = x; if (y > maxy) maxy = y; } for (let oy = 0; oy + maxy < lv.h; oy++) { for (let ox = 0; ox + maxx < lv.w; ox++) { let all = true; for (const [x, y] of cells) { if (!set.has((ox + x) + ',' + (oy + y))) { all = false; break; } } if (all) return true; } } return false; } const res = {}; for (const k of Object.keys(shapes)) { res[k] = !!shapes[k] && CAMPAIGN_LEVELS.some(lv => presentIn(lv, shapes[k])); } return res; }); ok('кампания: в наборе есть все фигуры (улей/мигалка/жаба/каравай/пульсар/ружьё)', Object.keys(shapePresence).every(k => shapePresence[k] === true), JSON.stringify(shapePresence)); // --- 37a2. Генератор уровней (Node, вне браузера): детерминизм, --check, объём // и сверка вывода с ВШИТЫМ блоком сгенерированных уровней (id 5..100) в // CAMPAIGN_LEVELS. Защита от дрейфа: поменяли SEED/расписание, но забыли // перевставить вывод — тест это поймает. Сверка по данным (карта id → запись), // т.к. порядок в массиве отличается от вывода генератора: «Издалека» вставлен // на 10-е место, а id5..id10 сдвинуты. --- { const genPath = path.resolve(__dirname, '..', 'tools', 'gen-campaign-levels.mjs'); const gen1 = execFileSync('node', [genPath], { encoding: 'utf8' }); const gen2 = execFileSync('node', [genPath], { encoding: 'utf8' }); ok('генератор уровней: два запуска дают идентичный вывод (детерминизм)', gen1.length > 0 && gen1 === gen2, 'bytes=' + gen1.length); const check = execFileSync('node', [genPath, '--check'], { encoding: 'utf8' }).trim(); ok('генератор уровней: --check сообщает OK — 96 уровней', /OK\s+—\s+96 уровней/.test(check), check); // Парсим вывод генератора в карту id → запись (данные, не байты). const parseGen = (txt) => { const re = /\{ id: (\d+), name: '([^']*)', w: (\d+), h: (\d+), wrap: false, budget: (\d+),\s*zone: \{ x: (-?\d+), y: (-?\d+), w: (-?\d+), h: (-?\d+) \},\s*cells: \[([\s\S]*?)\] \}/g; const map = {}; let m; while ((m = re.exec(txt))) { const cells = (m[10].match(/\[(-?\d+),(-?\d+)\]/g) || []).map(s => { const p = s.slice(1, -1).split(','); return p[0] + ',' + p[1]; }); cells.sort(); map[Number(m[1])] = { id: Number(m[1]), name: m[2], w: Number(m[3]), h: Number(m[4]), budget: Number(m[5]), zone: [Number(m[6]), Number(m[7]), Number(m[8]), Number(m[9])], cells, }; } return map; }; const genMap = parseGen(gen1); const genIds = Object.keys(genMap).map(Number).sort((a, b) => a - b); ok('генератор уровней: вывод парсится в 96 записей (id 5..100)', genIds.length === 96 && genIds[0] === 5 && genIds[95] === 100, 'n=' + genIds.length + ' [' + genIds[0] + '..' + genIds[genIds.length - 1] + ']'); // Вшитый блок: сгенерированные уровни CAMPAIGN_LEVELS (id 5..100), по карте id // (ручные showcase id 101..103 в вывод генератора не входят — исключаем). const pageMap = await page.evaluate(() => { const map = {}; for (const lv of CAMPAIGN_LEVELS) { if (lv.id < 5 || lv.id > 100) continue; const cells = lv.cells.map(([x, y]) => x + ',' + y); cells.sort(); map[lv.id] = { id: lv.id, name: lv.name, w: lv.w, h: lv.h, budget: lv.budget, zone: [lv.zone.x, lv.zone.y, lv.zone.w, lv.zone.h], cells, }; } return map; }); ok('генератор уровней: вывод совпадает с вшитым блоком id 5..100 (нет дрейфа)', (() => { const ids = Object.keys(genMap); if (ids.length !== Object.keys(pageMap).length) return false; for (const k of ids) { if (JSON.stringify(genMap[k]) !== JSON.stringify(pageMap[k])) return false; } return true; })(), (() => { for (const k of Object.keys(genMap)) { if (JSON.stringify(genMap[k]) !== JSON.stringify(pageMap[k])) { const g = genMap[k], p = pageMap[k]; return 'id ' + k + ' gen=' + JSON.stringify([g.w, g.budget, g.zone, g.cells.length]) + ' page=' + JSON.stringify(p && [p.w, p.budget, p.zone, p.cells.length]); } } return 'len gen=' + Object.keys(genMap).length + ' page=' + Object.keys(pageMap).length; })()); } // --- 37b. startLevel: старт уровня --- await resetGlobals(page); const stLevel = await page.evaluate(() => { startLevel(0); return { state, turn, campaign, levelIndex, mode, w: fieldW, h: fieldH, zone: zoneRect ? { x: zoneRect.x, y: zoneRect.y, w: zoneRect.w, h: zoneRect.h } : null, total: budget.total, used1: budget.used1, used2: budget.used2, enemy: countByPlayer(field).p2, p1: countByPlayer(field).p1, }; }); ok('startLevel: state=PLACE_P1, turn=1, campaign=true, levelIndex=0', stLevel.state === 'PLACE_P1' && stLevel.turn === 1 && stLevel.campaign === true && stLevel.levelIndex === 0 && stLevel.mode === 'place', JSON.stringify(stLevel)); ok('startLevel: поле уровня 30×30 и зона-прямоугольник (всё поле)', stLevel.w === 30 && stLevel.h === 30 && stLevel.zone && stLevel.zone.w === 30 && stLevel.zone.h === 30, JSON.stringify(stLevel.zone)); ok('startLevel: враг расставлен (4 клетки), игрок пуст (0)', stLevel.enemy === 4 && stLevel.p1 === 0, JSON.stringify(stLevel)); ok('startLevel: бюджет уровня (20), used=0', stLevel.total === 20 && stLevel.used1 === 0 && stLevel.used2 === 0, JSON.stringify(stLevel)); // --- 37c. Видимость врага: маска [1,2] и пиксельно --- const campVis = await page.evaluate(() => { startLevel(0); draw(); const img = ctx.getImageData(0, 0, canvas.width, canvas.height).data; let green = 0; for (let i = 0; i < img.length; i += 4) { if (img[i] === 46 && img[i + 1] === 204 && img[i + 2] === 113) green++; } return { mask: visibleMask().slice(), green }; }); ok('кампания: visibleMask=[1,2] (враг виден всегда)', JSON.stringify(campVis.mask) === '[1,2]', JSON.stringify(campVis.mask)); ok('кампания: враг отрисован (есть зелёные пиксели)', campVis.green > 0, 'green=' + campVis.green); // --- 37d. inZoneAt: делегирование вне кампании + прямоугольник в кампании --- const izAt = await page.evaluate(() => { campaign = false; zoneRect = null; levelIndex = -1; forceState('MENU'); setFieldSize(60); const out = { deleg1: inZoneAt(10, 999, 1) === inZone(10, 1), deleg2: inZoneAt(40, 0, 1) === inZone(40, 1), yIgnored: inZoneAt(10, 0, 1) === inZoneAt(10, 55, 1), }; // Регресс zoneRect=null: вне кампании — та же половина, что и inZone. startLevel(1); // L2 «Каравай»: zone {5,5,15,15} out.inside = [inZoneAt(5, 5, 1), inZoneAt(19, 19, 1)]; out.outside = [inZoneAt(4, 5, 1), inZoneAt(20, 5, 1), inZoneAt(5, 20, 1), inZoneAt(5, 4, 1)]; forceState('MENU'); return out; }); ok('inZoneAt вне кампании делегирует inZone (регресс)', izAt.deleg1 === true && izAt.deleg2 === true && izAt.yIgnored === true, JSON.stringify(izAt)); ok('inZoneAt в кампании — прямоугольник zoneRect (границы точны)', izAt.inside[0] === true && izAt.inside[1] === true && izAt.outside.every(v => v === false), JSON.stringify(izAt)); // --- 37e. Запрет трогать врага: краска/штамп/ластик не меняют клетки врага --- await resetGlobals(page); const enemyLock = await page.evaluate(() => { startLevel(0); // блок врага в (10,10)-(11,11), зона — всё поле selectTool('pencil'); const u0 = budget.used1; const changedPaint = paintTool(10, 10); const paintRes = { changed: changedPaint, used: budget.used1, v: field[10 * fieldW + 10] }; stamp = findPattern('block'); stampRot = 0; const changedStamp = paintTool(10, 10); const stampRes = { changed: changedStamp, used: budget.used1, intact: field[10 * fieldW + 10] === 2 && field[11 * fieldW + 10] === 2 && field[10 * fieldW + 11] === 2 && field[11 * fieldW + 11] === 2, }; stamp = null; selectTool('erase1'); const changedErase = paintTool(10, 10); const eraseRes = { changed: changedErase, used: budget.used1, intact: field[10 * fieldW + 10] === 2 }; // Своя клетка вне врага — ставится и тратит бюджет. selectTool('pencil'); const changedOwn = paintTool(5, 5); const ownRes = { changed: changedOwn, used: budget.used1, v: field[5 * fieldW + 5] }; forceState('MENU'); return { u0, paintRes, stampRes, eraseRes, ownRes }; }); ok('кампания: краска НЕ перезаписывает врага (changed=0, бюджет не тронут, цвет 2)', enemyLock.paintRes.changed === 0 && enemyLock.paintRes.used === enemyLock.u0 && enemyLock.paintRes.v === 2, JSON.stringify(enemyLock.paintRes)); ok('кампания: штамп не перезаписывает врага (клетки врага целы, changed=0)', enemyLock.stampRes.changed === 0 && enemyLock.stampRes.intact === true, JSON.stringify(enemyLock.stampRes)); ok('кампания: ластик не трогает врага и не возвращает бюджет', enemyLock.eraseRes.changed === 0 && enemyLock.eraseRes.used === enemyLock.u0 && enemyLock.eraseRes.intact === true, JSON.stringify(enemyLock.eraseRes)); ok('кампания: своя клетка ставится и тратит бюджет (used=1)', enemyLock.ownRes.changed === 1 && enemyLock.ownRes.used === 1 && enemyLock.ownRes.v === 1, JSON.stringify(enemyLock.ownRes)); // --- 37f. Победа: 0 клеток врага → win/winner=1, разблокировка следующего --- await resetGlobals(page); const campWin = await page.evaluate(() => { campaignProgress = { unlocked: 1 }; startLevel(0); for (let i = 0; i < field.length; i++) if (field[i] === 2) field[i] = 0; forceState('RUNNING'); checkEnd(); return { state, reason, winner, unlocked: campaignProgress.unlocked }; }); ok('кампания: 0 клеток врага → GAME_OVER/win/winner=1', campWin.state === 'GAME_OVER' && campWin.reason === 'win' && campWin.winner === '1', JSON.stringify(campWin)); ok('кампания: победа разблокирует следующий уровень (unlocked=2)', campWin.unlocked === 2, 'unlocked=' + campWin.unlocked); // Вымирание игрока финалом НЕ является (пока враг жив). const noExtinct = await page.evaluate(() => { startLevel(0); // у игрока 0 клеток, враг жив, бюджет есть forceState('RUNNING'); checkEnd(); return { state, reason }; }); ok('кампания: вымирание игрока не финал (враг жив → партия продолжается)', noExtinct.state === 'RUNNING' && noExtinct.reason === null, JSON.stringify(noExtinct)); // --- 37g. Поражение: бюджет исчерпан + враг жив на авто-паузе --- const campDefeat = await page.evaluate(() => { startLevel(0); budget.total = 5; budget.used1 = 5; // remaining(1) = 0 forceState('PAUSED'); // тест-форс: fieldStable = true const r = checkStalemate(); return { r, state, reason, winner, enemy: countByPlayer(field).p2 }; }); ok('кампания: remaining(1)<=0 + враг жив на авто-паузе → GAME_OVER/defeat/winner=2', campDefeat.r === true && campDefeat.state === 'GAME_OVER' && campDefeat.reason === 'defeat' && campDefeat.winner === '2', JSON.stringify(campDefeat)); // Негативные случаи поражения. const noDefeat = await page.evaluate(() => { const out = {}; startLevel(0); budget.total = 20; budget.used1 = 0; // остаток есть forceState('PAUSED'); out.withBudget = { r: checkStalemate(), state, reason }; startLevel(0); for (let i = 0; i < field.length; i++) if (field[i] === 2) field[i] = 0; // врага нет budget.total = 5; budget.used1 = 5; forceState('PAUSED'); out.noEnemy = { r: checkStalemate(), state, reason }; startLevel(0); budget.total = 5; budget.used1 = 5; forceState('RUNNING'); out.notPaused = { r: checkStalemate(), state, reason }; forceState('MENU'); return out; }); ok('кампания: поражение не срабатывает при остатке бюджета', noDefeat.withBudget.r === false && noDefeat.withBudget.state === 'PAUSED' && noDefeat.withBudget.reason === null, JSON.stringify(noDefeat.withBudget)); ok('кампания: поражение не срабатывает без врага', noDefeat.noEnemy.r === false && noDefeat.noEnemy.state === 'PAUSED' && noDefeat.noEnemy.reason === null, JSON.stringify(noDefeat.noEnemy)); ok('кампания: поражение фиксируется только на паузе', noDefeat.notPaused.r === false && noDefeat.notPaused.state === 'RUNNING', JSON.stringify(noDefeat.notPaused)); // Интеграция: стабильное поле (враг-блок) + исчерпанный бюджет → авто-пауза → поражение. await page.evaluate(() => { startLevel(0); budget.total = 5; budget.used1 = 5; forceState('RUNNING'); }); await waitCond(page, () => state === 'GAME_OVER', 3000); { const v = await page.evaluate(() => ({ state, reason, winner })); ok('кампания: авто-пауза стабильного поля без бюджета → поражение (через tick)', v.state === 'GAME_OVER' && v.reason === 'defeat' && v.winner === '2', JSON.stringify(v)); } // --- 37g2. Кампания: авто-пауза сразу открывает дорисовку (без клика «Дорисовать») --- await resetGlobals(page); await page.evaluate(() => { startLevel(0); forceState('RUNNING'); }); // «Разминка»: враг-блок 2×2 await waitCond(page, () => state === 'EDIT_P1', 3000); { const v = await page.evaluate(() => ({ state, turn, stable: fieldStable, campaign, edit1: !document.getElementById('btnEditP1').classList.contains('hidden'), })); ok('кампания: авто-пауза стабильного поля → сразу EDIT_P1 (без клика «Дорисовать»)', v.state === 'EDIT_P1' && v.turn === 1 && v.stable === true && v.campaign === true, JSON.stringify(v)); } // «Готов» из авто-дорисовки сразу запускает симуляцию (без отдельного «Старт»). { const afterReady = await page.evaluate(() => { paintTool(5, 5); // своя клетка + расход бюджета commitTurn(); return state; // сразу после «Готов» — RUNNING }); ok('кампания: «Готов» из авто-дорисовки сразу запускает симуляцию', afterReady === 'RUNNING', afterReady); } // --- 37h. Прогресс/разблокировка: закрытый уровень не выбирается --- await resetGlobals(page); const lock = await page.evaluate(() => { campaignProgress = { unlocked: 1 }; showLevelSelect(); requestCampaignLevel(1); // закрыт (unlocked=1) const locked = { confirm: confirmAction, pending: pendingLevel }; requestCampaignLevel(0); // открыт const open = { confirm: confirmAction, pending: pendingLevel }; confirmProceed(); // подтверждение → старт уровня const started = { state, campaign, levelIndex }; forceState('MENU'); return { locked, open, started }; }); ok('кампания: закрытый уровень не выбирается (нет диалога/уровня)', lock.locked.confirm === null && lock.locked.pending === null, JSON.stringify(lock.locked)); ok('кампания: открытый уровень открывает подтверждение', lock.open.confirm === 'campaign' && lock.open.pending === 0, JSON.stringify(lock.open)); ok('кампания: подтверждение запускает уровень (PLACE_P1)', lock.started.state === 'PLACE_P1' && lock.started.campaign === true && lock.started.levelIndex === 0, JSON.stringify(lock.started)); // --- 37i. Сейв/резюм уровня через reload --- await resetGlobals(page); const campBefore = await page.evaluate(() => { localStorage.clear(); startLevel(0); selectTool('pencil'); paintTool(5, 5); // своя клетка + расход бюджета persist(); return { hash: hashField(), used1: budget.used1, enemy: countByPlayer(field).p2, w: fieldW, h: fieldH }; }); await reload(page); { const r = await page.evaluate(() => ({ state, campaign, levelIndex, mode, zone: zoneRect ? { w: zoneRect.w, h: zoneRect.h } : null, hash: hashField(), used1: budget.used1, enemy: countByPlayer(field).p2, w: fieldW, h: fieldH, })); ok('кампания: резюм уровня после reload (state/campaign/levelIndex/зона)', r.state === 'PLACE_P1' && r.campaign === true && r.levelIndex === 0 && r.zone && r.zone.w === 30 && r.w === 30 && r.h === 30, JSON.stringify(r)); ok('кампания: поле уровня (враг+свои) и бюджет восстановлены', r.hash === campBefore.hash && r.enemy === campBefore.enemy && r.used1 === campBefore.used1, JSON.stringify({ before: campBefore, after: r })); } // --- 37j. suspended кампании: выход в меню и возврат (в т.ч. через reload) --- await resetGlobals(page); const suspCamp = await page.evaluate(() => { localStorage.clear(); startLevel(0); selectTool('pencil'); paintTool(5, 5); const hash = hashField(); toMenu(); return { hash, susp: suspended ? { campaign: suspended.campaign, levelIndex: suspended.levelIndex } : null, contVis: !document.getElementById('btnContinue').classList.contains('hidden'), }; }); ok('кампания: toMenu откладывает уровень (suspended.campaign/levelIndex, кнопка видна)', suspCamp.susp && suspCamp.susp.campaign === true && suspCamp.susp.levelIndex === 0 && suspCamp.contVis === true, JSON.stringify(suspCamp)); await page.click('#btnContinue'); { const r = await page.evaluate(() => ({ state, campaign, levelIndex, zoneSet: !!zoneRect, hash: hashField() })); ok('кампания: resumeFromMenu возвращает уровень (state/режим/зона/поле)', r.state === 'PLACE_P1' && r.campaign === true && r.levelIndex === 0 && r.zoneSet === true && r.hash === suspCamp.hash, JSON.stringify(r)); } // Отложенный уровень переживает reload. await page.evaluate(() => { toMenu(); }); await reload(page); { const r = await page.evaluate(() => ({ state, contVis: !document.getElementById('btnContinue').classList.contains('hidden'), susp: suspended ? { campaign: suspended.campaign, levelIndex: suspended.levelIndex } : null, })); ok('кампания: отложенный уровень переживает reload (suspended)', r.state === 'MENU' && r.contVis === true && r.susp && r.susp.campaign === true && r.susp.levelIndex === 0, JSON.stringify(r)); } await page.click('#btnContinue'); await page.evaluate(() => { localStorage.clear(); forceState('MENU'); }); // --- 37k. HUD: «Уровень N/T», скрытый счёт, остаток бюджета --- await resetGlobals(page); const campHud = await page.evaluate(() => { startLevel(0); budget.total = 20; budget.used1 = 3; syncDom(); const lab = document.getElementById('budgetLabel'); const scores = document.getElementById('scores'); const place = { label: document.getElementById('turnLabel').textContent, budgetHidden: lab.classList.contains('hidden'), budgetText: lab.textContent, scoresHidden: scores.classList.contains('hidden'), }; forceState('PAUSED'); fieldStable = true; syncDom(); const paused = { budgetHidden: lab.classList.contains('hidden'), label: document.getElementById('turnLabel').textContent }; forceState('RUNNING'); syncDom(); const run = { budgetHidden: lab.classList.contains('hidden') }; forceState('MENU'); return { place, paused, run }; }); ok('кампания: HUD — «Уровень 1/103», счёт скрыт, бюджет виден', /Уровень 1\/103/.test(campHud.place.label) && campHud.place.scoresHidden === true && campHud.place.budgetHidden === false, JSON.stringify(campHud.place)); ok('кампания: бюджет в HUD показывает остаток (17 / 20)', /17 \/ 20/.test(campHud.place.budgetText), campHud.place.budgetText); ok('кампания: бюджет виден на паузе и в RUNNING', campHud.paused.budgetHidden === false && campHud.run.budgetHidden === false, JSON.stringify({ paused: campHud.paused, run: campHud.run })); // --- 37l. Проводка UI: #btnCampaign/#btnLevelsBack/#btnNextLevel/#btnRevenge --- await resetGlobals(page); await page.click('#btnCampaign'); { const v = await page.evaluate(() => { const list = document.getElementById('levelList'); const back = document.getElementById('btnLevelsBack'); return { state, levelsVis: !document.getElementById('levels').classList.contains('hidden'), listN: document.querySelectorAll('#levelList button').length, locked: document.querySelectorAll('#levelList button:disabled').length, total: CAMPAIGN_LEVELS.length, label: document.getElementById('turnLabel').textContent, scrollable: list.scrollHeight > list.clientHeight, scrollH: list.scrollHeight, clientH: list.clientHeight, backVisible: !!back && back.offsetParent !== null, }; }); ok('клик #btnCampaign → LEVEL_SELECT, экран уровней виден', v.state === 'LEVEL_SELECT' && v.levelsVis === true && v.label === 'Кампания', JSON.stringify(v)); ok('кампания: список из CAMPAIGN_LEVELS.length уровней, все кроме 1 закрыты (unlocked=1)', v.listN === v.total && v.locked === v.total - 1, JSON.stringify(v)); ok('кампания: список уровней прокручивается (scrollHeight > clientHeight), #btnLevelsBack виден', v.scrollable === true && v.backVisible === true, 'scrollH=' + v.scrollH + ' clientH=' + v.clientH + ' back=' + v.backVisible); } await page.click('#btnLevelsBack'); ok('клик #btnLevelsBack → MENU', (await page.evaluate(() => state)) === 'MENU'); await page.click('#btnCampaign'); await page.click('#levelList button:not(:disabled)'); { const v = await page.evaluate(() => ({ confirm: confirmAction, vis: !document.getElementById('confirm').classList.contains('hidden'), head: document.getElementById('confirmHead').textContent, })); ok('клик по открытому уровню → диалог подтверждения «Начать уровень?»', v.confirm === 'campaign' && v.vis === true && /уровень/i.test(v.head), JSON.stringify(v)); } await page.click('#btnConfirmOk'); { const v = await page.evaluate(() => ({ state, campaign, levelIndex })); ok('подтверждение → старт уровня 1 (PLACE_P1)', v.state === 'PLACE_P1' && v.campaign === true && v.levelIndex === 0, JSON.stringify(v)); } // Победа на уровне 1 → видны «Следующий уровень»/«К уровням», «Заново». await page.evaluate(() => { for (let i = 0; i < field.length; i++) if (field[i] === 2) field[i] = 0; forceState('RUNNING'); checkEnd(); }); { const v = await page.evaluate(() => ({ state, reason, campaign, nextVis: !document.getElementById('btnNextLevel').classList.contains('hidden'), levelsVis: !document.getElementById('btnGameoverLevels').classList.contains('hidden'), revenge: document.getElementById('btnRevenge').textContent, })); ok('кампания: победа → GAME_OVER + «Следующий уровень»/«К уровням»/«Заново»', v.state === 'GAME_OVER' && v.reason === 'win' && v.campaign === true && v.nextVis === true && v.levelsVis === true && v.revenge === 'Заново', JSON.stringify(v)); } await page.click('#btnNextLevel'); { const v = await page.evaluate(() => ({ state, levelIndex, campaign })); ok('клик #btnNextLevel → следующий уровень (index=1)', v.state === 'PLACE_P1' && v.levelIndex === 1 && v.campaign === true, JSON.stringify(v)); } await page.evaluate(() => { for (let i = 0; i < field.length; i++) if (field[i] === 2) field[i] = 0; forceState('RUNNING'); checkEnd(); }); await page.click('#btnRevenge'); ok('клик #btnRevenge в кампании → перезапуск текущего уровня', (await page.evaluate(() => state)) === 'PLACE_P1' && (await page.evaluate(() => levelIndex)) === 1); // Кнопка «Заново» в панели действий — перезапуск текущего уровня в любой момент. { const before = await page.evaluate(() => { paintTool(5, 5); // своя клетка + расход бюджета syncDom(); return { restartVis: !document.getElementById('btnRestart').classList.contains('hidden'), used: budget.used1, }; }); ok('кампания: кнопка «Заново» видна во время уровня', before.restartVis === true && before.used > 0, JSON.stringify(before)); // «Заново» требует подтверждения: сначала диалог, поле не меняется. await page.click('#btnRestart'); { const mid = await page.evaluate(() => ({ confirm: confirmAction, vis: !document.getElementById('confirm').classList.contains('hidden'), head: document.getElementById('confirmHead').textContent, used: budget.used1, })); ok('клик #btnRestart → диалог подтверждения (партия ещё не сброшена)', mid.confirm === 'restart' && mid.vis === true && /заново/i.test(mid.head) && mid.used > 0, JSON.stringify(mid)); } // «Отмена» не перезапускает уровень. await page.click('#btnConfirmCancel'); ok('отмена диалога «Заново» → уровень не сброшен (бюджет сохранён)', (await page.evaluate(() => confirmAction)) === null && (await page.evaluate(() => budget.used1)) > 0); // Повторный клик + подтверждение сбрасывают уровень. await page.click('#btnRestart'); await page.click('#btnConfirmOk'); const after = await page.evaluate(() => ({ state, levelIndex, used: budget.used1, enemy: countByPlayer(field).p2, campaign, })); ok('подтверждение «Заново» → уровень перезапущен (PLACE_P1, бюджет/враг восстановлены)', after.state === 'PLACE_P1' && after.levelIndex === 1 && after.used === 0 && after.enemy > 0 && after.campaign === true, JSON.stringify(after)); } // Вне кампании кнопка «Заново» скрыта (она привязана только к уровню кампании). await resetGlobals(page); await startTwoPlayer(page); ok('вне кампании кнопка «Заново» скрыта', (await page.evaluate(() => document.getElementById('btnRestart').classList.contains('hidden'))) === true); await resetGlobals(page); await page.evaluate(() => { startLevel(1); }); await page.evaluate(() => { for (let i = 0; i < field.length; i++) if (field[i] === 2) field[i] = 0; forceState('RUNNING'); checkEnd(); }); await page.click('#btnGameoverLevels'); ok('клик #btnGameoverLevels → LEVEL_SELECT', (await page.evaluate(() => state)) === 'LEVEL_SELECT'); // --- 37n. Новые стартовые экраны: сплэш (краткое описание + тема + режимы) и // экран «Игра на двоих» (TWO_SETUP) со своей справкой и настройками поля. --- await resetGlobals(page); { const v = await page.evaluate(() => { forceState('MENU'); const menu = document.getElementById('menu'); const setup = document.getElementById('setup'); return { menuVis: !menu.classList.contains('hidden'), setupVis: !setup.classList.contains('hidden'), optSizeInSetup: setup.contains(document.getElementById('optSize')), themeInMenu: menu.contains(document.getElementById('optTheme')), themeVis: document.getElementById('optTheme').offsetParent !== null, playText: document.getElementById('btnPlay').textContent, }; }); ok('сплэш: #menu виден, #setup скрыт, настройки поля живут в #setup', v.menuVis === true && v.setupVis === false && v.optSizeInSetup === true, JSON.stringify(v)); ok('сплэш: тема осталась в #menu (видна), кнопка режима — «Игра на двоих»', v.themeInMenu === true && v.themeVis === true && /Игра на двоих/.test(v.playText), JSON.stringify(v)); } await page.click('#btnPlay'); { const v = await page.evaluate(() => ({ state, menuVis: !document.getElementById('menu').classList.contains('hidden'), setupVis: !document.getElementById('setup').classList.contains('hidden'), aboutTwo: document.getElementById('aboutTwo') ? document.getElementById('aboutTwo').textContent : '', })); ok('клик #btnPlay → TWO_SETUP, #setup виден, #menu скрыт', v.state === 'TWO_SETUP' && v.setupVis === true && v.menuVis === false, JSON.stringify(v)); ok('экран «Игра на двоих»: есть сворачиваемая справка #aboutTwo', v.aboutTwo.length > 0, 'len=' + v.aboutTwo.length); } await page.click('#btnTwoBack'); ok('клик #btnTwoBack → MENU', (await page.evaluate(() => state)) === 'MENU'); await page.click('#btnCampaign'); { const v = await page.evaluate(() => { const c = document.getElementById('aboutCampaign'); return { state, has: !!c, text: c ? c.textContent : '' }; }); ok('кампания: на экране уровней есть сворачиваемая справка #aboutCampaign', v.state === 'LEVEL_SELECT' && v.has === true && v.text.length > 0, 'len=' + v.text.length); } await page.click('#btnLevelsBack'); // TWO_SETUP не резюмируется: reload → MENU (как LEVEL_SELECT). await page.evaluate(() => { localStorage.clear(); forceState('TWO_SETUP'); persist(); }); await reload(page); ok('reload в TWO_SETUP → MENU (состояние не резюмируется)', (await page.evaluate(() => state)) === 'MENU'); await page.evaluate(() => { localStorage.clear(); forceState('MENU'); }); // --- 37m. Нормализация сейва: reason win/defeat и progress; reload в LEVEL_SELECT --- await resetGlobals(page); { const norm = await page.evaluate(() => { const mk = (over) => { const b = freshSave(); Object.assign(b, over); localStorage.setItem(SAVE_KEY, JSON.stringify(b)); return loadSave(); }; const w = mk({ state: 'GAME_OVER', reason: 'win', winner: '1' }); const d = mk({ state: 'GAME_OVER', reason: 'defeat', winner: '2' }); const u99 = mk({ progress: { unlocked: 999 } }); const u0 = mk({ progress: { unlocked: -4 } }); const uBad = mk({ progress: 'nonsense' }); const camp = mk({ state: 'PLACE_P1', campaign: true, levelIndex: 1, fieldW: 4, fieldH: 4, field: bytesToB64(new Uint8Array(16)) }); return { wr: w.reason, dr: d.reason, u99: u99.progress.unlocked, u0: u0.progress.unlocked, uBad: uBad.progress.unlocked, camp: camp.campaign, li: camp.levelIndex, n: CAMPAIGN_LEVELS.length, }; }); ok('сейв: reason win/defeat нормализуются (не сбрасываются в null)', norm.wr === 'win' && norm.dr === 'defeat', JSON.stringify(norm)); ok('сейв: progress.unlocked клампится в [1, N]', norm.u99 === norm.n && norm.u0 === 1 && norm.uBad === 1, JSON.stringify(norm)); ok('сейв: campaign/levelIndex нормализуются', norm.camp === true && norm.li === 1, JSON.stringify(norm)); } // LEVEL_SELECT не резюмируется: при reload → MENU. await page.evaluate(() => { const b = freshSave(); b.state = 'LEVEL_SELECT'; localStorage.setItem(SAVE_KEY, JSON.stringify(b)); }); await reload(page); ok('reload в LEVEL_SELECT → MENU (состояние не резюмируется)', (await page.evaluate(() => state)) === 'MENU'); await page.evaluate(() => { localStorage.clear(); forceState('MENU'); }); await resetGlobals(page); // ========================================================================= // 38. Дефекты независимого ревью: D1 (утечка настроек кампании в обычную // партию), D2 (suspended не хранил budget.total), D3 (ручное завершение в // кампании), D4 (смена размера в кампании), D5 (applyIntent по врагу), плюс // честный tick-прогон победы. // ========================================================================= // --- 38a. D1: настройки пользователя переживают кампанию (UI-путь) --- await resetGlobals(page); { const base = await page.evaluate(() => ({ w: fieldW, h: fieldH, total: budget.total })); ok('D1: стартовые настройки — 60×60 / бюджет 100', base.w === 60 && base.h === 60 && base.total === 100, JSON.stringify(base)); } await page.click('#btnCampaign'); await page.click('#levelList button:not(:disabled)'); // уровень 1 открыт await page.click('#btnConfirmOk'); // startLevel(0): 30×30 / бюджет 20 { const inLvl = await page.evaluate(() => ({ w: fieldW, h: fieldH, total: budget.total, li: levelIndex, mw: menuFieldW, mtotal: menuBudget, size: document.getElementById('optSize').value, })); ok('D1: кампания применяет размер/бюджет уровня к активной игре, не трогая настройки меню', inLvl.w === 30 && inLvl.total === 20 && inLvl.li === 0 && inLvl.mw === 60 && inLvl.mtotal === 100, JSON.stringify(inLvl)); ok('D1: во время уровня #optSize показывает размер МЕНЮ (60x60), а не уровня', inLvl.size === '60x60', inLvl.size); } // Победа на уровне. await page.evaluate(() => { for (let i = 0; i < field.length; i++) if (field[i] === 2) field[i] = 0; forceState('RUNNING'); checkEnd(); }); ok('D1: победа уровня — активная игра ещё уровня (30×30)', (await page.evaluate(() => state === 'GAME_OVER' && reason === 'win' && fieldW === 30)) === true); // «В меню» из финала. await page.click('#btnGameoverMenu'); { const m = await page.evaluate(() => ({ state, w: fieldW, h: fieldH, total: budget.total, mw: menuFieldW, size: document.getElementById('optSize').value, })); ok('D1: выход из кампании восстанавливает настройки пользователя (60×60 / 100)', m.state === 'MENU' && m.w === 60 && m.h === 60 && m.total === 100 && m.mw === 60, JSON.stringify(m)); ok('D1: после кампании #optSize показывает 60x60', m.size === '60x60', m.size); } // «Игра на двоих» → обычная партия с прежними настройками пользователя. await startTwoPlayer(page); { const g = await page.evaluate(() => ({ state, w: fieldW, h: fieldH, total: budget.total, len: field.length, campaign, })); ok('D1: обычная партия после кампании стартует с 60×60 / бюджет 100 (не уровня)', g.state === 'PLACE_P1' && g.w === 60 && g.h === 60 && g.total === 100 && g.len === 3600 && g.campaign === false, JSON.stringify(g)); } // D1: настройки меню (размер/wrap/бюджет), изменённые в меню при «спящем» suspended, // не портят отложенную игру и активную партию. await resetGlobals(page); { // Откладываем обычную партию 40×40 / бюджет 7. await page.evaluate(() => { setFieldSize(40); setBudgetTotal(7); newGame(); }); await page.evaluate(() => { applyBrush(field, 3, 3, TOOLS[0], 1); toMenu(); }); const suspBefore = await page.evaluate(() => ({ fw: suspended.fieldW, total: suspended.budget.total, hash: hashField(suspended.field), })); // Меняем настройки меню — отложенная партия не должна измениться. await page.click('#btnPlay'); // открыть экран «Игра на двоих» с настройками await page.select('#optSize', '100x100'); await page.$eval('#optBudget', inp => { inp.value = '500'; inp.dispatchEvent(new Event('change', { bubbles: true })); }); { const suspAfter = await page.evaluate(() => ({ fw: suspended.fieldW, total: suspended.budget.total, hash: hashField(suspended.field), mw: menuFieldW, mtotal: menuBudget, activeW: fieldW, })); ok('D1: смена настроек в меню не портит отложенную партию', suspAfter.fw === suspBefore.fw && suspAfter.total === suspBefore.total && suspAfter.hash === suspBefore.hash, JSON.stringify({ suspBefore, suspAfter })); ok('D1: после смены настроек в меню — пользовательские 100×100 / 500, отложенная 40×40 / 7', suspAfter.mw === 100 && suspAfter.mtotal === 500 && suspAfter.fw === 40, JSON.stringify(suspAfter)); } } // --- 38b. D2: suspended хранит budget.total (и кампания, и hot-seat) --- await resetGlobals(page); { const camp = await page.evaluate(() => { startLevel(0); // бюджет уровня 20 const totalBefore = budget.total; toMenu(); setBudgetTotal(500); // пользователь меняет лимит в меню return { totalBefore, suspTotal: suspended.budget.total, menu: menuBudget }; }); const resumed = await page.evaluate(() => { resumeFromMenu(); return { total: budget.total, menu: menuBudget }; }); ok('D2: отложенный УРОВЕНЬ хранит свой budget.total (20), настройка меню — 500', camp.totalBefore === 20 && camp.suspTotal === 20 && camp.menu === 500 && resumed.total === 20 && resumed.menu === 500, JSON.stringify({ camp, resumed })); } await resetGlobals(page); { const hs = await page.evaluate(() => { setFieldSize(40); setBudgetTotal(7); newGame(); toMenu(); setBudgetTotal(50); // смена настройки в меню const suspTotal = suspended.budget.total; resumeFromMenu(); return { suspTotal, total: budget.total, menu: menuBudget }; }); ok('D2: отложенная hot-seat партия возвращается со своим budget.total (7)', hs.suspTotal === 7 && hs.total === 7 && hs.menu === 50, JSON.stringify(hs)); } // D2: budget.total отложенной игры переживает reload (persist пишет именно его). { await page.evaluate(() => { localStorage.clear(); setFieldSize(40); setBudgetTotal(7); newGame(); toMenu(); setBudgetTotal(50); persist(); }); await reload(page); { const r = await page.evaluate(() => ({ suspTotal: suspended && suspended.budget.total, menuTotal: menuBudget, activeTotal: budget.total, })); ok('D2: budget.total отложенной игры (7) и настройка меню (50) переживают reload', r.suspTotal === 7 && r.menuTotal === 50 && r.activeTotal === 50, JSON.stringify(r)); } } // --- 38c. D3: ручное завершение уровня — явный текст, не «Поражение» --- await resetGlobals(page); { const d3 = await page.evaluate(() => { startLevel(0); forceState('RUNNING'); endGame(); return { state, reason, text: document.getElementById('gameoverText').textContent, sub: document.getElementById('gameoverSub').textContent, }; }); ok('D3: ручное завершение уровня → «Уровень прерван», reason=manual', d3.state === 'GAME_OVER' && d3.reason === 'manual' && /прерван/i.test(d3.text), JSON.stringify(d3)); ok('D3: текст не «Поражение» и не «бюджет исчерпан»', !/Поражение/.test(d3.text) && !/бюджет/.test(d3.sub), JSON.stringify(d3)); } // --- 38d. D4: смена размера в кампании — no-op (враг/зона/уровень целы) --- await resetGlobals(page); { const d4 = await page.evaluate(() => { campaignProgress = { unlocked: 3 }; startLevel(0); // 30×30 const before = { w: fieldW, h: fieldH, enemy: countByPlayer(field).p2, zoneW: zoneRect && zoneRect.w }; setFieldSize(60); // вне FIELD_SIZES-пути не важно; в кампании — no-op setFieldDim(100, 50); const after = { w: fieldW, h: fieldH, enemy: countByPlayer(field).p2, zoneW: zoneRect && zoneRect.w, li: levelIndex, campaign, }; forceState('MENU'); return { before, after }; }); ok('D4: setFieldSize/setFieldDim в кампании — no-op (размер/враг/зона/уровень целы)', d4.after.w === 30 && d4.after.h === 30 && d4.after.enemy === d4.before.enemy && d4.after.zoneW === 30 && d4.after.li === 0 && d4.after.campaign === true, JSON.stringify(d4)); } await resetGlobals(page); // --- 38e. D5: applyIntent возвращает факт изменения (враг/своя клетка → false) --- { const d5 = await page.evaluate(() => { startLevel(0); // враг-блок, зона — всё поле selectTool('pencil'); const cs = board.cell; const onEnemy = applyIntent(board.x + 10.5 * cs, board.y + 10.5 * cs); // клетка врага const onEmpty = applyIntent(board.x + 5.5 * cs, board.y + 5.5 * cs); // пустая своя const again = applyIntent(board.x + 5.5 * cs, board.y + 5.5 * cs); // уже своя — без изменений forceState('MENU'); return { onEnemy, onEmpty, again }; }); ok('D5: applyIntent по врагу → false (ничего не изменилось)', d5.onEnemy === false, JSON.stringify(d5)); ok('D5: applyIntent по пустой → true, повтор по своей → false', d5.onEmpty === true && d5.again === false, JSON.stringify(d5)); } await resetGlobals(page); // --- 38f. Реальная симуляция: обложенный враг гибнет под tick → победа --- // Квадрат 4×4 вокруг блока врага (9..12)×(9..12) заполняется игроком: клетки врага // переполняются (>3 соседей) и гибнут на первом же такте честной симуляции. await resetGlobals(page); { const enemy0 = await page.evaluate(() => { campaignProgress = { unlocked: 1 }; startLevel(0); selectTool('pencil'); for (let y = 9; y <= 12; y++) { for (let x = 9; x <= 12; x++) { if (field[y * fieldW + x] !== 2) paintTool(x, y); // врага не трогаем (paintTool пропускает) } } const n = countByPlayer(field).p2; commitTurn(); // PLACE_P1 (кампания) → RUNNING: реальная симуляция return n; }); ok('реальный tick: перед стартом враг жив (4 клетки)', enemy0 === 4, 'enemy0=' + enemy0); await waitCond(page, () => state === 'GAME_OVER', 5000); { const v = await page.evaluate(() => ({ state, reason, winner, enemy: countByPlayer(field).p2 })); ok('реальный tick: обложенный враг гибнет, победа через симуляцию (win/winner=1)', v.state === 'GAME_OVER' && v.reason === 'win' && v.winner === '1' && v.enemy === 0, JSON.stringify(v)); } } await resetGlobals(page); // --- 38g. D1 (остаток): reload из терминального состояния кампании не тянет // настройки уровня в активную игру. Терминальные GAME_OVER не резюмируются → // applySave() стартует в MENU и возвращает активную игру к настройкам меню. await resetGlobals(page); { // Победа на уровне 1 (30×30 / бюджет 20) при дефолтных настройках меню. const before = await page.evaluate(() => { localStorage.clear(); startLevel(0); for (let i = 0; i < field.length; i++) if (field[i] === 2) field[i] = 0; forceState('RUNNING'); checkEnd(); // GAME_OVER/win persist(); return { state, reason, activeW: fieldW, activeTotal: budget.total, menuW: menuFieldW, menuTotal: menuBudget }; }); ok('D1 (остаток): перед reload — GAME_OVER/win, активная игра уровня (30/20), меню 60/100', before.state === 'GAME_OVER' && before.reason === 'win' && before.activeW === 30 && before.activeTotal === 20 && before.menuW === 60 && before.menuTotal === 100, JSON.stringify(before)); await reload(page); { const r = await page.evaluate(() => ({ state, campaign, fieldW, fieldH, total: budget.total, len: field.length, menuW: menuFieldW, optSize: document.getElementById('optSize').value, })); ok('D1 (остаток): reload из GAME_OVER кампании → MENU с активной игрой 60×60 / 100', r.state === 'MENU' && r.campaign === false && r.fieldW === 60 && r.fieldH === 60 && r.total === 100 && r.len === 3600, JSON.stringify(r)); ok('D1 (остаток): #optSize после reload = 60x60', r.optSize === '60x60', r.optSize); } await startTwoPlayer(page); { const g = await page.evaluate(() => ({ state, fieldW, fieldH, total: budget.total, campaign })); ok('D1 (остаток): «Игра на двоих» после reload даёт обычную партию 60×60 / 100', g.state === 'PLACE_P1' && g.fieldW === 60 && g.fieldH === 60 && g.total === 100 && g.campaign === false, JSON.stringify(g)); } } // Аналогично для ПОРАЖЕНИЯ и нестандартных menu* (80×80 / 250 / torus). await resetGlobals(page); await page.click('#btnPlay'); // настройки партии живут на экране «Игра на двоих» await page.select('#optSize', '80x80'); await page.$eval('#optBudget', inp => { inp.value = '250'; inp.dispatchEvent(new Event('change', { bubbles: true })); }); await page.click('#optWrap'); { const before = await page.evaluate(() => { startLevel(0); budget.used1 = budget.total; // бюджет уровня исчерпан forceState('PAUSED'); // fieldStable=true checkStalemate(); // → GAME_OVER/defeat persist(); return { state, reason, activeW: fieldW, activeTotal: budget.total, menuW: menuFieldW, menuTotal: menuBudget, menuWrap }; }); ok('D1 (остаток): перед reload — GAME_OVER/defeat, активная 30/20, меню 80/250/torus', before.state === 'GAME_OVER' && before.reason === 'defeat' && before.activeW === 30 && before.activeTotal === 20 && before.menuW === 80 && before.menuTotal === 250 && before.menuWrap === true, JSON.stringify(before)); } await reload(page); { const r = await page.evaluate(() => ({ state, fieldW, fieldH, total: budget.total, wrap, menuW: menuFieldW, menuTotal: menuBudget, menuWrap, optSize: document.getElementById('optSize').value, optWrap: document.getElementById('optWrap').checked, })); ok('D1 (остаток): reload из GAME_OVER/defeat → MENU с активной игрой 80×80 / 250 / torus', r.state === 'MENU' && r.fieldW === 80 && r.fieldH === 80 && r.total === 250 && r.wrap === true && r.optSize === '80x80' && r.optWrap === true, JSON.stringify(r)); ok('D1 (остаток): настройки меню сохранены (80×80 / 250 / torus)', r.menuW === 80 && r.menuTotal === 250 && r.menuWrap === true, JSON.stringify(r)); } await resetGlobals(page); // --- 5. Ошибки консоли (молчаливые ошибки = провал) --- ok('нет ошибок JS (pageerror)', pageErrors.length === 0, pageErrors.join(' | ')); ok('нет ошибок консоли', consoleErrors.length === 0, consoleErrors.join(' | ')); await browser.close(); const failed = results.filter(r => !r.pass); console.log('\n===== ИТОГ: ' + (results.length - failed.length) + '/' + results.length + ' ====='); process.exit(failed.length ? 1 : 0); })().catch(e => { console.error('TEST CRASH:', e.message); process.exit(2); });