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 as codiesVersion } from '../metadata.json';
  9import { ClientNote, PartialClientNote, 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(dispatch: (action: PartialClientNote) => void): Sender {
 16    return React.useMemo<Sender>(() => {
 17        return {
 18            reveal: (row: number, col: number) =>
 19                dispatch({
 20                    method: 'reveal',
 21                    params: {
 22                        row,
 23                        col,
 24                    },
 25                }),
 26            newGame: () => dispatch({ method: 'newGame', params: {} }),
 27            endTurn: () => dispatch({ method: 'endTurn', params: {} }),
 28            changeNickname: (nickname: string) => dispatch({ method: 'changeNickname', params: { nickname } }),
 29            changeRole: (spymaster: boolean) => dispatch({ method: 'changeRole', params: { spymaster } }),
 30            changeTeam: (team: number) => dispatch({ method: 'changeTeam', params: { team } }),
 31            randomizeTeams: () => dispatch({ method: 'randomizeTeams', params: {} }),
 32            changePack: (num: number, enable: boolean) => dispatch({ method: 'changePack', params: { num, enable } }),
 33            changeTurnMode: (timed: boolean) => dispatch({ method: 'changeTurnMode', params: { timed } }),
 34            changeTurnTime: (seconds: number) => dispatch({ method: 'changeTurnTime', params: { seconds } }),
 35            addPacks: (packs: WordPack[]) => dispatch({ method: 'addPacks', params: { packs } }),
 36            removePack: (num: number) => dispatch({ method: 'removePack', params: { num } }),
 37            changeHideBomb: (hideBomb: boolean) => dispatch({ method: 'changeHideBomb', params: { hideBomb } }),
 38        };
 39    }, [dispatch]);
 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        // The names here matter; explicitly naming them so that renaming
 67        // these variables doesn't change the actual wire names.
 68        //
 69        // X-CODIES-VERSION would be cleaner, but the WS hook doesn't
 70        // support anything but query params.
 71        queryParams: { roomID: roomID, playerID: playerID, nickname: nickname, codiesVersion: codiesVersion },
 72        reconnectAttempts,
 73        onMessage: () => {
 74            retry.current = 0;
 75        },
 76        onOpen,
 77        onClose: (e: CloseEvent) => {
 78            if (e.code === 4418) {
 79                reloadOutdatedPage();
 80            }
 81        },
 82        shouldReconnect: () => {
 83            if (didUnmount.current) {
 84                return false;
 85            }
 86
 87            retry.current++;
 88
 89            if (retry.current >= reconnectAttempts) {
 90                dead();
 91                return false;
 92            }
 93
 94            return true;
 95        },
 96    });
 97}
 98
 99function useSyncedServerTime() {
100    const { setOffset } = useServerTime();
101
102    const syncTime = React.useCallback(() => {
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    }, [setOffset]);
132
133    React.useEffect(() => {
134        const interval = window.setInterval(() => {
135            syncTime();
136        }, 10 * 60 * 1000);
137        return () => window.clearInterval(interval);
138    }, [syncTime]);
139
140    return syncTime;
141}
142
143type StateAction = { method: 'setState'; state: State } | PartialClientNote;
144
145function useStateReducer(sendNote: (r: ClientNote) => void) {
146    // TODO: Create a new state which contains the server state.
147    // TODO: Put sendNote in the state instead of reffing it?
148    const sendNoteRef = React.useRef(sendNote);
149    sendNoteRef.current = sendNote;
150
151    return React.useCallback(
152        (state: State | undefined, action: StateAction): State | undefined => {
153            if (state === undefined) {
154                if (action.method === 'setState') {
155                    return action.state;
156                }
157                return state;
158            }
159
160            switch (action.method) {
161                case 'setState':
162                    return action.state;
163                default:
164                    sendNoteRef.current({ ...action, version: state.version });
165                    return state;
166            }
167        },
168        [sendNoteRef]
169    );
170}
171
172export interface GameProps {
173    roomID: string;
174    nickname: string;
175    leave: () => void;
176}
177
178export const Game = (props: GameProps) => {
179    const [playerID] = React.useState(v4);
180    const nickname = React.useRef(props.nickname); // Preserve a nickname for use in reconnects.
181
182    const syncTime = useSyncedServerTime();
183    const { sendJsonMessage, lastJsonMessage } = useWS(props.roomID, playerID, nickname.current, props.leave, syncTime);
184
185    const reducer = useStateReducer(sendJsonMessage);
186    const [state, dispatch] = React.useReducer(reducer, undefined);
187    const send = useSender(dispatch);
188
189    React.useEffect(() => {
190        if (!lastJsonMessage) {
191            return;
192        }
193
194        const note = ServerNote.parse(lastJsonMessage);
195
196        switch (note.method) {
197            case 'state':
198                dispatch({ method: 'setState', state: note.params });
199                break;
200            default:
201                assertNever(note.method);
202        }
203    }, [lastJsonMessage]);
204
205    const player = usePlayer(playerID, state);
206
207    if (!state) {
208        return <Loading />;
209    }
210
211    assertIsDefined(player);
212    nickname.current = player.pState.nickname;
213
214    return (
215        <GameView
216            roomID={props.roomID}
217            leave={props.leave}
218            send={send}
219            state={state}
220            pState={player.pState}
221            pTeam={player.pTeam}
222        />
223    );
224};