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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user