feat: add Splendor board game support

- Register Splendor in server game registry with metadata
- Import and register SplendorGame in frontend main.js
- Map 'splendor' slug to SplendorGame in GameRoomScene
- Preload splendor-cards spritesheet in PreloadScene
- Update game-icons assets for the new game
This commit is contained in:
Brian Fertig 2026-06-02 12:12:20 -06:00
parent 7858263c22
commit 6a33bf500b
10 changed files with 1183 additions and 1 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

View File

@ -0,0 +1,99 @@
// Splendor AI — synchronous, in-browser heuristic. 1-ply greedy over a board
// evaluation, with a skill model (15) that adds blunders / score noise and
// picks from a widening top-N, exactly the shape used by Connect4AI / IslandAI.
import { GEMS, GOLD, WIN_POINTS } from './SplendorData.js';
import { legalActions, applyAction, purchaseCost } from './SplendorLogic.js';
const SKILL_PROFILES = {
1: { topN: 5, blunder: 0.45, noise: 40, delay: [700, 1200] },
2: { topN: 4, blunder: 0.25, noise: 22, delay: [650, 1100] },
3: { topN: 3, blunder: 0.12, noise: 12, delay: [600, 1000] },
4: { topN: 2, blunder: 0.04, noise: 5, delay: [520, 900] },
5: { topN: 1, blunder: 0.00, noise: 0, delay: [440, 820] },
};
function profileFor(skill) {
return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3];
}
export function nextThinkDelay(skill) {
const [lo, hi] = profileFor(skill).delay;
return lo + Math.random() * (hi - lo);
}
// Coloured tokens (excluding gold) still required for `card` after bonuses and
// the player's current tokens — i.e. how far off buying it they are.
function shortfall(p, card) {
let s = 0;
for (const color of GEMS) {
s += Math.max(0, (card.cost[color] ?? 0) - (p.bonuses[color] ?? 0) - p.tokens[color]);
}
return Math.max(0, s - p.tokens[GOLD]);
}
// Evaluate the whole position from `seat`'s perspective (higher = better).
function evalPlayer(state, seat) {
const p = state.players[seat];
let v = p.points * 100;
// Permanent engine: each card bonus is lasting buying power; reward breadth.
let bonusSum = 0, distinct = 0;
for (const g of GEMS) { bonusSum += p.bonuses[g]; if (p.bonuses[g] > 0) distinct++; }
v += bonusSum * 9 + distinct * 4;
// Tokens are minor liquid value; gold (wild) is worth a touch more.
for (const g of GEMS) v += p.tokens[g] * 1.0;
v += p.tokens[GOLD] * 1.6;
// Progress toward the nobles still on the table.
for (const n of state.nobles) {
let prog = 0;
for (const [color, req] of Object.entries(n.requires)) {
prog += Math.min(p.bonuses[color] ?? 0, req);
}
v += prog * 2;
}
// Reach: how close is the best high-value card we could aim for?
let best = 0;
const targets = [];
for (const t of [1, 2, 3]) for (const c of state.board[t]) if (c) targets.push(c);
for (const c of p.reserved) targets.push(c);
for (const c of targets) {
const reach = c.points * 6 - shortfall(p, c) * 1.5;
if (reach > best) best = reach;
}
v += best;
// Mild nudge to close out the game when in front.
if (p.points >= WIN_POINTS) v += 500;
return v;
}
// Returns a single action object for the current player, or { type: 'pass' }.
export function chooseAction(state, skill) {
const prof = profileFor(skill);
const seat = state.current;
const legal = legalActions(state);
if (legal.length === 0) return { type: 'pass' };
if (legal.length === 1) return legal[0];
const ranked = legal
.map((action) => {
let score = evalPlayer(applyAction(state, action), seat);
score += (Math.random() * 2 - 1) * prof.noise;
return { action, score };
})
.sort((a, b) => b.score - a.score);
if (Math.random() < prof.blunder) {
return legal[Math.floor(Math.random() * legal.length)];
}
const pool = ranked.slice(0, Math.max(1, prof.topN));
return pool[Math.floor(Math.random() * pool.length)].action;
}
// Re-export so the scene can resolve AI / quick discards without importing Logic
// for that one helper.
export { defaultDiscards } from './SplendorLogic.js';

View File

@ -0,0 +1,139 @@
// Splendor — static catalog. No Phaser, no state, no mutations.
// The full development-card deck (90 cards across 3 tiers), the nobles, the gem
// palette, and the spritesheet frame mapping all live here. The Logic and Game
// modules import from this file; nothing here imports back.
// Gem colours. Order is fixed and used everywhere (token bank, costs, frames).
export const GEMS = ['white', 'blue', 'green', 'red', 'black'];
export const GOLD = 'gold';
// Display palette for vector-drawn tokens / pips / bonus badges. Chosen to read
// clearly on the dark felt; `black` is lightened so it stays visible.
export const GEM_HEX = {
white: 0xe9e4d6,
blue: 0x2f6fb0,
green: 0x3f9b54,
red: 0xc0392b,
black: 0x3c3a36,
gold: 0xd4a017,
};
// A contrasting edge colour so same-on-same (e.g. white token on light) reads.
export const GEM_EDGE = {
white: 0x9c9684,
blue: 0x1b4c80,
green: 0x2a6e3a,
red: 0x8a2419,
black: 0x16140f,
gold: 0x8a6a0c,
};
export const GEM_NAME = {
white: 'Diamond', blue: 'Sapphire', green: 'Emerald', red: 'Ruby', black: 'Onyx', gold: 'Gold',
};
// Rules constants.
export const WIN_POINTS = 15;
export const HAND_LIMIT = 10; // max tokens held at end of a turn
export const MAX_RESERVED = 3;
export const FACE_UP_PER_TIER = 4;
export const TAKE_SAME_MIN = 4; // pile must have ≥4 to take 2 of one colour
export const NOBLE_POINTS = 3;
// Per-colour token supply scales with player count; gold is always 5.
export function tokenSupplyFor(playerCount) {
const per = playerCount <= 2 ? 4 : playerCount === 3 ? 5 : 7;
const bank = {};
for (const g of GEMS) bank[g] = per;
bank[GOLD] = 5;
return bank;
}
// ── Development-card deck ────────────────────────────────────────────────────
// Built from the real Splendor structure: each bonus colour gets a fixed set of
// cards per tier (8 / 6 / 4), with costs assigned to the *other* colours by a
// cyclic offset, then rotated across all five colours. This reproduces the
// official counts (T1=40, T2=30, T3=20 = 90) and a balanced cost/point spread.
//
// A template's `c` maps an offset (1..4 = the next colours after the bonus, in
// GEMS order, wrapping) to a token cost. `p` is the prestige points.
const TIER_TEMPLATES = {
1: [
{ p: 0, c: { 1: 1, 2: 1, 3: 1, 4: 1 } },
{ p: 0, c: { 1: 2, 2: 1, 3: 1, 4: 1 } },
{ p: 0, c: { 1: 2, 2: 2, 4: 1 } },
{ p: 0, c: { 2: 1, 3: 3, 4: 1 } },
{ p: 0, c: { 1: 2, 4: 2 } },
{ p: 0, c: { 2: 3 } },
{ p: 0, c: { 1: 1, 2: 1, 3: 2, 4: 1 } },
{ p: 1, c: { 3: 4 } },
],
2: [
{ p: 1, c: { 1: 3, 2: 2, 3: 2 } },
{ p: 1, c: { 2: 3, 3: 3, 4: 2 } },
{ p: 2, c: { 1: 5 } },
{ p: 2, c: { 3: 5, 4: 3 } },
{ p: 2, c: { 1: 2, 2: 3, 4: 3 } },
{ p: 3, c: { 2: 6 } },
],
3: [
{ p: 3, c: { 1: 3, 2: 5, 3: 3, 4: 3 } },
{ p: 4, c: { 2: 6, 3: 3, 4: 3 } },
{ p: 4, c: { 1: 7 } },
{ p: 5, c: { 1: 7, 4: 3 } },
],
};
function buildCards() {
const cards = [];
let frame = 0;
for (const tier of [1, 2, 3]) {
for (let ci = 0; ci < GEMS.length; ci++) {
const bonus = GEMS[ci];
for (const tpl of TIER_TEMPLATES[tier]) {
const cost = {};
for (const [offsetStr, amount] of Object.entries(tpl.c)) {
const color = GEMS[(ci + Number(offsetStr)) % GEMS.length];
cost[color] = (cost[color] ?? 0) + amount;
}
cards.push({ id: `c${frame}`, frame, tier, bonus, points: tpl.p, cost });
frame++;
}
}
}
return cards;
}
// Flat catalog, ordered tier1(all colours) → tier2 → tier3. `frame` === array index.
export const CARDS = buildCards();
export const CARDS_BY_TIER = {
1: CARDS.filter((c) => c.tier === 1),
2: CARDS.filter((c) => c.tier === 2),
3: CARDS.filter((c) => c.tier === 3),
};
// ── Nobles ───────────────────────────────────────────────────────────────────
// 10 nobles; each needs a set of card bonuses and awards 3 prestige.
const NOBLE_REQS = [
{ white: 4, blue: 4 },
{ blue: 4, green: 4 },
{ green: 4, red: 4 },
{ red: 4, black: 4 },
{ black: 4, white: 4 },
{ white: 3, blue: 3, green: 3 },
{ blue: 3, green: 3, red: 3 },
{ green: 3, red: 3, black: 3 },
{ red: 3, black: 3, white: 3 },
{ black: 3, white: 3, blue: 3 },
];
export const NOBLES = NOBLE_REQS.map((requires, i) => ({
id: `n${i}`, frame: i, points: NOBLE_POINTS, requires,
}));
// ── Spritesheet frame mapping (270×390 cells, row-major) ─────────────────────
// Layout authored later: 089 = development cards (catalog order),
// 9099 = nobles, 100102 = tier deck backs. Code falls back to vector drawing
// when the 'splendor-cards' texture is absent, so these are advisory until art
// exists.
export function cardFrame(card) { return card.frame; }
export function nobleFrame(noble) { return 90 + noble.frame; }
export function deckBackFrame(tier) { return 100 + (tier - 1); }

View File

@ -0,0 +1,610 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { auth } from '../../services/auth.js';
import { api } from '../../services/api.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
GEMS, GOLD, GEM_HEX, GEM_EDGE, GEM_NAME, cardFrame, nobleFrame,
WIN_POINTS, MAX_RESERVED, TAKE_SAME_MIN,
} from './SplendorData.js';
import {
createInitialState, legalActions, applyAction, applyDiscard, defaultDiscards,
canAfford, tokenTotal, isGameOver, finalRanking, currentPlayer,
} from './SplendorLogic.js';
import { HAND_LIMIT } from './SplendorData.js';
import { chooseAction, nextThinkDelay } from './SplendorAI.js';
// ── Layout ───────────────────────────────────────────────────────────────────
const CW = 128, CH = 176, CARD_GAP = 16; // development card size
const DECK_W = 110;
const MARKET_X = 80; // left edge of decks
const CARDS_X = MARKET_X + DECK_W + 24; // first face-up card
const TIER_Y = { 3: 168, 2: 168 + CH + 26, 1: 168 + (CH + 26) * 2 };
const NOBLE = 96, NOBLE_Y = 56;
const BANK_X = 830, BANK_Y0 = 176, BANK_STEP = 88, TOKEN_R = 36;
const PANEL_X = 1036, PANEL_W = GAME_WIDTH - PANEL_X - 28;
const CONTROL_Y = 794; // bottom context bar
const DEPTH = { bg: 0, board: 10, card: 14, ui: 40, popup: 60, banner: 90 };
const hexStr = (n) => '#' + (n >>> 0).toString(16).padStart(6, '0').slice(-6);
export default class SplendorGame extends Phaser.Scene {
constructor() { super('SplendorGame'); }
init(data) {
this._initData = { ...data };
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.humanSeat = 0;
this.gs = null;
this.busy = false;
this.selection = []; // colours chosen for a take action
this.selectedCard = null; // { card, source } chosen to buy/reserve
this.dyn = []; // dynamic objects rebuilt every render
}
create() {
try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch { /* optional */ }
this.hasArt = this.textures.exists('splendor-cards');
this.buildBackground();
const playerCount = Math.max(2, Math.min(4, 1 + this.opponents.length));
this.skillBySeat = {};
const names = [];
for (let seat = 0; seat < playerCount; seat++) {
if (seat === this.humanSeat) {
names.push(auth.user?.username ?? 'You');
this.skillBySeat[seat] = 5;
} else {
const opp = this.opponents[seat - 1];
names.push(opp?.name ?? `Player ${seat}`);
this.skillBySeat[seat] = Math.max(1, Math.min(5, opp?.skill ?? 3));
}
}
this.gs = createInitialState({ playerCount, names });
this.render();
this.advance();
}
// ── static background / chrome ──────────────────────────────────────────────
buildBackground() {
const pf = this.playfield;
if (pf?.key && this.textures.exists(pf.key)) {
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
} else {
const g = this.add.graphics().setDepth(DEPTH.bg);
g.fillGradientStyle(0x14110b, 0x14110b, 0x070503, 0x070503, 1);
g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
}
this.add.text(GAME_WIDTH / 2, 26, 'Splendor', {
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex,
}).setOrigin(0.5, 0).setDepth(DEPTH.ui);
new Button(this, GAME_WIDTH - 96, 40, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 140, height: 42, fontSize: 18 }).setDepth(DEPTH.ui);
}
// ── render: rebuild all dynamic visuals from this.gs ─────────────────────────
reg(obj) { this.dyn.push(obj); return obj; }
clearDyn() {
for (const o of this.dyn) { try { o.destroy(); } catch { /* noop */ } }
this.dyn = [];
}
render() {
this.clearDyn();
this.drawNobles();
this.drawMarket();
this.drawBank();
this.drawPanels();
this.drawControlBar();
this.drawTurnLabel();
}
// ── nobles ──────────────────────────────────────────────────────────────────
drawNobles() {
this.gs.nobles.forEach((n, i) => {
const x = MARKET_X + i * (NOBLE + 14);
const g = this.reg(this.add.graphics().setDepth(DEPTH.card));
g.fillStyle(0x2b2620, 1).fillRoundedRect(x, NOBLE_Y, NOBLE, NOBLE, 10);
g.lineStyle(2, COLORS.accent, 0.8).strokeRoundedRect(x, NOBLE_Y, NOBLE, NOBLE, 10);
if (this.hasArt) {
this.reg(this.add.image(x + NOBLE / 2, NOBLE_Y + NOBLE / 2, 'splendor-cards', nobleFrame(n))
.setDisplaySize(NOBLE, NOBLE).setDepth(DEPTH.card + 1));
}
// prestige
this.reg(this.add.text(x + 8, NOBLE_Y + 4, '3', {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.goldHex,
}).setDepth(DEPTH.card + 2));
// requirement chips along the bottom
const reqs = Object.entries(n.requires);
reqs.forEach(([color, req], j) => {
const cx = x + 16 + j * 30, cy = NOBLE_Y + NOBLE - 18;
const c = this.reg(this.add.graphics().setDepth(DEPTH.card + 2));
c.fillStyle(GEM_HEX[color], 1).fillCircle(cx, cy, 11);
c.lineStyle(2, GEM_EDGE[color], 1).strokeCircle(cx, cy, 11);
this.reg(this.add.text(cx, cy, String(req), {
fontFamily: 'Righteous', fontSize: '14px',
color: color === 'white' ? '#222' : '#fff',
}).setOrigin(0.5).setDepth(DEPTH.card + 3));
});
});
}
// ── market ──────────────────────────────────────────────────────────────────
drawMarket() {
for (const tier of [3, 2, 1]) {
const y = TIER_Y[tier];
// deck pile
const deckN = this.gs.decks[tier].length;
const dg = this.reg(this.add.graphics().setDepth(DEPTH.card));
dg.fillStyle(tier === 3 ? 0x2a3550 : tier === 2 ? 0x3a4a2a : 0x4a3a2a, 1)
.fillRoundedRect(MARKET_X, y, DECK_W, CH, 10);
dg.lineStyle(2, 0x000000, 0.6).strokeRoundedRect(MARKET_X, y, DECK_W, CH, 10);
this.reg(this.add.text(MARKET_X + DECK_W / 2, y + 30, '★'.repeat(tier), {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.card + 1));
this.reg(this.add.text(MARKET_X + DECK_W / 2, y + CH - 26, `${deckN}`, {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.card + 1));
if (deckN > 0 && this.isHumanTurn()) {
const zone = this.reg(this.add.zone(MARKET_X + DECK_W / 2, y + CH / 2, DECK_W, CH)
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.card + 2));
zone.on('pointerdown', () => this.onDeckClick(tier));
}
// face-up cards
this.gs.board[tier].forEach((card, i) => {
if (!card) return;
const x = CARDS_X + i * (CW + CARD_GAP);
this.drawCard(card, x, y, 'board');
});
}
}
drawCard(card, x, y, source) {
const human = this.gs.players[this.humanSeat];
const affordable = this.isHumanTurn() && canAfford(human, card);
const selected = this.selectedCard && this.selectedCard.card.id === card.id;
if (this.hasArt) {
this.reg(this.add.image(x + CW / 2, y + CH / 2, 'splendor-cards', cardFrame(card))
.setDisplaySize(CW, CH).setDepth(DEPTH.card + 1));
} else {
const g = this.reg(this.add.graphics().setDepth(DEPTH.card + 1));
const tint = GEM_HEX[card.bonus];
g.fillStyle(0x14110b, 1).fillRoundedRect(x, y, CW, CH, 10);
g.fillStyle(tint, 0.20).fillRoundedRect(x, y, CW, CH, 10);
g.fillStyle(tint, 0.9).fillRoundedRect(x, y, CW, 30, { tl: 10, tr: 10, bl: 0, br: 0 });
g.lineStyle(2, GEM_EDGE[card.bonus], 1).strokeRoundedRect(x, y, CW, CH, 10);
// points
if (card.points > 0) {
this.reg(this.add.text(x + 10, y + 2, String(card.points), {
fontFamily: 'Righteous', fontSize: '26px',
color: card.bonus === 'white' ? '#222' : '#fff',
}).setDepth(DEPTH.card + 2));
}
// bonus gem badge (top-right)
const bg = this.reg(this.add.graphics().setDepth(DEPTH.card + 2));
bg.fillStyle(GEM_HEX[card.bonus], 1).fillCircle(x + CW - 22, y + 15, 12);
bg.lineStyle(2, GEM_EDGE[card.bonus], 1).strokeCircle(x + CW - 22, y + 15, 12);
// cost pips, bottom-left stacked
const costs = GEMS.filter((c) => (card.cost[c] ?? 0) > 0);
costs.forEach((c, i) => {
const cy = y + CH - 22 - i * 30;
const pg = this.reg(this.add.graphics().setDepth(DEPTH.card + 2));
pg.fillStyle(GEM_HEX[c], 1).fillCircle(x + 20, cy, 12);
pg.lineStyle(2, GEM_EDGE[c], 1).strokeCircle(x + 20, cy, 12);
this.reg(this.add.text(x + 20, cy, String(card.cost[c]), {
fontFamily: 'Righteous', fontSize: '15px',
color: c === 'white' ? '#222' : '#fff',
}).setOrigin(0.5).setDepth(DEPTH.card + 3));
});
}
// affordability / selection ring
if (selected || affordable) {
const ring = this.reg(this.add.graphics().setDepth(DEPTH.card + 4));
ring.lineStyle(4, selected ? COLORS.gold : 0x57c46a, selected ? 1 : 0.85)
.strokeRoundedRect(x - 3, y - 3, CW + 6, CH + 6, 12);
}
if (this.isHumanTurn()) {
const zone = this.reg(this.add.zone(x + CW / 2, y + CH / 2, CW, CH)
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.card + 5));
zone.on('pointerdown', () => this.onCardClick(card, source));
}
}
// ── token bank ──────────────────────────────────────────────────────────────
drawBank() {
const order = [...GEMS, GOLD];
order.forEach((color, i) => {
const cx = BANK_X, cy = BANK_Y0 + i * BANK_STEP;
const n = this.gs.bank[color];
const g = this.reg(this.add.graphics().setDepth(DEPTH.card));
g.fillStyle(GEM_HEX[color], n > 0 ? 1 : 0.25).fillCircle(cx, cy, TOKEN_R);
g.lineStyle(3, GEM_EDGE[color], 1).strokeCircle(cx, cy, TOKEN_R);
this.reg(this.add.text(cx, cy, String(n), {
fontFamily: 'Righteous', fontSize: '26px',
color: color === 'white' || color === 'gold' ? '#222' : '#fff',
}).setOrigin(0.5).setDepth(DEPTH.card + 1));
this.reg(this.add.text(cx + TOKEN_R + 14, cy, GEM_NAME[color], {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.card + 1));
// selection count overlay
const sel = this.selection.filter((c) => c === color).length;
if (sel > 0) {
this.reg(this.add.text(cx - TOKEN_R - 6, cy - TOKEN_R, `+${sel}`, {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(1, 0).setDepth(DEPTH.card + 2));
}
if (this.isHumanTurn() && color !== GOLD && n > 0) {
const zone = this.reg(this.add.zone(cx, cy, TOKEN_R * 2, TOKEN_R * 2)
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.card + 2));
zone.on('pointerdown', () => this.onTokenClick(color));
}
});
}
// ── player panels ───────────────────────────────────────────────────────────
drawPanels() {
const n = this.gs.players.length;
const gap = 16;
const h = Math.min(220, Math.floor((GAME_HEIGHT - 90 - gap * (n - 1)) / n));
this.gs.players.forEach((p, idx) => {
const x = PANEL_X, y = 76 + idx * (h + gap), w = PANEL_W;
const isCurrent = idx === this.gs.current && !isGameOver(this.gs);
const g = this.reg(this.add.graphics().setDepth(DEPTH.ui));
g.fillStyle(0x000000, 0.34).fillRoundedRect(x, y, w, h, 12);
g.lineStyle(isCurrent ? 3 : 1, isCurrent ? COLORS.gold : COLORS.accent, isCurrent ? 1 : 0.4)
.strokeRoundedRect(x, y, w, h, 12);
const youTag = idx === this.humanSeat ? ' (you)' : '';
this.reg(this.add.text(x + 16, y + 10, `${p.name}${youTag}`, {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
}).setDepth(DEPTH.ui + 1));
this.reg(this.add.text(x + w - 16, y + 8, `${p.points}`, {
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex,
}).setOrigin(1, 0).setDepth(DEPTH.ui + 1));
this.reg(this.add.text(x + w - 70, y + 18, 'pts', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(1, 0).setDepth(DEPTH.ui + 1));
// gem cells: cards (bonus) over tokens
const order = [...GEMS, GOLD];
const cellW = (w - 32) / order.length;
order.forEach((color, ci) => {
const ccx = x + 16 + cellW * ci + cellW / 2;
const ccy = y + 70;
const cards = color === GOLD ? 0 : (p.bonuses[color] ?? 0);
const toks = p.tokens[color] ?? 0;
const cg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
cg.fillStyle(GEM_HEX[color], 1).fillCircle(ccx, ccy, 16);
cg.lineStyle(2, GEM_EDGE[color], 1).strokeCircle(ccx, ccy, 16);
if (color !== GOLD) {
this.reg(this.add.text(ccx, ccy, String(cards), {
fontFamily: 'Righteous', fontSize: '16px',
color: color === 'white' ? '#222' : '#fff',
}).setOrigin(0.5).setDepth(DEPTH.ui + 2));
}
this.reg(this.add.text(ccx, ccy + 24, `${toks}`, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui + 2));
// discard interactivity (human, over limit)
const discardable = this.gs.phase === 'discard' && this.gs.current === this.humanSeat
&& idx === this.humanSeat && toks > 0;
if (discardable) {
const z = this.reg(this.add.zone(ccx, ccy + 8, cellW, 56)
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.ui + 3));
z.on('pointerdown', () => this.onDiscardClick(color));
}
});
// reserved cards
const rN = p.reserved.length;
this.reg(this.add.text(x + 16, y + h - 30, `Reserved: ${rN}/${MAX_RESERVED}`, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
}).setDepth(DEPTH.ui + 1));
if (idx === this.humanSeat && rN > 0) {
p.reserved.forEach((card, ri) => {
const rx = x + 150 + ri * 116, ry = y + h - 40;
const canBuy = this.isHumanTurn() && canAfford(p, card);
const mg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
mg.fillStyle(GEM_HEX[card.bonus], 0.85).fillRoundedRect(rx, ry, 104, 30, 6);
mg.lineStyle(2, canBuy ? 0x57c46a : GEM_EDGE[card.bonus], canBuy ? 1 : 0.8)
.strokeRoundedRect(rx, ry, 104, 30, 6);
this.reg(this.add.text(rx + 52, ry + 15,
`${card.points || ''} ${'★'.repeat(card.tier)}`.trim(), {
fontFamily: 'Righteous', fontSize: '14px',
color: card.bonus === 'white' ? '#222' : '#fff',
}).setOrigin(0.5).setDepth(DEPTH.ui + 2));
if (this.isHumanTurn()) {
const z = this.reg(this.add.zone(rx + 52, ry + 15, 104, 30)
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.ui + 3));
z.on('pointerdown', () => this.onCardClick(card, 'reserve'));
}
});
}
});
}
// ── bottom control bar ──────────────────────────────────────────────────────
drawControlBar() {
const x = MARKET_X, y = CONTROL_Y, w = 920, h = GAME_HEIGHT - y - 24;
const g = this.reg(this.add.graphics().setDepth(DEPTH.ui));
g.fillStyle(0x000000, 0.3).fillRoundedRect(x, y, w, h, 12);
g.lineStyle(1, COLORS.accent, 0.4).strokeRoundedRect(x, y, w, h, 12);
if (isGameOver(this.gs)) return;
// Discard prompt
if (this.gs.phase === 'discard' && this.gs.current === this.humanSeat) {
const over = tokenTotal(currentPlayer(this.gs)) - HAND_LIMIT;
this.reg(this.add.text(x + 20, y + 18,
`Over the ${HAND_LIMIT}-token limit — return ${over} token${over === 1 ? '' : 's'}.`, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.dangerHex,
}).setDepth(DEPTH.ui + 1));
this.reg(this.add.text(x + 20, y + 52, 'Click your tokens in the panel at right to return them.', {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex,
}).setDepth(DEPTH.ui + 1));
return;
}
if (!this.isHumanTurn()) {
this.reg(this.add.text(x + 20, y + 22, `${this.pname(this.gs.current)} is thinking…`, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setDepth(DEPTH.ui + 1));
return;
}
// Human turn — card selected → buy/reserve options
if (this.selectedCard) {
const { card, source } = this.selectedCard;
const human = this.gs.players[this.humanSeat];
this.reg(this.add.text(x + 20, y + 16,
`Tier ${card.tier}${GEM_NAME[card.bonus]}${card.points ? `${card.points} pts` : ''}`, {
fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.textHex,
}).setDepth(DEPTH.ui + 1));
let bx = x + 24;
if (canAfford(human, card)) {
this.addButton(bx + 80, y + 70, 'Buy', () => this.applyHuman({ type: 'buy', cardId: card.id, source }),
{ width: 150, height: 46 });
bx += 170;
}
if (source === 'board' && human.reserved.length < MAX_RESERVED) {
this.addButton(bx + 80, y + 70, 'Reserve (+1 gold)',
() => this.applyHuman({ type: 'reserve', cardId: card.id, tier: card.tier }),
{ width: 230, height: 46, variant: 'ghost' });
bx += 250;
}
this.addButton(bx + 70, y + 70, 'Cancel', () => { this.selectedCard = null; this.render(); },
{ width: 130, height: 46, variant: 'ghost' });
return;
}
// Token selection
if (this.selection.length > 0) {
this.reg(this.add.text(x + 20, y + 16, 'Taking gems:', {
fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.textHex,
}).setDepth(DEPTH.ui + 1));
this.selection.forEach((color, i) => {
const cx = x + 160 + i * 44;
const cg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
cg.fillStyle(GEM_HEX[color], 1).fillCircle(cx, y + 26, 16);
cg.lineStyle(2, GEM_EDGE[color], 1).strokeCircle(cx, y + 26, 16);
});
this.addButton(x + 110, y + 78, 'Take', () => this.confirmTake(), { width: 160, height: 44 });
this.addButton(x + 290, y + 78, 'Clear', () => { this.selection = []; this.render(); },
{ width: 140, height: 44, variant: 'ghost' });
return;
}
this.reg(this.add.text(x + 20, y + 26,
'Your turn — take gems (3 different, or 2 of one), or click a card to buy or reserve.', {
fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.textHex,
wordWrap: { width: w - 40 },
}).setDepth(DEPTH.ui + 1));
}
addButton(x, y, label, fn, opts = {}) {
const b = new Button(this, x, y, label, fn, { fontSize: 20, ...opts }).setDepth(DEPTH.ui + 2);
this.dyn.push(b);
return b;
}
drawTurnLabel() {
const txt = isGameOver(this.gs) ? 'Game over' : `${this.pname(this.gs.current)}'s turn`;
this.reg(this.add.text(BANK_X, GAME_HEIGHT - 70, txt, {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.ui + 1));
this.reg(this.add.text(BANK_X, GAME_HEIGHT - 40, `First to ${WIN_POINTS} prestige wins`, {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.ui + 1));
}
// ── helpers ─────────────────────────────────────────────────────────────────
isHumanTurn() {
return !this.busy && this.gs.phase === 'turn' && this.gs.current === this.humanSeat;
}
pname(seat) { return this.gs.players[seat]?.name ?? `Player ${seat}`; }
// ── human input ─────────────────────────────────────────────────────────────
onTokenClick(color) {
if (!this.isHumanTurn()) return;
this.selectedCard = null;
const sel = this.selection;
const sameCount = sel.filter((c) => c === color).length;
if (sel.length === 0) { sel.push(color); }
else if (sel.length === 1 && sel[0] === color) {
// second of the same → take2 (needs a pile of ≥4)
if (this.gs.bank[color] >= TAKE_SAME_MIN) sel.push(color);
} else if (sel.length < 3 && sameCount === 0) {
// a different colour, only if we aren't already on a take2
const isTake2 = sel.length === 2 && sel[0] === sel[1];
if (!isTake2) sel.push(color);
}
this.render();
}
confirmTake() {
const sel = this.selection;
if (sel.length === 0) return;
const action = (sel.length === 2 && sel[0] === sel[1])
? { type: 'take2', color: sel[0] }
: { type: 'take3', colors: [...new Set(sel)] };
// guard against an illegal combo
const ok = legalActions(this.gs).some((a) => this.sameAction(a, action));
if (!ok) { this.selection = []; this.render(); return; }
this.applyHuman(action);
}
sameAction(a, b) {
if (a.type !== b.type) return false;
if (a.type === 'take2') return a.color === b.color;
if (a.type === 'take3') {
const as = [...a.colors].sort().join(), bs = [...b.colors].sort().join();
return as === bs;
}
return false;
}
onCardClick(card, source) {
if (!this.isHumanTurn()) return;
this.selection = [];
this.selectedCard = { card, source };
this.render();
}
onDeckClick(tier) {
if (!this.isHumanTurn()) return;
if (this.gs.players[this.humanSeat].reserved.length >= MAX_RESERVED) return;
this.applyHuman({ type: 'reserveDeck', tier });
}
onDiscardClick(color) {
if (this.gs.phase !== 'discard' || this.gs.current !== this.humanSeat) return;
this.gs = applyDiscard(this.gs, { [color]: 1 });
if (this.gs.phase === 'turn' || isGameOver(this.gs)) {
// discard completed → the turn finalized
this.render();
this.advance();
} else {
this.render();
}
}
applyHuman(action) {
this.selectedCard = null;
this.selection = [];
this.gs = applyAction(this.gs, action);
this.playActionSound(action);
if (this.gs.phase === 'discard' && this.gs.current === this.humanSeat) {
this.render(); // hand over to the discard UI
return;
}
this.render();
this.advance();
}
// ── turn loop ───────────────────────────────────────────────────────────────
advance() {
if (isGameOver(this.gs)) { this.render(); this.onGameOver(); return; }
if (this.gs.phase === 'discard') {
// Only the human ever parks here interactively; AI auto-discards inline.
this.busy = false;
this.render();
return;
}
if (this.gs.current === this.humanSeat) {
this.busy = false;
this.render();
return;
}
this.aiTurn();
}
aiTurn() {
this.busy = true;
this.render();
const seat = this.gs.current;
const skill = this.skillBySeat[seat];
this.showTurnBanner(`${this.pname(seat)}'s turn`);
this.time.delayedCall(nextThinkDelay(skill), () => {
const action = chooseAction(this.gs, skill);
this.gs = applyAction(this.gs, action);
this.playActionSound(action);
if (this.gs.phase === 'discard') {
this.gs = applyDiscard(this.gs, defaultDiscards(this.gs));
}
this.busy = false;
this.render();
this.advance();
});
}
playActionSound(action) {
if (!action) return;
if (action.type === 'buy') playSound(this, SFX.PURCHASE);
else if (action.type === 'reserve' || action.type === 'reserveDeck') playSound(this, SFX.CARD_DEAL);
else if (action.type === 'take2' || action.type === 'take3') playSound(this, SFX.COINS);
}
showTurnBanner(text) {
const cx = GAME_WIDTH / 2;
const banner = this.add.text(cx, 130, text, {
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex,
backgroundColor: '#111923ee', padding: { x: 24, y: 10 },
}).setOrigin(0.5).setDepth(DEPTH.banner);
this.tweens.add({
targets: banner, y: 160, duration: 280, ease: 'Back.easeOut',
onComplete: () => this.time.delayedCall(800, () =>
this.tweens.add({ targets: banner, alpha: 0, y: 130, duration: 220, onComplete: () => banner.destroy() })),
});
}
// ── game over ───────────────────────────────────────────────────────────────
onGameOver() {
this.busy = true;
const ranking = finalRanking(this.gs);
const humanRank = ranking.findIndex((r) => r.seat === this.humanSeat);
const won = humanRank === 0;
const humanPoints = this.gs.players[this.humanSeat].points;
const oppScores = ranking.filter((r) => r.seat !== this.humanSeat).map((r) => r.points);
this.recordResult(won ? 'win' : 'loss', humanPoints, oppScores);
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const w = 640, h = 120 + ranking.length * 46;
this.add.rectangle(cx, cy, w, h, 0x0a0e14, 0.94).setStrokeStyle(3, COLORS.accent).setDepth(DEPTH.banner);
this.add.text(cx, cy - h / 2 + 30, won ? '🎉 You win!' : 'Game over', {
fontFamily: 'Righteous', fontSize: '34px', color: won ? COLORS.goldHex : COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.banner + 1);
ranking.forEach((r, i) => {
this.add.text(cx, cy - h / 2 + 80 + i * 40,
`${i + 1}. ${this.pname(r.seat)}${r.seat === this.humanSeat ? ' (you)' : ''}${r.points} pts (${r.cards} cards)`, {
fontFamily: '"Julius Sans One"', fontSize: '22px',
color: i === 0 ? COLORS.goldHex : COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.banner + 1);
});
new Button(this, cx - 110, cy + h / 2 - 36, 'Play Again', () => this.scene.restart(this._initData),
{ width: 190, fontSize: 22 }).setDepth(DEPTH.banner + 1);
new Button(this, cx + 110, cy + h / 2 - 36, 'Leave', () => this.scene.start('GameMenu'),
{ width: 190, fontSize: 22, variant: 'ghost' }).setDepth(DEPTH.banner + 1);
}
async recordResult(result, score, opponentScores) {
try {
await api.post('/history/single-player', { slug: 'splendor', score, opponentScores, result });
} catch { /* best effort */ }
}
}

View File

@ -0,0 +1,328 @@
// 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));
}

View File

@ -51,6 +51,7 @@ import SpellingBeeGame from './games/spellingbee/SpellingBeeGame.js';
import MiniCrosswordGame from './games/minicrossword/MiniCrosswordGame.js';
import ForbiddenIslandGame from './games/forbiddenisland/ForbiddenIslandGame.js';
import SolitaireTourGame from './games/solitairetour/SolitaireTourGame.js';
import SplendorGame from './games/splendor/SplendorGame.js';
const config = {
type: Phaser.AUTO,
@ -115,6 +116,7 @@ const config = {
MiniCrosswordGame,
ForbiddenIslandGame,
SolitaireTourGame,
SplendorGame,
],
};

View File

@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -98,6 +98,9 @@ export default class PreloadScene extends Phaser.Scene {
this.load.spritesheet('dominion-prosperity', '/assets/images/dominion-prosperity.png', { frameWidth: 270, frameHeight: 390 });
// Prosperity token sprites (1 VP, 5 VP, Gold) — 150×150 each.
this.load.spritesheet('dominion-tokens', '/assets/images/dominion-tokens.png', { frameWidth: 150, frameHeight: 150 });
// Splendor development cards + nobles. 270×390 cells; frame order documented
// in SplendorData.js. Optional — the scene draws vector cards when absent.
this.load.spritesheet('splendor-cards', '/assets/images/splendor-cards.png', { frameWidth: 270, frameHeight: 390 });
this.load.spritesheet('ttr-cards', '/assets/images/tickettoride-cards.png', { frameWidth: 270, frameHeight: 390 });
this.load.spritesheet('gofish-cards', '/assets/images/gofish-cards.png', { frameWidth: 270, frameHeight: 390 });
this.load.spritesheet('oldmaid-cards', '/assets/images/oldmaid-cards.png', { frameWidth: 270, frameHeight: 390 });

View File

@ -66,3 +66,4 @@ registerGame({ slug: 'spellingbee', name: 'Spelling Bee', category: 'w
registerGame({ slug: 'minicrossword', name: 'Mini Crossword', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 38 });
registerGame({ slug: 'forbiddenisland', name: 'Forbidden Island', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: false, iconFrame: 39 });
registerGame({ slug: 'solitairetour', name: 'Solitaire Tour', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 40 });
registerGame({ slug: 'splendor', name: 'Splendor', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 41 });