feat(core): add deterministic echo relay engine
This commit is contained in:
+213
@@ -0,0 +1,213 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user