From ef78a85b9caa0d2b23ec5ec8595780f131a64abf Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sun, 5 Apr 2026 18:48:25 +0200 Subject: [PATCH] feat(games): implement blackjack game plugin with manual start and custom payouts Adds a full blackjack game with dealer AI, hit/stand/double-down actions, and per-player payout multipliers (house-edge model). Extends the game framework with manualStart support and a START_GAME WebSocket message so hosts can begin when ready. Generalizes bet settlement transaction descriptions from chess-specific to game-agnostic. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/src/games/GameServer.ts | 83 ++- api/src/games/RoomManager.ts | 8 +- api/src/games/types.ts | 1 + api/src/server.ts | 2 + panel/public/cards/10_of_clubs.svg | 281 +++++++++++ panel/public/cards/10_of_diamonds.svg | 401 +++++++++++++++ panel/public/cards/10_of_hearts.svg | 407 +++++++++++++++ panel/public/cards/10_of_spades.svg | 230 +++++++++ panel/public/cards/2_of_clubs.svg | 216 ++++++++ panel/public/cards/2_of_diamonds.svg | 318 ++++++++++++ panel/public/cards/2_of_hearts.svg | 308 ++++++++++++ panel/public/cards/2_of_spades.svg | 147 ++++++ panel/public/cards/3_of_clubs.svg | 224 +++++++++ panel/public/cards/3_of_diamonds.svg | 319 ++++++++++++ panel/public/cards/3_of_hearts.svg | 318 ++++++++++++ panel/public/cards/3_of_spades.svg | 154 ++++++ panel/public/cards/4_of_clubs.svg | 230 +++++++++ panel/public/cards/4_of_diamonds.svg | 324 ++++++++++++ panel/public/cards/4_of_hearts.svg | 335 +++++++++++++ panel/public/cards/4_of_spades.svg | 163 ++++++ panel/public/cards/5_of_clubs.svg | 238 +++++++++ panel/public/cards/5_of_diamonds.svg | 333 +++++++++++++ panel/public/cards/5_of_hearts.svg | 336 +++++++++++++ panel/public/cards/5_of_spades.svg | 170 +++++++ panel/public/cards/6_of_clubs.svg | 244 +++++++++ panel/public/cards/6_of_diamonds.svg | 340 +++++++++++++ panel/public/cards/6_of_hearts.svg | 344 +++++++++++++ panel/public/cards/6_of_spades.svg | 177 +++++++ panel/public/cards/7_of_clubs.svg | 252 ++++++++++ panel/public/cards/7_of_diamonds.svg | 349 +++++++++++++ panel/public/cards/7_of_hearts.svg | 356 +++++++++++++ panel/public/cards/7_of_spades.svg | 186 +++++++ panel/public/cards/8_of_clubs.svg | 260 ++++++++++ panel/public/cards/8_of_diamonds.svg | 358 +++++++++++++ panel/public/cards/8_of_hearts.svg | 364 ++++++++++++++ panel/public/cards/8_of_spades.svg | 195 ++++++++ panel/public/cards/9_of_clubs.svg | 254 ++++++++++ panel/public/cards/9_of_diamonds.svg | 367 ++++++++++++++ panel/public/cards/9_of_hearts.svg | 378 ++++++++++++++ panel/public/cards/9_of_spades.svg | 198 ++++++++ panel/public/cards/LICENSE | 21 + panel/public/cards/ace_of_clubs.svg | 258 ++++++++++ panel/public/cards/ace_of_diamonds.svg | 311 ++++++++++++ panel/public/cards/ace_of_hearts.svg | 324 ++++++++++++ panel/public/cards/ace_of_spades.svg | 131 +++++ panel/public/cards/back.png | Bin 0 -> 17515 bytes panel/public/cards/jack_of_clubs.svg | 224 +++++++++ panel/public/cards/jack_of_diamonds.svg | 338 +++++++++++++ panel/public/cards/jack_of_hearts.svg | 330 ++++++++++++ panel/public/cards/jack_of_spades.svg | 336 +++++++++++++ panel/public/cards/king_of_clubs.svg | 254 ++++++++++ panel/public/cards/king_of_diamonds.svg | 351 +++++++++++++ panel/public/cards/king_of_hearts.svg | 337 +++++++++++++ panel/public/cards/king_of_spades.svg | 329 ++++++++++++ panel/public/cards/queen_of_clubs.svg | 250 ++++++++++ panel/public/cards/queen_of_diamonds.svg | 339 +++++++++++++ panel/public/cards/queen_of_hearts.svg | 331 ++++++++++++ panel/public/cards/queen_of_spades.svg | 324 ++++++++++++ panel/src/games/GameLobby.tsx | 62 ++- panel/src/games/GameRoom.tsx | 17 +- panel/src/games/blackjack/BlackjackGame.tsx | 258 ++++++++++ panel/src/games/registry.ts | 15 + panel/src/lib/useGameRoom.ts | 6 +- .../games/blackjack/blackjack.plugin.test.ts | 471 ++++++++++++++++++ shared/games/blackjack/blackjack.plugin.ts | 395 +++++++++++++++ shared/games/blackjack/blackjack.types.ts | 65 +++ shared/games/types.ts | 9 + 67 files changed, 16234 insertions(+), 20 deletions(-) create mode 100644 panel/public/cards/10_of_clubs.svg create mode 100644 panel/public/cards/10_of_diamonds.svg create mode 100644 panel/public/cards/10_of_hearts.svg create mode 100644 panel/public/cards/10_of_spades.svg create mode 100644 panel/public/cards/2_of_clubs.svg create mode 100644 panel/public/cards/2_of_diamonds.svg create mode 100644 panel/public/cards/2_of_hearts.svg create mode 100644 panel/public/cards/2_of_spades.svg create mode 100644 panel/public/cards/3_of_clubs.svg create mode 100644 panel/public/cards/3_of_diamonds.svg create mode 100644 panel/public/cards/3_of_hearts.svg create mode 100644 panel/public/cards/3_of_spades.svg create mode 100644 panel/public/cards/4_of_clubs.svg create mode 100644 panel/public/cards/4_of_diamonds.svg create mode 100644 panel/public/cards/4_of_hearts.svg create mode 100644 panel/public/cards/4_of_spades.svg create mode 100644 panel/public/cards/5_of_clubs.svg create mode 100644 panel/public/cards/5_of_diamonds.svg create mode 100644 panel/public/cards/5_of_hearts.svg create mode 100644 panel/public/cards/5_of_spades.svg create mode 100644 panel/public/cards/6_of_clubs.svg create mode 100644 panel/public/cards/6_of_diamonds.svg create mode 100644 panel/public/cards/6_of_hearts.svg create mode 100644 panel/public/cards/6_of_spades.svg create mode 100644 panel/public/cards/7_of_clubs.svg create mode 100644 panel/public/cards/7_of_diamonds.svg create mode 100644 panel/public/cards/7_of_hearts.svg create mode 100644 panel/public/cards/7_of_spades.svg create mode 100644 panel/public/cards/8_of_clubs.svg create mode 100644 panel/public/cards/8_of_diamonds.svg create mode 100644 panel/public/cards/8_of_hearts.svg create mode 100644 panel/public/cards/8_of_spades.svg create mode 100644 panel/public/cards/9_of_clubs.svg create mode 100644 panel/public/cards/9_of_diamonds.svg create mode 100644 panel/public/cards/9_of_hearts.svg create mode 100644 panel/public/cards/9_of_spades.svg create mode 100644 panel/public/cards/LICENSE create mode 100644 panel/public/cards/ace_of_clubs.svg create mode 100644 panel/public/cards/ace_of_diamonds.svg create mode 100644 panel/public/cards/ace_of_hearts.svg create mode 100644 panel/public/cards/ace_of_spades.svg create mode 100644 panel/public/cards/back.png create mode 100644 panel/public/cards/jack_of_clubs.svg create mode 100644 panel/public/cards/jack_of_diamonds.svg create mode 100644 panel/public/cards/jack_of_hearts.svg create mode 100644 panel/public/cards/jack_of_spades.svg create mode 100644 panel/public/cards/king_of_clubs.svg create mode 100644 panel/public/cards/king_of_diamonds.svg create mode 100644 panel/public/cards/king_of_hearts.svg create mode 100644 panel/public/cards/king_of_spades.svg create mode 100644 panel/public/cards/queen_of_clubs.svg create mode 100644 panel/public/cards/queen_of_diamonds.svg create mode 100644 panel/public/cards/queen_of_hearts.svg create mode 100644 panel/public/cards/queen_of_spades.svg create mode 100644 panel/src/games/blackjack/BlackjackGame.tsx create mode 100644 shared/games/blackjack/blackjack.plugin.test.ts create mode 100644 shared/games/blackjack/blackjack.plugin.ts create mode 100644 shared/games/blackjack/blackjack.types.ts diff --git a/api/src/games/GameServer.ts b/api/src/games/GameServer.ts index 023d502..f146735 100644 --- a/api/src/games/GameServer.ts +++ b/api/src/games/GameServer.ts @@ -4,6 +4,7 @@ import type { PlayerInfo } from "./types"; import { logger } from "@shared/lib/logger"; import { economyService } from "@shared/modules/economy/economy.service"; import { TransactionType } from "@shared/lib/constants"; +import { gameRegistry } from "@shared/games/registry"; import type { Server, ServerWebSocket } from "bun"; export interface WsConnectionData { @@ -59,13 +60,13 @@ export class GameServer { }); }); - this.roomManager.emitter.on("game:ended", ({ roomId, winner, reason }) => { + this.roomManager.emitter.on("game:ended", ({ roomId, winner, reason, payouts }) => { const room = this.roomManager.getRoom(roomId); const betAmount = room?.betAmount ?? 0; // Handle bet payouts asynchronously — broadcast happens after settlement if (betAmount > 0) { - this.settleBets(roomId, winner, betAmount).then((payout) => { + this.settleBets(roomId, winner, betAmount, payouts).then((payout) => { this.publish(`room:${roomId}`, { type: "GAME_ENDED", roomId, @@ -159,6 +160,17 @@ export class GameServer { } // fillRoom with betAmount=0 calls startGame internally } + + // Auto-start if room is immediately full (e.g. maxPlayers: 1) — skip for manualStart games + const plugin = gameRegistry.get(msg.gameType); + const createdRoom = this.roomManager.getRoom(result.roomId); + if (!options.soloMode && plugin && !plugin.manualStart && createdRoom && createdRoom.players.length >= plugin.maxPlayers && createdRoom.status === "waiting") { + if (createdRoom.betAmount > 0) { + this.deductBetsAndStart(result.roomId, createdRoom.betAmount, createdRoom.players, ws); + } else { + this.roomManager.startGame(result.roomId); + } + } break; } @@ -252,6 +264,37 @@ export class GameServer { break; } + case "START_GAME": { + const room = this.roomManager.getRoom(msg.roomId); + if (!room) { + ws.send(JSON.stringify({ type: "ERROR", message: "Room not found" })); + return; + } + if (room.host !== discordId) { + ws.send(JSON.stringify({ type: "ERROR", message: "Only the host can start the game" })); + return; + } + if (room.status !== "waiting") { + ws.send(JSON.stringify({ type: "ERROR", message: "Game is not in waiting state" })); + return; + } + const startPlugin = gameRegistry.get(room.gameSlug); + if (startPlugin && room.players.length < startPlugin.minPlayers) { + ws.send(JSON.stringify({ type: "ERROR", message: `Need at least ${startPlugin.minPlayers} player(s) to start` })); + return; + } + if (room.betAmount > 0) { + this.deductBetsAndStart(msg.roomId, room.betAmount, room.players, ws); + } else { + const startResult = this.roomManager.startGame(msg.roomId); + if (!startResult.ok) { + ws.send(JSON.stringify({ type: "ERROR", message: startResult.error })); + } + } + logger.info("web", `Host ${discordId} started game in room ${msg.roomId}`); + break; + } + case "FILL_ROOM": { if (role !== "admin") { ws.send(JSON.stringify({ type: "ERROR", message: "Only admins can fill a room for solo testing" })); @@ -306,12 +349,13 @@ export class GameServer { const deducted: string[] = []; try { + const gameName = gameRegistry.get(room.gameSlug)?.name ?? room.gameSlug; for (const pid of uniquePlayers) { await economyService.modifyUserBalance( pid, -BigInt(betAmount), TransactionType.GAME_BET, - `Chess wager (room ${roomId.slice(0, 8)})`, + `${gameName} wager (room ${roomId.slice(0, 8)})`, ); deducted.push(pid); } @@ -352,24 +396,43 @@ export class GameServer { roomId: string, winner: string | null, betAmount: number, + payouts?: Record, ): Promise<{ amount: number; refunded?: boolean }> { const room = this.roomManager.getRoom(roomId); const uniquePlayers = [...new Set(room?.players ?? [])]; - const pot = betAmount * uniquePlayers.length; + const gameName = gameRegistry.get(room?.gameSlug ?? "")?.name ?? "Game"; try { + // Custom payouts override default pot logic (used by house-edge games like blackjack) + if (payouts) { + let totalPaid = 0; + for (const [playerId, multiplier] of Object.entries(payouts)) { + if (multiplier <= 0) continue; + const amount = Math.floor(betAmount * multiplier); + await economyService.modifyUserBalance( + playerId, + BigInt(amount), + TransactionType.GAME_WIN, + `${gameName} payout (room ${roomId.slice(0, 8)})`, + ); + totalPaid = Math.max(totalPaid, amount); + } + const isRefund = !winner && totalPaid === betAmount; + return { amount: totalPaid, refunded: isRefund }; + } + + // Default pot logic: winner takes all, draw refunds everyone + const pot = betAmount * uniquePlayers.length; if (winner) { - // Winner takes the pot await economyService.modifyUserBalance( winner, BigInt(pot), TransactionType.GAME_WIN, - `Chess wager won (room ${roomId.slice(0, 8)})`, + `${gameName} wager won (room ${roomId.slice(0, 8)})`, ); return { amount: pot }; } else { - // Draw — refund all players - await this.refundPlayers(uniquePlayers, betAmount, roomId); + await this.refundPlayers(uniquePlayers, betAmount, roomId, gameName); return { amount: betAmount, refunded: true }; } } catch (err) { @@ -378,14 +441,14 @@ export class GameServer { } } - private async refundPlayers(playerIds: string[], betAmount: number, roomId: string): Promise { + private async refundPlayers(playerIds: string[], betAmount: number, roomId: string, gameName = "Game"): Promise { for (const pid of playerIds) { try { await economyService.modifyUserBalance( pid, BigInt(betAmount), TransactionType.GAME_WIN, - `Chess wager refund (room ${roomId.slice(0, 8)})`, + `${gameName} wager refund (room ${roomId.slice(0, 8)})`, ); } catch (err) { logger.error("web", `Failed to refund ${pid} for room ${roomId}: ${err}`); diff --git a/api/src/games/RoomManager.ts b/api/src/games/RoomManager.ts index 7b516eb..23ecf73 100644 --- a/api/src/games/RoomManager.ts +++ b/api/src/games/RoomManager.ts @@ -23,7 +23,7 @@ type RoomEvents = { "player:joined": { roomId: string; playerId: string; username: string; joinedAs: "player" | "spectator" }; "game:started": { roomId: string; spectatorView: unknown; playerViews: Map }; "game:updated": { roomId: string; spectatorView: unknown; playerViews: Map }; - "game:ended": { roomId: string; winner: string | null; reason: string }; + "game:ended": { roomId: string; winner: string | null; reason: string; payouts?: Record }; "player:left": { roomId: string; playerId: string }; "room:deleted": { roomId: string }; "room:list:changed": void; @@ -88,7 +88,7 @@ export class RoomManager { room.players.push(playerId); - if (room.players.length >= plugin.maxPlayers) { + if (room.players.length >= plugin.maxPlayers && !plugin.manualStart) { // Defer start when bets are involved — GameServer handles async deduction first if (room.betAmount > 0) { this.emitter.emit("room:list:changed"); @@ -127,7 +127,7 @@ export class RoomManager { this.emitter.emit("game:updated", { roomId, spectatorView, playerViews }); if (gameOver) { - this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason }); + this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason, payouts: gameOver.payouts }); this.emitter.emit("room:list:changed"); } @@ -152,7 +152,7 @@ export class RoomManager { if (gameOver) { room.status = "finished"; this.scheduleCleanup(roomId, ROOM_CONFIG.FINISHED_CLEANUP_MS); - this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason }); + this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason, payouts: gameOver.payouts }); } } } diff --git a/api/src/games/types.ts b/api/src/games/types.ts index 67449dc..693b311 100644 --- a/api/src/games/types.ts +++ b/api/src/games/types.ts @@ -44,6 +44,7 @@ export const GameWsClientSchema = z.discriminatedUnion("type", [ // Use looseObject for GAME_ACTION to avoid Zod bug with record() z.object({ type: z.literal("GAME_ACTION"), roomId: z.string(), action: z.looseObject({}, { message: "Invalid action" }) }), z.object({ type: z.literal("FILL_ROOM"), roomId: z.string() }), + z.object({ type: z.literal("START_GAME"), roomId: z.string() }), ]); export type GameWsClientMessage = z.infer; diff --git a/api/src/server.ts b/api/src/server.ts index 4ed5706..532ba02 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -21,7 +21,9 @@ import { GameWsClientSchema } from "./games/types"; // Register game plugins import { gameRegistry } from "@shared/games/registry"; import { chessPlugin } from "@shared/games/chess/chess.plugin"; +import { blackjackPlugin } from "@shared/games/blackjack/blackjack.plugin"; gameRegistry.register(chessPlugin); +gameRegistry.register(blackjackPlugin); const WS_CONFIG = { MAX_CONNECTIONS: 200, diff --git a/panel/public/cards/10_of_clubs.svg b/panel/public/cards/10_of_clubs.svg new file mode 100644 index 0000000..2e7788c --- /dev/null +++ b/panel/public/cards/10_of_clubs.svg @@ -0,0 +1,281 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 +0 +1 +0 + diff --git a/panel/public/cards/10_of_diamonds.svg b/panel/public/cards/10_of_diamonds.svg new file mode 100644 index 0000000..3d0b1c0 --- /dev/null +++ b/panel/public/cards/10_of_diamonds.svg @@ -0,0 +1,401 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 + + + + + + + + + + + + + + + + + + + + + + + +0 +1 +0 + diff --git a/panel/public/cards/10_of_hearts.svg b/panel/public/cards/10_of_hearts.svg new file mode 100644 index 0000000..c57575e --- /dev/null +++ b/panel/public/cards/10_of_hearts.svg @@ -0,0 +1,407 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 + + + + + + + + + + + + + + + + + + + + + +0 +1 +0 + diff --git a/panel/public/cards/10_of_spades.svg b/panel/public/cards/10_of_spades.svg new file mode 100644 index 0000000..a14767c --- /dev/null +++ b/panel/public/cards/10_of_spades.svg @@ -0,0 +1,230 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 + + + + + + + + +0 +1 +0 + diff --git a/panel/public/cards/2_of_clubs.svg b/panel/public/cards/2_of_clubs.svg new file mode 100644 index 0000000..0334dec --- /dev/null +++ b/panel/public/cards/2_of_clubs.svg @@ -0,0 +1,216 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +2 + + + + + +2 + + + + diff --git a/panel/public/cards/2_of_diamonds.svg b/panel/public/cards/2_of_diamonds.svg new file mode 100644 index 0000000..4956234 --- /dev/null +++ b/panel/public/cards/2_of_diamonds.svg @@ -0,0 +1,318 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2 + + + + + + +2 + + + + + + diff --git a/panel/public/cards/2_of_hearts.svg b/panel/public/cards/2_of_hearts.svg new file mode 100644 index 0000000..f6c9540 --- /dev/null +++ b/panel/public/cards/2_of_hearts.svg @@ -0,0 +1,308 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2 + + + + + + +2 + + + + + + diff --git a/panel/public/cards/2_of_spades.svg b/panel/public/cards/2_of_spades.svg new file mode 100644 index 0000000..759abf6 --- /dev/null +++ b/panel/public/cards/2_of_spades.svg @@ -0,0 +1,147 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +2 + + + +2 + + diff --git a/panel/public/cards/3_of_clubs.svg b/panel/public/cards/3_of_clubs.svg new file mode 100644 index 0000000..0fb95b3 --- /dev/null +++ b/panel/public/cards/3_of_clubs.svg @@ -0,0 +1,224 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +3 + + + + + + +3 + + + + + diff --git a/panel/public/cards/3_of_diamonds.svg b/panel/public/cards/3_of_diamonds.svg new file mode 100644 index 0000000..a562f1f --- /dev/null +++ b/panel/public/cards/3_of_diamonds.svg @@ -0,0 +1,319 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3 + + + + + + + + +3 + + + + + + + + diff --git a/panel/public/cards/3_of_hearts.svg b/panel/public/cards/3_of_hearts.svg new file mode 100644 index 0000000..aecd1ff --- /dev/null +++ b/panel/public/cards/3_of_hearts.svg @@ -0,0 +1,318 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3 + + + + + + + +3 + + + + + + + diff --git a/panel/public/cards/3_of_spades.svg b/panel/public/cards/3_of_spades.svg new file mode 100644 index 0000000..8963195 --- /dev/null +++ b/panel/public/cards/3_of_spades.svg @@ -0,0 +1,154 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +3 + + + +3 + + diff --git a/panel/public/cards/4_of_clubs.svg b/panel/public/cards/4_of_clubs.svg new file mode 100644 index 0000000..70f8904 --- /dev/null +++ b/panel/public/cards/4_of_clubs.svg @@ -0,0 +1,230 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +4 + + + + + + +4 + + + + + diff --git a/panel/public/cards/4_of_diamonds.svg b/panel/public/cards/4_of_diamonds.svg new file mode 100644 index 0000000..8677737 --- /dev/null +++ b/panel/public/cards/4_of_diamonds.svg @@ -0,0 +1,324 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +4 + + + + + + + +4 + + + + + + + diff --git a/panel/public/cards/4_of_hearts.svg b/panel/public/cards/4_of_hearts.svg new file mode 100644 index 0000000..9b7f0a7 --- /dev/null +++ b/panel/public/cards/4_of_hearts.svg @@ -0,0 +1,335 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +4 + + + + + + + +4 + + + + + + + diff --git a/panel/public/cards/4_of_spades.svg b/panel/public/cards/4_of_spades.svg new file mode 100644 index 0000000..01e27e4 --- /dev/null +++ b/panel/public/cards/4_of_spades.svg @@ -0,0 +1,163 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +4 + + + + +4 + + + diff --git a/panel/public/cards/5_of_clubs.svg b/panel/public/cards/5_of_clubs.svg new file mode 100644 index 0000000..864d2f2 --- /dev/null +++ b/panel/public/cards/5_of_clubs.svg @@ -0,0 +1,238 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +5 + + + + + + + +5 + + + + + + diff --git a/panel/public/cards/5_of_diamonds.svg b/panel/public/cards/5_of_diamonds.svg new file mode 100644 index 0000000..a8cb2b5 --- /dev/null +++ b/panel/public/cards/5_of_diamonds.svg @@ -0,0 +1,333 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +5 + + + + + + + + +5 + + + + + + + + diff --git a/panel/public/cards/5_of_hearts.svg b/panel/public/cards/5_of_hearts.svg new file mode 100644 index 0000000..af7415a --- /dev/null +++ b/panel/public/cards/5_of_hearts.svg @@ -0,0 +1,336 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +5 + + + + + + + + +5 + + + + + + + + diff --git a/panel/public/cards/5_of_spades.svg b/panel/public/cards/5_of_spades.svg new file mode 100644 index 0000000..d9492ba --- /dev/null +++ b/panel/public/cards/5_of_spades.svg @@ -0,0 +1,170 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +5 + + + + +5 + + + diff --git a/panel/public/cards/6_of_clubs.svg b/panel/public/cards/6_of_clubs.svg new file mode 100644 index 0000000..306754e --- /dev/null +++ b/panel/public/cards/6_of_clubs.svg @@ -0,0 +1,244 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +6 + + + + + + + +6 + + + + + + diff --git a/panel/public/cards/6_of_diamonds.svg b/panel/public/cards/6_of_diamonds.svg new file mode 100644 index 0000000..6f12a53 --- /dev/null +++ b/panel/public/cards/6_of_diamonds.svg @@ -0,0 +1,340 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +6 + + + + + + + + +6 + + + + + + + + diff --git a/panel/public/cards/6_of_hearts.svg b/panel/public/cards/6_of_hearts.svg new file mode 100644 index 0000000..9c319ad --- /dev/null +++ b/panel/public/cards/6_of_hearts.svg @@ -0,0 +1,344 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +6 + + + + + + + + +6 + + + + + + + + diff --git a/panel/public/cards/6_of_spades.svg b/panel/public/cards/6_of_spades.svg new file mode 100644 index 0000000..83e8feb --- /dev/null +++ b/panel/public/cards/6_of_spades.svg @@ -0,0 +1,177 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +6 + + + + +6 + + + diff --git a/panel/public/cards/7_of_clubs.svg b/panel/public/cards/7_of_clubs.svg new file mode 100644 index 0000000..11329b1 --- /dev/null +++ b/panel/public/cards/7_of_clubs.svg @@ -0,0 +1,252 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +7 + + + + + + + + +7 + + + + + + + diff --git a/panel/public/cards/7_of_diamonds.svg b/panel/public/cards/7_of_diamonds.svg new file mode 100644 index 0000000..d8483a7 --- /dev/null +++ b/panel/public/cards/7_of_diamonds.svg @@ -0,0 +1,349 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +7 + + + + + + + + + +7 + + + + + + + + + diff --git a/panel/public/cards/7_of_hearts.svg b/panel/public/cards/7_of_hearts.svg new file mode 100644 index 0000000..8d5fd98 --- /dev/null +++ b/panel/public/cards/7_of_hearts.svg @@ -0,0 +1,356 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +7 + + + + + + + + + + +7 + + + + + + + + + + diff --git a/panel/public/cards/7_of_spades.svg b/panel/public/cards/7_of_spades.svg new file mode 100644 index 0000000..3146b44 --- /dev/null +++ b/panel/public/cards/7_of_spades.svg @@ -0,0 +1,186 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +7 + + + + + +7 + + + + diff --git a/panel/public/cards/8_of_clubs.svg b/panel/public/cards/8_of_clubs.svg new file mode 100644 index 0000000..28ae6fb --- /dev/null +++ b/panel/public/cards/8_of_clubs.svg @@ -0,0 +1,260 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +8 + + + + + + + + + +8 + + + + + + + + diff --git a/panel/public/cards/8_of_diamonds.svg b/panel/public/cards/8_of_diamonds.svg new file mode 100644 index 0000000..d6d1b0e --- /dev/null +++ b/panel/public/cards/8_of_diamonds.svg @@ -0,0 +1,358 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +8 + + + + + + + + + + +8 + + + + + + + + + + diff --git a/panel/public/cards/8_of_hearts.svg b/panel/public/cards/8_of_hearts.svg new file mode 100644 index 0000000..ac8d5ba --- /dev/null +++ b/panel/public/cards/8_of_hearts.svg @@ -0,0 +1,364 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +8 + + + + + + + + + + +8 + + + + + + + + + + diff --git a/panel/public/cards/8_of_spades.svg b/panel/public/cards/8_of_spades.svg new file mode 100644 index 0000000..0e5bd89 --- /dev/null +++ b/panel/public/cards/8_of_spades.svg @@ -0,0 +1,195 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +8 + + + + + + +8 + + + + + diff --git a/panel/public/cards/9_of_clubs.svg b/panel/public/cards/9_of_clubs.svg new file mode 100644 index 0000000..85cde35 --- /dev/null +++ b/panel/public/cards/9_of_clubs.svg @@ -0,0 +1,254 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +9 + + + + + + + + + + + + +9 + + + + + + + + + + + diff --git a/panel/public/cards/9_of_diamonds.svg b/panel/public/cards/9_of_diamonds.svg new file mode 100644 index 0000000..05292f2 --- /dev/null +++ b/panel/public/cards/9_of_diamonds.svg @@ -0,0 +1,367 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +9 + + + + + + + + + + + +9 + + + + + + + + + + + diff --git a/panel/public/cards/9_of_hearts.svg b/panel/public/cards/9_of_hearts.svg new file mode 100644 index 0000000..0f6a211 --- /dev/null +++ b/panel/public/cards/9_of_hearts.svg @@ -0,0 +1,378 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +9 + + + +9 + diff --git a/panel/public/cards/9_of_spades.svg b/panel/public/cards/9_of_spades.svg new file mode 100644 index 0000000..2359db5 --- /dev/null +++ b/panel/public/cards/9_of_spades.svg @@ -0,0 +1,198 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +9 + + + + +9 + + + diff --git a/panel/public/cards/LICENSE b/panel/public/cards/LICENSE new file mode 100644 index 0000000..4d2ff44 --- /dev/null +++ b/panel/public/cards/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Howard Yeh (https://github.com/hayeah) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/panel/public/cards/ace_of_clubs.svg b/panel/public/cards/ace_of_clubs.svg new file mode 100644 index 0000000..be74fe0 --- /dev/null +++ b/panel/public/cards/ace_of_clubs.svg @@ -0,0 +1,258 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +A + + +A + diff --git a/panel/public/cards/ace_of_diamonds.svg b/panel/public/cards/ace_of_diamonds.svg new file mode 100644 index 0000000..e5636a4 --- /dev/null +++ b/panel/public/cards/ace_of_diamonds.svg @@ -0,0 +1,311 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +A + + +A + + diff --git a/panel/public/cards/ace_of_hearts.svg b/panel/public/cards/ace_of_hearts.svg new file mode 100644 index 0000000..24702e2 --- /dev/null +++ b/panel/public/cards/ace_of_hearts.svg @@ -0,0 +1,324 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +A + +A + diff --git a/panel/public/cards/ace_of_spades.svg b/panel/public/cards/ace_of_spades.svg new file mode 100644 index 0000000..db32c06 --- /dev/null +++ b/panel/public/cards/ace_of_spades.svg @@ -0,0 +1,131 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +A +A + diff --git a/panel/public/cards/back.png b/panel/public/cards/back.png new file mode 100644 index 0000000000000000000000000000000000000000..c2b1c67c1748d32ba769ee7b16adb52b4358b198 GIT binary patch literal 17515 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 z≷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 + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +J + + + +J + + + + diff --git a/panel/public/cards/jack_of_diamonds.svg b/panel/public/cards/jack_of_diamonds.svg new file mode 100644 index 0000000..d531541 --- /dev/null +++ b/panel/public/cards/jack_of_diamonds.svg @@ -0,0 +1,338 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +J + + + + + + + + + + + + + + + + +J + + + + diff --git a/panel/public/cards/jack_of_hearts.svg b/panel/public/cards/jack_of_hearts.svg new file mode 100644 index 0000000..dc95ce1 --- /dev/null +++ b/panel/public/cards/jack_of_hearts.svg @@ -0,0 +1,330 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +J + + + + + + + + + + + + +J + + + + diff --git a/panel/public/cards/jack_of_spades.svg b/panel/public/cards/jack_of_spades.svg new file mode 100644 index 0000000..281113f --- /dev/null +++ b/panel/public/cards/jack_of_spades.svg @@ -0,0 +1,336 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +J + + + + + + +J + + + + diff --git a/panel/public/cards/king_of_clubs.svg b/panel/public/cards/king_of_clubs.svg new file mode 100644 index 0000000..fe6e676 --- /dev/null +++ b/panel/public/cards/king_of_clubs.svg @@ -0,0 +1,254 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +K + + + + + +K + + + + diff --git a/panel/public/cards/king_of_diamonds.svg b/panel/public/cards/king_of_diamonds.svg new file mode 100644 index 0000000..f983c6a --- /dev/null +++ b/panel/public/cards/king_of_diamonds.svg @@ -0,0 +1,351 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +K + + + + + + + + + + + + + + + + + + +K + + + + diff --git a/panel/public/cards/king_of_hearts.svg b/panel/public/cards/king_of_hearts.svg new file mode 100644 index 0000000..7250745 --- /dev/null +++ b/panel/public/cards/king_of_hearts.svg @@ -0,0 +1,337 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +K + + + + + + + + + + + + + + +K + + + + diff --git a/panel/public/cards/king_of_spades.svg b/panel/public/cards/king_of_spades.svg new file mode 100644 index 0000000..ac4338d --- /dev/null +++ b/panel/public/cards/king_of_spades.svg @@ -0,0 +1,329 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +K + + + + + + + + +K + + + + diff --git a/panel/public/cards/queen_of_clubs.svg b/panel/public/cards/queen_of_clubs.svg new file mode 100644 index 0000000..6a636f7 --- /dev/null +++ b/panel/public/cards/queen_of_clubs.svg @@ -0,0 +1,250 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Q + + + + + +Q + + + + diff --git a/panel/public/cards/queen_of_diamonds.svg b/panel/public/cards/queen_of_diamonds.svg new file mode 100644 index 0000000..76b62db --- /dev/null +++ b/panel/public/cards/queen_of_diamonds.svg @@ -0,0 +1,339 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Q + + + + + + + + + + + + + + + + + + +Q + + + + diff --git a/panel/public/cards/queen_of_hearts.svg b/panel/public/cards/queen_of_hearts.svg new file mode 100644 index 0000000..90ede78 --- /dev/null +++ b/panel/public/cards/queen_of_hearts.svg @@ -0,0 +1,331 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Q + + + + + + + + + + + + + + + +Q + + + + + diff --git a/panel/public/cards/queen_of_spades.svg b/panel/public/cards/queen_of_spades.svg new file mode 100644 index 0000000..8dc5540 --- /dev/null +++ b/panel/public/cards/queen_of_spades.svg @@ -0,0 +1,324 @@ + + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Q + + + + + + + +Q + + + diff --git a/panel/src/games/GameLobby.tsx b/panel/src/games/GameLobby.tsx index 28927bb..a4ea7a3 100644 --- a/panel/src/games/GameLobby.tsx +++ b/panel/src/games/GameLobby.tsx @@ -68,6 +68,11 @@ export function GameLobby() { setBetAmount(0); return; } + if (gameSlug === "blackjack") { + setConfigGame("blackjack"); + setBetAmount(0); + return; + } send({ type: "CREATE_ROOM", gameType: gameSlug }); setShowCreate(false); } @@ -88,6 +93,19 @@ export function GameLobby() { setBetAmount(0); } + function createBlackjackRoom() { + send({ + type: "CREATE_ROOM", + gameType: "blackjack", + options: { + ...(betAmount > 0 && { betAmount }), + }, + }); + setShowCreate(false); + setConfigGame(null); + setBetAmount(0); + } + return (
@@ -184,7 +202,49 @@ export function GameLobby() { {showCreate && (
{ setShowCreate(false); setConfigGame(null); }}>
e.stopPropagation()}> - {configGame === "chess" ? ( + {configGame === "blackjack" ? ( + <> + +

{"\uD83C\uDCA1"} Blackjack

+

Beat the dealer — closest to 21 wins

+ + {/* Bet Amount */} +
+
+ Wager (AU) +
+
+ {[0, 10, 25, 50, 100, 250, 500].map(amt => ( + + ))} +
+
+ + + + ) : configGame === "chess" ? ( <> + )} {role === "admin" && players.length < plugin.maxPlayers && ( + +
+ )} + + {/* Waiting for other player */} + {!isSpectator && !isResolved && playerView && !playerView.canAct && ( +
+ {view.activePlayerId + ? `Waiting for ${getPlayerName(view.activePlayerId)}...` + : "Waiting..."} +
+ )} + + {/* Spectator indicator */} + {isSpectator && !isResolved && ( +
+ {view.activePlayerId + ? `${getPlayerName(view.activePlayerId)}'s turn` + : "Watching..."} +
+ )} +
+ ); +} diff --git a/panel/src/games/registry.ts b/panel/src/games/registry.ts index 2bb975f..4210b75 100644 --- a/panel/src/games/registry.ts +++ b/panel/src/games/registry.ts @@ -12,7 +12,10 @@ export interface GameUIPlugin { slug: string; name: string; icon: string; + minPlayers: number; maxPlayers: number; + /** If true, the host must manually start the game. */ + manualStart?: boolean; component: ComponentType; } @@ -36,11 +39,23 @@ export const gameUIRegistry = { // ── Register game UI plugins ── import { ChessGame } from "./chess/ChessGame"; +import { BlackjackGame } from "./blackjack/BlackjackGame"; gameUIRegistry.register({ slug: "chess", name: "Chess", icon: "\u265A", + minPlayers: 2, maxPlayers: 2, component: ChessGame, }); + +gameUIRegistry.register({ + slug: "blackjack", + name: "Blackjack", + icon: "\uD83C\uDCA1", + minPlayers: 1, + maxPlayers: 6, + manualStart: true, + component: BlackjackGame, +}); diff --git a/panel/src/lib/useGameRoom.ts b/panel/src/lib/useGameRoom.ts index 2997a27..bc5c606 100644 --- a/panel/src/lib/useGameRoom.ts +++ b/panel/src/lib/useGameRoom.ts @@ -171,5 +171,9 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe send({ type: "FILL_ROOM", roomId }); }, [roomId, send]); - return { ...state, sendAction, leaveRoom, rejoin, fillRoom }; + const startGame = useCallback(() => { + send({ type: "START_GAME", roomId }); + }, [roomId, send]); + + return { ...state, sendAction, leaveRoom, rejoin, fillRoom, startGame }; } diff --git a/shared/games/blackjack/blackjack.plugin.test.ts b/shared/games/blackjack/blackjack.plugin.test.ts new file mode 100644 index 0000000..8ec5d2b --- /dev/null +++ b/shared/games/blackjack/blackjack.plugin.test.ts @@ -0,0 +1,471 @@ +import { describe, it, expect } from "bun:test"; +import { blackjackPlugin } from "./blackjack.plugin"; +import { handValue } from "./blackjack.plugin"; +import type { BlackjackState, BlackjackPlayerView, BlackjackSpectatorView, Card, PlayerHand } from "./blackjack.types"; + +// ── Helpers ── + +function makeCard(rank: string, suit: string = "spades"): Card { + return { rank, suit } as Card; +} + +/** Create a rigged multiplayer state for deterministic testing. */ +function riggedState(overrides: Partial & { + hands: Record; + dealerHand: Card[]; + turnOrder: string[]; +}): BlackjackState { + return { + deck: overrides.deck ?? [makeCard("5"), makeCard("6"), makeCard("7"), makeCard("8"), makeCard("9"), makeCard("10")], + dealerHand: overrides.dealerHand, + hands: overrides.hands, + turnOrder: overrides.turnOrder, + activePlayerIndex: overrides.activePlayerIndex ?? 0, + phase: overrides.phase ?? "player_turns", + }; +} + +function playingHand(cards: Card[]): PlayerHand { + return { cards, status: "playing", result: null, resultReason: null }; +} + +function blackjackHand(cards: Card[]): PlayerHand { + return { cards, status: "blackjack", result: null, resultReason: null }; +} + +// ── handValue tests ── + +describe("handValue", () => { + it("calculates simple hand values", () => { + expect(handValue([makeCard("5"), makeCard("10")])).toBe(15); + expect(handValue([makeCard("K"), makeCard("Q")])).toBe(20); + }); + + it("treats ace as 11 when possible", () => { + expect(handValue([makeCard("A"), makeCard("10")])).toBe(21); + }); + + it("treats ace as 1 when 11 would bust", () => { + expect(handValue([makeCard("A"), makeCard("10"), makeCard("5")])).toBe(16); + }); + + it("handles multiple aces", () => { + expect(handValue([makeCard("A"), makeCard("A")])).toBe(12); + expect(handValue([makeCard("A"), makeCard("A"), makeCard("9")])).toBe(21); + }); +}); + +// ── Plugin metadata ── + +describe("blackjackPlugin metadata", () => { + it("has correct slug and name", () => { + expect(blackjackPlugin.slug).toBe("blackjack"); + expect(blackjackPlugin.name).toBe("Blackjack"); + }); + + it("supports 1-6 players with manual start", () => { + expect(blackjackPlugin.minPlayers).toBe(1); + expect(blackjackPlugin.maxPlayers).toBe(6); + expect(blackjackPlugin.manualStart).toBe(true); + }); +}); + +// ── createInitialState ── + +describe("createInitialState", () => { + it("deals 2 cards to each player and dealer", () => { + const state = blackjackPlugin.createInitialState(["p1", "p2", "p3"]); + expect(state.hands["p1"]!.cards.length).toBe(2); + expect(state.hands["p2"]!.cards.length).toBe(2); + expect(state.hands["p3"]!.cards.length).toBe(2); + expect(state.dealerHand.length).toBe(2); + expect(state.turnOrder).toEqual(["p1", "p2", "p3"]); + }); + + it("removes dealt cards from deck", () => { + const state = blackjackPlugin.createInitialState(["p1", "p2"]); + // 52 - (2*2 players + 2 dealer) = 46 + expect(state.deck.length).toBe(46); + }); + + it("works with a single player", () => { + const state = blackjackPlugin.createInitialState(["solo"]); + expect(state.hands["solo"]!.cards.length).toBe(2); + expect(state.turnOrder).toEqual(["solo"]); + }); +}); + +// ── Turn order ── + +describe("turn order", () => { + it("active player is the first non-blackjack player", () => { + const state = riggedState({ + hands: { + "p1": blackjackHand([makeCard("A"), makeCard("K")]), + "p2": playingHand([makeCard("5"), makeCard("6")]), + "p3": playingHand([makeCard("7"), makeCard("8")]), + }, + dealerHand: [makeCard("9"), makeCard("10")], + turnOrder: ["p1", "p2", "p3"], + activePlayerIndex: 1, // p1 skipped (blackjack) + }); + + // p1 is blackjack so active should be p2 (index 1) + const view = blackjackPlugin.getPlayerView(state, "p2") as BlackjackPlayerView; + expect(view.activePlayerId).toBe("p2"); + expect(view.canAct).toBe(true); + }); + + it("advances to next player on stand", () => { + const state = riggedState({ + hands: { + "p1": playingHand([makeCard("K"), makeCard("9")]), + "p2": playingHand([makeCard("5"), makeCard("6")]), + }, + dealerHand: [makeCard("7"), makeCard("8")], + turnOrder: ["p1", "p2"], + activePlayerIndex: 0, + }); + + const result = blackjackPlugin.handleAction(state, { type: "stand" }, "p1"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.state.activePlayerIndex).toBe(1); + expect(result.state.phase).toBe("player_turns"); + }); + + it("rejects action from non-active player", () => { + const state = riggedState({ + hands: { + "p1": playingHand([makeCard("K"), makeCard("9")]), + "p2": playingHand([makeCard("5"), makeCard("6")]), + }, + dealerHand: [makeCard("7"), makeCard("8")], + turnOrder: ["p1", "p2"], + activePlayerIndex: 0, + }); + + const result = blackjackPlugin.handleAction(state, { type: "hit" }, "p2"); + expect(result.ok).toBe(false); + }); +}); + +// ── handleAction: hit ── + +describe("handleAction — hit", () => { + it("adds a card to active player's hand", () => { + const state = riggedState({ + hands: { + "p1": playingHand([makeCard("5"), makeCard("6")]), + }, + dealerHand: [makeCard("K"), makeCard("7")], + turnOrder: ["p1"], + activePlayerIndex: 0, + }); + + const result = blackjackPlugin.handleAction(state, { type: "hit" }, "p1"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.state.hands["p1"]!.cards.length).toBe(3); + }); + + it("busts player and advances turn", () => { + const state = riggedState({ + hands: { + "p1": playingHand([makeCard("K"), makeCard("Q")]), + "p2": playingHand([makeCard("5"), makeCard("6")]), + }, + dealerHand: [makeCard("7"), makeCard("8")], + turnOrder: ["p1", "p2"], + activePlayerIndex: 0, + deck: [makeCard("5")], // K+Q+5 = 25 → bust + }); + + const result = blackjackPlugin.handleAction(state, { type: "hit" }, "p1"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.state.hands["p1"]!.status).toBe("bust"); + expect(result.state.hands["p1"]!.result).toBe("lose"); + expect(result.state.activePlayerIndex).toBe(1); // advanced to p2 + }); + + it("auto-stands on 21 and advances turn", () => { + const state = riggedState({ + hands: { + "p1": playingHand([makeCard("6"), makeCard("5")]), + "p2": playingHand([makeCard("7"), makeCard("8")]), + }, + dealerHand: [makeCard("9"), makeCard("10")], + turnOrder: ["p1", "p2"], + activePlayerIndex: 0, + deck: [makeCard("10")], // 6+5+10 = 21 + }); + + const result = blackjackPlugin.handleAction(state, { type: "hit" }, "p1"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.state.hands["p1"]!.status).toBe("stood"); + expect(result.state.activePlayerIndex).toBe(1); + }); +}); + +// ── handleAction: stand ── + +describe("handleAction — stand", () => { + it("resolves game when last player stands", () => { + const state = riggedState({ + hands: { + "p1": playingHand([makeCard("K"), makeCard("9")]), + }, + dealerHand: [makeCard("7"), makeCard("8")], + turnOrder: ["p1"], + activePlayerIndex: 0, + deck: [makeCard("2")], // dealer: 15 + 2 = 17 → stands + }); + + const result = blackjackPlugin.handleAction(state, { type: "stand" }, "p1"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.state.phase).toBe("resolved"); + expect(result.state.hands["p1"]!.result).toBe("win"); + }); + + it("dealer busts — all standing players win", () => { + const state = riggedState({ + hands: { + "p1": playingHand([makeCard("K"), makeCard("8")]), + "p2": { ...playingHand([makeCard("7"), makeCard("9")]), status: "stood" as const }, + }, + dealerHand: [makeCard("6"), makeCard("9")], + turnOrder: ["p1", "p2"], + activePlayerIndex: 0, + deck: [makeCard("K")], // dealer: 6+9+K = 25 → bust + }); + + const result = blackjackPlugin.handleAction(state, { type: "stand" }, "p1"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.state.phase).toBe("resolved"); + expect(result.state.hands["p1"]!.result).toBe("win"); + expect(result.state.hands["p2"]!.result).toBe("win"); + }); + + it("mixed results — one wins, one loses, one pushes", () => { + const state = riggedState({ + hands: { + "p1": { ...playingHand([makeCard("K"), makeCard("9")]), status: "stood" as const }, // 19 + "p2": { ...playingHand([makeCard("7"), makeCard("8")]), status: "stood" as const }, // 15 + "p3": playingHand([makeCard("Q"), makeCard("8")]), // 18 — active + }, + dealerHand: [makeCard("K"), makeCard("8")], // 18 → stands + turnOrder: ["p1", "p2", "p3"], + activePlayerIndex: 2, + }); + + const result = blackjackPlugin.handleAction(state, { type: "stand" }, "p3"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.state.hands["p1"]!.result).toBe("win"); // 19 > 18 + expect(result.state.hands["p2"]!.result).toBe("lose"); // 15 < 18 + expect(result.state.hands["p3"]!.result).toBe("push"); // 18 = 18 + }); +}); + +// ── getPlayerView ── + +describe("getPlayerView", () => { + it("hides dealer hole card during player turns", () => { + const state = riggedState({ + hands: { "p1": playingHand([makeCard("K"), makeCard("5")]) }, + dealerHand: [makeCard("7"), makeCard("Q")], + turnOrder: ["p1"], + }); + + const view = blackjackPlugin.getPlayerView(state, "p1") as BlackjackPlayerView; + expect(view.dealerHand[0]!.rank).toBe("7"); + expect(view.dealerHand[1]!.rank as string).toBe("?"); + expect(view.dealerFullValue).toBeNull(); + }); + + it("reveals dealer hand when resolved", () => { + const state = riggedState({ + hands: { "p1": { ...playingHand([makeCard("K"), makeCard("5")]), status: "stood" as const, result: "lose" as const, resultReason: "Dealer wins" } }, + dealerHand: [makeCard("7"), makeCard("Q")], + turnOrder: ["p1"], + phase: "resolved", + activePlayerIndex: -1, + }); + + const view = blackjackPlugin.getPlayerView(state, "p1") as BlackjackPlayerView; + expect(view.dealerHand[1]!.rank).toBe("Q"); + expect(view.dealerFullValue).toBe(17); + }); + + it("canAct is true only for active player", () => { + const state = riggedState({ + hands: { + "p1": playingHand([makeCard("5"), makeCard("6")]), + "p2": playingHand([makeCard("7"), makeCard("8")]), + }, + dealerHand: [makeCard("9"), makeCard("10")], + turnOrder: ["p1", "p2"], + activePlayerIndex: 0, + }); + + const view1 = blackjackPlugin.getPlayerView(state, "p1") as BlackjackPlayerView; + expect(view1.canAct).toBe(true); + expect(view1.myPlayerId).toBe("p1"); + + const view2 = blackjackPlugin.getPlayerView(state, "p2") as BlackjackPlayerView; + expect(view2.canAct).toBe(false); + }); + + it("includes all player hands in view", () => { + const state = riggedState({ + hands: { + "p1": playingHand([makeCard("5"), makeCard("6")]), + "p2": playingHand([makeCard("7"), makeCard("8")]), + }, + dealerHand: [makeCard("9"), makeCard("10")], + turnOrder: ["p1", "p2"], + }); + + const view = blackjackPlugin.getPlayerView(state, "p1") as BlackjackPlayerView; + expect(Object.keys(view.hands).length).toBe(2); + expect(view.hands["p1"]!.value).toBe(11); + expect(view.hands["p2"]!.value).toBe(15); + }); +}); + +// ── getSpectatorView ── + +describe("getSpectatorView", () => { + it("hides dealer hole card during player turns", () => { + const state = riggedState({ + hands: { "p1": playingHand([makeCard("K"), makeCard("5")]) }, + dealerHand: [makeCard("7"), makeCard("Q")], + turnOrder: ["p1"], + }); + + const view = blackjackPlugin.getSpectatorView(state) as BlackjackSpectatorView; + expect(view.dealerHand[1]!.rank as string).toBe("?"); + }); + + it("shows all player hands", () => { + const state = riggedState({ + hands: { + "p1": playingHand([makeCard("K"), makeCard("5")]), + "p2": playingHand([makeCard("7"), makeCard("8")]), + }, + dealerHand: [makeCard("9"), makeCard("10")], + turnOrder: ["p1", "p2"], + }); + + const view = blackjackPlugin.getSpectatorView(state) as BlackjackSpectatorView; + expect(Object.keys(view.hands).length).toBe(2); + expect(view.turnOrder).toEqual(["p1", "p2"]); + }); +}); + +// ── isGameOver ── + +describe("isGameOver", () => { + it("returns null when game is in progress", () => { + const state = riggedState({ + hands: { "p1": playingHand([makeCard("5"), makeCard("6")]) }, + dealerHand: [makeCard("7"), makeCard("8")], + turnOrder: ["p1"], + }); + expect(blackjackPlugin.isGameOver!(state)).toBeNull(); + }); + + it("returns per-player payouts for mixed results", () => { + const state = riggedState({ + hands: { + "p1": { cards: [makeCard("A"), makeCard("K")], status: "blackjack" as const, result: "blackjack" as const, resultReason: "Blackjack!" }, + "p2": { cards: [makeCard("K"), makeCard("9")], status: "stood" as const, result: "win" as const, resultReason: "Higher hand" }, + "p3": { cards: [makeCard("7"), makeCard("8")], status: "stood" as const, result: "lose" as const, resultReason: "Lower hand" }, + "p4": { cards: [makeCard("K"), makeCard("8")], status: "stood" as const, result: "push" as const, resultReason: "Push" }, + }, + dealerHand: [makeCard("K"), makeCard("8")], + turnOrder: ["p1", "p2", "p3", "p4"], + phase: "resolved", + activePlayerIndex: -1, + }); + + const result = blackjackPlugin.isGameOver!(state)!; + expect(result).not.toBeNull(); + expect(result.payouts?.["p1"]).toBe(2.5); // blackjack + expect(result.payouts?.["p2"]).toBe(2); // win + expect(result.payouts?.["p3"]).toBeUndefined(); // loss + expect(result.payouts?.["p4"]).toBe(1); // push + }); + + it("generates a summary reason", () => { + const state = riggedState({ + hands: { + "p1": { cards: [makeCard("K"), makeCard("9")], status: "stood" as const, result: "win" as const, resultReason: "Win" }, + "p2": { cards: [makeCard("7"), makeCard("8")], status: "bust" as const, result: "lose" as const, resultReason: "Bust" }, + }, + dealerHand: [makeCard("K"), makeCard("8")], + turnOrder: ["p1", "p2"], + phase: "resolved", + activePlayerIndex: -1, + }); + + const result = blackjackPlugin.isGameOver!(state)!; + expect(result.reason).toContain("1 win"); + expect(result.reason).toContain("1 loss"); + }); +}); + +// ── onPlayerDisconnect ── + +describe("onPlayerDisconnect", () => { + it("marks disconnected player as bust and advances turn", () => { + const state = riggedState({ + hands: { + "p1": playingHand([makeCard("5"), makeCard("6")]), + "p2": playingHand([makeCard("7"), makeCard("8")]), + }, + dealerHand: [makeCard("9"), makeCard("10")], + turnOrder: ["p1", "p2"], + activePlayerIndex: 0, + }); + + const newState = blackjackPlugin.onPlayerDisconnect!(state, "p1"); + expect(newState.hands["p1"]!.status).toBe("bust"); + expect(newState.hands["p1"]!.result).toBe("lose"); + expect(newState.activePlayerIndex).toBe(1); // advanced to p2 + }); + + it("resolves game if disconnected player was last active", () => { + const state = riggedState({ + hands: { + "p1": playingHand([makeCard("5"), makeCard("6")]), + }, + dealerHand: [makeCard("9"), makeCard("10")], + turnOrder: ["p1"], + activePlayerIndex: 0, + }); + + const newState = blackjackPlugin.onPlayerDisconnect!(state, "p1"); + expect(newState.phase).toBe("resolved"); + expect(newState.hands["p1"]!.result).toBe("lose"); + }); + + it("does nothing for already-resolved game", () => { + const state = riggedState({ + hands: { + "p1": { cards: [makeCard("K"), makeCard("9")], status: "stood" as const, result: "win" as const, resultReason: "Win" }, + }, + dealerHand: [makeCard("7"), makeCard("8")], + turnOrder: ["p1"], + phase: "resolved", + activePlayerIndex: -1, + }); + + const newState = blackjackPlugin.onPlayerDisconnect!(state, "p1"); + expect(newState.hands["p1"]!.result).toBe("win"); // unchanged + }); +}); diff --git a/shared/games/blackjack/blackjack.plugin.ts b/shared/games/blackjack/blackjack.plugin.ts new file mode 100644 index 0000000..9a32665 --- /dev/null +++ b/shared/games/blackjack/blackjack.plugin.ts @@ -0,0 +1,395 @@ +import type { GamePlugin, GameResult, GameOverResult } from "../types"; +import type { + BlackjackState, BlackjackAction, BlackjackPlayerView, + BlackjackSpectatorView, PlayerHandView, Card, Suit, Rank, PlayerHand, +} from "./blackjack.types"; + +// ── Card helpers ── + +const SUITS: Suit[] = ["hearts", "diamonds", "clubs", "spades"]; +const RANKS: Rank[] = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]; + +function createDeck(): Card[] { + const deck: Card[] = []; + for (const suit of SUITS) { + for (const rank of RANKS) { + deck.push({ suit, rank }); + } + } + return deck; +} + +function shuffleDeck(deck: Card[]): Card[] { + const shuffled = [...deck]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i]!, shuffled[j]!] = [shuffled[j]!, shuffled[i]!]; + } + return shuffled; +} + +function cardValue(rank: Rank): number { + if (rank === "A") return 11; + if (["K", "Q", "J"].includes(rank)) return 10; + return parseInt(rank); +} + +/** Calculate the best hand value (accounting for soft aces). */ +export function handValue(hand: Card[]): number { + let total = 0; + let aces = 0; + for (const card of hand) { + total += cardValue(card.rank); + if (card.rank === "A") aces++; + } + while (total > 21 && aces > 0) { + total -= 10; + aces--; + } + return total; +} + +function isBust(hand: Card[]): boolean { + return handValue(hand) > 21; +} + +function isNaturalBlackjack(hand: Card[]): boolean { + return hand.length === 2 && handValue(hand) === 21; +} + +function drawCard(deck: Card[]): [Card, Card[]] { + return [deck[0]!, deck.slice(1)]; +} + +/** Play dealer hand: hit until 17+. */ +function playDealerHand(dealerHand: Card[], deck: Card[]): { dealerHand: Card[]; deck: Card[] } { + let hand = [...dealerHand]; + let remaining = [...deck]; + while (handValue(hand) < 17) { + const [card, rest] = drawCard(remaining); + hand = [...hand, card]; + remaining = rest; + } + return { dealerHand: hand, deck: remaining }; +} + +/** Find the next player who still needs to act (skip blackjack/bust/stood). */ +function findNextActiveIndex(state: BlackjackState, afterIndex: number): number { + for (let i = afterIndex + 1; i < state.turnOrder.length; i++) { + const hand = state.hands[state.turnOrder[i]!]; + if (hand && hand.status === "playing") return i; + } + return -1; // no more active players +} + +/** Resolve all hands against the dealer, returning updated hands. */ +function resolveAllHands( + hands: Record, + dealerHand: Card[], +): Record { + const dealerVal = handValue(dealerHand); + const dealerBust = isBust(dealerHand); + const resolved: Record = {}; + + for (const [id, hand] of Object.entries(hands)) { + // Already resolved (natural blackjack checked at deal time, bust on hit) + if (hand.result) { + resolved[id] = hand; + continue; + } + + const playerVal = handValue(hand.cards); + + if (hand.status === "bust") { + resolved[id] = { ...hand, result: "lose", resultReason: "Player busts" }; + } else if (hand.status === "blackjack") { + // Natural blackjack vs dealer blackjack + if (isNaturalBlackjack(dealerHand)) { + resolved[id] = { ...hand, result: "push", resultReason: "Both have Blackjack" }; + } else { + resolved[id] = { ...hand, result: "blackjack", resultReason: "Blackjack!" }; + } + } else if (dealerBust) { + resolved[id] = { ...hand, result: "win", resultReason: "Dealer busts" }; + } else if (playerVal > dealerVal) { + resolved[id] = { ...hand, result: "win", resultReason: "Higher hand" }; + } else if (playerVal < dealerVal) { + resolved[id] = { ...hand, result: "lose", resultReason: "Dealer has higher hand" }; + } else { + resolved[id] = { ...hand, result: "push", resultReason: "Push" }; + } + } + + return resolved; +} + +/** Build the dealer hand for views — hole card hidden during player_turns. */ +function dealerVisibleHand(state: BlackjackState): Card[] { + if (state.phase === "resolved") return state.dealerHand; + return [state.dealerHand[0]!, { suit: "spades", rank: "?" as Rank }]; +} + +/** Convert internal PlayerHand to a view. */ +function toHandView(hand: PlayerHand): PlayerHandView { + return { + cards: hand.cards, + value: handValue(hand.cards), + status: hand.status, + result: hand.result, + resultReason: hand.resultReason, + }; +} + +/** Transition to dealer turn + resolve, or just resolve if all busted/blackjacked. */ +function finishPlayerTurns(state: BlackjackState): BlackjackState { + // Check if any player stood (dealer needs to play) + const anyStood = Object.values(state.hands).some(h => h.status === "stood"); + + let dealerHand = state.dealerHand; + let deck = state.deck; + + if (anyStood) { + const dealer = playDealerHand(dealerHand, deck); + dealerHand = dealer.dealerHand; + deck = dealer.deck; + } + + const resolvedHands = resolveAllHands(state.hands, dealerHand); + + return { + ...state, + deck, + dealerHand, + hands: resolvedHands, + activePlayerIndex: -1, + phase: "resolved", + }; +} + +// ── Plugin ── + +export const blackjackPlugin: GamePlugin = { + slug: "blackjack", + name: "Blackjack", + minPlayers: 1, + maxPlayers: 6, + manualStart: true, + + createInitialState(players: string[], _options?: Record): BlackjackState { + let deck = shuffleDeck(createDeck()); + const turnOrder = [...players]; + const hands: Record = {}; + + // Deal 2 cards to each player + for (const pid of turnOrder) { + const cards: Card[] = []; + let card: Card; + [card, deck] = drawCard(deck); cards.push(card); + [card, deck] = drawCard(deck); cards.push(card); + hands[pid] = { + cards, + status: isNaturalBlackjack(cards) ? "blackjack" : "playing", + result: null, + resultReason: null, + }; + } + + // Deal 2 cards to dealer + const dealerHand: Card[] = []; + let card: Card; + [card, deck] = drawCard(deck); dealerHand.push(card); + [card, deck] = drawCard(deck); dealerHand.push(card); + + let state: BlackjackState = { + deck, + dealerHand, + hands, + turnOrder, + activePlayerIndex: 0, + phase: "player_turns", + }; + + // Skip to first player that needs to act (skip natural blackjacks) + const firstActive = findNextActiveIndex(state, -1); + state.activePlayerIndex = firstActive; + + // If no players need to act (all natural blackjacks), go straight to resolution + if (firstActive === -1) { + return finishPlayerTurns(state); + } + + return state; + }, + + handleAction(state: BlackjackState, action: BlackjackAction, playerId: string): GameResult { + if (state.phase === "resolved") return { ok: false, error: "Game is already over" }; + + const activeId = state.turnOrder[state.activePlayerIndex]; + if (playerId !== activeId) return { ok: false, error: "It's not your turn" }; + + const hand = state.hands[playerId]; + if (!hand || hand.status !== "playing") return { ok: false, error: "You cannot act" }; + + switch (action.type) { + case "hit": { + const [card, remaining] = drawCard(state.deck); + const newCards = [...hand.cards, card]; + const bust = isBust(newCards); + const got21 = handValue(newCards) === 21; + + const newHand: PlayerHand = { + ...hand, + cards: newCards, + status: bust ? "bust" : got21 ? "stood" : "playing", + result: bust ? "lose" : null, + resultReason: bust ? "Player busts" : null, + }; + + const newHands = { ...state.hands, [playerId]: newHand }; + let newState: BlackjackState = { ...state, deck: remaining, hands: newHands }; + + // Advance turn if bust or auto-stood on 21 + if (bust || got21) { + const nextIdx = findNextActiveIndex(newState, state.activePlayerIndex); + if (nextIdx === -1) { + return { ok: true, state: finishPlayerTurns(newState) }; + } + newState = { ...newState, activePlayerIndex: nextIdx }; + } + + return { ok: true, state: newState }; + } + + case "stand": { + const newHand: PlayerHand = { ...hand, status: "stood" }; + const newHands = { ...state.hands, [playerId]: newHand }; + let newState: BlackjackState = { ...state, hands: newHands }; + + const nextIdx = findNextActiveIndex(newState, state.activePlayerIndex); + if (nextIdx === -1) { + return { ok: true, state: finishPlayerTurns(newState) }; + } + newState = { ...newState, activePlayerIndex: nextIdx }; + + return { ok: true, state: newState }; + } + + default: + return { ok: false, error: "Unknown action type" }; + } + }, + + getPlayerView(state: BlackjackState, playerId: string): BlackjackPlayerView { + const visibleDealer = dealerVisibleHand(state); + const handsView: Record = {}; + for (const [id, hand] of Object.entries(state.hands)) { + handsView[id] = toHandView(hand); + } + + const activeId = state.activePlayerIndex >= 0 ? state.turnOrder[state.activePlayerIndex] ?? null : null; + + return { + dealerHand: visibleDealer, + dealerVisibleValue: cardValue(state.dealerHand[0]!.rank), + dealerFullValue: state.phase === "resolved" ? handValue(state.dealerHand) : null, + hands: handsView, + turnOrder: state.turnOrder, + activePlayerId: activeId, + myPlayerId: playerId, + phase: state.phase, + canAct: activeId === playerId && state.phase === "player_turns", + }; + }, + + getSpectatorView(state: BlackjackState): BlackjackSpectatorView { + const visibleDealer = dealerVisibleHand(state); + const handsView: Record = {}; + for (const [id, hand] of Object.entries(state.hands)) { + handsView[id] = toHandView(hand); + } + + const activeId = state.activePlayerIndex >= 0 ? state.turnOrder[state.activePlayerIndex] ?? null : null; + + return { + dealerHand: visibleDealer, + dealerVisibleValue: cardValue(state.dealerHand[0]!.rank), + dealerFullValue: state.phase === "resolved" ? handValue(state.dealerHand) : null, + hands: handsView, + turnOrder: state.turnOrder, + activePlayerId: activeId, + phase: state.phase, + }; + }, + + isGameOver(state: BlackjackState): GameOverResult | null { + if (state.phase !== "resolved") return null; + + const payouts: Record = {}; + + for (const [id, hand] of Object.entries(state.hands)) { + switch (hand.result) { + case "blackjack": + payouts[id] = 2.5; // 3:2 payout + break; + case "win": + payouts[id] = 2; // 1:1 payout + break; + case "push": + payouts[id] = 1; // refund + break; + // "lose" / null → no payout + } + } + + // Find a "winner" for the room summary — pick any winning player, or null + const winner = Object.entries(state.hands).find( + ([, h]) => h.result === "blackjack" || h.result === "win" + )?.[0] ?? null; + + const wins = Object.values(state.hands).filter(h => h.result === "win" || h.result === "blackjack").length; + const losses = Object.values(state.hands).filter(h => h.result === "lose").length; + const pushes = Object.values(state.hands).filter(h => h.result === "push").length; + const parts: string[] = []; + if (wins > 0) parts.push(`${wins} win${wins > 1 ? "s" : ""}`); + if (losses > 0) parts.push(`${losses} loss${losses > 1 ? "es" : ""}`); + if (pushes > 0) parts.push(`${pushes} push${pushes > 1 ? "es" : ""}`); + + return { + winner, + reason: parts.join(", ") || "Game over", + payouts, + }; + }, + + onPlayerDisconnect(state: BlackjackState, playerId: string): BlackjackState { + if (state.phase === "resolved") return state; + + const hand = state.hands[playerId]; + if (!hand || hand.status !== "playing") return state; + + // Mark disconnected player as bust + const newHands = { + ...state.hands, + [playerId]: { ...hand, status: "bust" as const, result: "lose" as const, resultReason: "Disconnected" }, + }; + let newState: BlackjackState = { ...state, hands: newHands }; + + // Check if the disconnected player was the active player + const activeId = state.turnOrder[state.activePlayerIndex]; + if (activeId === playerId) { + const nextIdx = findNextActiveIndex(newState, state.activePlayerIndex); + if (nextIdx === -1) { + return finishPlayerTurns(newState); + } + newState = { ...newState, activePlayerIndex: nextIdx }; + } + + // Check if all players are now done + const anyPlaying = Object.values(newState.hands).some(h => h.status === "playing"); + if (!anyPlaying && newState.phase === "player_turns") { + return finishPlayerTurns(newState); + } + + return newState; + }, +}; diff --git a/shared/games/blackjack/blackjack.types.ts b/shared/games/blackjack/blackjack.types.ts new file mode 100644 index 0000000..17645bd --- /dev/null +++ b/shared/games/blackjack/blackjack.types.ts @@ -0,0 +1,65 @@ +export type Suit = "hearts" | "diamonds" | "clubs" | "spades"; +export type Rank = "A" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "J" | "Q" | "K"; + +export interface Card { + suit: Suit; + rank: Rank; +} + +// ── Per-player hand state ── + +export interface PlayerHand { + cards: Card[]; + status: "playing" | "stood" | "bust" | "blackjack"; + result: "win" | "blackjack" | "push" | "lose" | null; + resultReason: string | null; +} + +// ── Game state ── + +export interface BlackjackState { + deck: Card[]; + dealerHand: Card[]; + hands: Record; + turnOrder: string[]; + activePlayerIndex: number; // index into turnOrder, -1 when no active player + phase: "player_turns" | "resolved"; +} + +// ── Actions ── + +export type BlackjackAction = + | { type: "hit" } + | { type: "stand" }; + +// ── Views ── + +export interface PlayerHandView { + cards: Card[]; + value: number; + status: "playing" | "stood" | "bust" | "blackjack"; + result: "win" | "blackjack" | "push" | "lose" | null; + resultReason: string | null; +} + +export interface BlackjackPlayerView { + dealerHand: Card[]; + dealerVisibleValue: number; + dealerFullValue: number | null; + hands: Record; + turnOrder: string[]; + activePlayerId: string | null; + myPlayerId: string; + phase: "player_turns" | "resolved"; + canAct: boolean; +} + +export interface BlackjackSpectatorView { + dealerHand: Card[]; + dealerVisibleValue: number; + dealerFullValue: number | null; + hands: Record; + turnOrder: string[]; + activePlayerId: string | null; + phase: "player_turns" | "resolved"; +} diff --git a/shared/games/types.ts b/shared/games/types.ts index 2e5ebd1..9ccd1af 100644 --- a/shared/games/types.ts +++ b/shared/games/types.ts @@ -3,6 +3,8 @@ export interface GamePlugin { name: string; minPlayers: number; maxPlayers: number; + /** If true, the host must explicitly start the game instead of auto-starting when full. */ + manualStart?: boolean; createInitialState(players: string[], options?: Record): TState; handleAction(state: TState, action: TAction, playerId: string): GameResult; @@ -20,4 +22,11 @@ export type GameResult = export type GameOverResult = { winner: string | null; reason: string; + /** + * Per-player payout overrides as multipliers of betAmount. + * When set, settleBets uses these instead of default pot logic. + * e.g. { "player123": 2 } means player123 receives betAmount * 2. + * An empty object means no payouts (house wins, bets forfeit). + */ + payouts?: Record; };