159 lines
8.0 KiB
JavaScript
159 lines
8.0 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFile, mkdtemp, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
|
|
const root = new URL('..', import.meta.url);
|
|
const htmlPath = new URL('index.html', root);
|
|
const html = await readFile(htmlPath, 'utf8');
|
|
|
|
assert.match(html, /<!doctype html>/i, 'index.html must be a complete HTML document');
|
|
assert.match(html, /const LEVELS = Object\.freeze\(\[/, 'level configuration must be present');
|
|
assert.doesNotMatch(html, /(?:https?:)?\/\/(?:unpkg|cdn\.jsdelivr|cdnjs|fonts\.googleapis|fonts\.gstatic)/i, 'the game must not load external runtime dependencies');
|
|
assert.doesNotMatch(html, /<(?:script|link|img)[^>]+(?:src|href)=['"]https?:/i, 'the game must be self-contained');
|
|
|
|
const scriptMatches = [...html.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/gi)];
|
|
assert.equal(scriptMatches.length, 1, 'the game should have one inline script');
|
|
const script = scriptMatches[0][1];
|
|
const tempDir = await mkdtemp(join(tmpdir(), 'echo-loop-'));
|
|
const scriptPath = join(tempDir, 'game.mjs');
|
|
await writeFile(scriptPath, script);
|
|
const syntax = spawnSync(process.execPath, ['--check', scriptPath], { encoding: 'utf8' });
|
|
assert.equal(syntax.status, 0, `browser script has syntax errors:\n${syntax.stderr}`);
|
|
|
|
const levelsBlockMatch = script.match(/const LEVELS = Object\.freeze\(\[([\s\S]*?)\]\);\s*\n\s*const state/);
|
|
assert.ok(levelsBlockMatch, 'could not extract level definitions');
|
|
const levels = [...levelsBlockMatch[1].matchAll(/id:\s*'([^']+)'[\s\S]*?name:\s*'([^']+)'[\s\S]*?title:\s*'([^']+)'[\s\S]*?map:\s*\[([\s\S]*?)\n\s*\]/g)].map((match) => ({
|
|
id: match[1],
|
|
name: match[2],
|
|
title: match[3],
|
|
map: [...match[4].matchAll(/'([^']*)'/g)].map((row) => row[1])
|
|
}));
|
|
assert.equal(levels.length, 4, 'could not extract all level definitions');
|
|
assert.equal(levels.length, 4, 'the game must contain four experiments');
|
|
|
|
for (const [index, level] of levels.entries()) {
|
|
assert.ok(level.id && level.name && level.title, `level ${index + 1} must have identity metadata`);
|
|
const width = level.map[0].length;
|
|
const symbols = level.map.flatMap((row) => [...row]);
|
|
assert.ok(level.map.length >= 3 && width >= 7, `level ${index + 1} is too small to be meaningful`);
|
|
assert.ok(level.map.every((row) => row.length === width), `level ${index + 1} rows must have equal width`);
|
|
assert.equal(symbols.filter((symbol) => symbol === 'S').length, 1, `level ${index + 1} needs one start`);
|
|
assert.equal(symbols.filter((symbol) => symbol === 'G').length, 1, `level ${index + 1} needs one goal`);
|
|
const plates = new Set(symbols.filter((symbol) => /[a-z]/.test(symbol)));
|
|
const gates = new Set(symbols.filter((symbol) => /[A-Z]/.test(symbol) && symbol !== 'S' && symbol !== 'G').map((symbol) => symbol.toLowerCase()));
|
|
assert.ok(plates.size >= 1, `level ${index + 1} needs a memory node`);
|
|
assert.deepEqual([...plates].sort(), [...gates].sort(), `level ${index + 1} node/gate pairs must match`);
|
|
}
|
|
|
|
function canReachTarget(level, target, openGates = new Set()) {
|
|
const height = level.map.length;
|
|
const width = level.map[0].length;
|
|
const start = {};
|
|
const destination = {};
|
|
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
|
|
if (level.map[y][x] === 'S') Object.assign(start, { x, y });
|
|
if (level.map[y][x] === target) Object.assign(destination, { x, y });
|
|
}
|
|
const queue = [start];
|
|
const visited = new Set([`${start.x},${start.y}`]);
|
|
while (queue.length) {
|
|
const point = queue.shift();
|
|
if (point.x === destination.x && point.y === destination.y) return true;
|
|
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
const x = point.x + dx;
|
|
const y = point.y + dy;
|
|
if (x < 0 || y < 0 || x >= width || y >= height) continue;
|
|
const tile = level.map[y][x];
|
|
if (tile === '#' || /[A-Z]/.test(tile) && tile !== 'S' && tile !== 'G' && !openGates.has(tile)) continue;
|
|
const key = `${x},${y}`;
|
|
if (!visited.has(key)) {
|
|
visited.add(key);
|
|
queue.push({ x, y });
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
levels.forEach((level, index) => assert.equal(canReachTarget(level, 'G'), false, `level ${index + 1} must require at least one powered gate`));
|
|
const third = levels[2];
|
|
assert.equal(canReachTarget(third, 'b'), false, 'the third experiment node b must be behind gate A');
|
|
assert.equal(canReachTarget(third, 'b', new Set(['A'])), true, 'gate A must expose the third experiment node b');
|
|
assert.equal(canReachTarget(third, 'G', new Set(['A'])), false, 'the third experiment must still require gate B');
|
|
assert.equal(canReachTarget(third, 'G', new Set(['A', 'B'])), true, 'both powered gates must expose the third experiment exit');
|
|
|
|
function reachableSymbols(level) {
|
|
const height = level.map.length;
|
|
const width = level.map[0].length;
|
|
const start = {};
|
|
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) if (level.map[y][x] === 'S') Object.assign(start, { x, y });
|
|
const queue = [start];
|
|
const visited = new Set([`${start.x},${start.y}`]);
|
|
while (queue.length) {
|
|
const point = queue.shift();
|
|
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
const x = point.x + dx;
|
|
const y = point.y + dy;
|
|
if (x < 0 || y < 0 || x >= width || y >= height) continue;
|
|
if (level.map[y][x] === '#') continue;
|
|
const key = `${x},${y}`;
|
|
if (!visited.has(key)) {
|
|
visited.add(key);
|
|
queue.push({ x, y });
|
|
}
|
|
}
|
|
}
|
|
return [...visited].map((key) => key.split(',').map(Number));
|
|
}
|
|
|
|
levels.forEach((level, index) => {
|
|
const reachable = new Set(reachableSymbols(level).map(([x, y]) => `${x},${y}`));
|
|
[...level.map.join('')].filter((symbol) => /[a-z]/.test(symbol)).forEach((symbol) => {
|
|
const row = level.map.findIndex((line) => line.includes(symbol));
|
|
const column = level.map[row].indexOf(symbol);
|
|
assert.ok(reachable.has(`${column},${row}`), `level ${index + 1} node ${symbol} must be reachable from start`);
|
|
});
|
|
});
|
|
|
|
function simulateFirstExperiment(level) {
|
|
const map = level.map;
|
|
const start = { x: 1, y: 1 };
|
|
const plate = { x: 4, y: 1 };
|
|
const gate = { x: 5, y: 1 };
|
|
const goal = { x: 9, y: 1 };
|
|
const echo = { ...start };
|
|
const stepRight = (entity, allowGate) => {
|
|
const next = { x: entity.x + 1, y: entity.y };
|
|
const tile = map[next.y][next.x];
|
|
if (tile === '#' || (next.x === gate.x && !allowGate)) return false;
|
|
entity.x = next.x;
|
|
return true;
|
|
};
|
|
for (let index = 0; index < 3; index += 1) assert.equal(stepRight(echo, false), true, 'the recorded route must reach the first node');
|
|
assert.deepEqual(echo, plate, 'the first echo must remain on node A after recording');
|
|
const player = { ...start };
|
|
for (let index = 0; index < 8; index += 1) {
|
|
const passed = stepRight(player, echo.x === plate.x && echo.y === plate.y);
|
|
assert.equal(passed, true, `player pulse ${index + 1} should be legal`);
|
|
}
|
|
assert.deepEqual(player, goal, 'the player must reach the goal through the powered gate');
|
|
}
|
|
|
|
const first = levels[0];
|
|
simulateFirstExperiment(first);
|
|
|
|
assert.match(script, /function resetEchoPositions\(\)/, 'multi-echo timelines must reset every echo to the start');
|
|
assert.match(script, /resetEchoPositions\(\);\s*state\.actions = \[\];/, 'recording a new echo must restart the existing echo fleet');
|
|
assert.match(script, /const occupiedByEcho = state\.echoes\.some/, 'only echoes may power memory nodes');
|
|
assert.doesNotMatch(script, /const occupiedByPlayer = state\.player\.x/, 'the current player must not directly power memory nodes');
|
|
assert.match(script, /if \(!isPlate\(currentPlate\)\)/, 'recording must require the player to stand on a memory node');
|
|
assert.match(script, /state\.echoes\.length >= MAX_ECHOES/, 'the memory bank must enforce its visible capacity');
|
|
|
|
console.log('ECHO//LOOP verification passed');
|
|
console.log(`- ${levels.length} level definitions validated`);
|
|
console.log('- self-contained resource checks passed');
|
|
console.log('- inline JavaScript syntax check passed');
|
|
console.log('- echo-powered gate simulation passed');
|