codies/frontend/src/pages/game.tsx

223 lines
7.3 KiB
TypeScript

import { fail } from 'assert';
import * as React from 'react';
import useWebSocket from 'react-use-websocket';
import { DeepReadonly } from 'ts-essentials';
import { assertIsDefined, assertNever, noop, reloadOutdatedPage, websocketUrl } from '../common';
import { useServerTime } from '../hooks';
import { version as codiesVersion } from '../metadata.json';
import { ClientNote, PartialClientNote, ServerNote, State, StatePlayer, TimeResponse, WordPack } from '../protocol';
import { GameView, Sender } from './gameView';
import { Loading } from './loading';
const socketUrl = websocketUrl('/api/ws');
function useSender(dispatch: (action: PartialClientNote) => void): Sender {
return React.useMemo<Sender>(() => {
return {
reveal: (row: number, col: number) =>
dispatch({
method: 'reveal',
params: {
row,
col,
},
}),
newGame: () => dispatch({ method: 'newGame', params: {} }),
endTurn: () => dispatch({ method: 'endTurn', params: {} }),
changeNickname: (nickname: string) => dispatch({ method: 'changeNickname', params: { nickname } }),
changeRole: (spymaster: boolean) => dispatch({ method: 'changeRole', params: { spymaster } }),
changeTeam: (team: number) => dispatch({ method: 'changeTeam', params: { team } }),
randomizeTeams: () => dispatch({ method: 'randomizeTeams', params: {} }),
changePack: (num: number, enable: boolean) => dispatch({ method: 'changePack', params: { num, enable } }),
changeTurnMode: (timed: boolean) => dispatch({ method: 'changeTurnMode', params: { timed } }),
changeTurnTime: (seconds: number) => dispatch({ method: 'changeTurnTime', params: { seconds } }),
addPacks: (packs: WordPack[]) => dispatch({ method: 'addPacks', params: { packs } }),
removePack: (num: number) => dispatch({ method: 'removePack', params: { num } }),
changeHideBomb: (hideBomb: boolean) => dispatch({ method: 'changeHideBomb', params: { hideBomb } }),
};
}, [dispatch]);
}
function usePlayer(state?: State): { pState: StatePlayer; pTeam: number } | undefined {
return React.useMemo(() => {
if (!state) {
return undefined;
}
for (let i = 0; i < state.roomState.teams.length; i++) {
const pState = state.roomState.teams[i].find((p) => p.playerID === state.playerID);
if (pState) {
return { pState, pTeam: i };
}
}
fail('Player not found in any team');
}, [state]);
}
const reconnectAttempts = 2;
function useWS(roomID: string, nickname: string, dead: () => void, onOpen: () => void) {
const didUnmount = React.useRef(false);
const retry = React.useRef(0);
return useWebSocket(socketUrl, {
// The names here matter; explicitly naming them so that renaming
// these variables doesn't change the actual wire names.
//
// X-CODIES-VERSION would be cleaner, but the WS hook doesn't
// support anything but query params.
queryParams: { roomID: roomID, nickname: nickname, codiesVersion: codiesVersion },
reconnectAttempts,
onMessage: () => {
retry.current = 0;
},
onOpen,
onClose: (e: CloseEvent) => {
if (e.code === 4418) {
reloadOutdatedPage();
}
},
shouldReconnect: () => {
if (didUnmount.current) {
return false;
}
retry.current++;
if (retry.current >= reconnectAttempts) {
dead();
return false;
}
return true;
},
});
}
function useSyncedServerTime() {
const { setOffset } = useServerTime();
const syncTime = React.useCallback(() => {
const fn = async () => {
let bestRTT: number | undefined;
let offset = 0;
for (let i = 0; i < 3; i++) {
const before = Date.now();
const resp = await fetch('/api/time');
const after = Date.now();
const body = await resp.json();
if (resp.ok) {
const rtt = (after - before) / 2;
if (bestRTT !== undefined && rtt > bestRTT) {
continue;
}
bestRTT = rtt;
const t = TimeResponse.parse(body);
const serverTime = t.time.getTime() + rtt;
offset = serverTime - Date.now();
}
}
setOffset(offset);
};
fn().catch(noop);
}, [setOffset]);
React.useEffect(() => {
const interval = window.setInterval(() => {
syncTime();
}, 10 * 60 * 1000);
return () => window.clearInterval(interval);
}, [syncTime]);
return syncTime;
}
type StateAction = { method: 'setState'; state: State } | PartialClientNote;
function useStateReducer(sendNote: (r: ClientNote) => void) {
// TODO: Create a new state which contains the server state.
// TODO: Put sendNote in the state instead of reffing it?
const sendNoteRef = React.useRef(sendNote);
sendNoteRef.current = sendNote;
return React.useCallback(
(state: State | undefined, action: StateAction): State | undefined => {
if (state === undefined) {
if (action.method === 'setState') {
return action.state;
}
return state;
}
switch (action.method) {
case 'setState':
return action.state;
default:
sendNoteRef.current({ ...action, version: state.roomState.version });
return state;
}
},
[sendNoteRef]
);
}
export interface GameProps {
roomID: string;
nickname: string;
leave: () => void;
}
export const Game = (props: DeepReadonly<GameProps>) => {
const nickname = React.useRef(props.nickname); // Preserve a nickname for use in reconnects.
const syncTime = useSyncedServerTime();
const { sendJsonMessage, lastJsonMessage } = useWS(props.roomID, nickname.current, props.leave, syncTime);
const reducer = useStateReducer(sendJsonMessage);
const [state, dispatch] = React.useReducer(reducer, undefined);
const player = usePlayer(state);
const send = useSender(dispatch);
React.useEffect(() => {
if (!lastJsonMessage) {
return;
}
const note = ServerNote.parse(lastJsonMessage);
switch (note.method) {
case 'state':
dispatch({ method: 'setState', state: note.params });
break;
default:
assertNever(note.method);
}
}, [lastJsonMessage]);
if (!state) {
return <Loading />;
}
assertIsDefined(player);
nickname.current = player.pState.nickname;
return (
<GameView
roomID={props.roomID}
leave={props.leave}
send={send}
state={state.roomState}
pState={player.pState}
pTeam={player.pTeam}
/>
);
};