- PATTERNS: авианосец, длинная лодка, каноэ, MWSS, HWSS, Loafer, кролики, ружьё Симкина (22 → 30) - CAMPAIGN_LEVELS: ручные «Флотилия»/«Погоня»/«Кролики» после «Издалека» (100 → 103) - README: раздел «Интересные факты» и список ресурсов - tests: счётчики и проверки новых фигур/уровней синхронизированы
3918 lines
229 KiB
JavaScript
3918 lines
229 KiB
JavaScript
'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); });
|