329 lines
12 KiB
JavaScript
329 lines
12 KiB
JavaScript
// Splendor — pure state engine. No Phaser, no network, no rendering.
|
|
//
|
|
// State is immutable from the outside: every mutator deep-clones and returns a
|
|
// fresh state (the IslandLogic/BlokusLogic idiom). Randomness is seeded so games
|
|
// are reproducible and testable. A turn is exactly one action (take3 / take2 /
|
|
// reserve / buy); if it leaves the player over the 10-token limit the state
|
|
// parks in a 'discard' phase until tokens are returned, then the turn finalizes
|
|
// (noble visit + end-of-game check + advance).
|
|
|
|
import {
|
|
GEMS, GOLD, CARDS_BY_TIER, NOBLES, tokenSupplyFor,
|
|
WIN_POINTS, HAND_LIMIT, MAX_RESERVED, FACE_UP_PER_TIER, TAKE_SAME_MIN, NOBLE_POINTS,
|
|
} from './SplendorData.js';
|
|
|
|
const TIERS = [1, 2, 3];
|
|
|
|
// ── seeded RNG (mulberry32), matching IslandLogic ────────────────────────────
|
|
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;
|
|
};
|
|
}
|
|
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;
|
|
}
|
|
|
|
const clone = (s) => JSON.parse(JSON.stringify(s));
|
|
const emptyTokens = () => ({ white: 0, blue: 0, green: 0, red: 0, black: 0, gold: 0 });
|
|
const emptyBonuses = () => ({ white: 0, blue: 0, green: 0, red: 0, black: 0 });
|
|
|
|
// ── queries ──────────────────────────────────────────────────────────────────
|
|
export function currentPlayer(state) { return state.players[state.current]; }
|
|
|
|
export function tokenTotal(p) {
|
|
return GEMS.reduce((n, g) => n + p.tokens[g], 0) + p.tokens[GOLD];
|
|
}
|
|
|
|
// How a player would pay for `card`: coloured tokens used per colour + gold to
|
|
// cover the shortfall after permanent bonuses.
|
|
export function purchaseCost(p, card) {
|
|
const pay = {};
|
|
let gold = 0;
|
|
for (const color of GEMS) {
|
|
const need = Math.max(0, (card.cost[color] ?? 0) - (p.bonuses[color] ?? 0));
|
|
const use = Math.min(need, p.tokens[color]);
|
|
pay[color] = use;
|
|
gold += need - use;
|
|
}
|
|
return { pay, gold };
|
|
}
|
|
|
|
export function canAfford(p, card) {
|
|
return purchaseCost(p, card).gold <= p.tokens[GOLD];
|
|
}
|
|
|
|
// Nobles the player currently qualifies for.
|
|
export function qualifyingNobles(state, p) {
|
|
return state.nobles.filter((n) =>
|
|
Object.entries(n.requires).every(([color, req]) => (p.bonuses[color] ?? 0) >= req));
|
|
}
|
|
|
|
export function cardById(state, id) {
|
|
for (const t of TIERS) {
|
|
const found = state.board[t].find((c) => c && c.id === id);
|
|
if (found) return found;
|
|
}
|
|
for (const p of state.players) {
|
|
const r = p.reserved.find((c) => c.id === id);
|
|
if (r) return r;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── construction ─────────────────────────────────────────────────────────────
|
|
export function createInitialState({ playerCount = 4, names = [], seed } = {}) {
|
|
const pc = Math.max(2, Math.min(4, playerCount));
|
|
const s = {
|
|
seed: (seed ?? Math.floor(Math.random() * 1e9)) >>> 0,
|
|
rngCursor: 0,
|
|
playerCount: pc,
|
|
bank: tokenSupplyFor(pc),
|
|
decks: {},
|
|
board: {},
|
|
nobles: [],
|
|
players: [],
|
|
current: 0,
|
|
phase: 'turn', // turn | discard | gameOver
|
|
pendingDiscardSeat: null,
|
|
triggeredEnd: false,
|
|
lastVisit: null, // { seat, nobleId } for the scene to animate
|
|
log: [],
|
|
};
|
|
|
|
const rng = rngFrom((s.seed + 0x9e3779b9) >>> 0);
|
|
for (const t of TIERS) {
|
|
const deck = shuffle(CARDS_BY_TIER[t], rng);
|
|
s.board[t] = deck.splice(0, FACE_UP_PER_TIER);
|
|
while (s.board[t].length < FACE_UP_PER_TIER) s.board[t].push(null);
|
|
s.decks[t] = deck;
|
|
}
|
|
s.nobles = shuffle(NOBLES, rng).slice(0, pc + 1);
|
|
|
|
for (let seat = 0; seat < pc; seat++) {
|
|
s.players.push({
|
|
seat,
|
|
name: names[seat] ?? (seat === 0 ? 'You' : `Player ${seat}`),
|
|
tokens: emptyTokens(),
|
|
bonuses: emptyBonuses(),
|
|
reserved: [],
|
|
nobles: [],
|
|
points: 0,
|
|
cardsCount: 0,
|
|
});
|
|
}
|
|
return s;
|
|
}
|
|
|
|
// ── legal actions ────────────────────────────────────────────────────────────
|
|
function combinations(arr, k) {
|
|
if (k === 0) return [[]];
|
|
if (arr.length < k) return [];
|
|
const [head, ...rest] = arr;
|
|
return [
|
|
...combinations(rest, k - 1).map((c) => [head, ...c]),
|
|
...combinations(rest, k),
|
|
];
|
|
}
|
|
|
|
export function legalActions(state) {
|
|
if (state.phase !== 'turn') return [];
|
|
const p = currentPlayer(state);
|
|
const actions = [];
|
|
|
|
// take 3 different (or as many distinct colours as the bank can offer, up to 3)
|
|
const avail = GEMS.filter((g) => state.bank[g] > 0);
|
|
const k = Math.min(3, avail.length);
|
|
if (k > 0) {
|
|
for (const combo of combinations(avail, k)) {
|
|
actions.push({ type: 'take3', colors: combo });
|
|
}
|
|
}
|
|
|
|
// take 2 of one colour (pile must have ≥4)
|
|
for (const g of GEMS) {
|
|
if (state.bank[g] >= TAKE_SAME_MIN) actions.push({ type: 'take2', color: g });
|
|
}
|
|
|
|
// reserve (face-up card or top of a deck) — gains 1 gold if any left
|
|
if (p.reserved.length < MAX_RESERVED) {
|
|
for (const t of TIERS) {
|
|
for (const c of state.board[t]) {
|
|
if (c) actions.push({ type: 'reserve', cardId: c.id, tier: t });
|
|
}
|
|
if (state.decks[t].length > 0) actions.push({ type: 'reserveDeck', tier: t });
|
|
}
|
|
}
|
|
|
|
// buy a face-up card or one of your reserved cards
|
|
for (const t of TIERS) {
|
|
for (const c of state.board[t]) {
|
|
if (c && canAfford(p, c)) actions.push({ type: 'buy', cardId: c.id, source: 'board' });
|
|
}
|
|
}
|
|
for (const c of p.reserved) {
|
|
if (canAfford(p, c)) actions.push({ type: 'buy', cardId: c.id, source: 'reserve' });
|
|
}
|
|
|
|
return actions;
|
|
}
|
|
|
|
// ── helpers used by applyAction ──────────────────────────────────────────────
|
|
function drawTop(s, tier) {
|
|
return s.decks[tier].length ? s.decks[tier].shift() : null;
|
|
}
|
|
|
|
function refillSlot(s, tier, cardId) {
|
|
const row = s.board[tier];
|
|
const idx = row.findIndex((c) => c && c.id === cardId);
|
|
if (idx >= 0) row[idx] = drawTop(s, tier);
|
|
}
|
|
|
|
// After tokens change, either park for discard or finalize the turn.
|
|
function afterAction(s) {
|
|
if (tokenTotal(currentPlayer(s)) > HAND_LIMIT) {
|
|
s.phase = 'discard';
|
|
s.pendingDiscardSeat = s.current;
|
|
return s;
|
|
}
|
|
return finalizeTurn(s);
|
|
}
|
|
|
|
// Noble visit, win trigger, advance to next seat.
|
|
function finalizeTurn(s) {
|
|
const p = currentPlayer(s);
|
|
const eligible = qualifyingNobles(s, p);
|
|
if (eligible.length) {
|
|
// Award the highest-requirement noble (ties: first). One visit per turn.
|
|
const noble = eligible[0];
|
|
s.nobles = s.nobles.filter((n) => n.id !== noble.id);
|
|
p.nobles.push(noble);
|
|
p.points += NOBLE_POINTS;
|
|
s.lastVisit = { seat: p.seat, nobleId: noble.id };
|
|
} else {
|
|
s.lastVisit = null;
|
|
}
|
|
|
|
if (p.points >= WIN_POINTS) s.triggeredEnd = true;
|
|
|
|
s.current = (s.current + 1) % s.playerCount;
|
|
s.phase = (s.triggeredEnd && s.current === 0) ? 'gameOver' : 'turn';
|
|
s.pendingDiscardSeat = null;
|
|
return s;
|
|
}
|
|
|
|
// ── apply a turn action ──────────────────────────────────────────────────────
|
|
export function applyAction(state, action) {
|
|
const s = clone(state);
|
|
if (s.phase !== 'turn') return s;
|
|
const p = currentPlayer(s);
|
|
|
|
switch (action.type) {
|
|
case 'take3': {
|
|
for (const g of action.colors) { s.bank[g]--; p.tokens[g]++; }
|
|
s.log.push(`${p.name} takes ${action.colors.join(', ')}.`);
|
|
return afterAction(s);
|
|
}
|
|
case 'take2': {
|
|
s.bank[action.color] -= 2; p.tokens[action.color] += 2;
|
|
s.log.push(`${p.name} takes 2 ${action.color}.`);
|
|
return afterAction(s);
|
|
}
|
|
case 'reserve': {
|
|
const card = cardById(s, action.cardId);
|
|
if (!card) return s;
|
|
refillSlot(s, action.tier, action.cardId);
|
|
p.reserved.push(card);
|
|
if (s.bank[GOLD] > 0) { s.bank[GOLD]--; p.tokens[GOLD]++; }
|
|
s.log.push(`${p.name} reserves a tier-${action.tier} card.`);
|
|
return afterAction(s);
|
|
}
|
|
case 'reserveDeck': {
|
|
const card = drawTop(s, action.tier);
|
|
if (!card) return s;
|
|
p.reserved.push(card);
|
|
if (s.bank[GOLD] > 0) { s.bank[GOLD]--; p.tokens[GOLD]++; }
|
|
s.log.push(`${p.name} reserves the top tier-${action.tier} card.`);
|
|
return afterAction(s);
|
|
}
|
|
case 'buy': {
|
|
const card = cardById(s, action.cardId);
|
|
if (!card || !canAfford(p, card)) return s;
|
|
const { pay, gold } = purchaseCost(p, card);
|
|
for (const color of GEMS) { p.tokens[color] -= pay[color]; s.bank[color] += pay[color]; }
|
|
p.tokens[GOLD] -= gold; s.bank[GOLD] += gold;
|
|
p.bonuses[card.bonus]++;
|
|
p.points += card.points;
|
|
p.cardsCount++;
|
|
if (action.source === 'reserve') {
|
|
p.reserved = p.reserved.filter((c) => c.id !== card.id);
|
|
} else {
|
|
refillSlot(s, card.tier, card.id);
|
|
}
|
|
s.log.push(`${p.name} buys a ${card.bonus} card${card.points ? ` (+${card.points})` : ''}.`);
|
|
return afterAction(s);
|
|
}
|
|
case 'pass':
|
|
// No legal action available (bank empty, nothing affordable, reserve full).
|
|
s.log.push(`${p.name} cannot act and passes.`);
|
|
return finalizeTurn(s);
|
|
default:
|
|
return s;
|
|
}
|
|
}
|
|
|
|
// ── discard phase ────────────────────────────────────────────────────────────
|
|
// Default choice: return the most-abundant coloured tokens first, never gold.
|
|
export function defaultDiscards(state) {
|
|
const p = currentPlayer(state);
|
|
const excess = tokenTotal(p) - HAND_LIMIT;
|
|
const map = {};
|
|
let left = Math.max(0, excess);
|
|
// Greedily shave from the largest non-gold piles.
|
|
const pools = GEMS.map((g) => ({ g, n: p.tokens[g] }));
|
|
while (left > 0) {
|
|
pools.sort((a, b) => (b.n - (map[b.g] ?? 0)) - (a.n - (map[a.g] ?? 0)));
|
|
const top = pools[0];
|
|
if ((top.n - (map[top.g] ?? 0)) <= 0) break;
|
|
map[top.g] = (map[top.g] ?? 0) + 1;
|
|
left--;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
export function applyDiscard(state, discardMap) {
|
|
const s = clone(state);
|
|
if (s.phase !== 'discard') return s;
|
|
const p = currentPlayer(s);
|
|
for (const [color, n] of Object.entries(discardMap)) {
|
|
const take = Math.min(n, p.tokens[color] ?? 0);
|
|
p.tokens[color] -= take;
|
|
s.bank[color] += take;
|
|
}
|
|
if (tokenTotal(p) > HAND_LIMIT) {
|
|
// Still over (incomplete discard) — stay parked.
|
|
return s;
|
|
}
|
|
s.phase = 'turn';
|
|
return finalizeTurn(s);
|
|
}
|
|
|
|
// ── end of game ──────────────────────────────────────────────────────────────
|
|
export function isGameOver(state) { return state.phase === 'gameOver'; }
|
|
|
|
// Ranking: most prestige, then fewest purchased cards (official tiebreak).
|
|
export function finalRanking(state) {
|
|
return state.players
|
|
.map((p) => ({ seat: p.seat, name: p.name, points: p.points, cards: p.cardsCount }))
|
|
.sort((a, b) => (b.points - a.points) || (a.cards - b.cards));
|
|
}
|