Files
andrey 3061247ce5 Паттерны и кампания: 8 новых фигур, 3 showcase-уровня, факты в README
- PATTERNS: авианосец, длинная лодка, каноэ, MWSS, HWSS, Loafer, кролики, ружьё Симкина (22 → 30)
- CAMPAIGN_LEVELS: ручные «Флотилия»/«Погоня»/«Кролики» после «Издалека» (100 → 103)
- README: раздел «Интересные факты» и список ресурсов
- tests: счётчики и проверки новых фигур/уровней синхронизированы
2026-10-13 15:06:52 +03:00

3918 lines
229 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* 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 <select> подавлен
// (mousedown → preventDefault), клик по полю открывает свой список, выбор фигуры
// задаёт stamp и закрывает список, Escape/клик вне — закрывают. <select> остаётся
// контрактом (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); });