feat(games): refactor blackjack for continuous play, split/double, and table UI
Some checks failed
Deploy to Production / test (push) Failing after 32s
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:
@@ -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 }));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user