feat(games): refactor blackjack for continuous play, split/double, and table UI
Some checks failed
Deploy to Production / test (push) Failing after 32s

Transform blackjack from single-round to continuous-play table sessions with
round lifecycle (betting → playing → resolved → betting), split/double down
actions, per-hand bet tracking, leave/join table mid-session, and a responsive
felt-style table UI with arc-positioned player seats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-04-06 12:41:49 +02:00
parent ef78a85b9c
commit a36c05994c
12 changed files with 1907 additions and 554 deletions

View File

@@ -88,6 +88,34 @@ export class GameServer {
this.publishRoomListUpdate();
});
this.roomManager.emitter.on("round:settled", async ({ roomId, roundPayouts }) => {
const room = this.roomManager.getRoom(roomId);
if (!room || room.betAmount <= 0) return;
const betAmount = room.betAmount;
const gameName = gameRegistry.get(room.gameSlug)?.name ?? "Game";
const payoutDetails: Record<string, { net: number }> = {};
for (const [playerId, multiplier] of Object.entries(roundPayouts)) {
if (multiplier <= 0) continue;
const amount = Math.floor(betAmount * multiplier);
try {
await economyService.modifyUserBalance(
playerId,
BigInt(amount),
TransactionType.GAME_WIN,
`${gameName} round payout (room ${roomId.slice(0, 8)})`,
);
payoutDetails[playerId] = { net: amount };
} catch (err) {
logger.error("web", `Round payout failed for ${playerId} in room ${roomId}: ${err}`);
}
}
if (Object.keys(payoutDetails).length > 0) {
this.publish(`room:${roomId}`, { type: "ROUND_SETTLED", roomId, payouts: payoutDetails });
}
});
this.roomManager.emitter.on("player:left", ({ roomId, playerId }) => {
this.publish(`room:${roomId}`, {
type: "PLAYER_LEFT",
@@ -126,7 +154,7 @@ export class GameServer {
ws.send(JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: this.roomManager.listRooms() }));
}
handleMessage(ws: ServerWebSocket<WsConnectionData>, raw: unknown): void {
async handleMessage(ws: ServerWebSocket<WsConnectionData>, raw: unknown): Promise<void> {
const parsed = GameWsClientSchema.safeParse(raw);
if (!parsed.success) {
ws.send(JSON.stringify({ type: "ERROR", message: "Invalid message format" }));
@@ -255,6 +283,30 @@ export class GameServer {
}
case "GAME_ACTION": {
// Action cost pre-check: deduct bet before processing split/double/place_bet
const actionRoom = this.roomManager.getRoom(msg.roomId);
if (actionRoom && actionRoom.betAmount > 0 && actionRoom.state) {
const actionPlugin = gameRegistry.get(actionRoom.gameSlug);
if (actionPlugin?.getActionCost) {
const cost = actionPlugin.getActionCost(actionRoom.state, msg.action, discordId);
if (cost > 0) {
const amount = actionRoom.betAmount * cost;
const gameName = actionPlugin.name ?? actionRoom.gameSlug;
try {
await economyService.modifyUserBalance(
discordId,
-BigInt(amount),
TransactionType.GAME_BET,
`${gameName} action bet (room ${msg.roomId.slice(0, 8)})`,
);
} catch {
ws.send(JSON.stringify({ type: "ERROR", message: "Insufficient funds for this action" }));
return;
}
}
}
}
const result = this.roomManager.handleAction(msg.roomId, discordId, msg.action);
if (!result.ok) {
ws.send(JSON.stringify({ type: "ERROR", message: result.error }));

View File

@@ -9,7 +9,7 @@ const ROOM_CONFIG = {
} as const;
type ActionResult =
| { ok: true; state: unknown; gameOver: { winner: string | null; reason: string } | null }
| { ok: true; state: unknown; gameOver: { winner: string | null; reason: string } | null; roundPayouts?: Record<string, number> }
| { ok: false; error: string };
type CreateResult = { ok: true; roomId: string } | { ok: false; error: string };
@@ -24,6 +24,7 @@ type RoomEvents = {
"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; payouts?: Record<string, number> };
"round:settled": { roomId: string; roundPayouts: Record<string, number> };
"player:left": { roomId: string; playerId: string };
"room:deleted": { roomId: string };
"room:list:changed": void;
@@ -106,9 +107,19 @@ export class RoomManager {
const room = this.rooms.get(roomId);
if (!room) return { ok: false, error: "Room not found" };
if (room.status !== "playing") return { ok: false, error: "Game is not in progress" };
if (!room.players.includes(playerId)) return { ok: false, error: "You are not a player in this game" };
const plugin = gameRegistry.get(room.gameSlug)!;
// Spectator-to-player promotion for actions like "sit_down"
if (!room.players.includes(playerId)) {
if (room.spectators.has(playerId) && plugin.isSpectatorAction?.(action as any)) {
room.spectators.delete(playerId);
room.players.push(playerId);
} else {
return { ok: false, error: "You are not a player in this game" };
}
}
const result = plugin.handleAction(room.state, action, playerId);
if (!result.ok) return result;
@@ -121,17 +132,22 @@ export class RoomManager {
const spectatorView = plugin.getSpectatorView(room.state);
const playerViews = new Map<string, unknown>();
for (const pid of room.players) {
for (const pid of new Set(room.players)) {
playerViews.set(pid, plugin.getPlayerView(room.state, pid));
}
this.emitter.emit("game:updated", { roomId, spectatorView, playerViews });
// Emit round payouts for mid-game settlement (continuous-play games)
if (result.roundPayouts && !gameOver) {
this.emitter.emit("round:settled", { roomId, roundPayouts: result.roundPayouts });
}
if (gameOver) {
this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason, payouts: gameOver.payouts });
this.emitter.emit("room:list:changed");
}
return { ok: true, state: room.state, gameOver };
return { ok: true, state: room.state, gameOver, roundPayouts: result.roundPayouts };
}
leaveRoom(roomId: string, playerId: string): void {

View File

@@ -59,5 +59,6 @@ export type GameWsServerMessage =
| { type: "GAME_ENDED"; roomId: string; winner: string | null; reason: string; payout?: { amount: number; refunded?: boolean } }
| { type: "ROOM_CREATED"; roomId: string; gameSlug: string }
| { type: "JOIN_RESULT"; roomId: string; joinedAs: "player" | "spectator"; roomStatus: "waiting" | "playing" | "finished"; players: PlayerInfo[]; spectators: PlayerInfo[]; state?: unknown; roomOptions?: { betAmount?: number } }
| { type: "ROUND_SETTLED"; roomId: string; payouts: Record<string, { net: number }> }
| { type: "SESSION_REPLACED"; roomId: string }
| { type: "ERROR"; message: string };

View File

@@ -183,7 +183,9 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
// Route game messages — try to parse as a game client message
const gameCheck = GameWsClientSchema.safeParse(rawData);
if (gameCheck.success) {
gameServer.handleMessage(ws, rawData);
gameServer.handleMessage(ws, rawData).catch(err =>
logger.error("web", `Game message handler error: ${err}`),
);
return;
}