fertig-classic-games/public/src/games/splendor/SplendorLogic.js

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