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