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 changeHideBomb: (hideBomb: boolean) =>
39 sendNote({ method: 'changeHideBomb', version, params: { hideBomb } }),
40 };
41 }, [sendNote, version]);
42}
43
44function usePlayer(playerID: string, state?: State): { pState: StatePlayer; pTeam: number } | undefined {
45 return React.useMemo(() => {
46 if (!state) {
47 return undefined;
48 }
49
50 for (let i = 0; i < state.teams.length; i++) {
51 const pState = state.teams[i].find((p) => p.playerID === playerID);
52 if (pState) {
53 return { pState, pTeam: i };
54 }
55 }
56
57 fail('Player not found in any team');
58 }, [playerID, state]);
59}
60
61const reconnectAttempts = 5;
62
63function useWS(roomID: string, playerID: string, nickname: string, dead: () => void, onOpen: () => void) {
64 const didUnmount = React.useRef(false);
65 const retry = React.useRef(0);
66
67 return useWebSocket(socketUrl, {
68 queryParams: { roomID, playerID, nickname },
69 reconnectAttempts,
70 onMessage: () => {
71 retry.current = 0;
72 },
73 onOpen,
74 shouldReconnect: () => {
75 if (didUnmount.current) {
76 return false;
77 }
78
79 retry.current++;
80
81 if (retry.current >= reconnectAttempts) {
82 dead();
83 return false;
84 }
85
86 return true;
87 },
88 });
89}
90
91function syncTime(setOffset: (offset: number) => void) {
92 const fn = async () => {
93 let bestRTT: number | undefined;
94 let offset = 0;
95
96 for (let i = 0; i < 3; i++) {
97 const before = Date.now();
98 const resp = await fetch('/api/time');
99 const after = Date.now();
100
101 const body = await resp.json();
102 if (resp.ok) {
103 const rtt = (after - before) / 2;
104
105 if (bestRTT !== undefined && rtt > bestRTT) {
106 continue;
107 }
108
109 bestRTT = rtt;
110
111 const t = TimeResponse.parse(body);
112 const serverTime = t.time.getTime() + rtt;
113 offset = serverTime - Date.now();
114 }
115 }
116
117 setOffset(offset);
118 };
119 fn().catch(noop);
120}
121
122export interface GameProps {
123 roomID: string;
124 nickname: string;
125 leave: () => void;
126}
127
128export const Game = (props: GameProps) => {
129 const [playerID] = React.useState(v4);
130 const nickname = React.useRef(props.nickname); // Preserve a nickname for use in reconnects.
131 const [state, setState] = React.useState<State | undefined>();
132 const { setOffset } = useServerTime();
133
134 const { sendJsonMessage, lastJsonMessage } = useWS(props.roomID, playerID, nickname.current, props.leave, () =>
135 syncTime(setOffset)
136 );
137
138 React.useEffect(() => {
139 const interval = window.setInterval(() => {
140 syncTime(setOffset);
141 }, 10 * 60 * 1000);
142 return () => window.clearInterval(interval);
143 }, [setOffset]);
144
145 const send = useSender(sendJsonMessage, state?.version ?? 0);
146
147 React.useEffect(() => {
148 if (!lastJsonMessage) {
149 return;
150 }
151
152 const note = ServerNote.parse(lastJsonMessage);
153
154 switch (note.method) {
155 case 'state':
156 setState(note.params);
157 break;
158 default:
159 assertNever(note.method);
160 }
161 }, [lastJsonMessage]);
162
163 const player = usePlayer(playerID, state);
164
165 if (!state) {
166 return <Loading />;
167 }
168
169 assertIsDefined(player);
170 nickname.current = player.pState.nickname;
171
172 return (
173 <GameView
174 roomID={props.roomID}
175 leave={props.leave}
176 send={send}
177 state={state}
178 pState={player.pState}
179 pTeam={player.pTeam}
180 />
181 );
182};