From 4709956fab0745eec6154efa2b5b99198e2835f5 Mon Sep 17 00:00:00 2001 From: Dennis Qian Date: Sat, 4 Apr 2026 16:56:15 -0700 Subject: [PATCH 1/2] Add baccarat game with Punto Banco rules and card animations Implements a full baccarat game module following the existing game registry pattern. Features 8-deck shoe (configurable 4/6/8), three bet types (Player/Banker/Tie), standard third-card tableau, 5% banker commission, 8:1 tie payout, card slide-in animations, shoe depletion bar with cut card, and natural detection. Wired into the menu, options screen, and game dispatch. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/baccarat/game.ts | 320 +++++++++++++++++++++++++++++++++++++ src/baccarat/handler.ts | 79 +++++++++ src/baccarat/renderer.ts | 334 +++++++++++++++++++++++++++++++++++++++ src/renderer.ts | 13 +- src/tui.ts | 46 +++++- src/types.ts | 29 +++- 6 files changed, 815 insertions(+), 6 deletions(-) create mode 100644 src/baccarat/game.ts create mode 100644 src/baccarat/handler.ts create mode 100644 src/baccarat/renderer.ts diff --git a/src/baccarat/game.ts b/src/baccarat/game.ts new file mode 100644 index 0000000..44e1dc0 --- /dev/null +++ b/src/baccarat/game.ts @@ -0,0 +1,320 @@ +import type { AppState, Card, BaccaratBetType } from "../types"; +import { createShoe, drawCard } from "../shared/cards"; + +// Baccarat card value: A=1, 2-9=face value, 10/J/Q/K=0 +export function baccaratCardValue(card: Card): number { + if (card.rank === 'A') return 1; + if (card.rank === 'K' || card.rank === 'Q' || card.rank === 'J' || card.rank === '10') return 0; + return parseInt(card.rank); +} + +// Hand value = sum of card values mod 10 +export function baccaratHandValue(cards: Card[]): number { + let total = 0; + for (const card of cards) { + total += baccaratCardValue(card); + } + return total % 10; +} + +// Staged cards for deal animation — drawn upfront, added one at a time +interface DealStage { + p1: Card; + b1: Card; + p2: Card; + b2: Card; + playerThird: Card | null; + bankerThird: Card | null; +} + +let dealStage: DealStage | null = null; + +// Determine if player draws third card (returns true if player draws) +function playerDrawsThird(playerValue: number): boolean { + return playerValue <= 5; +} + +// Determine if banker draws third card based on banker total and player's third card +function bankerDrawsThird(bankerValue: number, playerThirdCard: Card | null): boolean { + // If player stood (no third card), banker draws on 0-5 + if (!playerThirdCard) { + return bankerValue <= 5; + } + + const pThird = baccaratCardValue(playerThirdCard); + + switch (bankerValue) { + case 0: + case 1: + case 2: + return true; // always draws + case 3: + return pThird !== 8; // draws unless player's third was 8 + case 4: + return pThird >= 2 && pThird <= 7; // draws on 2-7 + case 5: + return pThird >= 4 && pThird <= 7; // draws on 4-7 + case 6: + return pThird === 6 || pThird === 7; // draws on 6-7 + case 7: + return false; // stands + default: + return false; // 8-9 natural, should not reach here + } +} + +export function deal(state: AppState): boolean { + const bc = state.baccarat; + + if (bc.betAmount > state.balance) { + state.message = "Not enough balance!"; + return false; + } + + // Auto-shuffle when past the cut card + if (bc.shoe.length <= bc.cutCard) { + const nd = state.options.baccarat.numDecks; + bc.shoe = createShoe(nd); + bc.numDecks = nd; + const base = nd * 5; + bc.cutCard = base + Math.floor(Math.random() * (nd * 15 + 1)); + state.message = "Shoe reshuffled!"; + } + + state.balance -= bc.betAmount; + + // Draw initial 4 cards + const p1 = drawCard(bc.shoe); + const b1 = drawCard(bc.shoe); + const p2 = drawCard(bc.shoe); + const b2 = drawCard(bc.shoe); + + // Calculate initial values to determine third cards + const playerInitial = (baccaratCardValue(p1) + baccaratCardValue(p2)) % 10; + const bankerInitial = (baccaratCardValue(b1) + baccaratCardValue(b2)) % 10; + + let playerThird: Card | null = null; + let bankerThird: Card | null = null; + + // Check for naturals (8 or 9) + const isNatural = playerInitial >= 8 || bankerInitial >= 8; + + if (!isNatural) { + // Player third card rule + if (playerDrawsThird(playerInitial)) { + playerThird = drawCard(bc.shoe); + } + + // Banker third card rule + if (bankerDrawsThird(bankerInitial, playerThird)) { + bankerThird = drawCard(bc.shoe); + } + } + + dealStage = { p1, b1, p2, b2, playerThird, bankerThird }; + + // Set up empty hands + bc.playerCards = []; + bc.bankerCards = []; + bc.winAmount = 0; + bc.resultMessage = ""; + bc.cardAnim = null; + state.message = ""; + return true; +} + +function resolveRound(state: AppState): void { + const bc = state.baccarat; + const playerVal = baccaratHandValue(bc.playerCards); + const bankerVal = baccaratHandValue(bc.bankerCards); + + let winner: 'player' | 'banker' | 'tie'; + if (playerVal > bankerVal) { + winner = 'player'; + } else if (bankerVal > playerVal) { + winner = 'banker'; + } else { + winner = 'tie'; + } + + const isNatural = (bc.playerCards.length === 2 && playerVal >= 8) || + (bc.bankerCards.length === 2 && bankerVal >= 8); + + // Calculate payout + if (bc.betType === winner) { + switch (bc.betType) { + case 'player': + // 1:1 payout + state.balance += bc.betAmount * 2; + bc.winAmount = bc.betAmount; + break; + case 'banker': + // 1:1 minus 5% commission + const payout = bc.betAmount * 2; + const commission = Math.floor(bc.betAmount * 0.05); + state.balance += payout - commission; + bc.winAmount = bc.betAmount - commission; + break; + case 'tie': + // 8:1 payout + state.balance += bc.betAmount + bc.betAmount * 8; + bc.winAmount = bc.betAmount * 8; + break; + } + } else if (winner === 'tie' && bc.betType !== 'tie') { + // Tie returns bet on player/banker bets + state.balance += bc.betAmount; + bc.winAmount = 0; + } else { + bc.winAmount = -bc.betAmount; + } + + // Result message + const naturalStr = isNatural ? "Natural! " : ""; + if (winner === 'player') { + bc.resultMessage = `${naturalStr}Player wins! ${playerVal} vs ${bankerVal}`; + } else if (winner === 'banker') { + bc.resultMessage = `${naturalStr}Banker wins! ${bankerVal} vs ${playerVal}`; + } else { + bc.resultMessage = `${naturalStr}Tie! ${playerVal} - ${bankerVal}`; + } + + bc.phase = "result"; +} + +export function newRound(state: AppState): void { + state.baccarat.phase = "betting"; + state.baccarat.playerCards = []; + state.baccarat.bankerCards = []; + state.baccarat.winAmount = 0; + state.baccarat.resultMessage = ""; + state.baccarat.cardAnim = null; + state.message = ""; +} + +// --- Card animation --- + +const CARD_ANIM_FRAMES = 8; +const CARD_ANIM_DELAY = 40; +let animSkipped = false; +let animGeneration = 0; + +function animateCard( + state: AppState, + render: () => void, + target: 'player' | 'banker', + onDone: () => void, +): void { + const bc = state.baccarat; + + if (animSkipped) { + bc.cardAnim = null; + onDone(); + return; + } + + const gen = ++animGeneration; + bc.cardAnim = { target, frame: 0 }; + render(); + + const step = () => { + if (gen !== animGeneration) return; + + if (!bc.cardAnim || animSkipped) { + bc.cardAnim = null; + onDone(); + return; + } + bc.cardAnim.frame++; + if (bc.cardAnim.frame >= CARD_ANIM_FRAMES) { + bc.cardAnim = null; + render(); + onDone(); + return; + } + render(); + setTimeout(step, CARD_ANIM_DELAY); + }; + setTimeout(step, CARD_ANIM_DELAY); +} + +export function skipCardAnim(state: AppState): void { + animSkipped = true; + animGeneration++; + state.baccarat.cardAnim = null; +} + +export function startDealAnimation(state: AppState, render: () => void): void { + animSkipped = false; + const bc = state.baccarat; + const stage = dealStage; + if (!stage) return; + dealStage = null; + bc.phase = "dealing"; + + // Step 1: player card 1 + bc.playerCards.push(stage.p1); + animateCard(state, render, 'player', () => { + // Step 2: banker card 1 + bc.bankerCards.push(stage.b1); + animateCard(state, render, 'banker', () => { + // Step 3: player card 2 + bc.playerCards.push(stage.p2); + animateCard(state, render, 'player', () => { + // Step 4: banker card 2 + bc.bankerCards.push(stage.b2); + animateCard(state, render, 'banker', () => { + // Step 5: player third card (if any) + if (stage.playerThird) { + bc.playerCards.push(stage.playerThird); + animateCard(state, render, 'player', () => { + // Step 6: banker third card (if any) + if (stage.bankerThird) { + bc.bankerCards.push(stage.bankerThird); + animateCard(state, render, 'banker', () => { + resolveRound(state); + render(); + }); + } else { + resolveRound(state); + render(); + } + }); + } else if (stage.bankerThird) { + // No player third, but banker draws third + bc.bankerCards.push(stage.bankerThird); + animateCard(state, render, 'banker', () => { + resolveRound(state); + render(); + }); + } else { + // No third cards (natural or both stand) + resolveRound(state); + render(); + } + }); + }); + }); + }); +} + +export function skipDeal(state: AppState): void { + const bc = state.baccarat; + if (bc.phase !== "dealing") return; + skipCardAnim(state); + + const stage = dealStage; + if (stage) { + dealStage = null; + if (bc.playerCards.length < 2) { + if (bc.playerCards.length === 0) bc.playerCards.push(stage.p1); + bc.bankerCards.length === 0 && bc.bankerCards.push(stage.b1); + bc.playerCards.length < 2 && bc.playerCards.push(stage.p2); + bc.bankerCards.length < 2 && bc.bankerCards.push(stage.b2); + if (stage.playerThird) bc.playerCards.push(stage.playerThird); + if (stage.bankerThird) bc.bankerCards.push(stage.bankerThird); + } + } + + resolveRound(state); +} diff --git a/src/baccarat/handler.ts b/src/baccarat/handler.ts new file mode 100644 index 0000000..68f15c8 --- /dev/null +++ b/src/baccarat/handler.ts @@ -0,0 +1,79 @@ +import type { AppState, BaccaratBetType } from "../types"; +import type { KeyEvent } from "../keybindings"; +import { deal, newRound, startDealAnimation, skipCardAnim, skipDeal } from "./game"; + +const BET_TYPES: BaccaratBetType[] = ['player', 'banker', 'tie']; + +export function handleBaccaratKey(state: AppState, key: KeyEvent, render: () => void): void { + const bc = state.baccarat; + + // Card animation in progress: Enter skips + if (bc.cardAnim) { + if (key.name === "return") { + skipCardAnim(state); + } + return; + } + + // Dealing phase: Enter to skip + if (bc.phase === "dealing") { + if (key.name === "return") { + skipDeal(state); + } else if (key.name === "q" || key.name === "escape") { + skipDeal(state); + state.screen = "menu"; + state.message = ""; + } + return; + } + + // Result phase + if (bc.phase === "result") { + if (key.name === "return") { + newRound(state); + } else if (key.name === "q" || key.name === "escape") { + state.screen = "menu"; + state.message = ""; + } + return; + } + + // Betting phase + switch (key.name) { + case "return": + if (!deal(state)) return; // deal failed (insufficient balance) + startDealAnimation(state, render); + return; + case "left": { + const idx = BET_TYPES.indexOf(bc.betType); + bc.betType = BET_TYPES[(idx - 1 + BET_TYPES.length) % BET_TYPES.length]!; + state.message = ""; + break; + } + case "right": { + const idx = BET_TYPES.indexOf(bc.betType); + bc.betType = BET_TYPES[(idx + 1) % BET_TYPES.length]!; + state.message = ""; + break; + } + case "up": + if (bc.betAmount < 5) bc.betAmount = 5; + else if (bc.betAmount < 10) bc.betAmount = 10; + else if (bc.betAmount < 25) bc.betAmount = 25; + else if (bc.betAmount < 50) bc.betAmount = 50; + else bc.betAmount += 25; + break; + case "down": + if (bc.betAmount > 50) bc.betAmount -= 25; + else if (bc.betAmount > 25) bc.betAmount = 25; + else if (bc.betAmount > 10) bc.betAmount = 10; + else if (bc.betAmount > 5) bc.betAmount = 5; + else if (bc.betAmount > 1) bc.betAmount = 1; + break; + case "q": + case "escape": + state.screen = "menu"; + state.message = ""; + break; + } +} diff --git a/src/baccarat/renderer.ts b/src/baccarat/renderer.ts new file mode 100644 index 0000000..2c5e27f --- /dev/null +++ b/src/baccarat/renderer.ts @@ -0,0 +1,334 @@ +import type { AppState, Card, Rank } from "../types"; +import * as t from "../theme"; +import { baccaratHandValue } from "./game"; +import { renderHeader, renderHotkeySplit, sliceAnsi } from "../shared/render"; +import type { HotkeyItem } from "../shared/render"; + +const CARD_H = 9; +const INNER_W = 9; +const INNER_H = 7; + +// Pip positions within the 7-row content area (rows 1-5 are pip area) +const PIP_LAYOUTS: Record = { + 'A': [{c:4,r:3}], + '2': [{c:4,r:1}, {c:4,r:5}], + '3': [{c:4,r:1}, {c:4,r:3}, {c:4,r:5}], + '4': [{c:1,r:1}, {c:7,r:1}, {c:1,r:5}, {c:7,r:5}], + '5': [{c:1,r:1}, {c:7,r:1}, {c:4,r:3}, {c:1,r:5}, {c:7,r:5}], + '6': [{c:1,r:1}, {c:7,r:1}, {c:1,r:3}, {c:7,r:3}, {c:1,r:5}, {c:7,r:5}], + '7': [{c:1,r:1}, {c:7,r:1}, {c:1,r:3}, {c:7,r:3}, {c:4,r:2}, {c:1,r:5}, {c:7,r:5}], + '8': [{c:1,r:1}, {c:7,r:1}, {c:1,r:3}, {c:7,r:3}, {c:4,r:2}, {c:4,r:4}, {c:1,r:5}, {c:7,r:5}], + '9': [{c:1,r:1}, {c:7,r:1}, {c:1,r:2}, {c:7,r:2}, {c:4,r:3}, {c:1,r:4}, {c:7,r:4}, {c:1,r:5}, {c:7,r:5}], + '10': [{c:1,r:1}, {c:7,r:1}, {c:4,r:2}, {c:1,r:2}, {c:7,r:2}, {c:1,r:4}, {c:7,r:4}, {c:4,r:4}, {c:1,r:5}, {c:7,r:5}], +}; + +function renderPipBody(clr: string, suit: string, rank: Rank): string[] { + const pips = PIP_LAYOUTS[rank] ?? []; + const pipSet = new Set(pips.map(p => `${p.r},${p.c}`)); + const rows: string[] = []; + for (let row = 1; row <= 5; row++) { + let line = ""; + for (let col = 0; col < INNER_W; col++) { + if (pipSet.has(`${row},${col}`)) { + line += `${clr}${t.bold}${suit}${t.reset}`; + } else { + line += " "; + } + } + rows.push(line); + } + return rows; +} + +function renderFaceBody(clr: string, suit: string, rank: string): string[] { + return [ + ` ${clr}\u256D\u2500\u2500\u2500\u256E${t.reset} `, + ` ${clr}\u2502${t.reset} ${clr}${t.bold}${suit}${t.reset} ${clr}\u2502${t.reset} `, + ` ${clr}\u2502${t.reset} ${clr}${t.bold}${rank}${t.reset} ${clr}\u2502${t.reset} `, + ` ${clr}\u2502${t.reset} ${clr}${t.bold}${suit}${t.reset} ${clr}\u2502${t.reset} `, + ` ${clr}\u256E\u2500\u2500\u2500\u256F${t.reset} `, + ]; +} + +function renderCard(card: Card): string[] { + const isRed = card.suit === '\u2665' || card.suit === '\u2666'; + const clr = isRed ? t.brightRed : t.brightWhite; + const s = card.suit; + const r = card.rank; + + const topLabel = `${r}${s}`; + const topLine = `${clr}${t.bold}${topLabel}${t.reset}${" ".repeat(INNER_W - topLabel.length)}`; + + const botLabel = `${s}${r}`; + const botLine = `${" ".repeat(INNER_W - botLabel.length)}${clr}${t.bold}${botLabel}${t.reset}`; + + const bodyRows = (r === 'J' || r === 'Q' || r === 'K') + ? renderFaceBody(clr, s, r) + : renderPipBody(clr, s, r); + + const lines: string[] = []; + lines.push(`${t.gray}\u250C${"\u2500".repeat(INNER_W)}\u2510${t.reset}`); + lines.push(`${t.gray}\u2502${t.reset}${topLine}${t.gray}\u2502${t.reset}`); + for (const row of bodyRows) lines.push(`${t.gray}\u2502${t.reset}${row}${t.gray}\u2502${t.reset}`); + lines.push(`${t.gray}\u2502${t.reset}${botLine}${t.gray}\u2502${t.reset}`); + lines.push(`${t.gray}\u2514${"\u2500".repeat(INNER_W)}\u2518${t.reset}`); + return lines; +} + +function renderPlaceholderCard(): string[] { + const d = t.fg256(236); + const lines: string[] = []; + lines.push(`${d} ${"\u254C".repeat(INNER_W)} ${t.reset}`); + for (let r = 0; r < INNER_H; r++) { + lines.push(`${d}\u254E${" ".repeat(INNER_W)}\u254E${t.reset}`); + } + lines.push(`${d} ${"\u254C".repeat(INNER_W)} ${t.reset}`); + return lines; +} + +const CARD_ANIM_MAX_OFFSET = 25; +const CARD_ANIM_FRAMES = 8; +const CARD_SLOT_W = 12; + +function cardAnimOffset(frame: number): number { + const progress = Math.min(1, frame / CARD_ANIM_FRAMES); + return Math.floor(CARD_ANIM_MAX_OFFSET * Math.pow(1 - progress, 2)); +} + +function renderHandCards( + cards: Card[], + placeholderSlots: number, + lastCardOffset: number, +): string[] { + const totalSlots = Math.max(cards.length, placeholderSlots); + if (totalSlots === 0) return []; + + const isAnimating = lastCardOffset > 0 && cards.length > 0; + const animIdx = isAnimating ? cards.length - 1 : -1; + const settledCount = isAnimating ? cards.length - 1 : cards.length; + + const phCard = renderPlaceholderCard(); + const cardImages: string[][] = []; + for (let i = 0; i < cards.length; i++) { + cardImages.push(renderCard(cards[i]!)); + } + + const lines: string[] = []; + for (let row = 0; row < CARD_H; row++) { + let line = ""; + + for (let s = 0; s < totalSlots; s++) { + if (s > 0) line += " "; + + if (s === animIdx) { + if (s < placeholderSlots) { + line += phCard[row]; + } else { + line += " ".repeat(11); + } + } else if (s < settledCount) { + line += cardImages[s]![row]; + } else if (s < placeholderSlots) { + line += phCard[row]; + } else { + line += " ".repeat(11); + } + } + + // Overlay the animating card at its offset position + if (isAnimating) { + const animCardRow = cardImages[animIdx]![row]!; + const slotStart = animIdx * CARD_SLOT_W; + const animStart = slotStart + lastCardOffset; + const animVisW = 11; + + const baseVis = t.stripAnsi(line); + const baseLen = baseVis.length; + + let out = ""; + if (animStart <= baseLen) { + out += sliceAnsi(line, 0, animStart); + } else { + out += line + " ".repeat(animStart - baseLen); + } + out += animCardRow; + const afterPos = animStart + animVisW; + if (afterPos < baseLen) { + out += sliceAnsi(line, afterPos, baseLen); + } + line = out; + } + + lines.push(line); + } + return lines; +} + +// --- Main screen renderer --- + +export function renderBaccaratScreen(state: AppState): string[] { + const { columns: width } = process.stdout; + const lines: string[] = []; + const bc = state.baccarat; + + // Header + lines.push(...renderHeader("BACCARAT", state.balance, width)); + + // Shoe bar + const totalShoe = bc.numDecks * 52; + const leftLabel = " Shoe "; + const rightLabel = ` ${bc.shoe.length}/${totalShoe} `; + const barMax = Math.max(10, width - leftLabel.length - rightLabel.length); + const filled = Math.round((bc.shoe.length / totalShoe) * barMax); + const cutPos = Math.round((bc.cutCard / totalShoe) * barMax); + let bar = ""; + for (let i = 0; i < barMax; i++) { + if (i === cutPos) { + bar += `${t.yellow}${t.bold}|${t.reset}`; + } else if (i < filled) { + bar += `${t.gray}\u2588${t.reset}`; + } else { + bar += `${t.fg256(238)}\u2591${t.reset}`; + } + } + lines.push(`${t.gray}${leftLabel}${t.reset}${bar}${t.gray}${rightLabel}${t.reset}`); + + lines.push(""); + + // Bet info + const betTypeLabel = bc.betType === 'player' ? 'PLAYER' : bc.betType === 'banker' ? 'BANKER' : 'TIE'; + const betTypeClr = bc.betType === 'player' ? t.cyan : bc.betType === 'banker' ? t.red : t.green; + lines.push(` ${t.gray}Bet: ${t.reset}${t.brightWhite}${t.bold}$${bc.betAmount}${t.reset} ${t.gray}on ${t.reset}${betTypeClr}${t.bold}${betTypeLabel}${t.reset}`); + + lines.push(""); + lines.push(""); + + const pad = " "; + const anim = bc.cardAnim; + const playerOffset = anim?.target === 'player' ? cardAnimOffset(anim.frame) : 0; + const bankerOffset = anim?.target === 'banker' ? cardAnimOffset(anim.frame) : 0; + + const hasCards = bc.playerCards.length > 0 || bc.bankerCards.length > 0; + + // Player hand + const playerVal = bc.playerCards.length > 0 ? baccaratHandValue(bc.playerCards) : -1; + let playerLabel = `${t.cyan}${t.bold}PLAYER${t.reset}`; + if (playerVal >= 0) { + const valClr = playerVal >= 8 ? t.brightGreen : t.brightWhite; + playerLabel += ` ${t.gray}[${t.reset}${valClr}${t.bold}${playerVal}${t.reset}${t.gray}]${t.reset}`; + if (bc.playerCards.length === 2 && playerVal >= 8) { + playerLabel += ` ${t.brightGreen}${t.bold}Natural${t.reset}`; + } + } + lines.push(`${pad}${playerLabel}`); + + if (hasCards) { + const playerSettled = playerOffset > 0 ? bc.playerCards.length - 1 : bc.playerCards.length; + const playerPh = playerSettled < 2 ? 2 : 0; + const cardLines = renderHandCards(bc.playerCards, playerPh, playerOffset); + for (const line of cardLines) lines.push(`${pad}${line}`); + } else { + // Placeholder cards + const phLines = renderPlaceholderHand(); + for (const line of phLines) lines.push(`${pad}${line}`); + } + + lines.push(""); + lines.push(""); + + // Banker hand + const bankerVal = bc.bankerCards.length > 0 ? baccaratHandValue(bc.bankerCards) : -1; + let bankerLabel = `${t.red}${t.bold}BANKER${t.reset}`; + if (bankerVal >= 0) { + const valClr = bankerVal >= 8 ? t.brightGreen : t.brightWhite; + bankerLabel += ` ${t.gray}[${t.reset}${valClr}${t.bold}${bankerVal}${t.reset}${t.gray}]${t.reset}`; + if (bc.bankerCards.length === 2 && bankerVal >= 8) { + bankerLabel += ` ${t.brightGreen}${t.bold}Natural${t.reset}`; + } + } + lines.push(`${pad}${bankerLabel}`); + + if (hasCards) { + const bankerSettled = bankerOffset > 0 ? bc.bankerCards.length - 1 : bc.bankerCards.length; + const bankerPh = bankerSettled < 2 ? 2 : 0; + const cardLines = renderHandCards(bc.bankerCards, bankerPh, bankerOffset); + for (const line of cardLines) lines.push(`${pad}${line}`); + } else { + const phLines = renderPlaceholderHand(); + for (const line of phLines) lines.push(`${pad}${line}`); + } + + lines.push(""); + + // Result / status line + if (bc.phase === "result" && !bc.cardAnim) { + lines.push(""); + // Result message + if (bc.resultMessage) { + lines.push(`${pad}${t.brightWhite}${t.bold}${bc.resultMessage}${t.reset}`); + } + if (bc.winAmount > 0) { + const commissionNote = bc.betType === 'banker' ? ` ${t.gray}(5% commission)${t.reset}` : ""; + lines.push(`${pad}${t.green}${t.bold}+$${bc.winAmount}${t.reset}${commissionNote}`); + } else if (bc.winAmount < 0) { + lines.push(`${pad}${t.red}${t.bold}-$${Math.abs(bc.winAmount)}${t.reset}`); + } else { + lines.push(`${pad}${t.yellow}$0 ${t.gray}(push)${t.reset}`); + } + } else if (bc.phase === "dealing") { + lines.push(`${pad}${t.yellow}Dealing...${t.reset}`); + } else if (state.message) { + lines.push(`${pad}${t.yellow}${state.message}${t.reset}`); + } + + return lines; +} + +function renderPlaceholderHand(): string[] { + const c1 = renderPlaceholderCard(); + const c2 = renderPlaceholderCard(); + const lines: string[] = []; + for (let row = 0; row < CARD_H; row++) { + lines.push(`${c1[row]} ${c2[row]}`); + } + return lines; +} + +// --- Hotkey grid --- + +export function renderBaccaratHotkeys(width: number, state: AppState): string[] { + const bc = state.baccarat; + let left: HotkeyItem[] = []; + let right: HotkeyItem[] = []; + + switch (bc.phase) { + case "betting": + left = [ + { key: "\u2191\u2193", label: "Bet size" }, + { key: "\u25C4\u25BA", label: "Bet type" }, + { key: "Enter", label: "Deal" }, + ]; + right = [ + { key: "q", label: "Menu" }, + ]; + break; + case "dealing": + left = [ + { key: "Enter", label: "Skip" }, + ]; + right = [ + { key: "q", label: "Menu" }, + ]; + break; + case "result": + left = [ + { key: "Enter", label: "New round" }, + ]; + right = [ + { key: "q", label: "Menu" }, + ]; + break; + } + + return renderHotkeySplit(left, right, width); +} diff --git a/src/renderer.ts b/src/renderer.ts index c92a677..4212a62 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -10,11 +10,11 @@ export const MENU_ITEMS: MenuItem[] = [ { name: "Blackjack", screen: "blackjack", label: "2-Deck, 3:2" }, { name: "Pai Gow Poker", screen: "paigow", label: "53-Card, House Way" }, { name: "Craps", screen: "craps", label: "Standard Casino" }, - { name: "Baccarat", screen: null, label: "Coming Soon" }, + { name: "Baccarat", screen: "baccarat", label: "8-Deck, Punto Banco" }, ]; export function renderScreen(state: AppState): void { - if (state.screen === "roulette" || state.screen === "blackjack" || state.screen === "paigow" || state.screen === "craps") { + if (state.screen === "roulette" || state.screen === "blackjack" || state.screen === "paigow" || state.screen === "craps" || state.screen === "baccarat") { renderGameScreen(state); return; } @@ -131,7 +131,9 @@ function renderMenuScreen(state: AppState): void { ? "Coming Soon" : item.screen === "blackjack" ? `${state.options.blackjack.numDecks}-Deck, 3:2` - : item.label; + : item.screen === "baccarat" + ? `${state.options.baccarat.numDecks}-Deck, Punto Banco` + : item.label; let line: string; if (selected && available) { @@ -262,6 +264,11 @@ function renderOptionsScreen(state: AppState): void { lines.push(optRow(4, "Colored Suits", opts.paigow.coloredSuits ? "On" : "Off")); lines.push(""); + // Baccarat section + lines.push(` ${t.cyan}${t.bold}BACCARAT${t.reset}`); + lines.push(optRow(5, "Number of Decks", `${opts.baccarat.numDecks}`)); + lines.push(""); + // Fill while (lines.length < height - 4) lines.push(""); diff --git a/src/tui.ts b/src/tui.ts index d5acd80..4e49483 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -1,11 +1,13 @@ -import type { AppState, RouletteState, BlackjackState, PaiGowState, CrapsState, GameOptions, GameModule, AuthState } from "./types"; +import type { AppState, RouletteState, BlackjackState, PaiGowState, CrapsState, BaccaratState, GameOptions, GameModule, AuthState } from "./types"; import { parseKey } from "./keybindings"; import { renderScreen, MENU_ITEMS } from "./renderer"; import * as t from "./theme"; import { createShoe } from "./shared/cards"; import { newBjRound } from "./blackjack/game"; +import { newRound as newBaccaratRound } from "./baccarat/game"; import { handleRouletteKey } from "./roulette/handler"; import { handleBlackjackKey } from "./blackjack/handler"; +import { handleBaccaratKey } from "./baccarat/handler"; import { renderRouletteScreen, renderHotkeyGrid as renderRouletteHotkeys } from "./roulette/renderer"; import { renderBlackjackScreen, renderBjHotkeyGrid } from "./blackjack/renderer"; import { handlePaiGowKey } from "./paigow/handler"; @@ -14,6 +16,7 @@ import { createPaiGowState, newRound as newPaiGowRound } from "./paigow/game"; import { handleCrapsKey } from "./craps/handler"; import { renderCrapsScreen, renderCrapsHotkeys } from "./craps/renderer"; import { createCrapsState } from "./craps/game"; +import { renderBaccaratScreen, renderBaccaratHotkeys } from "./baccarat/renderer"; import { handleLoginKey, verifySession, syncBalanceToServer, serverResetBalance } from "./auth/handler"; import { loadAuth, clearAuth } from "./auth/store"; import { handleDepositKey, handleWithdrawKey, loadWallet } from "./wallet/handler"; @@ -41,6 +44,11 @@ export const GAMES: Record = { render: renderCrapsScreen, renderHotkeys: renderCrapsHotkeys, }, + baccarat: { + handleKey: handleBaccaratKey, + render: renderBaccaratScreen, + renderHotkeys: renderBaccaratHotkeys, + }, }; // --- State creation --- @@ -50,6 +58,7 @@ function createDefaultOptions(): GameOptions { roulette: { defaultWheelMode: "ball", tableMax: null }, blackjack: { numDecks: 2 }, paigow: { defaultSort: "descending", coloredSuits: true }, + baccarat: { numDecks: 8 }, }; } @@ -84,6 +93,23 @@ function randomCutCard(numDecks: number): number { return base + Math.floor(Math.random() * (numDecks * 15 + 1)); } +function createBaccaratState(options: GameOptions): BaccaratState { + const nd = options.baccarat.numDecks; + return { + phase: "betting", + shoe: createShoe(nd), + cutCard: randomCutCard(nd), + numDecks: nd, + playerCards: [], + bankerCards: [], + betAmount: 25, + betType: "player", + winAmount: 0, + resultMessage: "", + cardAnim: null, + }; +} + function createBlackjackState(options: GameOptions): BlackjackState { const nd = options.blackjack.numDecks; return { @@ -145,6 +171,7 @@ function createState(): AppState { blackjack: createBlackjackState(options), paigow: createPaiGowState(options), craps: createCrapsState(), + baccarat: createBaccaratState(options), options, optionsCursor: 0, auth: createAuthState(), @@ -307,6 +334,14 @@ function handleMenuKey(state: AppState, key: ReturnType, exit: newPaiGowRound(state); } else if (item.screen === "craps") { state.craps = createCrapsState(); + } else if (item.screen === "baccarat") { + const optDecks = state.options.baccarat.numDecks; + if (state.baccarat.numDecks !== optDecks) { + state.baccarat.shoe = createShoe(optDecks); + state.baccarat.numDecks = optDecks; + state.baccarat.cutCard = randomCutCard(optDecks); + } + newBaccaratRound(state); } state.message = ""; } else { @@ -433,10 +468,11 @@ function handleMenuKey(state: AppState, key: ReturnType, exit: const WHEEL_MODES = ["ball", "arrow"] as const; const TABLE_MAX_OPTIONS: (number | null)[] = [null, 100, 500, 1000, 5000, 10000]; const DECK_OPTIONS = [1, 2, 4, 6, 8]; +const BACCARAT_DECK_OPTIONS = [4, 6, 8]; function handleOptionsKey(state: AppState, key: ReturnType): void { const opts = state.options; - const total = 5; + const total = 6; switch (key.name) { case "up": @@ -475,6 +511,12 @@ function handleOptionsKey(state: AppState, key: ReturnType): vo opts.paigow.coloredSuits = !opts.paigow.coloredSuits; break; } + case 5: { + const idx = BACCARAT_DECK_OPTIONS.indexOf(opts.baccarat.numDecks); + const next = Math.max(0, Math.min(BACCARAT_DECK_OPTIONS.length - 1, idx + dir)); + opts.baccarat.numDecks = BACCARAT_DECK_OPTIONS[next]!; + break; + } } break; } diff --git a/src/types.ts b/src/types.ts index 160a8a2..b2433ad 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -export type Screen = "menu" | "roulette" | "blackjack" | "paigow" | "craps" | "options" | "login" | "deposit" | "withdraw"; +export type Screen = "menu" | "roulette" | "blackjack" | "paigow" | "craps" | "baccarat" | "options" | "login" | "deposit" | "withdraw"; export type MenuItem = { name: string; @@ -20,6 +20,9 @@ export interface GameOptions { defaultSort: PaiGowSortMode; coloredSuits: boolean; }; + baccarat: { + numDecks: number; + }; } export type LoginPhase = "email-input" | "sending" | "code-input" | "verifying" | "error"; @@ -82,6 +85,7 @@ export interface AppState { blackjack: BlackjackState; paigow: PaiGowState; craps: CrapsState; + baccarat: BaccaratState; options: GameOptions; optionsCursor: number; auth: AuthState; @@ -284,6 +288,29 @@ export interface CrapsState { skipAnim: boolean; // user pressed Enter to skip animation } +// Baccarat types +export type BaccaratPhase = 'betting' | 'dealing' | 'result'; +export type BaccaratBetType = 'player' | 'banker' | 'tie'; + +export interface BaccaratState { + phase: BaccaratPhase; + shoe: Card[]; + cutCard: number; + numDecks: number; + playerCards: Card[]; + bankerCards: Card[]; + betAmount: number; + betType: BaccaratBetType; + winAmount: number; + resultMessage: string; + cardAnim: BaccaratCardAnim | null; +} + +export interface BaccaratCardAnim { + target: 'player' | 'banker'; + frame: number; +} + // Game module interface — each game implements this for TUI dispatch export interface GameModule { handleKey(state: AppState, key: import("./keybindings").KeyEvent, render: () => void): void; From f0492528221a63dadf5e2811b2813d55f1e3f1b8 Mon Sep 17 00:00:00 2001 From: Dennis Qian Date: Sun, 5 Apr 2026 13:33:20 -0700 Subject: [PATCH 2/2] Add terminal width warning to baccarat renderer Show a yellow warning line when the terminal is narrower than 60 columns. The rest of the UI continues to render normally. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/baccarat/renderer.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/baccarat/renderer.ts b/src/baccarat/renderer.ts index 2c5e27f..1821eb7 100644 --- a/src/baccarat/renderer.ts +++ b/src/baccarat/renderer.ts @@ -1,7 +1,7 @@ import type { AppState, Card, Rank } from "../types"; import * as t from "../theme"; import { baccaratHandValue } from "./game"; -import { renderHeader, renderHotkeySplit, sliceAnsi } from "../shared/render"; +import { renderHeader, renderHotkeySplit, sliceAnsi, widthWarning } from "../shared/render"; import type { HotkeyItem } from "../shared/render"; const CARD_H = 9; @@ -174,6 +174,9 @@ export function renderBaccaratScreen(state: AppState): string[] { // Header lines.push(...renderHeader("BACCARAT", state.balance, width)); + const warn = widthWarning(width, 60); + if (warn) lines.push(warn); + // Shoe bar const totalShoe = bc.numDecks * 52; const leftLabel = " Shoe ";