const WALL = '#'; const START = '@'; const GOAL = 'X'; const PAD = /^[a-z]$/; const GATE = /^[A-Z]$/; function copyPoint(point) { return { x: point.x, y: point.y }; } function pointKey(point) { return `${point.x},${point.y}`; } function samePoint(left, right) { return left.x === right.x && left.y === right.y; } function isPad(tile) { return PAD.test(tile); } function isGate(tile) { return tile !== GOAL && GATE.test(tile); } function isParsedLevel(value) { return Boolean(value && Array.isArray(value.grid) && value.start && value.goal); } export function validateLevel(lines) { if (!Array.isArray(lines) || lines.length < 3 || lines.some((row) => typeof row !== 'string')) { throw new Error('A relay level needs at least three string rows.'); } const width = lines[0].length; if (width < 3 || lines.some((row) => row.length !== width)) { throw new Error('Every relay row must use the same width.'); } let starts = 0; let goals = 0; const pads = new Set(); const gates = new Set(); for (const row of lines) { for (const tile of row) { if (tile === START) starts += 1; if (tile === GOAL) goals += 1; if (isPad(tile)) pads.add(tile.toUpperCase()); if (isGate(tile)) gates.add(tile); } } if (starts !== 1 || goals !== 1) { throw new Error('Each relay needs exactly one start and one extraction point.'); } if (pads.size !== gates.size || [...pads].some((id) => !gates.has(id))) { throw new Error('Signal pads and gates must be paired one-to-one.'); } } export function parseLevel(lines) { validateLevel(lines); const pads = new Map(); const gates = new Map(); let start; let goal; lines.forEach((row, y) => { [...row].forEach((tile, x) => { const position = { x, y }; if (tile === START) start = position; if (tile === GOAL) goal = position; if (isPad(tile)) pads.set(tile, position); if (isGate(tile)) gates.set(tile, position); }); }); return { grid: lines.map((row) => [...row]), width: lines[0].length, height: lines.length, start, goal, pads, gates, }; } function resolveLevel(source) { return isParsedLevel(source) ? source : parseLevel(source); } function padAt(level, position) { for (const [id, pad] of level.pads) { if (samePoint(pad, position)) return id; } return null; } function activeGateSet(state) { const occupiedByEcho = state.echoes.map((echo) => echo.position); const active = new Set(); for (const [padId, position] of state.level.pads) { if (occupiedByEcho.some((entity) => samePoint(entity, position))) { active.add(padId.toUpperCase()); } } return active; } export function getActiveGateIds(state) { return [...activeGateSet(state)].sort(); } function tileAt(level, position) { if (position.x < 0 || position.y < 0 || position.x >= level.width || position.y >= level.height) { return null; } return level.grid[position.y][position.x]; } function eventState(state, lastEvent) { return { ...state, player: copyPoint(state.player), echoes: state.echoes.map((echo) => ({ ...echo, position: copyPoint(echo.position) })), lastEvent }; } export function createGame(source, { maxEchoes = 3 } = {}) { const level = resolveLevel(source); if (!Number.isInteger(maxEchoes) || maxEchoes < 1) { throw new Error('Relay capacity must be a positive integer.'); } return { level, player: copyPoint(level.start), echoes: [], maxEchoes, moves: 0, status: 'playing', lastEvent: 'ready', }; } const VECTORS = { U: { x: 0, y: -1 }, D: { x: 0, y: 1 }, L: { x: -1, y: 0 }, R: { x: 1, y: 0 }, }; export function move(state, command) { if (state.status !== 'playing') return eventState(state, 'complete'); const vector = VECTORS[command]; if (!vector) return eventState(state, 'invalid'); const target = { x: state.player.x + vector.x, y: state.player.y + vector.y }; const tile = tileAt(state.level, target); if (tile === null) return eventState(state, 'boundary'); if (tile === WALL) return eventState(state, 'wall'); if (isGate(tile) && !activeGateSet(state).has(tile)) return eventState(state, 'sealed'); const next = { ...state, player: target, echoes: state.echoes.map((echo) => ({ ...echo, position: copyPoint(echo.position) })), moves: state.moves + 1, lastEvent: tile === GOAL ? 'extract' : 'move', status: tile === GOAL ? 'won' : 'playing', }; return next; } export function anchor(state) { if (state.status !== 'playing') return eventState(state, 'complete'); const padId = padAt(state.level, state.player); if (!padId || state.echoes.length >= state.maxEchoes) return eventState(state, 'anchor-required'); return { ...state, player: copyPoint(state.level.start), echoes: [ ...state.echoes.map((echo) => ({ ...echo, position: copyPoint(echo.position) })), { id: `E${state.echoes.length + 1}`, padId: padId.toUpperCase(), position: copyPoint(state.player) }, ], moves: state.moves + 1, lastEvent: 'anchor', }; } export function removeLastEcho(state) { if (state.echoes.length === 0) return eventState(state, 'nothing-to-undo'); return { ...state, player: copyPoint(state.level.start), echoes: state.echoes.slice(0, -1).map((echo) => ({ ...echo, position: copyPoint(echo.position) })), status: 'playing', lastEvent: 'undo', }; } export function reset(state) { return createGame(state.level, { maxEchoes: state.maxEchoes }); } export function positionKey(position) { return pointKey(position); }