Added several opponents and Texas Hold Em

This commit is contained in:
Brian Fertig 2026-05-16 11:59:33 -06:00
parent 96f4cedd3e
commit 2c136e0567
62 changed files with 3936 additions and 36 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
.env
data/
public/uploads/
*.log
.DS_Store

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

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.

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

@ -1,4 +1,4 @@
import Phaser from 'phaser';
import * as Phaser from 'phaser';
// Common lifecycle and helpers for any game. Concrete games extend
// TabletopGame or CasinoGame, which extend this.

View File

@ -0,0 +1,95 @@
import {
cloneState, getValidMoves, applyMove, computePipCount,
} from './BackgammonLogic.js';
// Returns an ordered array of moves for the AI (Black) to execute.
export function chooseMoves(state) {
const sequences = generateSequences(state);
if (sequences.length === 0) return [];
let best = null;
let bestScore = -Infinity;
for (const seq of sequences) {
const score = evaluateState(seq.finalState, 'black');
if (score > bestScore) { bestScore = score; best = seq; }
}
return best ? best.moves : [];
}
function generateSequences(state, depth = 0) {
if (depth > 6) return [{ moves: [], finalState: state }];
const validMoves = getValidMoves(state);
if (validMoves.length === 0 || state.movesLeft.length === 0) {
return [{ moves: [], finalState: state }];
}
const results = [];
const seenBoards = new Set();
for (const move of validMoves) {
const next = applyMove(state, move);
// If applyMove ended the turn, no further moves are possible in this sequence
const finished = next.currentPlayer !== state.currentPlayer || next.phase !== 'move';
if (finished) {
results.push({ moves: [move], finalState: next });
} else {
const subSeqs = generateSequences(next, depth + 1);
for (const sub of subSeqs) {
const key = boardHash(sub.finalState);
if (!seenBoards.has(key)) {
seenBoards.add(key);
results.push({ moves: [move, ...sub.moves], finalState: sub.finalState });
}
}
}
}
return results.length > 0 ? results : [{ moves: [], finalState: state }];
}
function boardHash(state) {
return state.points.map((p) => `${p.color?.[0] ?? '-'}${p.count}`).join('') +
`|b${state.bar.black}w${state.bar.white}`;
}
function evaluateState(state, player) {
const opp = player === 'white' ? 'black' : 'white';
let score = 0;
// Pip count advantage
const ownPips = computePipCount(state, player);
const oppPips = computePipCount(state, opp);
score += (oppPips - ownPips) * 1.5;
// Borne off bonus
score += state.borneOff[player] * 25;
score -= state.borneOff[opp] * 25;
// Opponent checkers on bar (we sent them there)
score += state.bar[opp] * 20;
// Own blots (lone exposed checkers)
for (let i = 0; i < 24; i++) {
const pt = state.points[i];
if (pt.color === player && pt.count === 1) score -= 8;
if (pt.color === player && pt.count >= 2) {
// Home board anchors
const inHome = player === 'white' ? (i <= 5) : (i >= 18);
if (inHome) score += 10;
}
}
// Prime bonus — runs of 3+ consecutive blocked points
let run = 0;
for (let i = 0; i < 24; i++) {
if (state.points[i].color === player && state.points[i].count >= 2) {
run++;
if (run >= 3) score += 15;
} else {
run = 0;
}
}
return score;
}

View File

@ -0,0 +1,906 @@
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 { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import {
createInitialState, rollDice, getValidMoves,
applyMove, endTurn, hasAnyMove, computePipCount,
} from './BackgammonLogic.js';
import { chooseMoves } from './BackgammonAI.js';
// ── Layout constants ──────────────────────────────────────────────────────────
const BX = 300; // board outer left
const BY = 40; // board outer top
const BW = 1480; // board outer width
const BH = 980; // board outer height
const BORDER = 28; // wood border thickness
const BAR_W = 72; // center bar width
const BEAR_W = 110; // right bear-off column width
const FX = BX + BORDER; // felt left
const FY = BY + BORDER; // felt top
const FW = BW - BORDER * 2;
const FH = BH - BORDER * 2;
const HALF_W = (FW - BAR_W - BEAR_W) / 2; // ~601
const PW = HALF_W / 6; // point width ~100
const PH = FH * 0.43; // triangle height ~400
const CR = PW * 0.41; // checker radius ~41
const BAR_X = FX + HALF_W; // bar left edge
const BEAR_X = FX + HALF_W * 2 + BAR_W; // bear-off left edge
// ─────────────────────────────────────────────────────────────────────────────
const DEPTH = { board: 0, checker: 10, highlight: 20, movingChecker: 30, dice: 40, ui: 50, banner: 60 };
const C = {
wood: 0x2c1a0e,
woodLight: 0x5c3a1e,
felt: 0x0d3b1a,
feltLight: 0x124a22,
triAmber: 0xc47c1a,
triRed: 0x7c1a1a,
barWood: 0x241508,
wFill: 0xd4c5a0,
wRing: 0xf0e8d0,
bFill: 0x1a1a2e,
bRing: 0x3a3a4e,
gold: 0xffd700,
dieIvory: 0xf0e8d0,
diePip: 0x1a1a1a,
};
export default class BackgammonGame extends Phaser.Scene {
constructor() { super('Backgammon'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.gs = null; // game state
this.animating = false;
this.selectedFrom = null;
this.highlightObjs = [];
this.checkerObjs = []; // [{ from, stackPos, color, container }]
this.diceContainers = [];
this.diceGraphics = [];
this.rollBtn = null;
this.turnText = null;
this.statusText = null;
this.pipWhiteText = null;
this.pipBlackText = null;
this.opponentPortrait = null;
}
create() {
this.buildParticleTexture();
this.buildPlayfield();
this.buildBoard();
this.buildBearOffArea();
this.buildDice();
this.buildUI();
this.buildPlayerCards();
this.initGame();
}
// ── Board Construction ──────────────────────────────────────────────────────
buildPlayfield() {
const pf = this.playfield;
if (!pf) return;
if (pf.key && this.textures.exists(pf.key)) {
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT)
.setDepth(DEPTH.board - 1);
} else if (pf.fallbackColor) {
const color = parseInt(pf.fallbackColor.replace('#', ''), 16);
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, color)
.setDepth(DEPTH.board - 1);
}
}
buildParticleTexture() {
const g = this.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0xffffff, 1);
g.fillCircle(5, 5, 5);
g.generateTexture('bgParticle', 10, 10);
g.destroy();
}
buildBoard() {
const g = this.add.graphics().setDepth(DEPTH.board);
// Outer wood border
g.fillStyle(C.wood, 1);
g.fillRoundedRect(BX, BY, BW, BH, 8);
g.lineStyle(3, C.woodLight, 1);
g.strokeRoundedRect(BX + 4, BY + 4, BW - 8, BH - 8, 6);
g.lineStyle(1, 0x8b5c2a, 0.5);
g.strokeRoundedRect(BX + 2, BY + 2, BW - 4, BH - 4, 7);
// Felt surface
g.fillStyle(C.felt, 1);
g.fillRect(FX, FY, FW, FH);
// Subtle felt texture lines
g.lineStyle(1, C.feltLight, 0.12);
for (let y = FY + 20; y < FY + FH; y += 30) {
g.lineBetween(FX, y, FX + FW, y);
}
// Center bar
g.fillStyle(C.barWood, 1);
g.fillRect(BAR_X, FY, BAR_W, FH);
g.lineStyle(2, C.woodLight, 0.7);
g.strokeRect(BAR_X, FY, BAR_W, FH);
// 24 triangles
for (let idx = 0; idx < 24; idx++) {
const color = idx % 2 === 0 ? C.triAmber : C.triRed;
g.fillStyle(color, 0.9);
const { ax, bx, tipY, baseY } = this.triangleCoords(idx);
g.fillTriangle(ax, baseY, bx, baseY, (ax + bx) / 2, tipY);
// Subtle triangle outline
g.lineStyle(1, 0x000000, 0.2);
g.strokeTriangle(ax, baseY, bx, baseY, (ax + bx) / 2, tipY);
}
// Point number labels
for (let idx = 0; idx < 24; idx++) {
const label = String(idx + 1);
const { ax, bx } = this.triangleCoords(idx);
const cx = (ax + bx) / 2;
const isBottom = idx < 12;
const ly = isBottom ? FY + FH + 14 : FY - 14;
this.add.text(cx, ly, label, {
fontFamily: 'system-ui, sans-serif',
fontSize: '18px',
color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.board);
}
// Outer board decorative corner inlays
g.fillStyle(C.woodLight, 0.3);
g.fillRect(BX, BY, 28, 28);
g.fillRect(BX + BW - 28, BY, 28, 28);
g.fillRect(BX, BY + BH - 28, 28, 28);
g.fillRect(BX + BW - 28, BY + BH - 28, 28, 28);
}
buildBearOffArea() {
const g = this.add.graphics().setDepth(DEPTH.board);
const midY = FY + FH / 2;
// Background panel
g.fillStyle(C.barWood, 1);
g.fillRect(BEAR_X, FY, BEAR_W, FH);
g.lineStyle(2, C.woodLight, 0.6);
g.strokeRect(BEAR_X, FY, BEAR_W, FH);
// Divider line
g.lineStyle(1, C.woodLight, 0.4);
g.lineBetween(BEAR_X, midY, BEAR_X + BEAR_W, midY);
// Labels
this.add.text(BEAR_X + BEAR_W / 2, FY + 18, 'OFF', {
fontFamily: 'system-ui, sans-serif', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.board);
this.add.text(BEAR_X + BEAR_W / 2, FY + FH - 18, 'OFF', {
fontFamily: 'system-ui, sans-serif', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.board);
// Pip count labels
this.pipBlackText = this.add.text(BEAR_X + BEAR_W / 2, FY + FH / 4, '0', {
fontFamily: 'system-ui, sans-serif', fontSize: '28px', color: '#3a3a4e',
}).setOrigin(0.5).setDepth(DEPTH.ui);
this.pipWhiteText = this.add.text(BEAR_X + BEAR_W / 2, FY + 3 * FH / 4, '0', {
fontFamily: 'system-ui, sans-serif', fontSize: '28px', color: '#d4c5a0',
}).setOrigin(0.5).setDepth(DEPTH.ui);
}
buildDice() {
const diceY = FY + FH / 2;
const barCX = BAR_X + BAR_W / 2;
for (let i = 0; i < 2; i++) {
const dy = diceY + (i === 0 ? -50 : 50);
const g = this.add.graphics();
const container = this.add.container(barCX, dy).setDepth(DEPTH.dice);
container.add(g);
this.diceContainers.push(container);
this.diceGraphics.push(g);
this.renderDieFace(i, 1);
container.setAlpha(0.2);
}
}
renderDieFace(dieIndex, value) {
const g = this.diceGraphics[dieIndex];
const s = 36; // half-size
g.clear();
// Face
g.fillStyle(C.dieIvory, 1);
g.fillRoundedRect(-s, -s, s * 2, s * 2, 8);
g.lineStyle(2, C.wood, 1);
g.strokeRoundedRect(-s, -s, s * 2, s * 2, 8);
// Pip positions (normalized -1 to 1 mapped to ±22px)
const pipLayouts = {
1: [[0, 0]],
2: [[-0.6, -0.6], [0.6, 0.6]],
3: [[-0.6, -0.6], [0, 0], [0.6, 0.6]],
4: [[-0.6, -0.6], [0.6, -0.6], [-0.6, 0.6], [0.6, 0.6]],
5: [[-0.6, -0.6], [0.6, -0.6], [0, 0], [-0.6, 0.6], [0.6, 0.6]],
6: [[-0.6, -0.6], [0.6, -0.6], [-0.6, 0], [0.6, 0], [-0.6, 0.6], [0.6, 0.6]],
};
const pips = pipLayouts[value] ?? pipLayouts[1];
g.fillStyle(C.diePip, 1);
for (const [px, py] of pips) {
g.fillCircle(px * 22, py * 22, 6);
}
}
buildUI() {
const cx = BX + BW / 2;
// Status message (also serves as turn label at bottom)
this.turnText = this.add.text(cx, BY + BH + 18, '', {
fontFamily: 'system-ui, sans-serif',
fontSize: '24px',
color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
this.statusText = this.add.text(cx, BY + BH + 46, '', {
fontFamily: 'system-ui, sans-serif',
fontSize: '22px',
color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
// Roll dice button — inside bar area
const barCX = BAR_X + BAR_W / 2;
this.rollBtn = new Button(this, barCX, FY + FH / 2 + 130, 'Roll', () => this.onRollClick(), {
width: 62, height: 36, fontSize: 18,
});
this.rollBtn.setDepth(DEPTH.ui);
// Leave button
new Button(this, BX - 64, BY + BH / 2, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 110, height: 44, fontSize: 20,
}).setDepth(DEPTH.ui);
// New game button
new Button(this, BX - 64, BY + BH / 2 + 60, 'New', () => this.initGame(), {
variant: 'ghost', width: 110, height: 44, fontSize: 20,
}).setDepth(DEPTH.ui);
}
// ── Player / Opponent Cards ─────────────────────────────────────────────────
buildPlayerCards() {
const opp = this.opponents[0];
const r = 80;
const depth = DEPTH.ui;
const avatarX = BX / 2; // centered in the left strip
// Opponent avatar (upper-left)
const oppAY = FY + r + 20;
// Decorative wood-tone outer ring matching the board rail
this.add.circle(avatarX, oppAY, r + 5, C.barWood).setDepth(depth);
this.opponentPortrait = createOpponentPortrait(this, opp, avatarX, oppAY, r, depth + 1);
this.add.text(avatarX, oppAY + r + 14, opp?.name ?? 'CPU', {
fontFamily: 'system-ui, sans-serif', fontSize: '18px',
color: COLORS.textHex, wordWrap: { width: 240 }, align: 'center',
}).setOrigin(0.5, 0).setDepth(depth + 2);
// Player avatar (lower-left)
const plrAY = FY + FH - r - 20;
this.add.circle(avatarX, plrAY, r + 5, COLORS.accent, 0.5).setDepth(depth);
createPlayerPortrait(this, avatarX, plrAY, r, depth + 1, 'Backgammon');
this.add.text(avatarX, plrAY - r - 14, auth.user?.username ?? 'You', {
fontFamily: 'system-ui, sans-serif', fontSize: '18px',
color: COLORS.textHex, wordWrap: { width: 240 }, align: 'center',
}).setOrigin(0.5, 1).setDepth(depth + 2);
}
playOpponentEmotion(emotion) {
this.opponentPortrait?.playEmotion(emotion);
}
// ── Game State ──────────────────────────────────────────────────────────────
initGame() {
this.clearCheckers();
this.clearHighlights();
this.animating = false;
this.selectedFrom = null;
this.gs = createInitialState();
this.renderAll();
this.setStatus('');
this.showTurnBanner('Your Turn');
}
renderAll() {
this.clearCheckers();
this.renderCheckers();
this.renderBarCheckers();
this.renderBorneOff();
this.updateDiceDisplay();
this.updatePipLabels();
this.updateButtonStates();
}
clearCheckers() {
for (const obj of this.checkerObjs) obj.container.destroy();
this.checkerObjs = [];
}
renderCheckers() {
for (let idx = 0; idx < 24; idx++) {
const pt = this.gs.points[idx];
if (!pt.color || pt.count === 0) continue;
const max = Math.min(pt.count, 5);
for (let s = 0; s < max; s++) {
const pos = this.checkerScreenPos(idx, s);
const c = this.makeChecker(pt.color, pos.x, pos.y);
c.setDepth(DEPTH.checker);
c.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Circle(0, 0, CR), hitAreaCallback: Phaser.Geom.Circle.Contains });
c.on('pointerdown', () => this.onCheckerClick(idx));
this.checkerObjs.push({ from: idx, stackPos: s, color: pt.color, container: c });
}
// Stack count badge
if (pt.count > 5) {
const pos = this.checkerScreenPos(idx, 4);
this.add.text(pos.x, pos.y, String(pt.count), {
fontFamily: 'system-ui, sans-serif', fontSize: '22px',
color: pt.color === 'white' ? '#2c1a0e' : '#f0e8d0',
}).setOrigin(0.5).setDepth(DEPTH.checker + 1);
}
}
}
renderBarCheckers() {
const barCX = BAR_X + BAR_W / 2;
for (const color of ['white', 'black']) {
const count = this.gs.bar[color];
const baseY = color === 'white' ? FY + FH * 0.72 : FY + FH * 0.28;
for (let s = 0; s < Math.min(count, 4); s++) {
const dy = color === 'white' ? s * (CR * 2 + 2) : -(s * (CR * 2 + 2));
const c = this.makeChecker(color, barCX, baseY + dy);
c.setDepth(DEPTH.checker);
if (color === this.gs.currentPlayer) {
c.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Circle(0, 0, CR), hitAreaCallback: Phaser.Geom.Circle.Contains });
c.on('pointerdown', () => this.onCheckerClick('bar'));
}
this.checkerObjs.push({ from: 'bar', stackPos: s, color, container: c });
}
if (count > 4) {
const dy = color === 'white' ? 3 * (CR * 2 + 2) : -(3 * (CR * 2 + 2));
this.add.text(barCX, baseY + dy, `×${count}`, {
fontFamily: 'system-ui, sans-serif', fontSize: '20px',
color: color === 'white' ? COLORS.textHex : COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.checker + 1);
}
}
}
renderBorneOff() {
const cx = BEAR_X + BEAR_W / 2;
for (const color of ['white', 'black']) {
const count = this.gs.borneOff[color];
const baseY = color === 'white' ? FY + FH * 0.82 : FY + FH * 0.18;
const rows = Math.min(count, 15);
const colW = 18, rowH = 18;
for (let i = 0; i < rows; i++) {
const col = i % 5, row = Math.floor(i / 5);
const dx = (col - 2) * colW;
const dy = color === 'white' ? -row * rowH : row * rowH;
const g2 = this.add.graphics().setDepth(DEPTH.checker);
g2.fillStyle(color === 'white' ? C.wRing : C.bRing, 1);
g2.fillCircle(cx + dx, baseY + dy, 7);
const c2 = this.add.container(0, 0, [g2]);
this.checkerObjs.push({ from: 'off', stackPos: i, color, container: c2 });
}
}
}
makeChecker(color, x, y) {
const g = this.add.graphics();
// Shadow
g.fillStyle(0x000000, 0.25);
g.fillCircle(3, 4, CR);
// Outer ring
g.fillStyle(color === 'white' ? C.wRing : C.bRing, 1);
g.fillCircle(0, 0, CR);
// Inner fill
g.fillStyle(color === 'white' ? C.wFill : C.bFill, 1);
g.fillCircle(0, 0, CR - 5);
// Highlight arc (top-left sheen)
g.lineStyle(3, color === 'white' ? 0xffffff : 0x6a6a8e, 0.55);
g.beginPath();
g.arc(0, 0, CR - 8, Phaser.Math.DegToRad(200), Phaser.Math.DegToRad(320));
g.strokePath();
const container = this.add.container(x, y, [g]);
return container;
}
// ── Screen Coordinate Helpers ───────────────────────────────────────────────
// Returns { ax, bx, tipY, baseY } for triangle drawing
triangleCoords(idx) {
const isBottom = idx < 12;
let col;
if (idx < 6) col = 5 - idx; // bottom-right: idx 0 rightmost (point 1), idx 5 leftmost (point 6)
else if (idx < 12) col = 11 - idx; // bottom-left: idx 11 leftmost (point 12), idx 6 rightmost (point 7)
else if (idx < 18) col = idx - 12; // top-left: idx 12 leftmost (point 13), idx 17 rightmost (point 18)
else col = idx - 18; // top-right: idx 18 leftmost (point 19), idx 23 rightmost (point 24)
const isRight = idx < 6 || idx >= 18;
const halfStart = isRight ? BAR_X + BAR_W : FX;
const ax = halfStart + col * PW;
const bx = ax + PW;
const baseY = isBottom ? FY + FH : FY;
const tipY = isBottom ? FY + FH - PH : FY + PH;
return { ax, bx, tipY, baseY };
}
// Center x of a point
pointCX(idx) {
const { ax, bx } = this.triangleCoords(idx);
return (ax + bx) / 2;
}
// Screen position of nth checker on a point (stack 0 = closest to board edge)
checkerScreenPos(idx, stackPos) {
const isBottom = idx < 12;
const cx = this.pointCX(idx);
const edge = isBottom ? FY + FH : FY;
const dir = isBottom ? -1 : 1;
const visualStack = Math.min(stackPos, 4);
const y = edge + dir * (CR + visualStack * CR * 2);
return { x: cx, y };
}
barScreenPos(color) {
return {
x: BAR_X + BAR_W / 2,
y: color === 'white' ? FY + FH * 0.72 : FY + FH * 0.28,
};
}
bearOffScreenPos(color) {
return {
x: BEAR_X + BEAR_W / 2,
y: color === 'white' ? FY + FH * 0.82 : FY + FH * 0.18,
};
}
// ── Interaction ─────────────────────────────────────────────────────────────
onCheckerClick(fromIdx) {
if (this.animating) return;
if (this.gs.phase !== 'move') return;
if (this.gs.currentPlayer !== 'white') return;
// If bar has white checkers, only bar selection is valid
if (this.gs.bar.white > 0 && fromIdx !== 'bar') return;
const pt = fromIdx === 'bar'
? { color: 'white', count: this.gs.bar.white }
: this.gs.points[fromIdx];
if (pt.color !== 'white' || pt.count === 0) return;
const validMoves = getValidMoves(this.gs).filter((m) => m.from === fromIdx);
if (validMoves.length === 0) {
this.flashNoMove(fromIdx);
return;
}
this.clearHighlights();
this.selectedFrom = fromIdx;
this.pulseSelectedChecker(fromIdx);
this.showHighlights(validMoves);
}
pulseSelectedChecker(fromIdx) {
const obj = this.checkerObjs.find(
(o) => o.from === fromIdx && o.color === 'white' && o.stackPos === 0
);
if (!obj) return;
// Gold ring overlay
const ring = this.add.graphics().setDepth(DEPTH.highlight);
ring.lineStyle(4, C.gold, 1);
ring.strokeCircle(obj.container.x, obj.container.y, CR + 4);
this.tweens.add({ targets: ring, alpha: { from: 1, to: 0.3 }, duration: 500, yoyo: true, repeat: -1 });
this.highlightObjs.push(ring);
}
showHighlights(moves) {
const destinations = [...new Set(moves.map((m) => m.to))];
for (const dest of destinations) {
let hx, hy;
if (dest === 'off') {
const pos = this.bearOffScreenPos('white');
hx = pos.x; hy = pos.y;
} else {
const destStack = this.stackHeight(dest);
hx = this.pointCX(dest);
hy = this.checkerScreenPos(dest, destStack).y;
}
// Pulsing dot
const dot = this.add.graphics().setDepth(DEPTH.highlight);
dot.fillStyle(COLORS.accent, 0.85);
dot.fillCircle(hx, hy, 20);
dot.lineStyle(3, 0xffffff, 0.4);
dot.strokeCircle(hx, hy, 20);
this.tweens.add({ targets: dot, alpha: { from: 0.85, to: 0.2 }, duration: 600, yoyo: true, repeat: -1 });
// Invisible hit zone
const zone = this.add.zone(hx, hy, PW, CR * 4)
.setInteractive({ useHandCursor: true })
.setDepth(DEPTH.highlight);
zone.on('pointerdown', () => this.onDestinationClick(dest));
this.highlightObjs.push(dot, zone);
}
}
clearHighlights() {
for (const obj of this.highlightObjs) obj.destroy();
this.highlightObjs = [];
this.selectedFrom = null;
}
onDestinationClick(toIdx) {
if (this.animating || this.selectedFrom === null) return;
const validMoves = getValidMoves(this.gs).filter(
(m) => m.from === this.selectedFrom && m.to === toIdx
);
if (validMoves.length === 0) return;
// Prefer smaller die to preserve flexibility
const move = validMoves.sort((a, b) => a.dieUsed - b.dieUsed)[0];
this.clearHighlights();
this.executePlayerMove(move);
}
flashNoMove(fromIdx) {
const obj = this.checkerObjs.find((o) => o.from === fromIdx && o.color === 'white');
if (!obj) return;
this.tweens.add({ targets: obj.container, alpha: { from: 1, to: 0.2 }, duration: 120, yoyo: true, repeat: 3 });
}
// ── Move Execution ──────────────────────────────────────────────────────────
executePlayerMove(move) {
this.animating = true;
this.updateButtonStates();
// Find which container to animate (topmost checker of that point)
const obj = this.checkerObjs.filter((o) => o.from === move.from && o.color === 'white')
.sort((a, b) => b.stackPos - a.stackPos)[0];
const fromPos = move.from === 'bar'
? this.barScreenPos('white')
: this.checkerScreenPos(move.from, obj ? obj.stackPos : 0);
const toPos = move.to === 'off'
? this.bearOffScreenPos('white')
: this.checkerScreenPos(move.to, this.stackHeight(move.to));
const animContainer = obj ? obj.container : null;
if (animContainer) animContainer.setDepth(DEPTH.movingChecker);
this.animateArc(animContainer, fromPos, toPos, () => {
this.gs = applyMove(this.gs, move);
this.renderAll();
this.animating = false;
this.updateButtonStates();
if (move.hit) this.playOpponentEmotion('upset');
else if (move.to === 'off') this.playOpponentEmotion('upset');
if (this.gs.phase === 'game_over') {
this.onGameOver();
} else if (this.gs.currentPlayer === 'black' && this.gs.phase === 'roll') {
this.time.delayedCall(800, () => this.startAITurn());
}
});
// Animate hit separately if needed
if (move.hit) {
const hitObj = this.checkerObjs.find((o) => o.from === move.to && o.color === 'black');
if (hitObj) {
const barPos = this.barScreenPos('black');
this.animateSlide(hitObj.container, barPos, 250);
}
}
}
stackHeight(pointIdx) {
if (pointIdx === 'off' || pointIdx === 'bar') return 0;
const pt = this.gs.points[pointIdx];
return pt.color !== null ? pt.count : 0;
}
// ── Dice ────────────────────────────────────────────────────────────────────
onRollClick() {
if (this.animating || this.gs.phase !== 'roll' || this.gs.currentPlayer !== 'white') return;
this.rollBtn.setEnabled(false);
this.animating = true;
const finalState = rollDice(this.gs);
const [d1, d2] = finalState.dice;
this.animateDiceRoll([d1, d2], () => {
this.gs = finalState;
this.updateDiceDisplay();
this.animating = false;
if (d1 === d2) this.playOpponentEmotion('upset');
if (!hasAnyMove(this.gs)) {
this.setStatus('No moves available — turn passed');
this.time.delayedCall(1800, () => {
this.gs = endTurn(this.gs);
this.setStatus('');
this.renderAll();
this.time.delayedCall(600, () => this.startAITurn());
});
} else {
this.updateButtonStates();
}
});
}
animateDiceRoll(finalValues, onComplete) {
this.diceContainers.forEach((c) => c.setAlpha(1));
let elapsed = 0;
const totalMs = 700;
const phases = [{ until: 400, interval: 60 }, { until: 580, interval: 90 }, { until: totalMs, interval: 130 }];
const tick = () => {
const phaseInterval = phases.find((p) => elapsed < p.until)?.interval ?? 60;
for (let i = 0; i < 2; i++) {
this.renderDieFace(i, Phaser.Math.Between(1, 6));
}
elapsed += phaseInterval;
if (elapsed < totalMs) {
this.time.delayedCall(phaseInterval, tick);
} else {
this.renderDieFace(0, finalValues[0]);
this.renderDieFace(1, finalValues[1]);
// Landing pulse
for (const c of this.diceContainers) {
this.tweens.add({ targets: c, scaleX: 1.2, scaleY: 1.2, duration: 80, yoyo: true });
}
this.time.delayedCall(120, onComplete);
}
};
tick();
}
updateDiceDisplay() {
if (!this.gs.dice) {
this.diceContainers.forEach((c) => c.setAlpha(0.2));
return;
}
this.diceContainers.forEach((c) => c.setAlpha(1));
this.renderDieFace(0, this.gs.dice[0]);
this.renderDieFace(1, this.gs.dice[1]);
// Dim used dice
const used = [true, true];
const remaining = [...this.gs.movesLeft];
for (let i = 0; i < 2; i++) {
const idx = remaining.indexOf(this.gs.dice[i]);
if (idx !== -1) { used[i] = false; remaining.splice(idx, 1); }
}
this.diceContainers.forEach((c, i) => c.setAlpha(used[i] ? 0.3 : 1));
}
// ── AI Turn ─────────────────────────────────────────────────────────────────
startAITurn() {
if (this.gs.phase !== 'roll' || this.gs.currentPlayer !== 'black') return;
const opponentName = this.opponents[0]?.name ?? 'Opponent';
this.showTurnBanner(`${opponentName}'s Turn`);
this.animating = true;
this.updateButtonStates();
this.time.delayedCall(1000, () => {
const finalState = rollDice(this.gs);
const [d1, d2] = finalState.dice;
this.animateDiceRoll([d1, d2], () => {
this.gs = finalState;
this.updateDiceDisplay();
if (d1 === d2) this.playOpponentEmotion('happy');
if (!hasAnyMove(this.gs)) {
this.setStatus(`${opponentName} has no moves — turn passed`);
this.time.delayedCall(1500, () => {
this.gs = endTurn(this.gs);
this.setStatus('');
this.animating = false;
this.renderAll();
this.showTurnBanner('Your Turn');
});
return;
}
const moves = chooseMoves(this.gs);
this.executeAIMovesSequentially(moves, 0, () => {
this.animating = false;
if (this.gs.phase === 'game_over') {
this.onGameOver();
} else {
this.renderAll();
this.showTurnBanner('Your Turn');
}
});
});
});
}
executeAIMovesSequentially(moves, index, onAllDone) {
if (index >= moves.length || this.gs.phase === 'game_over') {
onAllDone();
return;
}
const move = moves[index];
const fromPos = move.from === 'bar'
? this.barScreenPos('black')
: this.checkerScreenPos(move.from, Math.max(0, this.gs.points[move.from].count - 1));
const toPos = move.to === 'off'
? this.bearOffScreenPos('black')
: this.checkerScreenPos(move.to, this.stackHeight(move.to));
// Spawn a temporary checker for animation
const tempChecker = this.makeChecker('black', fromPos.x, fromPos.y);
tempChecker.setDepth(DEPTH.movingChecker);
this.animateArc(tempChecker, fromPos, toPos, () => {
tempChecker.destroy();
this.gs = applyMove(this.gs, move);
this.renderAll();
if (move.hit) this.playOpponentEmotion('happy');
else if (move.to === 'off') this.playOpponentEmotion('happy');
this.time.delayedCall(350, () => {
this.executeAIMovesSequentially(moves, index + 1, onAllDone);
});
});
}
// ── Animations ──────────────────────────────────────────────────────────────
animateArc(container, from, to, onComplete) {
if (!container) { onComplete(); return; }
const midX = (from.x + to.x) / 2;
const midY = Math.min(from.y, to.y) - 130;
const prog = { t: 0 };
this.tweens.add({
targets: prog,
t: 1,
duration: 400,
ease: 'Cubic.easeInOut',
onUpdate: () => {
const t = prog.t;
const inv = 1 - t;
container.x = inv * inv * from.x + 2 * inv * t * midX + t * t * to.x;
container.y = inv * inv * from.y + 2 * inv * t * midY + t * t * to.y;
},
onComplete: () => {
container.x = to.x;
container.y = to.y;
// Squash-and-stretch on landing
this.tweens.add({ targets: container, scaleX: 1.3, scaleY: 0.7, duration: 60, yoyo: true, ease: 'Quad.easeOut', onComplete });
},
});
}
animateSlide(container, to, duration) {
this.tweens.add({
targets: container,
x: to.x, y: to.y,
duration,
ease: 'Quad.easeIn',
});
}
showTurnBanner(text) {
const cx = BX + BW / 2;
const banner = this.add.text(cx, BY - 80, text, {
fontFamily: 'system-ui, sans-serif',
fontSize: '36px',
color: COLORS.textHex,
backgroundColor: '#111923ee',
padding: { x: 28, y: 12 },
}).setOrigin(0.5).setDepth(DEPTH.banner);
this.tweens.add({
targets: banner,
y: BY - 22,
duration: 320,
ease: 'Back.easeOut',
onComplete: () => {
this.time.delayedCall(1200, () => {
this.tweens.add({ targets: banner, y: BY - 80, alpha: 0, duration: 220, onComplete: () => banner.destroy() });
});
},
});
}
// ── UI Updates ───────────────────────────────────────────────────────────────
updateButtonStates() {
const canRoll = !this.animating && this.gs.phase === 'roll' && this.gs.currentPlayer === 'white';
this.rollBtn?.setEnabled(canRoll);
this.turnText?.setText(this.gs.currentPlayer === 'white' ? 'Your Turn' : 'Opponent\'s Turn');
}
updatePipLabels() {
const wPip = computePipCount(this.gs, 'white');
const bPip = computePipCount(this.gs, 'black');
this.pipWhiteText?.setText(String(wPip));
this.pipBlackText?.setText(String(bPip));
}
setStatus(msg) {
this.statusText?.setText(msg);
}
// ── Win / Game Over ──────────────────────────────────────────────────────────
onGameOver() {
const winner = this.gs.winner;
const isHuman = winner === 'white';
const opponentName = this.opponents[0]?.name ?? 'Opponent';
this.playOpponentEmotion(isHuman ? 'upset' : 'happy');
// Particle burst
if (isHuman) {
const emitter = this.add.particles(BX + BW / 2, BY + BH / 2, 'bgParticle', {
speed: { min: 150, max: 500 },
lifespan: 1400,
scale: { start: 1.5, end: 0 },
alpha: { start: 1, end: 0 },
quantity: 5,
frequency: 25,
tint: [C.gold, 0xffffff, COLORS.accent],
angle: { min: 0, max: 360 },
}).setDepth(DEPTH.banner);
this.time.delayedCall(2000, () => emitter.destroy());
}
this.time.delayedCall(500, () => {
const msg = isHuman
? '🎉 You Win!\nBear off all 15 checkers!'
: `${opponentName} wins this time.\nBetter luck next game!`;
const overlay = this.add.rectangle(BX + BW / 2, BY + BH / 2, 700, 300, 0x0a0e14, 0.9)
.setStrokeStyle(3, COLORS.accent)
.setDepth(DEPTH.banner);
const txt = this.add.text(BX + BW / 2, BY + BH / 2 - 40, msg, {
fontFamily: 'system-ui, sans-serif',
fontSize: '32px',
color: isHuman ? '#ffd700' : COLORS.textHex,
align: 'center',
}).setOrigin(0.5).setDepth(DEPTH.banner + 1);
new Button(this, BX + BW / 2 - 90, BY + BH / 2 + 80, 'Play Again', () => {
overlay.destroy(); txt.destroy(); this.initGame();
}, { width: 160, fontSize: 22 }).setDepth(DEPTH.banner + 1);
new Button(this, BX + BW / 2 + 90, BY + BH / 2 + 80, 'Leave', () => {
this.scene.start('GameMenu');
}, { variant: 'ghost', width: 160, fontSize: 22 }).setDepth(DEPTH.banner + 1);
});
}
}

View File

@ -0,0 +1,219 @@
// Pure game logic — no Phaser dependency.
// Convention: point indices 023 (0 = point 1, White's home ace-point).
// White moves high→low (23→0), bears off past index 0.
// Black moves low→high (0→23), bears off past index 23.
// White home board: indices 05. Black home board: indices 1823.
export function createInitialState() {
const points = Array.from({ length: 24 }, () => ({ color: null, count: 0 }));
points[23] = { color: 'white', count: 2 };
points[12] = { color: 'white', count: 5 };
points[7] = { color: 'white', count: 3 };
points[5] = { color: 'white', count: 5 };
points[0] = { color: 'black', count: 2 };
points[11] = { color: 'black', count: 5 };
points[16] = { color: 'black', count: 3 };
points[18] = { color: 'black', count: 5 };
return {
points,
bar: { white: 0, black: 0 },
borneOff: { white: 0, black: 0 },
dice: null,
movesLeft: [],
currentPlayer: 'white',
phase: 'roll',
winner: null,
};
}
export function cloneState(state) {
return JSON.parse(JSON.stringify(state));
}
export function rollDice(state) {
const d1 = Math.ceil(Math.random() * 6);
const d2 = Math.ceil(Math.random() * 6);
return rollSpecificDice(state, d1, d2);
}
export function rollSpecificDice(state, d1, d2) {
const s = cloneState(state);
s.dice = [d1, d2];
s.movesLeft = d1 === d2 ? [d1, d1, d1, d1] : [d1, d2];
s.phase = 'move';
return s;
}
export function endTurn(state) {
const s = cloneState(state);
s.currentPlayer = s.currentPlayer === 'white' ? 'black' : 'white';
s.dice = null;
s.movesLeft = [];
s.phase = 'roll';
return s;
}
export function allCheckersInHome(state, player) {
if (state.bar[player] > 0) return false;
const [homeMin, homeMax] = player === 'white' ? [0, 5] : [18, 23];
for (let i = 0; i < 24; i++) {
if (i < homeMin || i > homeMax) {
if (state.points[i].color === player && state.points[i].count > 0) return false;
}
}
return true;
}
// Furthest checker from the bear-off edge (by index):
// White: highest index in 05 (index 5 = 6 pips away)
// Black: lowest index in 1823 (index 18 = 6 pips away)
function furthestHomeChecker(state, player) {
const { points } = state;
if (player === 'white') {
for (let i = 5; i >= 0; i--) {
if (points[i].color === 'white' && points[i].count > 0) return i;
}
} else {
for (let i = 18; i <= 23; i++) {
if (points[i].color === 'black' && points[i].count > 0) return i;
}
}
return -1;
}
export function getValidMoves(state) {
const { currentPlayer: player, movesLeft, points, bar } = state;
const opp = player === 'white' ? 'black' : 'white';
const uniqueDice = [...new Set(movesLeft)];
const inBearOff = allCheckersInHome(state, player);
const moves = [];
const isBlocked = (idx) => points[idx]?.color === opp && points[idx].count >= 2;
const isHit = (idx) => points[idx]?.color === opp && points[idx].count === 1;
// Bar entry takes priority — only these moves if bar has checkers
if (bar[player] > 0) {
for (const die of uniqueDice) {
// White enters opposite side: die 1 → index 23, die 6 → index 18
// Black enters near side: die 1 → index 0, die 6 → index 5
const entryIdx = player === 'white' ? (24 - die) : (die - 1);
if (!isBlocked(entryIdx)) {
moves.push({ from: 'bar', to: entryIdx, dieUsed: die, hit: isHit(entryIdx) });
}
}
return dedup(moves);
}
for (const die of uniqueDice) {
for (let i = 0; i < 24; i++) {
if (points[i].color !== player || points[i].count === 0) continue;
if (player === 'white') {
const dest = i - die;
if (dest >= 0) {
if (!isBlocked(dest)) moves.push({ from: i, to: dest, dieUsed: die, hit: isHit(dest) });
} else if (inBearOff) {
if (dest === -1) {
// Exact bear-off (die == i + 1)
moves.push({ from: i, to: 'off', dieUsed: die, hit: false });
} else {
// Overshoot — only the furthest checker may be borne off
if (furthestHomeChecker(state, 'white') === i) {
moves.push({ from: i, to: 'off', dieUsed: die, hit: false });
}
}
}
} else {
const dest = i + die;
if (dest <= 23) {
if (!isBlocked(dest)) moves.push({ from: i, to: dest, dieUsed: die, hit: isHit(dest) });
} else if (inBearOff) {
if (dest === 24) {
// Exact bear-off (die == 24 - i)
moves.push({ from: i, to: 'off', dieUsed: die, hit: false });
} else {
// Overshoot
if (furthestHomeChecker(state, 'black') === i) {
moves.push({ from: i, to: 'off', dieUsed: die, hit: false });
}
}
}
}
}
}
return dedup(moves);
}
function dedup(moves) {
const seen = new Set();
return moves.filter((m) => {
const key = `${m.from}|${m.to}|${m.dieUsed}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export function applyMove(state, move) {
let s = cloneState(state);
const player = s.currentPlayer;
const opp = player === 'white' ? 'black' : 'white';
// Remove from source
if (move.from === 'bar') {
s.bar[player] = Math.max(0, s.bar[player] - 1);
} else {
s.points[move.from].count--;
if (s.points[move.from].count === 0) s.points[move.from].color = null;
}
// Handle hit — send opponent blot to bar
if (move.hit && move.to !== 'off') {
s.points[move.to].count = 0;
s.points[move.to].color = null;
s.bar[opp]++;
}
// Place on destination
if (move.to === 'off') {
s.borneOff[player]++;
} else {
s.points[move.to].color = player;
s.points[move.to].count++;
}
// Consume the die
const dieIdx = s.movesLeft.indexOf(move.dieUsed);
if (dieIdx !== -1) s.movesLeft.splice(dieIdx, 1);
// Win check
if (s.borneOff[player] >= 15) {
s.winner = player;
s.phase = 'game_over';
return s;
}
// Auto-end turn if no dice remain or no legal moves remain
if (s.movesLeft.length === 0 || getValidMoves(s).length === 0) {
s = endTurn(s);
}
return s;
}
export function hasAnyMove(state) {
return getValidMoves(state).length > 0;
}
export function computePipCount(state, player) {
let pips = 0;
for (let i = 0; i < 24; i++) {
const pt = state.points[i];
if (pt.color !== player || pt.count === 0) continue;
const dist = player === 'white' ? (i + 1) : (24 - i);
pips += dist * pt.count;
}
pips += state.bar[player] * 25;
return pips;
}

View File

@ -0,0 +1,65 @@
export const SUITS = ['s', 'h', 'd', 'c']; // spades, hearts, diamonds, clubs
export const RANKS = ['2', '3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K', 'A'];
// Rank value for comparison (2=2 … A=14)
const RANK_VALUE = Object.fromEntries(RANKS.map((r, i) => [r, i + 2]));
export class Card {
constructor(rank, suit) {
this.rank = rank;
this.suit = suit;
this.value = RANK_VALUE[rank]; // 214
this.key = `${rank}${suit}`; // e.g. 'As', 'Td', '2h'
}
/** Display label shown on the card face (rank only; suit shown separately) */
get label() {
return this.rank === 'T' ? '10' : this.rank;
}
/** True for heart / diamond */
get isRed() {
return this.suit === 'h' || this.suit === 'd';
}
/** Unicode suit symbol */
get suitSymbol() {
return { s: '♠', h: '♥', d: '♦', c: '♣' }[this.suit];
}
}
export class Deck {
constructor() {
this.cards = [];
this.reset();
}
/** Rebuild full 52-card deck in order. */
reset() {
this.cards = [];
for (const suit of SUITS) {
for (const rank of RANKS) {
this.cards.push(new Card(rank, suit));
}
}
}
/** Fisher-Yates in-place shuffle. */
shuffle() {
const a = this.cards;
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
}
/** Remove and return n cards from the top of the deck. */
deal(n = 1) {
if (n > this.cards.length) throw new Error('Not enough cards in deck.');
return this.cards.splice(0, n);
}
get remaining() {
return this.cards.length;
}
}

View File

@ -0,0 +1,105 @@
import { evaluateHand, callAmount, canCheck, minRaise } from './HoldemLogic.js';
// Per-seat personalities indexed by seat number (13 for AI seats)
const PERSONALITIES = {
1: { style: 'aggressive', raiseFactor: 0.55, bluffRate: 0.22 }, // Marcus
2: { style: 'tight', raiseFactor: 0.28, bluffRate: 0.10 }, // Sofia
3: { style: 'loose', raiseFactor: 0.44, bluffRate: 0.27 }, // third AI
};
// Chen formula approximation: 020 score for pre-flop hand strength
function chenScore(hand) {
const [a, b] = [...hand].sort((x, y) => y.value - x.value);
const hi = a.value;
let score = hi >= 14 ? 10 : hi >= 13 ? 8 : hi >= 12 ? 7 : hi >= 11 ? 6 : hi / 2;
if (a.value === b.value) {
score = Math.max(score * 2, 5);
} else {
const gap = a.value - b.value - 1;
if (gap === 0) score += 1; // connected
else if (gap === 1) score -= 1; // 1-gap
else if (gap === 2) score -= 2; // 2-gap
else if (gap === 3) score -= 4; // 3-gap
else score -= 5; // bigger gap
if (b.value >= 2 && b.value <= 7 && gap <= 1) score += 1; // straight bonus
if (a.suit === b.suit) score += 2; // suited bonus
}
return Math.max(0, score);
}
// Post-flop: rough hand percentile (01) from evaluation rank + board texture
function handStrength(player, community) {
if (community.length === 0) return null;
const result = evaluateHand([...player.hand, ...community]);
// rank 08; normalize to 01 with exponential curve
return Math.min(1, (result.rank + 1) / 9 + result.tiebreakers[0] / 120);
}
// Pot odds: ratio of call amount to total pot after calling
function potOdds(state, seat) {
const toCall = callAmount(state, seat);
if (toCall === 0) return 1; // free to check
return toCall / (state.pot + toCall);
}
export function chooseAction(state, seat) {
const player = state.players[seat];
const pers = PERSONALITIES[seat] ?? PERSONALITIES[1];
const isCheck = canCheck(state, seat);
const toCall = callAmount(state, seat);
const odds = potOdds(state, seat);
// Determine strength metric
let strength;
if (state.phase === 'preflop') {
const chen = chenScore(player.hand);
strength = chen / 20; // normalize to 01
} else {
strength = handStrength(player, state.community) ?? 0.5;
}
// Bluffing: occasionally treat weak hand as strong
if (Math.random() < pers.bluffRate) strength = Math.min(1, strength + 0.40);
// Fold threshold: fold if strength is below the price of calling
const foldThreshold = odds * 0.88;
if (isCheck) {
// Check or bet
if (strength > 0.55 && Math.random() < pers.raiseFactor) {
const betAmount = computeRaiseAmount(state, seat, player, strength, pers);
return { type: 'raise', amount: betAmount };
}
return { type: 'check' };
}
// There's a bet to call
if (strength < foldThreshold && !isCheck) {
return { type: 'fold' };
}
// Strong enough to continue — raise or call?
if (strength > 0.62 && Math.random() < pers.raiseFactor) {
if (player.chips <= toCall * 1.5) {
return { type: 'allin' };
}
const betAmount = computeRaiseAmount(state, seat, player, strength, pers);
return { type: 'raise', amount: betAmount };
}
// Go all-in if call would leave almost nothing
if (toCall >= player.chips) return { type: 'allin' };
return { type: 'call' };
}
function computeRaiseAmount(state, seat, player, strength, pers) {
const base = minRaise(state);
// Scale raise size with hand strength and personality
const multiplier = 1 + strength * pers.raiseFactor * 3;
const raw = Math.round(base * multiplier / 5) * 5; // round to $5
return Math.min(raw, player.chips + (state.players[seat].bet));
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,570 @@
import { Deck } from '../cards/Deck.js';
// ── Blind schedule ────────────────────────────────────────────────────────────
export const BLIND_LEVELS = [
{ level: 1, small: 5, big: 10 },
{ level: 2, small: 10, big: 20 },
{ level: 3, small: 15, big: 30 },
{ level: 4, small: 25, big: 50 },
{ level: 5, small: 50, big: 100 },
{ level: 6, small: 75, big: 150 },
{ level: 7, small: 100, big: 200 },
{ level: 8, small: 150, big: 300 },
{ level: 9, small: 250, big: 500 },
{ level: 10, small: 400, big: 800 },
];
const LEVEL_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
export function getBlindLevel(elapsedMs) {
const idx = Math.min(
Math.floor(elapsedMs / LEVEL_INTERVAL_MS),
BLIND_LEVELS.length - 1,
);
return BLIND_LEVELS[idx];
}
// ── Hand evaluation ───────────────────────────────────────────────────────────
// Returns { rank: 08, name, tiebreakers: number[] }
// rank 0=high card … 8=straight flush
export function evaluateHand(cards) {
if (cards.length <= 5) return evaluate5(cards);
return bestFiveFrom(cards);
}
// Picks the best 5-card hand from any n≥6 card set via true C(n,5) enumeration.
function bestFiveFrom(cards) {
const n = cards.length;
let best = null;
for (let a = 0; a < n - 4; a++)
for (let b = a + 1; b < n - 3; b++)
for (let c = b + 1; c < n - 2; c++)
for (let d = c + 1; d < n - 1; d++)
for (let e = d + 1; e < n; e++) {
const result = evaluate5([cards[a], cards[b], cards[c], cards[d], cards[e]]);
if (!best || compareHands(result, best) > 0) best = result;
}
return best;
}
function evaluate5(cards) {
const values = cards.map((c) => c.value).sort((a, b) => b - a);
const suits = cards.map((c) => c.suit);
const ranks = cards.map((c) => c.rank);
const isFlush = suits.every((s) => s === suits[0]);
const isStraight = checkStraight(values);
const counts = countValues(values);
const groups = buildGroups(counts); // sorted by [count desc, value desc]
if (isFlush && isStraight) {
const high = isStraight === 'wheel' ? 5 : values[0];
return { rank: 8, name: high === 14 ? 'Royal Flush' : 'Straight Flush', tiebreakers: [high] };
}
if (groups[0][0] === 4) {
return { rank: 7, name: 'Four of a Kind', tiebreakers: [groups[0][1], groups[1][1]] };
}
if (groups[0][0] === 3 && groups[1][0] === 2) {
return { rank: 6, name: 'Full House', tiebreakers: [groups[0][1], groups[1][1]] };
}
if (isFlush) {
return { rank: 5, name: 'Flush', tiebreakers: values };
}
if (isStraight) {
const high = isStraight === 'wheel' ? 5 : values[0];
return { rank: 4, name: 'Straight', tiebreakers: [high] };
}
if (groups[0][0] === 3) {
return { rank: 3, name: 'Three of a Kind', tiebreakers: [groups[0][1], ...groups.slice(1).map((g) => g[1])] };
}
if (groups[0][0] === 2 && groups[1][0] === 2) {
const [p1, p2, kick] = groups;
return { rank: 2, name: 'Two Pair', tiebreakers: [p1[1], p2[1], kick[1]] };
}
if (groups[0][0] === 2) {
return { rank: 1, name: 'Pair', tiebreakers: [groups[0][1], ...groups.slice(1).map((g) => g[1])] };
}
return { rank: 0, name: 'High Card', tiebreakers: values };
}
function checkStraight(sortedValues) {
// Check wheel (A-2-3-4-5) by treating Ace as 1
const vals = [...new Set(sortedValues)];
if (vals.length < 5) return false;
// Standard straight
if (vals[0] - vals[4] === 4 && vals.length === 5) return true;
// Wheel: A-2-3-4-5 → sorted values [14,5,4,3,2]
if (vals[0] === 14 && vals[1] === 5 && vals[2] === 4 && vals[3] === 3 && vals[4] === 2) {
return 'wheel';
}
return false;
}
function countValues(values) {
const map = {};
for (const v of values) map[v] = (map[v] ?? 0) + 1;
return map;
}
function buildGroups(counts) {
return Object.entries(counts)
.map(([v, c]) => [c, Number(v)])
.sort((a, b) => b[0] - a[0] || b[1] - a[1]);
}
// Returns positive if a beats b, negative if b beats a, 0 if tie
export function compareHands(a, b) {
if (a.rank !== b.rank) return a.rank - b.rank;
for (let i = 0; i < Math.max(a.tiebreakers.length, b.tiebreakers.length); i++) {
const diff = (a.tiebreakers[i] ?? 0) - (b.tiebreakers[i] ?? 0);
if (diff !== 0) return diff;
}
return 0;
}
// ── Pot building (side pots) ──────────────────────────────────────────────────
// Builds pots from p.totalBet (cumulative hand contribution), safe to call
// after bets have been swept into state.pot by collectBets.
// Returns array of { amount, eligibleSeats[] } ordered smallest to largest.
function buildPotsFromTotals(allPlayers) {
const live = allPlayers.filter((p) => !p.eliminated && p.totalBet > 0);
if (live.length === 0) return [];
const allInLevels = [...new Set(
live.filter((p) => p.allIn && !p.folded).map((p) => p.totalBet),
)].sort((a, b) => a - b);
const pots = [];
let prev = 0;
const slice = (cap) => {
let amount = 0;
const eligible = [];
for (const p of live) {
const contrib = Math.min(p.totalBet, cap) - Math.min(p.totalBet, prev);
if (contrib <= 0) continue;
amount += contrib;
if (!p.folded) eligible.push(p.seat);
}
if (amount > 0) pots.push({ amount, eligibleSeats: eligible });
prev = cap;
};
for (const level of allInLevels) slice(level);
// Main pot — uncapped remainder
let mainAmount = 0;
const mainEligible = [];
for (const p of live) {
const contrib = p.totalBet - prev;
if (contrib <= 0) continue;
mainAmount += contrib;
if (!p.folded) mainEligible.push(p.seat);
}
if (mainAmount > 0) pots.push({ amount: mainAmount, eligibleSeats: mainEligible });
return pots;
}
// Legacy helper used for UI side-pot display during active betting rounds.
// Uses current-round p.bet values (only valid before collectBets is called).
export function buildPots(players) {
const active = players.filter((p) => !p.folded && p.bet > 0);
if (active.length === 0) return [];
const allInAmounts = [...new Set(
active.filter((p) => p.allIn).map((p) => p.bet),
)].sort((a, b) => a - b);
const pots = [];
let prev = 0;
const slice = (cap) => {
let amount = 0;
const eligible = [];
for (const p of players) {
if (p.folded) { amount += Math.min(p.bet, cap) - Math.min(p.bet, prev); }
else {
const contrib = Math.min(p.bet, cap) - Math.min(p.bet, prev);
if (contrib > 0) { amount += contrib; eligible.push(p.seat); }
}
}
if (amount > 0) pots.push({ amount, eligibleSeats: eligible });
prev = cap;
};
for (const level of allInAmounts) slice(level);
const mainEligible = players.filter((p) => !p.folded && p.bet > prev).map((p) => p.seat);
let mainAmount = 0;
for (const p of players) mainAmount += Math.max(0, p.bet - prev);
if (mainAmount > 0) pots.push({ amount: mainAmount, eligibleSeats: mainEligible });
return pots;
}
// ── Showdown resolution ───────────────────────────────────────────────────────
// allPlayers: full players array including folded (needed for side-pot math).
// Returns Map<seat, chipsWon>
export function resolveShowdown(allPlayers, community) {
const pots = buildPotsFromTotals(allPlayers);
const winnings = new Map(allPlayers.map((p) => [p.seat, 0]));
for (const pot of pots) {
if (pot.eligibleSeats.length === 0) continue;
const contenders = pot.eligibleSeats.map((seat) => {
const p = allPlayers.find((pl) => pl.seat === seat);
return { seat, eval: evaluateHand([...p.hand, ...community]) };
});
let best = contenders[0].eval;
for (const c of contenders) {
if (compareHands(c.eval, best) > 0) best = c.eval;
}
const winners = contenders.filter((c) => compareHands(c.eval, best) === 0);
const share = Math.floor(pot.amount / winners.length);
const remainder = pot.amount - share * winners.length;
const sorted = [...winners].sort((a, b) => a.seat - b.seat);
for (let i = 0; i < sorted.length; i++) {
winnings.set(sorted[i].seat, (winnings.get(sorted[i].seat) ?? 0) + share + (i === 0 ? remainder : 0));
}
}
return winnings;
}
// ── Initial state ─────────────────────────────────────────────────────────────
export function createInitialState(opponentDefs, buyIn) {
const players = [
{
seat: 0,
name: 'You',
chips: buyIn,
hand: [],
folded: false,
allIn: false,
bet: 0,
totalBet: 0,
isHuman: true,
isDealer: false,
eliminated: false,
hasActedThisRound: false,
},
...opponentDefs.slice(0, 3).map((opp, i) => ({
seat: i + 1,
name: opp.name,
chips: buyIn,
hand: [],
folded: false,
allIn: false,
bet: 0,
totalBet: 0,
isHuman: false,
isDealer: false,
eliminated: false,
hasActedThisRound: false,
})),
];
return {
phase: 'waiting',
deck: null,
players,
community: [],
pot: 0,
sidePots: [],
dealerSeat: 0,
actionSeat: -1,
roundBet: 0,
lastRaiser: null,
gameStartMs: Date.now(),
handNumber: 0,
};
}
// ── Hand setup ────────────────────────────────────────────────────────────────
export function startHand(state) {
const activePlayers = state.players.filter((p) => !p.eliminated);
if (activePlayers.length < 2) return state; // game over
// Advance dealer
let dealerSeat = state.dealerSeat;
for (let i = 0; i < state.players.length; i++) {
dealerSeat = (dealerSeat + 1) % state.players.length;
if (!state.players[dealerSeat].eliminated) break;
}
// Reset all hands
const players = state.players.map((p) => ({
...p,
hand: [],
folded: p.eliminated,
allIn: false,
bet: 0,
totalBet: 0,
isDealer: p.seat === dealerSeat,
hasActedThisRound: false,
}));
// Deal 2 cards each
const deck = new Deck();
deck.shuffle();
for (let i = 0; i < 2; i++) {
for (const p of players) {
if (!p.eliminated) p.hand.push(deck.deal(1)[0]);
}
}
// Post blinds
const blind = getBlindLevel(Date.now() - state.gameStartMs);
const { small: sb, big: bb } = blind;
const seatOrder = getSeatOrder(players, dealerSeat);
const sbSeat = seatOrder[0];
const bbSeat = seatOrder[1];
for (const p of players) {
if (p.seat === sbSeat) {
const amount = Math.min(p.chips, sb);
p.chips -= amount;
p.bet = amount;
p.totalBet = amount;
if (p.chips === 0) p.allIn = true;
}
if (p.seat === bbSeat) {
const amount = Math.min(p.chips, bb);
p.chips -= amount;
p.bet = amount;
p.totalBet = amount;
if (p.chips === 0) p.allIn = true;
}
}
// First to act pre-flop is after big blind
let actionSeat = seatOrder[2] ?? seatOrder[0];
return {
...state,
phase: 'preflop',
deck,
players,
community: [],
pot: 0,
sidePots: [],
dealerSeat,
actionSeat,
roundBet: bb,
lastRaiser: bbSeat,
handNumber: state.handNumber + 1,
sbSeat,
bbSeat,
blind,
};
}
// Returns active (not eliminated, not folded) seat numbers starting after dealer
function getSeatOrder(players, dealerSeat) {
const n = players.length;
const order = [];
for (let i = 1; i <= n; i++) {
const seat = (dealerSeat + i) % n;
if (!players[seat].eliminated) order.push(seat);
}
return order;
}
// ── Betting actions ───────────────────────────────────────────────────────────
export function applyAction(state, seat, action) {
if (state.actionSeat !== seat) return state;
const players = state.players.map((p) => ({ ...p }));
const player = players[seat];
let { pot, roundBet, lastRaiser } = state;
switch (action.type) {
case 'fold':
player.folded = true;
player.hasActedThisRound = true;
break;
case 'check':
if (player.bet < roundBet) return state;
player.hasActedThisRound = true;
break;
case 'call': {
const toCall = Math.min(roundBet - player.bet, player.chips);
player.chips -= toCall;
player.bet += toCall;
player.totalBet += toCall;
if (player.chips === 0) player.allIn = true;
player.hasActedThisRound = true;
break;
}
case 'raise': {
const minR = roundBet === 0 ? (state.blind?.big ?? 10) : roundBet * 2;
const raiseTotal = Math.max(minR, action.amount ?? minR);
const toAdd = Math.min(raiseTotal - player.bet, player.chips);
player.chips -= toAdd;
player.bet += toAdd;
player.totalBet += toAdd;
if (player.chips === 0) player.allIn = true;
roundBet = player.bet;
lastRaiser = seat;
for (const p of players) {
if (p.seat !== seat && !p.eliminated && !p.folded && !p.allIn) {
p.hasActedThisRound = false;
}
}
player.hasActedThisRound = true;
break;
}
case 'allin': {
const toAdd = player.chips;
player.chips = 0;
player.bet += toAdd;
player.totalBet += toAdd;
player.allIn = true;
if (player.bet > roundBet) {
roundBet = player.bet;
lastRaiser = seat;
for (const p of players) {
if (p.seat !== seat && !p.eliminated && !p.folded && !p.allIn) {
p.hasActedThisRound = false;
}
}
}
player.hasActedThisRound = true;
break;
}
default:
return state;
}
const nextSeat = nextToAct(players, seat, roundBet);
if (nextSeat === null) {
// Betting round over — collect bets into pot and advance phase
return advancePhase({ ...state, players, pot, roundBet, lastRaiser });
}
return { ...state, players, pot, roundBet, lastRaiser, actionSeat: nextSeat };
}
function nextToAct(players, currentSeat, roundBet) {
const n = players.length;
for (let i = 1; i <= n; i++) {
const seat = (currentSeat + i) % n;
const p = players[seat];
if (p.eliminated || p.folded || p.allIn) continue;
if (p.bet < roundBet || !p.hasActedThisRound) return seat;
}
return null;
}
function collectBets(players, pot) {
let total = pot;
const updated = players.map((p) => {
total += p.bet;
return { ...p, bet: 0, hasActedThisRound: false };
});
return { players: updated, pot: total };
}
function advancePhase(state) {
const { players, deck, community } = state;
// Check if only one player remains (everyone else folded)
const remaining = players.filter((p) => !p.eliminated && !p.folded);
const { players: clearedPlayers, pot } = collectBets(players, state.pot);
if (remaining.length === 1) {
// Award pot to last player standing
const winner = clearedPlayers.find((p) => p.seat === remaining[0].seat);
winner.chips += pot;
return endHand({ ...state, players: clearedPlayers, pot: 0, phase: 'showdown', community });
}
const nextPhase = { preflop: 'flop', flop: 'turn', turn: 'river', river: 'showdown' }[state.phase];
let newCommunity = [...community];
if (nextPhase === 'flop') newCommunity = [...newCommunity, ...deck.deal(3)];
if (nextPhase === 'turn') newCommunity = [...newCommunity, ...deck.deal(1)];
if (nextPhase === 'river') newCommunity = [...newCommunity, ...deck.deal(1)];
if (nextPhase === 'showdown') {
return endHand({ ...state, players: clearedPlayers, pot, community: newCommunity, phase: 'showdown' });
}
// Reset bets for new round; first to act is first active after dealer
const seatOrder = getSeatOrder(clearedPlayers, state.dealerSeat);
const actionSeat = seatOrder[0];
return {
...state,
phase: nextPhase,
players: clearedPlayers,
pot,
community: newCommunity,
roundBet: 0,
lastRaiser: null,
actionSeat,
sidePots: buildPots(clearedPlayers),
};
}
function endHand(state) {
const { players, community } = state;
let updatedPlayers = players.map((p) => ({ ...p }));
if (state.phase === 'showdown' && state.pot > 0) {
const active = updatedPlayers.filter((p) => !p.eliminated && !p.folded);
if (active.length === 1) {
// Everyone else folded — sole survivor takes the pot
updatedPlayers[active[0].seat].chips += state.pot;
} else if (active.length > 1) {
// Full showdown — use totalBet to build side pots and evaluate hands
const winnings = resolveShowdown(updatedPlayers, community);
for (const [seat, amount] of winnings) {
updatedPlayers[seat].chips += amount;
}
}
}
// Eliminate players at 0 chips
for (const p of updatedPlayers) {
if (p.chips === 0 && !p.eliminated) p.eliminated = true;
}
const stillIn = updatedPlayers.filter((p) => !p.eliminated);
const gameOver = stillIn.length <= 1;
return {
...state,
players: updatedPlayers,
pot: 0,
sidePots: [],
phase: gameOver ? 'game_over' : 'between_hands',
winner: gameOver ? stillIn[0]?.seat ?? null : null,
};
}
// ── Query helpers ─────────────────────────────────────────────────────────────
export function getActivePlayers(state) {
return state.players.filter((p) => !p.eliminated && !p.folded);
}
export function canCheck(state, seat) {
const p = state.players[seat];
return p && !p.folded && p.bet >= state.roundBet;
}
export function callAmount(state, seat) {
const p = state.players[seat];
if (!p) return 0;
return Math.min(state.roundBet - p.bet, p.chips);
}
export function minRaise(state) {
return state.roundBet === 0 ? (state.blind?.big ?? 10) : state.roundBet * 2;
}

View File

@ -1,4 +1,4 @@
import Phaser from 'phaser';
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from './config.js';
import BootScene from './scenes/BootScene.js';
import PreloadScene from './scenes/PreloadScene.js';
@ -8,8 +8,11 @@ import RegisterScene from './scenes/RegisterScene.js';
import VerifyScene from './scenes/VerifyScene.js';
import ProfileScene from './scenes/ProfileScene.js';
import GameMenuScene from './scenes/GameMenuScene.js';
import OpponentSelectScene from './scenes/OpponentSelectScene.js';
import LobbyScene from './scenes/LobbyScene.js';
import GameRoomScene from './scenes/GameRoomScene.js';
import BackgammonGame from './games/backgammon/BackgammonGame.js';
import HoldemGame from './games/holdem/HoldemGame.js';
const config = {
type: Phaser.AUTO,
@ -31,8 +34,11 @@ const config = {
VerifyScene,
ProfileScene,
GameMenuScene,
OpponentSelectScene,
LobbyScene,
GameRoomScene,
BackgammonGame,
HoldemGame,
],
};

View File

@ -1,4 +1,4 @@
import Phaser from 'phaser';
import * as Phaser from 'phaser';
export default class BootScene extends Phaser.Scene {
constructor() { super('Boot'); }

View File

@ -1,4 +1,4 @@
import Phaser from 'phaser';
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { api } from '../services/api.js';
import { auth } from '../services/auth.js';
@ -55,14 +55,11 @@ export default class GameMenuScene extends Phaser.Scene {
}
openGame(game) {
if (!auth.user && game.supportsMultiplayer) {
this.scene.start('Login');
return;
}
if (game.supportsMultiplayer) {
if (game.multiplayerOnly) {
if (!auth.user) { this.scene.start('Login'); return; }
this.scene.start('Lobby', { game });
} else {
this.scene.start('GameRoom', { game });
this.scene.start('OpponentSelect', { game });
}
}
}

View File

@ -1,4 +1,4 @@
import Phaser from 'phaser';
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { getSocket } from '../services/socket.js';
import { Button } from '../ui/Button.js';
@ -12,9 +12,22 @@ export default class GameRoomScene extends Phaser.Scene {
init(data) {
this.game = data.game;
this.room = data.room ?? null;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,
opponents: this.opponents,
playfield: this.playfield,
cardBack: this.cardBack,
});
return;
}
const cx = GAME_WIDTH / 2;
this.add.text(cx, 80, `${this.game.name}`, {

View File

@ -1,4 +1,4 @@
import Phaser from 'phaser';
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { auth } from '../services/auth.js';
import { Button } from '../ui/Button.js';

View File

@ -1,4 +1,4 @@
import Phaser from 'phaser';
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { connectSocket, getSocket } from '../services/socket.js';
import { Button } from '../ui/Button.js';

View File

@ -1,4 +1,4 @@
import Phaser from 'phaser';
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { auth } from '../services/auth.js';
import { Button } from '../ui/Button.js';

View File

@ -0,0 +1,383 @@
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { Button } from '../ui/Button.js';
// Option tile dimensions — playfield rows
const TILE_W = 190;
const TILE_H = 90;
const TILE_GAP = 16;
// Card-back tile dimensions — sized to preserve 320:420 card aspect ratio
// thumbW = CARD_TILE_W - 16 = 84; thumbH = 84 * (420/320) ≈ 110; CARD_TILE_H = 110 + 30 = 140
const CARD_TILE_W = 100;
const CARD_TILE_H = 141;
const CARD_TILE_GAP = 14;
// Opponent grid scroll area
const OPP_SCROLL_W = 1780;
const OPP_SCROLL_H = 440;
const OPP_SCROLL_TOP = 155; // top edge of scroll area
export default class OpponentSelectScene extends Phaser.Scene {
constructor() { super('OpponentSelect'); }
init(data) {
this.gameDef = data.game;
this.selected = new Set();
this.cards = []; // [{ opp, el }]
this.selectedPlayfield = null;
this.playfieldTiles = [];
this.selectedCardBack = null;
this.cardBackTiles = [];
}
async create() {
const cx = GAME_WIDTH / 2;
this.add.text(cx, 60, this.gameDef.name, {
fontFamily: 'system-ui, sans-serif',
fontSize: '52px',
color: COLORS.textHex,
}).setOrigin(0.5);
this.add.text(cx, 122, 'Choose your opponent', {
fontFamily: 'system-ui, sans-serif',
fontSize: '36px',
color: COLORS.mutedHex,
}).setOrigin(0.5);
let opponents = [];
try {
const res = await fetch('/data/opponents.json');
const json = await res.json();
opponents = json.opponents ?? [];
} catch {
this.add.text(cx, GAME_HEIGHT / 2, 'Failed to load opponents.', {
fontSize: '28px', color: COLORS.dangerHex,
}).setOrigin(0.5);
return;
}
this.startBtn = new Button(this, cx, 1048, 'Start Game', () => this.startGame(), { width: 280 });
this.startBtn.setEnabled(false);
new Button(this, cx, 978, 'Back', () => this.scene.start('GameMenu'), {
variant: 'ghost',
width: 280,
});
this.buildOpponentGrid(opponents);
this.buildOptionSection('Playfield', 630, this.cache.json.get('playfields')?.playfields ?? [],
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf));
if (this.gameDef.cardGame) {
this.buildOptionSection('Card Back', 798, this.cache.json.get('card-backs')?.cardBacks ?? [],
'selectedCardBack', 'cardBackTiles', (cb) => this.selectCardBack(cb),
CARD_TILE_W, CARD_TILE_H, CARD_TILE_GAP);
}
// Apply defaults
const pfd = this.cache.json.get('playfields') ?? {};
this.applyDefault('playfields', pfd.default, 'selectedPlayfield', 'playfieldTiles');
if (this.gameDef.cardGame) {
const cbd = this.cache.json.get('card-backs') ?? {};
this.applyDefault('card-backs', cbd.default, 'selectedCardBack', 'cardBackTiles');
}
}
// ── Opponent grid (DOM-based, 2-column scrollable list) ────────────────────
buildOpponentGrid(opponents) {
const cx = GAME_WIDTH / 2;
const CARD_H = 118;
const GAP = 16;
// Outer scroll container — just height + overflow, no layout opinions
const wrapper = document.createElement('div');
wrapper.style.cssText = [
`width:${OPP_SCROLL_W}px`,
`height:${OPP_SCROLL_H}px`,
'overflow-y:auto',
'overflow-x:hidden',
'display:flex',
'flex-direction:column',
`gap:${GAP}px`,
'padding:2px',
'box-sizing:border-box',
'scrollbar-width:thin',
`scrollbar-color:${COLORS.accentHex} ${COLORS.panelHex}`,
].join(';');
// Group opponents into pairs and build one row per pair
for (let i = 0; i < opponents.length; i += 2) {
const row = document.createElement('div');
row.style.cssText = `display:flex;gap:${GAP}px;flex-shrink:0;`;
const left = this.buildOpponentCardEl(opponents[i], CARD_H);
row.appendChild(left);
if (opponents[i + 1]) {
const right = this.buildOpponentCardEl(opponents[i + 1], CARD_H);
row.appendChild(right);
} else {
// Odd opponent out — add an invisible spacer so the card stays half-width
const spacer = document.createElement('div');
spacer.style.cssText = 'flex:1;';
row.appendChild(spacer);
}
wrapper.appendChild(row);
}
this.add.dom(cx, OPP_SCROLL_TOP + OPP_SCROLL_H / 2, wrapper);
}
buildOpponentCardEl(opp, cardH) {
const el = document.createElement('div');
el.style.cssText = [
'flex:1', // fill exactly half of the row
`height:${cardH}px`,
`background:${COLORS.panelHex}`,
`border:2px solid ${COLORS.mutedHex}`,
'border-radius:8px',
'display:flex',
'align-items:center',
'gap:16px',
'padding:12px 16px',
'cursor:pointer',
'box-sizing:border-box',
'user-select:none',
'transition:border-color 0.12s',
].join(';');
// Portrait wrapper — fixed size so canvas/video can be stacked inside
const portraitSize = cardH - 24;
const portraitWrap = document.createElement('div');
portraitWrap.style.cssText = `position:relative;width:${portraitSize}px;height:${portraitSize}px;flex-shrink:0;`;
// Static canvas (sprite frame with circular clip)
const canvas = document.createElement('canvas');
canvas.width = portraitSize;
canvas.height = portraitSize;
canvas.style.cssText = `position:absolute;top:0;left:0;width:${portraitSize}px;height:${portraitSize}px;border-radius:50%;background:${COLORS.panelHex};`;
const ctx = canvas.getContext('2d');
const r = portraitSize / 2;
ctx.save();
ctx.beginPath();
ctx.arc(r, r, r, 0, Math.PI * 2);
ctx.clip();
if (this.textures.exists('opponents')) {
const frame = this.textures.getFrame('opponents', opp.spriteIndex ?? 0);
ctx.drawImage(
frame.source.image,
frame.cutX, frame.cutY, frame.cutWidth, frame.cutHeight,
0, 0, portraitSize, portraitSize,
);
} else {
ctx.fillStyle = COLORS.panelHex;
ctx.fillRect(0, 0, portraitSize, portraitSize);
ctx.fillStyle = COLORS.accentHex;
ctx.font = `bold ${Math.round(r * 0.8)}px system-ui,sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText((opp.name ?? '?').charAt(0).toUpperCase(), r, r);
}
ctx.restore();
// Idle video (shown when selected, hidden otherwise)
const video = document.createElement('video');
video.src = `/assets/videos/${opp.id}-idle.mp4`;
video.muted = true;
video.loop = true;
video.playsInline = true;
video.style.cssText = `position:absolute;top:0;left:0;width:${portraitSize}px;height:${portraitSize}px;border-radius:50%;object-fit:cover;display:none;`;
// If the video file doesn't exist, restore the canvas
video.addEventListener('error', () => {
video.style.display = 'none';
canvas.style.display = 'block';
});
portraitWrap.appendChild(canvas);
portraitWrap.appendChild(video);
// Text block
const info = document.createElement('div');
info.style.cssText = 'display:flex;flex-direction:column;gap:6px;min-width:0;flex:1;';
const name = document.createElement('div');
name.textContent = opp.name ?? '';
name.style.cssText = [
'font-family:system-ui,sans-serif',
'font-size:22px',
'font-weight:600',
`color:${COLORS.textHex}`,
'white-space:nowrap',
'overflow:hidden',
'text-overflow:ellipsis',
].join(';');
const bio = document.createElement('div');
bio.textContent = opp.bio ?? '';
bio.style.cssText = [
'font-family:system-ui,sans-serif',
'font-size:15px',
`color:${COLORS.mutedHex}`,
'line-height:1.4',
'overflow:hidden',
'display:-webkit-box',
'-webkit-line-clamp:2',
'-webkit-box-orient:vertical',
].join(';');
info.appendChild(name);
info.appendChild(bio);
el.appendChild(portraitWrap);
el.appendChild(info);
el.addEventListener('click', () => this.toggleOpponent(opp, el));
this.cards.push({ opp, el, canvas, video });
return el;
}
toggleOpponent(opp, el) {
const min = this.gameDef.minOpponents ?? 1;
const max = this.gameDef.maxOpponents ?? 1;
if (this.selected.has(opp.id)) {
this.selected.delete(opp.id);
el.style.borderColor = COLORS.mutedHex;
const card = this.cards.find((c) => c.opp.id === opp.id);
if (card) {
card.video.pause();
card.video.style.display = 'none';
card.canvas.style.display = 'block';
}
} else {
// If already at max, bump the oldest selection out first
if (this.selected.size >= max) {
const oldestId = this.selected.values().next().value;
this.selected.delete(oldestId);
const oldCard = this.cards.find((c) => c.opp.id === oldestId);
if (oldCard) {
oldCard.el.style.borderColor = COLORS.mutedHex;
oldCard.video.pause();
oldCard.video.style.display = 'none';
oldCard.canvas.style.display = 'block';
}
}
this.selected.add(opp.id);
el.style.borderColor = COLORS.accentHex;
const card = this.cards.find((c) => c.opp.id === opp.id);
if (card) {
card.canvas.style.display = 'none';
card.video.style.display = 'block';
card.video.play().catch(() => {
card.video.style.display = 'none';
card.canvas.style.display = 'block';
});
}
}
this.startBtn.setEnabled(this.selected.size >= min);
}
// ── Generic option section builder ─────────────────────────────────────────
buildOptionSection(label, labelY, items, selectedProp, tilesProp, onSelect, tileW = TILE_W, tileH = TILE_H, tileGap = TILE_GAP) {
const cx = GAME_WIDTH / 2;
this.add.text(cx, labelY, label, {
fontFamily: 'system-ui, sans-serif',
fontSize: '24px',
color: COLORS.mutedHex,
}).setOrigin(0.5);
const tileY = labelY + Math.max(82, Math.round(tileH / 2) + 18);
const totalW = items.length * tileW + (items.length - 1) * tileGap;
const startX = cx - totalW / 2 + tileW / 2;
items.forEach((item, i) => {
const x = startX + i * (tileW + tileGap);
this.buildOptionTile(item, x, tileY, selectedProp, tilesProp, onSelect, tileW, tileH);
});
}
buildOptionTile(item, x, y, selectedProp, tilesProp, onSelect, tileW = TILE_W, tileH = TILE_H) {
const container = this.add.container(x, y);
const isSelected = this[selectedProp]?.id === item.id;
const bg = this.add.rectangle(0, 0, tileW, tileH, COLORS.panel)
.setStrokeStyle(3, isSelected ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const thumbW = tileW - 16;
const thumbH = tileH - 30;
let thumb;
const thumbKey = item.key;
if (thumbKey && this.textures.exists(thumbKey)) {
const frame = item.spriteIndex ?? undefined;
thumb = this.add.image(0, -8, thumbKey, frame)
.setDisplaySize(thumbW, thumbH)
.setOrigin(0.5);
} else {
const fallbackHex = item.fallbackColor ?? '#1a3a6b';
const fallback = parseInt(fallbackHex.replace('#', ''), 16);
thumb = this.add.rectangle(0, -8, thumbW, thumbH, fallback);
}
const nameText = this.add.text(0, tileH / 2 - 11, item.name, {
fontFamily: 'system-ui, sans-serif',
fontSize: '13px',
color: COLORS.textHex,
}).setOrigin(0.5);
container.add([bg, thumb, nameText]);
bg.on('pointerup', () => {
this[selectedProp] = item;
for (const t of this[tilesProp]) {
t.bg.setStrokeStyle(3, t.item.id === item.id ? COLORS.accent : COLORS.muted);
}
onSelect(item);
});
bg.on('pointerover', () => {
if (this[selectedProp]?.id !== item.id) bg.setStrokeStyle(3, COLORS.text);
});
bg.on('pointerout', () => {
if (this[selectedProp]?.id !== item.id) bg.setStrokeStyle(3, COLORS.muted);
});
this[tilesProp].push({ item, bg });
}
applyDefault(cacheKey, defaultId, selectedProp, tilesProp) {
const data = this.cache.json.get(cacheKey);
const items = data?.playfields ?? data?.cardBacks ?? [];
const def = items.find((i) => i.id === defaultId) ?? items[0] ?? null;
if (!def) return;
this[selectedProp] = def;
for (const t of this[tilesProp]) {
t.bg.setStrokeStyle(3, t.item.id === def.id ? COLORS.accent : COLORS.muted);
}
}
selectPlayfield(pf) { this.selectedPlayfield = pf; }
selectCardBack(cb) { this.selectedCardBack = cb; }
// ── Start game ─────────────────────────────────────────────────────────────
startGame() {
if (this.selected.size === 0) return;
const opponents = this.cards
.filter(({ opp }) => this.selected.has(opp.id))
.map(({ opp }) => opp);
this.scene.start('GameRoom', {
game: this.gameDef,
opponents,
playfield: this.selectedPlayfield,
cardBack: this.selectedCardBack,
});
}
}

View File

@ -1,4 +1,4 @@
import Phaser from 'phaser';
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { auth } from '../services/auth.js';
@ -22,11 +22,36 @@ export default class PreloadScene extends Phaser.Scene {
this.load.on('progress', (p) => bar.width = barWidth * p);
this.load.on('complete', () => { bg.destroy(); bar.destroy(); });
// Placeholder asset slot — drop sprite files here later, no asset is
// required for the framework to boot.
this.load.spritesheet('opponents', '/assets/images/opponents.png', {
frameWidth: 300,
frameHeight: 300,
});
this.load.spritesheet('cardbacks', '/assets/images/cardbacks.png', {
frameWidth: 320,
frameHeight: 420,
});
this.load.json('playfields', '/data/playfields.json');
this.load.json('card-backs', '/data/card-backs.json');
}
async create() {
// Collect all image assets that need loading from JSON configs
const pfd = this.cache.json.get('playfields');
const cbd = this.cache.json.get('card-backs');
const toLoad = [
...(pfd?.playfields ?? []).filter((pf) => pf.path && !this.textures.exists(pf.key)),
...(cbd?.cardBacks ?? []).filter((cb) => cb.path && !this.textures.exists(cb.key)),
];
if (toLoad.length > 0) {
for (const asset of toLoad) this.load.image(asset.key, asset.path);
await new Promise((resolve) => {
this.load.once('complete', resolve);
this.load.start();
});
}
await auth.refresh();
this.scene.start('Landing');
}

View File

@ -1,4 +1,4 @@
import Phaser from 'phaser';
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { api } from '../services/api.js';
import { auth } from '../services/auth.js';
@ -78,14 +78,46 @@ export default class ProfileScene extends Phaser.Scene {
}).setOrigin(0, 0.5);
}
// Chip balance row
const chipsY = 460;
this.add.text(cx - 320, chipsY, 'Chip balance', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
const chipsValueText = this.add.text(cx - 40, chipsY, `$${profile.chips.toLocaleString()}`, {
fontFamily: 'system-ui, sans-serif',
fontSize: '22px',
color: COLORS.textHex,
}).setOrigin(0, 0.5);
// Financial reset — only visible when chips < $100
let resetBtn = null;
if (profile.chips < 100) {
this.add.text(cx - 320, chipsY + 44, 'You are running low on chips.', {
fontFamily: 'system-ui, sans-serif',
fontSize: '18px',
color: COLORS.dangerHex,
}).setOrigin(0, 0.5);
resetBtn = new Button(this, cx + 200, chipsY + 44, 'Request financial reset', async () => {
resetBtn.setEnabled(false);
try {
const { chips } = await api.post('/profile/chips/reset', {});
profile.chips = chips;
chipsValueText.setText(`$${chips.toLocaleString()}`);
new Modal(this, `Chip balance reset to $${chips.toLocaleString()}.`, { autoCloseMs: 2000 });
} catch (err) {
new Modal(this, err.message, { color: COLORS.dangerHex, autoCloseMs: 2400 });
resetBtn.setEnabled(true);
}
}, { variant: 'ghost', width: 300, fontSize: 18 });
}
// Editable fields
this.add.text(cx - 320, 480, 'Display name', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
const displayNameInput = new TextInput(this, cx + 80, 520, { width: 600, value: profile.displayName ?? '', maxLength: 60 });
this.add.text(cx - 320, 560, 'Display name', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
const displayNameInput = new TextInput(this, cx + 80, 600, { width: 600, value: profile.displayName ?? '', maxLength: 60 });
this.add.text(cx - 320, 600, 'Bio', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
const bioInput = new TextInput(this, cx + 80, 660, { width: 600, height: 120, multiline: true, value: profile.bio ?? '', maxLength: 500 });
this.add.text(cx - 320, 680, 'Bio', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
const bioInput = new TextInput(this, cx + 80, 740, { width: 600, height: 120, multiline: true, value: profile.bio ?? '', maxLength: 500 });
new Button(this, cx - 200, 820, 'Save profile', async () => {
new Button(this, cx - 200, 900, 'Save profile', async () => {
try {
const { profile: updated } = await api.patch('/profile', {
displayName: displayNameInput.value,
@ -98,8 +130,8 @@ export default class ProfileScene extends Phaser.Scene {
}
});
new Button(this, cx + 100, 820, 'Upload avatar', () => this.pickAvatar());
new Button(this, cx + 400, 820, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
new Button(this, cx + 100, 900, 'Upload avatar', () => this.pickAvatar());
new Button(this, cx + 400, 900, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
}
pickAvatar() {

View File

@ -1,4 +1,4 @@
import Phaser from 'phaser';
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { auth } from '../services/auth.js';
import { Button } from '../ui/Button.js';

View File

@ -1,4 +1,4 @@
import Phaser from 'phaser';
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { auth } from '../services/auth.js';
import { Button } from '../ui/Button.js';

View File

@ -1,4 +1,4 @@
import Phaser from 'phaser';
import * as Phaser from 'phaser';
import { COLORS } from '../config.js';
export class Button extends Phaser.GameObjects.Container {

View File

@ -1,4 +1,4 @@
import Phaser from 'phaser';
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
export class Modal extends Phaser.GameObjects.Container {

149
public/src/ui/Portrait.js Normal file
View File

@ -0,0 +1,149 @@
import { COLORS } from '../config.js';
import { auth } from '../services/auth.js';
import { api } from '../services/api.js';
// ── Shared portrait utility ───────────────────────────────────────────────────
// Both factories add elements directly to the scene at world coordinates so
// that GeometryMasks and DOM video elements work without container offsets.
function drawBacking(scene, x, y, radius, depth) {
const g = scene.add.graphics().setDepth(depth);
g.fillStyle(0x1a1a2e, 1);
g.fillCircle(x, y, radius + 3);
g.fillStyle(COLORS.panel, 1);
g.fillCircle(x, y, radius + 1);
return g;
}
// ── Opponent portrait (video + sprite fallback) ───────────────────────────────
// Returns { playEmotion(emotion), destroy() }
export function createOpponentPortrait(scene, opponent, worldX, worldY, radius, depth) {
const size = radius * 2;
const backingG = drawBacking(scene, worldX, worldY, radius, depth);
// Always draw the sprite first as a fallback layer
let spriteImg = null;
if (scene.textures.exists('opponents')) {
const maskG = scene.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff);
maskG.fillCircle(worldX, worldY, radius);
spriteImg = scene.add.image(worldX, worldY, 'opponents', opponent?.spriteIndex ?? 0)
.setDisplaySize(size, size)
.setMask(maskG.createGeometryMask())
.setDepth(depth + 1);
}
// Attempt video on top — CSS border-radius handles circular clipping
const videoEl = document.createElement('video');
videoEl.muted = true;
videoEl.loop = true;
videoEl.playsInline = true;
videoEl.autoplay = true;
videoEl.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;`;
videoEl.src = `/assets/videos/${opponent?.id}-idle.mp4`;
videoEl.play().catch(() => {});
// Hide the video element if the file doesn't exist so sprite shows through
let videoError = false;
videoEl.addEventListener('error', () => {
videoEl.style.display = 'none';
videoError = true;
}, { once: true });
const domEl = scene.add.dom(worldX, worldY, videoEl).setDepth(depth + 2);
let emotionPlaying = false;
function playEmotion(emotion) {
if (!opponent || emotionPlaying || videoEl.style.display === 'none') return;
emotionPlaying = true;
const idleSrc = `/assets/videos/${opponent.id}-idle.mp4`;
const returnToIdle = () => {
videoEl.onended = null;
videoEl.onerror = null;
videoEl.loop = true;
videoEl.src = idleSrc;
videoEl.play().catch(() => {});
emotionPlaying = false;
};
videoEl.loop = false;
videoEl.src = `/assets/videos/${opponent.id}-${emotion}.mp4`;
videoEl.play().catch(returnToIdle);
videoEl.onended = returnToIdle;
videoEl.onerror = returnToIdle;
}
function hide() {
backingG.setVisible(false);
if (spriteImg) spriteImg.setVisible(false);
domEl.setVisible(false);
}
function show() {
backingG.setVisible(true);
if (spriteImg) spriteImg.setVisible(true);
if (!videoError) domEl.setVisible(true);
}
function destroy() {
videoEl.pause();
videoEl.src = '';
}
scene.events.once('shutdown', destroy);
return { playEmotion, hide, show, destroy };
}
// ── Player portrait (profile avatar with letter fallback) ─────────────────────
// Returns { destroy() }
export function createPlayerPortrait(scene, worldX, worldY, radius, depth, sceneName) {
const size = radius * 2;
const backingG = drawBacking(scene, worldX, worldY, radius, depth);
const initial = (auth.user?.username ?? 'You').charAt(0).toUpperCase();
const placeholder = scene.add.text(worldX, worldY, initial, {
fontFamily: 'system-ui, sans-serif',
fontSize: `${Math.round(radius * 0.9)}px`,
color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(depth + 1);
const allObjs = [backingG, placeholder];
// Async avatar load
(async () => {
try {
const { profile } = await api.get('/profile');
if (!profile?.avatarPath) return;
if (!scene.scene.isActive(sceneName)) return;
const key = `player-avatar-${profile.id}`;
if (!scene.textures.exists(key)) {
await new Promise((resolve) => {
scene.load.image(key, profile.avatarPath);
scene.load.once('complete', resolve);
scene.load.start();
});
}
if (!scene.scene.isActive(sceneName)) return;
const maskG = scene.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff);
maskG.fillCircle(worldX, worldY, radius);
placeholder.destroy();
const avatarImg = scene.add.image(worldX, worldY, key)
.setDisplaySize(size, size)
.setMask(maskG.createGeometryMask())
.setDepth(depth + 1);
allObjs.push(avatarImg);
} catch { /* placeholder remains */ }
})();
function hide() { for (const o of allObjs) o.setVisible?.(false); }
function show() { for (const o of allObjs) o.setVisible?.(true); }
return { hide, show, destroy() {} };
}

View File

@ -1,4 +1,4 @@
import Phaser from 'phaser';
import * as Phaser from 'phaser';
// DOM-overlay text input. Positions a real <input> element above the canvas
// using the scene's scale so it lines up with where you'd draw it in Phaser.

View File

@ -0,0 +1 @@
ALTER TABLE profiles ADD COLUMN chips_balance INTEGER NOT NULL DEFAULT 2000;

View File

@ -6,9 +6,13 @@ export function registerGame(definition) {
slug: definition.slug,
name: definition.name ?? definition.slug,
category: definition.category ?? 'tabletop',
cardGame: definition.cardGame ?? false,
minPlayers: definition.minPlayers ?? 2,
maxPlayers: definition.maxPlayers ?? 2,
minOpponents: definition.minOpponents ?? 1,
maxOpponents: definition.maxOpponents ?? 1,
supportsMultiplayer: definition.supportsMultiplayer ?? true,
multiplayerOnly: definition.multiplayerOnly ?? false,
});
}
@ -21,7 +25,7 @@ export function getGame(slug) {
}
// Built-in placeholders so the menu has something to show.
registerGame({ slug: 'backgammon', name: 'Backgammon', category: 'tabletop', minPlayers: 2, maxPlayers: 2 });
registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 2, maxPlayers: 4 });
registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', minPlayers: 1, maxPlayers: 6 });
registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', minPlayers: 2, maxPlayers: 8 });
registerGame({ slug: 'backgammon', name: 'Backgammon', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, multiplayerOnly: false });
registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 2, maxPlayers: 4, multiplayerOnly: false });
registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 6, multiplayerOnly: false });
registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 8, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });

View File

@ -5,7 +5,7 @@ import { Router } from 'express';
import multer from 'multer';
import config from '../config.js';
import { requireAuth } from '../auth/middleware.js';
import { getProfile, setAvatarPath, updateProfile } from './service.js';
import { getProfile, setAvatarPath, updateProfile, getChipsBalance, adjustChipsBalance, resetChipsBalance } from './service.js';
fs.mkdirSync(config.uploads.dir, { recursive: true });
@ -53,4 +53,26 @@ router.post('/avatar', requireAuth, (req, res, next) => {
});
});
router.get('/chips', requireAuth, (req, res) => {
res.json({ chips: getChipsBalance(req.user.id) });
});
router.post('/chips/adjust', requireAuth, (req, res) => {
const { delta } = req.body ?? {};
if (typeof delta !== 'number' || !Number.isInteger(delta)) {
return res.status(400).json({ error: 'delta must be an integer.' });
}
const chips = adjustChipsBalance(req.user.id, delta);
res.json({ chips });
});
router.post('/chips/reset', requireAuth, (req, res) => {
try {
const chips = resetChipsBalance(req.user.id);
res.json({ chips });
} catch (err) {
res.status(err.status ?? 500).json({ error: err.message });
}
});
export default router;

View File

@ -4,7 +4,7 @@ export function getProfile(userId) {
const row = db
.prepare(
`SELECT u.id, u.email, u.username, u.email_verified,
p.display_name, p.avatar_path, p.bio, p.updated_at
p.display_name, p.avatar_path, p.bio, p.chips_balance, p.updated_at
FROM users u LEFT JOIN profiles p ON p.user_id = u.id
WHERE u.id = ?`,
)
@ -18,6 +18,7 @@ export function getProfile(userId) {
displayName: row.display_name,
avatarPath: row.avatar_path,
bio: row.bio,
chips: row.chips_balance ?? 2000,
updatedAt: row.updated_at,
};
}
@ -46,3 +47,31 @@ export function setAvatarPath(userId, avatarPath) {
).run(avatarPath, userId);
return getProfile(userId);
}
export function getChipsBalance(userId) {
const row = db.prepare('SELECT chips_balance FROM profiles WHERE user_id = ?').get(userId);
return row?.chips_balance ?? 2000;
}
export function adjustChipsBalance(userId, delta) {
const current = getChipsBalance(userId);
const next = Math.max(0, current + delta);
db.prepare(
`UPDATE profiles SET chips_balance = ?, updated_at = datetime('now') WHERE user_id = ?`,
).run(next, userId);
return next;
}
export function resetChipsBalance(userId) {
const current = getChipsBalance(userId);
if (current >= 100) {
const err = new Error('Financial reset is only available when your chip balance is below $100.');
err.status = 403;
throw err;
}
const RESET_AMOUNT = 2000;
db.prepare(
`UPDATE profiles SET chips_balance = ?, updated_at = datetime('now') WHERE user_id = ?`,
).run(RESET_AMOUNT, userId);
return RESET_AMOUNT;
}