// Bloxorz ASCII map parser + level design gates. Shared by genBloxorz.js (which // builds the bank) and by scratch/tuning scripts, so a level can be audited on // its own without running the whole generator. Pure Node, no Phaser. // // See genBloxorz.js for the map legend. import { loadLevel, solve } from '../src/games/bloxorz/BloxorzLogic.js'; // ── Map parser ─────────────────────────────────────────────────────────────── const BRIDGE_CHARS = '123'; const HEAVY_CHARS = 'ABC'; const SOFT_CHARS = 'abc'; const HOLD_CHARS = 'pqr'; const TELEPORT_CHARS = 'tuv'; export function parseMap(n, name, art, opts = {}) { const raw = art.replace(/^\n/, '').replace(/\s+$/, '').split('\n'); const indent = Math.min(...raw.filter((l) => l.trim()).map((l) => l.match(/^ */)[0].length)); const lines = raw.map((l) => l.slice(indent)); const rows = lines.length; const cols = Math.max(...lines.map((l) => l.length)); const openGroups = new Set(opts.open ?? []); const tiles = []; let start = null; let goal = null; let switchSeq = 0; const linksFor = (ch, fallback) => (opts.links?.[ch] ?? fallback).map((g) => `b${g}`); for (let y = 0; y < rows; y++) { for (let x = 0; x < lines[y].length; x++) { const ch = lines[y][x]; if (ch === ' ') continue; if (ch === 'S') { start = { x, y, orient: 'up' }; tiles.push({ x, y, type: 'floor' }); continue; } if (ch === 'G') { goal = { x, y }; tiles.push({ x, y, type: 'goal' }); continue; } if (ch === '.') { tiles.push({ x, y, type: 'floor' }); continue; } if (ch === ':') { tiles.push({ x, y, type: 'floor', split: true }); continue; } if (ch === '#') { tiles.push({ x, y, type: 'wall' }); continue; } if (ch === '~') { tiles.push({ x, y, type: 'fragile' }); continue; } if (BRIDGE_CHARS.includes(ch)) { const group = BRIDGE_CHARS.indexOf(ch) + 1; tiles.push({ x, y, type: 'bridge', bridgeId: `b${group}`, initiallyOpen: openGroups.has(group) }); continue; } if (TELEPORT_CHARS.includes(ch)) { tiles.push({ x, y, type: 'teleport', teleportId: ch }); continue; } const asSwitch = (chars, requireOrientation, mode) => { const group = chars.indexOf(ch) + 1; switchSeq += 1; tiles.push({ x, y, type: 'switch', switchId: `s${switchSeq}`, linkedBridgeIds: linksFor(ch, [group]), requireOrientation, mode, }); }; if (HEAVY_CHARS.includes(ch)) { asSwitch(HEAVY_CHARS, 'standing', 'toggle'); continue; } if (SOFT_CHARS.includes(ch)) { asSwitch(SOFT_CHARS, 'any', 'toggle'); continue; } if (HOLD_CHARS.includes(ch)) { asSwitch(HOLD_CHARS, 'lying', 'momentary'); continue; } throw new Error(`L${n} "${name}": unknown map character '${ch}' at ${x},${y}`); } } if (!start) throw new Error(`L${n} "${name}": no start (S)`); if (!goal) throw new Error(`L${n} "${name}": no goal (G)`); const teleports = new Map(); for (const t of tiles) { if (t.type === 'teleport') teleports.set(t.teleportId, (teleports.get(t.teleportId) ?? 0) + 1); } for (const [id, count] of teleports) { if (count !== 2) throw new Error(`L${n} "${name}": teleport '${id}' appears ${count}x, must be exactly 2`); } const splitCount = tiles.filter((t) => t.split).length; if (splitCount % 2 !== 0) throw new Error(`L${n} "${name}": odd number of split tiles (${splitCount})`); const bridgeGroups = new Set(tiles.filter((t) => t.type === 'bridge').map((t) => t.bridgeId)); const linked = new Map(); for (const t of tiles) { if (t.type !== 'switch') continue; for (const bid of t.linkedBridgeIds) { if (!linked.has(bid)) linked.set(bid, new Set()); linked.get(bid).add(t.mode); } } for (const bid of bridgeGroups) { if (!linked.has(bid)) throw new Error(`L${n} "${name}": bridge ${bid} has no switch`); if (linked.get(bid).size > 1) throw new Error(`L${n} "${name}": bridge ${bid} mixes toggle and momentary switches`); } for (const bid of linked.keys()) { if (!bridgeGroups.has(bid)) throw new Error(`L${n} "${name}": switch wired to ${bid}, which has no bridge tiles`); } return { level: n, name, cols, rows, start, goal, tiles }; } // ── Design gates ───────────────────────────────────────────────────────────── const SOLVE_OPTS = { maxStates: 400000 }; const clone = (def) => JSON.parse(JSON.stringify(def)); export function parOf(def) { return solve(loadLevel(def), SOLVE_OPTS).moves; } // A mechanic that the level can be finished without is just decoration. Each // gate strips one mechanic and demands the level become unsolvable. function withoutTiles(def, pred) { const out = clone(def); out.tiles = out.tiles.filter((t) => !pred(t)); return out; } function mapTiles(def, fn) { const out = clone(def); out.tiles = out.tiles.map(fn); return out; } export function auditLevel(def) { const problems = []; const notes = []; const has = (pred) => def.tiles.some(pred); const par = parOf(def); if (par < 0) { problems.push('unsolvable'); return { par, problems, notes }; } if (has((t) => t.type === 'bridge')) { // Bridges gone: the far side must be genuinely cut off. if (parOf(withoutTiles(def, (t) => t.type === 'bridge')) >= 0) problems.push('bridges are optional'); } if (has((t) => t.type === 'teleport')) { if (parOf(mapTiles(def, (t) => (t.type === 'teleport' ? { x: t.x, y: t.y, type: 'floor' } : t))) >= 0) { problems.push('teleporters are optional'); } } if (has((t) => t.split)) { if (parOf(mapTiles(def, (t) => (t.split ? { ...t, split: false } : t))) >= 0) problems.push('split is optional'); } if (has((t) => t.type === 'fragile')) { // Fragile ground has to be load-bearing (you must cross it) AND has to // actually constrain the route, or it is just orange-coloured floor. if (parOf(withoutTiles(def, (t) => t.type === 'fragile')) >= 0) problems.push('fragile ground is optional'); const solid = parOf(mapTiles(def, (t) => (t.type === 'fragile' ? { x: t.x, y: t.y, type: 'floor' } : t))); if (solid === par) notes.push('fragile ground does not change the optimal route'); } if (has((t) => t.type === 'wall')) { const noWalls = parOf(withoutTiles(def, (t) => t.type === 'wall')); if (noWalls === par) notes.push('walls do not change the optimal route'); } return { par, problems, notes }; }