commit 8e5631f74ae82702d9db6c0daa732de7707bda4c Author: Hermes Agent Date: Mon Jul 27 17:58:28 2026 +0800 feat(core): add deterministic echo relay engine diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2752eb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.DS_Store diff --git a/game-engine.js b/game-engine.js new file mode 100644 index 0000000..7c632e0 --- /dev/null +++ b/game-engine.js @@ -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); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..dff5b6a --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/tests/game-engine.test.mjs b/tests/game-engine.test.mjs new file mode 100644 index 0000000..babe299 --- /dev/null +++ b/tests/game-engine.test.mjs @@ -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 }); +});