fmt
This commit is contained in:
@@ -1,390 +1,390 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
DEFAULT_WS_URL,
|
||||
ParsedMessage,
|
||||
RECONNECT_INTERVAL_MS,
|
||||
RECONNECT_TIMEOUT_MS,
|
||||
cmd,
|
||||
parseMessage,
|
||||
DEFAULT_WS_URL,
|
||||
ParsedMessage,
|
||||
RECONNECT_INTERVAL_MS,
|
||||
RECONNECT_TIMEOUT_MS,
|
||||
cmd,
|
||||
parseMessage,
|
||||
} from "@/lib/protocol";
|
||||
|
||||
export type ConnectionRole = "observer" | "player";
|
||||
export type ConnectionStatus =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "disconnected";
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "disconnected";
|
||||
|
||||
type MessageListener = (message: ParsedMessage, raw: string) => void;
|
||||
|
||||
interface ConnectOptions {
|
||||
role: ConnectionRole;
|
||||
wsUrl: string;
|
||||
username?: string;
|
||||
role: ConnectionRole;
|
||||
wsUrl: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface ConnectionContextValue {
|
||||
role: ConnectionRole | null;
|
||||
wsUrl: string;
|
||||
username: string;
|
||||
status: ConnectionStatus;
|
||||
isInMatch: boolean;
|
||||
reconnectAttempts: number;
|
||||
shouldRedirectToConnect: boolean;
|
||||
becomePlayer: (username: string) => void;
|
||||
connect: (options: ConnectOptions) => void;
|
||||
disconnect: () => void;
|
||||
send: (message: string) => boolean;
|
||||
subscribe: (listener: MessageListener) => () => void;
|
||||
clearRedirectFlag: () => void;
|
||||
role: ConnectionRole | null;
|
||||
wsUrl: string;
|
||||
username: string;
|
||||
status: ConnectionStatus;
|
||||
isInMatch: boolean;
|
||||
reconnectAttempts: number;
|
||||
shouldRedirectToConnect: boolean;
|
||||
becomePlayer: (username: string) => void;
|
||||
connect: (options: ConnectOptions) => void;
|
||||
disconnect: () => void;
|
||||
send: (message: string) => boolean;
|
||||
subscribe: (listener: MessageListener) => () => void;
|
||||
clearRedirectFlag: () => void;
|
||||
}
|
||||
|
||||
const ConnectionContext = createContext<ConnectionContextValue | null>(null);
|
||||
|
||||
interface SessionState {
|
||||
role: ConnectionRole;
|
||||
wsUrl: string;
|
||||
username: string;
|
||||
role: ConnectionRole;
|
||||
wsUrl: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export function ConnectionProvider({
|
||||
children,
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [role, setRole] = useState<ConnectionRole | null>(null);
|
||||
const [wsUrl, setWsUrl] = useState(DEFAULT_WS_URL);
|
||||
const [username, setUsername] = useState("");
|
||||
const [status, setStatus] = useState<ConnectionStatus>("idle");
|
||||
const [isInMatch, setIsInMatch] = useState(false);
|
||||
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
||||
const [shouldRedirectToConnect, setShouldRedirectToConnect] = useState(false);
|
||||
const [role, setRole] = useState<ConnectionRole | null>(null);
|
||||
const [wsUrl, setWsUrl] = useState(DEFAULT_WS_URL);
|
||||
const [username, setUsername] = useState("");
|
||||
const [status, setStatus] = useState<ConnectionStatus>("idle");
|
||||
const [isInMatch, setIsInMatch] = useState(false);
|
||||
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
||||
const [shouldRedirectToConnect, setShouldRedirectToConnect] = useState(false);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const listenersRef = useRef<Set<MessageListener>>(new Set());
|
||||
const manualCloseRef = useRef(false);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectDeadlineRef = useRef<number | null>(null);
|
||||
const reconnectActiveRef = useRef(false);
|
||||
const isInMatchRef = useRef(false);
|
||||
const sessionRef = useRef<SessionState | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const listenersRef = useRef<Set<MessageListener>>(new Set());
|
||||
const manualCloseRef = useRef(false);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectDeadlineRef = useRef<number | null>(null);
|
||||
const reconnectActiveRef = useRef(false);
|
||||
const isInMatchRef = useRef(false);
|
||||
const sessionRef = useRef<SessionState | null>(null);
|
||||
|
||||
const clearReconnectTimer = useCallback(() => {
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
const clearReconnectTimer = useCallback(() => {
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearReconnectState = useCallback(() => {
|
||||
reconnectActiveRef.current = false;
|
||||
reconnectDeadlineRef.current = null;
|
||||
clearReconnectTimer();
|
||||
setReconnectAttempts(0);
|
||||
}, [clearReconnectTimer]);
|
||||
const clearReconnectState = useCallback(() => {
|
||||
reconnectActiveRef.current = false;
|
||||
reconnectDeadlineRef.current = null;
|
||||
clearReconnectTimer();
|
||||
setReconnectAttempts(0);
|
||||
}, [clearReconnectTimer]);
|
||||
|
||||
const emitMessage = useCallback((message: ParsedMessage, raw: string) => {
|
||||
listenersRef.current.forEach((listener) => listener(message, raw));
|
||||
}, []);
|
||||
const emitMessage = useCallback((message: ParsedMessage, raw: string) => {
|
||||
listenersRef.current.forEach((listener) => listener(message, raw));
|
||||
}, []);
|
||||
|
||||
const safeCloseSocket = useCallback(() => {
|
||||
const current = wsRef.current;
|
||||
if (!current) return;
|
||||
current.onopen = null;
|
||||
current.onmessage = null;
|
||||
current.onclose = null;
|
||||
current.onerror = null;
|
||||
try {
|
||||
current.close();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
wsRef.current = null;
|
||||
}, []);
|
||||
const safeCloseSocket = useCallback(() => {
|
||||
const current = wsRef.current;
|
||||
if (!current) return;
|
||||
current.onopen = null;
|
||||
current.onmessage = null;
|
||||
current.onclose = null;
|
||||
current.onerror = null;
|
||||
try {
|
||||
current.close();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
wsRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
const currentRole = sessionRef.current?.role;
|
||||
const handleDisconnect = useCallback(() => {
|
||||
const currentRole = sessionRef.current?.role;
|
||||
|
||||
if (currentRole === "observer") {
|
||||
clearReconnectState();
|
||||
setStatus("disconnected");
|
||||
setShouldRedirectToConnect(true);
|
||||
return;
|
||||
}
|
||||
if (currentRole === "observer") {
|
||||
clearReconnectState();
|
||||
setStatus("disconnected");
|
||||
setShouldRedirectToConnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentRole === "player" && isInMatchRef.current) {
|
||||
if (reconnectActiveRef.current) {
|
||||
setStatus("reconnecting");
|
||||
return;
|
||||
}
|
||||
reconnectActiveRef.current = true;
|
||||
reconnectDeadlineRef.current = Date.now() + RECONNECT_TIMEOUT_MS;
|
||||
setStatus("reconnecting");
|
||||
setReconnectAttempts(0);
|
||||
return;
|
||||
}
|
||||
if (currentRole === "player" && isInMatchRef.current) {
|
||||
if (reconnectActiveRef.current) {
|
||||
setStatus("reconnecting");
|
||||
return;
|
||||
}
|
||||
reconnectActiveRef.current = true;
|
||||
reconnectDeadlineRef.current = Date.now() + RECONNECT_TIMEOUT_MS;
|
||||
setStatus("reconnecting");
|
||||
setReconnectAttempts(0);
|
||||
return;
|
||||
}
|
||||
|
||||
clearReconnectState();
|
||||
setStatus("disconnected");
|
||||
setShouldRedirectToConnect(true);
|
||||
}, [clearReconnectState]);
|
||||
clearReconnectState();
|
||||
setStatus("disconnected");
|
||||
setShouldRedirectToConnect(true);
|
||||
}, [clearReconnectState]);
|
||||
|
||||
const attachSocket = useCallback(
|
||||
(socket: WebSocket, reconnecting: boolean) => {
|
||||
socket.onopen = () => {
|
||||
const session = sessionRef.current;
|
||||
if (!session) return;
|
||||
const attachSocket = useCallback(
|
||||
(socket: WebSocket, reconnecting: boolean) => {
|
||||
socket.onopen = () => {
|
||||
const session = sessionRef.current;
|
||||
if (!session) return;
|
||||
|
||||
if (session.role === "observer") {
|
||||
socket.send(cmd.observe());
|
||||
return;
|
||||
}
|
||||
if (session.role === "observer") {
|
||||
socket.send(cmd.observe());
|
||||
return;
|
||||
}
|
||||
|
||||
if (reconnecting) {
|
||||
socket.send(cmd.reconnect(session.username));
|
||||
} else {
|
||||
socket.send(cmd.connect(session.username));
|
||||
}
|
||||
};
|
||||
if (reconnecting) {
|
||||
socket.send(cmd.reconnect(session.username));
|
||||
} else {
|
||||
socket.send(cmd.connect(session.username));
|
||||
}
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const raw = event.data as string;
|
||||
console.log(raw);
|
||||
const parsed = parseMessage(raw);
|
||||
socket.onmessage = (event) => {
|
||||
const raw = event.data as string;
|
||||
console.log(raw);
|
||||
const parsed = parseMessage(raw);
|
||||
|
||||
if (parsed.type === "OBSERVE_ACK") {
|
||||
setRole("observer");
|
||||
setShouldRedirectToConnect(false);
|
||||
setStatus("connected");
|
||||
}
|
||||
if (parsed.type === "OBSERVE_ACK") {
|
||||
setRole("observer");
|
||||
setShouldRedirectToConnect(false);
|
||||
setStatus("connected");
|
||||
}
|
||||
|
||||
if (parsed.type === "CONNECT_ACK") {
|
||||
setRole("player");
|
||||
}
|
||||
if (parsed.type === "CONNECT_ACK") {
|
||||
setRole("player");
|
||||
}
|
||||
|
||||
if (parsed.type === "RECONNECT_ACK") {
|
||||
clearReconnectState();
|
||||
setShouldRedirectToConnect(false);
|
||||
setStatus("connected");
|
||||
}
|
||||
if (parsed.type === "RECONNECT_ACK") {
|
||||
clearReconnectState();
|
||||
setShouldRedirectToConnect(false);
|
||||
setStatus("connected");
|
||||
}
|
||||
|
||||
if (parsed.type === "DISCONNECT_ACK") {
|
||||
setRole("observer");
|
||||
setUsername("");
|
||||
isInMatchRef.current = false;
|
||||
setIsInMatch(false);
|
||||
}
|
||||
if (parsed.type === "DISCONNECT_ACK") {
|
||||
setRole("observer");
|
||||
setUsername("");
|
||||
isInMatchRef.current = false;
|
||||
setIsInMatch(false);
|
||||
}
|
||||
|
||||
if (parsed.type === "GAME_START") {
|
||||
isInMatchRef.current = true;
|
||||
setIsInMatch(true);
|
||||
}
|
||||
if (parsed.type === "GAME_START") {
|
||||
isInMatchRef.current = true;
|
||||
setIsInMatch(true);
|
||||
}
|
||||
|
||||
if (
|
||||
parsed.type === "GAME_WINS" ||
|
||||
parsed.type === "GAME_LOSS" ||
|
||||
parsed.type === "GAME_DRAW" ||
|
||||
parsed.type === "GAME_TERMINATED"
|
||||
) {
|
||||
isInMatchRef.current = false;
|
||||
setIsInMatch(false);
|
||||
}
|
||||
if (
|
||||
parsed.type === "GAME_WINS" ||
|
||||
parsed.type === "GAME_LOSS" ||
|
||||
parsed.type === "GAME_DRAW" ||
|
||||
parsed.type === "GAME_TERMINATED"
|
||||
) {
|
||||
isInMatchRef.current = false;
|
||||
setIsInMatch(false);
|
||||
}
|
||||
|
||||
if (
|
||||
parsed.type === "ERROR" &&
|
||||
reconnecting &&
|
||||
parsed.message.startsWith("ERROR:INVALID:RECONNECT")
|
||||
) {
|
||||
safeCloseSocket();
|
||||
}
|
||||
if (
|
||||
parsed.type === "ERROR" &&
|
||||
reconnecting &&
|
||||
parsed.message.startsWith("ERROR:INVALID:RECONNECT")
|
||||
) {
|
||||
safeCloseSocket();
|
||||
}
|
||||
|
||||
emitMessage(parsed, raw);
|
||||
};
|
||||
emitMessage(parsed, raw);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
wsRef.current = null;
|
||||
if (manualCloseRef.current) {
|
||||
manualCloseRef.current = false;
|
||||
return;
|
||||
}
|
||||
handleDisconnect();
|
||||
};
|
||||
socket.onclose = () => {
|
||||
wsRef.current = null;
|
||||
if (manualCloseRef.current) {
|
||||
manualCloseRef.current = false;
|
||||
return;
|
||||
}
|
||||
handleDisconnect();
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
// Allow close event to drive state transitions.
|
||||
};
|
||||
},
|
||||
[clearReconnectState, emitMessage, handleDisconnect, safeCloseSocket],
|
||||
);
|
||||
socket.onerror = () => {
|
||||
// Allow close event to drive state transitions.
|
||||
};
|
||||
},
|
||||
[clearReconnectState, emitMessage, handleDisconnect, safeCloseSocket],
|
||||
);
|
||||
|
||||
const openSocket = useCallback(
|
||||
(reconnecting: boolean) => {
|
||||
const session = sessionRef.current;
|
||||
if (!session) return;
|
||||
const openSocket = useCallback(
|
||||
(reconnecting: boolean) => {
|
||||
const session = sessionRef.current;
|
||||
if (!session) return;
|
||||
|
||||
safeCloseSocket();
|
||||
manualCloseRef.current = false;
|
||||
const socket = new WebSocket(session.wsUrl);
|
||||
wsRef.current = socket;
|
||||
attachSocket(socket, reconnecting);
|
||||
},
|
||||
[attachSocket, safeCloseSocket],
|
||||
);
|
||||
safeCloseSocket();
|
||||
manualCloseRef.current = false;
|
||||
const socket = new WebSocket(session.wsUrl);
|
||||
wsRef.current = socket;
|
||||
attachSocket(socket, reconnecting);
|
||||
},
|
||||
[attachSocket, safeCloseSocket],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reconnectActiveRef.current) return;
|
||||
useEffect(() => {
|
||||
if (!reconnectActiveRef.current) return;
|
||||
|
||||
const runReconnectAttempt = () => {
|
||||
if (!reconnectActiveRef.current) return;
|
||||
const runReconnectAttempt = () => {
|
||||
if (!reconnectActiveRef.current) return;
|
||||
|
||||
const deadline = reconnectDeadlineRef.current;
|
||||
if (!deadline || Date.now() >= deadline) {
|
||||
reconnectActiveRef.current = false;
|
||||
reconnectDeadlineRef.current = null;
|
||||
setStatus("disconnected");
|
||||
setShouldRedirectToConnect(true);
|
||||
return;
|
||||
}
|
||||
const deadline = reconnectDeadlineRef.current;
|
||||
if (!deadline || Date.now() >= deadline) {
|
||||
reconnectActiveRef.current = false;
|
||||
reconnectDeadlineRef.current = null;
|
||||
setStatus("disconnected");
|
||||
setShouldRedirectToConnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setReconnectAttempts((prev) => prev + 1);
|
||||
openSocket(true);
|
||||
setReconnectAttempts((prev) => prev + 1);
|
||||
openSocket(true);
|
||||
|
||||
clearReconnectTimer();
|
||||
reconnectTimerRef.current = setTimeout(
|
||||
runReconnectAttempt,
|
||||
RECONNECT_INTERVAL_MS,
|
||||
);
|
||||
};
|
||||
clearReconnectTimer();
|
||||
reconnectTimerRef.current = setTimeout(
|
||||
runReconnectAttempt,
|
||||
RECONNECT_INTERVAL_MS,
|
||||
);
|
||||
};
|
||||
|
||||
runReconnectAttempt();
|
||||
runReconnectAttempt();
|
||||
|
||||
return () => clearReconnectTimer();
|
||||
}, [clearReconnectTimer, openSocket, status]);
|
||||
return () => clearReconnectTimer();
|
||||
}, [clearReconnectTimer, openSocket, status]);
|
||||
|
||||
const connect = useCallback(
|
||||
({ role, wsUrl, username }: ConnectOptions) => {
|
||||
const resolvedUsername = (username ?? "").trim();
|
||||
sessionRef.current = { role, wsUrl, username: resolvedUsername };
|
||||
const connect = useCallback(
|
||||
({ role, wsUrl, username }: ConnectOptions) => {
|
||||
const resolvedUsername = (username ?? "").trim();
|
||||
sessionRef.current = { role, wsUrl, username: resolvedUsername };
|
||||
|
||||
setRole(role);
|
||||
setWsUrl(wsUrl);
|
||||
setUsername(resolvedUsername);
|
||||
setShouldRedirectToConnect(false);
|
||||
clearReconnectState();
|
||||
isInMatchRef.current = false;
|
||||
setIsInMatch(false);
|
||||
setStatus("connecting");
|
||||
setRole(role);
|
||||
setWsUrl(wsUrl);
|
||||
setUsername(resolvedUsername);
|
||||
setShouldRedirectToConnect(false);
|
||||
clearReconnectState();
|
||||
isInMatchRef.current = false;
|
||||
setIsInMatch(false);
|
||||
setStatus("connecting");
|
||||
|
||||
openSocket(false);
|
||||
},
|
||||
[clearReconnectState, openSocket],
|
||||
);
|
||||
openSocket(false);
|
||||
},
|
||||
[clearReconnectState, openSocket],
|
||||
);
|
||||
|
||||
const becomePlayer = useCallback(
|
||||
(username: string) => {
|
||||
const resolvedUsername = (username ?? "").trim();
|
||||
setRole("player");
|
||||
setUsername(resolvedUsername);
|
||||
isInMatchRef.current = false;
|
||||
setIsInMatch(false);
|
||||
send(cmd.connect(resolvedUsername));
|
||||
},
|
||||
[clearReconnectState, openSocket],
|
||||
);
|
||||
const becomePlayer = useCallback(
|
||||
(username: string) => {
|
||||
const resolvedUsername = (username ?? "").trim();
|
||||
setRole("player");
|
||||
setUsername(resolvedUsername);
|
||||
isInMatchRef.current = false;
|
||||
setIsInMatch(false);
|
||||
send(cmd.connect(resolvedUsername));
|
||||
},
|
||||
[clearReconnectState, openSocket],
|
||||
);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
clearReconnectState();
|
||||
manualCloseRef.current = true;
|
||||
safeCloseSocket();
|
||||
const disconnect = useCallback(() => {
|
||||
clearReconnectState();
|
||||
manualCloseRef.current = true;
|
||||
safeCloseSocket();
|
||||
|
||||
sessionRef.current = null;
|
||||
setRole(null);
|
||||
setStatus("idle");
|
||||
setUsername("");
|
||||
setIsInMatch(false);
|
||||
isInMatchRef.current = false;
|
||||
setShouldRedirectToConnect(false);
|
||||
}, [clearReconnectState, safeCloseSocket]);
|
||||
sessionRef.current = null;
|
||||
setRole(null);
|
||||
setStatus("idle");
|
||||
setUsername("");
|
||||
setIsInMatch(false);
|
||||
isInMatchRef.current = false;
|
||||
setShouldRedirectToConnect(false);
|
||||
}, [clearReconnectState, safeCloseSocket]);
|
||||
|
||||
const send = useCallback((message: string) => {
|
||||
if (wsRef.current?.readyState !== WebSocket.OPEN) return false;
|
||||
wsRef.current.send(message);
|
||||
return true;
|
||||
}, []);
|
||||
const send = useCallback((message: string) => {
|
||||
if (wsRef.current?.readyState !== WebSocket.OPEN) return false;
|
||||
wsRef.current.send(message);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const subscribe = useCallback((listener: MessageListener) => {
|
||||
listenersRef.current.add(listener);
|
||||
return () => {
|
||||
listenersRef.current.delete(listener);
|
||||
};
|
||||
}, []);
|
||||
const subscribe = useCallback((listener: MessageListener) => {
|
||||
listenersRef.current.add(listener);
|
||||
return () => {
|
||||
listenersRef.current.delete(listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearRedirectFlag = useCallback(() => {
|
||||
setShouldRedirectToConnect(false);
|
||||
}, []);
|
||||
const clearRedirectFlag = useCallback(() => {
|
||||
setShouldRedirectToConnect(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearReconnectState();
|
||||
manualCloseRef.current = true;
|
||||
safeCloseSocket();
|
||||
};
|
||||
}, [clearReconnectState, safeCloseSocket]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearReconnectState();
|
||||
manualCloseRef.current = true;
|
||||
safeCloseSocket();
|
||||
};
|
||||
}, [clearReconnectState, safeCloseSocket]);
|
||||
|
||||
const value = useMemo<ConnectionContextValue>(
|
||||
() => ({
|
||||
role,
|
||||
wsUrl,
|
||||
username,
|
||||
status,
|
||||
isInMatch,
|
||||
reconnectAttempts,
|
||||
shouldRedirectToConnect,
|
||||
becomePlayer,
|
||||
connect,
|
||||
disconnect,
|
||||
send,
|
||||
subscribe,
|
||||
clearRedirectFlag,
|
||||
}),
|
||||
[
|
||||
role,
|
||||
wsUrl,
|
||||
username,
|
||||
status,
|
||||
isInMatch,
|
||||
reconnectAttempts,
|
||||
shouldRedirectToConnect,
|
||||
connect,
|
||||
disconnect,
|
||||
send,
|
||||
subscribe,
|
||||
clearRedirectFlag,
|
||||
],
|
||||
);
|
||||
const value = useMemo<ConnectionContextValue>(
|
||||
() => ({
|
||||
role,
|
||||
wsUrl,
|
||||
username,
|
||||
status,
|
||||
isInMatch,
|
||||
reconnectAttempts,
|
||||
shouldRedirectToConnect,
|
||||
becomePlayer,
|
||||
connect,
|
||||
disconnect,
|
||||
send,
|
||||
subscribe,
|
||||
clearRedirectFlag,
|
||||
}),
|
||||
[
|
||||
role,
|
||||
wsUrl,
|
||||
username,
|
||||
status,
|
||||
isInMatch,
|
||||
reconnectAttempts,
|
||||
shouldRedirectToConnect,
|
||||
connect,
|
||||
disconnect,
|
||||
send,
|
||||
subscribe,
|
||||
clearRedirectFlag,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ConnectionContext.Provider value={value}>
|
||||
{children}
|
||||
</ConnectionContext.Provider>
|
||||
);
|
||||
return (
|
||||
<ConnectionContext.Provider value={value}>
|
||||
{children}
|
||||
</ConnectionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConnection() {
|
||||
const context = useContext(ConnectionContext);
|
||||
if (!context) {
|
||||
throw new Error("useConnection must be used within a ConnectionProvider");
|
||||
}
|
||||
return context;
|
||||
const context = useContext(ConnectionContext);
|
||||
if (!context) {
|
||||
throw new Error("useConnection must be used within a ConnectionProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -1,262 +1,262 @@
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GameEntry {
|
||||
id: number;
|
||||
player1: string;
|
||||
player2: string;
|
||||
id: number;
|
||||
player1: string;
|
||||
player2: string;
|
||||
}
|
||||
|
||||
export interface PlayerEntry {
|
||||
username: string;
|
||||
ready: boolean;
|
||||
inMatch: boolean;
|
||||
username: string;
|
||||
ready: boolean;
|
||||
inMatch: boolean;
|
||||
}
|
||||
|
||||
export interface ScoreEntry {
|
||||
player: string;
|
||||
score: number;
|
||||
player: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface MoveEntry {
|
||||
username: string;
|
||||
column: number;
|
||||
username: string;
|
||||
column: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_WS_URL =
|
||||
process.env.NODE_ENV === "development"
|
||||
? "ws://localhost:8080"
|
||||
: "wss://connect4.abunchofknowitalls.com";
|
||||
process.env.NODE_ENV === "development"
|
||||
? "ws://localhost:8080"
|
||||
: "wss://connect4.abunchofknowitalls.com";
|
||||
export const RECONNECT_INTERVAL_MS = 5000;
|
||||
export const RECONNECT_TIMEOUT_MS = 60000;
|
||||
|
||||
// ─── Parsed message union ────────────────────────────────────────────────────
|
||||
|
||||
export type ParsedMessage =
|
||||
| { type: "CONNECT_ACK" }
|
||||
| { type: "RECONNECT_ACK" }
|
||||
| { type: "DISCONNECT_ACK" }
|
||||
| { type: "OBSERVE_ACK"; enabled: boolean }
|
||||
| { type: "READY_ACK" }
|
||||
| { type: "GAME_START"; goesFirst: boolean }
|
||||
| { type: "GAME_WINS" }
|
||||
| { type: "GAME_LOSS" }
|
||||
| { type: "GAME_DRAW"; matchId?: number }
|
||||
| { type: "GAME_TERMINATED"; matchId?: number }
|
||||
| { type: "OPPONENT_MOVE"; column: number }
|
||||
| { type: "GAME_LIST"; games: GameEntry[] }
|
||||
| {
|
||||
type: "GAME_WATCH_ACK";
|
||||
matchId: number;
|
||||
player1: string;
|
||||
player2: string;
|
||||
moves: MoveEntry[];
|
||||
}
|
||||
| { type: "GAME_MOVE"; matchId?: number; username: string; column: number }
|
||||
| { type: "GAME_WIN"; matchId?: number; winner: string }
|
||||
| { type: "PLAYER_LIST"; players: PlayerEntry[] }
|
||||
| { type: "TOURNAMENT_START"; tournamentType: string }
|
||||
| { type: "TOURNAMENT_CANCEL" }
|
||||
| { type: "TOURNAMENT_SCORES"; scores: ScoreEntry[] }
|
||||
| { type: "TOURNAMENT_END" }
|
||||
| { type: "ADMIN_AUTH_ACK" }
|
||||
| { type: "GET_DATA"; key: string; value: string }
|
||||
| { type: "SET_DATA_ACK"; key: string }
|
||||
| { type: "ERROR"; message: string }
|
||||
| { type: "UNKNOWN"; raw: string };
|
||||
| { type: "CONNECT_ACK" }
|
||||
| { type: "RECONNECT_ACK" }
|
||||
| { type: "DISCONNECT_ACK" }
|
||||
| { type: "OBSERVE_ACK"; enabled: boolean }
|
||||
| { type: "READY_ACK" }
|
||||
| { type: "GAME_START"; goesFirst: boolean }
|
||||
| { type: "GAME_WINS" }
|
||||
| { type: "GAME_LOSS" }
|
||||
| { type: "GAME_DRAW"; matchId?: number }
|
||||
| { type: "GAME_TERMINATED"; matchId?: number }
|
||||
| { type: "OPPONENT_MOVE"; column: number }
|
||||
| { type: "GAME_LIST"; games: GameEntry[] }
|
||||
| {
|
||||
type: "GAME_WATCH_ACK";
|
||||
matchId: number;
|
||||
player1: string;
|
||||
player2: string;
|
||||
moves: MoveEntry[];
|
||||
}
|
||||
| { type: "GAME_MOVE"; matchId?: number; username: string; column: number }
|
||||
| { type: "GAME_WIN"; matchId?: number; winner: string }
|
||||
| { type: "PLAYER_LIST"; players: PlayerEntry[] }
|
||||
| { type: "TOURNAMENT_START"; tournamentType: string }
|
||||
| { type: "TOURNAMENT_CANCEL" }
|
||||
| { type: "TOURNAMENT_SCORES"; scores: ScoreEntry[] }
|
||||
| { type: "TOURNAMENT_END" }
|
||||
| { type: "ADMIN_AUTH_ACK" }
|
||||
| { type: "GET_DATA"; key: string; value: string }
|
||||
| { type: "SET_DATA_ACK"; key: string }
|
||||
| { type: "ERROR"; message: string }
|
||||
| { type: "UNKNOWN"; raw: string };
|
||||
|
||||
// ─── Parser ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function parseMessage(raw: string): ParsedMessage {
|
||||
const parts = raw.split(":");
|
||||
const parts = raw.split(":");
|
||||
|
||||
switch (parts[0]) {
|
||||
case "CONNECT":
|
||||
if (parts[1] === "ACK") return { type: "CONNECT_ACK" };
|
||||
break;
|
||||
case "RECONNECT":
|
||||
if (parts[1] === "ACK") return { type: "RECONNECT_ACK" };
|
||||
break;
|
||||
case "DISCONNECT":
|
||||
if (parts[1] === "ACK") return { type: "DISCONNECT_ACK" };
|
||||
break;
|
||||
case "OBSERVE":
|
||||
if (parts[1] === "ACK") {
|
||||
return { type: "OBSERVE_ACK", enabled: parts[2] === "1" };
|
||||
}
|
||||
break;
|
||||
case "READY":
|
||||
if (parts[1] === "ACK") return { type: "READY_ACK" };
|
||||
break;
|
||||
switch (parts[0]) {
|
||||
case "CONNECT":
|
||||
if (parts[1] === "ACK") return { type: "CONNECT_ACK" };
|
||||
break;
|
||||
case "RECONNECT":
|
||||
if (parts[1] === "ACK") return { type: "RECONNECT_ACK" };
|
||||
break;
|
||||
case "DISCONNECT":
|
||||
if (parts[1] === "ACK") return { type: "DISCONNECT_ACK" };
|
||||
break;
|
||||
case "OBSERVE":
|
||||
if (parts[1] === "ACK") {
|
||||
return { type: "OBSERVE_ACK", enabled: parts[2] === "1" };
|
||||
}
|
||||
break;
|
||||
case "READY":
|
||||
if (parts[1] === "ACK") return { type: "READY_ACK" };
|
||||
break;
|
||||
|
||||
case "GAME": {
|
||||
const scopedMatchId = parseInt(parts[1], 10);
|
||||
if (!Number.isNaN(scopedMatchId)) {
|
||||
switch (parts[2]) {
|
||||
case "MOVE":
|
||||
return {
|
||||
type: "GAME_MOVE",
|
||||
matchId: scopedMatchId,
|
||||
username: parts[3],
|
||||
column: parseInt(parts[4], 10),
|
||||
};
|
||||
case "WIN":
|
||||
return {
|
||||
type: "GAME_WIN",
|
||||
matchId: scopedMatchId,
|
||||
winner: parts[3],
|
||||
};
|
||||
case "DRAW":
|
||||
return { type: "GAME_DRAW", matchId: scopedMatchId };
|
||||
case "TERMINATED":
|
||||
return { type: "GAME_TERMINATED", matchId: scopedMatchId };
|
||||
}
|
||||
}
|
||||
case "GAME": {
|
||||
const scopedMatchId = parseInt(parts[1], 10);
|
||||
if (!Number.isNaN(scopedMatchId)) {
|
||||
switch (parts[2]) {
|
||||
case "MOVE":
|
||||
return {
|
||||
type: "GAME_MOVE",
|
||||
matchId: scopedMatchId,
|
||||
username: parts[3],
|
||||
column: parseInt(parts[4], 10),
|
||||
};
|
||||
case "WIN":
|
||||
return {
|
||||
type: "GAME_WIN",
|
||||
matchId: scopedMatchId,
|
||||
winner: parts[3],
|
||||
};
|
||||
case "DRAW":
|
||||
return { type: "GAME_DRAW", matchId: scopedMatchId };
|
||||
case "TERMINATED":
|
||||
return { type: "GAME_TERMINATED", matchId: scopedMatchId };
|
||||
}
|
||||
}
|
||||
|
||||
switch (parts[1]) {
|
||||
case "START":
|
||||
return { type: "GAME_START", goesFirst: parts[2] === "1" };
|
||||
case "WINS":
|
||||
return { type: "GAME_WINS" };
|
||||
case "LOSS":
|
||||
return { type: "GAME_LOSS" };
|
||||
case "DRAW":
|
||||
return { type: "GAME_DRAW" };
|
||||
case "TERMINATED":
|
||||
return { type: "GAME_TERMINATED" };
|
||||
switch (parts[1]) {
|
||||
case "START":
|
||||
return { type: "GAME_START", goesFirst: parts[2] === "1" };
|
||||
case "WINS":
|
||||
return { type: "GAME_WINS" };
|
||||
case "LOSS":
|
||||
return { type: "GAME_LOSS" };
|
||||
case "DRAW":
|
||||
return { type: "GAME_DRAW" };
|
||||
case "TERMINATED":
|
||||
return { type: "GAME_TERMINATED" };
|
||||
|
||||
case "LIST": {
|
||||
const data = parts[2] ?? "";
|
||||
if (!data) return { type: "GAME_LIST", games: [] };
|
||||
const games: GameEntry[] = data.split("|").map((g) => {
|
||||
const [id, player1, player2] = g.split(",");
|
||||
return { id: parseInt(id), player1, player2 };
|
||||
});
|
||||
return { type: "GAME_LIST", games };
|
||||
}
|
||||
case "LIST": {
|
||||
const data = parts[2] ?? "";
|
||||
if (!data) return { type: "GAME_LIST", games: [] };
|
||||
const games: GameEntry[] = data.split("|").map((g) => {
|
||||
const [id, player1, player2] = g.split(",");
|
||||
return { id: parseInt(id), player1, player2 };
|
||||
});
|
||||
return { type: "GAME_LIST", games };
|
||||
}
|
||||
|
||||
case "WATCH": {
|
||||
if (parts[2] === "ACK") {
|
||||
// GAME:WATCH:ACK:<id>,<p1>,<p2>|<username>,<col>|...
|
||||
const data = parts.slice(3).join(":");
|
||||
const segments = data.split("|");
|
||||
const [idStr, player1, player2] = segments[0].split(",");
|
||||
const moves: MoveEntry[] = segments
|
||||
.slice(1)
|
||||
.filter(Boolean)
|
||||
.map((m) => {
|
||||
const lastComma = m.lastIndexOf(",");
|
||||
return {
|
||||
username: m.substring(0, lastComma),
|
||||
column: parseInt(m.substring(lastComma + 1)),
|
||||
};
|
||||
});
|
||||
return {
|
||||
type: "GAME_WATCH_ACK",
|
||||
matchId: parseInt(idStr),
|
||||
player1,
|
||||
player2,
|
||||
moves,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "WATCH": {
|
||||
if (parts[2] === "ACK") {
|
||||
// GAME:WATCH:ACK:<id>,<p1>,<p2>|<username>,<col>|...
|
||||
const data = parts.slice(3).join(":");
|
||||
const segments = data.split("|");
|
||||
const [idStr, player1, player2] = segments[0].split(",");
|
||||
const moves: MoveEntry[] = segments
|
||||
.slice(1)
|
||||
.filter(Boolean)
|
||||
.map((m) => {
|
||||
const lastComma = m.lastIndexOf(",");
|
||||
return {
|
||||
username: m.substring(0, lastComma),
|
||||
column: parseInt(m.substring(lastComma + 1)),
|
||||
};
|
||||
});
|
||||
return {
|
||||
type: "GAME_WATCH_ACK",
|
||||
matchId: parseInt(idStr),
|
||||
player1,
|
||||
player2,
|
||||
moves,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "OPPONENT":
|
||||
return {
|
||||
type: "OPPONENT_MOVE",
|
||||
column: parseInt(parts[parts.length - 1], 10),
|
||||
};
|
||||
case "OPPONENT":
|
||||
return {
|
||||
type: "OPPONENT_MOVE",
|
||||
column: parseInt(parts[parts.length - 1], 10),
|
||||
};
|
||||
|
||||
case "PLAYER": {
|
||||
if (parts[1] === "LIST") {
|
||||
const data = parts[2] ?? "";
|
||||
if (!data) return { type: "PLAYER_LIST", players: [] };
|
||||
const players: PlayerEntry[] = data.split("|").map((p) => {
|
||||
const [username, ready, inMatch] = p.split(",");
|
||||
return {
|
||||
username,
|
||||
ready: ready === "true",
|
||||
inMatch: inMatch === "true",
|
||||
};
|
||||
});
|
||||
return { type: "PLAYER_LIST", players };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "PLAYER": {
|
||||
if (parts[1] === "LIST") {
|
||||
const data = parts[2] ?? "";
|
||||
if (!data) return { type: "PLAYER_LIST", players: [] };
|
||||
const players: PlayerEntry[] = data.split("|").map((p) => {
|
||||
const [username, ready, inMatch] = p.split(",");
|
||||
return {
|
||||
username,
|
||||
ready: ready === "true",
|
||||
inMatch: inMatch === "true",
|
||||
};
|
||||
});
|
||||
return { type: "PLAYER_LIST", players };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "TOURNAMENT": {
|
||||
switch (parts[1]) {
|
||||
case "START":
|
||||
return { type: "TOURNAMENT_START", tournamentType: parts[2] };
|
||||
case "CANCEL":
|
||||
return { type: "TOURNAMENT_CANCEL" };
|
||||
case "SCORES": {
|
||||
const data = parts[2] ?? "";
|
||||
if (!data) return { type: "TOURNAMENT_SCORES", scores: [] };
|
||||
const scores: ScoreEntry[] = data.split("|").map((s) => {
|
||||
const lastComma = s.lastIndexOf(",");
|
||||
return {
|
||||
player: s.substring(0, lastComma),
|
||||
score: parseInt(s.substring(lastComma + 1)),
|
||||
};
|
||||
});
|
||||
return { type: "TOURNAMENT_SCORES", scores };
|
||||
}
|
||||
case "END":
|
||||
return { type: "TOURNAMENT_END" };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "TOURNAMENT": {
|
||||
switch (parts[1]) {
|
||||
case "START":
|
||||
return { type: "TOURNAMENT_START", tournamentType: parts[2] };
|
||||
case "CANCEL":
|
||||
return { type: "TOURNAMENT_CANCEL" };
|
||||
case "SCORES": {
|
||||
const data = parts[2] ?? "";
|
||||
if (!data) return { type: "TOURNAMENT_SCORES", scores: [] };
|
||||
const scores: ScoreEntry[] = data.split("|").map((s) => {
|
||||
const lastComma = s.lastIndexOf(",");
|
||||
return {
|
||||
player: s.substring(0, lastComma),
|
||||
score: parseInt(s.substring(lastComma + 1)),
|
||||
};
|
||||
});
|
||||
return { type: "TOURNAMENT_SCORES", scores };
|
||||
}
|
||||
case "END":
|
||||
return { type: "TOURNAMENT_END" };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "ADMIN":
|
||||
if (parts[1] === "AUTH" && parts[2] === "ACK")
|
||||
return { type: "ADMIN_AUTH_ACK" };
|
||||
break;
|
||||
case "ADMIN":
|
||||
if (parts[1] === "AUTH" && parts[2] === "ACK")
|
||||
return { type: "ADMIN_AUTH_ACK" };
|
||||
break;
|
||||
|
||||
case "GET":
|
||||
return { type: "GET_DATA", key: parts[1], value: parts[2] ?? "" };
|
||||
case "GET":
|
||||
return { type: "GET_DATA", key: parts[1], value: parts[2] ?? "" };
|
||||
|
||||
case "SET":
|
||||
if (parts[2] === "ACK") return { type: "SET_DATA_ACK", key: parts[1] };
|
||||
break;
|
||||
case "SET":
|
||||
if (parts[2] === "ACK") return { type: "SET_DATA_ACK", key: parts[1] };
|
||||
break;
|
||||
|
||||
case "ERROR":
|
||||
return { type: "ERROR", message: raw };
|
||||
}
|
||||
case "ERROR":
|
||||
return { type: "ERROR", message: raw };
|
||||
}
|
||||
|
||||
return { type: "UNKNOWN", raw };
|
||||
return { type: "UNKNOWN", raw };
|
||||
}
|
||||
|
||||
// ─── Command builders ────────────────────────────────────────────────────────
|
||||
|
||||
export const cmd = {
|
||||
connect: (username: string) => `CONNECT:${username}`,
|
||||
reconnect: (username: string) => `RECONNECT:${username}`,
|
||||
disconnect: () => "DISCONNECT",
|
||||
observe: () => "OBSERVE",
|
||||
ready: () => "READY",
|
||||
play: (column: number) => `PLAY:${column}`,
|
||||
playerList: () => "PLAYER:LIST",
|
||||
gameList: () => "GAME:LIST",
|
||||
gameWatch: (matchId: number) => `GAME:WATCH:${matchId}`,
|
||||
gameTerminate: (matchId: number) => `GAME:TERMINATE:${matchId}`,
|
||||
gameAward: (matchId: number, winner: string) =>
|
||||
`GAME:AWARD:${matchId}:${winner}`,
|
||||
adminAuth: (password: string) => `ADMIN:AUTH:${password}`,
|
||||
adminKick: (username: string) => `ADMIN:KICK:${username}`,
|
||||
tournamentStart: (type = "RoundRobin") => `TOURNAMENT:START:${type}`,
|
||||
tournamentCancel: () => "TOURNAMENT:CANCEL",
|
||||
getData: (
|
||||
key: "TOURNAMENT_STATUS" | "MOVE_WAIT" | "DEMO_MODE" | "MAX_TIMEOUT",
|
||||
) => `GET:${key}`,
|
||||
setData: (key: string, value: string) => `SET:${key}:${value}`,
|
||||
reservationAdd: (p1: string, p2: string) => `RESERVATION:ADD:${p1},${p2}`,
|
||||
reservationDelete: (p1: string, p2: string) =>
|
||||
`RESERVATION:DELETE:${p1},${p2}`,
|
||||
reservationGet: () => "RESERVATION:GET",
|
||||
connect: (username: string) => `CONNECT:${username}`,
|
||||
reconnect: (username: string) => `RECONNECT:${username}`,
|
||||
disconnect: () => "DISCONNECT",
|
||||
observe: () => "OBSERVE",
|
||||
ready: () => "READY",
|
||||
play: (column: number) => `PLAY:${column}`,
|
||||
playerList: () => "PLAYER:LIST",
|
||||
gameList: () => "GAME:LIST",
|
||||
gameWatch: (matchId: number) => `GAME:WATCH:${matchId}`,
|
||||
gameTerminate: (matchId: number) => `GAME:TERMINATE:${matchId}`,
|
||||
gameAward: (matchId: number, winner: string) =>
|
||||
`GAME:AWARD:${matchId}:${winner}`,
|
||||
adminAuth: (password: string) => `ADMIN:AUTH:${password}`,
|
||||
adminKick: (username: string) => `ADMIN:KICK:${username}`,
|
||||
tournamentStart: (type = "RoundRobin") => `TOURNAMENT:START:${type}`,
|
||||
tournamentCancel: () => "TOURNAMENT:CANCEL",
|
||||
getData: (
|
||||
key: "TOURNAMENT_STATUS" | "MOVE_WAIT" | "DEMO_MODE" | "MAX_TIMEOUT",
|
||||
) => `GET:${key}`,
|
||||
setData: (key: string, value: string) => `SET:${key}:${value}`,
|
||||
reservationAdd: (p1: string, p2: string) => `RESERVATION:ADD:${p1},${p2}`,
|
||||
reservationDelete: (p1: string, p2: string) =>
|
||||
`RESERVATION:DELETE:${p1},${p2}`,
|
||||
reservationGet: () => "RESERVATION:GET",
|
||||
};
|
||||
|
||||
// ─── Board helpers ────────────────────────────────────────────────────────────
|
||||
@@ -266,39 +266,39 @@ export type CellColor = 0 | 1 | 2;
|
||||
export type BoardState = CellColor[][]; // board[col][row], 7 cols × 6 rows
|
||||
|
||||
export function createEmptyBoard(): BoardState {
|
||||
return Array.from({ length: 7 }, () => Array(6).fill(0)) as BoardState;
|
||||
return Array.from({ length: 7 }, () => Array(6).fill(0)) as BoardState;
|
||||
}
|
||||
|
||||
/** Place a token and return the new board plus the row it landed in (-1 if column full). */
|
||||
export function placeToken(
|
||||
board: BoardState,
|
||||
color: 1 | 2,
|
||||
column: number,
|
||||
board: BoardState,
|
||||
color: 1 | 2,
|
||||
column: number,
|
||||
): { board: BoardState; row: number } {
|
||||
const newBoard = board.map((col) => [...col]) as BoardState;
|
||||
let placedRow = -1;
|
||||
for (let row = 0; row < 6; row++) {
|
||||
if (newBoard[column][row] === 0) {
|
||||
newBoard[column][row] = color;
|
||||
placedRow = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { board: newBoard, row: placedRow };
|
||||
const newBoard = board.map((col) => [...col]) as BoardState;
|
||||
let placedRow = -1;
|
||||
for (let row = 0; row < 6; row++) {
|
||||
if (newBoard[column][row] === 0) {
|
||||
newBoard[column][row] = color;
|
||||
placedRow = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { board: newBoard, row: placedRow };
|
||||
}
|
||||
|
||||
/** Replay a move list onto an empty board. */
|
||||
export function replayMoves(
|
||||
moves: MoveEntry[],
|
||||
player1: string,
|
||||
moves: MoveEntry[],
|
||||
player1: string,
|
||||
): { board: BoardState; lastMove: { column: number; row: number } | null } {
|
||||
let board = createEmptyBoard();
|
||||
let lastMove: { column: number; row: number } | null = null;
|
||||
for (const move of moves) {
|
||||
const color: 1 | 2 = move.username === player1 ? 1 : 2;
|
||||
const result = placeToken(board, color, move.column);
|
||||
board = result.board;
|
||||
lastMove = { column: move.column, row: result.row };
|
||||
}
|
||||
return { board, lastMove };
|
||||
let board = createEmptyBoard();
|
||||
let lastMove: { column: number; row: number } | null = null;
|
||||
for (const move of moves) {
|
||||
const color: 1 | 2 = move.username === player1 ? 1 : 2;
|
||||
const result = placeToken(board, color, move.column);
|
||||
board = result.board;
|
||||
lastMove = { column: move.column, row: result.row };
|
||||
}
|
||||
return { board, lastMove };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user