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;
}

View File

@@ -42,7 +42,7 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
const {
gameState, players, spectators, roomStatus,
isSpectator, gameOver, error, sendAction, leaveRoom, sessionReplaced, rejoin, fillRoom, startGame, roomOptions,
isSpectator, gameOver, roundResult, error, sendAction, leaveRoom, sessionReplaced, rejoin, fillRoom, startGame, roomOptions,
} = useGameRoom(roomId!, userId, role, preferAs);
const betAmount = roomOptions.betAmount ?? 0;
@@ -219,6 +219,8 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
isSpectator={isSpectator}
onAction={sendAction}
players={players}
roundResult={roundResult}
roomOptions={roomOptions}
/>
)}
</div>

View File

@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { useMemo, useState, useEffect, useCallback } from "react";
import type { GameUIProps } from "../registry";
import { Hand } from "lucide-react";
import { Hand, Square, ArrowLeftRight, ChevronsUp, LogOut, Check, Coins } from "lucide-react";
// ── Types matching server views ──
@@ -15,25 +15,35 @@ interface PlayerHandView {
status: "playing" | "stood" | "bust" | "blackjack";
result: "win" | "blackjack" | "push" | "lose" | null;
resultReason: string | null;
bet: number;
fromSplit: boolean;
}
interface PlayerSeatView {
hands: PlayerHandView[];
activeHandIndex: number;
hasBet: boolean;
}
interface BlackjackViewBase {
dealerHand: Card[];
dealerVisibleValue: number;
dealerFullValue: number | null;
hands: Record<string, PlayerHandView>;
seats: Record<string, PlayerSeatView>;
turnOrder: string[];
activePlayerId: string | null;
phase: "player_turns" | "resolved";
activeHandIndex: number;
phase: "betting" | "player_turns" | "resolved";
roundNumber: number;
}
interface PlayerView extends BlackjackViewBase {
myPlayerId: string;
canAct: boolean;
canSplit: boolean;
canDoubleDown: boolean;
}
interface SpectatorView extends BlackjackViewBase {}
function isPlayerView(state: unknown): state is PlayerView {
return typeof state === "object" && state !== null && "canAct" in state;
}
@@ -51,16 +61,31 @@ function cardImageUrl(card: Card): string {
return `/cards/${rank}_of_${card.suit}.svg`;
}
// ── Card rendering ──
// ── Seat layout positions (percentage-based for desktop arc) ──
function PlayingCard({ card, faceDown, compact }: { card: Card; faceDown?: boolean; compact?: boolean }) {
const SEAT_POSITIONS = [
{ left: "10%", top: "72%" }, // seat 1 — bottom-left
{ left: "2%", top: "42%" }, // seat 2 — mid-left
{ left: "16%", top: "16%" }, // seat 3 — top-left
{ left: "84%", top: "16%" }, // seat 4 — top-right (mirrored)
{ left: "98%", top: "42%" }, // seat 5 — mid-right
{ left: "90%", top: "72%" }, // seat 6 — bottom-right
] as const;
// ── Card component ──
function PlayingCard({ card, faceDown, size = "normal" }: {
card: Card;
faceDown?: boolean;
size?: "small" | "normal";
}) {
const isFaceDown = faceDown || card.rank === "?";
const sizeClass = compact
? "w-14 h-20 sm:w-16 sm:h-[5.5rem]"
: "w-18 h-25 sm:w-20 sm:h-28";
const sizeClass = size === "small"
? "w-9 h-[3.25rem] sm:w-11 sm:h-[3.875rem]"
: "w-12 h-[4.25rem] sm:w-14 sm:h-[5rem]";
return (
<div className={`relative ${sizeClass} rounded-lg overflow-hidden shadow-lg shrink-0`}>
<div className={`relative ${sizeClass} rounded-md overflow-hidden shadow-md shrink-0`}>
{isFaceDown ? (
<img
src="/cards/back.png"
@@ -72,7 +97,7 @@ function PlayingCard({ card, faceDown, compact }: { card: Card; faceDown?: boole
<img
src={cardImageUrl(card)}
alt={`${card.rank} of ${card.suit}`}
className="w-full h-full object-contain bg-white rounded-lg"
className="w-full h-full object-contain bg-white rounded-md"
draggable={false}
/>
)}
@@ -80,18 +105,36 @@ function PlayingCard({ card, faceDown, compact }: { card: Card; faceDown?: boole
);
}
// ── Card fan (overlapping cards) ──
function CardFan({ cards, faceDown, size = "normal" }: {
cards: Card[];
faceDown?: boolean;
size?: "small" | "normal";
}) {
return (
<div className="flex">
{cards.map((card, i) => (
<div key={`${card.suit}-${card.rank}-${i}`} className={i > 0 ? "-ml-5 sm:-ml-6" : ""}>
<PlayingCard card={card} faceDown={faceDown} size={size} />
</div>
))}
</div>
);
}
// ── Value badge ──
function ValueBadge({ value, status }: { value: number; status?: string }) {
const colorClass =
value === 21 || status === "blackjack"
? "bg-success/15 text-success font-bold"
? "bg-emerald-500/20 text-emerald-400 font-bold"
: value > 21 || status === "bust"
? "bg-destructive/15 text-destructive font-bold"
: "bg-card text-text-secondary";
? "bg-red-500/20 text-red-400 font-bold"
: "bg-black/30 text-white/80";
return (
<span className={`text-xs font-mono px-2 py-0.5 rounded-md ${colorClass}`}>
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${colorClass}`}>
{value}
</span>
);
@@ -101,158 +144,496 @@ function ValueBadge({ value, status }: { value: number; status?: string }) {
function ResultBadge({ result, reason }: { result: string; reason: string | null }) {
const config: Record<string, { label: string; color: string }> = {
blackjack: { label: "Blackjack!", color: "bg-warning/15 text-warning" },
win: { label: "Win", color: "bg-success/15 text-success" },
push: { label: "Push", color: "bg-info/15 text-info" },
lose: { label: "Lose", color: "bg-destructive/15 text-destructive" },
blackjack: { label: "Blackjack!", color: "bg-yellow-500/20 text-yellow-300" },
win: { label: "Win", color: "bg-emerald-500/20 text-emerald-400" },
push: { label: "Push", color: "bg-blue-500/20 text-blue-400" },
lose: { label: "Lose", color: "bg-red-500/20 text-red-400" },
};
const c = config[result] ?? { label: "", color: "bg-card text-text-tertiary" };
const c = config[result] ?? { label: "-", color: "bg-black/20 text-white/50" };
return (
<div className={`rounded-lg px-2.5 py-1 text-xs font-semibold ${c.color}`} title={reason ?? undefined}>
<span className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${c.color}`} title={reason ?? undefined}>
{c.label}
</span>
);
}
// ── Bet chip indicator ──
function BetChip({ bet, betAmount }: { bet: number; betAmount: number }) {
if (betAmount <= 0) return null;
const total = bet * betAmount;
return (
<div className="flex items-center gap-0.5 bg-yellow-500/20 text-yellow-300 rounded px-1.5 py-0.5">
<Coins className="w-2.5 h-2.5" />
<span className="text-[9px] font-bold">{total}</span>
</div>
);
}
// ── Player hand section ──
// ── Single hand display ──
function PlayerHandSection({ hand, playerName, isActive, isMe, compact }: {
function HandDisplay({ hand, isActive, betAmount, size = "normal" }: {
hand: PlayerHandView;
playerName: string;
isActive: boolean;
isMe: boolean;
compact: boolean;
betAmount: number;
size?: "small" | "normal";
}) {
return (
<div className={`rounded-xl px-3 py-3 transition-colors ${
isActive ? "bg-primary/5 ring-1 ring-primary/20" : "bg-card/50"
<div className={`flex flex-col items-center gap-1 rounded-lg px-1.5 py-1 transition-all ${
isActive ? "ring-1 ring-primary/40 bg-primary/5" : ""
}`}>
{/* Header */}
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-2 min-w-0">
<span className={`text-sm font-label font-semibold truncate ${
isActive ? "text-primary" : "text-text-tertiary"
}`}>
{playerName}{isMe ? " (You)" : ""}
</span>
{isActive && (
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse shrink-0" />
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
<ValueBadge value={hand.value} status={hand.status} />
{hand.result && <ResultBadge result={hand.result} reason={hand.resultReason} />}
</div>
<CardFan cards={hand.cards} size={size} />
<div className="flex items-center gap-1 flex-wrap justify-center">
<ValueBadge value={hand.value} status={hand.status} />
{hand.bet > 1 && <BetChip bet={hand.bet} betAmount={betAmount} />}
{hand.result && <ResultBadge result={hand.result} reason={hand.resultReason} />}
</div>
{/* Cards */}
<div className="flex gap-1.5 flex-wrap">
{hand.cards.map((card, i) => (
<PlayingCard key={`${card.suit}-${card.rank}-${i}`} card={card} compact={compact} />
))}
</div>
);
}
// ── Player seat ──
function Seat({ seat, playerName, isMe, isActivePlayer, activeHandIdx, betAmount }: {
seat: PlayerSeatView;
playerName: string;
isMe: boolean;
isActivePlayer: boolean;
activeHandIdx: number;
betAmount: number;
}) {
const hasBet = seat.hasBet;
const hasHands = seat.hands.length > 0;
const compact = seat.hands.length > 2;
return (
<div className={`flex flex-col items-center gap-1.5 transition-all ${
isActivePlayer ? "scale-105" : ""
}`}>
{/* Player label */}
<div className="flex items-center gap-1.5">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold ${
isActivePlayer ? "bg-primary/30 text-primary ring-2 ring-primary/50" : "bg-white/10 text-white/70"
}`}>
{playerName[0]?.toUpperCase() ?? "?"}
</div>
<span className={`text-xs font-medium truncate max-w-20 ${
isActivePlayer ? "text-primary" : "text-white/70"
}`}>
{playerName}{isMe ? "" : ""}
</span>
{isActivePlayer && (
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
)}
</div>
{/* Bet status (during betting phase, no hands yet) */}
{!hasHands && (
<div className={`flex items-center gap-1 text-[10px] rounded-full px-2 py-0.5 ${
hasBet ? "bg-emerald-500/20 text-emerald-400" : "bg-white/5 text-white/40"
}`}>
{hasBet ? <><Check className="w-2.5 h-2.5" /> Ready</> : "Waiting..."}
</div>
)}
{/* Hands */}
{hasHands && (
<div className={`flex gap-1 ${seat.hands.length > 1 ? "flex-row" : "flex-col"}`}>
{seat.hands.map((hand, hi) => (
<HandDisplay
key={hi}
hand={hand}
isActive={isActivePlayer && hi === activeHandIdx}
betAmount={betAmount}
size={compact ? "small" : "normal"}
/>
))}
</div>
)}
{/* Bet amount indicator */}
{hasHands && betAmount > 0 && (
<BetChip bet={seat.hands.reduce((sum, h) => sum + h.bet, 0)} betAmount={betAmount} />
)}
</div>
);
}
// ── Empty seat ──
function EmptySeat({ canSit, onSit }: { canSit: boolean; onSit: () => void }) {
return (
<div className="flex flex-col items-center gap-1.5">
<div className={`w-6 h-6 rounded-full border border-dashed flex items-center justify-center text-[10px] ${
canSit ? "border-white/30 text-white/30" : "border-white/10 text-white/10"
}`}>
+
</div>
{canSit ? (
<button
onClick={onSit}
className="text-[10px] text-primary/70 hover:text-primary transition-colors"
>
Sit
</button>
) : (
<span className="text-[10px] text-white/15">Empty</span>
)}
</div>
);
}
// ── Dealer area ──
function DealerArea({ dealerHand, visibleValue, fullValue }: {
dealerHand: Card[];
visibleValue: number;
fullValue: number | null;
}) {
if (dealerHand.length === 0) {
return (
<div className="flex flex-col items-center gap-1.5">
<span className="text-xs font-semibold text-white/50">Dealer</span>
<div className="w-12 h-[4.25rem] sm:w-14 sm:h-[5rem] rounded-md border border-dashed border-white/10" />
</div>
);
}
const displayValue = fullValue ?? visibleValue;
const isBust = fullValue !== null && fullValue > 21;
return (
<div className="flex flex-col items-center gap-1.5">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-white/60">Dealer</span>
{displayValue > 0 && (
<ValueBadge value={displayValue} status={isBust ? "bust" : undefined} />
)}
</div>
<CardFan cards={dealerHand} />
</div>
);
}
// ── Round result banner ──
function RoundResultBanner({ roundNumber, roundResult, myPlayerId, betAmount }: {
roundNumber: number;
roundResult: GameUIProps["roundResult"];
myPlayerId: string;
betAmount: number;
}) {
const myPayout = roundResult?.payouts[myPlayerId];
return (
<div className="flex items-center justify-center gap-3 bg-black/30 rounded-xl px-4 py-2">
<span className="text-xs text-white/50">Round {roundNumber}</span>
{myPayout && betAmount > 0 && (
<span className={`text-xs font-semibold ${
myPayout.net > betAmount ? "text-emerald-400" : myPayout.net === betAmount ? "text-blue-400" : "text-white/60"
}`}>
+{myPayout.net} AU
</span>
)}
</div>
);
}
// ── Main Component ──
export function BlackjackGame({ state, myPlayerId, isSpectator, onAction, players }: GameUIProps) {
const view = state as PlayerView | SpectatorView;
export function BlackjackGame({ state, myPlayerId, isSpectator, onAction, players, roundResult, roomOptions }: GameUIProps) {
const view = state as PlayerView | BlackjackViewBase;
const playerView = isPlayerView(state) ? state : null;
const betAmount = roomOptions?.betAmount ?? 0;
const isResolved = view.phase === "resolved";
const compact = view.turnOrder.length > 3;
const isBetting = view.phase === "betting";
const isPlaying = view.phase === "player_turns";
const getPlayerName = useMemo(() => {
const nameMap = new Map(players.map(p => [p.discordId, p.username]));
return (id: string) => nameMap.get(id) ?? id.slice(0, 8);
}, [players]);
const mySeat = view.seats[myPlayerId];
const myBetPlaced = mySeat?.hasBet ?? false;
// Determine who can sit (spectators during betting phase)
const canSitDown = isSpectator && isBetting && view.turnOrder.length < 6;
// Build the seat map for rendering (up to 6 seats)
const seatSlots = useMemo(() => {
const slots: Array<{ playerId: string | null; seat: PlayerSeatView | null }> = [];
// Fill with seated players
for (const pid of view.turnOrder) {
slots.push({ playerId: pid, seat: view.seats[pid] ?? null });
}
// Fill remaining with empty seats up to 6
while (slots.length < 6) {
slots.push({ playerId: null, seat: null });
}
return slots;
}, [view.turnOrder, view.seats]);
// Auto-place bet after resolved phase (with delay for UX)
const [showNextRound, setShowNextRound] = useState(false);
useEffect(() => {
if (isResolved) {
const timer = setTimeout(() => setShowNextRound(true), 2000);
return () => clearTimeout(timer);
}
setShowNextRound(false);
}, [isResolved, view.roundNumber]);
const handlePlaceBet = useCallback(() => onAction({ type: "place_bet" }), [onAction]);
const handleHit = useCallback(() => onAction({ type: "hit" }), [onAction]);
const handleStand = useCallback(() => onAction({ type: "stand" }), [onAction]);
const handleSplit = useCallback(() => onAction({ type: "split" }), [onAction]);
const handleDouble = useCallback(() => onAction({ type: "double_down" }), [onAction]);
const handleLeave = useCallback(() => onAction({ type: "leave_table" }), [onAction]);
const handleSit = useCallback(() => onAction({ type: "sit_down" }), [onAction]);
return (
<div className="flex flex-col gap-4 max-w-2xl mx-auto">
{/* Dealer section */}
<div className="rounded-xl bg-card/50 px-3 py-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-label font-semibold text-text-tertiary">
Dealer
</span>
{(view.dealerFullValue !== null || view.dealerVisibleValue) && (
<ValueBadge
value={view.dealerFullValue ?? view.dealerVisibleValue}
status={view.dealerFullValue !== null && view.dealerFullValue > 21 ? "bust" : undefined}
/>
)}
</div>
<div className="flex gap-1.5 flex-wrap">
{view.dealerHand.map((card, i) => (
<PlayingCard
key={`dealer-${card.suit}-${card.rank}-${i}`}
card={card}
faceDown={card.rank === "?"}
compact={compact}
/>
))}
</div>
</div>
{/* Divider */}
<div className="h-px bg-border" />
{/* Player hands */}
<div className="space-y-2">
{view.turnOrder.map(pid => {
const hand = view.hands[pid];
if (!hand) return null;
const isActive = view.activePlayerId === pid;
const isMe = !isSpectator && pid === myPlayerId;
return (
<PlayerHandSection
key={pid}
hand={hand}
playerName={getPlayerName(pid)}
isActive={isActive}
isMe={isMe}
compact={compact}
/>
);
})}
</div>
{/* Action buttons (only for current player on their turn) */}
{!isSpectator && !isResolved && playerView?.canAct && (
<div className="flex gap-3">
<div className="flex flex-col gap-3 w-full max-w-4xl mx-auto">
{/* Leave table button */}
{!isSpectator && (
<div className="flex justify-end">
<button
onClick={() => onAction({ type: "hit" })}
className="flex-1 flex items-center justify-center gap-2 rounded-xl bg-primary text-on-primary px-4 py-3 text-sm font-label font-semibold hover:opacity-90 transition-colors"
onClick={handleLeave}
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium bg-white/5 text-white/50 hover:text-white/80 hover:bg-white/10 transition-colors"
>
<Hand className="w-4 h-4" /> Hit
</button>
<button
onClick={() => onAction({ type: "stand" })}
className="flex-1 flex items-center justify-center gap-2 rounded-xl bg-raised text-foreground px-4 py-3 text-sm font-label font-semibold hover:bg-surface-container-high transition-colors"
>
Stand
<LogOut className="w-3 h-3" /> Leave Table
</button>
</div>
)}
{/* Waiting for other player */}
{!isSpectator && !isResolved && playerView && !playerView.canAct && (
<div className="rounded-xl bg-card px-4 py-2.5 text-sm text-text-tertiary text-center">
{view.activePlayerId
? `Waiting for ${getPlayerName(view.activePlayerId)}...`
: "Waiting..."}
{/* Table felt */}
<div className="relative bg-gradient-to-b from-emerald-800 to-emerald-900 rounded-2xl sm:rounded-[2rem] border-2 sm:border-4 border-emerald-950/60 shadow-[inset_0_0_60px_rgba(0,0,0,0.3)] overflow-hidden">
{/* ── Desktop layout (md+): absolute positioned seats ── */}
<div className="hidden md:block relative" style={{ aspectRatio: "16/10" }}>
{/* Dealer — top center */}
<div className="absolute top-[6%] left-1/2 -translate-x-1/2 z-10">
<DealerArea
dealerHand={view.dealerHand}
visibleValue={view.dealerVisibleValue}
fullValue={view.dealerFullValue}
/>
</div>
{/* Table text */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center pointer-events-none select-none">
<div className="text-white/[0.06] font-display text-4xl font-bold tracking-widest uppercase">
Blackjack
</div>
{isBetting && (
<div className="text-white/20 text-xs mt-1">
Round {view.roundNumber} Place your bets
</div>
)}
{isResolved && (
<div className="text-white/20 text-xs mt-1">
Round {view.roundNumber} Results
</div>
)}
</div>
{/* Seats in arc */}
{seatSlots.map((slot, i) => (
<div
key={i}
className="absolute -translate-x-1/2 -translate-y-1/2 z-10"
style={{ left: SEAT_POSITIONS[i]!.left, top: SEAT_POSITIONS[i]!.top }}
>
{slot.playerId && slot.seat ? (
<Seat
seat={slot.seat}
playerName={getPlayerName(slot.playerId)}
isMe={slot.playerId === myPlayerId}
isActivePlayer={view.activePlayerId === slot.playerId}
activeHandIdx={view.activePlayerId === slot.playerId ? view.activeHandIndex : -1}
betAmount={betAmount}
/>
) : (
<EmptySeat canSit={canSitDown} onSit={handleSit} />
)}
</div>
))}
</div>
{/* ── Mobile layout (<md): vertical stack ── */}
<div className="md:hidden flex flex-col gap-4 p-4">
{/* Dealer */}
<DealerArea
dealerHand={view.dealerHand}
visibleValue={view.dealerVisibleValue}
fullValue={view.dealerFullValue}
/>
{/* Divider */}
<div className="h-px bg-white/10" />
{/* Phase label */}
{isBetting && (
<div className="text-center text-white/30 text-xs">
Round {view.roundNumber} Place your bets
</div>
)}
{isResolved && (
<div className="text-center text-white/30 text-xs">
Round {view.roundNumber} Results
</div>
)}
{/* Player seats */}
<div className="space-y-3">
{seatSlots.map((slot, i) => {
if (!slot.playerId || !slot.seat) {
if (canSitDown && i === view.turnOrder.length) {
return (
<div key={i} className="flex justify-center">
<EmptySeat canSit={true} onSit={handleSit} />
</div>
);
}
return null;
}
return (
<div
key={slot.playerId}
className={`rounded-xl px-3 py-2 transition-colors ${
view.activePlayerId === slot.playerId
? "bg-white/5 ring-1 ring-primary/20"
: "bg-white/[0.02]"
}`}
>
<Seat
seat={slot.seat}
playerName={getPlayerName(slot.playerId)}
isMe={slot.playerId === myPlayerId}
isActivePlayer={view.activePlayerId === slot.playerId}
activeHandIdx={view.activePlayerId === slot.playerId ? view.activeHandIndex : -1}
betAmount={betAmount}
/>
</div>
);
})}
</div>
</div>
</div>
{/* Round result banner */}
{isResolved && roundResult && (
<RoundResultBanner
roundNumber={view.roundNumber}
roundResult={roundResult}
myPlayerId={myPlayerId}
betAmount={betAmount}
/>
)}
{/* ── Action bar ── */}
{!isSpectator && (
<div className="flex flex-col gap-2">
{/* Betting phase: place bet button */}
{isBetting && !myBetPlaced && (
<button
onClick={handlePlaceBet}
className="w-full flex items-center justify-center gap-2 rounded-xl bg-primary text-on-primary px-4 py-3 text-sm font-label font-semibold hover:opacity-90 transition-colors"
>
<Coins className="w-4 h-4" />
{betAmount > 0 ? `Place Bet (${betAmount} AU)` : "Ready Up"}
</button>
)}
{/* Betting phase: waiting for others */}
{isBetting && myBetPlaced && (
<div className="rounded-xl bg-card px-4 py-2.5 text-sm text-text-tertiary text-center">
Waiting for other players to bet...
</div>
)}
{/* Playing phase: action buttons */}
{isPlaying && playerView?.canAct && (
<div className="flex gap-2">
<button
onClick={handleHit}
className="flex-1 flex items-center justify-center gap-2 rounded-xl bg-primary text-on-primary px-4 py-3 text-sm font-label font-semibold hover:opacity-90 transition-colors"
>
<Hand className="w-4 h-4" /> Hit
</button>
<button
onClick={handleStand}
className="flex-1 flex items-center justify-center gap-2 rounded-xl bg-raised text-foreground px-4 py-3 text-sm font-label font-semibold hover:bg-surface-container-high transition-colors"
>
<Square className="w-3.5 h-3.5" /> Stand
</button>
{playerView.canSplit && (
<button
onClick={handleSplit}
className="flex items-center justify-center gap-2 rounded-xl bg-raised text-foreground px-4 py-3 text-sm font-label font-semibold hover:bg-surface-container-high transition-colors"
>
<ArrowLeftRight className="w-4 h-4" /> Split
</button>
)}
{playerView.canDoubleDown && (
<button
onClick={handleDouble}
className="flex items-center justify-center gap-2 rounded-xl bg-raised text-foreground px-4 py-3 text-sm font-label font-semibold hover:bg-surface-container-high transition-colors"
>
<ChevronsUp className="w-4 h-4" /> Double
</button>
)}
</div>
)}
{/* Playing phase: waiting for other player */}
{isPlaying && playerView && !playerView.canAct && (
<div className="rounded-xl bg-card px-4 py-2.5 text-sm text-text-tertiary text-center">
{view.activePlayerId
? `Waiting for ${getPlayerName(view.activePlayerId)}...`
: "Waiting..."}
</div>
)}
{/* Resolved phase: next round button */}
{isResolved && showNextRound && (
<button
onClick={handlePlaceBet}
className="w-full flex items-center justify-center gap-2 rounded-xl bg-primary text-on-primary px-4 py-3 text-sm font-label font-semibold hover:opacity-90 transition-colors"
>
<Coins className="w-4 h-4" />
{betAmount > 0 ? `Next Round (${betAmount} AU)` : "Next Round"}
</button>
)}
{/* Resolved phase: waiting for countdown */}
{isResolved && !showNextRound && (
<div className="rounded-xl bg-card px-4 py-2.5 text-sm text-text-tertiary text-center">
Round complete next round starting soon...
</div>
)}
</div>
)}
{/* Spectator indicator */}
{isSpectator && !isResolved && (
{isSpectator && !canSitDown && (
<div className="rounded-xl bg-card px-4 py-2.5 text-xs text-text-tertiary text-center">
{view.activePlayerId
{isPlaying && view.activePlayerId
? `${getPlayerName(view.activePlayerId)}'s turn`
: "Watching..."}
</div>
)}
{/* Spectator sit-down prompt */}
{canSitDown && (
<button
onClick={handleSit}
className="w-full flex items-center justify-center gap-2 rounded-xl bg-primary/10 text-primary px-4 py-3 text-sm font-label font-semibold hover:bg-primary/20 transition-colors"
>
Sit Down & Join
</button>
)}
</div>
);
}

View File

@@ -6,6 +6,8 @@ export interface GameUIProps {
isSpectator: boolean;
onAction: (action: unknown) => void;
players: { discordId: string; username: string }[];
roundResult?: { payouts: Record<string, { net: number }> } | null;
roomOptions?: { betAmount?: number };
}
export interface GameUIPlugin {

View File

@@ -7,6 +7,10 @@ interface PlayerInfo {
username: string;
}
interface RoundResult {
payouts: Record<string, { net: number }>;
}
interface GameRoomState {
gameState: unknown;
players: PlayerInfo[];
@@ -14,6 +18,7 @@ interface GameRoomState {
roomStatus: "connecting" | "waiting" | "playing" | "finished" | "not_found";
isSpectator: boolean;
gameOver: { winner: string | null; reason: string; payout?: { amount: number; refunded?: boolean } } | null;
roundResult: RoundResult | null;
error: string | null;
sessionReplaced: boolean;
roomOptions: { betAmount?: number };
@@ -36,6 +41,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
roomStatus: "connecting",
isSpectator: false,
gameOver: null,
roundResult: null,
error: null,
sessionReplaced: false,
roomOptions: {},
@@ -64,11 +70,17 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
case "GAME_STATE":
// Authoritative player view — sent directly to this player
setState(prev => ({
...prev,
gameState: msg.state,
roomStatus: prev.roomStatus === "finished" ? "finished" : "playing",
}));
setState(prev => {
// Clear round result when a new betting phase starts
const phase = (msg.state as any)?.phase;
const roundResult = phase === "betting" ? null : prev.roundResult;
return {
...prev,
gameState: msg.state,
roundResult,
roomStatus: prev.roomStatus === "finished" ? "finished" : "playing",
};
});
break;
case "GAME_STARTED":
@@ -116,6 +128,13 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
}));
break;
case "ROUND_SETTLED":
setState(prev => ({
...prev,
roundResult: { payouts: msg.payouts },
}));
break;
case "GAME_ENDED":
setState(prev => ({
...prev,

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,15 @@
import type { GamePlugin, GameResult, GameOverResult } from "../types";
import type {
BlackjackState, BlackjackAction, BlackjackPlayerView,
BlackjackSpectatorView, PlayerHandView, Card, Suit, Rank, PlayerHand,
BlackjackSpectatorView, PlayerHandView, PlayerSeatView,
Card, Suit, Rank, PlayerHand, PlayerSeat,
} from "./blackjack.types";
// ── Constants ──
const MAX_HANDS_PER_PLAYER = 4; // max 3 splits
const RESHUFFLE_THRESHOLD = 15; // reshuffle when deck drops below this
// ── Card helpers ──
const SUITS: Suit[] = ["hearts", "diamonds", "clubs", "spades"];
@@ -61,6 +67,19 @@ function drawCard(deck: Card[]): [Card, Card[]] {
return [deck[0]!, deck.slice(1)];
}
/** Check if two cards can be split (same rank). */
function canSplitHand(hand: PlayerHand, totalHands: number): boolean {
if (hand.cards.length !== 2) return false;
if (hand.status !== "playing") return false;
if (totalHands >= MAX_HANDS_PER_PLAYER) return false;
return cardValue(hand.cards[0]!.rank) === cardValue(hand.cards[1]!.rank);
}
/** Check if a hand can be doubled down (exactly 2 cards, still playing). */
function canDoubleHand(hand: PlayerHand): boolean {
return hand.cards.length === 2 && hand.status === "playing";
}
/** Play dealer hand: hit until 17+. */
function playDealerHand(dealerHand: Card[], deck: Card[]): { dealerHand: Card[]; deck: Card[] } {
let hand = [...dealerHand];
@@ -73,59 +92,154 @@ function playDealerHand(dealerHand: Card[], deck: Card[]): { dealerHand: Card[];
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 {
/** Find the next player who has a "playing" hand, starting after the given index. */
function findNextActivePlayer(state: BlackjackState, afterIndex: number): { playerIndex: number; handIndex: 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;
const seat = state.seats[state.turnOrder[i]!];
if (!seat) continue;
const hi = seat.hands.findIndex(h => h.status === "playing");
if (hi !== -1) return { playerIndex: i, handIndex: hi };
}
return -1; // no more active players
return { playerIndex: -1, handIndex: -1 };
}
/** Resolve all hands against the dealer, returning updated hands. */
function resolveAllHands(
hands: Record<string, PlayerHand>,
dealerHand: Card[],
): Record<string, PlayerHand> {
/** Advance to the next hand within the current player, or to the next player. */
function advanceTurn(state: BlackjackState): BlackjackState {
const activeId = state.turnOrder[state.activePlayerIndex];
if (!activeId) return finishPlayerTurns(state);
const seat = state.seats[activeId];
if (!seat) return finishPlayerTurns(state);
// Try next hand in current player's seat
for (let hi = seat.activeHandIndex + 1; hi < seat.hands.length; hi++) {
if (seat.hands[hi]!.status === "playing") {
return {
...state,
seats: {
...state.seats,
[activeId]: { ...seat, activeHandIndex: hi },
},
};
}
}
// No more hands for this player — move to next player
const next = findNextActivePlayer(state, state.activePlayerIndex);
if (next.playerIndex === -1) {
return finishPlayerTurns(state);
}
const nextId = state.turnOrder[next.playerIndex]!;
const nextSeat = state.seats[nextId]!;
return {
...state,
activePlayerIndex: next.playerIndex,
seats: {
...state.seats,
[activeId]: { ...seat, activeHandIndex: -1 },
[nextId]: { ...nextSeat, activeHandIndex: next.handIndex },
},
};
}
/** Resolve a single hand against the dealer. */
function resolveHand(hand: PlayerHand, dealerHand: Card[]): PlayerHand {
if (hand.result) return hand; // already resolved (bust, etc.)
const dealerVal = handValue(dealerHand);
const dealerBust = isBust(dealerHand);
const resolved: Record<string, PlayerHand> = {};
const playerVal = handValue(hand.cards);
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;
if (hand.status === "bust") {
return { ...hand, result: "lose", resultReason: "Player busts" };
}
if (hand.status === "blackjack") {
if (isNaturalBlackjack(dealerHand)) {
return { ...hand, result: "push", resultReason: "Both have Blackjack" };
}
return { ...hand, result: "blackjack", resultReason: "Blackjack!" };
}
const playerVal = handValue(hand.cards);
if (dealerBust) {
return { ...hand, result: "win", resultReason: "Dealer busts" };
}
if (playerVal > dealerVal) {
return { ...hand, result: "win", resultReason: "Higher hand" };
}
if (playerVal < dealerVal) {
return { ...hand, result: "lose", resultReason: "Dealer has higher hand" };
}
return { ...hand, result: "push", resultReason: "Push" };
}
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!" };
/** Transition to dealer turn + resolve all hands. Returns round payouts. */
function finishPlayerTurns(state: BlackjackState): BlackjackState {
const anyStood = Object.values(state.seats).some(
seat => seat.hands.some(h => h.status === "stood" || h.status === "blackjack"),
);
let dealerHand = state.dealerHand;
let deck = state.deck;
if (anyStood) {
const dealer = playDealerHand(dealerHand, deck);
dealerHand = dealer.dealerHand;
deck = dealer.deck;
}
const resolvedSeats: Record<string, PlayerSeat> = {};
for (const [id, seat] of Object.entries(state.seats)) {
resolvedSeats[id] = {
...seat,
activeHandIndex: -1,
hands: seat.hands.map(h => resolveHand(h, dealerHand)),
};
}
return {
...state,
deck,
dealerHand,
seats: resolvedSeats,
activePlayerIndex: -1,
phase: "resolved",
};
}
/** Calculate round payouts as multipliers of betAmount. */
function calculateRoundPayouts(seats: Record<string, PlayerSeat>): Record<string, number> {
const payouts: Record<string, number> = {};
for (const [playerId, seat] of Object.entries(seats)) {
let playerPayout = 0;
for (const hand of seat.hands) {
switch (hand.result) {
case "blackjack":
playerPayout += hand.bet * 2.5; // 3:2 payout
break;
case "win":
playerPayout += hand.bet * 2; // 1:1 payout
break;
case "push":
playerPayout += hand.bet * 1; // refund
break;
// "lose" / null → 0
}
} 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" };
}
if (playerPayout > 0) {
payouts[playerId] = playerPayout;
}
}
return resolved;
return payouts;
}
/** Build the dealer hand for views — hole card hidden during player_turns. */
/** Build the dealer hand for views — hole card hidden during player_turns/betting. */
function dealerVisibleHand(state: BlackjackState): Card[] {
if (state.phase === "resolved") return state.dealerHand;
if (state.dealerHand.length === 0) return [];
return [state.dealerHand[0]!, { suit: "spades", rank: "?" as Rank }];
}
@@ -137,33 +251,92 @@ function toHandView(hand: PlayerHand): PlayerHandView {
status: hand.status,
result: hand.result,
resultReason: hand.resultReason,
bet: hand.bet,
fromSplit: hand.fromSplit,
};
}
/** 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");
/** Convert internal PlayerSeat to a view. */
function toSeatView(seat: PlayerSeat): PlayerSeatView {
return {
hands: seat.hands.map(toHandView),
activeHandIndex: seat.activeHandIndex,
hasBet: seat.hasBet,
};
}
let dealerHand = state.dealerHand;
let deck = state.deck;
/** Deal cards to all seated players and the dealer. */
function dealRound(state: BlackjackState): BlackjackState {
let deck = state.deck.length < RESHUFFLE_THRESHOLD
? shuffleDeck(createDeck())
: [...state.deck];
if (anyStood) {
const dealer = playDealerHand(dealerHand, deck);
dealerHand = dealer.dealerHand;
deck = dealer.deck;
const seats: Record<string, PlayerSeat> = {};
// Deal 2 cards to each player
for (const pid of state.turnOrder) {
const cards: Card[] = [];
let card: Card;
[card, deck] = drawCard(deck); cards.push(card);
[card, deck] = drawCard(deck); cards.push(card);
const isBlackjack = isNaturalBlackjack(cards);
seats[pid] = {
hands: [{
cards,
status: isBlackjack ? "blackjack" : "playing",
result: null,
resultReason: null,
bet: 1,
fromSplit: false,
}],
activeHandIndex: -1,
hasBet: true,
};
}
const resolvedHands = resolveAllHands(state.hands, dealerHand);
// 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);
return {
let newState: BlackjackState = {
...state,
deck,
dealerHand,
hands: resolvedHands,
activePlayerIndex: -1,
phase: "resolved",
seats,
activePlayerIndex: 0,
phase: "player_turns",
};
// Find first player that needs to act (skip natural blackjacks)
const first = findNextActivePlayer(newState, -1);
if (first.playerIndex === -1) {
// All players have blackjack — resolve immediately
return finishPlayerTurns(newState);
}
newState.activePlayerIndex = first.playerIndex;
const firstId = newState.turnOrder[first.playerIndex]!;
newState.seats = {
...newState.seats,
[firstId]: { ...newState.seats[firstId]!, activeHandIndex: first.handIndex },
};
return newState;
}
/** Get the active player's active hand, or null. */
function getActiveHand(state: BlackjackState): { playerId: string; seat: PlayerSeat; hand: PlayerHand } | null {
if (state.phase !== "player_turns" || state.activePlayerIndex < 0) return null;
const playerId = state.turnOrder[state.activePlayerIndex];
if (!playerId) return null;
const seat = state.seats[playerId];
if (!seat || seat.activeHandIndex < 0) return null;
const hand = seat.hands[seat.activeHandIndex];
if (!hand) return null;
return { playerId, seat, hand };
}
// ── Plugin ──
@@ -176,104 +349,55 @@ export const blackjackPlugin: GamePlugin<BlackjackState, BlackjackAction> = {
manualStart: true,
createInitialState(players: string[], _options?: Record<string, unknown>): BlackjackState {
let deck = shuffleDeck(createDeck());
const turnOrder = [...players];
const hands: Record<string, PlayerHand> = {};
// 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,
const seats: Record<string, PlayerSeat> = {};
for (const pid of players) {
seats[pid] = {
hands: [],
activeHandIndex: -1,
hasBet: false,
};
}
// 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",
return {
deck: shuffleDeck(createDeck()),
dealerHand: [],
seats,
turnOrder: [...players],
activePlayerIndex: -1,
phase: "betting",
roundNumber: 1,
};
},
// 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);
getActionCost(state: BlackjackState, action: BlackjackAction, _playerId: string): number {
switch (action.type) {
case "place_bet": return 1;
case "split": return 1;
case "double_down": return 1;
default: return 0;
}
},
return state;
isSpectatorAction(action: BlackjackAction): boolean {
return action.type === "sit_down";
},
handleAction(state: BlackjackState, action: BlackjackAction, playerId: string): GameResult<BlackjackState> {
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 };
}
case "place_bet":
return handlePlaceBet(state, playerId);
case "hit":
return handleHit(state, playerId);
case "stand":
return handleStand(state, playerId);
case "split":
return handleSplit(state, playerId);
case "double_down":
return handleDoubleDown(state, playerId);
case "leave_table":
return handleLeaveTable(state, playerId);
case "sit_down":
return handleSitDown(state, playerId);
default:
return { ok: false, error: "Unknown action type" };
}
@@ -281,115 +405,418 @@ export const blackjackPlugin: GamePlugin<BlackjackState, BlackjackAction> = {
getPlayerView(state: BlackjackState, playerId: string): BlackjackPlayerView {
const visibleDealer = dealerVisibleHand(state);
const handsView: Record<string, PlayerHandView> = {};
for (const [id, hand] of Object.entries(state.hands)) {
handsView[id] = toHandView(hand);
const seatsView: Record<string, PlayerSeatView> = {};
for (const [id, seat] of Object.entries(state.seats)) {
seatsView[id] = toSeatView(seat);
}
const activeId = state.activePlayerIndex >= 0 ? state.turnOrder[state.activePlayerIndex] ?? null : null;
const activeId = state.activePlayerIndex >= 0
? state.turnOrder[state.activePlayerIndex] ?? null
: null;
const isMyTurn = activeId === playerId && state.phase === "player_turns";
const mySeat = state.seats[playerId];
const myActiveHand = mySeat && mySeat.activeHandIndex >= 0
? mySeat.hands[mySeat.activeHandIndex]
: null;
// Determine active hand index for the view
const activeHandIdx = activeId && state.seats[activeId]
? state.seats[activeId]!.activeHandIndex
: -1;
return {
dealerHand: visibleDealer,
dealerVisibleValue: cardValue(state.dealerHand[0]!.rank),
dealerVisibleValue: state.dealerHand.length > 0 ? cardValue(state.dealerHand[0]!.rank) : 0,
dealerFullValue: state.phase === "resolved" ? handValue(state.dealerHand) : null,
hands: handsView,
seats: seatsView,
turnOrder: state.turnOrder,
activePlayerId: activeId,
activeHandIndex: activeHandIdx,
myPlayerId: playerId,
phase: state.phase,
canAct: activeId === playerId && state.phase === "player_turns",
canAct: isMyTurn,
canSplit: isMyTurn && myActiveHand !== null && canSplitHand(myActiveHand, mySeat!.hands.length),
canDoubleDown: isMyTurn && myActiveHand !== null && canDoubleHand(myActiveHand),
roundNumber: state.roundNumber,
};
},
getSpectatorView(state: BlackjackState): BlackjackSpectatorView {
const visibleDealer = dealerVisibleHand(state);
const handsView: Record<string, PlayerHandView> = {};
for (const [id, hand] of Object.entries(state.hands)) {
handsView[id] = toHandView(hand);
const seatsView: Record<string, PlayerSeatView> = {};
for (const [id, seat] of Object.entries(state.seats)) {
seatsView[id] = toSeatView(seat);
}
const activeId = state.activePlayerIndex >= 0 ? state.turnOrder[state.activePlayerIndex] ?? null : null;
const activeId = state.activePlayerIndex >= 0
? state.turnOrder[state.activePlayerIndex] ?? null
: null;
const activeHandIdx = activeId && state.seats[activeId]
? state.seats[activeId]!.activeHandIndex
: -1;
return {
dealerHand: visibleDealer,
dealerVisibleValue: cardValue(state.dealerHand[0]!.rank),
dealerVisibleValue: state.dealerHand.length > 0 ? cardValue(state.dealerHand[0]!.rank) : 0,
dealerFullValue: state.phase === "resolved" ? handValue(state.dealerHand) : null,
hands: handsView,
seats: seatsView,
turnOrder: state.turnOrder,
activePlayerId: activeId,
activeHandIndex: activeHandIdx,
phase: state.phase,
roundNumber: state.roundNumber,
};
},
isGameOver(state: BlackjackState): GameOverResult | null {
if (state.phase !== "resolved") return null;
const payouts: Record<string, number> = {};
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
}
if (state.turnOrder.length === 0) {
return { winner: null, reason: "All players left the table", payouts: {} };
}
// 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,
};
return null;
},
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;
return removePlayer(state, playerId);
},
};
// ── Action handlers ──
function handlePlaceBet(state: BlackjackState, playerId: string): GameResult<BlackjackState> {
if (state.phase !== "betting" && state.phase !== "resolved") {
return { ok: false, error: "Cannot place bets right now" };
}
if (!state.turnOrder.includes(playerId)) {
return { ok: false, error: "You are not seated at this table" };
}
let newState = { ...state };
// Transition from resolved to betting for a new round
if (state.phase === "resolved") {
const deck = state.deck.length < RESHUFFLE_THRESHOLD
? shuffleDeck(createDeck())
: state.deck;
const resetSeats: Record<string, PlayerSeat> = {};
for (const pid of state.turnOrder) {
resetSeats[pid] = {
hands: [],
activeHandIndex: -1,
hasBet: false,
};
}
newState = {
...state,
deck,
dealerHand: [],
seats: resetSeats,
activePlayerIndex: -1,
phase: "betting",
roundNumber: state.roundNumber + 1,
};
}
const seat = newState.seats[playerId];
if (!seat) return { ok: false, error: "Seat not found" };
if (seat.hasBet) return { ok: false, error: "You have already placed your bet" };
newState = {
...newState,
seats: {
...newState.seats,
[playerId]: { ...seat, hasBet: true },
},
};
// Check if all seated players have bet
const allBet = newState.turnOrder.every(pid => newState.seats[pid]?.hasBet);
if (allBet) {
const dealtState = dealRound(newState);
if (dealtState.phase === "resolved") {
// All players got blackjack — round is already over
return { ok: true, state: dealtState, roundPayouts: calculateRoundPayouts(dealtState.seats) };
}
return { ok: true, state: dealtState };
}
return { ok: true, state: newState };
}
function handleHit(state: BlackjackState, playerId: string): GameResult<BlackjackState> {
const active = getActiveHand(state);
if (!active) return { ok: false, error: "Game is not in play" };
if (active.playerId !== playerId) return { ok: false, error: "It's not your turn" };
const [card, remaining] = drawCard(state.deck);
const newCards = [...active.hand.cards, card];
const bust = isBust(newCards);
const got21 = handValue(newCards) === 21;
const newHand: PlayerHand = {
...active.hand,
cards: newCards,
status: bust ? "bust" : got21 ? "stood" : "playing",
result: bust ? "lose" : null,
resultReason: bust ? "Player busts" : null,
};
const newHands = [...active.seat.hands];
newHands[active.seat.activeHandIndex] = newHand;
let newState: BlackjackState = {
...state,
deck: remaining,
seats: {
...state.seats,
[playerId]: { ...active.seat, hands: newHands },
},
};
if (bust || got21) {
newState = advanceTurn(newState);
}
if (newState.phase === "resolved") {
return { ok: true, state: newState, roundPayouts: calculateRoundPayouts(newState.seats) };
}
return { ok: true, state: newState };
}
function handleStand(state: BlackjackState, playerId: string): GameResult<BlackjackState> {
const active = getActiveHand(state);
if (!active) return { ok: false, error: "Game is not in play" };
if (active.playerId !== playerId) return { ok: false, error: "It's not your turn" };
const newHand: PlayerHand = { ...active.hand, status: "stood" };
const newHands = [...active.seat.hands];
newHands[active.seat.activeHandIndex] = newHand;
let newState: BlackjackState = {
...state,
seats: {
...state.seats,
[playerId]: { ...active.seat, hands: newHands },
},
};
newState = advanceTurn(newState);
if (newState.phase === "resolved") {
return { ok: true, state: newState, roundPayouts: calculateRoundPayouts(newState.seats) };
}
return { ok: true, state: newState };
}
function handleSplit(state: BlackjackState, playerId: string): GameResult<BlackjackState> {
const active = getActiveHand(state);
if (!active) return { ok: false, error: "Game is not in play" };
if (active.playerId !== playerId) return { ok: false, error: "It's not your turn" };
if (!canSplitHand(active.hand, active.seat.hands.length)) {
return { ok: false, error: "Cannot split this hand" };
}
const [card1, deck1] = drawCard(state.deck);
const [card2, deck2] = drawCard(deck1);
const isAceSplit = active.hand.cards[0]!.rank === "A";
const hand1Cards = [active.hand.cards[0]!, card1];
const hand2Cards = [active.hand.cards[1]!, card2];
// Split aces: auto-stand, and 21 on split is not blackjack
const hand1Status = isAceSplit ? "stood" as const
: handValue(hand1Cards) === 21 ? "stood" as const
: "playing" as const;
const hand2Status = isAceSplit ? "stood" as const
: handValue(hand2Cards) === 21 ? "stood" as const
: "playing" as const;
const hand1: PlayerHand = {
cards: hand1Cards,
status: hand1Status,
result: null,
resultReason: null,
bet: 1,
fromSplit: true,
};
const hand2: PlayerHand = {
cards: hand2Cards,
status: hand2Status,
result: null,
resultReason: null,
bet: 1,
fromSplit: true,
};
const newHands = [...active.seat.hands];
newHands.splice(active.seat.activeHandIndex, 1, hand1, hand2);
// Find the first playable hand starting from the split position
let newActiveHandIndex = active.seat.activeHandIndex;
if (hand1.status !== "playing") {
if (hand2.status !== "playing") {
newActiveHandIndex = -1; // both auto-stood
} else {
newActiveHandIndex = active.seat.activeHandIndex + 1;
}
}
let newState: BlackjackState = {
...state,
deck: deck2,
seats: {
...state.seats,
[playerId]: {
...active.seat,
hands: newHands,
activeHandIndex: newActiveHandIndex,
},
},
};
// If both split hands auto-stood (aces), advance turn
if (newActiveHandIndex === -1 || newHands[newActiveHandIndex]?.status !== "playing") {
newState = advanceTurn(newState);
}
if (newState.phase === "resolved") {
return { ok: true, state: newState, roundPayouts: calculateRoundPayouts(newState.seats) };
}
return { ok: true, state: newState };
}
function handleDoubleDown(state: BlackjackState, playerId: string): GameResult<BlackjackState> {
const active = getActiveHand(state);
if (!active) return { ok: false, error: "Game is not in play" };
if (active.playerId !== playerId) return { ok: false, error: "It's not your turn" };
if (!canDoubleHand(active.hand)) {
return { ok: false, error: "Cannot double down on this hand" };
}
const [card, remaining] = drawCard(state.deck);
const newCards = [...active.hand.cards, card];
const bust = isBust(newCards);
const newHand: PlayerHand = {
...active.hand,
cards: newCards,
bet: 2,
status: bust ? "bust" : "stood",
result: bust ? "lose" : null,
resultReason: bust ? "Player busts" : null,
};
const newHands = [...active.seat.hands];
newHands[active.seat.activeHandIndex] = newHand;
let newState: BlackjackState = {
...state,
deck: remaining,
seats: {
...state.seats,
[playerId]: { ...active.seat, hands: newHands },
},
};
newState = advanceTurn(newState);
if (newState.phase === "resolved") {
return { ok: true, state: newState, roundPayouts: calculateRoundPayouts(newState.seats) };
}
return { ok: true, state: newState };
}
function handleLeaveTable(state: BlackjackState, playerId: string): GameResult<BlackjackState> {
if (!state.turnOrder.includes(playerId)) {
return { ok: false, error: "You are not seated at this table" };
}
const newState = removePlayer(state, playerId);
return { ok: true, state: newState };
}
function handleSitDown(state: BlackjackState, playerId: string): GameResult<BlackjackState> {
if (state.phase !== "betting") {
return { ok: false, error: "You can only sit down during the betting phase" };
}
if (state.turnOrder.includes(playerId)) {
return { ok: false, error: "You are already seated" };
}
if (state.turnOrder.length >= 6) {
return { ok: false, error: "Table is full" };
}
return {
ok: true,
state: {
...state,
turnOrder: [...state.turnOrder, playerId],
seats: {
...state.seats,
[playerId]: {
hands: [],
activeHandIndex: -1,
hasBet: false,
},
},
},
};
}
/** Remove a player from the table, handling mid-round cleanup. */
function removePlayer(state: BlackjackState, playerId: string): BlackjackState {
if (!state.turnOrder.includes(playerId)) return state;
const playerIdx = state.turnOrder.indexOf(playerId);
const newTurnOrder = state.turnOrder.filter(id => id !== playerId);
const { [playerId]: _, ...remainingSeats } = state.seats;
let newState: BlackjackState = {
...state,
turnOrder: newTurnOrder,
seats: remainingSeats,
};
// Adjust activePlayerIndex if we're in player_turns
if (state.phase === "player_turns") {
const wasActive = state.activePlayerIndex === playerIdx;
if (wasActive) {
// The leaving player was the active player — find next
// Since we removed the player, the index effectively points to the next player
const adjustedIndex = playerIdx >= newTurnOrder.length ? newTurnOrder.length - 1 : playerIdx;
newState.activePlayerIndex = adjustedIndex;
// Try to find a playing hand from this adjusted index onward
const next = findNextActivePlayer(
{ ...newState, activePlayerIndex: adjustedIndex - 1 },
adjustedIndex - 1,
);
if (next.playerIndex === -1) {
if (newTurnOrder.length > 0) {
newState = finishPlayerTurns(newState);
}
} else {
newState.activePlayerIndex = next.playerIndex;
const nextId = newTurnOrder[next.playerIndex]!;
newState.seats = {
...newState.seats,
[nextId]: { ...newState.seats[nextId]!, activeHandIndex: next.handIndex },
};
}
} else if (playerIdx < state.activePlayerIndex) {
// Player was before the active player — shift index down
newState.activePlayerIndex = state.activePlayerIndex - 1;
}
}
return newState;
}

View File

@@ -6,13 +6,27 @@ export interface Card {
rank: Rank;
}
// ── Per-player hand state ──
// ── Per-hand state ──
export interface PlayerHand {
cards: Card[];
status: "playing" | "stood" | "bust" | "blackjack";
result: "win" | "blackjack" | "push" | "lose" | null;
resultReason: string | null;
/** Bet multiplier of room betAmount (1 = normal, 2 = doubled). */
bet: number;
/** Whether this hand was created via a split. */
fromSplit: boolean;
}
// ── Per-player seat ──
export interface PlayerSeat {
hands: PlayerHand[];
/** Index of the hand currently being played, -1 when not active. */
activeHandIndex: number;
/** Whether the player has placed their bet for the current round. */
hasBet: boolean;
}
// ── Game state ──
@@ -20,17 +34,23 @@ export interface PlayerHand {
export interface BlackjackState {
deck: Card[];
dealerHand: Card[];
hands: Record<string, PlayerHand>;
seats: Record<string, PlayerSeat>;
turnOrder: string[];
activePlayerIndex: number; // index into turnOrder, -1 when no active player
phase: "player_turns" | "resolved";
activePlayerIndex: number;
phase: "betting" | "player_turns" | "resolved";
roundNumber: number;
}
// ── Actions ──
export type BlackjackAction =
| { type: "hit" }
| { type: "stand" };
| { type: "stand" }
| { type: "split" }
| { type: "double_down" }
| { type: "place_bet" }
| { type: "leave_table" }
| { type: "sit_down" };
// ── Views ──
@@ -40,26 +60,40 @@ export interface PlayerHandView {
status: "playing" | "stood" | "bust" | "blackjack";
result: "win" | "blackjack" | "push" | "lose" | null;
resultReason: string | null;
bet: number;
fromSplit: boolean;
}
export interface PlayerSeatView {
hands: PlayerHandView[];
activeHandIndex: number;
hasBet: boolean;
}
export interface BlackjackPlayerView {
dealerHand: Card[];
dealerVisibleValue: number;
dealerFullValue: number | null;
hands: Record<string, PlayerHandView>;
seats: Record<string, PlayerSeatView>;
turnOrder: string[];
activePlayerId: string | null;
activeHandIndex: number;
myPlayerId: string;
phase: "player_turns" | "resolved";
phase: "betting" | "player_turns" | "resolved";
canAct: boolean;
canSplit: boolean;
canDoubleDown: boolean;
roundNumber: number;
}
export interface BlackjackSpectatorView {
dealerHand: Card[];
dealerVisibleValue: number;
dealerFullValue: number | null;
hands: Record<string, PlayerHandView>;
seats: Record<string, PlayerSeatView>;
turnOrder: string[];
activePlayerId: string | null;
phase: "player_turns" | "resolved";
activeHandIndex: number;
phase: "betting" | "player_turns" | "resolved";
roundNumber: number;
}

View File

@@ -13,10 +13,16 @@ export interface GamePlugin<TState = unknown, TAction = unknown> {
isGameOver?(state: TState): GameOverResult | null;
onPlayerDisconnect?(state: TState, playerId: string): TState;
/** Cost of an action in betAmount units (0 = free). Checked before handleAction. */
getActionCost?(state: TState, action: TAction, playerId: string): number;
/** Whether a spectator can send this action (e.g., sit_down to join mid-session). */
isSpectatorAction?(action: TAction): boolean;
}
export type GameResult<TState> =
| { ok: true; state: TState }
| { ok: true; state: TState; roundPayouts?: Record<string, number> }
| { ok: false; error: string };
export type GameOverResult = {