diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index b075b70..04c9e70 100644 Binary files a/public/assets/images/game-icons.png and b/public/assets/images/game-icons.png differ diff --git a/public/assets/images/game-icons.psd b/public/assets/images/game-icons.psd index ad59c92..91ea068 100644 Binary files a/public/assets/images/game-icons.psd and b/public/assets/images/game-icons.psd differ diff --git a/public/src/games/paigow/PaiGowPokerAI.js b/public/src/games/paigow/PaiGowPokerAI.js new file mode 100644 index 0000000..491848f --- /dev/null +++ b/public/src/games/paigow/PaiGowPokerAI.js @@ -0,0 +1,9 @@ +// Pai Gow Poker AI — bet sizing + re-exports house way + +export { houseWay } from './PaiGowPokerLogic.js'; + +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); +} diff --git a/public/src/games/paigow/PaiGowPokerGame.js b/public/src/games/paigow/PaiGowPokerGame.js new file mode 100644 index 0000000..5c0f8de --- /dev/null +++ b/public/src/games/paigow/PaiGowPokerGame.js @@ -0,0 +1,1317 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { Modal } from '../../ui/Modal.js'; +import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js'; +import { api } from '../../services/api.js'; +import { playSound, playChipBet, SFX } from '../../ui/Sounds.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { + HAND_RANK, makeJoker, + evaluate5Card, evaluate2Card, compare5Card, compare2Card, + isFoul, handName5, rankLabel, + createInitialState, prepareRound, applyBet, dealHands, + applyHumanSplit, applyHouseWaySplit, applyDealerHouseWay, resolveRound, +} from './PaiGowPokerLogic.js'; +import { chooseBet, houseWay } from './PaiGowPokerAI.js'; + +// ─── Layout ─────────────────────────────────────────────────────────────────── +const CX = GAME_WIDTH / 2; +const CARD_W = 90; +const CARD_H = 126; +const CARD_R = 8; +const CARD_SPREAD_FAN = 22; // overlap in 7-card fan at opponent seats +const CARD_SPREAD_SPLIT = 10; // tighter spread for 5-card split display + +const TABLE_CY = 475; // table centre — slightly higher than Blackjack to make room for panel + +const DEALER_X = CX; +const DEALER_Y = 315; + +// Seat 0 = human (bottom-centre); seats 1-5 = AI +const SEAT_POS = [ + { x: CX, y: 762, portraitR: 72, portraitX: CX - 230, portraitY: 840, betX: 1110, betY: 750 }, + { x: 1380, y: 690, portraitR: 58, portraitX: 1600, portraitY: 780 }, + { x: 540, y: 690, portraitR: 58, portraitX: 320, portraitY: 780 }, + { x: 1560, y: 560, portraitR: 58, portraitX: 1805, portraitY: 583 }, + { x: 360, y: 560, portraitR: 58, portraitX: 115, portraitY: 583 }, + { x: 1440, y: 395, portraitR: 58, portraitX: 1675, portraitY: 320, labelDX: 10 }, +]; + +// AI turn order (clockwise from top-right) +const PLAY_ORDER = [5, 3, 1, 0, 2, 4]; + +const CHIP_COLORS = { 5: 0xe05c5c, 25: 0x5cb85c, 50: 0x4a90d9, 100: 0x2c2c2c }; +const CHIP_AMOUNTS = [5, 25, 50, 100]; +const RESULT_COLORS = { win: '#5cb85c', lose: '#e05c5c', push: '#8a94a6', foul: '#e05c5c' }; + +const D = { bg: -1, table: 0, cards: 10, chips: 20, ui: 30, panel: 25, modal: 50 }; + +// ─── Hand-setting panel geometry ────────────────────────────────────────────── +const PANEL_H = 390; // panel height +const PANEL_Y0 = Math.round(540 - PANEL_H / 2); // vertically centred on 1080px canvas +const HIGH_SLOT_CX = 480; // centre-X of 5-card zone +const LOW_SLOT_CX = 1440; // centre-X of 2-card zone +const SLOT_Y = PANEL_Y0 + 95; // centre-Y of drop slots +const SOURCE_Y = PANEL_Y0 + 275; // centre-Y of source cards (180px below slot centre) +const SLOT_GAP = 100; // slot-to-slot horizontal spacing + +// ─── Scene ──────────────────────────────────────────────────────────────────── +export default class PaiGowPokerGame extends Phaser.Scene { + constructor() { super('PaiGowPokerGame'); } + + init(data) { + this.gameDef = data.game; + this.opponents = data.opponents ?? []; + this.playfield = data.playfield ?? null; + this.cardBack = data.cardBack ?? null; + } + + async create() { + new MusicPlayer(this, this.cache.json.get('music').tracks); + this.gs = null; + this.animating = false; + this.pendingBet = 0; + this.portraits = []; + this.seatCardGraphics = {}; // seat → [containers] for the 7-card fan + this.dealerCardGraphics = []; + this.betGraphics = {}; + this.actionBtns = []; + this.bettingUIGroup = []; + this.chipBtnGraphics = []; + this.betDisplayText = null; + this.balanceText = null; + this.scoreTxts = {}; + this.nameTxts = {}; + this.chipTxts = {}; + + // Hand-setting panel state + this.setPanelGroup = []; // all objects in the panel (toggled visible) + this.setCardConts = []; // 7 containers, one per dealt card + this.setHighSlots = []; // 5 slot graphics + this.setLowSlots = []; // 2 slot graphics + this.humanHighSlots = [null, null, null, null, null]; + this.humanLowSlots = [null, null]; + this.selectedCardIdx = null; // which source-card index is selected + this.highRankTxt = null; + this.lowRankTxt = null; + this.foulWarnTxt = null; + this.setHandBtn = null; + this.houseWayBtn = null; + this.panelVisible = false; + + this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg).setDepth(D.bg); + this.buildPlayfield(); + this.buildTable(); + this.buildTableMarkings(); + this.buildDealerArea(); + this.buildSeats(); + this.buildBettingUI(); + this.buildHandSetPanel(); + + new Button(this, 80, GAME_HEIGHT - 44, 'Leave', () => this.scene.start('GameMenu'), { + variant: 'ghost', width: 140, fontSize: 20, + }); + + await this.loadPlayerChips(); + this.initGame(); + } + + // ── Playfield ───────────────────────────────────────────────────────────── + buildPlayfield() { + const pf = this.playfield; + if (pf?.key && this.textures.exists(pf.key)) { + this.add.image(CX, GAME_HEIGHT / 2, pf.key) + .setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.bg + 1); + } + } + + // ── Table ───────────────────────────────────────────────────────────────── + buildTable() { + const g = this.add.graphics().setDepth(D.table); + g.fillStyle(0x1a5c2a, 1); + g.fillEllipse(CX, TABLE_CY, 1500, 680); + g.lineStyle(8, 0x2e7d32, 1); + g.strokeEllipse(CX, TABLE_CY, 1500, 680); + g.lineStyle(3, 0x4caf50, 0.4); + g.strokeEllipse(CX, TABLE_CY, 1440, 620); + } + + buildTableMarkings() { + const g = this.add.graphics().setDepth(D.table + 1); + + for (let seat = 0; seat < SEAT_POS.length; seat++) { + const pos = SEAT_POS[seat]; + g.lineStyle(2, 0xffffff, 0.5); + g.strokeRoundedRect(pos.x - CARD_W / 2 - 4, pos.y - CARD_H / 2 - 4, CARD_W + 8, CARD_H + 8, CARD_R + 2); + const { x: bx, y: by } = this.betCirclePos(seat); + g.lineStyle(2, 0xffffff, 0.55); + g.strokeCircle(bx, by, 30); + } + + this.drawArcText('PAI GOW POKER', CX, 580, 760, { + fontSize: 32, color: COLORS.goldHex, bold: true, advanceFactor: 0.96, + }); + this.drawArcText('BANKER WINS ALL TIES · 5% COMMISSION ON WINS', CX, 636, 720, { + fontSize: 19, color: COLORS.mutedHex, advanceFactor: 0.84, + }); + } + + drawArcText(text, centerX, baseY, radius, opts = {}) { + const fontSize = opts.fontSize ?? 24; + const anglePer = ((opts.advanceFactor ?? 0.92) * fontSize) / radius; + const cy = baseY - radius; + const start = -anglePer * (text.length - 1) / 2; + const style = { + fontFamily: opts.fontFamily ?? '"Julius Sans One"', + fontSize: `${fontSize}px`, + color: opts.color ?? COLORS.textHex, + ...(opts.bold ? { fontStyle: 'bold' } : {}), + }; + const depth = opts.depth ?? (D.table + 1); + for (let i = 0; i < text.length; i++) { + if (text[i] === ' ') continue; + const a = start + i * anglePer; + this.add.text(centerX + radius * Math.sin(a), cy + radius * Math.cos(a), text[i], style) + .setOrigin(0.5).setRotation(-a).setDepth(depth); + } + } + + // ── Dealer area ─────────────────────────────────────────────────────────── + buildDealerArea() { + this.add.text(CX, 55, 'Pai Gow Poker', { + fontFamily: 'Righteous', fontSize: '48px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.ui); + + this.dealerHighLabel = this.add.text(CX - 170, DEALER_Y - CARD_H / 2 - 24, '', { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.ui); + + this.dealerLowLabel = this.add.text(CX + 170, DEALER_Y - CARD_H / 2 - 24, '', { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.ui); + } + + // ── Seats ───────────────────────────────────────────────────────────────── + buildSeats() { + for (let seat = 0; seat < SEAT_POS.length; seat++) { + const pos = SEAT_POS[seat]; + const opp = seat === 0 ? null : this.opponents[seat - 1] ?? null; + const active = seat === 0 ? true : !!opp; + if (!active) continue; + + const px = pos.portraitX ?? pos.x; + const py = pos.portraitY ?? (pos.y - CARD_H / 2 - (pos.portraitR ?? 58) - 60); + const labelX = px + (pos.labelDX ?? 0); + const nameY = py + (pos.portraitR ?? 58) + 18; + const chipY = nameY + 24; + + this.nameTxts[seat] = this.add.text(labelX, nameY, seat === 0 ? 'You' : opp.name, { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.ui); + + this.chipTxts[seat] = this.add.text(labelX, chipY, '', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.ui); + + { + const maxTW = Math.max(this.nameTxts[seat].width, 130); + const rectW = maxTW + 20; + const rectH = (chipY - nameY) + 36; + const bg = this.add.graphics().setDepth(D.ui - 1); + bg.fillStyle(0x000000, 0.60); + bg.fillRoundedRect(labelX - rectW / 2, (nameY + chipY) / 2 - rectH / 2, rectW, rectH, 6); + } + + this.scoreTxts[seat] = this.add.text(pos.x, pos.y - CARD_H / 2 - 11, '', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.ui); + + if (seat === 0) { + this.portraits[seat] = createPlayerPortrait(this, px, py, pos.portraitR ?? 72, D.ui, 'PaiGowPokerGame'); + } else { + this.portraits[seat] = createOpponentPortrait(this, opp, px, py, pos.portraitR ?? 58, D.ui); + } + } + } + + // ── Betting UI ──────────────────────────────────────────────────────────── + buildBettingUI() { + const y = GAME_HEIGHT - 80; + const cx = CX + 60; + + const panel = this.add.graphics().setDepth(D.ui); + panel.fillStyle(0x000000, 1); + panel.fillRoundedRect(cx - 174, y - 69, 726, 116, 18); + panel.lineStyle(3, COLORS.accent, 1); + panel.strokeRoundedRect(cx - 174, y - 69, 726, 116, 18); + this.bettingUIGroup.push(panel); + + CHIP_AMOUNTS.forEach((amt, i) => { + const bx = cx - 120 + i * 80; + const container = this.add.container(bx, y).setDepth(D.ui + 1); + const g = this.add.graphics(); + g.lineStyle(3, 0xffffff, 0.4); + g.strokeCircle(0, 0, 28); + g.fillStyle(CHIP_COLORS[amt], 1); + g.fillCircle(0, 0, 28); + container.add(g); + container.setInteractive(new Phaser.Geom.Circle(0, 0, 28), Phaser.Geom.Circle.Contains); + container.on('pointerdown', () => this.onChipClick(amt)); + container.on('pointerover', () => container.setAlpha(0.8)); + container.on('pointerout', () => container.setAlpha(1)); + + const t = this.add.text(bx, y, `$${amt}`, { + fontFamily: '"Julius Sans One"', fontSize: '13px', + color: '#ffffff', fontStyle: 'bold', + }).setOrigin(0.5).setDepth(D.ui + 2); + + this.bettingUIGroup.push(container, t); + this.chipBtnGraphics.push(container); + }); + + this.betDisplayText = this.add.text(cx + 170, y, 'Bet: $0', { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex, + }).setOrigin(0, 0.5).setDepth(D.ui + 1); + + this.balanceText = this.add.text(cx + 170, y - 32, '', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, + }).setOrigin(0, 0.5).setDepth(D.ui + 1); + + const clearBtn = new Button(this, cx + 360, y, 'Clear', () => this.onClearBet(), { + width: 100, height: 50, fontSize: 18, variant: 'ghost', + }); + this.dealBtn = new Button(this, cx + 470, y, 'Deal', () => this.onDealClick(), { + width: 110, height: 50, fontSize: 20, + }); + this.dealBtn.setEnabled(false); + clearBtn.setDepth(D.ui + 1); + this.dealBtn.setDepth(D.ui + 1); + + this.bettingUIGroup.push(this.betDisplayText, this.balanceText, clearBtn, this.dealBtn); + this.hideBettingUI(); + } + + showBettingUI() { + for (const o of this.bettingUIGroup) o.setVisible?.(true) ?? (o.visible = true); + } + hideBettingUI() { + for (const o of this.bettingUIGroup) o.setVisible?.(false) ?? (o.visible = false); + } + + // ── Hand-setting panel ──────────────────────────────────────────────────── + buildHandSetPanel() { + const panelBg = this.add.graphics().setDepth(D.panel).setVisible(false); + panelBg.fillStyle(0x000000, 0.90); + panelBg.fillRoundedRect(40, PANEL_Y0, GAME_WIDTH - 80, PANEL_H, 14); + panelBg.lineStyle(2, COLORS.accent, 0.7); + panelBg.strokeRoundedRect(40, PANEL_Y0, GAME_WIDTH - 80, PANEL_H, 14); + this.setPanelGroup.push(panelBg); + + // Zone labels + const labelStyle = { fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex }; + const hiLabel = this.add.text(HIGH_SLOT_CX, PANEL_Y0 + 20, 'HIGH HAND (5 cards)', labelStyle) + .setOrigin(0.5).setDepth(D.panel + 1).setVisible(false); + const loLabel = this.add.text(LOW_SLOT_CX, PANEL_Y0 + 20, 'LOW HAND (2 cards)', labelStyle) + .setOrigin(0.5).setDepth(D.panel + 1).setVisible(false); + this.setPanelGroup.push(hiLabel, loLabel); + + // Separator line between high and low zones + const sep = this.add.graphics().setDepth(D.panel + 1).setVisible(false); + sep.lineStyle(1, 0xffffff, 0.15); + sep.beginPath(); + sep.moveTo(1060, PANEL_Y0 + 10); + sep.lineTo(1060, PANEL_Y0 + PANEL_H - 10); + sep.strokePath(); + this.setPanelGroup.push(sep); + + // 5 drop-zone slots for high hand + this.setHighSlots = []; + for (let i = 0; i < 5; i++) { + const x = HIGH_SLOT_CX - 2 * SLOT_GAP + i * SLOT_GAP; + const g = this.add.graphics().setDepth(D.panel + 1).setVisible(false); + this.drawEmptySlot(g, x, SLOT_Y); + const zone = this.add.zone(x, SLOT_Y, CARD_W + 10, CARD_H + 10) + .setDepth(D.panel + 2).setDropZone().setVisible(false); + zone._pgSlot = { type: 'high', idx: i }; + zone.on('pointerdown', () => this.onHighSlotClick(i)); + this.setHighSlots.push({ g, zone, x, y: SLOT_Y }); + this.setPanelGroup.push(g, zone); + } + + // 2 drop-zone slots for low hand + this.setLowSlots = []; + for (let i = 0; i < 2; i++) { + const x = LOW_SLOT_CX - SLOT_GAP / 2 + i * SLOT_GAP; + const g = this.add.graphics().setDepth(D.panel + 1).setVisible(false); + this.drawEmptySlot(g, x, SLOT_Y); + const zone = this.add.zone(x, SLOT_Y, CARD_W + 10, CARD_H + 10) + .setDepth(D.panel + 2).setDropZone().setVisible(false); + zone._pgSlot = { type: 'low', idx: i }; + zone.on('pointerdown', () => this.onLowSlotClick(i)); + this.setLowSlots.push({ g, zone, x, y: SLOT_Y }); + this.setPanelGroup.push(g, zone); + } + + // Phaser drop-zone events — handle the actual card placement on drop + this.input.on('drop', (_ptr, gameObj, dropZone) => { + if (!this.panelVisible || !dropZone._pgSlot) return; + const cardIdx = this.setCardConts.indexOf(gameObj); + if (cardIdx === -1) return; + const { type, idx } = dropZone._pgSlot; + if (type === 'high') this._dropToHighSlot(cardIdx, idx); + else this._dropToLowSlot(cardIdx, idx); + }); + + this.input.on('dragenter', (_ptr, _obj, zone) => { + if (!this.panelVisible || !zone._pgSlot) return; + const { type, idx } = zone._pgSlot; + const slot = type === 'high' ? this.setHighSlots[idx] : this.setLowSlots[idx]; + this._drawHighlightSlot(slot.g, slot.x, SLOT_Y); + }); + + this.input.on('dragleave', (_ptr, _obj, zone) => { + if (!this.panelVisible || !zone._pgSlot) return; + const { type, idx } = zone._pgSlot; + const slot = type === 'high' ? this.setHighSlots[idx] : this.setLowSlots[idx]; + this.drawEmptySlot(slot.g, slot.x, SLOT_Y); + }); + + // Live rank labels below the slots + this.highRankTxt = this.add.text(HIGH_SLOT_CX, SLOT_Y + CARD_H / 2 + 14, '', { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.panel + 1).setVisible(false); + + this.lowRankTxt = this.add.text(LOW_SLOT_CX, SLOT_Y + CARD_H / 2 + 14, '', { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.panel + 1).setVisible(false); + + // Foul warning + this.foulWarnTxt = this.add.text(CX, SLOT_Y + CARD_H / 2 + 14, '', { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: '#e06c75', + }).setOrigin(0.5).setDepth(D.panel + 1).setVisible(false); + + this.setPanelGroup.push(this.highRankTxt, this.lowRankTxt, this.foulWarnTxt); + + // Buttons + this.houseWayBtn = new Button(this, HIGH_SLOT_CX - 100, PANEL_Y0 + PANEL_H - 28, 'House Way', () => this.applyHouseWayUI(), { + width: 150, height: 44, fontSize: 16, + }); + this.clearSetBtn = new Button(this, HIGH_SLOT_CX + 75, PANEL_Y0 + PANEL_H - 28, 'Clear', () => this.clearHandSet(), { + width: 110, height: 44, fontSize: 16, variant: 'ghost', + }); + this.setHandBtn = new Button(this, CX + 20, PANEL_Y0 + PANEL_H - 28, 'Set Hand', () => this.onSetHand(), { + width: 160, height: 44, fontSize: 18, + }); + this.setHandBtn.setEnabled(false); + this.houseWayBtn.setDepth(D.panel + 2); + this.clearSetBtn.setDepth(D.panel + 2); + this.setHandBtn.setDepth(D.panel + 2); + this.setPanelGroup.push(this.houseWayBtn, this.clearSetBtn, this.setHandBtn); + + this.hideHandSetPanel(); + } + + drawEmptySlot(g, cx, cy) { + g.clear(); + g.lineStyle(2, 0xffffff, 0.25); + g.strokeRoundedRect(cx - CARD_W / 2, cy - CARD_H / 2, CARD_W, CARD_H, CARD_R); + } + + drawSelectedSlot(g, cx, cy) { + g.clear(); + g.lineStyle(3, 0xf5d020, 0.9); + g.strokeRoundedRect(cx - CARD_W / 2, cy - CARD_H / 2, CARD_W, CARD_H, CARD_R); + } + + showHandSetPanel() { + this.panelVisible = true; + for (const o of this.setPanelGroup) o.setVisible(true); + // Hide all portraits and their seat labels so they don't overlap the panel + for (const p of this.portraits) p?.hide?.(); + for (const t of Object.values(this.nameTxts)) t.setVisible(false); + for (const t of Object.values(this.chipTxts)) t.setVisible(false); + // Build and place the 7 card containers in the source area + this.buildSetCardContainers(); + this.updateHandSetUI(); + } + + hideHandSetPanel() { + this.panelVisible = false; + for (const o of this.setPanelGroup) o.setVisible(false); + for (const c of this.setCardConts) c.destroy(); + this.setCardConts = []; + // Restore portraits and seat labels + for (const p of this.portraits) p?.show?.(); + for (const t of Object.values(this.nameTxts)) t.setVisible(true); + for (const t of Object.values(this.chipTxts)) t.setVisible(true); + } + + buildSetCardContainers() { + for (const c of this.setCardConts) c.destroy(); + this.setCardConts = []; + const hand = this.gs.players[0].hand; + hand.forEach((card, i) => { + const sx = this.sourceCardX(i); + const cont = this.add.container(sx, SOURCE_Y).setDepth(D.panel + 3); + this.drawCard(cont, card, true); + cont.setSize(CARD_W, CARD_H); + cont.setInteractive({ useHandCursor: true }); + this.input.setDraggable(cont); + + let _dragged = false; + + cont.on('dragstart', () => { + _dragged = true; + this.selectedCardIdx = null; + if (this._selRings) { for (const r of this._selRings) r.destroy(); this._selRings = []; } + cont.setDepth(D.panel + 10); + }); + + cont.on('drag', (_ptr, dragX, dragY) => { + cont.setPosition(dragX, dragY); + }); + + cont.on('dragend', () => { + cont.setDepth(D.panel + 3); + this._clearDragHighlights(); + // 'drop' event (fired before dragend) already placed the card if it hit a zone. + // Re-render to snap card to its slot (or back to source on a miss). + this.updateHandSetUI(); + }); + + // Only treat as a click when the pointer never moved far enough to trigger a drag + cont.on('pointerdown', () => { _dragged = false; }); + cont.on('pointerup', () => { + if (!_dragged) this.onSourceCardClick(i); + _dragged = false; // reset after the check so dragend's state is preserved + }); + + this.setCardConts.push(cont); + }); + } + + // Highlights whichever slot the card is hovering over; clears all others + _drawHighlightSlot(g, cx, cy) { + g.clear(); + g.fillStyle(0xf5d020, 0.18); + g.fillRoundedRect(cx - CARD_W / 2, cy - CARD_H / 2, CARD_W, CARD_H, CARD_R); + g.lineStyle(3, 0xf5d020, 1); + g.strokeRoundedRect(cx - CARD_W / 2, cy - CARD_H / 2, CARD_W, CARD_H, CARD_R); + } + + _clearDragHighlights() { + for (let i = 0; i < 5; i++) { + const slot = this.setHighSlots[i]; + this.drawEmptySlot(slot.g, slot.x, SLOT_Y); + } + for (let i = 0; i < 2; i++) { + const slot = this.setLowSlots[i]; + this.drawEmptySlot(slot.g, slot.x, SLOT_Y); + } + } + + _dropToHighSlot(cardIdx, slotIdx) { + // Remove cardIdx from its current slot (if any) + const fromHi = this.humanHighSlots.indexOf(cardIdx); + const fromLo = this.humanLowSlots.indexOf(cardIdx); + if (fromHi !== -1) this.humanHighSlots[fromHi] = null; + if (fromLo !== -1) this.humanLowSlots[fromLo] = null; + // Displaced occupant goes back to source (its slot becomes null) + // — no extra action needed; it will re-anchor in updateHandSetUI + this.humanHighSlots[slotIdx] = cardIdx; + this.selectedCardIdx = null; + this.updateHandSetUI(); + } + + _dropToLowSlot(cardIdx, slotIdx) { + const fromHi = this.humanHighSlots.indexOf(cardIdx); + const fromLo = this.humanLowSlots.indexOf(cardIdx); + if (fromHi !== -1) this.humanHighSlots[fromHi] = null; + if (fromLo !== -1) this.humanLowSlots[fromLo] = null; + this.humanLowSlots[slotIdx] = cardIdx; + this.selectedCardIdx = null; + this.updateHandSetUI(); + } + + sourceCardX(idx) { + // 7 cards spread centered at CX + return CX - 3 * SLOT_GAP + idx * SLOT_GAP; + } + + updateHandSetUI() { + const hand = this.gs.players[0].hand; + const placedSet = new Set([ + ...this.humanHighSlots.filter(v => v !== null), + ...this.humanLowSlots.filter(v => v !== null), + ]); + + // Position each card container + this.setCardConts.forEach((cont, i) => { + const highIdx = this.humanHighSlots.indexOf(i); + const lowIdx = this.humanLowSlots.indexOf(i); + if (highIdx !== -1) { + // In high hand slot + const slot = this.setHighSlots[highIdx]; + cont.setPosition(slot.x, SLOT_Y); + cont.setAlpha(1); + // Highlight slot outline if this card is "selected" (for swapping) + this.drawEmptySlot(slot.g, slot.x, SLOT_Y); + if (this.selectedCardIdx === i) this.drawSelectedSlot(slot.g, slot.x, SLOT_Y); + } else if (lowIdx !== -1) { + // In low hand slot + const slot = this.setLowSlots[lowIdx]; + cont.setPosition(slot.x, SLOT_Y); + cont.setAlpha(1); + if (this.selectedCardIdx === i) this.drawSelectedSlot(slot.g, slot.x, SLOT_Y); + else this.drawEmptySlot(slot.g, slot.x, SLOT_Y); + } else { + // In source zone + cont.setPosition(this.sourceCardX(i), SOURCE_Y); + cont.setAlpha(this.selectedCardIdx === i ? 0.5 : 1); + // Pulse ring for selected source card + if (this.selectedCardIdx === i) { + // Add a selection ring by re-drawing card with highlight + // (simpler: just alpha fade the others is not great; instead use a ring overlay) + } + } + }); + + // Draw selection ring on selected source card + this.drawSelectionRings(); + + // Update empty slot outlines + for (let i = 0; i < 5; i++) { + const slot = this.setHighSlots[i]; + if (this.humanHighSlots[i] === null) this.drawEmptySlot(slot.g, slot.x, SLOT_Y); + } + for (let i = 0; i < 2; i++) { + const slot = this.setLowSlots[i]; + if (this.humanLowSlots[i] === null) this.drawEmptySlot(slot.g, slot.x, SLOT_Y); + } + + // Update rank labels + const highCards = this.humanHighSlots.filter(v => v !== null).map(i => hand[i]); + const lowCards = this.humanLowSlots.filter(v => v !== null).map(i => hand[i]); + + if (highCards.length === 5) { + const ev = evaluate5Card(highCards); + this.highRankTxt.setText(handName5(ev)); + } else { + this.highRankTxt.setText(`${highCards.length}/5 placed`); + } + + if (lowCards.length === 2) { + const ev = evaluate2Card(lowCards); + this.lowRankTxt.setText(ev.name); + } else { + this.lowRankTxt.setText(`${lowCards.length}/2 placed`); + } + + // Validate and update foul / set hand button + const complete = highCards.length === 5 && lowCards.length === 2; + let fouled = false; + if (complete) { + fouled = isFoul(highCards, lowCards); + this.foulWarnTxt.setText(fouled ? 'Invalid: High hand must outrank Low hand' : ''); + } else { + this.foulWarnTxt.setText(''); + } + this.setHandBtn.setEnabled(complete && !fouled); + } + + drawSelectionRings() { + // Remove existing rings + if (this._selRings) { + for (const r of this._selRings) r.destroy(); + } + this._selRings = []; + if (this.selectedCardIdx === null) return; + const hand = this.gs.players[0].hand; + const hi = this.humanHighSlots.indexOf(this.selectedCardIdx); + const lo = this.humanLowSlots.indexOf(this.selectedCardIdx); + let cx, cy; + if (hi !== -1) { + cx = this.setHighSlots[hi].x; cy = SLOT_Y; + } else if (lo !== -1) { + cx = this.setLowSlots[lo].x; cy = SLOT_Y; + } else { + cx = this.sourceCardX(this.selectedCardIdx); cy = SOURCE_Y; + } + const ring = this.add.graphics().setDepth(D.panel + 4); + ring.lineStyle(4, 0xf5d020, 1); + ring.strokeRoundedRect(cx - CARD_W / 2 - 3, cy - CARD_H / 2 - 3, CARD_W + 6, CARD_H + 6, CARD_R + 2); + this._selRings.push(ring); + } + + onSourceCardClick(idx) { + if (this.animating) return; + if (this.selectedCardIdx === idx) { + // Deselect + this.selectedCardIdx = null; + } else { + // If a slot card was selected, swap them + const prevSelected = this.selectedCardIdx; + if (prevSelected !== null) { + const hiPrev = this.humanHighSlots.indexOf(prevSelected); + const loPrev = this.humanLowSlots.indexOf(prevSelected); + if (hiPrev !== -1) { + // Prev was in high slot → move source card there, return prev to source + this.humanHighSlots[hiPrev] = idx; + } else if (loPrev !== -1) { + this.humanLowSlots[loPrev] = idx; + } + this.selectedCardIdx = null; + } else { + this.selectedCardIdx = idx; + } + } + this.updateHandSetUI(); + } + + onHighSlotClick(slotIdx) { + if (this.animating) return; + const currentInSlot = this.humanHighSlots[slotIdx]; + + if (this.selectedCardIdx !== null) { + // Place selected card into this slot + const prevInSlot = currentInSlot; + // Remove selected card from wherever it currently is + const hiSel = this.humanHighSlots.indexOf(this.selectedCardIdx); + const loSel = this.humanLowSlots.indexOf(this.selectedCardIdx); + if (hiSel !== -1) this.humanHighSlots[hiSel] = null; + if (loSel !== -1) this.humanLowSlots[loSel] = null; + // Put selected card in this slot + this.humanHighSlots[slotIdx] = this.selectedCardIdx; + // If there was a card here before, it stays selected (or goes to source) + this.selectedCardIdx = prevInSlot; // null if slot was empty + } else if (currentInSlot !== null) { + // No card selected — select the card in this slot + this.selectedCardIdx = currentInSlot; + } + this.updateHandSetUI(); + } + + onLowSlotClick(slotIdx) { + if (this.animating) return; + const currentInSlot = this.humanLowSlots[slotIdx]; + + if (this.selectedCardIdx !== null) { + const prevInSlot = currentInSlot; + const hiSel = this.humanHighSlots.indexOf(this.selectedCardIdx); + const loSel = this.humanLowSlots.indexOf(this.selectedCardIdx); + if (hiSel !== -1) this.humanHighSlots[hiSel] = null; + if (loSel !== -1) this.humanLowSlots[loSel] = null; + this.humanLowSlots[slotIdx] = this.selectedCardIdx; + this.selectedCardIdx = prevInSlot; + } else if (currentInSlot !== null) { + this.selectedCardIdx = currentInSlot; + } + this.updateHandSetUI(); + } + + applyHouseWayUI() { + const hand = this.gs.players[0].hand; + const { highHand, lowHand } = houseWay(hand); + // Map card objects to indices in the hand array + this.humanHighSlots = highHand.map(c => hand.indexOf(c)); + this.humanLowSlots = lowHand.map(c => hand.indexOf(c)); + this.selectedCardIdx = null; + this.updateHandSetUI(); + } + + clearHandSet() { + this.humanHighSlots = [null, null, null, null, null]; + this.humanLowSlots = [null, null]; + this.selectedCardIdx = null; + this.updateHandSetUI(); + } + + onSetHand() { + if (this.animating) return; + const hand = this.gs.players[0].hand; + const highHand = this.humanHighSlots.map(i => hand[i]); + const lowHand = this.humanLowSlots.map(i => hand[i]); + this.gs = applyHumanSplit(this.gs, highHand, lowHand); + this.hideHandSetPanel(); + this.startComparingPhase(); + } + + // ── Chip balance ────────────────────────────────────────────────────────── + async loadPlayerChips() { + try { + const { profile } = await api.get('/profile'); + this._playerChips = profile.chips ?? 2000; + } catch { + this._playerChips = 2000; + } + } + + // ── Game init ───────────────────────────────────────────────────────────── + initGame() { + this.gs = createInitialState(this.opponents, this._playerChips); + this.startNewRound(); + } + + startNewRound() { + playSound(this, SFX.CARD_SHUFFLE); + this.pendingBet = 0; + this.gs = prepareRound(this.gs); + this.clearCardGraphics(); + this.renderAll(); + this.showBettingUI(); + this.updateBetDisplay(); + } + + // ── Render ──────────────────────────────────────────────────────────────── + renderAll() { + this.renderDealer(); + for (let seat = 0; seat < SEAT_POS.length; seat++) { + const p = this.gs.players[seat]; + if (!p.active) continue; + this.renderSeat(seat); + this.renderSeatInfo(seat); + } + this.renderBetAreas(); + } + + renderDealer() { + for (const c of this.dealerCardGraphics) c.destroy(); + this.dealerCardGraphics = []; + this.dealerHighLabel.setText(''); + this.dealerLowLabel.setText(''); + + const dealer = this.gs.dealer; + if (!dealer.hand.length) return; + + if (!dealer.revealed) { + // Fan of face-down cards + dealer.hand.forEach((card, i) => { + const x = this.fanCardX(DEALER_X, i, dealer.hand.length); + const cont = this.add.container(x, DEALER_Y).setDepth(D.cards); + this.drawCard(cont, null, false); + this.dealerCardGraphics.push(cont); + }); + return; + } + + // After reveal: show split layout (5 HIGH + 2 LOW) + this.renderSplitCards(DEALER_X - 170, DEALER_Y, dealer.highHand, this.dealerCardGraphics); + this.renderSplitCards(DEALER_X + 170, DEALER_Y, dealer.lowHand, this.dealerCardGraphics); + + if (dealer.highEval) this.dealerHighLabel.setText(handName5(dealer.highEval)); + if (dealer.lowEval) this.dealerLowLabel.setText(dealer.lowEval.name); + } + + renderSeat(seat) { + if (this.seatCardGraphics[seat]) { + for (const c of this.seatCardGraphics[seat]) c.destroy(); + } + this.seatCardGraphics[seat] = []; + + const p = this.gs.players[seat]; + const pos = SEAT_POS[seat]; + + if (!p.hand.length) return; + + const phase = this.gs.phase; + const showSplit = phase === 'comparing' || phase === 'resolved'; + + if (seat === 0 && this.panelVisible) { + // Human cards are shown in the panel while it's open — don't render at seat + return; + } + + if (showSplit && p.highHand.length > 0) { + // Split view: high hand left, low hand right + if (seat === 0) { + this.renderSplitCards(pos.x - 130, pos.y, p.highHand, this.seatCardGraphics[seat]); + this.renderSplitCards(pos.x + 130, pos.y, p.lowHand, this.seatCardGraphics[seat]); + } else { + this.renderSplitCards(pos.x - 100, pos.y, p.highHand, this.seatCardGraphics[seat]); + this.renderSplitCards(pos.x + 100, pos.y, p.lowHand, this.seatCardGraphics[seat]); + } + if (this.scoreTxts[seat]) this.scoreTxts[seat].setText(''); + } else { + // Fan view: 7 face-down cards + p.hand.forEach((card, i) => { + const x = this.fanCardX(pos.x, i, p.hand.length); + const cont = this.add.container(x, pos.y).setDepth(D.cards); + // Human's cards are face-up, AI cards face-down during dealing/setting + const faceUp = seat === 0; + this.drawCard(cont, faceUp ? card : null, faceUp); + this.seatCardGraphics[seat].push(cont); + }); + } + } + + renderSplitCards(cx, cy, cards, targetArr) { + cards.forEach((card, i) => { + const total = cards.length; + const spread = CARD_SPREAD_SPLIT; + const totalW = (total - 1) * spread; + const x = cx - totalW / 2 + i * spread; + const cont = this.add.container(x, cy).setDepth(D.cards); + this.drawCard(cont, card, true); + targetArr.push(cont); + }); + } + + renderSeatInfo(seat) { + const p = this.gs.players[seat]; + if (this.chipTxts[seat]) this.chipTxts[seat].setText(`$${p.chips.toLocaleString()}`); + } + + renderBetAreas() { + for (const g of Object.values(this.betGraphics)) g.destroy(); + this.betGraphics = {}; + for (let seat = 0; seat < SEAT_POS.length; seat++) { + const p = this.gs.players[seat]; + if (!p.active || p.bet === 0) continue; + const { x: betX, y: betY } = this.betCirclePos(seat); + const cont = this.add.container(betX, betY).setDepth(D.chips); + const circle = this.add.graphics(); + circle.fillStyle(0x2a2a2a, 1); + circle.fillCircle(0, 0, 28); + circle.lineStyle(2, 0xf0e8d0, 0.8); + circle.strokeCircle(0, 0, 28); + const txt = this.add.text(0, 0, `$${p.bet}`, { + fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#f0e8d0', + }).setOrigin(0.5); + cont.add([circle, txt]); + this.betGraphics[seat] = cont; + } + } + + clearCardGraphics() { + for (const cards of Object.values(this.seatCardGraphics)) { + for (const c of cards) c.destroy(); + } + this.seatCardGraphics = {}; + for (const c of this.dealerCardGraphics) c.destroy(); + this.dealerCardGraphics = []; + for (const g of Object.values(this.betGraphics)) g.destroy(); + this.betGraphics = {}; + } + + betCirclePos(seat) { + const pos = SEAT_POS[seat]; + if (pos.betX !== undefined) return { x: pos.betX, y: pos.betY }; + const t = 0.22; + return { + x: Math.round(pos.x + t * (CX - pos.x)), + y: Math.round(pos.y + t * (TABLE_CY - pos.y)), + }; + } + + fanCardX(centerX, idx, total) { + const totalW = (total - 1) * CARD_SPREAD_FAN + CARD_W; + return centerX - totalW / 2 + CARD_W / 2 + idx * CARD_SPREAD_FAN; + } + + // ── Card drawing ────────────────────────────────────────────────────────── + addCardBackToContainer(container) { + if (this.cardBack?.spriteIndex !== undefined && this.textures.exists('cardbacks')) { + container.add( + this.add.image(0, 0, 'cardbacks', this.cardBack.spriteIndex) + .setDisplaySize(CARD_W, CARD_H).setOrigin(0.5) + ); + } else { + const g = this.add.graphics(); + this.drawCardBack(g, -CARD_W / 2, -CARD_H / 2); + container.add(g); + } + } + + drawCardBack(g, x, y) { + const color = this.cardBack?.fallbackColor + ? parseInt(this.cardBack.fallbackColor.replace('#', ''), 16) : 0x1a3a6b; + g.fillStyle(color, 1); + g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R); + g.lineStyle(2, 0xffffff, 0.25); + g.strokeRoundedRect(x + 6, y + 6, CARD_W - 12, CARD_H - 12, CARD_R - 2); + } + + drawCard(container, card, faceUp) { + container.removeAll(true); + const x = -CARD_W / 2, y = -CARD_H / 2; + if (faceUp && card) { + if (card.isJoker) { + // Special Joker rendering + const g = this.add.graphics(); + g.fillStyle(0xffffff, 1); + g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R); + g.lineStyle(2, 0xd4a017, 1.5); + g.strokeRoundedRect(x, y, CARD_W, CARD_H, CARD_R); + container.add(g); + container.add(this.add.text(x + 7, y + 5, 'JK', { + fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#7b2fbe', fontStyle: 'bold', + })); + container.add(this.add.text(0, 4, '★', { + fontFamily: '"Julius Sans One"', fontSize: '50px', color: '#d4a017', + }).setOrigin(0.5)); + container.add(this.add.text(x + CARD_W - 7, y + CARD_H - 8, 'JK', { + fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#7b2fbe', fontStyle: 'bold', + }).setOrigin(1, 1)); + } else { + const g = this.add.graphics(); + g.fillStyle(0xffffff, 1); + g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R); + g.lineStyle(1, 0xcccccc, 1); + g.strokeRoundedRect(x, y, CARD_W, CARD_H, CARD_R); + container.add(g); + const color = card.isRed ? '#c0392b' : '#1a1a2e'; + const s = (sz, bold = false) => ({ + fontFamily: '"Julius Sans One"', fontSize: `${sz}px`, color, + ...(bold ? { fontStyle: 'bold' } : {}), + }); + container.add(this.add.text(x + 7, y + 5, card.label, s(17, true))); + container.add(this.add.text(x + 7, y + 23, card.suitSymbol, s(13))); + container.add(this.add.text(0, 4, card.suitSymbol, s(40)).setOrigin(0.5)); + container.add(this.add.text(x + CARD_W - 7, y + CARD_H - 8, card.label, s(17, true)).setOrigin(1, 1)); + container.add(this.add.text(x + CARD_W - 7, y + CARD_H - 22, card.suitSymbol, s(13)).setOrigin(1, 1)); + } + } else { + this.addCardBackToContainer(container); + } + } + + // ── Betting phase ───────────────────────────────────────────────────────── + onChipClick(amount) { + if (this.animating) return; + const human = this.gs.players[0]; + if (this.pendingBet + amount > 100) return; + if (this.pendingBet + amount > human.chips) return; + this.pendingBet += amount; + this.updateBetDisplay(); + playChipBet(this); + } + + onClearBet() { + this.pendingBet = 0; + this.updateBetDisplay(); + } + + updateBetDisplay() { + if (this.betDisplayText) this.betDisplayText.setText(`Bet: $${this.pendingBet}`); + if (this.dealBtn) this.dealBtn.setEnabled(this.pendingBet >= 5); + const human = this.gs?.players[0]; + if (this.balanceText && human) this.balanceText.setText(`Balance: $${human.chips.toLocaleString()}`); + } + + onDealClick() { + if (this.animating || this.pendingBet < 5) return; + this.animating = true; + this.hideBettingUI(); + + this.gs = applyBet(this.gs, 0, this.pendingBet); + + for (let seat = 1; seat < SEAT_POS.length; seat++) { + const p = this.gs.players[seat]; + if (!p.active) continue; + this.gs = applyBet(this.gs, seat, Math.max(5, chooseBet(p))); + } + + this.gs = dealHands(this.gs); + + this.animateBets(() => { + this.animateDeal(() => { + this.animating = false; + this.renderAll(); + this.startSettingPhase(); + }); + }); + } + + // ── Animations ──────────────────────────────────────────────────────────── + animateBets(onComplete) { + this.renderBetAreas(); + playChipBet(this); + let done = 0; + const active = this.gs.players.filter(p => p.active); + if (!active.length) { onComplete(); return; } + for (const p of active) { + const pos = SEAT_POS[p.seat]; + const { x: betX, y: betY } = this.betCirclePos(p.seat); + const chip = this.add.graphics().setDepth(D.chips + 5); + chip.fillStyle(0x5cb85c, 1); chip.fillCircle(0, 0, 14); + chip.x = pos.x; chip.y = pos.y; + this.tweens.add({ + targets: chip, x: betX, y: betY, duration: 280, ease: 'Power2', + onComplete: () => { chip.destroy(); done++; if (done >= active.length) onComplete(); }, + }); + } + } + + animateDeal(onComplete) { + // All 7 cards dealt face-down to each player and dealer in sequence + const seats = PLAY_ORDER.filter(s => this.gs.players[s]?.active); + const DECK_X = CX + 200, DECK_Y = 50; + const totalDeals = (seats.length + 1) * 7; // seats + dealer + + // Pre-render all cards (face-down initially via renderAll) + this.renderAll(); + + // Hide all seat card graphics + for (const cards of Object.values(this.seatCardGraphics)) { + for (const c of cards) c.setAlpha(0); + } + for (const c of this.dealerCardGraphics) c.setAlpha(0); + + // Build deal sequence: deal 7 cards to each player in order, then dealer + const dealSeq = []; + for (let round = 0; round < 7; round++) { + for (const seat of seats) dealSeq.push({ type: 'player', seat, cardIdx: round }); + dealSeq.push({ type: 'dealer', cardIdx: round }); + } + + let idx = 0; + const stagger = 70; + const dealNext = () => { + if (idx >= dealSeq.length) { onComplete(); return; } + const entry = dealSeq[idx++]; + const flying = this.add.container(DECK_X, DECK_Y).setDepth(D.cards + 10); + this.addCardBackToContainer(flying); + + let tx, ty; + if (entry.type === 'dealer') { + tx = this.fanCardX(DEALER_X, entry.cardIdx, 7); + ty = DEALER_Y; + } else { + const pos = SEAT_POS[entry.seat]; + tx = this.fanCardX(pos.x, entry.cardIdx, 7); + ty = pos.y; + } + + this.tweens.add({ + targets: flying, x: tx, y: ty, duration: 160, ease: 'Power2', + onComplete: () => { + flying.destroy(); + playSound(this, SFX.CARD_DEAL); + if (entry.type === 'dealer') { + if (this.dealerCardGraphics[entry.cardIdx]) this.dealerCardGraphics[entry.cardIdx].setAlpha(1); + } else { + const cards = this.seatCardGraphics[entry.seat] ?? []; + if (cards[entry.cardIdx]) cards[entry.cardIdx].setAlpha(1); + } + this.time.delayedCall(stagger, dealNext); + }, + }); + }; + dealNext(); + } + + animateDealerReveal(onComplete) { + playSound(this, SFX.CARD_SHOW); + // Flip each dealer card to face-up + let done = 0; + const total = this.dealerCardGraphics.length; + if (!total) { this.renderDealer(); onComplete(); return; } + + this.dealerCardGraphics.forEach((cont, i) => { + this.time.delayedCall(i * 120, () => { + this.tweens.add({ + targets: cont, scaleX: 0, duration: 110, ease: 'Linear', + onComplete: () => { + // Reveal the face + const card = this.gs.dealer.hand[i]; + this.drawCard(cont, card, true); + this.tweens.add({ + targets: cont, scaleX: 1, duration: 110, ease: 'Linear', + onComplete: () => { + done++; + if (done === total) { + // Now show split layout + this.time.delayedCall(400, () => { + this.gs = { ...this.gs, dealer: { ...this.gs.dealer, revealed: true } }; + this.renderDealer(); + onComplete(); + }); + } + }, + }); + }, + }); + }); + }); + } + + // ── Setting phase ───────────────────────────────────────────────────────── + startSettingPhase() { + // Auto-set all AI players and dealer via House Way + for (let seat = 1; seat < SEAT_POS.length; seat++) { + if (this.gs.players[seat].active) { + this.gs = applyHouseWaySplit(this.gs, seat); + } + } + this.gs = applyDealerHouseWay(this.gs); + + // Reset human split state + this.humanHighSlots = [null, null, null, null, null]; + this.humanLowSlots = [null, null]; + this.selectedCardIdx = null; + + // Show the hand-setting panel + this.showHandSetPanel(); + } + + // ── Comparing phase ─────────────────────────────────────────────────────── + startComparingPhase() { + this.animating = true; + + // Briefly reveal all AI splits (1.5s), then reveal dealer + this.renderAll(); // shows AI split layouts + + this.time.delayedCall(1500, () => { + // Reveal dealer cards + this.animateDealerReveal(() => { + // Resolve round + this.gs = resolveRound(this.gs); + this.renderAll(); + // Show comparison results seat by seat + this.showComparisons(() => { + this.animating = false; + this.showNextRoundPrompt(); + }); + }); + }); + } + + showComparisons(onComplete) { + const activePlayers = this.gs.players.filter(p => p.active); + let done = 0; + const total = activePlayers.length; + + activePlayers.forEach((p, i) => { + this.time.delayedCall(i * 350, () => { + this.showSeatResult(p); + done++; + if (done === total) { + // Animate chip flows + this.animateAllChips(() => { + this.updateServerChips(); + this.time.delayedCall(800, onComplete); + }); + } + }); + }); + } + + showSeatResult(player) { + const pos = SEAT_POS[player.seat]; + const resultColor = RESULT_COLORS[player.result] ?? '#8a94a6'; + + // Net label for human (show commission) + let label; + if (player.result === 'win') { + label = `Win! +$${player.chipsWon}`; + } else if (player.result === 'push') { + label = 'Push'; + } else if (player.result === 'foul') { + label = 'Foul — Lose'; + } else { + label = `Lose -$${-player.chipsWon}`; + } + + const badge = this.add.text(pos.x, pos.y - CARD_H / 2 - 36, label, { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: resultColor, fontStyle: 'bold', + }).setOrigin(0.5).setDepth(D.ui + 5).setAlpha(0).setScale(0.7); + + this.tweens.add({ + targets: badge, alpha: 1, scaleX: 1, scaleY: 1, duration: 200, ease: 'Back.easeOut', + onComplete: () => { + this.time.delayedCall(1800, () => { + this.tweens.add({ targets: badge, alpha: 0, y: badge.y - 30, duration: 380, ease: 'Power2', + onComplete: () => badge.destroy() }); + }); + }, + }); + + playSound(this, player.result === 'win' ? SFX.WIN : SFX.LOSE); + if (this.scoreTxts[player.seat]) this.scoreTxts[player.seat].setText(''); + } + + animateAllChips(onComplete) { + const players = this.gs.players.filter(p => p.active); + let done = 0; + const check = () => { done++; if (done >= players.length) onComplete(); }; + for (const p of players) { + if (p.result === 'win') this.animateChipsFromDealer(p.seat, check); + else if (p.result === 'push') this.animateChipReturn(p.seat, check); + else this.animateChipsToDealer(p.seat, check); + } + } + + animateChipsFromDealer(seat, onComplete) { + const { x: bx, y: by } = this.betCirclePos(seat); + const numChips = 3; + let done = 0; + for (let i = 0; i < numChips; i++) { + this.time.delayedCall(i * 60, () => { + const chip = this.add.graphics().setDepth(D.chips + 5); + chip.fillStyle(0xf5d020, 1); chip.fillCircle(0, 0, 12); + chip.x = DEALER_X; chip.y = DEALER_Y; + this.tweens.add({ + targets: chip, x: bx, y: by, duration: 380, ease: 'Power2', + onComplete: () => { chip.destroy(); done++; if (done === numChips) onComplete(); }, + }); + }); + } + } + + animateChipsToDealer(seat, onComplete) { + if (!this.betGraphics[seat]) { onComplete(); return; } + const { x: bx, y: by } = this.betCirclePos(seat); + const numChips = 2; + let done = 0; + for (let i = 0; i < numChips; i++) { + this.time.delayedCall(i * 60, () => { + const chip = this.add.graphics().setDepth(D.chips + 5); + chip.fillStyle(0xe05c5c, 1); chip.fillCircle(0, 0, 12); + chip.x = bx; chip.y = by; + this.tweens.add({ + targets: chip, x: DEALER_X, y: DEALER_Y, duration: 380, ease: 'Power2', + onComplete: () => { chip.destroy(); done++; if (done === numChips) onComplete(); }, + }); + }); + } + } + + animateChipReturn(seat, onComplete) { + // Push — chips return to player from bet circle + if (!this.betGraphics[seat]) { onComplete(); return; } + const bg = this.betGraphics[seat]; + this.tweens.add({ + targets: bg, alpha: 0, duration: 320, ease: 'Power2', + onComplete: () => { onComplete(); }, + }); + } + + async updateServerChips() { + const human = this.gs.players[0]; + const delta = human.chipsWon ?? 0; + if (delta === 0) return; + try { + await api.post('/profile/chips/adjust', { delta }); + } catch { /* silent — local state is still correct */ } + } + + // ── Next round ──────────────────────────────────────────────────────────── + showNextRoundPrompt() { + const human = this.gs.players[0]; + if (human.chips < 5) { + new Modal(this, { + title: 'Out of Chips', + body: 'You ran out of chips. Better luck next time!', + buttons: [{ label: 'Leave', onClick: () => this.scene.start('GameMenu') }], + }); + return; + } + + const btn = new Button(this, CX, GAME_HEIGHT / 2 + 200, 'Next Round', () => { + btn.destroy(); + this.startNewRound(); + }, { width: 220, height: 56, fontSize: 24 }); + btn.setDepth(D.ui + 5); + } +} diff --git a/public/src/games/paigow/PaiGowPokerLogic.js b/public/src/games/paigow/PaiGowPokerLogic.js new file mode 100644 index 0000000..bf390cd --- /dev/null +++ b/public/src/games/paigow/PaiGowPokerLogic.js @@ -0,0 +1,504 @@ +// Pai Gow Poker pure game logic — no Phaser dependencies + +import { SUITS, RANKS } from '../cards/Deck.js'; + +// ─── Card values ─────────────────────────────────────────────────────────────── + +const RANK_VALUE = Object.fromEntries(RANKS.map((r, i) => [r, i + 2])); // 2=2…A=14 + +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}`, + isJoker: false, + }; +} + +export function makeJoker() { + return { rank: 'JK', suit: null, value: 15, label: '★', isRed: false, suitSymbol: '★', key: 'JK', isJoker: true }; +} + +export function buildDeck() { + const cards = []; + for (const suit of SUITS) { + for (const rank of RANKS) { + cards.push(makeCard(rank, suit)); + } + } + cards.push(makeJoker()); + 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 rank constants ─────────────────────────────────────────────────────── + +export const HAND_RANK = { + HIGH_CARD: -1, + ONE_PAIR: 0, + TWO_PAIR: 1, + THREE_OF_A_KIND: 2, + STRAIGHT: 3, + FLUSH: 4, + FULL_HOUSE: 5, + FOUR_OF_A_KIND: 6, + STRAIGHT_FLUSH: 7, + ROYAL_FLUSH: 8, + FIVE_ACES: 9, +}; + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +function cardVal(c) { return c.isJoker ? 14 : c.value; } + +export function rankLabel(val) { + const labels = { 14:'Ace', 13:'King', 12:'Queen', 11:'Jack', 10:'10', + 9:'9', 8:'8', 7:'7', 6:'6', 5:'5', 4:'4', 3:'3', 2:'2' }; + return labels[val] ?? String(val); +} + +function getCombinations(arr, k) { + if (k === arr.length) return [[...arr]]; + if (k === 0) return [[]]; + const [first, ...rest] = arr; + const withFirst = getCombinations(rest, k - 1).map(c => [first, ...c]); + const withoutFirst = getCombinations(rest, k); + return [...withFirst, ...withoutFirst]; +} + +// Groups cards by effective rank value; sorted by group size desc, then value desc +function getGroups(cards) { + const map = {}; + for (const c of cards) { + const v = cardVal(c); + if (!map[v]) map[v] = []; + map[v].push(c); + } + return Object.entries(map) + .map(([v, cs]) => ({ value: +v, cards: cs })) + .sort((a, b) => b.cards.length - a.cards.length || b.value - a.value); +} + +function checkStraight(sortedVals) { + const uniq = [...new Set(sortedVals)]; + if (uniq.length < 5) return false; + if (uniq[0] - uniq[4] === 4) return uniq[0]; // returns high card value + // Wheel: A-2-3-4-5 + if (uniq[0] === 14 && uniq[1] === 5 && uniq[2] === 4 && uniq[3] === 3 && uniq[4] === 2) return 5; + return false; +} + +// ─── 5-card evaluation (no Joker) ───────────────────────────────────────────── + +function evaluate5Pure(cards) { + const vals = cards.map(c => c.value).sort((a, b) => b - a); + const suits = cards.map(c => c.suit); + const isFlush = suits.every(s => s === suits[0]); + const straightHigh = checkStraight(vals); + + if (isFlush && straightHigh) { + if (straightHigh === 14) return { rank: HAND_RANK.ROYAL_FLUSH, name: 'Royal Flush', tiebreakers: [14] }; + return { rank: HAND_RANK.STRAIGHT_FLUSH, name: 'Straight Flush', tiebreakers: [straightHigh] }; + } + + const groups = getGroups(cards); + const counts = groups.map(g => g.cards.length); + + if (counts[0] === 4) { + return { rank: HAND_RANK.FOUR_OF_A_KIND, name: 'Four of a Kind', tiebreakers: [groups[0].value, groups[1].value] }; + } + if (counts[0] === 3 && counts[1] === 2) { + return { rank: HAND_RANK.FULL_HOUSE, name: 'Full House', tiebreakers: [groups[0].value, groups[1].value] }; + } + if (isFlush) { + return { rank: HAND_RANK.FLUSH, name: 'Flush', tiebreakers: vals }; + } + if (straightHigh) { + return { rank: HAND_RANK.STRAIGHT, name: 'Straight', tiebreakers: [straightHigh] }; + } + if (counts[0] === 3) { + return { rank: HAND_RANK.THREE_OF_A_KIND, name: 'Three of a Kind', tiebreakers: [groups[0].value, groups[1].value, groups[2].value] }; + } + if (counts[0] === 2 && counts[1] === 2) { + return { rank: HAND_RANK.TWO_PAIR, name: 'Two Pair', tiebreakers: [groups[0].value, groups[1].value, groups[2].value] }; + } + if (counts[0] === 2) { + return { rank: HAND_RANK.ONE_PAIR, name: 'One Pair', tiebreakers: [groups[0].value, groups[1].value, groups[2].value, groups[3].value] }; + } + return { rank: HAND_RANK.HIGH_CARD, name: 'High Card', tiebreakers: vals }; +} + +// ─── 5-card evaluation (Joker aware) ────────────────────────────────────────── + +export function evaluate5Card(cards) { + const joker = cards.find(c => c.isJoker); + if (!joker) return evaluate5Pure(cards); + + const others = cards.filter(c => !c.isJoker); + + // Five Aces: 4 Aces + Joker + if (others.filter(c => c.rank === 'A').length === 4) { + return { rank: HAND_RANK.FIVE_ACES, name: 'Five Aces', tiebreakers: [15, 14, 14, 14, 14] }; + } + + // Brute-force all possible Joker substitutions + const existingKeys = new Set(others.map(c => c.key)); + let best = null; + + for (const suit of SUITS) { + for (const rank of RANKS) { + const sub = makeCard(rank, suit); + if (existingKeys.has(sub.key)) continue; + const hand = [...others, sub]; + const ev = evaluate5Pure(hand); + if (!best || compare5Card(ev, best) > 0) best = ev; + } + } + + // Fallback: Joker as Ace of spades if no non-duplicate substitution works (rare) + return best ?? evaluate5Pure([...others, makeCard('A', 's')]); +} + +export function handName5(ev) { return ev.name; } + +// ─── 2-card evaluation (Joker always = Ace) ─────────────────────────────────── + +export function evaluate2Card(cards) { + const vals = cards.map(c => cardVal(c)).sort((a, b) => b - a); + if (vals[0] === vals[1]) { + return { rank: 1, name: `Pair of ${rankLabel(vals[0])}s`, tiebreakers: [vals[0]] }; + } + return { rank: 0, name: `${rankLabel(vals[0])}-${rankLabel(vals[1])} High`, tiebreakers: vals }; +} + +// ─── Comparison ──────────────────────────────────────────────────────────────── + +export function compare5Card(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 av = a.tiebreakers[i] ?? 0; + const bv = b.tiebreakers[i] ?? 0; + if (av !== bv) return av - bv; + } + return 0; +} + +export function compare2Card(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 av = a.tiebreakers[i] ?? 0; + const bv = b.tiebreakers[i] ?? 0; + if (av !== bv) return av - bv; + } + return 0; +} + +// ─── Foul detection ──────────────────────────────────────────────────────────── + +export function isFoul(highHand5, lowHand2) { + const high = evaluate5Card(highHand5); + const low = evaluate2Card(lowHand2); + + // Any 5-card rank above ONE_PAIR always beats any 2-card hand + if (high.rank > HAND_RANK.ONE_PAIR) return false; + + if (high.rank === HAND_RANK.ONE_PAIR) { + // 5-card pair vs 2-card high card: always valid + if (low.rank === 0) return false; + // Both pairs: foul only if 5-card pair rank is strictly less (equal is OK — kickers decide) + return high.tiebreakers[0] < low.tiebreakers[0]; + } + + // 5-card HIGH_CARD: foul if low hand has a pair, or if 5-card top card < 2-card top card + if (low.rank === 1) return true; // pair beats high card + const highTop2 = high.tiebreakers.slice(0, 2); + for (let i = 0; i < 2; i++) { + if (highTop2[i] > (low.tiebreakers[i] ?? 0)) return false; + if (highTop2[i] < (low.tiebreakers[i] ?? 0)) return true; + } + return false; // equal top 2: not foul (5-card wins on kickers) +} + +// ─── House Way ───────────────────────────────────────────────────────────────── + +function sortByValDesc(cards) { + return [...cards].sort((a, b) => cardVal(b) - cardVal(a)); +} + +function bestFiveCombo(cards) { + const combos = getCombinations(cards, 5); + let bestEval = null, bestCards = null; + for (const c of combos) { + const ev = evaluate5Card(c); + if (!bestEval || compare5Card(ev, bestEval) > 0) { bestEval = ev; bestCards = c; } + } + return { eval: bestEval, cards: bestCards }; +} + +export function houseWay(sevenCards) { + // Five Aces: keep in high + const joker = sevenCards.find(c => c.isJoker); + const aces = sevenCards.filter(c => c.rank === 'A'); + if (joker && aces.length >= 4) { + const high5 = [...aces.slice(0, 4), joker]; + const low2 = sevenCards.filter(c => !high5.includes(c)); + return { highHand: high5, lowHand: low2 }; + } + + const groups = getGroups(sevenCards); + const numPairs = groups.filter(g => g.cards.length === 2).length; + const numTrips = groups.filter(g => g.cards.length === 3).length; + const numQuads = groups.filter(g => g.cards.length === 4).length; + + const { eval: bestEval, cards: bestCombo } = bestFiveCombo(sevenCards); + const lowCards = sevenCards.filter(c => !bestCombo.includes(c)); + + // Straight flush / royal flush: keep in high + if (bestEval.rank >= HAND_RANK.STRAIGHT_FLUSH) { + return { highHand: bestCombo, lowHand: lowCards }; + } + + // Four of a kind + if (numQuads > 0) { + const quadGroup = groups.find(g => g.cards.length >= 4); + const quads = quadGroup.cards; + const qVal = quadGroup.value; + const rest = sortByValDesc(sevenCards.filter(c => !quads.includes(c))); + + // 2s–6s: keep quads together + if (qVal <= 6) return { highHand: [...quads, rest[0]], lowHand: [rest[1], rest[2]] }; + + // 7s–10s: split only if Ace-equivalent available for low + if (qVal <= 10) { + const hasAce = rest.some(c => cardVal(c) >= 14); + if (!hasAce) return { highHand: [...quads, rest[0]], lowHand: [rest[1], rest[2]] }; + } + + // JJ–AA (and 7-10 with Ace): split into pair+pair + return { highHand: [quads[0], quads[1], ...rest], lowHand: [quads[2], quads[3]] }; + } + + // Full house + if (bestEval.rank === HAND_RANK.FULL_HOUSE) { + const tripsGroup = groups.find(g => g.cards.length >= 3); + const pairGroup = groups.find(g => g !== tripsGroup && g.cards.length >= 2); + const trips3 = tripsGroup.cards.slice(0, 3); + const pair2 = pairGroup.cards.slice(0, 2); + const extras = sevenCards.filter(c => !trips3.includes(c) && !pair2.includes(c)); + return { highHand: [...trips3, ...extras], lowHand: pair2 }; + } + + // Flush (no sf) — keep flush in high + if (bestEval.rank === HAND_RANK.FLUSH) { + return { highHand: bestCombo, lowHand: lowCards }; + } + + // Straight (no flush) — keep straight in high + if (bestEval.rank === HAND_RANK.STRAIGHT) { + return { highHand: bestCombo, lowHand: lowCards }; + } + + // Three of a kind + if (numTrips > 0 && numPairs === 0) { + const tripsGroup = groups.find(g => g.cards.length >= 3); + const trips3 = tripsGroup.cards.slice(0, 3); + + // Three Aces: pair in high, one Ace in low + if (tripsGroup.value === 14) { + const rest = sortByValDesc(sevenCards.filter(c => !trips3.includes(c))); + return { highHand: [trips3[0], trips3[1], ...rest.slice(0, 3)], lowHand: [trips3[2], rest[3]] }; + } + + // Other trips: trips in high, 2 best singletons in low + const rest = sortByValDesc(sevenCards.filter(c => !trips3.includes(c))); + return { highHand: [...trips3, ...rest.slice(2)], lowHand: [rest[0], rest[1]] }; + } + + // Three pair + const pairGroups = groups.filter(g => g.cards.length >= 2).slice(0, 3); + if (pairGroups.length >= 3) { + // Highest pair → low hand; other two pairs + singleton in high + const highestPair = pairGroups[0]; // already sorted by value desc + const lowPairCards = highestPair.cards.slice(0, 2); + const remainPairs = sevenCards.filter(c => !lowPairCards.includes(c)); + const { eval: bestRemEval, cards: bestRemCombo } = bestFiveCombo(remainPairs); + return { highHand: bestRemCombo, lowHand: lowPairCards }; + } + + // Two pair + if (numPairs >= 2) { + const pg1 = pairGroups[0]; // higher pair + const pg2 = pairGroups[1]; // lower pair + const p1Cards = pg1.cards.slice(0, 2); + const p2Cards = pg2.cards.slice(0, 2); + const singletons = sortByValDesc(sevenCards.filter(c => !p1Cards.includes(c) && !p2Cards.includes(c))); + + if (pg1.value >= 11) { + // JJ+ high pair: split — HIGH pair → HIGH 5-card hand, LOW pair → LOW 2-card hand + return { highHand: [...p1Cards, ...singletons], lowHand: p2Cards }; + } + // Both pairs ≤ 10: keep together in high, 2 best singletons in low + return { highHand: [...p1Cards, ...p2Cards, singletons[2]], lowHand: [singletons[0], singletons[1]] }; + } + + // One pair + if (numPairs === 1) { + const pg = pairGroups[0]; + const pCards = pg.cards.slice(0, 2); + const singletons = sortByValDesc(sevenCards.filter(c => !pCards.includes(c))); + // 2 best singletons in low + return { highHand: [...pCards, ...singletons.slice(2)], lowHand: [singletons[0], singletons[1]] }; + } + + // No pair (HIGH_CARD): 2nd and 3rd best in low + const sorted = sortByValDesc(sevenCards); + return { highHand: [sorted[0], sorted[3], sorted[4], sorted[5], sorted[6]], lowHand: [sorted[1], sorted[2]] }; +} + +// ─── State creation ──────────────────────────────────────────────────────────── + +export function createInitialState(opponents, chips) { + const players = [ + { + seat: 0, name: 'You', isHuman: true, active: true, opponent: null, + chips, bet: 0, + hand: [], highHand: [], lowHand: [], + highEval: null, lowEval: null, + isFoul: false, result: null, chipsWon: 0, + }, + ]; + + for (let i = 0; i < 5; i++) { + const opp = opponents[i] ?? null; + players.push({ + seat: i + 1, name: opp?.name ?? '', isHuman: false, active: !!opp, opponent: opp, + chips: 1000, bet: 0, + hand: [], highHand: [], lowHand: [], + highEval: null, lowEval: null, + isFoul: false, result: null, chipsWon: 0, + }); + } + + return { + phase: 'betting', + deck: [], + players, + dealer: { hand: [], highHand: [], lowHand: [], highEval: null, lowEval: null, revealed: false }, + roundNumber: 0, + }; +} + +// ─── Round management ────────────────────────────────────────────────────────── + +export function prepareRound(gs) { + const deck = buildDeck(); + const players = gs.players.map(p => ({ + ...p, + bet: 0, hand: [], highHand: [], lowHand: [], + highEval: null, lowEval: null, + isFoul: false, result: null, chipsWon: 0, + })); + return { + ...gs, + phase: 'betting', + deck, + players, + dealer: { hand: [], highHand: [], lowHand: [], highEval: null, lowEval: null, revealed: false }, + roundNumber: gs.roundNumber + 1, + }; +} + +export function applyBet(gs, seat, amount) { + const players = gs.players.map(p => p.seat === seat ? { ...p, bet: amount } : p); + return { ...gs, players }; +} + +export function dealHands(gs) { + const deck = [...gs.deck]; + const players = gs.players.map(p => { + if (!p.active) return p; + const hand = deck.splice(0, 7); + return { ...p, hand }; + }); + const dealerHand = deck.splice(0, 7); + return { + ...gs, + phase: 'setting', + deck, + players, + dealer: { ...gs.dealer, hand: dealerHand }, + }; +} + +export function applyHumanSplit(gs, highHand, lowHand) { + const foul = isFoul(highHand, lowHand); + const highEval = foul ? null : evaluate5Card(highHand); + const lowEval = foul ? null : evaluate2Card(lowHand); + const players = gs.players.map(p => { + if (p.seat !== 0) return p; + return { ...p, highHand, lowHand, highEval, lowEval, isFoul: foul }; + }); + return { ...gs, players }; +} + +export function applyHouseWaySplit(gs, seat) { + const player = gs.players.find(p => p.seat === seat); + if (!player || !player.active) return gs; + const { highHand, lowHand } = houseWay(player.hand); + const highEval = evaluate5Card(highHand); + const lowEval = evaluate2Card(lowHand); + const players = gs.players.map(p => p.seat === seat ? { ...p, highHand, lowHand, highEval, lowEval, isFoul: false } : p); + return { ...gs, players }; +} + +export function applyDealerHouseWay(gs) { + const { highHand, lowHand } = houseWay(gs.dealer.hand); + const highEval = evaluate5Card(highHand); + const lowEval = evaluate2Card(lowHand); + return { ...gs, dealer: { ...gs.dealer, highHand, lowHand, highEval, lowEval } }; +} + +// ─── Resolution ──────────────────────────────────────────────────────────────── + +// Returns 'win' | 'push' | 'lose'. Ties go to dealer (copy rule). +function resolvePlayer(player, dealer) { + if (player.isFoul) return 'foul'; + + const highCmp = compare5Card(player.highEval, dealer.highEval); + const lowCmp = compare2Card(player.lowEval, dealer.lowEval); + + // copy rule: ties go to dealer (cmp <= 0 means dealer wins or ties) + const winHigh = highCmp > 0; + const winLow = lowCmp > 0; + + if (winHigh && winLow) return 'win'; + if (!winHigh && !winLow) return 'lose'; + return 'push'; +} + +export function resolveRound(gs) { + const dealer = gs.dealer; + const COMMISSION = 0.05; + + const players = gs.players.map(p => { + if (!p.active || p.bet === 0) return p; + + const result = resolvePlayer(p, dealer); + let chipsWon = 0; + if (result === 'win') { + chipsWon = p.bet - Math.floor(p.bet * COMMISSION); + } else if (result === 'lose' || result === 'foul') { + chipsWon = -p.bet; + } + + return { ...p, result, chipsWon, chips: p.chips + chipsWon }; + }); + + return { ...gs, phase: 'resolved', players }; +} diff --git a/public/src/games/stratego/StrategoGame.js b/public/src/games/stratego/StrategoGame.js index cc82977..2ffa8f0 100644 --- a/public/src/games/stratego/StrategoGame.js +++ b/public/src/games/stratego/StrategoGame.js @@ -747,6 +747,19 @@ export default class StrategoGame extends Phaser.Scene { // Map result to containers. const attackerIsHuman = mover.owner === this.humanSeat; let winnerCont, loserCont, winnerTxt, loserTxt; + + // Portrait emotion: AI upset when it loses a high-value piece; happy when it captures one. + const REACT_RANK = 6; + const aiLosesPiece = res === 'both' || + (res === 'attacker' && attackerIsHuman) || + (res === 'defender' && !attackerIsHuman); + const aiCapturesPiece = (res === 'attacker' && !attackerIsHuman) || + (res === 'defender' && attackerIsHuman); + const triggerEmotion = () => { + const ctrl = this.portraits[this.aiSeat]; + if (aiLosesPiece && aiPiece.rank >= REACT_RANK) ctrl?.playEmotion?.('upset'); + else if (aiCapturesPiece && humanPiece.rank >= REACT_RANK) ctrl?.playEmotion?.('happy'); + }; if (res === 'attacker') { [winnerCont, loserCont] = attackerIsHuman ? [humanCont, aiCont] : [aiCont, humanCont]; [winnerTxt, loserTxt] = attackerIsHuman ? [humanRankTxt, aiRankTxt] : [aiRankTxt, humanRankTxt]; @@ -805,13 +818,16 @@ export default class StrategoGame extends Phaser.Scene { if (res === 'both') { // Tie: both shoot simultaneously, both explode/fade. + let emotionFired = false; fireShot(humanStageX, STAGE_CY, aiStageX, STAGE_CY, () => { this._spawnExplosions(aiStageX, STAGE_CY); fadeOut(aiCont, aiRankTxt); + if (!emotionFired) { emotionFired = true; triggerEmotion(); } }); fireShot(aiStageX, STAGE_CY, humanStageX, STAGE_CY, () => { this._spawnExplosions(humanStageX, STAGE_CY); fadeOut(humanCont, humanRankTxt); + if (!emotionFired) { emotionFired = true; triggerEmotion(); } }); // After both explosions settle, fade outcome label and undim. this.time.delayedCall(2700, () => { @@ -828,6 +844,7 @@ export default class StrategoGame extends Phaser.Scene { fireShot(winnerX, STAGE_CY, loserX, STAGE_CY, () => { this._spawnExplosions(loserX, STAGE_CY); fadeOut(loserCont, loserTxt); + triggerEmotion(); finishWinner(); }); } diff --git a/public/src/main.js b/public/src/main.js index a2c408a..329ba66 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -83,6 +83,7 @@ import RiskGame from './games/risk/RiskGame.js'; import GeniusSquareGame from './games/geniussquare/GeniusSquareGame.js'; import KataminoGame from './games/katamino/KataminoGame.js'; import BookworkGame from './games/bookwork/BookworkGame.js'; +import PaiGowPokerGame from './games/paigow/PaiGowPokerGame.js'; const config = { type: Phaser.AUTO, @@ -179,6 +180,7 @@ const config = { GeniusSquareGame, KataminoGame, BookworkGame, + PaiGowPokerGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index dfb6f22..5dd7128 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/server/games/registry.js b/server/games/registry.js index c628bcb..024a138 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -99,3 +99,4 @@ registerGame({ slug: 'risk', name: 'Risk', category: 'tabletop', minPlayers: 2, registerGame({ slug: 'geniussquare', name: 'Genius Square', category: 'logic', minPlayers: 1, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 70 }); registerGame({ slug: 'katamino', name: 'Katamino', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 71 }); registerGame({ slug: 'bookwork', name: 'Bookwork', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 }); +registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 6, minOpponents: 0, maxOpponents: 5, defaultOpponents: 5, iconFrame: 73 }); diff --git a/verifyPaiGowPoker.js b/verifyPaiGowPoker.js new file mode 100644 index 0000000..21d614a --- /dev/null +++ b/verifyPaiGowPoker.js @@ -0,0 +1,486 @@ +#!/usr/bin/env node +// verifyPaiGowPoker.js — engine tests for Pai Gow Poker + +import { + HAND_RANK, makeJoker, buildDeck, + evaluate5Card, evaluate2Card, + compare5Card, compare2Card, + isFoul, houseWay, + createInitialState, prepareRound, applyBet, dealHands, + applyHouseWaySplit, applyDealerHouseWay, applyHumanSplit, resolveRound, + rankLabel, +} from './public/src/games/paigow/PaiGowPokerLogic.js'; +import { chooseBet } from './public/src/games/paigow/PaiGowPokerAI.js'; +import { SUITS, RANKS } from './public/src/games/cards/Deck.js'; + +let pass = 0, fail = 0; +function ok(label, cond) { + if (cond) { console.log(` ✓ ${label}`); pass++; } + else { console.error(` ✗ ${label}`); fail++; } +} + +function card(rank, suit) { + const RANK_VALUE = Object.fromEntries(RANKS.map((r, i) => [r, i + 2])); + 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}`, isJoker: false }; +} + +// ── Deck ────────────────────────────────────────────────────────────────────── +console.log('\nDeck'); +const deck = buildDeck(); +ok('deck has 53 cards', deck.length === 53); +ok('deck contains one Joker', deck.filter(c => c.isJoker).length === 1); +ok('Joker has correct key', deck.find(c => c.isJoker)?.key === 'JK'); +const nonJoker = deck.filter(c => !c.isJoker); +ok('52 standard cards present', nonJoker.length === 52); +const keys = new Set(nonJoker.map(c => c.key)); +ok('all standard keys unique', keys.size === 52); +// Deck should be shuffled (not in insertion order) +const firstRank = deck[0].rank; +const allSame = deck.slice(0, 13).every(c => c.rank === firstRank); +ok('deck is shuffled (not all same rank in first 13)', !allSame); + +// ── 5-card eval — standard hands ───────────────────────────────────────────── +console.log('\n5-card evaluation — standard hands'); + +const royalFlush = [card('A','s'), card('K','s'), card('Q','s'), card('J','s'), card('T','s')]; +const ev = evaluate5Card(royalFlush); +ok('Royal Flush detected', ev.rank === HAND_RANK.ROYAL_FLUSH); +ok('Royal Flush name', ev.name === 'Royal Flush'); + +const sf = [card('9','h'), card('8','h'), card('7','h'), card('6','h'), card('5','h')]; +const sfEv = evaluate5Card(sf); +ok('Straight Flush detected', sfEv.rank === HAND_RANK.STRAIGHT_FLUSH); +ok('Straight Flush tiebreaker = 9', sfEv.tiebreakers[0] === 9); + +const quads = [card('A','s'), card('A','h'), card('A','d'), card('A','c'), card('K','s')]; +const qEv = evaluate5Card(quads); +ok('Four of a Kind detected', qEv.rank === HAND_RANK.FOUR_OF_A_KIND); +ok('Quads tiebreakers [14,13]', qEv.tiebreakers[0] === 14 && qEv.tiebreakers[1] === 13); + +const fh = [card('K','s'), card('K','h'), card('K','d'), card('Q','s'), card('Q','h')]; +ok('Full House detected', evaluate5Card(fh).rank === HAND_RANK.FULL_HOUSE); + +const flush = [card('A','h'), card('J','h'), card('9','h'), card('6','h'), card('2','h')]; +ok('Flush detected', evaluate5Card(flush).rank === HAND_RANK.FLUSH); + +const straight = [card('9','s'), card('8','h'), card('7','d'), card('6','c'), card('5','s')]; +ok('Straight detected', evaluate5Card(straight).rank === HAND_RANK.STRAIGHT); +ok('Straight tiebreaker = 9', evaluate5Card(straight).tiebreakers[0] === 9); + +// Wheel (A-2-3-4-5) +const wheel = [card('A','s'), card('2','h'), card('3','d'), card('4','c'), card('5','s')]; +const wheelEv = evaluate5Card(wheel); +ok('Wheel straight detected', wheelEv.rank === HAND_RANK.STRAIGHT); +ok('Wheel tiebreaker = 5', wheelEv.tiebreakers[0] === 5); + +const trips = [card('Q','s'), card('Q','h'), card('Q','d'), card('7','c'), card('3','s')]; +ok('Three of a Kind detected', evaluate5Card(trips).rank === HAND_RANK.THREE_OF_A_KIND); + +const twoPair = [card('A','s'), card('A','h'), card('K','d'), card('K','c'), card('J','s')]; +ok('Two Pair detected', evaluate5Card(twoPair).rank === HAND_RANK.TWO_PAIR); + +const onePair = [card('T','s'), card('T','h'), card('A','d'), card('K','c'), card('Q','s')]; +ok('One Pair detected', evaluate5Card(onePair).rank === HAND_RANK.ONE_PAIR); + +const hiCard = [card('A','s'), card('K','h'), card('Q','d'), card('J','c'), card('9','s')]; +ok('High Card detected', evaluate5Card(hiCard).rank === HAND_RANK.HIGH_CARD); + +// Flush SF distinction (non-SF flush) +const sfDistinct = [card('K','s'), card('Q','s'), card('J','s'), card('T','s'), card('8','s')]; +ok('Non-consecutive flush is Flush not SF', evaluate5Card(sfDistinct).rank === HAND_RANK.FLUSH); + +// ── 5-card eval — Joker ─────────────────────────────────────────────────────── +console.log('\n5-card evaluation — Joker'); +const joker = makeJoker(); + +// Five Aces +const fiveAces = [card('A','s'), card('A','h'), card('A','d'), card('A','c'), joker]; +const faEv = evaluate5Card(fiveAces); +ok('Five Aces detected', faEv.rank === HAND_RANK.FIVE_ACES); +ok('Five Aces name', faEv.name === 'Five Aces'); + +// Joker completes a flush +const flushWithJoker = [card('K','h'), card('J','h'), card('9','h'), card('6','h'), joker]; +const fjEv = evaluate5Card(flushWithJoker); +ok('Joker completes flush', fjEv.rank === HAND_RANK.FLUSH); + +// Joker completes a straight +const stWithJoker = [card('9','s'), card('8','h'), card('7','d'), card('6','c'), joker]; +const sjEv = evaluate5Card(stWithJoker); +ok('Joker completes straight', sjEv.rank === HAND_RANK.STRAIGHT); + +// Joker completes a straight flush +const sfWithJoker = [card('9','h'), card('8','h'), card('7','h'), card('6','h'), joker]; +const sfJEv = evaluate5Card(sfWithJoker); +ok('Joker completes straight flush', sfJEv.rank === HAND_RANK.STRAIGHT_FLUSH); + +// Joker as Ace (no better use in a scattered hand) +const jokerAlone = [card('K','s'), card('Q','h'), card('J','d'), card('9','c'), joker]; +const jaEv = evaluate5Card(jokerAlone); +// K-Q-J-9 + Joker can complete K-Q-J-T-9 straight with Joker as T → STRAIGHT +ok('Joker completes a straight when possible', jaEv.rank >= HAND_RANK.STRAIGHT); + +// Joker with one Ace in a hand that can make a straight (AKQJ+Joker=T → STRAIGHT) +const jokerAceStr = [card('A','s'), card('K','h'), card('Q','d'), card('J','c'), joker]; +const jaStr = evaluate5Card(jokerAceStr); +ok('Joker+AKQJ makes a straight (Joker=T)', jaStr.rank === HAND_RANK.STRAIGHT); + +// Joker with one Ace in a hand that cannot make straight or flush → pair of Aces +const jokerAcePair = [card('A','s'), card('9','h'), card('7','d'), card('5','c'), joker]; +const jaPair = evaluate5Card(jokerAcePair); +ok('Joker + Ace (no straight/flush) = pair of Aces', jaPair.rank === HAND_RANK.ONE_PAIR && jaPair.tiebreakers[0] === 14); + +// ── 2-card evaluation ───────────────────────────────────────────────────────── +console.log('\n2-card evaluation'); +const pairKings = [card('K','s'), card('K','h')]; +const hiCardAQ = [card('A','s'), card('Q','h')]; +ok('pair beats high card', compare2Card(evaluate2Card(pairKings), evaluate2Card(hiCardAQ)) > 0); + +const pairAces = [card('A','s'), card('A','h')]; +ok('pair of Aces beats pair of Kings', compare2Card(evaluate2Card(pairAces), evaluate2Card(pairKings)) > 0); + +const AK = [card('A','s'), card('K','h')]; +const AQ = [card('A','h'), card('Q','d')]; +ok('A-K beats A-Q', compare2Card(evaluate2Card(AK), evaluate2Card(AQ)) > 0); + +const jokerK = [joker, card('K','s')]; +const jokerKEv = evaluate2Card(jokerK); +ok('Joker in 2-card hand = Ace (Ace-King)', jokerKEv.rank === 0 && jokerKEv.tiebreakers[0] === 14); + +// Pair of Joker+Ace (Joker treated as Ace) +const jokerAceLow2 = [joker, card('A','s')]; +const jokerAceEv = evaluate2Card(jokerAceLow2); +ok('Joker + Ace in 2-card = pair of Aces', jokerAceEv.rank === 1 && jokerAceEv.tiebreakers[0] === 14); + +// ── compare5Card / compare2Card ──────────────────────────────────────────────── +console.log('\nComparison'); +const sfEval = evaluate5Card(sf); +const flushEval = evaluate5Card(flush); +ok('compare5Card: SF beats Flush', compare5Card(sfEval, flushEval) > 0); + +const pairAcesEv = evaluate5Card([card('A','s'), card('A','h'), card('K','d'), card('Q','c'), card('J','s')]); +const pairKingsEv = evaluate5Card([card('K','s'), card('K','h'), card('A','d'), card('Q','c'), card('J','s')]); +ok('compare5Card: pair Aces > pair Kings', compare5Card(pairAcesEv, pairKingsEv) > 0); + +ok('compare5Card: tie returns 0', compare5Card(pairAcesEv, pairAcesEv) === 0); + +const p2 = evaluate2Card([card('A','s'), card('K','h')]); +const p3 = evaluate2Card([card('A','h'), card('K','d')]); +ok('compare2Card: equal high cards returns 0', compare2Card(p2, p3) === 0); + +// ── Foul detection ───────────────────────────────────────────────────────────── +console.log('\nFoul detection'); + +// Valid: 5-card pair of 2s, 2-card A-K +const valid5 = [card('2','s'), card('2','h'), card('A','d'), card('K','c'), card('Q','s')]; +const valid2 = [card('A','s'), card('K','h')]; +ok('pair of 2s vs A-K high card: NOT foul', !isFoul(valid5, valid2)); + +// Foul: 5-card A-K-Q-J-9 high card, 2-card pair of Kings +const foul5 = [card('A','s'), card('K','h'), card('Q','d'), card('J','c'), card('9','s')]; +const foul2 = [card('K','s'), card('K','d')]; +ok('high card 5-card vs pair of Kings 2-card: IS foul', isFoul(foul5, foul2)); + +// Foul: 5-card pair of Kings, 2-card pair of Aces +const foul5b = [card('K','s'), card('K','h'), card('Q','d'), card('J','c'), card('9','s')]; +const foul2b = [card('A','s'), card('A','h')]; +ok('pair of Kings high vs pair of Aces low: IS foul', isFoul(foul5b, foul2b)); + +// Valid: 5-card pair of Aces, 2-card pair of Kings +const valid5b = [card('A','s'), card('A','h'), card('K','d'), card('Q','c'), card('J','s')]; +const valid2b = [card('K','s'), card('K','h')]; +ok('pair of Aces vs pair of Kings: NOT foul', !isFoul(valid5b, valid2b)); + +// Equal pairs: NOT a foul (5-card hand wins on kickers; this case only arises with quads) +const equalP5 = [card('K','d'), card('K','c'), card('Q','s'), card('J','h'), card('9','s')]; +const equalP2 = [card('K','s'), card('K','h')]; +ok('pair of Kings vs pair of Kings: NOT foul (equal rank, 5-card wins on kickers)', !isFoul(equalP5, equalP2)); + +// ── House Way ────────────────────────────────────────────────────────────────── +console.log('\nHouse Way — No Pair'); +const noPair7 = [card('A','s'), card('Q','h'), card('J','d'), card('9','c'), card('8','s'), card('6','h'), card('3','d')]; +const hwNP = houseWay(noPair7); +ok('no pair: high hand has 5 cards', hwNP.highHand.length === 5); +ok('no pair: low hand has 2 cards', hwNP.lowHand.length === 2); +ok('no pair: Ace in high hand', hwNP.highHand.some(c => c.rank === 'A')); +// 2nd/3rd best (Q, J) should be in low hand +ok('no pair: Q in low hand', hwNP.lowHand.some(c => c.rank === 'Q')); +ok('no pair: split not foul', !isFoul(hwNP.highHand, hwNP.lowHand)); + +console.log('\nHouse Way — One Pair'); +// Hand with no straight/flush possible so pair rule is exercised cleanly +const onePair7 = [card('7','s'), card('7','h'), card('A','d'), card('K','c'), card('9','s'), card('5','h'), card('3','d')]; +const hwOP = houseWay(onePair7); +ok('one pair: high hand has pair', evaluate5Card(hwOP.highHand).rank >= HAND_RANK.ONE_PAIR); +ok('one pair: A in low hand (best 2 singletons)', hwOP.lowHand.some(c => c.rank === 'A')); +ok('one pair: K in low hand', hwOP.lowHand.some(c => c.rank === 'K')); +ok('one pair: split not foul', !isFoul(hwOP.highHand, hwOP.lowHand)); + +console.log('\nHouse Way — Two Pair'); +// Both pairs ≤ 6s: keep together +const tp1 = [card('6','s'), card('6','h'), card('4','d'), card('4','c'), card('A','s'), card('K','h'), card('Q','d')]; +const hwTP1 = houseWay(tp1); +ok('two pair (≤6): both pairs in high', evaluate5Card(hwTP1.highHand).rank === HAND_RANK.TWO_PAIR); +ok('two pair (≤6): not foul', !isFoul(hwTP1.highHand, hwTP1.lowHand)); + +// High pair ≥ JJ: split — HIGH pair → HIGH hand, LOW pair → LOW hand +const tp2 = [card('J','s'), card('J','h'), card('5','d'), card('5','c'), card('A','s'), card('K','h'), card('Q','d')]; +const hwTP2 = houseWay(tp2); +ok('two pair (JJ+): JJ in HIGH hand', hwTP2.highHand.some(c => c.rank === 'J')); +ok('two pair (JJ+): low pair (55) in LOW hand', hwTP2.lowHand.every(c => c.rank === '5')); +ok('two pair (JJ+): not foul', !isFoul(hwTP2.highHand, hwTP2.lowHand)); + +// AA + 22: split — AA in HIGH hand, 22 in LOW hand +const tp3 = [card('A','s'), card('A','h'), card('2','d'), card('2','c'), card('K','s'), card('Q','h'), card('J','d')]; +const hwTP3 = houseWay(tp3); +ok('two pair (AA+22): AA in HIGH hand', hwTP3.highHand.some(c => c.rank === 'A')); +ok('two pair (AA+22): 22 in LOW hand', hwTP3.lowHand.every(c => c.rank === '2')); +ok('two pair (AA+22): not foul', !isFoul(hwTP3.highHand, hwTP3.lowHand)); + +console.log('\nHouse Way — Three Pair'); +const threePair = [card('A','s'), card('A','h'), card('K','d'), card('K','c'), card('Q','s'), card('Q','h'), card('J','d')]; +const hwThreePair = houseWay(threePair); +ok('three pair: low hand is a pair', evaluate2Card(hwThreePair.lowHand).rank === 1); +ok('three pair: low hand has highest pair (Aces)', hwThreePair.lowHand.some(c => c.rank === 'A')); +ok('three pair: not foul', !isFoul(hwThreePair.highHand, hwThreePair.lowHand)); + +console.log('\nHouse Way — Three of a Kind'); +const trips7 = [card('8','s'), card('8','h'), card('8','d'), card('A','c'), card('K','s'), card('Q','h'), card('J','d')]; +const hwTrips = houseWay(trips7); +ok('trips (888): trips in high hand', evaluate5Card(hwTrips.highHand).rank === HAND_RANK.THREE_OF_A_KIND); +ok('trips: A in low hand', hwTrips.lowHand.some(c => c.rank === 'A')); +ok('trips: not foul', !isFoul(hwTrips.highHand, hwTrips.lowHand)); + +// Three Aces +const tripsAces = [card('A','s'), card('A','h'), card('A','d'), card('K','c'), card('Q','s'), card('J','h'), card('T','d')]; +const hwTA = houseWay(tripsAces); +ok('three Aces: one Ace in low hand', hwTA.lowHand.some(c => c.rank === 'A')); +ok('three Aces: not foul', !isFoul(hwTA.highHand, hwTA.lowHand)); + +console.log('\nHouse Way — Full House'); +const fullHouse7 = [card('K','s'), card('K','h'), card('K','d'), card('Q','s'), card('Q','h'), card('A','c'), card('J','d')]; +const hwFH = houseWay(fullHouse7); +ok('full house: pair in low hand', evaluate2Card(hwFH.lowHand).rank === 1); +ok('full house: pair of Queens in low', hwFH.lowHand.every(c => c.rank === 'Q')); +ok('full house: trips in high', evaluate5Card(hwFH.highHand).rank === HAND_RANK.THREE_OF_A_KIND); +ok('full house: not foul', !isFoul(hwFH.highHand, hwFH.lowHand)); + +console.log('\nHouse Way — Four of a Kind'); +// Low quads (2s-6s): keep together +const quads6 = [card('6','s'), card('6','h'), card('6','d'), card('6','c'), card('A','s'), card('K','h'), card('Q','d')]; +const hwQ6 = houseWay(quads6); +ok('quads of 6s: all 4 in high hand', hwQ6.highHand.filter(c => c.rank === '6').length === 4); +ok('quads of 6s: not foul', !isFoul(hwQ6.highHand, hwQ6.lowHand)); + +// High quads (Aces): always split +const quadsA = [card('A','s'), card('A','h'), card('A','d'), card('A','c'), card('K','s'), card('Q','h'), card('J','d')]; +const hwQA = houseWay(quadsA); +ok('quads of Aces: split (2 Aces in low)', hwQA.lowHand.filter(c => c.rank === 'A').length === 2); +ok('quads of Aces: 2 Aces in high', hwQA.highHand.filter(c => c.rank === 'A').length === 2); +ok('quads of Aces: not foul', !isFoul(hwQA.highHand, hwQA.lowHand)); + +// Jack quads: split +const quadsJ = [card('J','s'), card('J','h'), card('J','d'), card('J','c'), card('K','s'), card('Q','h'), card('T','d')]; +const hwQJ = houseWay(quadsJ); +ok('quads of Jacks: split', hwQJ.lowHand.filter(c => c.rank === 'J').length === 2); +ok('quads of Jacks: not foul', !isFoul(hwQJ.highHand, hwQJ.lowHand)); + +console.log('\nHouse Way — Five Aces'); +const fiveAces7 = [card('A','s'), card('A','h'), card('A','d'), card('A','c'), makeJoker(), card('K','s'), card('Q','h')]; +const hwFA = houseWay(fiveAces7); +ok('Five Aces: 5 ace-equiv cards in high', hwFA.highHand.length === 5); +ok('Five Aces: Five Aces rank', evaluate5Card(hwFA.highHand).rank === HAND_RANK.FIVE_ACES); +ok('Five Aces: not foul', !isFoul(hwFA.highHand, hwFA.lowHand)); + +// ── State management ─────────────────────────────────────────────────────────── +console.log('\nState management'); +const opponents = Array.from({ length: 3 }, (_, i) => ({ name: `AI${i+1}`, id: i+1 })); +let gs = createInitialState(opponents, 2000); +ok('createInitialState: 6 seats total', gs.players.length === 6); +ok('seat 0 is human', gs.players[0].isHuman); +ok('3 opponents active', gs.players.filter(p => p.active && !p.isHuman).length === 3); +ok('2 empty seats inactive', gs.players.filter(p => !p.active).length === 2); + +gs = prepareRound(gs); +ok('prepareRound: phase = betting', gs.phase === 'betting'); +ok('prepareRound: deck has 53 cards', gs.deck.length === 53); +ok('prepareRound: bets reset', gs.players.every(p => p.bet === 0)); + +gs = applyBet(gs, 0, 25); +ok('applyBet: human bet = 25', gs.players[0].bet === 25); + +gs = dealHands(gs); +ok('dealHands: phase = setting', gs.phase === 'setting'); +ok('human has 7 cards', gs.players[0].hand.length === 7); +ok('AI1 has 7 cards', gs.players[1].hand.length === 7); +ok('dealer has 7 cards', gs.dealer.hand.length === 7); +// 4 active players × 7 + dealer × 7 = 35 cards dealt; 53 - 35 = 18 remaining +ok('4 active players + dealer = 35 cards dealt, 18 remaining', gs.deck.length === 53 - 35); + +// Apply house way to AI +gs = applyHouseWaySplit(gs, 1); +ok('AI1 has highHand of 5', gs.players[1].highHand.length === 5); +ok('AI1 has lowHand of 2', gs.players[1].lowHand.length === 2); +ok('AI1 not foul', !gs.players[1].isFoul); + +gs = applyDealerHouseWay(gs); +ok('dealer has highHand of 5', gs.dealer.highHand.length === 5); +ok('dealer has lowHand of 2', gs.dealer.lowHand.length === 2); + +// Human sets hands (use house way for simplicity in test) +const hwHuman = houseWay(gs.players[0].hand); +gs = applyHumanSplit(gs, hwHuman.highHand, hwHuman.lowHand); +ok('human has highHand of 5', gs.players[0].highHand.length === 5); +ok('human not foul', !gs.players[0].isFoul); + +// Apply house way to remaining AI seats too +for (let s = 2; s <= 5; s++) { + if (gs.players[s].active) gs = applyHouseWaySplit(gs, s); +} + +gs = resolveRound(gs); +ok('resolveRound: phase = resolved', gs.phase === 'resolved'); +ok('human has a result', ['win','push','lose','foul'].includes(gs.players[0].result)); + +// ── Resolution — chips math ─────────────────────────────────────────────────── +console.log('\nResolution — chips math'); + +// Simulate a known win (human wins both hands) +{ + let testGs = createInitialState([{ name: 'Bot' }], 1000); + testGs = prepareRound(testGs); + testGs = applyBet(testGs, 0, 100); + testGs = dealHands(testGs); + + // Force-set human and dealer hands for a guaranteed win + const winHigh = [card('A','s'), card('A','h'), card('A','d'), card('K','s'), card('K','h')]; // FH + const winLow = [card('Q','s'), card('Q','h')]; // pair of Qs + const looseHigh = [card('2','s'), card('3','h'), card('5','d'), card('7','c'), card('9','s')]; // garbage + const looseLow = [card('4','s'), card('6','h')]; + + testGs = { ...testGs, players: testGs.players.map(p => p.seat === 0 + ? { ...p, highHand: winHigh, lowHand: winLow, + highEval: { rank: HAND_RANK.FULL_HOUSE, name: 'Full House', tiebreakers: [14, 13] }, + lowEval: { rank: 1, tiebreakers: [12] }, isFoul: false } + : p), }; + testGs = { ...testGs, dealer: { ...testGs.dealer, highHand: looseHigh, lowHand: looseLow, + highEval: evaluate5Card(looseHigh), lowEval: evaluate2Card(looseLow) } }; + testGs = applyHouseWaySplit(testGs, 1); + testGs = resolveRound(testGs); + + ok('win result: chipsWon = 95 (100 - 5% commission)', testGs.players[0].chipsWon === 95); + ok('win result: chips increased', testGs.players[0].chips === 1095); +} + +// Simulate a known loss +{ + let testGs = createInitialState([{ name: 'Bot' }], 1000); + testGs = prepareRound(testGs); + testGs = applyBet(testGs, 0, 50); + testGs = dealHands(testGs); + + const loseHigh = [card('2','s'), card('3','h'), card('5','d'), card('7','c'), card('9','s')]; + const loseLow = [card('4','s'), card('6','h')]; + const winHigh = [card('A','s'), card('A','h'), card('A','d'), card('K','s'), card('K','h')]; + const winLow = [card('Q','s'), card('Q','h')]; + + testGs = { ...testGs, players: testGs.players.map(p => p.seat === 0 + ? { ...p, highHand: loseHigh, lowHand: loseLow, + highEval: evaluate5Card(loseHigh), lowEval: evaluate2Card(loseLow), isFoul: false } + : p) }; + testGs = { ...testGs, dealer: { ...testGs.dealer, highHand: winHigh, lowHand: winLow, + highEval: { rank: HAND_RANK.FULL_HOUSE, name: 'Full House', tiebreakers: [14, 13] }, + lowEval: { rank: 1, tiebreakers: [12] } } }; + testGs = applyHouseWaySplit(testGs, 1); + testGs = resolveRound(testGs); + + ok('lose result: chipsWon = -50', testGs.players[0].chipsWon === -50); + ok('lose result: chips = 950', testGs.players[0].chips === 950); +} + +// Push: player wins high, loses low +{ + let testGs = createInitialState([{ name: 'Bot' }], 1000); + testGs = prepareRound(testGs); + testGs = applyBet(testGs, 0, 25); + testGs = dealHands(testGs); + + const pHigh = [card('K','s'), card('K','h'), card('K','d'), card('Q','s'), card('Q','h')]; // FH KKK-QQ + const pLow = [card('3','s'), card('4','h')]; + const dHigh = [card('2','s'), card('3','h'), card('5','d'), card('7','c'), card('9','s')]; // high card + const dLow = [card('A','s'), card('A','h')]; // pair of Aces + + testGs = { ...testGs, + players: testGs.players.map(p => p.seat === 0 + ? { ...p, highHand: pHigh, lowHand: pLow, highEval: evaluate5Card(pHigh), lowEval: evaluate2Card(pLow), isFoul: false } + : p), + dealer: { ...testGs.dealer, highHand: dHigh, lowHand: dLow, highEval: evaluate5Card(dHigh), lowEval: evaluate2Card(dLow) } + }; + testGs = applyHouseWaySplit(testGs, 1); + testGs = resolveRound(testGs); + ok('push result: chipsWon = 0', testGs.players[0].chipsWon === 0); + ok('push result: result = push', testGs.players[0].result === 'push'); +} + +// Foul auto-lose +{ + let testGs = createInitialState([{ name: 'Bot' }], 1000); + testGs = prepareRound(testGs); + testGs = applyBet(testGs, 0, 40); + testGs = dealHands(testGs); + + const foulHighHand = [card('A','s'), card('K','h'), card('Q','d'), card('J','c'), card('9','s')]; + const foulLowHand = [card('A','h'), card('A','d')]; // pair Aces in low, but high hand is high card = foul + testGs = applyHumanSplit(testGs, foulHighHand, foulLowHand); + testGs = applyDealerHouseWay(testGs); + testGs = applyHouseWaySplit(testGs, 1); + testGs = resolveRound(testGs); + ok('foul: auto-lose', testGs.players[0].result === 'foul'); + ok('foul: chipsWon = -40', testGs.players[0].chipsWon === -40); +} + +// Copy rule: tied high hand → dealer wins (push if player wins low) +{ + // Force both high hands to the same rank/tiebreakers + let testGs = createInitialState([{ name: 'Bot' }], 1000); + testGs = prepareRound(testGs); + testGs = applyBet(testGs, 0, 20); + testGs = dealHands(testGs); + + const sameHighEval = { rank: HAND_RANK.FLUSH, name: 'Flush', tiebreakers: [14, 12, 10, 8, 6] }; + const winLowEval = { rank: 1, tiebreakers: [13] }; // pair Kings + const loseLowEval = { rank: 0, tiebreakers: [12, 10] }; // Q-T high + + testGs = { ...testGs, + players: testGs.players.map(p => p.seat === 0 + ? { ...p, highHand: [], lowHand: [], highEval: sameHighEval, lowEval: winLowEval, isFoul: false } + : p), + dealer: { ...testGs.dealer, highHand: [], lowHand: [], highEval: sameHighEval, lowEval: loseLowEval } + }; + testGs = applyHouseWaySplit(testGs, 1); + testGs = resolveRound(testGs); + // Player wins low (pair K > Q-T), dealer wins high (tie → dealer). Push overall. + ok('copy rule: tied high + player wins low = push', testGs.players[0].result === 'push'); +} + +// ── AI bet ──────────────────────────────────────────────────────────────────── +console.log('\nAI bet'); +const aiPlayer = { chips: 500 }; +const bets = Array.from({ length: 100 }, () => chooseBet(aiPlayer)); +ok('chooseBet: all in range [5, 100]', bets.every(b => b >= 5 && b <= 100)); +ok('chooseBet: never exceeds chips (500)', bets.every(b => b <= 500)); + +const poorPlayer = { chips: 7 }; +const poorBets = Array.from({ length: 20 }, () => chooseBet(poorPlayer)); +ok('chooseBet: never exceeds player chips (7)', poorBets.every(b => b <= 7)); + +// ── Deck coverage ───────────────────────────────────────────────────────────── +console.log('\nDeck coverage'); +// 6 players + dealer × 7 = 49 cards ≤ 53 +ok('max 5 AI opponents: 6 players + dealer = 49 cards dealt ≤ 53', 6 * 7 + 7 <= 53); + +// ── Summary ──────────────────────────────────────────────────────────────────── +console.log(`\n ${pass} passed, ${fail} failed\n`); +if (fail > 0) process.exit(1);