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};