// Labyrinth — pure game engine. No Phaser, no rendering, no timers. Every // mutator deep-clones the state and returns the next one, so the scene and the // AI can freely look ahead. A turn is two steps: INSERT the spare tile (after // optionally rotating it), then MOVE your pawn along connected corridors. import { GRID, DELTA, OPPOSITE, openSides, isOpen, FIXED, isFixed, buildMovableBag, TREASURES, TREASURE_COUNT, HOME_CORNERS, PLAYER_COLORS, PLAYER_COLOR_HEX, SLOTS, reverseSlotId, } from './LabyrinthData.js'; // ── tiny seedable RNG (deterministic when a seed is supplied) ──────────────── function makeRng(seed) { if (seed == null) return Math.random; let a = seed >>> 0; return function () { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function shuffle(arr, rng) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } const keyOf = (r, c) => r * GRID + c; // ── clone ──────────────────────────────────────────────────────────────────── function cloneTile(t) { return t ? { type: t.type, rot: t.rot, treasure: t.treasure } : t; } export function cloneState(s) { return { board: s.board.map((row) => row.map(cloneTile)), spare: cloneTile(s.spare), players: s.players.map((p) => ({ ...p, home: { ...p.home }, targets: [...p.targets] })), current: s.current, phase: s.phase, lastSlotId: s.lastSlotId, blockedSlotId: s.blockedSlotId, winner: s.winner, playerCount: s.playerCount, }; } // ── setup ──────────────────────────────────────────────────────────────────── export function createInitialState({ playerCount = 4, names = [], seed = null } = {}) { const rng = makeRng(seed); const n = Math.max(2, Math.min(4, playerCount)); // Empty board, then stamp the fixed skeleton. const board = Array.from({ length: GRID }, () => new Array(GRID).fill(null)); for (const f of FIXED) { board[f.r][f.c] = { type: f.type, rot: f.rot, treasure: f.treasure ?? null }; } // Shuffle the movable bag, give each a random rotation, and fill the open // cells in reading order; the leftover tile is the starting spare. const bag = shuffle(buildMovableBag(), rng).map((t) => ({ type: t.type, rot: Math.floor(rng() * 4), treasure: t.treasure, })); let bi = 0; for (let r = 0; r < GRID; r++) { for (let c = 0; c < GRID; c++) { if (isFixed(r, c)) continue; board[r][c] = bag[bi++]; } } const spare = bag[bi++]; // Deal the 24 treasures evenly as ordered, hidden target stacks. const deck = shuffle(Array.from({ length: TREASURE_COUNT }, (_, i) => i), rng); const per = Math.floor(TREASURE_COUNT / n); const players = []; for (let seat = 0; seat < n; seat++) { const home = HOME_CORNERS[seat]; players.push({ seat, name: names[seat] ?? `Player ${seat + 1}`, color: PLAYER_COLORS[seat], colorHex: PLAYER_COLOR_HEX[seat], home: { ...home }, r: home.r, c: home.c, targets: deck.slice(seat * per, seat * per + per), targetIdx: 0, }); } return { board, spare, players, current: 0, phase: 'insert', lastSlotId: null, blockedSlotId: null, winner: null, playerCount: n, }; } // ── queries ────────────────────────────────────────────────────────────────── export function currentPlayer(state) { return state.players[state.current]; } export function currentTarget(p) { return p.targetIdx < p.targets.length ? p.targets[p.targetIdx] : null; } export function allCollected(p) { return p.targetIdx >= p.targets.length; } export function targetsRemaining(p) { return p.targets.length - p.targetIdx; } export function isGameOver(state) { return state.phase === 'over'; } export function winner(state) { return state.winner; } // Slots that are legal this turn (every slot except the one that would directly // reverse the previous insertion). export function legalSlots(state) { return SLOTS.filter((sl) => sl.id !== state.blockedSlotId); } // Where a treasure currently sits on the board, or null if it's on the spare. export function findTreasure(state, idx) { for (let r = 0; r < GRID; r++) { for (let c = 0; c < GRID; c++) { if (state.board[r][c].treasure === idx) return { r, c }; } } return null; } // All cells reachable from (sr,sc) along connected corridors, including the // start. Two adjacent tiles connect when each has an opening on their shared // side. export function reachableFrom(state, sr, sc) { const b = state.board; const seen = new Set([keyOf(sr, sc)]); const out = [{ r: sr, c: sc }]; const stack = [{ r: sr, c: sc }]; while (stack.length) { const { r, c } = stack.pop(); const t = b[r][c]; for (const side of openSides(t.type, t.rot)) { const { dr, dc } = DELTA[side]; const nr = r + dr, nc = c + dc; if (nr < 0 || nr >= GRID || nc < 0 || nc >= GRID) continue; const nt = b[nr][nc]; if (!isOpen(nt.type, nt.rot, OPPOSITE[side])) continue; const k = keyOf(nr, nc); if (seen.has(k)) continue; seen.add(k); out.push({ r: nr, c: nc }); stack.push({ r: nr, c: nc }); } } return out; } export function isReachable(state, sr, sc, tr, tc) { return reachableFrom(state, sr, sc).some((q) => q.r === tr && q.c === tc); } // BFS shortest path from (sr,sc) to (tr,tc) along connected corridors. // Returns the path as [{r,c}…] including both endpoints, or [{r:sr,c:sc}] if // unreachable. The caller must ensure (tr,tc) is actually reachable. export function pathTo(state, sr, sc, tr, tc) { if (sr === tr && sc === tc) return [{ r: sr, c: sc }]; const b = state.board; const prev = new Map(); const seen = new Set([keyOf(sr, sc)]); const queue = [{ r: sr, c: sc }]; let found = false; outer: while (queue.length) { const { r, c } = queue.shift(); for (const side of openSides(b[r][c].type, b[r][c].rot)) { const { dr, dc } = DELTA[side]; const nr = r + dr, nc = c + dc; if (nr < 0 || nr >= GRID || nc < 0 || nc >= GRID) continue; if (!isOpen(b[nr][nc].type, b[nr][nc].rot, OPPOSITE[side])) continue; const k = keyOf(nr, nc); if (seen.has(k)) continue; seen.add(k); prev.set(k, { r, c }); if (nr === tr && nc === tc) { found = true; break outer; } queue.push({ r: nr, c: nc }); } } if (!found) return [{ r: sr, c: sc }]; const path = []; let pos = { r: tr, c: tc }; for (;;) { path.unshift(pos); if (pos.r === sr && pos.c === sc) break; pos = prev.get(keyOf(pos.r, pos.c)); if (!pos) break; } return path; } // ── mutators ───────────────────────────────────────────────────────────────── export function rotateSpare(state, dir = 1) { const s = cloneState(state); if (s.phase !== 'insert') return s; s.spare.rot = (s.spare.rot + (dir > 0 ? 1 : 3)) % 4; return s; } export function withSpareRot(state, rot) { const s = cloneState(state); s.spare.rot = ((rot % 4) + 4) % 4; return s; } // Push the spare into a slot: slide the affected row/column, wrap any pawn that // rides off the far edge back onto the newly-inserted tile, and turn the // ejected far tile into the new spare. Mutates `s` in place. function shiftLine(s, slot) { const b = s.board; const spare = s.spare; let ejected; const last = GRID - 1; if (slot.side === 'top' || slot.side === 'bottom') { const c = slot.index; const col = b.map((row) => row[c]); if (slot.side === 'top') { ejected = col[last]; const nc = [spare, ...col.slice(0, last)]; for (let r = 0; r < GRID; r++) b[r][c] = nc[r]; for (const p of s.players) if (p.c === c) p.r = p.r === last ? 0 : p.r + 1; } else { ejected = col[0]; const nc = [...col.slice(1), spare]; for (let r = 0; r < GRID; r++) b[r][c] = nc[r]; for (const p of s.players) if (p.c === c) p.r = p.r === 0 ? last : p.r - 1; } } else { const r = slot.index; const row = b[r]; if (slot.side === 'left') { ejected = row[last]; b[r] = [spare, ...row.slice(0, last)]; for (const p of s.players) if (p.r === r) p.c = p.c === last ? 0 : p.c + 1; } else { ejected = row[0]; b[r] = [...row.slice(1), spare]; for (const p of s.players) if (p.r === r) p.c = p.c === 0 ? last : p.c - 1; } } s.spare = ejected; } export function applyInsertion(state, slotId) { const s = cloneState(state); if (s.phase !== 'insert') return s; if (!legalSlots(s).some((sl) => sl.id === slotId)) return s; const slot = SLOTS.find((sl) => sl.id === slotId); shiftLine(s, slot); s.lastSlotId = slotId; s.blockedSlotId = reverseSlotId(slot); // next player can't shove it straight back s.phase = 'move'; return s; } // Claim the player's current target if standing on its tile, advancing their // hidden stack. Mutates the player. function claimIfPossible(s, p) { const target = currentTarget(p); if (target == null) return false; if (s.board[p.r][p.c].treasure === target) { p.targetIdx++; return true; } return false; } export function applyMove(state, r, c) { const s = cloneState(state); if (s.phase !== 'move') return s; const p = s.players[s.current]; if (!isReachable(s, p.r, p.c, r, c)) return s; // illegal — ignore p.r = r; p.c = c; claimIfPossible(s, p); if (allCollected(p) && p.r === p.home.r && p.c === p.home.c) { s.phase = 'over'; s.winner = p.seat; return s; } s.current = (s.current + 1) % s.players.length; s.phase = 'insert'; return s; } // Uniform entry point used by the AI driver. `action` is one of: // { type:'insert', slotId, rot? } { type:'move', r, c } { type:'rotate', dir } export function applyAction(state, action) { if (action.type === 'insert') { const s = action.rot != null ? withSpareRot(state, action.rot) : state; return applyInsertion(s, action.slotId); } if (action.type === 'move') return applyMove(state, action.r, action.c); if (action.type === 'rotate') return rotateSpare(state, action.dir); return state; }