214 lines
5.5 KiB
JavaScript
214 lines
5.5 KiB
JavaScript
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);
|
|
}
|