- Split chess and blackjack setup into guided creation steps - Add chess time control presets and reusable lobby room metrics - Improve room filtering, ordering, and live connection state
219 lines
8.4 KiB
TypeScript
219 lines
8.4 KiB
TypeScript
import { useEffect, useState, useCallback, useRef } from "react";
|
|
import { useWebSocket } from "./useWebSocket";
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
interface PlayerInfo {
|
|
discordId: string;
|
|
username: string;
|
|
}
|
|
|
|
interface RoundResult {
|
|
settlements: Record<string, { wager: number; payout: number; net: number }>;
|
|
}
|
|
|
|
interface GameRoomState {
|
|
gameState: unknown;
|
|
players: PlayerInfo[];
|
|
spectators: PlayerInfo[];
|
|
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; timeControl?: string };
|
|
}
|
|
|
|
function createInitialRoomState(): GameRoomState {
|
|
return {
|
|
gameState: null,
|
|
players: [],
|
|
spectators: [],
|
|
roomStatus: "connecting",
|
|
isSpectator: false,
|
|
gameOver: null,
|
|
roundResult: null,
|
|
error: null,
|
|
sessionReplaced: false,
|
|
roomOptions: {},
|
|
};
|
|
}
|
|
|
|
export function useGameRoom(roomId: string, userId: string, role?: string, preferAs: "player" | "spectator" = "player") {
|
|
const { send, subscribe, connected } = useWebSocket();
|
|
const navigate = useNavigate();
|
|
const navigateRef = useRef(navigate);
|
|
const errorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
useEffect(() => {
|
|
navigateRef.current = navigate;
|
|
}, [navigate]);
|
|
useEffect(() => () => {
|
|
if (errorTimerRef.current !== null) {
|
|
clearTimeout(errorTimerRef.current);
|
|
}
|
|
}, []);
|
|
|
|
const [state, setState] = useState<GameRoomState>(() => createInitialRoomState());
|
|
|
|
useEffect(() => {
|
|
if (!connected) return;
|
|
|
|
setState(createInitialRoomState());
|
|
|
|
send({ type: "JOIN_ROOM", roomId, preferAs, role: role ?? "player" });
|
|
|
|
const unsubscribe = subscribe((msg: any) => {
|
|
if (msg.roomId && msg.roomId !== roomId) return;
|
|
|
|
switch (msg.type) {
|
|
case "JOIN_RESULT":
|
|
setState(prev => ({
|
|
...prev,
|
|
isSpectator: msg.joinedAs === "spectator",
|
|
roomStatus: msg.roomStatus,
|
|
players: msg.players ?? prev.players,
|
|
spectators: msg.spectators ?? prev.spectators,
|
|
gameState: msg.state !== undefined ? msg.state : prev.gameState,
|
|
roomOptions: msg.roomOptions ?? prev.roomOptions,
|
|
}));
|
|
break;
|
|
|
|
case "GAME_STATE":
|
|
// Authoritative player view — sent directly to this player
|
|
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":
|
|
// Broadcast with spectator view — only use for state if we're a spectator
|
|
// (players get their own GAME_STATE via direct send)
|
|
setState(prev => ({
|
|
...prev,
|
|
gameState: prev.isSpectator ? msg.state : prev.gameState,
|
|
roomStatus: "playing",
|
|
}));
|
|
break;
|
|
|
|
case "GAME_UPDATE":
|
|
// Broadcast with spectator view — only update state for spectators
|
|
setState(prev => {
|
|
if (!prev.isSpectator) return prev;
|
|
const phase = (msg.state as any)?.phase;
|
|
return {
|
|
...prev,
|
|
gameState: msg.state,
|
|
roundResult: phase === "betting" ? null : prev.roundResult,
|
|
};
|
|
});
|
|
break;
|
|
|
|
case "PLAYER_JOINED":
|
|
setState(prev => {
|
|
if (msg.joinedAs === "spectator") {
|
|
const isMe = msg.player.discordId === userId;
|
|
return {
|
|
...prev,
|
|
spectators: [...prev.spectators.filter(s => s.discordId !== msg.player.discordId), msg.player],
|
|
isSpectator: isMe || prev.isSpectator,
|
|
roomStatus: prev.roomStatus === "connecting" ? "waiting" : prev.roomStatus,
|
|
};
|
|
}
|
|
|
|
const isMe = msg.player.discordId === userId;
|
|
return {
|
|
...prev,
|
|
players: [...prev.players.filter(p => p.discordId !== msg.player.discordId), msg.player],
|
|
isSpectator: isMe ? false : prev.isSpectator,
|
|
roomStatus: prev.roomStatus === "connecting" ? "waiting" : prev.roomStatus,
|
|
};
|
|
});
|
|
break;
|
|
|
|
case "PLAYER_LEFT":
|
|
setState(prev => ({
|
|
...prev,
|
|
players: prev.players.filter(p => p.discordId !== msg.playerId),
|
|
spectators: prev.spectators.filter(s => s.discordId !== msg.playerId),
|
|
}));
|
|
break;
|
|
|
|
case "ROUND_SETTLED":
|
|
setState(prev => ({
|
|
...prev,
|
|
roundResult: { settlements: msg.settlements },
|
|
}));
|
|
break;
|
|
|
|
case "GAME_ENDED":
|
|
setState(prev => ({
|
|
...prev,
|
|
roomStatus: "finished",
|
|
gameOver: { winner: msg.winner, reason: msg.reason, payout: msg.payout },
|
|
}));
|
|
break;
|
|
|
|
case "SESSION_REPLACED":
|
|
setState(prev => ({ ...prev, sessionReplaced: true }));
|
|
break;
|
|
|
|
case "ERROR":
|
|
if (msg.message === "Room not found") {
|
|
setState(prev => ({ ...prev, roomStatus: "not_found" }));
|
|
setTimeout(() => navigateRef.current("/games"), 2000);
|
|
} else {
|
|
setState(prev => ({ ...prev, error: msg.message }));
|
|
if (errorTimerRef.current !== null) {
|
|
clearTimeout(errorTimerRef.current);
|
|
}
|
|
errorTimerRef.current = setTimeout(() => {
|
|
setState(prev => ({ ...prev, error: null }));
|
|
}, 5000);
|
|
}
|
|
break;
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
send({ type: "LEAVE_ROOM", roomId });
|
|
unsubscribe();
|
|
};
|
|
}, [connected, preferAs, role, roomId, send, subscribe, userId]);
|
|
|
|
const sendAction = useCallback((action: unknown) => {
|
|
const sent = send({ type: "GAME_ACTION", roomId, action });
|
|
if (!sent) {
|
|
setState(prev => ({ ...prev, error: "Not connected — action not sent." }));
|
|
return;
|
|
}
|
|
setState(prev => ({ ...prev, error: null }));
|
|
}, [roomId, send]);
|
|
|
|
const leaveRoom = useCallback(() => {
|
|
send({ type: "LEAVE_ROOM", roomId });
|
|
}, [roomId, send]);
|
|
|
|
const rejoin = useCallback(() => {
|
|
send({ type: "JOIN_ROOM", roomId, preferAs, role: role ?? "player" });
|
|
setState(prev => ({ ...prev, sessionReplaced: false }));
|
|
}, [roomId, preferAs, role, send]);
|
|
|
|
const fillRoom = useCallback(() => {
|
|
send({ type: "FILL_ROOM", roomId });
|
|
}, [roomId, send]);
|
|
|
|
const startGame = useCallback(() => {
|
|
send({ type: "START_GAME", roomId });
|
|
}, [roomId, send]);
|
|
|
|
return { ...state, sendAction, leaveRoom, rejoin, fillRoom, startGame };
|
|
}
|