// Forbidden Island — pure state engine. No Phaser. The whole game is a // cooperative race against the board: players take 3 actions, draw 2 Treasure // cards, then the island floods. Everyone wins or loses together. // // State is treated as immutable from the outside: every mutator returns a fresh // cloned state (the BlokusLogic idiom). Randomness is seeded so games are // reproducible and unit-testable. import { CELLS, TILES, TREASURES, TREASURE_KEYS, ROLES, SPECIAL, buildTreasureDeck, floodDrawCount, DIFFICULTY, CARDS_TO_CAPTURE, HAND_LIMIT, ACTIONS_PER_TURN, MAX_WATER, } from './IslandData.js'; const ORTH = [[-1, 0], [1, 0], [0, -1], [0, 1]]; const DIAG = [[-1, -1], [-1, 1], [1, -1], [1, 1]]; // ---- seeded RNG (mulberry32) ---------------------------------------------- function rngFrom(seedState) { let a = seedState >>> 0; return () => { 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; }; } // Advance the stored rng state deterministically by `n` draws and shuffle. function shuffle(arr, rng) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } // We keep an explicit integer rng cursor on the state so clones stay // deterministic. Each helper pulls a fresh generator seeded by (seed + cursor) // and bumps the cursor by how many values it consumed. function makeRng(state) { const rng = rngFrom((state.seed + state.rngCursor * 2654435761) >>> 0); let calls = 0; return { next: () => { calls++; return rng(); }, commit: () => { state.rngCursor += calls + 1; }, }; } // ---- construction ---------------------------------------------------------- export function createInitialState({ roles, difficulty = 'normal', seed, humanSeat = 0, skipInitialFlood = false } = {}) { const roleKeys = (roles && roles.length) ? roles.slice(0, 4) : ['pilot', 'engineer', 'navigator', 'diver']; const s = { seed: (seed ?? Math.floor(Math.random() * 1e9)) >>> 0, rngCursor: 0, difficulty, tiles: {}, players: [], treasureDeck: [], treasureDiscard: [], floodDeck: [], floodDiscard: [], waterLevel: DIFFICULTY[difficulty]?.start ?? 2, current: 0, actionsLeft: ACTIONS_PER_TURN, phase: 'actions', // actions | discard | flood | won | lost pendingDiscard: null, // seat that must discard pendingSwim: null, // { seat, options } a pawn forced to leave a sunk tile priorities: { focusTreasure: null, saveTiles: [], regroup: false, hold: false }, lossReason: null, log: [], }; const rng = makeRng(s); // Assign the 24 tiles to the 24 board cells. const shuffledTiles = shuffle(TILES, rng.next); CELLS.forEach(([r, c], i) => { const t = shuffledTiles[i]; s.tiles[t.id] = { id: t.id, name: t.name, r, c, state: 'dry', treasure: t.treasure ?? null, landing: !!t.landing, }; }); // Pawns: everyone starts on Fools' Landing (safe, central rally point). roleKeys.forEach((rk, seat) => { s.players.push({ seat, role: rk, color: ROLES[rk].color, tileId: 'fools-landing', hand: [], captured: {}, // treasureKey -> true isHuman: seat === humanSeat, pilotFlew: false, }); }); // Treasure deck — deal 2 to each player, reshuffling any Waters Rise! back in. let deck = shuffle(buildTreasureDeck(), rng.next); for (const p of s.players) { let need = 2; while (need > 0) { const card = deck.shift(); if (card === SPECIAL.WATERS_RISE) { deck.push(card); deck = shuffle(deck, rng.next); continue; } p.hand.push(card); need--; } } s.treasureDeck = deck; // Flood deck — shuffle one card per tile; flood the first 6 (or defer for animation). s.floodDeck = shuffle(Object.keys(s.tiles), rng.next); if (skipInitialFlood) { s.pendingFlood = s.floodDeck.splice(0, 6); } else { for (let i = 0; i < 6; i++) { const id = s.floodDeck.shift(); s.tiles[id].state = 'flooded'; s.floodDiscard.push(id); } s.pendingFlood = []; } rng.commit(); s.log.push({ kind: 'setup' }); return s; } // Finalise deferred initial flood after the animation completes. export function applyPendingFlood(state) { const s = cloneState(state); for (const id of (s.pendingFlood ?? [])) { s.tiles[id].state = 'flooded'; s.floodDiscard.push(id); } s.pendingFlood = []; return s; } // Preview which tile IDs the next flood phase will draw (in order), without // modifying state. Uses the same RNG seeding as resolveFlood so reshuffles match. export function peekFloodDraw(state) { const count = floodDrawCount(state.waterLevel); let deck = state.floodDeck.slice(); let discard = state.floodDiscard.slice(); const rng = makeRng(state); const ids = []; for (let i = 0; i < count; i++) { if (deck.length === 0) { if (discard.length === 0) break; deck = shuffle(discard, rng.next); discard = []; } ids.push(deck.shift()); } return ids; } // Preview all cards that will be drawn from the treasure deck during endActions // (including any Waters Rise cards, which don't count toward the draw-2 quota). // Uses the same RNG seeding as endActions so reshuffles match exactly. export function peekTreasureDraw(state) { let deck = state.treasureDeck.slice(); let discard = state.treasureDiscard.slice(); const rng = makeRng(state); const cards = []; let drawn = 0; while (drawn < 2) { if (deck.length === 0) { if (discard.length === 0) break; deck = shuffle(discard, rng.next); discard = []; } const card = deck.shift(); cards.push(card); if (card !== SPECIAL.WATERS_RISE) drawn++; } return cards; } export function cloneState(state) { return { ...state, tiles: Object.fromEntries(Object.entries(state.tiles).map(([k, t]) => [k, { ...t }])), players: state.players.map((p) => ({ ...p, hand: p.hand.slice(), captured: { ...p.captured } })), treasureDeck: state.treasureDeck.slice(), treasureDiscard: state.treasureDiscard.slice(), floodDeck: state.floodDeck.slice(), floodDiscard: state.floodDiscard.slice(), priorities: { ...state.priorities, saveTiles: state.priorities.saveTiles.slice(), }, pendingSwim: state.pendingSwim ? { ...state.pendingSwim, options: state.pendingSwim.options.slice() } : null, pendingFlood: (state.pendingFlood ?? []).slice(), log: state.log.slice(), }; } // ---- geometry / adjacency -------------------------------------------------- function cellIndex(state) { const map = {}; for (const t of Object.values(state.tiles)) map[`${t.r},${t.c}`] = t.id; return map; } export function adjacentTiles(state, tileId, { diagonal = false } = {}) { const t = state.tiles[tileId]; if (!t) return []; const map = cellIndex(state); const dirs = diagonal ? [...ORTH, ...DIAG] : ORTH; const out = []; for (const [dr, dc] of dirs) { const id = map[`${t.r + dr},${t.c + dc}`]; if (id) out.push(id); } return out; } function isSunk(state, id) { return state.tiles[id].state === 'sunk'; } function isFlooded(state, id) { return state.tiles[id].state === 'flooded'; } // Tiles a Diver can reach: any non-sunk tile reachable by stepping through // flooded/sunk tiles (orthogonally) from the start. function diverDestinations(state, startId) { const start = state.tiles[startId]; const map = cellIndex(state); const seen = new Set([startId]); const dest = new Set(); const stack = [start]; while (stack.length) { const t = stack.pop(); for (const [dr, dc] of ORTH) { const id = map[`${t.r + dr},${t.c + dc}`]; if (!id || seen.has(id)) continue; seen.add(id); const nt = state.tiles[id]; if (nt.state === 'sunk') { stack.push(nt); } // swim through else { dest.add(id); if (nt.state === 'flooded') stack.push(nt); } // can stop, can continue through flooded } } dest.delete(startId); return [...dest]; } function moveDestinations(state, player) { const role = player.role; if (role === 'diver') return diverDestinations(state, player.tileId); const diagonal = role === 'explorer'; return adjacentTiles(state, player.tileId, { diagonal }).filter((id) => !isSunk(state, id)); } function shoreTargets(state, player) { const diagonal = player.role === 'explorer'; const here = player.tileId; const ids = [here, ...adjacentTiles(state, here, { diagonal })]; return [...new Set(ids)].filter((id) => isFlooded(state, id)); } // ---- legal actions --------------------------------------------------------- // Returns a flat list of action descriptors the actor may take this turn. export function legalActions(state, seat) { if (state.phase !== 'actions' || state.current !== seat || state.actionsLeft <= 0) return []; const p = state.players[seat]; const out = []; // Move for (const id of moveDestinations(state, p)) out.push({ type: 'move', seat, tileId: id }); // Pilot fly — once per turn, to any non-sunk tile. if (p.role === 'pilot' && !p.pilotFlew) { for (const t of Object.values(state.tiles)) { if (t.id !== p.tileId && t.state !== 'sunk') out.push({ type: 'fly', seat, tileId: t.id }); } } // Shore up const shoreable = shoreTargets(state, p); for (const id of shoreable) out.push({ type: 'shoreUp', seat, tiles: [id] }); // Engineer: shore two at once. if (p.role === 'engineer' && shoreable.length >= 2) { for (let i = 0; i < shoreable.length; i++) for (let j = i + 1; j < shoreable.length; j++) out.push({ type: 'shoreUp', seat, tiles: [shoreable[i], shoreable[j]] }); } // Capture a treasure. const tile = state.tiles[p.tileId]; if (tile.treasure && !p.captured[tile.treasure]) { const have = p.hand.filter((c) => c === `treasure:${tile.treasure}`).length; if (have >= CARDS_TO_CAPTURE) out.push({ type: 'capture', seat, treasure: tile.treasure }); } // Navigator: move another adventurer up to 2 tiles. if (p.role === 'navigator') { for (const other of state.players) { if (other.seat === seat) continue; for (const id of navDestinations(state, other)) out.push({ type: 'navMove', seat, targetSeat: other.seat, tileId: id }); } } return out; } function navDestinations(state, target) { // Up to two orthogonal steps through non-sunk tiles. const one = adjacentTiles(state, target.tileId).filter((id) => !isSunk(state, id)); const set = new Set(one); for (const id of one) for (const id2 of adjacentTiles(state, id).filter((x) => !isSunk(state, x))) set.add(id2); set.delete(target.tileId); return [...set]; } // ---- applying an action ---------------------------------------------------- export function applyAction(state, seat, action) { if (state.phase !== 'actions' || state.current !== seat || state.actionsLeft <= 0) return state; const s = cloneState(state); const p = s.players[seat]; switch (action.type) { case 'move': p.tileId = action.tileId; s.log.push({ kind: 'move', seat, tileId: action.tileId }); break; case 'fly': p.tileId = action.tileId; p.pilotFlew = true; s.log.push({ kind: 'fly', seat, tileId: action.tileId }); break; case 'shoreUp': for (const id of action.tiles) if (isFlooded(s, id)) s.tiles[id].state = 'dry'; s.log.push({ kind: 'shoreUp', seat, tiles: action.tiles.slice() }); break; case 'capture': { let removed = 0; p.hand = p.hand.filter((c) => { if (removed < CARDS_TO_CAPTURE && c === `treasure:${action.treasure}`) { removed++; s.treasureDiscard.push(c); return false; } return true; }); p.captured[action.treasure] = true; s.log.push({ kind: 'capture', seat, treasure: action.treasure }); break; } case 'navMove': s.players[action.targetSeat].tileId = action.tileId; s.log.push({ kind: 'navMove', seat, targetSeat: action.targetSeat, tileId: action.tileId }); break; default: return state; } s.actionsLeft -= 1; return checkLoss(s) ?? s; } // ---- special cards (free, any time) --------------------------------------- export function playSandbags(state, seat, tileId) { const p = state.players[seat]; if (!p.hand.includes(SPECIAL.SANDBAGS) || !isFlooded(state, tileId)) return state; const s = cloneState(state); s.players[seat].hand.splice(s.players[seat].hand.indexOf(SPECIAL.SANDBAGS), 1); s.tiles[tileId].state = 'dry'; s.treasureDiscard.push(SPECIAL.SANDBAGS); s.log.push({ kind: 'sandbags', seat, tileId }); return s; } export function playHelicopter(state, seat, pawnSeats, destTileId) { const p = state.players[seat]; if (!p.hand.includes(SPECIAL.HELICOPTER) || isSunk(state, destTileId)) return state; const s = cloneState(state); s.players[seat].hand.splice(s.players[seat].hand.indexOf(SPECIAL.HELICOPTER), 1); for (const ps of pawnSeats) s.players[ps].tileId = destTileId; s.treasureDiscard.push(SPECIAL.HELICOPTER); s.log.push({ kind: 'helicopter', seat, pawnSeats: pawnSeats.slice(), destTileId }); return s; } // Swap one card between two players — free action (no action cost, no tile check). export function swapCards(state, seatA, cardA, seatB, cardB) { if (seatA === seatB) return state; const pA = state.players[seatA]; const pB = state.players[seatB]; if (!pA || !pB) return state; const iA = pA.hand.indexOf(cardA); const iB = pB.hand.indexOf(cardB); if (iA < 0 || iB < 0) return state; const s = cloneState(state); s.players[seatA].hand[iA] = cardB; s.players[seatB].hand[iB] = cardA; s.log.push({ kind: 'swapCards', seatA, cardA, seatB, cardB }); return s; } export function canEscape(state) { const allCaptured = TREASURE_KEYS.every((k) => state.players.some((p) => p.captured[k])); const allOnLanding = state.players.every((p) => p.tileId === 'fools-landing'); const hasHeli = state.players.some((p) => p.hand.includes(SPECIAL.HELICOPTER)); return allCaptured && allOnLanding && hasHeli; } export function attemptEscape(state) { if (!canEscape(state)) return state; const s = cloneState(state); s.phase = 'won'; s.log.push({ kind: 'won' }); return s; } // ---- end of actions: draw treasure, then flood ---------------------------- export function endActions(state) { if (state.phase !== 'actions') return state; let s = cloneState(state); const p = s.players[s.current]; const rng = makeRng(s); let drawn = 0; while (drawn < 2) { if (s.treasureDeck.length === 0) { if (s.treasureDiscard.length === 0) break; s.treasureDeck = shuffle(s.treasureDiscard, rng.next); s.treasureDiscard = []; } const card = s.treasureDeck.shift(); if (card === SPECIAL.WATERS_RISE) { s.treasureDiscard.push(card); s.waterLevel = Math.min(MAX_WATER, s.waterLevel + 1); // Reshuffle the flood discard back on TOP of the flood deck. if (s.floodDiscard.length) { s.floodDeck = [...shuffle(s.floodDiscard, rng.next), ...s.floodDeck]; s.floodDiscard = []; } s.log.push({ kind: 'watersRise', waterLevel: s.waterLevel }); if (s.waterLevel >= MAX_WATER) { rng.commit(); s.phase = 'lost'; s.lossReason = 'The water level reached the skull.'; return s; } } else { p.hand.push(card); s.log.push({ kind: 'drawTreasure', seat: p.seat, card }); } drawn++; } rng.commit(); if (p.hand.length > HAND_LIMIT) { s.phase = 'discard'; s.pendingDiscard = p.seat; return s; } s.phase = 'flood'; return s; } export function discardCard(state, seat, cardId) { if (state.phase !== 'discard' || state.pendingDiscard !== seat) return state; const s = cloneState(state); const hand = s.players[seat].hand; const idx = hand.indexOf(cardId); if (idx < 0) return state; const [card] = hand.splice(idx, 1); if (card.startsWith('treasure:') || card === SPECIAL.SANDBAGS || card === SPECIAL.HELICOPTER) s.treasureDiscard.push(card); s.log.push({ kind: 'discard', seat, card }); if (s.players[seat].hand.length <= HAND_LIMIT) { s.phase = 'flood'; s.pendingDiscard = null; } return s; } // ---- flood phase ----------------------------------------------------------- // Draws flood cards equal to the water level, flooding/sinking tiles, then // auto-resolves forced swims and advances to the next player. Returns the new // state; inspect `state.log` (entries since the call) to animate. export function resolveFlood(state) { if (state.phase !== 'flood') return state; let s = cloneState(state); const count = floodDrawCount(s.waterLevel); const rng = makeRng(s); for (let i = 0; i < count; i++) { if (s.floodDeck.length === 0) { if (s.floodDiscard.length === 0) break; s.floodDeck = shuffle(s.floodDiscard, rng.next); s.floodDiscard = []; } const id = s.floodDeck.shift(); const tile = s.tiles[id]; if (tile.state === 'dry') { tile.state = 'flooded'; s.floodDiscard.push(id); s.log.push({ kind: 'flood', tileId: id }); } else if (tile.state === 'flooded') { tile.state = 'sunk'; s.log.push({ kind: 'sink', tileId: id }); // Card is removed from the game (not discarded) since the tile is gone. } } rng.commit(); // Forced swims for any pawn whose tile has sunk. for (const p of s.players) { if (isSunk(s, p.tileId)) { const opts = swimOptions(s, p); if (opts.length === 0) { s.phase = 'lost'; s.lossReason = `${ROLES[p.role].name} was lost beneath the waves.`; return s; } // Auto-swim to the safest option (nearest dry tile, else nearest tile). p.tileId = bestSwim(s, opts); s.log.push({ kind: 'swim', seat: p.seat, tileId: p.tileId }); } } const lost = checkLoss(s); if (lost) return lost; // Advance to next player. advanceTurn(s); return s; } function swimOptions(state, player) { if (player.role === 'diver') return diverDestinations(state, player.tileId); const diagonal = player.role === 'explorer' || player.role === 'pilot'; return adjacentTiles(state, player.tileId, { diagonal: player.role === 'explorer' }) .filter((id) => !isSunk(state, id)); } function bestSwim(state, opts) { const dry = opts.filter((id) => state.tiles[id].state === 'dry'); const pool = dry.length ? dry : opts; // Prefer the tile closest to Fools' Landing. const fl = state.tiles['fools-landing']; if (!fl || isSunk(state, 'fools-landing')) return pool[0]; let best = pool[0], bestD = Infinity; for (const id of pool) { const t = state.tiles[id]; const d = Math.abs(t.r - fl.r) + Math.abs(t.c - fl.c); if (d < bestD) { bestD = d; best = id; } } return best; } function advanceTurn(state) { state.current = (state.current + 1) % state.players.length; state.actionsLeft = ACTIONS_PER_TURN; state.phase = 'actions'; state.players[state.current].pilotFlew = false; } // ---- loss conditions ------------------------------------------------------- // Returns a `lost`-phase clone if the game is over, else null. Safe to call on // any state. export function checkLoss(state) { // Fools' Landing sunk. if (isSunk(state, 'fools-landing')) { const s = cloneState(state); s.phase = 'lost'; s.lossReason = "Fools' Landing sank — there is no way off the island."; return s; } // A treasure became unreachable (both tiles sunk) and was never captured. for (const key of TREASURE_KEYS) { const captured = state.players.some((p) => p.captured[key]); if (captured) continue; const bothSunk = TREASURES[key].tiles.every((id) => isSunk(state, id)); if (bothSunk) { const s = cloneState(state); s.phase = 'lost'; s.lossReason = `${TREASURES[key].name} was lost forever as its temples sank.`; return s; } } if (state.waterLevel >= MAX_WATER) { const s = cloneState(state); s.phase = 'lost'; s.lossReason = 'The water level reached the skull.'; return s; } return null; } export function isGameOver(state) { return state.phase === 'won' || state.phase === 'lost'; } // ---- human-set strategy hints --------------------------------------------- export function setPriority(state, patch) { const s = cloneState(state); s.priorities = { ...s.priorities, ...patch, saveTiles: patch.saveTiles ?? s.priorities.saveTiles }; return s; } // ---- progress helpers (for UI / AI) --------------------------------------- export function capturedCount(state) { return TREASURE_KEYS.filter((k) => state.players.some((p) => p.captured[k])).length; } export function handTreasureCounts(player) { const counts = {}; for (const k of TREASURE_KEYS) counts[k] = 0; for (const c of player.hand) if (c.startsWith('treasure:')) counts[c.slice('treasure:'.length)]++; return counts; }