fertig-classic-games/public/src/games/forbiddenisland/IslandLogic.js

585 lines
20 KiB
JavaScript

// 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;
}