585 lines
20 KiB
JavaScript
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;
|
|
}
|