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