From c53bc26d7d0b229ec602d67e410f4ea80bf8cddf Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 28 Jul 2026 09:18:02 +0800 Subject: [PATCH] feat(game): add neon isometric relay interface --- README.md | 56 +++++ app.js | 507 ++++++++++++++++++++++++++++++++++++++++++ index.html | 199 +++++++++++++++++ levels.js | 84 +++++++ package.json | 2 +- tests/levels.test.mjs | 48 ++++ tests/shell.test.mjs | 22 ++ 7 files changed, 917 insertions(+), 1 deletion(-) create mode 100644 README.md create mode 100644 app.js create mode 100644 index.html create mode 100644 levels.js create mode 100644 tests/levels.test.mjs create mode 100644 tests/shell.test.mjs diff --git a/README.md b/README.md new file mode 100644 index 0000000..4fa8371 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# VANTA//RELAY + +A neon isometric puzzle game about being your own infrastructure. + +The vault will not accept the current you as a power source. Walk onto a lower-case signal node, **anchor** a frozen echo, then use the reset present-self to cross the matching upper-case gate. Each chamber asks you to sustain more impossible versions of yourself at once. + +## Play + +This project is fully static and has no package installation or build step. + +```sh +cd /root/vanta-relay-20260727-095415 +python3 -m http.server 4173 +``` + +Then open `http://127.0.0.1:4173`. + +- `WASD` or arrow keys: move +- `Space`: anchor an echo while standing on a lower-case node +- `Z`: remove the most recently anchored echo +- `R`: reset the current relay +- `M`: toggle synthesized audio +- `H`: show the protocol card + +The on-screen D-pad and buttons provide touch/click alternatives. + +## Design + +- **Three authored chambers** — from one echo to three simultaneous signal witnesses. +- **Actual game rules, separate from rendering** — `game-engine.js` is a deterministic, DOM-free state model. +- **Pseudo-3D canvas world** — an isometric grid, extruded null-walls, glowing gates, particles, animated portal rings, and procedural starfield; no images, fonts, CDN, or network runtime calls. +- **Accessible controls** — semantic buttons, canvas label, visible objective/status readouts, and keyboard controls. +- **Optional local progress** — the highest unlocked chamber is stored locally only when browser storage is available. + +## Verify + +Node 22+ is sufficient; there are no dependencies to install. + +```sh +npm test +npm run check +``` + +The suite covers engine behavior, invalid maps, closed-gate negative paths, echo capacity/undo/reset, authored-map reachability, staged gates, and a static zero-external-asset shell check. + +## Project map + +```text +index.html interface, responsive chrome, and inline style system +app.js canvas renderer, controls, particles, audio, smoke-test surface +game-engine.js deterministic relay state machine +levels.js authored chambers plus graph reachability helper +tests/ Node test suite +``` + +No credentials are stored in this repository. diff --git a/app.js b/app.js new file mode 100644 index 0000000..9b2cf0b --- /dev/null +++ b/app.js @@ -0,0 +1,507 @@ +import { + anchor, + createGame, + getActiveGateIds, + move, + removeLastEcho, + reset, +} from './game-engine.js'; +import { LEVELS } from './levels.js'; + +const canvas = document.querySelector('#relay-canvas'); +const ctx = canvas.getContext('2d'); +const ui = { + levelCode: document.querySelector('#level-code'), + levelTitle: document.querySelector('#level-title'), + briefing: document.querySelector('#briefing'), + echoCount: document.querySelector('#echo-count'), + echoCapacity: document.querySelector('#echo-capacity'), + moveCount: document.querySelector('#move-count'), + gateCount: document.querySelector('#gate-count'), + coordinate: document.querySelector('#coordinate-readout'), + cycle: document.querySelector('#cycle-readout'), + objective: document.querySelector('#objective-text'), + event: document.querySelector('#event-feed'), + protocol: document.querySelector('#protocol-list'), + modal: document.querySelector('#signal-modal'), + modalTitle: document.querySelector('#modal-title'), + modalCopy: document.querySelector('#modal-copy'), + nextLevel: document.querySelector('#next-level'), + help: document.querySelector('#help-card'), + mute: document.querySelector('[data-action="mute"]'), +}; + +let levelIndex = 0; +let game; +let muted = false; +let audioContext; +let completionQueued = false; +let lastFrame = 0; +let elapsed = 0; +let particles = []; +let activeEvent = 'AWAITING INPUT'; +let highestUnlocked = readNumber('vanta-relay-unlocked', 0); + +function readNumber(key, fallback) { + try { + const value = Number.parseInt(localStorage.getItem(key) || '', 10); + return Number.isInteger(value) ? value : fallback; + } catch { + return fallback; + } +} + +function writeNumber(key, value) { + try { + localStorage.setItem(key, String(value)); + } catch { + // Progress is optional. The relay remains fully playable without storage. + } +} + +function levelCapacity(index) { + return Math.min(index + 1, 3); +} + +function startLevel(index) { + levelIndex = index; + game = createGame(LEVELS[index].map, { maxEchoes: levelCapacity(index) }); + completionQueued = false; + particles = []; + activeEvent = index === 0 ? 'FIND A LOWER-CASE SIGNAL NODE' : 'NEW MEMORY CHAMBER SYNCHRONIZED'; + document.documentElement.style.setProperty('--cyan', LEVELS[index].accent); + updateHud(); +} + +function updateHud() { + const definition = LEVELS[levelIndex]; + const activeGates = getActiveGateIds(game); + ui.levelCode.textContent = definition.code; + ui.levelTitle.textContent = definition.title; + ui.briefing.textContent = definition.briefing; + ui.echoCount.textContent = String(game.echoes.length).padStart(2, '0'); + ui.echoCapacity.textContent = String(game.maxEchoes).padStart(2, '0'); + ui.moveCount.textContent = String(game.moves).padStart(3, '0'); + ui.gateCount.textContent = `${String(activeGates.length).padStart(2, '0')} ACTIVE`; + ui.coordinate.textContent = `POS // ${String(game.player.x).padStart(2, '0')}.${String(game.player.y).padStart(2, '0')}`; + ui.cycle.textContent = `CYCLE // ${String(game.moves).padStart(3, '0')}`; + ui.objective.textContent = game.status === 'won' + ? 'Extraction signature accepted. The next chamber is listening.' + : `Only echoes power signal nodes. ${game.echoes.length}/${game.maxEchoes} memories anchored.`; + ui.event.textContent = activeEvent; + + ui.protocol.innerHTML = LEVELS.map((level, index) => { + const state = index === levelIndex ? 'current' : index <= highestUnlocked ? 'active' : ''; + const tag = index === levelIndex ? 'LIVE' : index <= highestUnlocked ? 'ARCHIVED' : 'SEALED'; + return `
${level.code}${tag}
`; + }).join(''); +} + +function makeTone(frequency, duration, kind = 'sine', volume = 0.025) { + if (muted) return; + try { + audioContext ||= new AudioContext(); + const oscillator = audioContext.createOscillator(); + const gain = audioContext.createGain(); + oscillator.type = kind; + oscillator.frequency.setValueAtTime(frequency, audioContext.currentTime); + gain.gain.setValueAtTime(0.0001, audioContext.currentTime); + gain.gain.exponentialRampToValueAtTime(volume, audioContext.currentTime + 0.012); + gain.gain.exponentialRampToValueAtTime(0.0001, audioContext.currentTime + duration); + oscillator.connect(gain).connect(audioContext.destination); + oscillator.start(); + oscillator.stop(audioContext.currentTime + duration + 0.02); + } catch { + muted = true; + } +} + +function eventLabel(event) { + const labels = { + anchor: 'ECHO FROZEN // GATE SIGNATURE POWERED', + 'anchor-required': 'ANCHOR REJECTED // STAND ON A VACANT SIGNAL NODE', + sealed: 'GATE REJECTED // ONLY AN ECHO MAY SUPPLY CURRENT', + wall: 'HARD NULL // PATH TERMINATED', + boundary: 'OUTSIDE RELAY GEOMETRY', + invalid: 'INVALID VECTOR', + undo: 'LAST WITNESS UNWOUND', + 'nothing-to-undo': 'NO WITNESS TO UNWIND', + ready: 'RELAY PRIMED', + extract: 'EXTRACTION SIGNATURE ACCEPTED', + }; + return labels[event] || 'PULSE REGISTERED'; +} + +function project(position) { + const { width, height } = game.level; + const tileWidth = Math.min(92, 1250 / (width + height)); + const tileHeight = tileWidth * 0.46; + return { + x: canvas.width / 2 + (position.x - position.y) * tileWidth * 0.5, + y: 172 + (position.x + position.y) * tileHeight * 0.5, + tileWidth, + tileHeight, + }; +} + +function diamond(x, y, width, height) { + ctx.beginPath(); + ctx.moveTo(x, y - height / 2); + ctx.lineTo(x + width / 2, y); + ctx.lineTo(x, y + height / 2); + ctx.lineTo(x - width / 2, y); + ctx.closePath(); +} + +function worldBurst(position, color, count = 18, force = 1) { + const point = project(position); + for (let index = 0; index < count; index += 1) { + const angle = (Math.PI * 2 * index) / count + Math.random() * 0.34; + const speed = (0.7 + Math.random() * 1.5) * force; + particles.push({ + x: point.x, + y: point.y - 30, + vx: Math.cos(angle) * speed * 80, + vy: Math.sin(angle) * speed * 80 - 24, + life: 0.55 + Math.random() * 0.6, + age: 0, + color, + size: 1 + Math.random() * 3, + }); + } +} + +function perform(action) { + if (game.status === 'won') return; + const before = game; + if (action === 'anchor') game = anchor(game); + if (action === 'undo') game = removeLastEcho(game); + if (action === 'reset') game = reset(game); + if (/^[UDLR]$/.test(action)) game = move(game, action); + + activeEvent = eventLabel(game.lastEvent); + if (game.lastEvent === 'anchor') { + worldBurst(before.player, '#ff62d1', 34, 1.45); + makeTone(188, 0.16, 'triangle', 0.045); + setTimeout(() => makeTone(380, 0.15, 'sine', 0.035), 80); + } else if (game.lastEvent === 'sealed') { + worldBurst(before.player, '#a98bff', 10, 0.5); + makeTone(92, 0.11, 'square', 0.018); + } else if (game.lastEvent === 'move') { + worldBurst(game.player, '#65f9ff', 5, 0.36); + makeTone(220 + game.moves * 3, 0.04, 'sine', 0.013); + } else if (game.lastEvent === 'extract') { + worldBurst(game.player, '#fff3a1', 80, 2.1); + makeTone(260, 0.24, 'triangle', 0.05); + setTimeout(() => makeTone(520, 0.28, 'sine', 0.045), 130); + queueCompletion(); + } else if (game.lastEvent === 'undo' || action === 'reset') { + particles = []; + makeTone(130, 0.08, 'sine', 0.02); + } + updateHud(); +} + +function queueCompletion() { + if (completionQueued) return; + completionQueued = true; + highestUnlocked = Math.max(highestUnlocked, Math.min(levelIndex + 1, LEVELS.length - 1)); + writeNumber('vanta-relay-unlocked', highestUnlocked); + setTimeout(() => { + const lastRelay = levelIndex === LEVELS.length - 1; + ui.modalTitle.textContent = lastRelay ? 'YOU ARE THE RELAY' : 'SIGNAL HOLDS'; + ui.modalCopy.textContent = lastRelay + ? 'Three versions of you held the void open. The vault has no owner now — only an exit.' + : 'A new chamber has acknowledged your impossible alibi.'; + ui.nextLevel.textContent = lastRelay ? 'REBOOT THE RELAY' : 'ENTER NEXT CHAMBER'; + ui.modal.hidden = false; + }, 650); +} + +function drawBackground(time) { + const gradient = ctx.createRadialGradient(canvas.width * 0.5, 250, 20, canvas.width * 0.5, 370, 890); + gradient.addColorStop(0, '#1b3b62'); + gradient.addColorStop(0.33, '#101c3a'); + gradient.addColorStop(1, '#050711'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + for (let index = 0; index < 82; index += 1) { + const x = ((Math.sin(index * 643.13) + 1) * 0.5) * canvas.width; + const y = ((Math.sin(index * 93.77 + 1.1) + 1) * 0.5) * 480; + const alpha = 0.12 + ((Math.sin(time * 0.001 + index) + 1) * 0.08); + ctx.fillStyle = `rgba(151, 223, 255, ${alpha})`; + ctx.fillRect(x, y, index % 5 === 0 ? 2 : 1, index % 5 === 0 ? 2 : 1); + } + + const horizon = 132; + ctx.save(); + ctx.strokeStyle = 'rgba(90, 231, 255, 0.11)'; + ctx.lineWidth = 1; + for (let index = -18; index <= 18; index += 1) { + ctx.beginPath(); + ctx.moveTo(canvas.width / 2, horizon); + ctx.lineTo(canvas.width / 2 + index * 92, canvas.height); + ctx.stroke(); + } + for (let row = 0; row < 12; row += 1) { + const progress = row / 12; + const y = horizon + progress * progress * 720; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(canvas.width, y); + ctx.stroke(); + } + ctx.restore(); +} + +function drawFloor(point, tile) { + const { x, y, tileWidth, tileHeight } = point; + const fill = ctx.createLinearGradient(x - tileWidth / 2, y - tileHeight / 2, x + tileWidth / 2, y + tileHeight / 2); + fill.addColorStop(0, tile === '#' ? '#15213b' : '#123550'); + fill.addColorStop(1, tile === '#' ? '#0a1021' : '#071424'); + diamond(x, y, tileWidth, tileHeight); + ctx.fillStyle = fill; + ctx.fill(); + ctx.strokeStyle = tile === '#' ? 'rgba(115, 164, 210, .25)' : 'rgba(98, 238, 255, .24)'; + ctx.stroke(); +} + +function drawWall(point, depth) { + const { x, y, tileWidth, tileHeight } = point; + const left = x - tileWidth / 2; + const right = x + tileWidth / 2; + const bottom = y + tileHeight / 2; + ctx.fillStyle = '#081226'; + ctx.beginPath(); + ctx.moveTo(left, y); + ctx.lineTo(x, bottom); + ctx.lineTo(x, bottom + depth); + ctx.lineTo(left, y + depth); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = '#112a46'; + ctx.beginPath(); + ctx.moveTo(right, y); + ctx.lineTo(x, bottom); + ctx.lineTo(x, bottom + depth); + ctx.lineTo(right, y + depth); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = 'rgba(91, 232, 255, .14)'; + ctx.stroke(); +} + +function drawNode(point, id, active, time) { + const { x, y, tileWidth } = point; + const pulse = 1 + Math.sin(time * 0.006 + id.charCodeAt(0)) * 0.16; + ctx.save(); + ctx.translate(x, y - 4); + ctx.scale(1, 0.48); + ctx.beginPath(); + ctx.arc(0, 0, tileWidth * 0.23 * pulse, 0, Math.PI * 2); + ctx.fillStyle = active ? 'rgba(255, 98, 209, .46)' : 'rgba(246, 196, 66, .24)'; + ctx.fill(); + ctx.lineWidth = 3; + ctx.strokeStyle = active ? '#ff62d1' : '#ffc45a'; + ctx.shadowBlur = active ? 26 : 10; + ctx.shadowColor = ctx.strokeStyle; + ctx.stroke(); + ctx.restore(); + ctx.shadowBlur = 0; + ctx.fillStyle = active ? '#fff0fb' : '#ffe1a0'; + ctx.font = '700 13px monospace'; + ctx.textAlign = 'center'; + ctx.fillText(id.toUpperCase(), x, y + 4); +} + +function drawGate(point, id, active, time) { + const { x, y, tileWidth, tileHeight } = point; + const height = tileWidth * 0.84; + const glow = active ? '#3df2ff' : '#a98bff'; + ctx.save(); + ctx.globalAlpha = active ? 0.72 : 0.46; + const fade = ctx.createLinearGradient(x, y - height, x, y + tileHeight / 2); + fade.addColorStop(0, active ? 'rgba(152,255,255,.24)' : 'rgba(185,135,255,.1)'); + fade.addColorStop(1, active ? 'rgba(61,242,255,.05)' : 'rgba(169,139,255,.03)'); + ctx.fillStyle = fade; + ctx.beginPath(); + ctx.moveTo(x - tileWidth * 0.28, y); + ctx.lineTo(x, y - height); + ctx.lineTo(x + tileWidth * 0.28, y); + ctx.lineTo(x, y + tileHeight * 0.16); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = glow; + ctx.lineWidth = active ? 2.5 : 2; + ctx.shadowBlur = active ? 28 : 12; + ctx.shadowColor = glow; + ctx.stroke(); + ctx.globalAlpha = active ? 0.34 + Math.sin(time * 0.008) * 0.1 : 0.3; + ctx.beginPath(); + ctx.moveTo(x - tileWidth * 0.2, y - 2); + ctx.lineTo(x + tileWidth * 0.2, y - height + 2); + ctx.stroke(); + ctx.restore(); + ctx.shadowBlur = 0; + ctx.fillStyle = active ? '#dfffff' : '#d7bdff'; + ctx.font = '700 14px monospace'; + ctx.textAlign = 'center'; + ctx.fillText(id, x, y - height * 0.46); +} + +function drawExtraction(point, time) { + const { x, y, tileWidth } = point; + ctx.save(); + ctx.translate(x, y - tileWidth * 0.42); + ctx.scale(1, 0.43); + for (let ring = 0; ring < 4; ring += 1) { + ctx.beginPath(); + ctx.arc(0, 0, tileWidth * (0.15 + ring * 0.07) + Math.sin(time * 0.008 + ring) * 2, 0, Math.PI * 2); + ctx.strokeStyle = `hsla(${52 + ring * 18}, 100%, 70%, ${0.62 - ring * 0.1})`; + ctx.lineWidth = 2; + ctx.shadowBlur = 16; + ctx.shadowColor = '#fff39c'; + ctx.stroke(); + } + ctx.restore(); + ctx.shadowBlur = 0; +} + +function drawEntity(position, kind, time, index = 0) { + const point = project(position); + const bob = Math.sin(time * 0.005 + index) * 5; + const color = kind === 'echo' ? '#ff62d1' : '#64faff'; + const core = kind === 'echo' ? '#ffe4fa' : '#ecffff'; + ctx.save(); + ctx.translate(point.x, point.y - point.tileWidth * 0.55 + bob); + ctx.scale(1, 0.42); + ctx.beginPath(); + ctx.arc(0, point.tileWidth * 0.55 / 0.42, point.tileWidth * 0.18, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(0,0,0,.32)'; + ctx.fill(); + ctx.restore(); + + const radius = point.tileWidth * (kind === 'echo' ? 0.16 : 0.19); + const gradient = ctx.createRadialGradient(point.x - radius * 0.25, point.y - point.tileWidth * 0.55 + bob - radius * 0.25, 1, point.x, point.y - point.tileWidth * 0.55 + bob, radius); + gradient.addColorStop(0, core); + gradient.addColorStop(0.35, color); + gradient.addColorStop(1, kind === 'echo' ? 'rgba(255,98,209,.08)' : 'rgba(61,242,255,.08)'); + ctx.beginPath(); + ctx.arc(point.x, point.y - point.tileWidth * 0.55 + bob, radius, 0, Math.PI * 2); + ctx.fillStyle = gradient; + ctx.shadowBlur = kind === 'echo' ? 30 : 36; + ctx.shadowColor = color; + ctx.fill(); + ctx.shadowBlur = 0; + if (kind === 'echo') { + ctx.beginPath(); + ctx.arc(point.x, point.y - point.tileWidth * 0.55 + bob, radius * 1.45 + Math.sin(time * 0.008) * 2, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255, 161, 225, .52)'; + ctx.stroke(); + } +} + +function drawParticles(delta) { + particles = particles.filter((particle) => { + particle.age += delta; + particle.x += particle.vx * delta; + particle.y += particle.vy * delta; + particle.vy += 56 * delta; + const ratio = 1 - particle.age / particle.life; + if (ratio <= 0) return false; + ctx.fillStyle = particle.color; + ctx.globalAlpha = ratio * ratio; + ctx.fillRect(particle.x, particle.y, particle.size, particle.size); + ctx.globalAlpha = 1; + return true; + }); +} + +function drawBoard(time, delta) { + const level = game.level; + const activeGates = new Set(getActiveGateIds(game)); + const cells = []; + for (let y = 0; y < level.height; y += 1) { + for (let x = 0; x < level.width; x += 1) cells.push({ x, y, tile: level.grid[y][x] }); + } + cells.sort((left, right) => (left.x + left.y) - (right.x + right.y)); + + for (const cell of cells) { + const point = project(cell); + drawFloor(point, cell.tile); + if (cell.tile === '#') drawWall(point, point.tileWidth * 0.52); + } + + for (const [id, position] of level.pads) drawNode(project(position), id, activeGates.has(id.toUpperCase()), time); + for (const [id, position] of level.gates) drawGate(project(position), id, activeGates.has(id), time); + drawExtraction(project(level.goal), time); + + game.echoes.forEach((echo, index) => drawEntity(echo.position, 'echo', time, index)); + drawEntity(game.player, 'player', time, game.echoes.length); + drawParticles(delta); +} + +function frame(timestamp) { + const delta = Math.min((timestamp - lastFrame) / 1000 || 0, 0.05); + lastFrame = timestamp; + elapsed += delta; + drawBackground(timestamp); + drawBoard(timestamp, delta); + requestAnimationFrame(frame); +} + +function toggleMute() { + muted = !muted; + ui.mute.textContent = muted ? '◌ AUDIO: OFF' : '◌ AUDIO: ON'; + activeEvent = muted ? 'AUDIO LINK MUTED' : 'AUDIO LINK RESTORED'; + updateHud(); +} + +for (const button of document.querySelectorAll('[data-move]')) { + button.addEventListener('click', () => perform(button.dataset.move)); +} +for (const button of document.querySelectorAll('[data-action]')) { + button.addEventListener('click', () => { + const action = button.dataset.action; + if (action === 'mute') toggleMute(); + else if (action === 'help') ui.help.hidden = !ui.help.hidden; + else perform(action); + }); +} + +ui.nextLevel.addEventListener('click', () => { + ui.modal.hidden = true; + startLevel(levelIndex === LEVELS.length - 1 ? 0 : levelIndex + 1); +}); + +window.addEventListener('keydown', (event) => { + const keys = { + ArrowUp: 'U', ArrowDown: 'D', ArrowLeft: 'L', ArrowRight: 'R', + w: 'U', W: 'U', s: 'D', S: 'D', a: 'L', A: 'L', d: 'R', D: 'R', + ' ': 'anchor', z: 'undo', Z: 'undo', r: 'reset', R: 'reset', + }; + if (!(event.key in keys)) { + if (event.key === 'm' || event.key === 'M') toggleMute(); + if (event.key === 'h' || event.key === 'H') ui.help.hidden = !ui.help.hidden; + return; + } + event.preventDefault(); + perform(keys[event.key]); +}); + +window.__VANTA_TEST__ = { + getState: () => ({ + level: LEVELS[levelIndex].id, + player: { ...game.player }, + echoes: game.echoes.map((echo) => ({ id: echo.id, position: { ...echo.position } })), + gates: getActiveGateIds(game), + status: game.status, + moves: game.moves, + }), + act: (action) => perform(action), + loadLevel: (index) => startLevel(index), +}; + +startLevel(0); +requestAnimationFrame(frame); diff --git a/index.html b/index.html new file mode 100644 index 0000000..63c2611 --- /dev/null +++ b/index.html @@ -0,0 +1,199 @@ + + + + + + + + VANTA//RELAY — Echo puzzle + + + +
+
+
VANTA//RELAYa memory-routing experiment
+
NEURAL LINK STABLE
+
+ +
+ + +
+ + +
POS // 01.01CYCLE // 000
+
Walk onto a lower-case signal node, then press ANCHOR. Your new echo stays there while you restart.
AWAITING INPUT
+
+ + +
+
+ + + + + + + diff --git a/levels.js b/levels.js new file mode 100644 index 0000000..a38ab9c --- /dev/null +++ b/levels.js @@ -0,0 +1,84 @@ +export const LEVELS = [ + { + id: 'cold-boot', + code: '01 / COLD BOOT', + title: 'Wake the first witness', + briefing: 'The exit only remembers a body that is no longer there. Leave one echo on the amber node.', + accent: '#3df2ff', + map: [ + '#########', + '#@..aA.X#', + '#.#######', + '#.#######', + '#########', + ], + }, + { + id: 'prism-drift', + code: '02 / PRISM DRIFT', + title: 'Hold two impossible places', + briefing: 'One self is a memory. Two selves are an alibi. Sustain both locks at once.', + accent: '#ff62d1', + map: [ + '#############', + '#@..aA...B.X#', + '#.###########', + '#.....b######', + '#############', + ], + }, + { + id: 'cathedral-null', + code: '03 / CATHEDRAL NULL', + title: 'Outnumber the silence', + briefing: 'The vault was built for one mind. Fill three vacant memories and walk through its contradiction.', + accent: '#b48cff', + map: [ + '###############', + '#@..aA..B..C.X#', + '#.#############', + '#.....b########', + '#.#############', + '#.....c########', + '###############', + ], + }, +]; + +function key(position) { + return `${position.x},${position.y}`; +} + +function isWalkable(level, position, openGates) { + if (position.x < 0 || position.y < 0 || position.x >= level.width || position.y >= level.height) { + return false; + } + const tile = level.grid[position.y][position.x]; + if (tile === '#') return false; + return !level.gates.has(tile) || openGates.has(tile); +} + +export function canReachTarget(level, target, openGates = new Set()) { + const queue = [{ ...level.start }]; + const visited = new Set([key(level.start)]); + const vectors = [ + { x: 0, y: -1 }, + { x: 1, y: 0 }, + { x: 0, y: 1 }, + { x: -1, y: 0 }, + ]; + + while (queue.length > 0) { + const current = queue.shift(); + if (current.x === target.x && current.y === target.y) return true; + + for (const vector of vectors) { + const next = { x: current.x + vector.x, y: current.y + vector.y }; + if (!isWalkable(level, next, openGates) || visited.has(key(next))) continue; + visited.add(key(next)); + queue.push(next); + } + } + + return false; +} diff --git a/package.json b/package.json index dff5b6a..5861750 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,6 @@ "type": "module", "scripts": { "test": "node --test tests/*.test.mjs", - "check": "node --check game-engine.js" + "check": "node --check game-engine.js && node --check levels.js && node --check app.js" } } diff --git a/tests/levels.test.mjs b/tests/levels.test.mjs new file mode 100644 index 0000000..ec335f6 --- /dev/null +++ b/tests/levels.test.mjs @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { LEVELS, canReachTarget } from '../levels.js'; +import { parseLevel } from '../game-engine.js'; + +test('every authored relay has reachable signal pads before any echo is anchored', () => { + for (const definition of LEVELS) { + const level = parseLevel(definition.map); + for (const [padId, position] of level.pads) { + assert.equal( + canReachTarget(level, position, new Set()), + true, + `${definition.id}: pad ${padId} should be initially reachable`, + ); + } + } +}); + +test('each relay keeps extraction sealed until its full gate sequence is sustained', () => { + for (const definition of LEVELS) { + const level = parseLevel(definition.map); + const allGates = new Set(level.gates.keys()); + + assert.equal( + canReachTarget(level, level.goal, new Set()), + false, + `${definition.id}: extraction must not be reachable with all gates closed`, + ); + assert.equal( + canReachTarget(level, level.goal, allGates), + true, + `${definition.id}: extraction should be reachable with all gates active`, + ); + } +}); + +test('multi-gate relays preserve their intended staged locks', () => { + const double = parseLevel(LEVELS.find((level) => level.id === 'prism-drift').map); + const triple = parseLevel(LEVELS.find((level) => level.id === 'cathedral-null').map); + + assert.equal(canReachTarget(double, double.goal, new Set(['A'])), false); + assert.equal(canReachTarget(double, double.goal, new Set(['A', 'B'])), true); + + assert.equal(canReachTarget(triple, triple.goal, new Set(['A'])), false); + assert.equal(canReachTarget(triple, triple.goal, new Set(['A', 'B'])), false); + assert.equal(canReachTarget(triple, triple.goal, new Set(['A', 'B', 'C'])), true); +}); diff --git a/tests/shell.test.mjs b/tests/shell.test.mjs new file mode 100644 index 0000000..69ea0c6 --- /dev/null +++ b/tests/shell.test.mjs @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const root = new URL('../', import.meta.url); +const readProjectFile = (name) => readFileSync(new URL(name, root), 'utf8'); + +test('the playable shell uses a local canvas entry point and no external runtime assets', () => { + const html = readProjectFile('index.html'); + + assert.match(html, /]+id="relay-canvas"/); + assert.match(html, /<\/script>/); + assert.match(html, /data-action="anchor"/); + assert.doesNotMatch(html, /<(?:script|link|img)\b[^>]+(?:src|href)\s*=\s*["']https?:\/\//i); +}); + +test('the runtime exposes a narrow smoke-test surface without network calls', () => { + const app = readProjectFile('app.js'); + + assert.match(app, /window\.__VANTA_TEST__/); + assert.doesNotMatch(app, /\bfetch\s*\(|XMLHttpRequest|WebSocket/); +});