Added Skip Bo and Phase 10 and Yatzi. Updated Read Me. Added some sound effects.

This commit is contained in:
Brian Fertig 2026-05-17 09:24:30 -06:00
parent 56f1cdd752
commit 1996e255c3
24 changed files with 4782 additions and 3 deletions

View File

@ -55,8 +55,11 @@ The backend is Node.js + Express + Socket.IO with SQLite for persistence.
- A C/C++ toolchain for `better-sqlite3` and `bcrypt` to build native bindings:
- **Linux**: `build-essential`, `python3`
- **macOS**: Xcode command line tools (`xcode-select --install`)
- **Windows**: `npm install --global windows-build-tools` (older Windows)
or install Visual Studio Build Tools
- **Windows**: install **Visual Studio Build Tools 2022** with the
"Desktop development with C++" workload and **Python 3.x** (add to PATH).
Do **not** use the deprecated `windows-build-tools` npm package — it is
broken on modern Node.js. See [Troubleshooting](#troubleshooting) for
step-by-step instructions.
No bundler, no Docker, no external database required to get started.
@ -455,6 +458,33 @@ to an insecure default with a warning.
**`better-sqlite3` or `bcrypt` build failure on install** — install platform
build tools (see [Prerequisites](#prerequisites)) and re-run `npm install`.
**Windows build failure / `windows-build-tools` error** — the
`windows-build-tools` npm package is deprecated and broken on modern Node.js.
Use one of these approaches instead:
*Option 1 — Re-run the Node.js installer (easiest)*: Download the Node.js
installer from nodejs.org. On the "Tools for Native Modules" step, check the
box to automatically install Chocolatey, Python, and VS Build Tools.
*Option 2 — Manual install*:
1. Download **Visual Studio Build Tools 2022** from Microsoft and install it
with the **"Desktop development with C++"** workload selected.
2. Install **Python 3.x** from python.org, checking "Add to PATH" during
installation.
3. Open an Administrator PowerShell and run:
```powershell
npm install -g node-gyp
```
4. Re-run `npm install` in the project directory.
If you have the broken `windows-build-tools` package installed globally,
uninstall it first (run PowerShell as Administrator):
```powershell
npm uninstall -g windows-build-tools
```
If that fails with a permission error, manually delete
`C:\Users\<you>\AppData\Roaming\npm\node_modules\windows-build-tools`.
**Verification email never arrives** — if `SMTP_HOST` is empty, by design no
email is sent; check the server console for the dev link. If SMTP *is* set,
check provider auth (Gmail requires an app password, not your account

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,444 @@
// Phase 10 AI — heuristic single-action chooser.
//
// The caller invokes chooseAction(state) and receives one action; after
// applying it (with animation), call again. The turn ends when a 'discard'
// action is returned (or the round/match ends).
//
// Actions:
// { type: 'drawDeck' }
// { type: 'drawDiscard' }
// { type: 'laydown', groups: [{ kind, cardIds: [...] }, ...] }
// { type: 'hit', handIdx, targetSeat, groupIdx, position }
// { type: 'discard', handIdx, skipTargetSeat? }
import { getPhase } from './PhaseSpec.js';
import {
cardPoints,
discardTop,
getHitTargets,
} from './Phase10Logic.js';
export function chooseAction(state) {
if (state.roundPhase !== 'play') return null;
const seat = state.currentPlayer;
const player = state.players[seat];
// ── Step 1: draw ──
if (!state.drawnThisTurn) {
return chooseDraw(state);
}
// ── Step 2: lay down if possible ──
if (!player.laidDown) {
const layout = findLaydown(player.hand, player.phase);
if (layout) {
return {
type: 'laydown',
groups: layout.map((g) => ({ kind: g.kind, cardIds: g.cards.map((c) => c.id) })),
};
}
}
// ── Step 3: hit ──
if (player.laidDown) {
const hit = findBestHit(state);
if (hit) return hit;
}
// ── Step 4: discard ──
return chooseDiscard(state);
}
// ── Draw step ───────────────────────────────────────────────────────────────
function chooseDraw(state) {
const seat = state.currentPlayer;
const player = state.players[seat];
const top = discardTop(state);
if (top && top.value !== 'skip') {
// Take from discard if the top is immediately useful.
if (cardIsUseful(top, player)) return { type: 'drawDiscard' };
}
return { type: 'drawDeck' };
}
function cardIsUseful(card, player) {
if (player.laidDown) {
// We've already laid down — useful if it can hit any of our groups (cheap check).
for (const g of player.laidDown) {
if (g.kind === 'set') {
const v = g.cards.find((c) => c.value !== 'wild')?.value;
if (card.value === 'wild' || card.value === v) return true;
} else if (g.kind === 'color') {
const col = g.cards.find((c) => c.color)?.color;
if (card.value === 'wild' || card.color === col) return true;
} else if (g.kind === 'run') {
if (card.value === 'wild') return true;
// Allow a quick "could extend" check via low/high natural boundaries.
const nats = g.cards.filter((c) => c.value !== 'wild').map((c) => c.value);
if (nats.length === 0) return true;
const min = Math.min(...nats);
const max = Math.max(...nats);
if (card.value === min - 1 || card.value === max + 1) return true;
}
}
return false;
}
// Not laid down yet — useful if it brings us closer to our phase.
return progressContribution(card, player.hand, player.phase) > 0;
}
function progressContribution(card, hand, phaseNum) {
// Cheap heuristic: would adding this card *increase* the size of the best
// candidate group for the current phase? Wilds always help.
if (card.value === 'wild') return 1;
const spec = getPhase(phaseNum);
const before = findLaydown(hand, phaseNum) ? 2 : 0;
if (before === 2) return 0; // we're already complete; no need to take
const after = findLaydown([...hand, card], phaseNum) ? 2 : 0;
if (after > before) return 2;
// Set phases: same-value pairs are valuable.
const needsSet = spec?.groups.some((g) => g.kind === 'set');
if (needsSet) {
const matches = hand.filter((c) => c.value === card.value).length;
if (matches > 0) return 1;
}
// Run phases: card fills a gap in our densest run window.
const needsRun = spec?.groups.some((g) => g.kind === 'run');
if (needsRun) {
const runSize = spec.groups.find((g) => g.kind === 'run').count;
const valuesInHand = new Set(hand.filter((c) => typeof c.value === 'number').map((c) => c.value));
if (!valuesInHand.has(card.value) && typeof card.value === 'number') {
// Check whether this value lands within reach of a dense window.
for (let s = Math.max(1, card.value - runSize + 1); s <= Math.min(card.value, 13 - runSize); s++) {
let nats = 0;
for (let p = 0; p < runSize; p++) {
if (valuesInHand.has(s + p) || s + p === card.value) nats++;
}
if (nats >= Math.max(3, runSize - 4)) return 1;
}
}
}
// Color phase: matches our dominant color.
const needsColor = spec?.groups.some((g) => g.kind === 'color');
if (needsColor && card.color) {
const sameColor = hand.filter((c) => c.color === card.color).length;
const colorSize = spec.groups.find((g) => g.kind === 'color').count;
if (sameColor >= Math.max(3, colorSize - 4)) return 1;
}
return 0;
}
// ── Hit step ────────────────────────────────────────────────────────────────
function findBestHit(state) {
const seat = state.currentPlayer;
const hand = state.players[seat].hand;
if (hand.length === 0) return null;
let best = null;
let bestScore = -Infinity;
for (let h = 0; h < hand.length; h++) {
const card = hand[h];
if (card.value === 'skip') continue; // never hit with a Skip (and not legal anyway)
const targets = getHitTargets(state, card);
if (targets.length === 0) continue;
// Score: prefer hitting high-point cards first (we want them off our hand).
const score = cardPoints(card) + (card.value === 'wild' ? 5 : 0);
if (score > bestScore) {
bestScore = score;
const t = targets[0]; // any legal target works
best = { type: 'hit', handIdx: h, targetSeat: t.targetSeat, groupIdx: t.groupIdx, position: t.position };
}
}
return best;
}
// ── Discard step ────────────────────────────────────────────────────────────
function chooseDiscard(state) {
const seat = state.currentPlayer;
const player = state.players[seat];
const hand = player.hand;
// Identify any "reserved" cards we want to keep for next-turn laydown.
let reservedIds = new Set();
if (!player.laidDown) {
const layout = findLaydown(hand, player.phase);
if (layout) {
for (const g of layout) for (const c of g.cards) reservedIds.add(c.id);
} else {
// Reserve cards that show up in the best partial layout — keep duplicates
// of useful values from being discarded.
reservedIds = reserveLikelyKeepers(hand, player.phase);
}
}
// Choose card to discard.
// Priority: 1) Skip (with targeted opponent), 2) Wilds NEVER, 3) unreserved
// high-point natural, 4) any unreserved card, 5) reserved card.
// 1) If we hold a Skip, save it for now unless our hand is large and the
// opponent leader is close to going out.
const skipIdx = hand.findIndex((c) => c.value === 'skip');
if (skipIdx !== -1) {
const leader = pickSkipTarget(state, seat);
if (leader != null) {
// Use the Skip when an opponent is at ≤ 3 cards OR our hand is heavy.
const leaderHand = state.players[leader].hand.length;
if (leaderHand <= 3 || hand.length >= 8) {
return { type: 'discard', handIdx: skipIdx, skipTargetSeat: leader };
}
}
}
// Score every card; higher = more discardable.
let bestIdx = -1;
let bestScore = -Infinity;
for (let i = 0; i < hand.length; i++) {
const c = hand[i];
let s = cardPoints(c);
if (c.value === 'wild') s -= 1000; // never discard wilds
if (c.value === 'skip') s -= 500; // don't discard skips here (handled above)
if (reservedIds.has(c.id)) s -= 100;
if (s > bestScore) { bestScore = s; bestIdx = i; }
}
if (bestIdx === -1) bestIdx = 0;
const out = { type: 'discard', handIdx: bestIdx };
// If forced to discard a skip (no other choice), target someone valid.
if (hand[bestIdx].value === 'skip') {
const tgt = pickSkipTarget(state, seat);
if (tgt != null) out.skipTargetSeat = tgt;
else {
// No valid skip target — pick a different card.
for (let i = 0; i < hand.length; i++) {
if (hand[i].value !== 'skip' && hand[i].value !== 'wild') {
out.handIdx = i;
delete out.skipTargetSeat;
break;
}
}
}
}
return out;
}
function pickSkipTarget(state, seat) {
// Pick the opponent with the smallest hand; tie-break by furthest phase
// (most threatening) and then by lowest score.
let best = null;
let bestKey = null;
for (let s = 0; s < state.players.length; s++) {
if (s === seat) continue;
const p = state.players[s];
if (p.skipped) continue;
const key = [p.hand.length, -p.phase, p.score];
if (best === null || lex(key, bestKey) < 0) { best = s; bestKey = key; }
}
return best;
}
function lex(a, b) {
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return a[i] - b[i];
}
return 0;
}
function reserveLikelyKeepers(hand, phaseNum) {
// Mark cards that look critical to forming the phase. Wilds always reserved.
const out = new Set();
for (const c of hand) if (c.value === 'wild') out.add(c.id);
const spec = getPhase(phaseNum);
const needsSet = spec?.groups.some((g) => g.kind === 'set');
const needsRun = spec?.groups.some((g) => g.kind === 'run');
const needsColor = spec?.groups.some((g) => g.kind === 'color');
// Same-value clusters — important for sets and helpful elsewhere.
if (needsSet || !needsRun) {
const byValue = new Map();
for (const c of hand) {
if (typeof c.value !== 'number') continue;
const arr = byValue.get(c.value) ?? [];
arr.push(c);
byValue.set(c.value, arr);
}
for (const arr of byValue.values()) {
if (arr.length >= 2) for (const c of arr) out.add(c.id);
}
}
// Run-friendly reservation: find the longest near-consecutive window of
// distinct natural values and reserve those cards. Wilds can fill gaps.
if (needsRun) {
const runSize = spec.groups.find((g) => g.kind === 'run').count;
const numerics = hand.filter((c) => typeof c.value === 'number');
const wildCount = hand.filter((c) => c.value === 'wild').length;
// Distinct values present
const presentByVal = new Map();
for (const c of numerics) {
if (!presentByVal.has(c.value)) presentByVal.set(c.value, c);
}
// For each possible starting value, count how many naturals fit and how
// many wilds would be needed. Pick the best (most naturals using ≤ wilds).
let bestStart = -1;
let bestNaturals = -1;
for (let s = 1; s + runSize - 1 <= 12; s++) {
let nats = 0;
for (let p = 0; p < runSize; p++) {
if (presentByVal.has(s + p)) nats++;
}
const wildsNeeded = runSize - nats;
if (wildsNeeded > wildCount) continue;
if (nats > bestNaturals) { bestNaturals = nats; bestStart = s; }
}
if (bestStart !== -1) {
for (let p = 0; p < runSize; p++) {
const v = bestStart + p;
const card = presentByVal.get(v);
if (card) out.add(card.id);
}
}
}
// Color clusters for phase 8.
if (needsColor) {
const byColor = new Map();
for (const c of hand) {
if (!c.color) continue;
const arr = byColor.get(c.color) ?? [];
arr.push(c);
byColor.set(c.color, arr);
}
// Reserve every card of the dominant color.
let bestColor = null, bestN = 0;
for (const [col, arr] of byColor.entries()) {
if (arr.length > bestN) { bestN = arr.length; bestColor = col; }
}
if (bestColor) {
for (const c of byColor.get(bestColor)) out.add(c.id);
}
}
return out;
}
// ── findLaydown — brute force combo search ──────────────────────────────────
/**
* Try to build a complete laydown for `phaseNum` using cards from `hand`.
* Returns an array of { kind, cards: [...] } or null.
* Prefers candidates that use fewer wilds.
*/
export function findLaydown(hand, phaseNum) {
const spec = getPhase(phaseNum);
if (!spec) return null;
// Per spec-group, enumerate candidate card subsets (sorted by wild-use asc).
const perGroupCandidates = spec.groups.map((s) => enumerateGroupCandidates(hand, s));
// Backtracking combo search.
const used = new Set();
const out = [];
function recurse(idx) {
if (idx === spec.groups.length) {
// Must have at least one natural (non-wild) card across the whole laydown.
for (const g of out) for (const c of g.cards) {
if (c.value !== 'wild') return true;
}
return false;
}
for (const cand of perGroupCandidates[idx]) {
let overlap = false;
for (const c of cand.cards) {
if (used.has(c.id)) { overlap = true; break; }
}
if (overlap) continue;
for (const c of cand.cards) used.add(c.id);
out.push({ kind: cand.kind, cards: cand.cards });
if (recurse(idx + 1)) return true;
out.pop();
for (const c of cand.cards) used.delete(c.id);
}
return false;
}
return recurse(0) ? out.map((g) => ({ kind: g.kind, cards: g.cards.slice() })) : null;
}
function enumerateGroupCandidates(hand, spec) {
if (spec.kind === 'set') return enumerateSetCandidates(hand, spec.count);
if (spec.kind === 'run') return enumerateRunCandidates(hand, spec.count);
if (spec.kind === 'color') return enumerateColorCandidates(hand, spec.count);
return [];
}
function wilds(hand) {
return hand.filter((c) => c.value === 'wild');
}
function enumerateSetCandidates(hand, N) {
const cands = [];
const w = wilds(hand);
for (let v = 1; v <= 12; v++) {
const naturals = hand.filter((c) => c.value === v);
if (naturals.length === 0) continue;
const take = Math.min(naturals.length, N);
const wildsNeeded = N - take;
if (wildsNeeded > w.length) continue;
const cards = [...naturals.slice(0, take), ...w.slice(0, wildsNeeded)];
cands.push({ kind: 'set', cards, wildsUsed: wildsNeeded, key: `set:${v}` });
}
cands.sort((a, b) => a.wildsUsed - b.wildsUsed);
return cands;
}
function enumerateColorCandidates(hand, N) {
const cands = [];
const w = wilds(hand);
const colors = ['red', 'blue', 'yellow', 'green'];
for (const col of colors) {
const naturals = hand.filter((c) => c.color === col);
if (naturals.length === 0) continue;
const take = Math.min(naturals.length, N);
const wildsNeeded = N - take;
if (wildsNeeded > w.length) continue;
const cards = [...naturals.slice(0, take), ...w.slice(0, wildsNeeded)];
cands.push({ kind: 'color', cards, wildsUsed: wildsNeeded, key: `color:${col}` });
}
cands.sort((a, b) => a.wildsUsed - b.wildsUsed);
return cands;
}
function enumerateRunCandidates(hand, N) {
const cands = [];
const w = wilds(hand);
// For each starting value S, fill the N positions.
for (let S = 1; S + N - 1 <= 12; S++) {
const cards = new Array(N).fill(null);
let wildsNeeded = 0;
const usedIds = new Set();
let ok = true;
for (let p = 0; p < N; p++) {
const needVal = S + p;
// Find a natural with this value that isn't yet used in this candidate.
const nat = hand.find((c) => c.value === needVal && !usedIds.has(c.id));
if (nat) {
cards[p] = nat;
usedIds.add(nat.id);
} else {
wildsNeeded++;
}
}
if (wildsNeeded > w.length) { ok = false; }
if (!ok) continue;
// Slot wilds into the empty positions in order.
let wIdx = 0;
for (let p = 0; p < N; p++) {
if (!cards[p]) { cards[p] = w[wIdx++]; }
}
cands.push({ kind: 'run', cards, wildsUsed: wildsNeeded, key: `run:${S}` });
}
cands.sort((a, b) => a.wildsUsed - b.wildsUsed);
return cands;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,464 @@
// Phase 10 — pure state engine. No Phaser imports.
//
// Standard rules:
// - 108-card deck: values 1..12 × 2 per color × 4 colors (96), 8 wilds, 4 skips.
// - 2..4 players. Each player dealt 10 cards. Top of remaining deck → discard.
// - On your turn: draw 1 (from deck or top of discard, but Skip can't be drawn
// from discard), optionally lay down current phase if you haven't yet,
// optionally hit cards onto any laid-down phase, then discard 1.
// - Discarding a Skip lets you target an opponent; their next turn is skipped.
// - Round ends when one player empties their hand. Players score remaining
// cards (1-9=5, 10-12=10, Skip=15, Wild=25). Those who completed their
// phase advance; others stay on the same phase.
// - Match winner: lowest score among players who have completed phase 10.
import { validateLaydown, getPhase, COLORS_LIST } from './PhaseSpec.js';
export const HAND_DEAL = 10;
export const POINTS = { low: 5, high: 10, skip: 15, wild: 25 };
function makeCard(value, color, id) {
return { value, color, id };
}
export function buildDeck() {
const cards = [];
let id = 0;
for (const color of COLORS_LIST) {
for (let v = 1; v <= 12; v++) {
// 2 copies of each value per color
cards.push(makeCard(v, color, id++));
cards.push(makeCard(v, color, id++));
}
}
for (let i = 0; i < 8; i++) cards.push(makeCard('wild', null, id++));
for (let i = 0; i < 4; i++) cards.push(makeCard('skip', null, id++));
return cards;
}
// Mulberry32 — same seedable PRNG used by SkipBo.
function rng(seed) {
let a = (seed >>> 0) || 1;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle(arr, seed) {
const rand = seed === undefined ? Math.random : rng(seed);
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
// ── State construction ──────────────────────────────────────────────────────
export function createInitialState({ playerCount, seed, startingPlayer = 0 } = {}) {
if (playerCount < 2 || playerCount > 4) {
throw new Error(`Phase 10 supports 2..4 players, got ${playerCount}`);
}
const state = {
players: [],
drawPile: [],
discardPile: [],
currentPlayer: startingPlayer,
roundPhase: 'play', // 'play' | 'roundOver' | 'gameOver'
roundWinner: null,
matchWinner: null,
matchTied: false,
seed: seed ?? null,
turnCount: 0,
roundNum: 1,
lastRoundSummary: null,
drawnThisTurn: false,
laidDownThisTurn: false,
};
for (let i = 0; i < playerCount; i++) {
state.players.push({
seat: i,
hand: [],
phase: 1,
score: 0,
laidDown: null,
skipped: false,
clearedAt: null, // round number when phase 10 was cleared
});
}
dealRound(state);
return state;
}
function dealRound(state) {
const deck = buildDeck();
shuffle(deck, state.seed ? state.seed + state.roundNum * 17 : undefined);
for (const p of state.players) {
p.hand = deck.splice(0, HAND_DEAL);
p.laidDown = null;
p.skipped = false;
}
state.drawPile = deck;
// Flip first card to discard pile
state.discardPile = [state.drawPile.pop()];
state.drawnThisTurn = false;
state.laidDownThisTurn = false;
state.roundPhase = 'play';
state.roundWinner = null;
}
// ── Cloning ─────────────────────────────────────────────────────────────────
export function cloneState(state) {
return {
players: state.players.map((p) => ({
seat: p.seat,
hand: p.hand.map(cloneCard),
phase: p.phase,
score: p.score,
laidDown: p.laidDown ? p.laidDown.map((g) => ({ kind: g.kind, cards: g.cards.map(cloneCard) })) : null,
skipped: p.skipped,
clearedAt: p.clearedAt,
})),
drawPile: state.drawPile.map(cloneCard),
discardPile: state.discardPile.map(cloneCard),
currentPlayer: state.currentPlayer,
roundPhase: state.roundPhase,
roundWinner: state.roundWinner,
matchWinner: state.matchWinner,
matchTied: state.matchTied,
seed: state.seed,
turnCount: state.turnCount,
roundNum: state.roundNum,
lastRoundSummary: state.lastRoundSummary
? { ...state.lastRoundSummary, rows: state.lastRoundSummary.rows.map((r) => ({ ...r })) }
: null,
drawnThisTurn: state.drawnThisTurn,
laidDownThisTurn: state.laidDownThisTurn,
};
}
function cloneCard(c) {
return { value: c.value, color: c.color, id: c.id };
}
// ── Helpers ─────────────────────────────────────────────────────────────────
export function discardTop(state) {
return state.discardPile.length > 0 ? state.discardPile[state.discardPile.length - 1] : null;
}
export function cardPoints(card) {
if (card.value === 'wild') return POINTS.wild;
if (card.value === 'skip') return POINTS.skip;
if (card.value <= 9) return POINTS.low;
return POINTS.high;
}
function recycleDiscardIntoDraw(state) {
if (state.discardPile.length <= 1) return;
const keep = state.discardPile.pop();
const recycled = state.discardPile;
state.discardPile = [keep];
shuffle(recycled, state.seed ? state.seed + state.turnCount * 31 : undefined);
state.drawPile = recycled;
}
// ── Draw step ───────────────────────────────────────────────────────────────
export function applyDrawFromDeck(state) {
if (state.roundPhase !== 'play' || state.drawnThisTurn) return state;
const next = cloneState(state);
if (next.drawPile.length === 0) recycleDiscardIntoDraw(next);
if (next.drawPile.length === 0) return state; // shouldn't happen
const card = next.drawPile.pop();
next.players[next.currentPlayer].hand.push(card);
next.drawnThisTurn = true;
return next;
}
export function applyDrawFromDiscard(state) {
if (state.roundPhase !== 'play' || state.drawnThisTurn) return state;
const top = discardTop(state);
if (!top || top.value === 'skip') return state;
const next = cloneState(state);
const card = next.discardPile.pop();
next.players[next.currentPlayer].hand.push(card);
next.drawnThisTurn = true;
return next;
}
// ── Laydown step ────────────────────────────────────────────────────────────
/**
* Lay down the current phase. `groups` is an array of
* { kind, cardIds: [id,...] }
* referencing cards in the current player's hand.
*/
export function applyLaydown(state, groups) {
if (state.roundPhase !== 'play' || !state.drawnThisTurn || state.laidDownThisTurn) return state;
const seat = state.currentPlayer;
const player = state.players[seat];
if (player.laidDown) return state;
// Resolve cardIds → card objects in hand.
const resolved = [];
const usedIds = new Set();
for (const g of groups) {
const cards = [];
for (const id of g.cardIds) {
if (usedIds.has(id)) return state;
const idx = player.hand.findIndex((c) => c.id === id);
if (idx === -1) return state;
cards.push(player.hand[idx]);
usedIds.add(id);
}
resolved.push({ kind: g.kind, cards });
}
const r = validateLaydown(player.phase, resolved);
if (!r.ok) return state;
// Commit
const next = cloneState(state);
const nextPlayer = next.players[seat];
// Remove used cards from hand
nextPlayer.hand = nextPlayer.hand.filter((c) => !usedIds.has(c.id));
nextPlayer.laidDown = resolved.map((g) => ({
kind: g.kind,
cards: g.cards.map(cloneCard),
}));
next.laidDownThisTurn = true;
return next;
}
// ── Hitting ─────────────────────────────────────────────────────────────────
/**
* Returns true if `card` can extend the laid-down `group` at `position`.
* position: for runs, 'low' or 'high'. Ignored for sets/colors.
*/
export function canHit(group, card, position) {
if (!group || !group.cards || group.cards.length === 0) return false;
if (card.value === 'skip') return false;
if (group.kind === 'set') {
if (card.value === 'wild') return true;
// Determine set value from existing naturals
const v = group.cards.find((c) => c.value !== 'wild')?.value;
if (v == null) return true; // all wilds (shouldn't happen but allow)
return card.value === v;
}
if (group.kind === 'color') {
if (card.value === 'wild') return true;
if (!card.color) return false;
const col = group.cards.find((c) => c.color)?.color;
if (col == null) return true;
return card.color === col;
}
if (group.kind === 'run') {
if (card.value === 'wild') return true;
// Reconstruct the assigned values from the run order.
const vals = runAssignedValues(group);
if (!vals) return false;
if (position === 'low') return vals[0] > 1 && card.value === vals[0] - 1;
if (position === 'high') return vals[vals.length - 1] < 12 && card.value === vals[vals.length - 1] + 1;
return false;
}
return false;
}
/**
* For a run group, compute the assigned consecutive values for each card.
* Returns null if it can't be reconciled (shouldn't happen for a validated
* laid-down group). For wilds, returns the inferred slot value.
*/
export function runAssignedValues(group) {
const cards = group.cards;
const N = cards.length;
// Find an anchor natural.
let anchorIdx = -1;
for (let i = 0; i < N; i++) {
if (cards[i].value !== 'wild') { anchorIdx = i; break; }
}
if (anchorIdx === -1) {
// All wilds — pick a centered window.
const start = Math.max(1, Math.min(12 - N + 1, 1));
return Array.from({ length: N }, (_, i) => start + i);
}
const anchorVal = cards[anchorIdx].value;
const start = anchorVal - anchorIdx;
if (start < 1 || start + N - 1 > 12) return null;
const out = Array.from({ length: N }, (_, i) => start + i);
// Sanity check naturals
for (let i = 0; i < N; i++) {
if (cards[i].value !== 'wild' && cards[i].value !== out[i]) return null;
}
return out;
}
/**
* Enumerate every legal hit target for the current player's hand card.
* Returns array of { targetSeat, groupIdx, position }.
*/
export function getHitTargets(state, card) {
const targets = [];
for (let s = 0; s < state.players.length; s++) {
const p = state.players[s];
if (!p.laidDown) continue;
for (let g = 0; g < p.laidDown.length; g++) {
const group = p.laidDown[g];
if (group.kind === 'run') {
if (canHit(group, card, 'low')) targets.push({ targetSeat: s, groupIdx: g, position: 'low' });
if (canHit(group, card, 'high')) targets.push({ targetSeat: s, groupIdx: g, position: 'high' });
} else if (canHit(group, card)) {
targets.push({ targetSeat: s, groupIdx: g, position: null });
}
}
}
return targets;
}
export function applyHit(state, handIdx, targetSeat, groupIdx, position) {
if (state.roundPhase !== 'play' || !state.drawnThisTurn) return state;
const seat = state.currentPlayer;
const player = state.players[seat];
if (!player.laidDown) return state;
if (handIdx < 0 || handIdx >= player.hand.length) return state;
const card = player.hand[handIdx];
const target = state.players[targetSeat];
if (!target?.laidDown?.[groupIdx]) return state;
const group = target.laidDown[groupIdx];
if (!canHit(group, card, position)) return state;
const next = cloneState(state);
const nCard = next.players[seat].hand.splice(handIdx, 1)[0];
const nGroup = next.players[targetSeat].laidDown[groupIdx];
if (nGroup.kind === 'run' && position === 'low') {
nGroup.cards.unshift(nCard);
} else {
nGroup.cards.push(nCard);
}
// Check for emptied hand → round ends
if (next.players[seat].hand.length === 0) {
finalizeRound(next, seat);
}
return next;
}
// ── Discard / end-of-turn ───────────────────────────────────────────────────
/**
* Discard a hand card to end the turn.
* - If discarding a Skip, `skipTargetSeat` must be supplied (a player who
* isn't already flagged as skipped and isn't the current player).
* That player's next turn will be skipped.
*/
export function applyDiscard(state, handIdx, skipTargetSeat = null) {
if (state.roundPhase !== 'play' || !state.drawnThisTurn) return state;
const seat = state.currentPlayer;
const player = state.players[seat];
if (handIdx < 0 || handIdx >= player.hand.length) return state;
const card = player.hand[handIdx];
if (card.value === 'skip') {
if (skipTargetSeat == null || skipTargetSeat === seat) return state;
const tgt = state.players[skipTargetSeat];
if (!tgt || tgt.skipped) return state;
}
const next = cloneState(state);
const nCard = next.players[seat].hand.splice(handIdx, 1)[0];
next.discardPile.push(nCard);
if (nCard.value === 'skip' && skipTargetSeat != null) {
next.players[skipTargetSeat].skipped = true;
}
// Round ends if player emptied their hand
if (next.players[seat].hand.length === 0) {
finalizeRound(next, seat);
return next;
}
advanceTurn(next);
return next;
}
function advanceTurn(state) {
state.turnCount += 1;
let nextSeat = (state.currentPlayer + 1) % state.players.length;
// Skip any flagged seats (consume the flag).
while (state.players[nextSeat].skipped) {
state.players[nextSeat].skipped = false;
nextSeat = (nextSeat + 1) % state.players.length;
}
state.currentPlayer = nextSeat;
state.drawnThisTurn = false;
state.laidDownThisTurn = false;
}
// ── Round / match finalization ──────────────────────────────────────────────
function finalizeRound(state, winnerSeat) {
state.roundPhase = 'roundOver';
state.roundWinner = winnerSeat;
const rows = [];
for (const p of state.players) {
let pts = 0;
for (const c of p.hand) pts += cardPoints(c);
p.score += pts;
const advanced = !!p.laidDown;
if (advanced && p.phase < 10) p.phase += 1;
else if (advanced && p.phase === 10 && p.clearedAt == null) {
p.clearedAt = state.roundNum;
}
rows.push({
seat: p.seat,
pointsThisRound: pts,
totalScore: p.score,
advanced,
phaseNext: p.phase,
cleared10: p.clearedAt != null,
});
}
state.lastRoundSummary = { roundNum: state.roundNum, winnerSeat, rows };
// Check for match end: anyone has cleared phase 10 → game over
const cleared = state.players.filter((p) => p.clearedAt != null);
if (cleared.length > 0) {
const minScore = Math.min(...cleared.map((p) => p.score));
const leaders = cleared.filter((p) => p.score === minScore);
state.roundPhase = 'gameOver';
if (leaders.length === 1) {
state.matchWinner = leaders[0].seat;
state.matchTied = false;
} else {
// Tie among cleared players — continue another round to break the tie.
state.matchWinner = null;
state.matchTied = true;
state.roundPhase = 'roundOver';
}
}
}
/** Advance to the next round (caller invokes after roundOver). */
export function startNextRound(state) {
if (state.roundPhase !== 'roundOver') return state;
const next = cloneState(state);
next.roundNum += 1;
// Starting player rotates each round to keep things fair.
next.currentPlayer = (next.roundWinner != null
? (next.roundWinner + 1) % next.players.length
: (next.currentPlayer + 1) % next.players.length);
dealRound(next);
return next;
}
// ── Spec helper re-export for the UI ────────────────────────────────────────
export { getPhase, validateLaydown };

View File

@ -0,0 +1,177 @@
// Phase 10 — declarative spec for the 10 phases plus a laydown validator.
//
// A "group" is one of:
// { kind: 'set', count: N } N cards of the same value
// { kind: 'run', count: N } N consecutive numbers (no wrap)
// { kind: 'color', count: N } N cards of one color
//
// Wilds substitute for any card in a group, but the entire laydown must
// contain at least one natural (non-wild) card to be legal.
export const PHASES = [
/* phase 1 */ { num: 1, short: '2 sets of 3', groups: [{ kind: 'set', count: 3 }, { kind: 'set', count: 3 }] },
/* phase 2 */ { num: 2, short: '1 set of 3 + 1 run of 4', groups: [{ kind: 'set', count: 3 }, { kind: 'run', count: 4 }] },
/* phase 3 */ { num: 3, short: '1 set of 4 + 1 run of 4', groups: [{ kind: 'set', count: 4 }, { kind: 'run', count: 4 }] },
/* phase 4 */ { num: 4, short: '1 run of 7', groups: [{ kind: 'run', count: 7 }] },
/* phase 5 */ { num: 5, short: '1 run of 8', groups: [{ kind: 'run', count: 8 }] },
/* phase 6 */ { num: 6, short: '1 run of 9', groups: [{ kind: 'run', count: 9 }] },
/* phase 7 */ { num: 7, short: '2 sets of 4', groups: [{ kind: 'set', count: 4 }, { kind: 'set', count: 4 }] },
/* phase 8 */ { num: 8, short: '7 cards of one color', groups: [{ kind: 'color', count: 7 }] },
/* phase 9 */ { num: 9, short: '1 set of 5 + 1 set of 2', groups: [{ kind: 'set', count: 5 }, { kind: 'set', count: 2 }] },
/* phase 10 */ { num: 10, short: '1 set of 5 + 1 set of 3', groups: [{ kind: 'set', count: 5 }, { kind: 'set', count: 3 }] },
];
export const COLORS_LIST = ['red', 'blue', 'yellow', 'green'];
export function getPhase(num) {
return PHASES[num - 1] ?? null;
}
// ── Validation ──────────────────────────────────────────────────────────────
/**
* Validate a laydown attempt for a given phase.
* phaseNum: 1..10
* groups: array of { kind, cards: [card,...] } must match phase spec order
* OR any order we accept the proposed kind for each group and
* check the multiset matches the phase requirement.
*
* Returns { ok: true } or { ok: false, reason }.
*/
export function validateLaydown(phaseNum, groups) {
const spec = getPhase(phaseNum);
if (!spec) return { ok: false, reason: `Unknown phase ${phaseNum}` };
if (!Array.isArray(groups) || groups.length !== spec.groups.length) {
return { ok: false, reason: `Phase ${phaseNum} needs ${spec.groups.length} group(s)` };
}
// Match groups by kind+count multiset — order in the player's laydown
// doesn't matter. We greedily pair each player group with an unused spec.
const usedSpec = new Array(spec.groups.length).fill(false);
for (const g of groups) {
let matchedAt = -1;
for (let i = 0; i < spec.groups.length; i++) {
if (usedSpec[i]) continue;
const s = spec.groups[i];
if (s.kind === g.kind && s.count === g.cards.length) {
matchedAt = i;
break;
}
}
if (matchedAt === -1) {
return { ok: false, reason: `Phase ${phaseNum} doesn't accept a ${g.kind} of ${g.cards.length}` };
}
usedSpec[matchedAt] = true;
}
// Cards must be non-empty and only wilds/numbers/skips? Skips never count.
let anyNatural = false;
for (const g of groups) {
for (const c of g.cards) {
if (c.value === 'skip') {
return { ok: false, reason: 'Skip cards cannot be laid down' };
}
if (c.value !== 'wild') anyNatural = true;
}
}
if (!anyNatural) {
return { ok: false, reason: 'Laydown must contain at least one natural card' };
}
// Per-group structural rules
for (const g of groups) {
const r = validateGroup(g);
if (!r.ok) return r;
}
return { ok: true };
}
export function validateGroup(g) {
if (!g || !Array.isArray(g.cards) || g.cards.length === 0) {
return { ok: false, reason: 'Empty group' };
}
if (g.kind === 'set') return validateSet(g.cards);
if (g.kind === 'run') return validateRun(g.cards);
if (g.kind === 'color') return validateColor(g.cards);
return { ok: false, reason: `Unknown group kind: ${g.kind}` };
}
function validateSet(cards) {
// All non-wild cards must share the same value.
let v = null;
for (const c of cards) {
if (c.value === 'wild') continue;
if (c.value === 'skip') return { ok: false, reason: 'Set cannot contain skips' };
if (v === null) v = c.value;
else if (c.value !== v) return { ok: false, reason: 'Set values must match' };
}
return { ok: true };
}
function validateColor(cards) {
// All non-wild cards must share the same color.
let col = null;
for (const c of cards) {
if (c.value === 'wild') continue;
if (c.value === 'skip') return { ok: false, reason: 'Color group cannot contain skips' };
if (!c.color) return { ok: false, reason: 'Color group needs colored cards' };
if (col === null) col = c.color;
else if (c.color !== col) return { ok: false, reason: 'Color group colors must match' };
}
return { ok: true };
}
/**
* A run of length N must be assignable consecutive values such that each
* position is either a natural matching the assigned value or a wild.
* We greedily fit the natural cards: sort naturals ascending, then check
* that there's a starting value S where each natural's value fits in
* [S, S+N-1] without collision; gaps are filled by wilds.
*/
function validateRun(cards) {
const naturals = cards.filter((c) => c.value !== 'wild').map((c) => c.value);
for (const n of naturals) if (n === 'skip') return { ok: false, reason: 'Run cannot contain skips' };
const wildCount = cards.length - naturals.length;
const N = cards.length;
if (naturals.length === 0) {
// Validated elsewhere: at least one natural across the whole laydown.
// A single all-wild run by itself is structurally fine (only the
// global "needs one natural" rule catches all-wild laydowns).
return { ok: true };
}
naturals.sort((a, b) => a - b);
// Duplicates among naturals are illegal in a run.
for (let i = 1; i < naturals.length; i++) {
if (naturals[i] === naturals[i - 1]) {
return { ok: false, reason: 'Run cannot have duplicate values' };
}
}
// Spread between min and max must be < N (so they all fit in a window of N).
const span = naturals[naturals.length - 1] - naturals[0] + 1;
if (span > N) return { ok: false, reason: 'Run values too spread out' };
// All naturals must be in [1,12].
for (const n of naturals) {
if (typeof n !== 'number' || n < 1 || n > 12) {
return { ok: false, reason: 'Run values must be 1..12' };
}
}
// Determine if there's a starting value S with naturals[0] - k = S and
// S + N - 1 ≤ 12 and S ≥ 1.
// The window has length N starting at S; the leftmost natural can sit at
// any of positions 0..(N-span), so S can range from
// max(1, naturals[max]-N+1) to min(naturals[min], 13-N)
const minS = Math.max(1, naturals[naturals.length - 1] - N + 1);
const maxS = Math.min(naturals[0], 13 - N);
if (minS > maxS) return { ok: false, reason: 'Run does not fit within 1..12' };
// Wild count must equal (N - naturals.length) — already true by construction.
void wildCount;
return { ok: true };
}

View File

@ -0,0 +1,205 @@
// Skip-Bo AI — single-action heuristic chooser.
//
// The caller invokes chooseAction(state) repeatedly. Each call returns ONE
// action: either a play or a discard. The scene applies the action with
// animation, then re-invokes until a discard is returned (which ends the
// turn) or the game ends.
import {
BUILD_PILE_COUNT,
DISCARD_PILE_COUNT,
buildPileTopValue,
canPlayOnBuild,
discardTop,
getValidPlays,
nextRequired,
stockTop,
} from './SkipBoLogic.js';
const PLAY_PRIORITY = { stock: 3, discard: 2, hand: 1 };
/**
* Return one action for the AI player at `state.currentPlayer`, or null if
* `state.phase !== 'play'`.
*
* Actions:
* { type: 'play', source, sourceIdx, buildIdx, asNumber? }
* { type: 'discard', handIdx, discardIdx }
*/
export function chooseAction(state) {
if (state.phase !== 'play') return null;
const seat = state.currentPlayer;
const player = state.players[seat];
const plays = getValidPlays(state);
// 1) Stock-emptying play (winning move) trumps everything.
if (player.stock.length === 1) {
for (const p of plays) {
if (p.source === 'stock') return playAction(state, p);
}
}
// 2) Filter / score plays.
if (plays.length > 0) {
const stockReq = stockTop(state, seat)?.value;
let best = null;
let bestScore = -Infinity;
for (const p of plays) {
const sc = scorePlay(state, p, stockReq);
if (sc > bestScore) { bestScore = sc; best = p; }
}
// Only commit to a play if it doesn't "waste" a wild unnecessarily.
if (best && (best.card.value !== 'wild' || shouldUseWild(state, best, plays))) {
return playAction(state, best);
}
}
// 3) No useful play — must discard.
return chooseDiscard(state);
}
function playAction(state, play) {
const action = {
type: 'play',
source: play.source,
sourceIdx: play.sourceIdx,
buildIdx: play.buildIdx,
};
if (play.card.value === 'wild') {
action.asNumber = nextRequired(state, play.buildIdx);
}
return action;
}
function scorePlay(state, play, stockTopValue) {
let s = PLAY_PRIORITY[play.source] * 100;
// Bias toward plays that unlock the stock top right now or soon
if (stockTopValue !== undefined && stockTopValue !== 'wild') {
const after = nextRequired(state, play.buildIdx) + 1;
if (play.card.value === stockTopValue) s += 50; // playing the same value we need on stock
if (after === stockTopValue) s += 80; // this play sets up stock to play next
}
// Prefer plays that complete a pile (top would be 12 after play)
const need = nextRequired(state, play.buildIdx);
if (play.card.value === 12 || (play.card.value === 'wild' && need === 12)) s += 40;
// Light penalty for wilds (we want to save them)
if (play.card.value === 'wild') s -= 60;
// Tiny tie-breaker — prefer lower build piles to keep them moving
s += (4 - state.buildPiles[play.buildIdx].length) * 0.1;
return s;
}
/**
* Decide if a wild play is worthwhile. We accept a wild play when:
* - it empties our stock (handled above),
* - it's playing from stock (always good frees the stock),
* - it completes a build pile (need === 12),
* - it unblocks the stock top (next-required after play === stock top value),
* - or we have nothing else useful AND we're holding extra wilds (>1).
*/
function shouldUseWild(state, play, allPlays) {
if (play.source === 'stock') return true;
const need = nextRequired(state, play.buildIdx);
if (need === 12) return true;
const seat = state.currentPlayer;
const stockTopCard = stockTop(state, seat);
if (stockTopCard && stockTopCard.value !== 'wild') {
// After this play, would the stock top become playable on this pile?
if (need + 1 === stockTopCard.value) return true;
}
// Count non-wild useful plays remaining
const hasNonWildPlay = allPlays.some((p) => p.card.value !== 'wild');
if (hasNonWildPlay) return false;
// Otherwise only use a wild if we have multiple
const wildsInHand = state.players[seat].hand.filter((c) => c.value === 'wild').length;
return wildsInHand >= 2;
}
function chooseDiscard(state) {
const seat = state.currentPlayer;
const player = state.players[seat];
const hand = player.hand;
if (hand.length === 0) {
// Shouldn't happen — refill to 5 happens automatically. Fail-safe:
return null;
}
// Build piles' next-required values; useful for ranking what to keep.
const nexts = [];
for (let b = 0; b < BUILD_PILE_COUNT; b++) nexts.push(nextRequired(state, b));
// 1) Choose which CARD to discard.
// Prefer: highest non-wild value (less likely playable soon), then ties
// broken by least usefulness vs current build piles.
let bestHandIdx = -1;
let bestCardScore = -Infinity;
for (let i = 0; i < hand.length; i++) {
const c = hand[i];
if (c.value === 'wild') continue; // never discard wilds first
const dist = minPlayDistance(c.value, nexts);
const score = c.value * 2 + dist * 3; // higher = better to discard
if (score > bestCardScore) { bestCardScore = score; bestHandIdx = i; }
}
if (bestHandIdx === -1) {
// All wilds in hand — pick the first (forced discard)
bestHandIdx = 0;
}
const cardToDiscard = hand[bestHandIdx];
// 2) Choose which DISCARD PILE.
// Prefer building a descending run from the top down (so we can replay
// later when build piles allow): place on pile whose top is exactly
// cardValue + 1, falling back to cardValue, then the emptiest pile, then
// pile with smallest difference. Never bury a wild.
let bestPileIdx = -1;
let bestPileScore = -Infinity;
for (let d = 0; d < DISCARD_PILE_COUNT; d++) {
const top = discardTop(state, seat, d);
let s = 0;
if (!top) {
s = 50; // empty pile is great
} else if (top.value === 'wild' || top.playedAs === 'wild') {
s = -200; // don't bury wilds
} else if (cardToDiscard.value === 'wild') {
s = -50; // wild on any pile is mediocre
if (player.discards[d].length === 0) s = 30;
} else {
const topVal = top.playedAs ?? top.value;
if (topVal === cardToDiscard.value + 1) s = 40; // stack descending (replayable)
else if (topVal === cardToDiscard.value) s = 25; // duplicate stack
else s = -Math.abs(topVal - cardToDiscard.value); // closer is better
}
// Prefer shorter piles slightly
s += (8 - player.discards[d].length) * 0.5;
if (s > bestPileScore) { bestPileScore = s; bestPileIdx = d; }
}
if (bestPileIdx === -1) bestPileIdx = 0;
return { type: 'discard', handIdx: bestHandIdx, discardIdx: bestPileIdx };
}
function minPlayDistance(value, nexts) {
let best = 12;
for (const n of nexts) {
if (n > 12) continue;
if (value >= n) {
const d = value - n;
if (d < best) best = d;
} else {
const d = (12 - n) + value; // would have to wrap
if (d < best) best = d;
}
}
return best;
}

View File

@ -0,0 +1,847 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { auth } from '../../services/auth.js';
import { api } from '../../services/api.js';
import {
BUILD_PILE_COUNT,
DISCARD_PILE_COUNT,
HAND_SIZE,
STOCK_SIZE,
applyDiscard,
applyPlay,
buildPileTopValue,
canPlayOnBuild,
createInitialState,
discardTop,
getValidPlays,
nextRequired,
stockTop,
} from './SkipBoLogic.js';
import { chooseAction } from './SkipBoAI.js';
// ── Layout constants ────────────────────────────────────────────────────────
const CX = GAME_WIDTH / 2;
const CY = GAME_HEIGHT / 2;
const CARD_W = 90;
const CARD_H = 126;
const CARD_R = 8;
const HAND_SPREAD = 110;
const D = {
felt: -1, board: 0, pile: 5, card: 10, highlight: 20,
ui: 30, portrait: 35, banner: 60, modal: 80,
};
// Rim color per number band so a glance reveals the top-of-pile value.
const NUMBER_RIM = {
1: 0x2b7fbf, 2: 0x2b7fbf, 3: 0x2b7fbf,
4: 0x2f9e44, 5: 0x2f9e44, 6: 0x2f9e44,
7: 0xe6b800, 8: 0xe6b800, 9: 0xe6b800,
10: 0xc92a2a, 11: 0xc92a2a, 12: 0xc92a2a,
};
const WILD_FILL = 0xf2ead8;
const WILD_STRIPE = 0xc8a84b;
// ── Seat anchors ────────────────────────────────────────────────────────────
// Indexed by SEAT slot (0=bottom local, 1=left, 2=top, 3=right). At runtime
// we map game-state seat indices to slots based on player count.
const SEAT_SLOTS = ['bottom', 'left', 'top', 'right'];
// Number of slot positions used per player count: always uses the bottom slot
// for the local player, then top/left/right in this order for opponents.
const SLOTS_USED = {
2: ['bottom', 'top'],
3: ['bottom', 'left', 'right'],
4: ['bottom', 'left', 'top', 'right'],
};
// ── Slot layout ─────────────────────────────────────────────────────────────
// Each slot returns positions for: stock, 4 discards, the hand row's start
// position (cards laid out along an axis), the portrait position, and the
// hand layout direction.
function slotLayout(slot) {
switch (slot) {
case 'bottom':
return {
rotation: 0,
stock: { x: 260, y: 950 },
discards: [{x:430,y:950},{x:550,y:950},{x:670,y:950},{x:790,y:950}],
handStart:{ x: 1000, y: 1000 },
handAxis: 'x',
portrait: { x: 120, y: 950, r: 60 },
nameLabel:{ x: 120, y: 1030 },
stockCntPos: { x: 260, y: 880 },
rotateCards: 0,
};
case 'top':
return {
rotation: 180,
stock: { x: 260, y: 130 },
discards: [{x:430,y:130},{x:550,y:130},{x:670,y:130},{x:790,y:130}],
handStart:{ x: 1000, y: 80 },
handAxis: 'x',
portrait: { x: 120, y: 130, r: 60 },
nameLabel:{ x: 120, y: 210 },
stockCntPos: { x: 260, y: 60 },
rotateCards: 180,
};
case 'left':
return {
rotation: 90,
stock: { x: 100, y: 260 },
discards: [{x:100,y:430},{x:100,y:550},{x:100,y:670},{x:100,y:790}],
handStart:{ x: 40, y: 920 },
handAxis: 'y-up', // hand fans upward toward 920..520
portrait: { x: 100, y: 130, r: 56 },
nameLabel:{ x: 100, y: 50 },
stockCntPos:{ x: 180, y: 260 },
rotateCards: 90,
};
case 'right':
return {
rotation: 270,
stock: { x: 1820, y: 260 },
discards: [{x:1820,y:430},{x:1820,y:550},{x:1820,y:670},{x:1820,y:790}],
handStart:{ x: 1880,y: 920 },
handAxis: 'y-up',
portrait: { x: 1820, y: 130, r: 56 },
nameLabel:{ x: 1820, y: 50 },
stockCntPos:{ x: 1740,y: 260 },
rotateCards: 270,
};
default:
throw new Error(`Unknown slot: ${slot}`);
}
}
// Center build piles
const BUILD_X0 = CX - (CARD_W + 28) * 1.5;
const BUILD_Y = CY + 30;
function buildPilePos(idx) {
return { x: BUILD_X0 + idx * (CARD_W + 28), y: BUILD_Y };
}
const DRAW_POS = { x: CX - 320, y: CY + 30 };
// ── Scene ───────────────────────────────────────────────────────────────────
export default class SkipBoGame extends Phaser.Scene {
constructor() { super('SkipBoGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
// Runtime state
this.gs = null;
this.animating = false;
this.gameOver = false;
// Card visuals: map card.id → Phaser.Container
this.cardObjs = new Map();
// Decorations cleared every renderAll (count chips, etc.)
this.transientObjs = [];
// Pile drop targets (rects + zones)
this.buildPileObjs = []; // [{ rect, label }]
this.discardPileObjs = []; // [seat][discIdx] → { rect }
this.stockPileObjs = []; // seat → { rect, count }
// Local interaction
this.selectedSource = null; // { kind: 'hand'|'stock'|'discard', idx? }
this.highlightObjs = [];
this.handObjs = []; // seat → [container] for arranging
this.opponentPortraits = []; // seat → portrait controller (or null)
// Seat slot mapping. Slot index in SLOTS_USED matches seat index.
this.slotForSeat = [];
}
create() {
this.buildPlayfield();
this.assignSeats();
this.buildSeatAreas();
this.buildCenter();
this.buildHUD();
this.startNewGame();
}
// ── Setup ────────────────────────────────────────────────────────────────
buildPlayfield() {
const pf = this.playfield;
if (pf?.key && this.textures.exists(pf.key)) {
this.add.image(CX, CY, pf.key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.felt);
} else {
const color = pf?.fallbackColor
? parseInt(pf.fallbackColor.replace('#', ''), 16) : 0x14532d;
this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, color).setDepth(D.felt);
}
}
assignSeats() {
const playerCount = 1 + this.opponents.length;
const slots = SLOTS_USED[playerCount];
if (!slots) throw new Error(`Skip-Bo needs 2..4 players, got ${playerCount}`);
this.slotForSeat = slots.slice();
}
buildSeatAreas() {
const playerCount = this.slotForSeat.length;
for (let seat = 0; seat < playerCount; seat++) {
const slot = this.slotForSeat[seat];
const layout = slotLayout(slot);
// Stock placeholder
const sRect = this.add.rectangle(layout.stock.x, layout.stock.y, CARD_W + 6, CARD_H + 6, 0x000000, 0.35)
.setStrokeStyle(2, COLORS.muted).setDepth(D.pile);
// Stock count badge
const sCnt = this.add.text(layout.stockCntPos.x, layout.stockCntPos.y, '0', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.accentHex, fontStyle: 'bold',
}).setOrigin(0.5).setDepth(D.ui);
this.stockPileObjs[seat] = { rect: sRect, count: sCnt, x: layout.stock.x, y: layout.stock.y };
if (seat === 0) {
// Clickable for local player
sRect.setInteractive({ useHandCursor: true });
sRect.on('pointerdown', () => this.onStockClick());
}
// Discard placeholders
this.discardPileObjs[seat] = [];
for (let d = 0; d < DISCARD_PILE_COUNT; d++) {
const pos = layout.discards[d];
const r = this.add.rectangle(pos.x, pos.y, CARD_W + 6, CARD_H + 6, 0x000000, 0.25)
.setStrokeStyle(1, COLORS.muted).setDepth(D.pile);
if (seat === 0) {
r.setInteractive({ useHandCursor: true });
r.on('pointerdown', () => this.onDiscardClick(d));
}
this.discardPileObjs[seat][d] = { rect: r, x: pos.x, y: pos.y };
}
// Portrait + name
if (seat === 0) {
createPlayerPortrait(this, layout.portrait.x, layout.portrait.y, layout.portrait.r, D.portrait, 'SkipBoGame');
this.add.text(layout.nameLabel.x, layout.nameLabel.y, auth.user?.username ?? 'You', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
} else {
const opp = this.opponents[seat - 1];
if (opp) {
this.opponentPortraits[seat] = createOpponentPortrait(this, opp, layout.portrait.x, layout.portrait.y, layout.portrait.r, D.portrait);
this.add.text(layout.nameLabel.x, layout.nameLabel.y, opp.name ?? `P${seat + 1}`, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
}
}
}
}
buildCenter() {
// Draw pile placeholder
const drawRect = this.add.rectangle(DRAW_POS.x, DRAW_POS.y, CARD_W + 8, CARD_H + 8, 0x000000, 0.4)
.setStrokeStyle(2, COLORS.accent).setDepth(D.pile);
this.add.text(DRAW_POS.x, DRAW_POS.y - CARD_H/2 - 18, 'DRAW', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
this.drawPileText = this.add.text(DRAW_POS.x, DRAW_POS.y, '', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.accentHex, fontStyle: 'bold',
}).setOrigin(0.5).setDepth(D.ui);
this.drawPileRect = drawRect;
// Build piles
for (let i = 0; i < BUILD_PILE_COUNT; i++) {
const p = buildPilePos(i);
const r = this.add.rectangle(p.x, p.y, CARD_W + 8, CARD_H + 8, 0x000000, 0.3)
.setStrokeStyle(2, COLORS.muted).setDepth(D.pile)
.setInteractive({ useHandCursor: true });
r.on('pointerdown', () => this.onBuildPileClick(i));
const label = this.add.text(p.x, p.y - CARD_H/2 - 18, `BUILD ${i + 1}`, {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
const needText = this.add.text(p.x, p.y + CARD_H/2 + 18, 'next: 1', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(D.ui);
this.buildPileObjs[i] = { rect: r, label, needText, x: p.x, y: p.y };
}
}
buildHUD() {
this.statusText = this.add.text(CX, 36, '', {
fontFamily: 'Righteous', fontSize: '28px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
new Button(this, 110, GAME_HEIGHT - 50, 'New', () => this.startNewGame(), {
variant: 'ghost', width: 110, height: 40, fontSize: 18,
}).setDepth(D.ui);
new Button(this, 110, GAME_HEIGHT - 100, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 110, height: 40, fontSize: 18,
}).setDepth(D.ui);
}
// ── Game start ───────────────────────────────────────────────────────────
startNewGame() {
if (this.animating) return;
this.gameOver = false;
this.clearAllCardObjs();
this.clearHighlights();
this.selectedSource = null;
const playerCount = this.slotForSeat.length;
this.gs = createInitialState({ playerCount });
this.renderAll();
this.setStatus('Your turn');
if (this.gs.currentPlayer !== 0) {
this.runAITurn();
}
}
// ── Card sprite factory ─────────────────────────────────────────────────
makeCardSprite(card, x, y, { faceUp = true, rotation = 0 } = {}) {
const c = this.add.container(x, y).setDepth(D.card);
c.setRotation((rotation * Math.PI) / 180);
this.renderCardFace(c, card, faceUp);
c.card = card;
return c;
}
renderCardFace(container, card, faceUp) {
// Remove existing children safely
container.removeAll(true);
const x = -CARD_W / 2, y = -CARD_H / 2;
const g = this.add.graphics();
if (!faceUp) {
this.drawCardBackGfx(g, x, y);
container.add(g);
return;
}
if (card.value === 'wild') {
// Wild face — gold stripes + "SKIP-BO" text
g.fillStyle(WILD_FILL, 1);
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
g.lineStyle(3, WILD_STRIPE, 1);
g.strokeRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
// Diagonal stripes
g.lineStyle(2, WILD_STRIPE, 0.5);
for (let s = -CARD_H; s < CARD_W; s += 14) {
g.beginPath();
g.moveTo(x + s, y + CARD_H);
g.lineTo(x + s + CARD_H, y);
g.strokePath();
}
container.add(g);
if (card.playedAs) {
// Big played-as number overlaid on a chip
const chip = this.add.graphics();
chip.fillStyle(0x1a1208, 0.9);
chip.fillRoundedRect(-26, -22, 52, 44, 8);
chip.lineStyle(2, WILD_STRIPE, 1);
chip.strokeRoundedRect(-26, -22, 52, 44, 8);
container.add(chip);
container.add(this.add.text(0, 0, `${card.playedAs}`, {
fontFamily: '"Julius Sans One"', fontSize: '32px',
color: COLORS.accentHex, fontStyle: 'bold',
}).setOrigin(0.5));
} else {
container.add(this.add.text(0, -8, 'SKIP', {
fontFamily: 'Righteous', fontSize: '18px', color: '#1a1208',
}).setOrigin(0.5));
container.add(this.add.text(0, 14, 'BO', {
fontFamily: 'Righteous', fontSize: '18px', color: '#1a1208',
}).setOrigin(0.5));
}
return;
}
// Numbered face
const rim = NUMBER_RIM[card.value] ?? 0x888888;
g.fillStyle(0xfbf6e7, 1);
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
g.lineStyle(4, rim, 1);
g.strokeRoundedRect(x + 2, y + 2, CARD_W - 4, CARD_H - 4, CARD_R - 1);
container.add(g);
const numStyle = (sz) => ({
fontFamily: 'Righteous', fontSize: `${sz}px`, color: '#1a1208',
});
container.add(this.add.text(x + 8, y + 6, `${card.value}`, numStyle(20)));
container.add(this.add.text(0, 0, `${card.value}`, numStyle(46)).setOrigin(0.5));
container.add(this.add.text(x + CARD_W - 8, y + CARD_H - 8, `${card.value}`,
numStyle(20)).setOrigin(1, 1));
}
drawCardBackGfx(g, x, y) {
const color = this.cardBack?.fallbackColor
? parseInt(this.cardBack.fallbackColor.replace('#', ''), 16) : 0x1a3a6b;
g.fillStyle(color, 1);
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
g.lineStyle(2, COLORS.accent, 0.6);
g.strokeRoundedRect(x + 6, y + 6, CARD_W - 12, CARD_H - 12, CARD_R - 2);
g.lineStyle(1, 0xffffff, 0.15);
g.strokeRoundedRect(x + 10, y + 10, CARD_W - 20, CARD_H - 20, CARD_R - 4);
}
clearAllCardObjs() {
for (const c of this.cardObjs.values()) c.destroy();
this.cardObjs.clear();
for (const o of this.transientObjs) o.destroy();
this.transientObjs = [];
}
// ── Rendering ────────────────────────────────────────────────────────────
renderAll() {
this.clearAllCardObjs();
this.renderCenter();
for (let seat = 0; seat < this.gs.players.length; seat++) {
this.renderSeat(seat);
}
this.renderTurnIndicator();
}
renderCenter() {
// Draw pile — show a card back representing the deck size
const remaining = this.gs.drawPile.length;
if (remaining > 0) {
const c = this.makeCardSprite({ value: 'back', id: -1 }, DRAW_POS.x, DRAW_POS.y, { faceUp: false });
c.card = null;
this.cardObjs.set('draw', c);
}
this.drawPileText.setText(`${remaining}`);
// Build piles
for (let i = 0; i < BUILD_PILE_COUNT; i++) {
const pile = this.gs.buildPiles[i];
const obj = this.buildPileObjs[i];
const top = pile.length > 0 ? pile[pile.length - 1] : null;
if (top) {
const c = this.makeCardSprite(top, obj.x, obj.y, { faceUp: true });
this.cardObjs.set(`build-${i}-${top.id}`, c);
}
const need = nextRequired(this.gs, i);
obj.needText.setText(need > 12 ? '—' : `next: ${need}`);
}
}
renderSeat(seat) {
const slot = this.slotForSeat[seat];
const layout = slotLayout(slot);
const player = this.gs.players[seat];
// Stock — show the top card if any
const stockCnt = this.stockPileObjs[seat].count;
stockCnt.setText(`${player.stock.length}`);
const top = stockTop(this.gs, seat);
if (top) {
const c = this.makeCardSprite(top, layout.stock.x, layout.stock.y, {
faceUp: true, rotation: layout.rotateCards,
});
this.cardObjs.set(`stock-${seat}-${top.id}`, c);
if (seat === 0) {
c.setInteractive(new Phaser.Geom.Rectangle(-CARD_W/2, -CARD_H/2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains);
c.input.cursor = 'pointer';
c.on('pointerdown', () => this.onStockClick());
}
}
// Discards — show top of each
for (let d = 0; d < DISCARD_PILE_COUNT; d++) {
const pile = player.discards[d];
if (pile.length === 0) continue;
const t = pile[pile.length - 1];
const pos = layout.discards[d];
const c = this.makeCardSprite(t, pos.x, pos.y, {
faceUp: true, rotation: layout.rotateCards,
});
this.cardObjs.set(`discard-${seat}-${d}-${t.id}`, c);
if (seat === 0) {
c.setInteractive(new Phaser.Geom.Rectangle(-CARD_W/2, -CARD_H/2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains);
c.input.cursor = 'pointer';
c.on('pointerdown', () => this.onDiscardClick(d));
}
// Pile-depth chip if more than one card
if (pile.length > 1) {
const chip = this.add.text(pos.x + CARD_W/2 - 4, pos.y + CARD_H/2 - 4, `×${pile.length}`, {
fontFamily: '"Julius Sans One"', fontSize: '14px',
color: COLORS.textDarkHex, backgroundColor: COLORS.accentHex,
padding: { x: 4, y: 2 },
}).setOrigin(1, 1).setDepth(D.ui);
this.transientObjs.push(chip);
}
}
// Hand
this.renderHand(seat);
}
renderHand(seat) {
const slot = this.slotForSeat[seat];
const layout = slotLayout(slot);
const player = this.gs.players[seat];
const isLocal = seat === 0;
for (let i = 0; i < player.hand.length; i++) {
const card = player.hand[i];
let x, y;
if (layout.handAxis === 'x') {
x = layout.handStart.x + i * HAND_SPREAD;
y = layout.handStart.y;
} else { // 'y-up'
x = layout.handStart.x;
y = layout.handStart.y - i * HAND_SPREAD;
}
const c = this.makeCardSprite(card, x, y, {
faceUp: isLocal, rotation: layout.rotateCards,
});
this.cardObjs.set(`hand-${seat}-${card.id}`, c);
if (isLocal) {
c.setInteractive(new Phaser.Geom.Rectangle(-CARD_W/2, -CARD_H/2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains);
c.input.cursor = 'pointer';
c.on('pointerdown', () => this.onHandClick(i));
}
}
}
renderTurnIndicator() {
const seat = this.gs.currentPlayer;
const slot = this.slotForSeat[seat];
const lay = slotLayout(slot);
if (!this.turnGlow) {
this.turnGlow = this.add.circle(0, 0, 80, COLORS.accent, 0.18).setDepth(D.portrait - 1);
}
this.turnGlow.setPosition(lay.portrait.x, lay.portrait.y);
}
// ── Local input ─────────────────────────────────────────────────────────
isLocalTurn() {
return this.gs && !this.gameOver && this.gs.phase === 'play' && this.gs.currentPlayer === 0;
}
onHandClick(handIdx) {
if (!this.isLocalTurn() || this.animating) return;
this.selectedSource = { kind: 'hand', idx: handIdx };
this.highlightValidTargets();
this.setStatus('Choose a build pile, or click a discard pile to end your turn.');
}
onStockClick() {
if (!this.isLocalTurn() || this.animating) return;
this.selectedSource = { kind: 'stock' };
this.highlightValidTargets();
this.setStatus('Choose a build pile for your stock top.');
}
onDiscardClick(discardIdx) {
if (!this.isLocalTurn() || this.animating) return;
// If we have a selected hand card, treat this as a discard-to-end-turn.
if (this.selectedSource?.kind === 'hand') {
const handIdx = this.selectedSource.idx;
this.selectedSource = null;
this.clearHighlights();
this.commitDiscard(handIdx, discardIdx);
return;
}
// Otherwise treat as selecting that discard top as a source.
const top = discardTop(this.gs, 0, discardIdx);
if (!top) return;
this.selectedSource = { kind: 'discard', idx: discardIdx };
this.highlightValidTargets();
this.setStatus('Choose a build pile for the discard top.');
}
onBuildPileClick(buildIdx) {
if (!this.isLocalTurn() || this.animating) return;
const sel = this.selectedSource;
if (!sel) {
this.setStatus('Select a card first (hand, stock top, or discard top).');
return;
}
const card = this.cardForSelection(sel);
if (!card) { this.selectedSource = null; return; }
if (!canPlayOnBuild(this.gs, card, buildIdx)) {
this.setStatus("Can't play there.");
return;
}
const play = this.buildPlayObject(sel, buildIdx, card);
if (card.value === 'wild') {
play.asNumber = nextRequired(this.gs, buildIdx);
}
this.selectedSource = null;
this.clearHighlights();
this.commitPlay(play);
}
cardForSelection(sel) {
if (sel.kind === 'hand') return this.gs.players[0].hand[sel.idx];
if (sel.kind === 'stock') return stockTop(this.gs, 0);
if (sel.kind === 'discard') return discardTop(this.gs, 0, sel.idx);
return null;
}
buildPlayObject(sel, buildIdx, card) {
return {
type: 'play',
source: sel.kind,
sourceIdx: sel.kind === 'hand' ? sel.idx : (sel.kind === 'discard' ? sel.idx : 0),
buildIdx,
};
}
highlightValidTargets() {
this.clearHighlights();
const sel = this.selectedSource;
const card = this.cardForSelection(sel);
if (!card) return;
for (let i = 0; i < BUILD_PILE_COUNT; i++) {
if (canPlayOnBuild(this.gs, card, i)) {
const obj = this.buildPileObjs[i];
const h = this.add.rectangle(obj.x, obj.y, CARD_W + 18, CARD_H + 18, 0xffd700, 0.18)
.setStrokeStyle(3, 0xffd700, 0.9).setDepth(D.highlight);
this.highlightObjs.push(h);
}
}
// Highlight discard piles only when a hand card is selected (turn-ender)
if (sel.kind === 'hand') {
for (let d = 0; d < DISCARD_PILE_COUNT; d++) {
const pos = slotLayout(this.slotForSeat[0]).discards[d];
const h = this.add.rectangle(pos.x, pos.y, CARD_W + 18, CARD_H + 18, 0x4dabf7, 0.12)
.setStrokeStyle(2, 0x4dabf7, 0.7).setDepth(D.highlight);
this.highlightObjs.push(h);
}
}
}
clearHighlights() {
for (const h of this.highlightObjs) h.destroy();
this.highlightObjs = [];
}
// ── Action commit ───────────────────────────────────────────────────────
commitPlay(action) {
this.animating = true;
const beforeSeat = this.gs.currentPlayer;
const next = applyPlay(this.gs, action);
this.animatePlay(this.gs, next, action, () => {
this.gs = next;
this.renderAll();
this.animating = false;
if (this.gs.phase === 'gameover') {
this.endGame();
return;
}
// After a successful play it's still the same player's turn until they
// discard. If AI's turn, continue.
if (this.gs.currentPlayer !== 0) {
this.runAITurn();
} else {
this.setStatus("Play more, or end your turn by discarding.");
}
});
}
commitDiscard(handIdx, discardIdx) {
this.animating = true;
const next = applyDiscard(this.gs, handIdx, discardIdx);
this.animateDiscard(this.gs, next, handIdx, discardIdx, () => {
this.gs = next;
this.renderAll();
this.animating = false;
if (this.gs.phase === 'gameover') { this.endGame(); return; }
if (this.gs.currentPlayer !== 0) {
this.runAITurn();
} else {
this.setStatus('Your turn');
}
});
}
// ── Animation ───────────────────────────────────────────────────────────
// Cheap approach: identify the source card object before mutating, tween it
// to the destination, then on complete fully re-render from the next state.
animatePlay(before, after, action, done) {
const fromKey = this.sourceKey(before, action);
const target = this.cardObjs.get(fromKey);
const dest = buildPilePos(action.buildIdx);
if (!target) { done(); return; }
this.tweens.add({
targets: target,
x: dest.x, y: dest.y, scale: 1,
duration: 280, ease: 'Cubic.easeOut',
onComplete: done,
});
}
animateDiscard(before, after, handIdx, discardIdx, done) {
const card = before.players[before.currentPlayer].hand[handIdx];
const fromKey = `hand-${before.currentPlayer}-${card.id}`;
const target = this.cardObjs.get(fromKey);
const layout = slotLayout(this.slotForSeat[before.currentPlayer]);
const dest = layout.discards[discardIdx];
if (!target) { done(); return; }
this.tweens.add({
targets: target, x: dest.x, y: dest.y,
duration: 240, ease: 'Cubic.easeOut',
onComplete: done,
});
}
sourceKey(state, action) {
const seat = state.currentPlayer;
if (action.source === 'stock') {
const top = stockTop(state, seat);
return top ? `stock-${seat}-${top.id}` : null;
}
if (action.source === 'hand') {
const c = state.players[seat].hand[action.sourceIdx];
return c ? `hand-${seat}-${c.id}` : null;
}
if (action.source === 'discard') {
const c = discardTop(state, seat, action.sourceIdx);
return c ? `discard-${seat}-${action.sourceIdx}-${c.id}` : null;
}
return null;
}
// ── AI loop ─────────────────────────────────────────────────────────────
runAITurn() {
if (this.gameOver) return;
const seat = this.gs.currentPlayer;
const slot = this.slotForSeat[seat];
const name = (this.opponents[seat - 1]?.name) ?? `Player ${seat + 1}`;
this.setStatus(`${name}'s turn…`);
this.time.delayedCall(450, () => this.stepAI());
}
stepAI() {
if (this.gameOver) return;
if (this.gs.currentPlayer === 0) {
this.setStatus('Your turn');
return;
}
const action = chooseAction(this.gs);
if (!action) {
// Defensive — shouldn't happen
this.setStatus('AI passed.');
return;
}
if (action.type === 'play') {
this.animating = true;
const next = applyPlay(this.gs, action);
this.animatePlay(this.gs, next, action, () => {
this.gs = next;
this.renderAll();
this.animating = false;
if (this.gs.phase === 'gameover') { this.endGame(); return; }
this.time.delayedCall(420, () => this.stepAI());
});
} else {
this.animating = true;
const next = applyDiscard(this.gs, action.handIdx, action.discardIdx);
this.animateDiscard(this.gs, next, action.handIdx, action.discardIdx, () => {
this.gs = next;
this.renderAll();
this.animating = false;
if (this.gs.phase === 'gameover') { this.endGame(); return; }
if (this.gs.currentPlayer === 0) {
this.setStatus('Your turn');
} else {
this.runAITurn();
}
});
}
}
// ── Endgame ─────────────────────────────────────────────────────────────
endGame() {
this.gameOver = true;
const winnerSeat = this.gs.winner;
const youWon = winnerSeat === 0;
const name = winnerSeat === 0
? (auth.user?.username ?? 'You')
: (this.opponents[winnerSeat - 1]?.name ?? `Player ${winnerSeat + 1}`);
const msg = youWon ? `You win!` : `${name} wins!`;
this.setStatus(msg);
// Emotion reactions for AI portraits
for (let s = 1; s < this.gs.players.length; s++) {
const p = this.opponentPortraits[s];
if (!p) continue;
if (winnerSeat === s) p.playEmotion?.('win');
else p.playEmotion?.('loss');
}
this.recordHistory(youWon);
this.showGameOverPanel(msg, youWon, name);
}
showGameOverPanel(msg, youWon, name) {
const overlay = this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65)
.setInteractive().setDepth(D.modal);
const panelW = 720;
const panelH = 320;
this.add.rectangle(CX, CY, panelW, panelH, COLORS.panel, 1)
.setStrokeStyle(2, COLORS.accent).setDepth(D.modal);
this.add.text(CX, CY - panelH / 2 + 56, msg, {
fontFamily: 'Righteous', fontSize: '42px',
color: youWon ? COLORS.goldHex : COLORS.textHex,
}).setOrigin(0.5).setDepth(D.modal);
this.add.text(CX, CY - 10, youWon
? 'You emptied your stock first.'
: `${name} emptied their stock first.`, {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.modal);
new Button(this, CX - 130, CY + panelH / 2 - 60, 'Play again', () => {
overlay.destroy();
this.scene.restart({
game: this.gameDef, opponents: this.opponents,
playfield: this.playfield, cardBack: this.cardBack,
});
}, { width: 220, fontSize: 22 }).setDepth(D.modal);
new Button(this, CX + 130, CY + panelH / 2 - 60, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 220, fontSize: 22 }).setDepth(D.modal);
}
async recordHistory(youWon) {
try {
const localStockRemaining = this.gs.players[0].stock.length;
const opponentScores = [];
for (let s = 1; s < this.gs.players.length; s++) {
opponentScores.push(this.gs.players[s].stock.length);
}
// Score = (STOCK_SIZE - cards remaining in your stock); higher = better
const score = STOCK_SIZE - localStockRemaining;
await api.post('/history/single-player', {
slug: 'skipbo',
score,
opponentScores,
result: youWon ? 'win' : 'loss',
});
} catch (err) {
console.warn('[skipbo] failed to record history', err);
}
}
// ── HUD helpers ─────────────────────────────────────────────────────────
setStatus(s) {
if (this.statusText) this.statusText.setText(s);
}
}

View File

@ -0,0 +1,322 @@
// Skip-Bo — pure state engine. No Phaser imports.
//
// Standard rules:
// - 162-card deck: numbers 1..12 × 12 each (144) + 18 wild "SKIP-BO" cards.
// - 24 players. Each player gets a 30-card stock pile (top face-up).
// - Center: 4 shared build piles, each built 1..12 face-up; on hitting 12 the
// pile is cleared to a "completed" pile that recycles into the draw pile
// when the draw runs out.
// - On your turn: hand is filled to 5 from draw pile. You may play cards onto
// build piles from (a) your stock top, (b) your hand, (c) the top of one
// of your 4 discard piles. Wilds are any value. Turn ends by discarding
// one hand card onto any of your 4 personal discard piles (your choice).
// - First to empty their stock wins.
// ── Card factory ────────────────────────────────────────────────────────────
//
// `value` is 1..12 for numbered cards, or the string 'wild' for SKIP-BO.
// `id` is unique across the whole deck — used as a Phaser object key.
// `playedAs` is set when a wild is played onto a build pile so the visual
// representation can render the chosen number.
function makeCard(value, id) {
return { value, id, playedAs: null };
}
export function buildDeck() {
const cards = [];
let id = 0;
for (let v = 1; v <= 12; v++) {
for (let i = 0; i < 12; i++) cards.push(makeCard(v, id++));
}
for (let i = 0; i < 18; i++) cards.push(makeCard('wild', id++));
return cards;
}
// Mulberry32 — deterministic PRNG so seeded shuffles match across clients.
function rng(seed) {
let a = (seed >>> 0) || 1;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle(arr, seed) {
const rand = seed === undefined ? Math.random : rng(seed);
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
export const STOCK_SIZE = 30;
export const HAND_SIZE = 5;
export const BUILD_PILE_COUNT = 4;
export const DISCARD_PILE_COUNT = 4;
// ── State construction ──────────────────────────────────────────────────────
export function createInitialState({ playerCount, seed, startingPlayer = 0 } = {}) {
if (playerCount < 2 || playerCount > 4) {
throw new Error(`Skip-Bo supports 2..4 players, got ${playerCount}`);
}
const deck = buildDeck();
shuffle(deck, seed);
const players = [];
for (let i = 0; i < playerCount; i++) {
const stock = deck.splice(0, STOCK_SIZE);
players.push({
seat: i,
stock,
hand: [],
discards: [[], [], [], []],
});
}
const state = {
players,
buildPiles: [[], [], [], []],
drawPile: deck,
completedPile: [],
currentPlayer: startingPlayer,
phase: 'play', // 'play' | 'gameover'
winner: null,
seed: seed ?? null,
turnCount: 0,
};
// Top of every stock starts face-up. We model "face-up" implicitly: stock
// top is always the last element in the array and is treated as visible.
refillHand(state, startingPlayer);
return state;
}
// ── Deck management ─────────────────────────────────────────────────────────
function recycleCompletedIntoDraw(state) {
if (state.completedPile.length === 0) return;
const recycled = state.completedPile.splice(0, state.completedPile.length);
// Wild cards revert to wild when recycled (clear `playedAs`).
for (const c of recycled) c.playedAs = null;
shuffle(recycled, state.seed ? state.seed + state.turnCount : undefined);
state.drawPile.push(...recycled);
}
function drawOne(state) {
if (state.drawPile.length === 0) recycleCompletedIntoDraw(state);
if (state.drawPile.length === 0) return null;
return state.drawPile.pop();
}
function refillHand(state, seat) {
const p = state.players[seat];
while (p.hand.length < HAND_SIZE) {
const c = drawOne(state);
if (!c) break;
p.hand.push(c);
}
}
// ── Cloning ─────────────────────────────────────────────────────────────────
export function cloneState(state) {
return {
players: state.players.map((p) => ({
seat: p.seat,
stock: p.stock.map(cloneCard),
hand: p.hand.map(cloneCard),
discards: p.discards.map((pile) => pile.map(cloneCard)),
})),
buildPiles: state.buildPiles.map((pile) => pile.map(cloneCard)),
drawPile: state.drawPile.map(cloneCard),
completedPile: state.completedPile.map(cloneCard),
currentPlayer: state.currentPlayer,
phase: state.phase,
winner: state.winner,
seed: state.seed,
turnCount: state.turnCount,
};
}
function cloneCard(c) {
return { value: c.value, id: c.id, playedAs: c.playedAs };
}
// ── Queries ─────────────────────────────────────────────────────────────────
export function stockTop(state, seat) {
const stock = state.players[seat].stock;
return stock.length > 0 ? stock[stock.length - 1] : null;
}
export function discardTop(state, seat, discardIdx) {
const pile = state.players[seat].discards[discardIdx];
return pile.length > 0 ? pile[pile.length - 1] : null;
}
export function buildPileTopValue(state, buildIdx) {
const pile = state.buildPiles[buildIdx];
if (pile.length === 0) return 0; // next-required = 1
const top = pile[pile.length - 1];
return top.playedAs ?? top.value; // wild reports the value it was played as
}
export function nextRequired(state, buildIdx) {
const top = buildPileTopValue(state, buildIdx);
return top === 0 ? 1 : top + 1;
}
/** True if the (possibly wild) card can extend build pile `buildIdx`. */
export function canPlayOnBuild(state, card, buildIdx) {
const need = nextRequired(state, buildIdx);
if (need > 12) return false; // pile is full but not yet cleared
if (card.value === 'wild') return true;
return card.value === need;
}
/**
* Enumerate every legal play for the current player.
* Returns an array of `{ source, sourceIdx, buildIdx, card }`.
* source: 'stock' | 'hand' | 'discard'
* sourceIdx: hand index, discard pile index, or 0 for stock
* buildIdx: 0..3
*/
export function getValidPlays(state) {
if (state.phase !== 'play') return [];
const seat = state.currentPlayer;
const player = state.players[seat];
const plays = [];
const stockCard = stockTop(state, seat);
if (stockCard) {
for (let b = 0; b < BUILD_PILE_COUNT; b++) {
if (canPlayOnBuild(state, stockCard, b)) {
plays.push({ source: 'stock', sourceIdx: 0, buildIdx: b, card: stockCard });
}
}
}
for (let h = 0; h < player.hand.length; h++) {
const c = player.hand[h];
for (let b = 0; b < BUILD_PILE_COUNT; b++) {
if (canPlayOnBuild(state, c, b)) {
plays.push({ source: 'hand', sourceIdx: h, buildIdx: b, card: c });
}
}
}
for (let d = 0; d < DISCARD_PILE_COUNT; d++) {
const c = discardTop(state, seat, d);
if (!c) continue;
for (let b = 0; b < BUILD_PILE_COUNT; b++) {
if (canPlayOnBuild(state, c, b)) {
plays.push({ source: 'discard', sourceIdx: d, buildIdx: b, card: c });
}
}
}
return plays;
}
/** True if the current player has any legal play. (Cheaper than enumerating.) */
export function hasAnyPlay(state) {
return getValidPlays(state).length > 0;
}
// ── Mutations (always return a NEW state) ───────────────────────────────────
/**
* Apply a play. If the play is from stock and empties the stock pile, the
* winner is set. If a build pile reaches 12, it's cleared into completedPile.
* If the play empties the player's hand, refill to 5.
* For wild cards, `play.asNumber` is required.
*/
export function applyPlay(state, play) {
if (state.phase !== 'play') return state;
const next = cloneState(state);
const seat = next.currentPlayer;
const player = next.players[seat];
// Remove from source
let card;
if (play.source === 'stock') {
card = player.stock.pop();
} else if (play.source === 'hand') {
card = player.hand.splice(play.sourceIdx, 1)[0];
} else if (play.source === 'discard') {
card = player.discards[play.sourceIdx].pop();
}
if (!card) return state;
// Tag wild with its played-as number for later equality / completion checks
if (card.value === 'wild') {
const required = nextRequired(next, play.buildIdx);
card.playedAs = play.asNumber ?? required;
} else {
card.playedAs = null;
}
next.buildPiles[play.buildIdx].push(card);
// If pile completed (top is 12), recycle it
const topVal = buildPileTopValue(next, play.buildIdx);
if (topVal === 12) {
const cleared = next.buildPiles[play.buildIdx].splice(0);
for (const c of cleared) c.playedAs = null;
next.completedPile.push(...cleared);
}
// Check for win first — stock empty after a stock play wins immediately
if (player.stock.length === 0) {
next.phase = 'gameover';
next.winner = seat;
return next;
}
// If hand is empty mid-turn, immediately refill to 5
if (player.hand.length === 0) refillHand(next, seat);
return next;
}
/**
* End the current player's turn by discarding one hand card onto one of
* their discard piles. Advances `currentPlayer` and refills the next
* player's hand to 5.
*/
export function applyDiscard(state, handIdx, discardIdx) {
if (state.phase !== 'play') return state;
const next = cloneState(state);
const seat = next.currentPlayer;
const player = next.players[seat];
if (handIdx < 0 || handIdx >= player.hand.length) return state;
if (discardIdx < 0 || discardIdx >= DISCARD_PILE_COUNT) return state;
const [card] = player.hand.splice(handIdx, 1);
card.playedAs = null;
player.discards[discardIdx].push(card);
// Advance turn
next.currentPlayer = (seat + 1) % next.players.length;
next.turnCount += 1;
refillHand(next, next.currentPlayer);
return next;
}
// Convenience for UI — count cards in each pile per seat for the always-on
// HUD. The top stock card is always visible separately.
export function snapshotCounts(state) {
return state.players.map((p) => ({
seat: p.seat,
stock: p.stock.length,
stockTop: p.stock.length > 0 ? p.stock[p.stock.length - 1] : null,
hand: p.hand.length,
discards: p.discards.map((d) => d.length),
}));
}

View File

@ -0,0 +1,159 @@
// Heuristic Yatzi AI. Stateless functions consumed by YatziGame.
//
// `chooseDiceToHold(dice, scorecard, rollsRemaining)` → 5-bool hold mask
// `chooseCategory(dice, scorecard)` → category slug
// `shouldKeepRolling(holdMask, rollsRemaining)` → boolean
import {
UPPER, legalCategories, scoreForCommit, isYahtzee,
} from './YatziLogic.js';
export function chooseDiceToHold(dice, scorecard, rollsRemaining) {
const open = (c) => scorecard[c] === null;
const counts = {};
for (const d of dice) counts[d] = (counts[d] || 0) + 1;
// Yahtzee in hand — always hold all (Joker rules let us score it somewhere)
if (isYahtzee(dice)) return [true, true, true, true, true];
const face4 = faceWithCount(counts, 4);
const face3 = faceWithCount(counts, 3);
const pairs = Object.entries(counts).filter(([, c]) => c >= 2).map(([f]) => Number(f));
// 4-of-a-kind: hold the four; reroll the spare hunting Yahtzee
if (face4 !== null && (open('yahtzee') || open('fourOfKind') || open('threeOfKind'))) {
return dice.map((d) => d === face4);
}
// Full house in hand
if (face3 !== null && pairs.some((p) => p !== face3) && open('fullHouse')) {
return [true, true, true, true, true];
}
// Large straight in hand (5 in a row)
if (hasLargeStraight(dice) && open('largeStraight')) {
return [true, true, true, true, true];
}
// Small straight in hand (4 in a row) — hold those 4, reroll the dupe
const smallStraightHold = holdForSmallStraight(dice);
if (smallStraightHold && (open('smallStraight') || open('largeStraight'))) {
return smallStraightHold;
}
// 3-of-a-kind in hand
if (face3 !== null) {
if (open('threeOfKind') || open('fourOfKind') || open('yahtzee') || open(UPPER[face3 - 1])) {
return dice.map((d) => d === face3);
}
}
// 4-in-a-row distinct values — chase a straight if slots open
const run = longestConsecutive(dice);
if (run.length >= 4 && (open('largeStraight') || open('smallStraight'))) {
return maskMatchingValues(dice, run);
}
// 3-in-a-row + open straight goals — keep the run
if (run.length === 3 && rollsRemaining >= 1 && (open('largeStraight') || open('smallStraight'))) {
return maskMatchingValues(dice, run);
}
// Highest pair toward upper / 3-of-a-kind
if (pairs.length > 0) {
const bestPair = Math.max(...pairs);
if (open(UPPER[bestPair - 1]) || open('threeOfKind') || open('fourOfKind') || open('yahtzee')) {
return dice.map((d) => d === bestPair);
}
}
// Nothing matched: keep individual high pips whose upper slot is open
const held = [false, false, false, false, false];
for (let i = 0; i < 5; i++) {
const f = dice[i];
if (f >= 4 && open(UPPER[f - 1])) held[i] = true;
}
return held;
}
// AI decides to stop early if every die is held (further rolls do nothing).
export function shouldKeepRolling(holdMask, rollsRemaining) {
if (rollsRemaining <= 0) return false;
return holdMask.some((h) => !h);
}
// Pick the category to commit to. Best score wins; ties resolved by
// sacrificing the cheapest slot first.
export function chooseCategory(dice, scorecard) {
const legal = legalCategories(dice, scorecard);
if (legal.length === 0) return null;
const scored = legal.map((c) => ({ c, v: scoreForCommit(dice, c, scorecard) ?? 0 }));
scored.sort((a, b) => {
if (b.v !== a.v) return b.v - a.v;
return sacrificeRank(a.c) - sacrificeRank(b.c);
});
return scored[0].c;
}
// Lower rank = cheaper to zero out when no slot scores positive
const SACRIFICE_ORDER = [
'ones', 'twos', 'threes',
'fourOfKind', 'threeOfKind',
'fours', 'fives',
'fullHouse', 'smallStraight',
'sixes',
'largeStraight', 'chance', 'yahtzee',
];
function sacrificeRank(category) {
const i = SACRIFICE_ORDER.indexOf(category);
return i === -1 ? 99 : i;
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function faceWithCount(counts, n) {
for (const [f, c] of Object.entries(counts)) {
if (c >= n) return Number(f);
}
return null;
}
function hasLargeStraight(dice) {
const s = new Set(dice);
return [[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]].some((seq) => seq.every((n) => s.has(n)));
}
function holdForSmallStraight(dice) {
const s = new Set(dice);
for (const seq of [[1, 2, 3, 4], [2, 3, 4, 5], [3, 4, 5, 6]]) {
if (seq.every((n) => s.has(n))) {
return maskMatchingValues(dice, seq);
}
}
return null;
}
// Build a hold mask that picks one die per target value (in order).
function maskMatchingValues(dice, targets) {
const need = {};
for (const t of targets) need[t] = (need[t] ?? 0) + 1;
const mask = [false, false, false, false, false];
for (let i = 0; i < 5; i++) {
if (need[dice[i]] > 0) { mask[i] = true; need[dice[i]] -= 1; }
}
return mask;
}
// Longest run of consecutive distinct values in dice.
function longestConsecutive(dice) {
const unique = [...new Set(dice)].sort((a, b) => a - b);
if (unique.length === 0) return [];
let best = [unique[0]];
let cur = [unique[0]];
for (let i = 1; i < unique.length; i++) {
if (unique[i] === unique[i - 1] + 1) cur.push(unique[i]);
else cur = [unique[i]];
if (cur.length > best.length) best = [...cur];
}
return best;
}

View File

@ -0,0 +1,700 @@
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 { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import {
CATEGORIES, UPPER, LOWER, CATEGORY_LABELS,
createInitialState, rollDice, toggleHold, commitScore,
computeTotals, baseScore, scoreForCommit, legalCategories,
getWinners,
} from './YatziLogic.js';
import { chooseDiceToHold, chooseCategory, shouldKeepRolling } from './YatziAI.js';
// ─── Layout ───────────────────────────────────────────────────────────────
const LEFT_CX = 480;
const DICE_SIZE = 104;
const DICE_GAP = 22;
const DICE_TOTAL_W = 5 * DICE_SIZE + 4 * DICE_GAP; // 608
const DICE_LEFT = LEFT_CX - DICE_TOTAL_W / 2; // 176
const DICE_Y = 480;
// Scorecard panel on the right half
const SC_X = 980; // left edge of panel
const SC_RIGHT_MARGIN = 40;
const SC_W = GAME_WIDTH - SC_X - SC_RIGHT_MARGIN; // 900
const SC_CAT_W = 220;
const SC_PLAYERS_W = SC_W - SC_CAT_W; // shared among N player columns
const SC_TOP = 60;
const SC_HEADER_H = 50;
const SC_ROW_H = 42;
const SC_SUMMARY_H = 40;
const SC_GRAND_H = 54;
// Portrait column
const PORTRAIT_X = 90;
const PORTRAIT_R = 48;
const PORTRAIT_TOP = 200;
const PORTRAIT_GAP = 124;
const DEPTH = {
bg: -1, panel: 0, grid: 1, cellBg: 2,
cellText: 3, preview: 4, hover: 5,
dieBox: 10, diePip: 11, dieFrame: 12, dieHit: 13,
ui: 20, toast: 50, modal: 60,
};
// 3x3 pip layout (col, row offsets in {-1, 0, 1}) per face
const PIP_POS = {
1: [[0, 0]],
2: [[-1, -1], [1, 1]],
3: [[-1, -1], [0, 0], [1, 1]],
4: [[-1, -1], [1, -1], [-1, 1], [1, 1]],
5: [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
6: [[-1, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [1, 1]],
};
const ROW_INDEX = (() => {
// y-offset of each category row from top of its section
const m = {};
UPPER.forEach((c, i) => { m[c] = i; });
LOWER.forEach((c, i) => { m[c] = i; });
return m;
})();
export default class YatziGame extends Phaser.Scene {
constructor() { super('YatziGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.gs = null;
this.animating = false;
this.aiRunning = false;
this.gameOverShown = false;
this.dieEls = []; // [{ frame: Graphics, face: Graphics, hit: Zone, currentFace }]
this.cellText = {}; // `${pi}-${cat}` → Text
this.cellHit = {}; // `${pi}-${cat}` → Zone
this.cellHoverGfx = null; // single hover highlight rect
this.columnHeaderText = []; // [Text per player]
this.activeColumnGfx = null;
this.summaryRefs = []; // [{ upperSub, upperBonus, lowerSub, yahtzBonus, grand }]
this.portraitCtrls = []; // [{ ring, controller }]
this.rollBtn = null;
this.rollsText = null;
this.statusText = null;
this.toastText = null;
}
async create() {
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg).setDepth(DEPTH.bg);
// Compose players: human first, then AI opponents
const playerNames = [
{ name: auth.user?.username ?? 'You', isAI: false },
...this.opponents.map((o) => ({ name: o.name ?? o.id ?? 'Bot', isAI: true, avatar: o })),
];
this.gs = createInitialState({ playerNames });
this.buildLeftPanel();
this.buildScorecard();
this.buildDice();
this.buildButtons();
this.buildPortraits();
this.buildHover();
new Button(this, 80, GAME_HEIGHT - 50, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 140, fontSize: 20,
});
this.renderAllDice();
this.updateScorecard();
this.updateActiveColumn();
this.updateControls();
this.time.delayedCall(450, () => this.nextTurn());
}
// ─── Left panel (title + status) ──────────────────────────────────────
buildLeftPanel() {
this.add.text(LEFT_CX, 56, 'Yatzi', {
fontFamily: 'Righteous', fontSize: '60px', color: COLORS.textHex,
}).setOrigin(0.5);
this.statusText = this.add.text(LEFT_CX, 130, '', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.accentHex,
}).setOrigin(0.5);
this.rollsText = this.add.text(LEFT_CX, 740, '', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5);
this.add.text(LEFT_CX, 800, 'Click a die to hold it between rolls.', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5);
}
// ─── Scorecard ────────────────────────────────────────────────────────
buildScorecard() {
const N = this.gs.players.length;
const colW = SC_PLAYERS_W / N;
// Background panel
const totalH = SC_HEADER_H
+ UPPER.length * SC_ROW_H
+ SC_SUMMARY_H * 2
+ LOWER.length * SC_ROW_H
+ SC_SUMMARY_H * 2
+ SC_GRAND_H;
this.add.rectangle(SC_X + SC_W / 2, SC_TOP + totalH / 2, SC_W + 4, totalH + 4, COLORS.panel)
.setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.panel);
const grid = this.add.graphics().setDepth(DEPTH.grid);
grid.lineStyle(1, COLORS.muted, 0.4);
// Header row
let y = SC_TOP;
this.drawRowDivider(grid, y);
this.drawRowDivider(grid, y + SC_HEADER_H);
this.add.text(SC_X + 14, y + SC_HEADER_H / 2, 'Category', {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.cellText);
for (let p = 0; p < N; p++) {
const cx = SC_X + SC_CAT_W + colW * p + colW / 2;
const name = this.gs.players[p].name;
const trimmed = name.length > 10 ? name.slice(0, 9) + '…' : name;
const t = this.add.text(cx, y + SC_HEADER_H / 2, trimmed, {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.cellText);
this.columnHeaderText.push(t);
}
// Vertical lines
grid.beginPath();
grid.moveTo(SC_X + SC_CAT_W, y);
grid.lineTo(SC_X + SC_CAT_W, y + totalH);
for (let p = 1; p < N; p++) {
const x = SC_X + SC_CAT_W + colW * p;
grid.moveTo(x, y); grid.lineTo(x, y + totalH);
}
grid.strokePath();
y += SC_HEADER_H;
// ─── Upper section ──────────────────────────────────────────────
for (const cat of UPPER) {
this.buildCategoryRow(grid, y, cat, colW, false);
y += SC_ROW_H;
}
// Upper subtotal
this.buildSummaryRow(grid, y, 'Upper subtotal', 'upperSubtotal', colW);
y += SC_SUMMARY_H;
// Upper bonus
this.buildSummaryRow(grid, y, 'Bonus (≥63 → +35)', 'upperBonus', colW);
y += SC_SUMMARY_H;
// ─── Lower section ──────────────────────────────────────────────
for (const cat of LOWER) {
this.buildCategoryRow(grid, y, cat, colW, true);
y += SC_ROW_H;
}
// Lower subtotal
this.buildSummaryRow(grid, y, 'Lower subtotal', 'lowerSubtotal', colW);
y += SC_SUMMARY_H;
// Yahtzee bonus
this.buildSummaryRow(grid, y, 'Yahtzee bonus', 'yahtzeeBonus', colW);
y += SC_SUMMARY_H;
// Grand total
this.drawRowDivider(grid, y);
this.drawRowDivider(grid, y + SC_GRAND_H);
this.add.text(SC_X + 14, y + SC_GRAND_H / 2, 'GRAND TOTAL', {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.goldHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.cellText);
for (let p = 0; p < N; p++) {
const cx = SC_X + SC_CAT_W + colW * p + colW / 2;
const t = this.add.text(cx, y + SC_GRAND_H / 2, '0', {
fontFamily: 'Righteous', fontSize: '28px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.cellText);
this.summaryRefs[p] = this.summaryRefs[p] ?? {};
this.summaryRefs[p].grand = t;
}
// Active column outline (drawn later, updated each turn)
this.activeColumnGfx = this.add.graphics().setDepth(DEPTH.hover);
this._scLastY = y + SC_GRAND_H;
this._scColW = colW;
this._scN = N;
}
drawRowDivider(grid, y) {
grid.beginPath();
grid.moveTo(SC_X, y);
grid.lineTo(SC_X + SC_W, y);
grid.strokePath();
}
buildCategoryRow(grid, y, cat, colW, isLower) {
this.drawRowDivider(grid, y);
this.add.text(SC_X + 14, y + SC_ROW_H / 2, CATEGORY_LABELS[cat], {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.cellText);
for (let p = 0; p < this.gs.players.length; p++) {
const cx = SC_X + SC_CAT_W + colW * p + colW / 2;
const cy = y + SC_ROW_H / 2;
const t = this.add.text(cx, cy, '', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.cellText);
this.cellText[`${p}-${cat}`] = t;
// Hit zone for clicking
const cellX = SC_X + SC_CAT_W + colW * p;
const zone = this.add.zone(cellX, y, colW, SC_ROW_H).setOrigin(0, 0).setDepth(DEPTH.cellBg);
zone.setInteractive({ useHandCursor: true });
zone.on('pointerover', () => this.onCellHover(p, cat, true));
zone.on('pointerout', () => this.onCellHover(p, cat, false));
zone.on('pointerdown', () => this.onCellClick(p, cat));
this.cellHit[`${p}-${cat}`] = zone;
}
}
buildSummaryRow(grid, y, label, key, colW) {
this.drawRowDivider(grid, y);
this.add.text(SC_X + 14, y + SC_SUMMARY_H / 2, label, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
fontStyle: 'italic',
}).setOrigin(0, 0.5).setDepth(DEPTH.cellText);
for (let p = 0; p < this.gs.players.length; p++) {
const cx = SC_X + SC_CAT_W + colW * p + colW / 2;
const cy = y + SC_SUMMARY_H / 2;
const t = this.add.text(cx, cy, '0', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.cellText);
this.summaryRefs[p] = this.summaryRefs[p] ?? {};
this.summaryRefs[p][key] = t;
}
}
buildHover() {
this.cellHoverGfx = this.add.graphics().setDepth(DEPTH.hover);
}
// ─── Dice ──────────────────────────────────────────────────────────────
buildDice() {
for (let i = 0; i < 5; i++) {
const x = DICE_LEFT + i * (DICE_SIZE + DICE_GAP) + DICE_SIZE / 2;
const y = DICE_Y;
const frame = this.add.graphics().setDepth(DEPTH.dieBox);
const face = this.add.graphics().setDepth(DEPTH.diePip);
const hit = this.add.zone(x, y, DICE_SIZE, DICE_SIZE).setOrigin(0.5).setDepth(DEPTH.dieHit);
hit.setInteractive({ useHandCursor: true });
hit.on('pointerdown', () => this.onDieClick(i));
this.dieEls.push({ frame, face, hit, cx: x, cy: y, currentFace: 1 });
}
}
renderAllDice() {
for (let i = 0; i < 5; i++) {
this.renderDieFace(i, this.gs.dice[i], this.gs.held[i]);
}
}
renderDieFace(idx, face, held) {
const el = this.dieEls[idx];
el.currentFace = face;
const x = el.cx, y = el.cy;
const half = DICE_SIZE / 2;
const r = 14;
// Frame
el.frame.clear();
const borderColor = held ? COLORS.accent : COLORS.muted;
const borderWidth = held ? 5 : 2;
el.frame.fillStyle(0xf2ead8, 1); // cream face
el.frame.fillRoundedRect(x - half, y - half, DICE_SIZE, DICE_SIZE, r);
el.frame.lineStyle(borderWidth, borderColor, 1);
el.frame.strokeRoundedRect(x - half, y - half, DICE_SIZE, DICE_SIZE, r);
// Pips
el.face.clear();
el.face.fillStyle(0x1a1208, 1);
const pipR = 9;
const off = DICE_SIZE * 0.28;
for (const [cx, cy] of PIP_POS[face]) {
el.face.fillCircle(x + cx * off, y + cy * off, pipR);
}
}
onDieClick(idx) {
if (this.animating || this.aiRunning) return;
if (this.gs.players[this.gs.current].isAI) return;
if (this.gs.rollsRemaining === 3 || this.gs.rollsRemaining === 0) return;
this.gs = toggleHold(this.gs, idx);
this.renderDieFace(idx, this.gs.dice[idx], this.gs.held[idx]);
}
// ─── Buttons ───────────────────────────────────────────────────────────
buildButtons() {
this.rollBtn = new Button(this, LEFT_CX, 660, 'Roll', () => this.onRollClick(), { width: 240, fontSize: 28 });
}
onRollClick() {
if (this.animating || this.aiRunning) return;
if (this.gs.players[this.gs.current].isAI) return;
if (this.gs.rollsRemaining <= 0) return;
this.doRoll();
}
async doRoll() {
const next = rollDice(this.gs);
await this.animateRoll(next.dice);
this.gs = next;
this.renderAllDice();
this.updateScorecard();
this.updateControls();
}
async animateRoll(targetDice) {
this.animating = true;
return new Promise((resolve) => {
this.tweens.addCounter({
from: 0, to: 1, duration: 550, ease: 'Quad.Out',
onUpdate: () => {
for (let i = 0; i < 5; i++) {
if (this.gs.held[i]) continue;
const fake = Math.ceil(Math.random() * 6);
this.renderDieFace(i, fake, false);
}
},
onComplete: () => {
for (let i = 0; i < 5; i++) {
this.renderDieFace(i, targetDice[i], this.gs.held[i]);
}
this.animating = false;
resolve();
},
});
});
}
// ─── Portraits ─────────────────────────────────────────────────────────
buildPortraits() {
const y0 = PORTRAIT_TOP;
// Player (human)
const ring0 = this.add.graphics().setDepth(DEPTH.ui);
const ctrl0 = createPlayerPortrait(this, PORTRAIT_X, y0, PORTRAIT_R, DEPTH.ui, 'YatziGame');
this.portraitCtrls.push({ ring: ring0, controller: ctrl0, x: PORTRAIT_X, y: y0 });
this.add.text(PORTRAIT_X, y0 + PORTRAIT_R + 18, this.gs.players[0].name, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5);
for (let i = 1; i < this.gs.players.length; i++) {
const py = y0 + i * PORTRAIT_GAP;
const ring = this.add.graphics().setDepth(DEPTH.ui);
const opp = this.opponents[i - 1] ?? { id: 'bot', spriteIndex: 0 };
const ctrl = createOpponentPortrait(this, opp, PORTRAIT_X, py, PORTRAIT_R, DEPTH.ui);
this.portraitCtrls.push({ ring, controller: ctrl, x: PORTRAIT_X, y: py });
this.add.text(PORTRAIT_X, py + PORTRAIT_R + 18, this.gs.players[i].name, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5);
}
}
updateActivePortraitRing() {
for (let i = 0; i < this.portraitCtrls.length; i++) {
const { ring, x, y } = this.portraitCtrls[i];
ring.clear();
if (i === this.gs.current) {
ring.lineStyle(4, COLORS.gold, 1);
ring.strokeCircle(x, y, PORTRAIT_R + 6);
}
}
}
// ─── Active column outline ─────────────────────────────────────────────
updateActiveColumn() {
const g = this.activeColumnGfx;
g.clear();
const colW = this._scColW;
const N = this._scN;
if (this.gs.current >= N) return;
const x = SC_X + SC_CAT_W + colW * this.gs.current;
const y = SC_TOP;
const h = this._scLastY - SC_TOP;
g.lineStyle(4, COLORS.accent, 1);
g.strokeRect(x + 1, y + 1, colW - 2, h - 2);
// Header color emphasis
for (let p = 0; p < N; p++) {
this.columnHeaderText[p].setColor(p === this.gs.current ? COLORS.accentHex : COLORS.textHex);
}
}
// ─── Cell hover preview / committed score render ───────────────────────
updateScorecard() {
const N = this.gs.players.length;
for (let p = 0; p < N; p++) {
const player = this.gs.players[p];
for (const cat of CATEGORIES) {
const t = this.cellText[`${p}-${cat}`];
const v = player.scorecard[cat];
if (v !== null) {
t.setText(String(v));
t.setColor(COLORS.textHex);
t.setAlpha(1);
} else if (p === this.gs.current && !player.isAI && this.gs.rollsRemaining < 3) {
// Preview score for the human, after the first roll
const legal = legalCategories(this.gs.dice, player.scorecard);
if (legal.includes(cat)) {
const preview = scoreForCommit(this.gs.dice, cat, player.scorecard) ?? 0;
t.setText(String(preview));
t.setColor(preview > 0 ? COLORS.accentHex : COLORS.mutedHex);
t.setAlpha(0.65);
} else {
t.setText('');
}
} else {
t.setText('');
}
}
// Summary rows
const totals = computeTotals(player);
this.summaryRefs[p].upperSubtotal.setText(String(totals.upperSubtotal));
this.summaryRefs[p].upperBonus.setText(String(totals.upperBonus));
this.summaryRefs[p].lowerSubtotal.setText(String(totals.lowerSubtotal));
this.summaryRefs[p].yahtzeeBonus.setText(String(totals.yahtzeeBonus));
this.summaryRefs[p].grand.setText(String(totals.grand));
}
}
onCellHover(p, cat, entering) {
const g = this.cellHoverGfx;
g.clear();
if (!entering) return;
if (this.animating || this.aiRunning) return;
const cur = this.gs.players[this.gs.current];
if (p !== this.gs.current || cur.isAI) return;
if (this.gs.rollsRemaining === 3) return;
if (cur.scorecard[cat] !== null) return;
const legal = legalCategories(this.gs.dice, cur.scorecard);
if (!legal.includes(cat)) return;
const zone = this.cellHit[`${p}-${cat}`];
g.lineStyle(2, COLORS.gold, 1);
g.strokeRect(zone.x + 2, zone.y + 2, zone.width - 4, zone.height - 4);
}
onCellClick(p, cat) {
if (this.animating || this.aiRunning) return;
if (this.gameOverShown) return;
const cur = this.gs.players[this.gs.current];
if (p !== this.gs.current || cur.isAI) return;
if (this.gs.rollsRemaining === 3) return;
if (cur.scorecard[cat] !== null) return;
const legal = legalCategories(this.gs.dice, cur.scorecard);
if (!legal.includes(cat)) return;
this.commitCategory(cat);
}
commitCategory(cat) {
const before = this.gs;
const after = commitScore(before, cat);
if (after === before) return;
this.gs = after;
if (after.lastBonusGained) this.showBonusToast();
this.cellHoverGfx?.clear();
this.renderAllDice();
this.updateScorecard();
this.updateActiveColumn();
this.updateControls();
if (this.gs.phase === 'gameover') {
this.showGameOverModal();
} else {
this.time.delayedCall(350, () => this.nextTurn());
}
}
showBonusToast() {
if (this.toastText) this.toastText.destroy();
this.toastText = this.add.text(LEFT_CX, 380, '+100 Yahtzee Bonus!', {
fontFamily: 'Righteous', fontSize: '32px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.toast);
this.tweens.add({
targets: this.toastText, alpha: { from: 1, to: 0 }, y: 340, duration: 1600,
onComplete: () => { this.toastText?.destroy(); this.toastText = null; },
});
}
// ─── Turn flow ─────────────────────────────────────────────────────────
nextTurn() {
if (this.gs.phase === 'gameover') {
this.showGameOverModal();
return;
}
this.updateActiveColumn();
this.updateActivePortraitRing();
this.renderAllDice();
this.updateScorecard();
this.updateControls();
const cur = this.gs.players[this.gs.current];
if (cur.isAI) {
this.runAITurn();
}
}
async runAITurn() {
this.aiRunning = true;
this.updateControls();
await this.delay(500);
let keepRolling = true;
while (keepRolling && this.gs.rollsRemaining > 0) {
await this.doRoll();
if (this.gs.rollsRemaining === 0) break;
await this.delay(450);
const mask = chooseDiceToHold(
this.gs.dice,
this.gs.players[this.gs.current].scorecard,
this.gs.rollsRemaining,
);
// Apply hold mask visibly
for (let i = 0; i < 5; i++) {
if (mask[i] !== this.gs.held[i]) {
this.gs.held[i] = mask[i];
this.renderDieFace(i, this.gs.dice[i], this.gs.held[i]);
}
}
await this.delay(500);
keepRolling = shouldKeepRolling(mask, this.gs.rollsRemaining);
}
await this.delay(500);
const cat = chooseCategory(
this.gs.dice,
this.gs.players[this.gs.current].scorecard,
);
if (cat) {
this.flashCell(this.gs.current, cat);
await this.delay(500);
this.aiRunning = false;
this.commitCategory(cat);
} else {
this.aiRunning = false;
}
}
flashCell(p, cat) {
const zone = this.cellHit[`${p}-${cat}`];
if (!zone) return;
const flash = this.add.graphics().setDepth(DEPTH.hover);
flash.fillStyle(COLORS.accent, 0.4);
flash.fillRect(zone.x + 2, zone.y + 2, zone.width - 4, zone.height - 4);
this.tweens.add({
targets: flash, alpha: { from: 0.6, to: 0 }, duration: 450,
onComplete: () => flash.destroy(),
});
}
// ─── Controls update ───────────────────────────────────────────────────
updateControls() {
const cur = this.gs.players[this.gs.current];
const isHuman = !cur.isAI;
const canRoll = isHuman && this.gs.rollsRemaining > 0 && !this.animating && !this.aiRunning && this.gs.phase !== 'gameover';
this.rollBtn?.setEnabled(canRoll);
this.rollBtn?.setLabel(this.gs.rollsRemaining === 3 ? 'Roll' : 'Re-roll');
this.rollsText?.setText(this.gs.phase === 'gameover' ? '' : `Rolls remaining: ${this.gs.rollsRemaining}`);
if (this.gs.phase === 'gameover') {
this.statusText.setText('Game over');
} else if (cur.isAI) {
this.statusText.setText(`${cur.name}'s turn`);
} else if (this.gs.rollsRemaining === 3) {
this.statusText.setText('Your turn — roll the dice');
} else if (this.gs.rollsRemaining === 0) {
this.statusText.setText('Pick a category to score');
} else {
this.statusText.setText('Hold dice or re-roll');
}
}
// ─── Game over ─────────────────────────────────────────────────────────
showGameOverModal() {
if (this.gameOverShown) return;
this.gameOverShown = true;
// Submit to server (silent on failure)
this.postHistory().catch(() => {});
const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65)
.setInteractive().setDepth(DEPTH.modal);
const panelW = 760;
const N = this.gs.players.length;
const panelH = 220 + N * 60;
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, panelW, panelH, COLORS.panel, 1)
.setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.modal);
const cy = GAME_HEIGHT / 2;
this.add.text(GAME_WIDTH / 2, cy - panelH / 2 + 50, 'Final Score', {
fontFamily: 'Righteous', fontSize: '42px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.modal);
const winners = new Set(getWinners(this.gs));
let rowY = cy - panelH / 2 + 110;
for (let p = 0; p < N; p++) {
const totals = computeTotals(this.gs.players[p]);
const isWinner = winners.has(p);
const color = isWinner ? COLORS.goldHex : COLORS.textHex;
const prefix = isWinner ? '★ ' : ' ';
this.add.text(GAME_WIDTH / 2 - panelW / 2 + 40, rowY, `${prefix}${this.gs.players[p].name}`, {
fontFamily: 'Righteous', fontSize: '24px', color,
}).setOrigin(0, 0.5).setDepth(DEPTH.modal);
this.add.text(GAME_WIDTH / 2 + panelW / 2 - 40, rowY, String(totals.grand), {
fontFamily: 'Righteous', fontSize: '28px', color,
}).setOrigin(1, 0.5).setDepth(DEPTH.modal);
rowY += 50;
}
new Button(this, GAME_WIDTH / 2, cy + panelH / 2 - 50, 'Back to Menu',
() => this.scene.start('GameMenu'),
{ width: 280, fontSize: 24 },
).setDepth(DEPTH.modal);
}
async postHistory() {
const N = this.gs.players.length;
const totals = this.gs.players.map((p) => computeTotals(p).grand);
const human = totals[0];
const winners = new Set(getWinners(this.gs));
let result;
if (N === 1) {
result = 'win';
} else if (winners.has(0) && winners.size === 1) {
result = 'win';
} else if (winners.has(0)) {
result = 'draw';
} else {
result = 'loss';
}
await api.post('/history/single-player', {
slug: 'yatzi',
score: human,
opponentScores: totals.slice(1),
result,
});
}
// ─── Utility ───────────────────────────────────────────────────────────
delay(ms) {
return new Promise((resolve) => this.time.delayedCall(ms, resolve));
}
}

View File

@ -0,0 +1,243 @@
// Pure Yahtzee rules. No Phaser dependency.
//
// Categories (13):
// Upper: ones, twos, threes, fours, fives, sixes (sum of those faces)
// Lower: threeOfKind, fourOfKind, fullHouse(25), smallStraight(30),
// largeStraight(40), yahtzee(50), chance(sum)
//
// Scoring extras:
// - Upper bonus: +35 if upper subtotal ≥ 63
// - Yahtzee bonus: +100 per subsequent Yahtzee after the first scored 50,
// with Joker placement rules:
// 1. If matching upper section is empty, must use it (scores face×5).
// 2. Else any lower section: fullHouse=25, smallStraight=30,
// largeStraight=40 even when pattern doesn't strictly match;
// threeOfKind / fourOfKind / chance score sum of dice.
// 3. Else any upper section may be zeroed.
// If yahtzee slot was already zeroed (player scored 0 there earlier),
// Joker placement still applies but no +100 bonus accrues.
export const UPPER = ['ones', 'twos', 'threes', 'fours', 'fives', 'sixes'];
export const LOWER = ['threeOfKind', 'fourOfKind', 'fullHouse', 'smallStraight', 'largeStraight', 'yahtzee', 'chance'];
export const CATEGORIES = [...UPPER, ...LOWER];
export const CATEGORY_LABELS = {
ones: 'Aces',
twos: 'Twos',
threes: 'Threes',
fours: 'Fours',
fives: 'Fives',
sixes: 'Sixes',
threeOfKind: '3 of a Kind',
fourOfKind: '4 of a Kind',
fullHouse: 'Full House',
smallStraight: 'Sm. Straight',
largeStraight: 'Lg. Straight',
yahtzee: 'Yahtzee',
chance: 'Chance',
};
export function createInitialState({ playerNames }) {
const players = playerNames.map((p) => ({
name: p.name,
isAI: !!p.isAI,
avatar: p.avatar ?? null,
scorecard: Object.fromEntries(CATEGORIES.map((c) => [c, null])),
yahtzeeBonusCount: 0,
}));
return {
players,
current: 0,
dice: [1, 1, 1, 1, 1],
held: [false, false, false, false, false],
rollsRemaining: 3,
phase: 'preroll', // preroll → rolling → scoring → gameover
lastBonusGained: false, // transient flag set when last commitScore awarded a Yahtzee bonus
};
}
export function cloneState(state) {
return JSON.parse(JSON.stringify(state));
}
// ─── Dice ──────────────────────────────────────────────────────────────────
export function rollDice(state) {
if (state.rollsRemaining <= 0) return state;
const s = cloneState(state);
for (let i = 0; i < 5; i++) {
if (!s.held[i]) s.dice[i] = Math.ceil(Math.random() * 6);
}
s.rollsRemaining -= 1;
s.phase = s.rollsRemaining > 0 ? 'rolling' : 'scoring';
s.lastBonusGained = false;
return s;
}
export function setDice(state, dice) {
const s = cloneState(state);
s.dice = [...dice];
return s;
}
export function toggleHold(state, idx) {
if (state.phase === 'preroll' || state.phase === 'gameover') return state;
if (state.rollsRemaining <= 0) return state; // no point holding once all rolls used
const s = cloneState(state);
s.held[idx] = !s.held[idx];
return s;
}
// ─── Scoring primitives ────────────────────────────────────────────────────
const sumDice = (dice) => dice.reduce((a, b) => a + b, 0);
function diceCounts(dice) {
const c = {};
for (const d of dice) c[d] = (c[d] || 0) + 1;
return c;
}
function nOfAKind(dice, n) {
return Object.values(diceCounts(dice)).some((v) => v >= n);
}
function isFullHouse(dice) {
const c = Object.values(diceCounts(dice)).sort((a, b) => a - b);
return c.length === 2 && c[0] === 2 && c[1] === 3;
}
function isSmallStraight(dice) {
const s = new Set(dice);
return [[1, 2, 3, 4], [2, 3, 4, 5], [3, 4, 5, 6]].some((seq) => seq.every((n) => s.has(n)));
}
function isLargeStraight(dice) {
const s = new Set(dice);
return [[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]].some((seq) => seq.every((n) => s.has(n)));
}
export function isYahtzee(dice) {
return new Set(dice).size === 1;
}
// Base (non-Joker) score for a category given dice.
export function baseScore(dice, category) {
switch (category) {
case 'ones': return dice.filter((d) => d === 1).length * 1;
case 'twos': return dice.filter((d) => d === 2).length * 2;
case 'threes': return dice.filter((d) => d === 3).length * 3;
case 'fours': return dice.filter((d) => d === 4).length * 4;
case 'fives': return dice.filter((d) => d === 5).length * 5;
case 'sixes': return dice.filter((d) => d === 6).length * 6;
case 'threeOfKind': return nOfAKind(dice, 3) ? sumDice(dice) : 0;
case 'fourOfKind': return nOfAKind(dice, 4) ? sumDice(dice) : 0;
case 'fullHouse': return isFullHouse(dice) ? 25 : 0;
case 'smallStraight': return isSmallStraight(dice) ? 30 : 0;
case 'largeStraight': return isLargeStraight(dice) ? 40 : 0;
case 'yahtzee': return isYahtzee(dice) ? 50 : 0;
case 'chance': return sumDice(dice);
default: return 0;
}
}
// Whether Joker rules apply to this roll.
function jokerStatus(dice, scorecard) {
if (!isYahtzee(dice)) return { applies: false, bonus: false };
if (scorecard.yahtzee === 50) return { applies: true, bonus: true };
if (scorecard.yahtzee === 0) return { applies: true, bonus: false };
return { applies: false, bonus: false };
}
// Categories that are legal placements for these dice + scorecard.
export function legalCategories(dice, scorecard) {
const open = CATEGORIES.filter((c) => scorecard[c] === null);
if (open.length === 0) return [];
const j = jokerStatus(dice, scorecard);
if (!j.applies) return open;
const face = dice[0];
const matchingUpper = UPPER[face - 1];
// Rule 1: matching upper must be used first if still empty.
if (scorecard[matchingUpper] === null) {
return [matchingUpper];
}
// Rule 2: any open category may be used (lower-section Jokers + upper zeroes).
return open;
}
// Compute committed value for a category, honoring Joker substitutions.
// Returns null if placement is illegal.
export function scoreForCommit(dice, category, scorecard) {
if (scorecard[category] !== null) return null;
const legal = legalCategories(dice, scorecard);
if (!legal.includes(category)) return null;
const j = jokerStatus(dice, scorecard);
if (!j.applies) return baseScore(dice, category);
// Joker substitution: lower-section specials score their face values
// even if dice don't strictly match the pattern.
switch (category) {
case 'fullHouse': return 25;
case 'smallStraight': return 30;
case 'largeStraight': return 40;
case 'threeOfKind':
case 'fourOfKind':
case 'chance': return sumDice(dice);
case 'yahtzee': return 50; // unreachable: already filled when Joker applies
default: return baseScore(dice, category); // upper: face * count (zero unless matching upper used)
}
}
// Commit current dice to a category; advances turn. Pure — returns new state.
export function commitScore(state, category) {
if (state.phase !== 'rolling' && state.phase !== 'scoring') return state;
const player = state.players[state.current];
const value = scoreForCommit(state.dice, category, player.scorecard);
if (value === null) return state;
const s = cloneState(state);
const cur = s.players[s.current];
const j = jokerStatus(s.dice, cur.scorecard);
cur.scorecard[category] = value;
s.lastBonusGained = false;
if (j.bonus) {
cur.yahtzeeBonusCount += 1;
s.lastBonusGained = true;
}
if (isGameOver(s)) {
s.phase = 'gameover';
return s;
}
// Advance turn
s.current = (s.current + 1) % s.players.length;
s.dice = [1, 1, 1, 1, 1];
s.held = [false, false, false, false, false];
s.rollsRemaining = 3;
s.phase = 'preroll';
return s;
}
export function isGameOver(state) {
return state.players.every((p) => CATEGORIES.every((c) => p.scorecard[c] !== null));
}
export function computeTotals(player) {
const upperSubtotal = UPPER.reduce((a, c) => a + (player.scorecard[c] ?? 0), 0);
const upperBonus = upperSubtotal >= 63 ? 35 : 0;
const lowerSubtotal = LOWER.reduce((a, c) => a + (player.scorecard[c] ?? 0), 0);
const yahtzeeBonus = player.yahtzeeBonusCount * 100;
const grand = upperSubtotal + upperBonus + lowerSubtotal + yahtzeeBonus;
return { upperSubtotal, upperBonus, lowerSubtotal, yahtzeeBonus, grand };
}
// Returns array of player indices with max grand total (ties supported).
export function getWinners(state) {
const totals = state.players.map((p, i) => ({ idx: i, total: computeTotals(p).grand }));
const max = Math.max(...totals.map((t) => t.total));
return totals.filter((t) => t.total === max).map((t) => t.idx);
}

View File

@ -15,6 +15,9 @@ import BackgammonGame from './games/backgammon/BackgammonGame.js';
import HoldemGame from './games/holdem/HoldemGame.js';
import BlackjackGame from './games/blackjack/BlackjackGame.js';
import ParchisiGame from './games/parchisi/ParchisiGame.js';
import YatziGame from './games/yatzi/YatziGame.js';
import SkipBoGame from './games/skipbo/SkipBoGame.js';
import Phase10Game from './games/phase10/Phase10Game.js';
const config = {
type: Phaser.AUTO,
@ -43,6 +46,9 @@ const config = {
HoldemGame,
BlackjackGame,
ParchisiGame,
YatziGame,
SkipBoGame,
Phase10Game,
],
};

View File

@ -18,7 +18,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -0,0 +1,66 @@
import { Router } from 'express';
import db from '../db/index.js';
import { requireAuth } from '../auth/middleware.js';
import { getGame } from '../multiplayer/gameRegistry.js';
const router = Router();
const RESULTS = new Set(['win', 'loss', 'draw']);
// POST /api/history/single-player
// Body: { slug, score, opponentScores: number[], result }
// Records a finished single-player vs-AI match for the signed-in user.
// AI opponents do not get match_players rows (user_id is a FK to users).
router.post('/single-player', requireAuth, (req, res) => {
const { slug, score, opponentScores, result } = req.body ?? {};
if (typeof slug !== 'string' || !slug) {
return res.status(400).json({ error: 'Missing slug.' });
}
const def = getGame(slug);
if (!def) {
return res.status(400).json({ error: 'Unknown game slug.' });
}
if (!Number.isFinite(score) || score < 0 || score > 100000) {
return res.status(400).json({ error: 'Invalid score.' });
}
if (!Array.isArray(opponentScores) || !opponentScores.every((n) => Number.isFinite(n))) {
return res.status(400).json({ error: 'Invalid opponentScores.' });
}
if (!RESULTS.has(result)) {
return res.status(400).json({ error: 'Invalid result.' });
}
const tx = db.transaction(() => {
db.prepare(
`INSERT INTO games (slug, name, category, max_players, supports_multiplayer)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(slug) DO NOTHING`,
).run(def.slug, def.name, def.category, def.maxPlayers, def.supportsMultiplayer ? 1 : 0);
const gameRow = db.prepare('SELECT id FROM games WHERE slug = ?').get(def.slug);
const matchRes = db.prepare(
`INSERT INTO matches (game_id, ended_at, status)
VALUES (?, datetime('now'), 'completed')`,
).run(gameRow.id);
const matchId = matchRes.lastInsertRowid;
db.prepare(
`INSERT INTO match_players (match_id, user_id, seat, result, score)
VALUES (?, ?, 0, ?, ?)`,
).run(matchId, req.user.id, result, Math.round(score));
return matchId;
});
try {
const matchId = tx();
res.json({ matchId });
} catch (err) {
console.error('[history/single-player]', err);
res.status(500).json({ error: 'Failed to record match.' });
}
});
export default router;

View File

@ -8,6 +8,7 @@ import { loadUser } from './auth/middleware.js';
import authRoutes from './auth/routes.js';
import profileRoutes from './profile/routes.js';
import historyRoutes from './history/routes.js';
import historyRecordRoutes from './history/recordRoutes.js';
import { listGames } from './multiplayer/gameRegistry.js';
import { attachMultiplayer } from './multiplayer/index.js';
@ -23,6 +24,7 @@ app.get('/api/games', (_req, res) => res.json({ games: listGames() }));
app.use('/api/auth', authRoutes);
app.use('/api/profile', profileRoutes);
app.use('/api/history', historyRoutes);
app.use('/api/history', historyRecordRoutes);
app.use(express.static(config.publicDir, { extensions: ['html'] }));

View File

@ -29,3 +29,6 @@ registerGame({ slug: 'backgammon', name: 'Backgammon', category: 'tabletop', min
registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4, multiplayerOnly: false });
registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 8, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'yatzi', name: 'Yatzi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'skipbo', name: 'Skip-Bo', category: 'tabletop', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'phase10', name: 'Phase 10', category: 'tabletop', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });