From 5105464a1f51caec3e90e1f3323fc009be2ea5ac Mon Sep 17 00:00:00 2001 From: kimsongwu Date: Mon, 27 Jul 2026 17:10:58 +0800 Subject: [PATCH] fix: enforce echo puzzle constraints and validate maps --- index.html | 23 +++++++++------ tests/verify.mjs | 74 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/index.html b/index.html index e416e7f..e351019 100644 --- a/index.html +++ b/index.html @@ -371,6 +371,7 @@ const STORAGE_KEY = 'echo-loop-progress-v1'; const MAX_PULSES = 120; + const MAX_ECHOES = 4; const DIRS = Object.freeze({ up: { dx: 0, dy: -1, key: 'U', label: '上' }, left: { dx: -1, dy: 0, key: 'L', label: '左' }, @@ -416,12 +417,8 @@ map: [ '###############', '#S..aA........#', - '#.###.#####.#.#', - '#...#.....#...#', - '###.#.###.#.#.#', - '#b..#...B...G.#', - '#.###########.#', - '#.............#', + '#######.#######', + '#b......B..G..#', '###############' ] }, @@ -535,8 +532,7 @@ const active = new Set(); Object.entries(state.plates).forEach(([code, point]) => { const occupiedByEcho = state.echoes.some((echo) => echo.x === point.x && echo.y === point.y); - const occupiedByPlayer = state.player.x === point.x && state.player.y === point.y; - if (occupiedByEcho || occupiedByPlayer) active.add(code); + if (occupiedByEcho) active.add(code); }); return active; } @@ -611,6 +607,15 @@ showToast('还没有可封存的路线'); return; } + if (state.echoes.length >= MAX_ECHOES) { + showToast('记忆库已满 · 先用现有回声完成实验'); + return; + } + const currentPlate = tileAt(state.player.x, state.player.y); + if (!isPlate(currentPlate)) { + showToast('只有站在绿色节点上,才能封存回声'); + return; + } const echoNumber = state.echoes.length + 1; state.echoes.push({ id: echoNumber, x: state.start.x, y: state.start.y, actions: [...state.actions] }); resetEchoPositions(); @@ -753,7 +758,7 @@ els.echoList.appendChild(entry); }); } - els.bankCount.textContent = `${String(state.echoes.length).padStart(2, '0')} / 04`; + els.bankCount.textContent = `${String(state.echoes.length).padStart(2, '0')} / ${String(MAX_ECHOES).padStart(2, '0')}`; els.echoCoord.textContent = state.echoes.length ? `${state.echoes.length} 条记忆正在从起点重播` : '尚未封存任何记忆'; els.echoStatus.textContent = state.echoes.length ? '在线' : '空'; els.echoStatus.className = `objective-status${state.echoes.length ? ' live' : ''}`; diff --git a/tests/verify.mjs b/tests/verify.mjs index dd807f1..b291cb4 100644 --- a/tests/verify.mjs +++ b/tests/verify.mjs @@ -41,6 +41,76 @@ for (const [index, level] of levels.entries()) { 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 }; @@ -70,6 +140,10 @@ 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`);