feat(core): add deterministic echo relay engine
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
+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);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "vanta-relay",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"check": "node --check game-engine.js"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
anchor,
|
||||
createGame,
|
||||
getActiveGateIds,
|
||||
move,
|
||||
parseLevel,
|
||||
removeLastEcho,
|
||||
reset,
|
||||
validateLevel,
|
||||
} from '../game-engine.js';
|
||||
|
||||
const ONE_RELAY = [
|
||||
'#########',
|
||||
'#@..aA.X#',
|
||||
'#.#######',
|
||||
'#.#######',
|
||||
'#########',
|
||||
];
|
||||
|
||||
const DOUBLE_RELAY = [
|
||||
'#############',
|
||||
'#@..aA...B.X#',
|
||||
'#.###########',
|
||||
'#.....b######',
|
||||
'#############',
|
||||
];
|
||||
|
||||
function drive(state, commands) {
|
||||
return [...commands].reduce((next, command) => move(next, command), state);
|
||||
}
|
||||
|
||||
test('parseLevel identifies a rectangular relay, start, goal, pads, and gates', () => {
|
||||
const level = parseLevel(ONE_RELAY);
|
||||
|
||||
assert.equal(level.width, 9);
|
||||
assert.equal(level.height, 5);
|
||||
assert.deepEqual(level.start, { x: 1, y: 1 });
|
||||
assert.deepEqual(level.goal, { x: 7, y: 1 });
|
||||
assert.deepEqual(level.pads.get('a'), { x: 4, y: 1 });
|
||||
assert.deepEqual(level.gates.get('A'), { x: 5, y: 1 });
|
||||
});
|
||||
|
||||
test('validateLevel rejects ragged maps and unpaired signal identifiers', () => {
|
||||
assert.throws(
|
||||
() => validateLevel(['#####', '#@X#', '#####']),
|
||||
/same width/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLevel(['#####', '#@aX#', '#####']),
|
||||
/paired/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a sealed gate blocks the runner before an echo occupies its matching pad', () => {
|
||||
const beforeGate = drive(createGame(ONE_RELAY), 'RRR');
|
||||
const blocked = move(beforeGate, 'R');
|
||||
|
||||
assert.deepEqual(blocked.player, { x: 4, y: 1 });
|
||||
assert.equal(blocked.lastEvent, 'sealed');
|
||||
assert.deepEqual(getActiveGateIds(blocked), []);
|
||||
});
|
||||
|
||||
test('anchoring at a signal pad leaves an echo, resets the runner, and opens its gate', () => {
|
||||
const onPad = drive(createGame(ONE_RELAY), 'RRR');
|
||||
const anchored = anchor(onPad);
|
||||
|
||||
assert.deepEqual(anchored.player, { x: 1, y: 1 });
|
||||
assert.deepEqual(anchored.echoes.map((echo) => echo.position), [{ x: 4, y: 1 }]);
|
||||
assert.deepEqual(getActiveGateIds(anchored), ['A']);
|
||||
assert.equal(anchored.lastEvent, 'anchor');
|
||||
});
|
||||
|
||||
test('a relay becomes complete only after its matching echo opens the route to extraction', () => {
|
||||
const anchored = anchor(drive(createGame(ONE_RELAY), 'RRR'));
|
||||
const escaped = drive(anchored, 'RRRRRR');
|
||||
|
||||
assert.equal(escaped.status, 'won');
|
||||
assert.deepEqual(escaped.player, { x: 7, y: 1 });
|
||||
});
|
||||
|
||||
test('an anchor cannot be created off a signal pad and respects its capacity', () => {
|
||||
const fresh = createGame(ONE_RELAY, { maxEchoes: 1 });
|
||||
const rejected = anchor(fresh);
|
||||
const first = anchor(drive(fresh, 'RRR'));
|
||||
const atCap = anchor(drive(first, 'LLL'));
|
||||
|
||||
assert.equal(rejected.lastEvent, 'anchor-required');
|
||||
assert.equal(rejected.echoes.length, 0);
|
||||
assert.equal(first.echoes.length, 1);
|
||||
assert.equal(atCap.lastEvent, 'anchor-required');
|
||||
assert.equal(atCap.echoes.length, 1);
|
||||
});
|
||||
|
||||
test('undo and reset restore deterministic relay state', () => {
|
||||
const withEcho = anchor(drive(createGame(ONE_RELAY), 'RRR'));
|
||||
const undone = removeLastEcho(withEcho);
|
||||
const resetState = reset(withEcho);
|
||||
|
||||
assert.equal(undone.echoes.length, 0);
|
||||
assert.deepEqual(undone.player, { x: 1, y: 1 });
|
||||
assert.equal(resetState.echoes.length, 0);
|
||||
assert.equal(resetState.status, 'playing');
|
||||
assert.equal(resetState.moves, 0);
|
||||
});
|
||||
|
||||
test('two independent echoes can sustain a staged two-gate extraction route', () => {
|
||||
let state = createGame(DOUBLE_RELAY, { maxEchoes: 2 });
|
||||
state = anchor(drive(state, 'DDRRRRR'));
|
||||
state = anchor(drive(state, 'RRR'));
|
||||
|
||||
assert.deepEqual(getActiveGateIds(state), ['A', 'B']);
|
||||
|
||||
state = drive(state, 'RRRRRRRRRR');
|
||||
assert.equal(state.status, 'won');
|
||||
assert.deepEqual(state.player, { x: 11, y: 1 });
|
||||
});
|
||||
Reference in New Issue
Block a user