Some checks failed
Deploy to Production / test (push) Failing after 39s
Adds a full blackjack game with dealer AI, hit/stand/double-down actions, and per-player payout multipliers (house-edge model). Extends the game framework with manualStart support and a START_GAME WebSocket message so hosts can begin when ready. Generalizes bet settlement transaction descriptions from chess-specific to game-agnostic. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
227 lines
11 KiB
TypeScript
227 lines
11 KiB
TypeScript
import { useState } from "react";
|
|
import { useParams, useNavigate, useLocation } from "react-router-dom";
|
|
import { useGameRoom } from "../lib/useGameRoom";
|
|
import { gameUIRegistry } from "./registry";
|
|
import { Loader2 } from "lucide-react";
|
|
|
|
function CopyInviteLink({ url }: { url: string }) {
|
|
const [copied, setCopied] = useState(false);
|
|
function copy() {
|
|
navigator.clipboard.writeText(url).then(() => {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
});
|
|
}
|
|
return (
|
|
<div className="flex flex-col items-center gap-2">
|
|
<div className="text-xs text-text-disabled mb-1">Share this link to invite:</div>
|
|
<div className="flex items-center gap-2 w-full max-w-sm">
|
|
<span className="flex-1 font-mono bg-card rounded-lg px-2 py-1.5 text-[11px] text-text-tertiary truncate">
|
|
{url}
|
|
</span>
|
|
<button
|
|
onClick={copy}
|
|
className={`shrink-0 rounded px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
copied
|
|
? "bg-success/15 text-success"
|
|
: "bg-raised text-text-tertiary hover:text-foreground"
|
|
}`}
|
|
>
|
|
{copied ? "Copied!" : "Copy"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
|
const { gameSlug, roomId } = useParams<{ gameSlug: string; roomId: string }>();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const preferAs = (location.state as { preferAs?: "player" | "spectator" } | null)?.preferAs ?? "player";
|
|
|
|
const {
|
|
gameState, players, spectators, roomStatus,
|
|
isSpectator, gameOver, error, sendAction, leaveRoom, sessionReplaced, rejoin, fillRoom, startGame, roomOptions,
|
|
} = useGameRoom(roomId!, userId, role, preferAs);
|
|
|
|
const betAmount = roomOptions.betAmount ?? 0;
|
|
|
|
const plugin = gameSlug ? gameUIRegistry.get(gameSlug) : undefined;
|
|
|
|
if (!plugin) {
|
|
return (
|
|
<div className="text-center py-16">
|
|
<div className="text-lg font-display font-semibold mb-2">Unknown Game</div>
|
|
<p className="text-sm text-text-tertiary mb-4">The game type "{gameSlug}" doesn't exist.</p>
|
|
<button onClick={() => navigate("/games")} className="text-sm text-primary hover:underline">
|
|
Back to Games
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (roomStatus === "not_found") {
|
|
return (
|
|
<div className="text-center py-16">
|
|
<div className="text-lg font-display font-semibold mb-2">Room Not Found</div>
|
|
<p className="text-sm text-text-tertiary mb-4">This room no longer exists or has expired.</p>
|
|
<button onClick={() => navigate("/games")} className="text-sm text-primary hover:underline">
|
|
Back to Games
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (roomStatus === "connecting") {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
|
<Loader2 className="w-6 h-6 animate-spin text-text-tertiary" />
|
|
<p className="text-sm text-text-tertiary">
|
|
{preferAs === "spectator" ? "Joining as spectator..." : "Joining room..."}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const GameComponent = plugin.component;
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between gap-3 mb-4 md:mb-6">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<span className="text-xl shrink-0">{plugin.icon}</span>
|
|
<div className="min-w-0">
|
|
<h1 className="font-display text-base font-semibold truncate">{plugin.name}</h1>
|
|
<div className="flex items-center gap-2 text-xs text-text-tertiary">
|
|
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${
|
|
roomStatus === "waiting" ? "bg-warning/15 text-warning"
|
|
: roomStatus === "playing" ? "bg-success/15 text-success"
|
|
: "bg-card text-text-tertiary"
|
|
}`}>
|
|
{roomStatus === "waiting" ? "Waiting" : roomStatus === "playing" ? "Playing" : "Finished"}
|
|
</span>
|
|
{isSpectator && <span className="text-text-disabled">Spectating</span>}
|
|
{betAmount > 0 && (
|
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-warning/15 text-warning">
|
|
{betAmount} AU{players.length > 1 ? `/player` : ""}
|
|
</span>
|
|
)}
|
|
<span>👁 {spectators.length}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => { leaveRoom(); navigate("/games"); }}
|
|
className="rounded-md px-3 py-1.5 text-sm font-medium bg-raised text-text-tertiary hover:text-foreground transition-colors shrink-0"
|
|
>
|
|
Leave
|
|
</button>
|
|
</div>
|
|
|
|
{sessionReplaced && (
|
|
<div className="mb-4 rounded-xl bg-warning/10 px-4 py-3 flex items-center justify-between gap-3">
|
|
<p className="text-sm text-warning">
|
|
You opened this game in another tab. Actions from this tab are disabled.
|
|
</p>
|
|
<button
|
|
onClick={rejoin}
|
|
className="shrink-0 text-xs font-medium text-warning underline hover:no-underline"
|
|
>
|
|
Rejoin here
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="mb-4 rounded-xl bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{gameOver && (
|
|
<div className="mb-4 rounded-xl bg-primary/10 px-4 py-3">
|
|
<div className="text-sm font-semibold text-primary">
|
|
{gameOver.winner
|
|
? `Winner: ${players.find(p => p.discordId === gameOver.winner)?.username ?? gameOver.winner}`
|
|
: "Draw!"}
|
|
</div>
|
|
<div className="text-xs text-text-tertiary mt-1">{gameOver.reason}</div>
|
|
{gameOver.payout && (
|
|
<div className="text-xs font-semibold text-warning mt-1.5">
|
|
{gameOver.payout.refunded
|
|
? `Wager refunded: ${gameOver.payout.amount} AU`
|
|
: `Payout: ${gameOver.payout.amount} AU`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{roomStatus === "finished" && (
|
|
<div className="mt-4 text-center">
|
|
<button
|
|
onClick={() => { leaveRoom(); navigate("/games"); }}
|
|
className="rounded-xl bg-primary text-on-primary px-5 py-2 text-sm font-label font-medium hover:opacity-90 transition-colors"
|
|
>
|
|
Back to Lobby
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{roomStatus === "waiting" && (
|
|
<div className="bg-card rounded-xl p-5 md:p-8">
|
|
<div className="text-sm font-semibold mb-4 text-center">
|
|
Waiting for players ({players.length}/{plugin.maxPlayers})
|
|
</div>
|
|
<div className="flex gap-3 justify-center mb-6 flex-wrap">
|
|
{Array.from({ length: Math.max(players.length + 1, plugin.minPlayers ?? 1, Math.min(plugin.maxPlayers, 4)) }).map((_, i) => {
|
|
if (i >= plugin.maxPlayers) return null;
|
|
const player = players[i];
|
|
return (
|
|
<div key={i} className={`flex flex-col items-center gap-2 px-4 py-3 rounded-xl ${player ? "bg-primary/10" : "bg-surface"}`}>
|
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold ${player ? "bg-primary/20 text-primary" : "bg-surface text-text-disabled animate-pulse"}`}>
|
|
{player ? player.username[0]?.toUpperCase() : "?"}
|
|
</div>
|
|
<div className="text-xs font-medium">
|
|
{player ? player.username : <span className="text-text-disabled">Waiting...</span>}
|
|
</div>
|
|
<div className="text-[10px] text-text-disabled">
|
|
Player {i + 1}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<CopyInviteLink url={window.location.href} />
|
|
{plugin.manualStart && players.length >= (plugin.minPlayers ?? 1) && players[0]?.discordId === userId && (
|
|
<button
|
|
onClick={startGame}
|
|
className="mt-4 w-full max-w-sm mx-auto block rounded-xl bg-primary text-on-primary px-4 py-2.5 text-sm font-label font-semibold hover:opacity-90 transition-colors"
|
|
>
|
|
Start Game ({players.length} player{players.length !== 1 ? "s" : ""})
|
|
</button>
|
|
)}
|
|
{role === "admin" && players.length < plugin.maxPlayers && (
|
|
<button
|
|
onClick={fillRoom}
|
|
className="mt-4 w-full max-w-sm rounded-md px-4 py-2 text-sm font-medium bg-warning/10 text-warning border border-warning/30 hover:bg-warning/20 transition-colors"
|
|
>
|
|
Start Solo Test
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{(roomStatus === "playing" || roomStatus === "finished") && gameState != null && (
|
|
<GameComponent
|
|
state={gameState}
|
|
myPlayerId={userId}
|
|
isSpectator={isSpectator}
|
|
onAction={sendAction}
|
|
players={players}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|