feat(games): implement blackjack game plugin with manual start and custom payouts
Some checks failed
Deploy to Production / test (push) Failing after 39s
Some checks failed
Deploy to Production / test (push) Failing after 39s
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, number>,
|
||||
): 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<void> {
|
||||
private async refundPlayers(playerIds: string[], betAmount: number, roomId: string, gameName = "Game"): Promise<void> {
|
||||
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}`);
|
||||
|
||||
@@ -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<string, unknown> };
|
||||
"game:updated": { roomId: string; spectatorView: unknown; playerViews: Map<string, unknown> };
|
||||
"game:ended": { roomId: string; winner: string | null; reason: string };
|
||||
"game:ended": { roomId: string; winner: string | null; reason: string; payouts?: Record<string, number> };
|
||||
"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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof GameWsClientSchema>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user