game.tsx

  1import { fail } from 'assert';
  2import * as React from 'react';
  3import useWebSocket from 'react-use-websocket';
  4import { v4 } from 'uuid';
  5
  6import { assertIsDefined, assertNever, noop, websocketUrl } from '../common';
  7import { useServerTime } from '../hooks/useServerTime';
  8import { ClientNote, ServerNote, State, StatePlayer, TimeResponse, WordPack } from '../protocol';
  9import { GameView, Sender } from './gameView';
 10import { Loading } from './loading';
 11
 12const socketUrl = websocketUrl('/api/ws');
 13
 14function useSender(sendNote: (r: ClientNote) => void, version: number): Sender {
 15    return React.useMemo<Sender>(() => {
 16        return {
 17            reveal: (row: number, col: number) =>
 18                sendNote({
 19                    method: 'reveal',
 20                    version,
 21                    params: {
 22                        row,
 23                        col,
 24                    },
 25                }),
 26            newGame: () => sendNote({ method: 'newGame', version, params: {} }),
 27            endTurn: () => sendNote({ method: 'endTurn', version, params: {} }),
 28            changeNickname: (nickname: string) => sendNote({ method: 'changeNickname', version, params: { nickname } }),
 29            changeRole: (spymaster: boolean) => sendNote({ method: 'changeRole', version, params: { spymaster } }),
 30            changeTeam: (team: number) => sendNote({ method: 'changeTeam', version, params: { team } }),
 31            randomizeTeams: () => sendNote({ method: 'randomizeTeams', version, params: {} }),
 32            changePack: (num: number, enable: boolean) =>
 33                sendNote({ method: 'changePack', version, params: { num, enable } }),
 34            changeTurnMode: (timed: boolean) => sendNote({ method: 'changeTurnMode', version, params: { timed } }),
 35            changeTurnTime: (seconds: number) => sendNote({ method: 'changeTurnTime', version, params: { seconds } }),
 36            addPacks: (packs: WordPack[]) => sendNote({ method: 'addPacks', version, params: { packs } }),
 37            removePack: (num: number) => sendNote({ method: 'removePack', version, params: { num } }),
 38        };
 39    }, [sendNote, version]);
 40}
 41
 42function usePlayer(playerID: string, state?: State): { pState: StatePlayer; pTeam: number } | undefined {
 43    return React.useMemo(() => {
 44        if (!state) {
 45            return undefined;
 46        }
 47
 48        for (let i = 0; i < state.teams.length; i++) {
 49            const pState = state.teams[i].find((p) => p.playerID === playerID);
 50            if (pState) {
 51                return { pState, pTeam: i };
 52            }
 53        }
 54
 55        fail('Player not found in any team');
 56    }, [playerID, state]);
 57}
 58
 59const reconnectAttempts = 5;
 60
 61function useWS(roomID: string, playerID: string, nickname: string, dead: () => void, onOpen: () => void) {
 62    const didUnmount = React.useRef(false);
 63    const retry = React.useRef(0);
 64
 65    return useWebSocket(socketUrl, {
 66        queryParams: { roomID, playerID, nickname },
 67        reconnectAttempts,
 68        onMessage: () => {
 69            retry.current = 0;
 70        },
 71        onOpen,
 72        shouldReconnect: () => {
 73            if (didUnmount.current) {
 74                return false;
 75            }
 76
 77            retry.current++;
 78
 79            if (retry.current >= reconnectAttempts) {
 80                dead();
 81                return false;
 82            }
 83
 84            return true;
 85        },
 86    });
 87}
 88
 89function syncTime(setOffset: (offset: number) => void) {
 90    const fn = async () => {
 91        let bestRTT: number | undefined;
 92        let offset = 0;
 93
 94        for (let i = 0; i < 3; i++) {
 95            const before = Date.now();
 96            const resp = await fetch('/api/time');
 97            const after = Date.now();
 98
 99            const body = await resp.json();
100            if (resp.ok) {
101                const rtt = (after - before) / 2;
102
103                if (bestRTT !== undefined && rtt > bestRTT) {
104                    continue;
105                }
106
107                bestRTT = rtt;
108
109                const t = TimeResponse.parse(body);
110                const serverTime = t.time.getTime() + rtt;
111                offset = serverTime - Date.now();
112            }
113        }
114
115        setOffset(offset);
116    };
117    fn().catch(noop);
118}
119
120export interface GameProps {
121    roomID: string;
122    nickname: string;
123    leave: () => void;
124}
125
126export const Game = (props: GameProps) => {
127    const [playerID] = React.useState(v4);
128    const nickname = React.useRef(props.nickname); // Preserve a nickname for use in reconnects.
129    const [state, setState] = React.useState<State | undefined>();
130    const { setOffset } = useServerTime();
131
132    const { sendJsonMessage, lastJsonMessage } = useWS(props.roomID, playerID, nickname.current, props.leave, () =>
133        syncTime(setOffset)
134    );
135
136    React.useEffect(() => {
137        const interval = window.setInterval(() => {
138            syncTime(setOffset);
139        }, 10 * 60 * 1000);
140        return () => window.clearInterval(interval);
141    }, [setOffset]);
142
143    const send = useSender(sendJsonMessage, state?.version ?? 0);
144
145    React.useEffect(() => {
146        if (!lastJsonMessage) {
147            return;
148        }
149
150        const note = ServerNote.parse(lastJsonMessage);
151
152        switch (note.method) {
153            case 'state':
154                setState(note.params);
155                break;
156            default:
157                assertNever(note.method);
158        }
159    }, [lastJsonMessage]);
160
161    const player = usePlayer(playerID, state);
162
163    if (!state) {
164        return <Loading />;
165    }
166
167    assertIsDefined(player);
168    nickname.current = player.pState.nickname;
169
170    return (
171        <GameView
172            roomID={props.roomID}
173            leave={props.leave}
174            send={send}
175            state={state}
176            pState={player.pState}
177            pTeam={player.pTeam}
178        />
179    );
180};