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