feat: add Baccarat casino game with AI opponents and cinematic reveals

- Implement core game logic in BaccaratLogic.js including shoe management,
  hand evaluation, and betting resolution with standard rules (5% banker
  commission, 8-to-1 tie payout).
- Create BaccaratGame.js Phaser scene with full UI: betting panel, chip
  selection, seat portraits, and animated card dealing.
- Add BaccaratAI.js for opponent bet sizing and type selection (weighted
  toward Banker).
- Introduce "Equation Ribbon" reveal sequence that walks through each card's
  value, sums them, and performs a mod-10 reduction with visual flair.
- Register Baccarat in the server game registry and frontend scene dispatch.
This commit is contained in:
Brian Fertig 2026-05-25 14:15:53 -06:00
parent e46cd1cc2f
commit 78466530a3
6 changed files with 1260 additions and 1 deletions

View File

@ -0,0 +1,18 @@
import { BET } from './BaccaratLogic.js';
// ─── Bet sizing ───────────────────────────────────────────────────────────────
export function chooseBet(player) {
const options = [5, 10, 15, 25];
const raw = options[Math.floor(Math.random() * options.length)];
return Math.min(raw, player.chips, 100);
}
// ─── Bet-type selection ─────────────────────────────────────────────────────
// Weighted toward Banker (the lower house-edge wager), with the occasional
// long-shot Tie bet for flavor.
export function chooseBetType() {
const r = Math.random();
if (r < 0.45) return BET.BANKER;
if (r < 0.85) return BET.PLAYER;
return BET.TIE;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,197 @@
// Baccarat (Punto Banco) pure game logic — no Phaser dependencies
import { SUITS, RANKS } from '../cards/Deck.js';
// ─── Bet types ──────────────────────────────────────────────────────────────
export const BET = { PLAYER: 'player', BANKER: 'banker', TIE: 'tie' };
// Banker win commission and tie payout (standard table odds).
const BANKER_COMMISSION = 0.05;
const TIE_PAYOUT = 8;
// ─── Shoe ─────────────────────────────────────────────────────────────────────
// Baccarat point values: A=1, 29 face value, 10/J/Q/K = 0.
const RANK_VALUE = {
'2':2,'3':3,'4':4,'5':5,'6':6,'7':7,'8':8,'9':9,'T':0,'J':0,'Q':0,'K':0,'A':1,
};
function makeCard(rank, suit) {
return {
rank, suit,
value: RANK_VALUE[rank],
label: rank === 'T' ? '10' : rank,
isRed: suit === 'h' || suit === 'd',
suitSymbol: { s:'♠', h:'♥', d:'♦', c:'♣' }[suit],
key: `${rank}${suit}`,
};
}
export function buildShoe(numDecks = 8) {
const cards = [];
for (let d = 0; d < numDecks; d++) {
for (const suit of SUITS) {
for (const rank of RANKS) {
cards.push(makeCard(rank, suit));
}
}
}
for (let i = cards.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[cards[i], cards[j]] = [cards[j], cards[i]];
}
return cards;
}
// ─── Hand evaluation ──────────────────────────────────────────────────────────
// A baccarat hand's value is the sum of its cards modulo 10.
export function handValue(cards) {
return cards.reduce((sum, c) => sum + c.value, 0) % 10;
}
// A "natural" is an 8 or 9 on the first two cards — no further draws.
export function isNatural(cards) {
return cards.length === 2 && (handValue(cards) === 8 || handValue(cards) === 9);
}
// ─── State creation ───────────────────────────────────────────────────────────
export function createInitialState(opponents, chips) {
const players = [
{
seat: 0, name: 'You', isHuman: true, active: true, opponent: null,
chips, bet: 0, betType: null,
result: null, chipsWon: 0,
},
];
for (let i = 0; i < 6; i++) {
const opp = opponents[i] ?? null;
players.push({
seat: i + 1, name: opp?.name ?? '', isHuman: false, active: !!opp, opponent: opp,
chips: 1000, bet: 0, betType: null,
result: null, chipsWon: 0,
});
}
return {
phase: 'betting',
playerHand: [],
bankerHand: [],
outcome: null, // 'player' | 'banker' | 'tie'
players,
roundNumber: 0,
};
}
// ─── Round start ──────────────────────────────────────────────────────────────
export function prepareRound(gs) {
const players = gs.players.map(p => ({
...p,
bet: 0, betType: null,
result: null, chipsWon: 0,
}));
return {
...gs,
phase: 'betting',
playerHand: [],
bankerHand: [],
outcome: null,
players,
roundNumber: gs.roundNumber + 1,
};
}
export function applyBet(gs, seat, amount, betType) {
const players = gs.players.map(p =>
p.seat === seat ? { ...p, bet: amount, betType } : p
);
return { ...gs, players };
}
// ─── Deal a coup ────────────────────────────────────────────────────────────
// Deals the Player and Banker hands per the fixed drawing rules. The shoe is
// mutated. Returns new gs with both hands and the outcome populated. The deal
// order popped from the shoe is P1, B1, P2, B2, then any third cards.
export function dealCoup(gs, shoe) {
const playerHand = [];
const bankerHand = [];
playerHand.push(shoe.pop()); // P1
bankerHand.push(shoe.pop()); // B1
playerHand.push(shoe.pop()); // P2
bankerHand.push(shoe.pop()); // B2
const playerNatural = isNatural(playerHand);
const bankerNatural = isNatural(bankerHand);
if (!playerNatural && !bankerNatural) {
let playerThird = null;
// Player draws on 05, stands on 67.
if (handValue(playerHand) <= 5) {
const card = shoe.pop();
playerHand.push(card);
playerThird = card.value;
}
// Banker drawing rules.
const bankerTotal = handValue(bankerHand);
let bankerDraws;
if (playerThird === null) {
// Player stood: banker plays like the player (draw on 05).
bankerDraws = bankerTotal <= 5;
} else if (bankerTotal <= 2) {
bankerDraws = true;
} else if (bankerTotal === 3) {
bankerDraws = playerThird !== 8;
} else if (bankerTotal === 4) {
bankerDraws = playerThird >= 2 && playerThird <= 7;
} else if (bankerTotal === 5) {
bankerDraws = playerThird >= 4 && playerThird <= 7;
} else if (bankerTotal === 6) {
bankerDraws = playerThird >= 6 && playerThird <= 7;
} else {
bankerDraws = false; // 7 stands
}
if (bankerDraws) bankerHand.push(shoe.pop());
}
const playerTotal = handValue(playerHand);
const bankerTotal = handValue(bankerHand);
const outcome = playerTotal > bankerTotal ? 'player'
: bankerTotal > playerTotal ? 'banker'
: 'tie';
return { ...gs, phase: 'resolved', playerHand, bankerHand, outcome };
}
// ─── Resolve bets ─────────────────────────────────────────────────────────────
// Returns the net chip delta for a single wager given the coup outcome.
export function betPayout(betType, bet, outcome) {
if (betType === BET.PLAYER) {
if (outcome === 'player') return bet;
if (outcome === 'tie') return 0; // bet returned
return -bet;
}
if (betType === BET.BANKER) {
if (outcome === 'banker') return bet - Math.floor(bet * BANKER_COMMISSION);
if (outcome === 'tie') return 0; // bet returned
return -bet;
}
// Tie
if (outcome === 'tie') return bet * TIE_PAYOUT;
return -bet;
}
export function resolveRound(gs) {
const players = gs.players.map(p => {
if (!p.active || !p.betType || p.bet === 0) return p;
const net = betPayout(p.betType, p.bet, gs.outcome);
let result;
if (net > 0) result = 'win';
else if (net < 0) result = 'lose';
else result = 'push';
return { ...p, chips: p.chips + net, chipsWon: net, result };
});
return { ...gs, phase: 'resolved', players };
}

View File

@ -27,6 +27,7 @@ import HeartsGame from './games/hearts/HeartsGame.js';
import CatanGame from './games/catan/CatanGame.js';
import NertsGame from './games/nerts/NertsGame.js';
import BingoGame from './games/bingo/BingoGame.js';
import BaccaratGame from './games/baccarat/BaccaratGame.js';
const config = {
type: Phaser.AUTO,
@ -67,6 +68,7 @@ const config = {
CatanGame,
NertsGame,
BingoGame,
BaccaratGame,
],
};

View File

@ -16,7 +16,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', nerts: 'NertsGame', bingo: 'BingoGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -40,3 +40,4 @@ registerGame({ slug: 'hearts', name: 'Hearts', category: 'cards', cardGame: true
registerGame({ slug: 'catan', name: 'Settlers of Catan', category: 'tabletop', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3 });
registerGame({ slug: 'nerts', name: 'Nerts', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
registerGame({ slug: 'bingo', name: 'Bingo', category: 'casino', minPlayers: 2, maxPlayers: 11, minOpponents: 1, maxOpponents: 10 });
registerGame({ slug: 'baccarat', name: 'Baccarat', category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 7, minOpponents: 1, maxOpponents: 6 });