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 0000000..c2b1c67 Binary files /dev/null and b/panel/public/cards/back.png differ diff --git a/panel/public/cards/jack_of_clubs.svg b/panel/public/cards/jack_of_clubs.svg new file mode 100644 index 0000000..a350305 --- /dev/null +++ b/panel/public/cards/jack_of_clubs.svg @@ -0,0 +1,224 @@ + + + + +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; };