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