Switch sending/state changes to reducer to prep for optimizations

This commit is contained in:
zikaeroh 2020-05-28 18:06:36 -07:00
parent c74e4f8ce0
commit 6a43b5c0dc
2 changed files with 149 additions and 116 deletions

View File

@ -5,41 +5,38 @@ import { v4 } from 'uuid';
import { assertIsDefined, assertNever, noop, reloadOutdatedPage, websocketUrl } from '../common'; import { assertIsDefined, assertNever, noop, reloadOutdatedPage, websocketUrl } from '../common';
import { useServerTime } from '../hooks/useServerTime'; import { useServerTime } from '../hooks/useServerTime';
import { version } from '../metadata.json'; import { version as codiesVersion } from '../metadata.json';
import { ClientNote, ServerNote, State, StatePlayer, TimeResponse, WordPack } from '../protocol'; import { ClientNote, PartialClientNote, ServerNote, State, StatePlayer, TimeResponse, WordPack } from '../protocol';
import { GameView, Sender } from './gameView'; import { GameView, Sender } from './gameView';
import { Loading } from './loading'; import { Loading } from './loading';
const socketUrl = websocketUrl('/api/ws'); const socketUrl = websocketUrl('/api/ws');
function useSender(sendNote: (r: ClientNote) => void, version: number): Sender { function useSender(dispatch: (action: PartialClientNote) => void): Sender {
return React.useMemo<Sender>(() => { return React.useMemo<Sender>(() => {
return { return {
reveal: (row: number, col: number) => reveal: (row: number, col: number) =>
sendNote({ dispatch({
method: 'reveal', method: 'reveal',
version,
params: { params: {
row, row,
col, col,
}, },
}), }),
newGame: () => sendNote({ method: 'newGame', version, params: {} }), newGame: () => dispatch({ method: 'newGame', params: {} }),
endTurn: () => sendNote({ method: 'endTurn', version, params: {} }), endTurn: () => dispatch({ method: 'endTurn', params: {} }),
changeNickname: (nickname: string) => sendNote({ method: 'changeNickname', version, params: { nickname } }), changeNickname: (nickname: string) => dispatch({ method: 'changeNickname', params: { nickname } }),
changeRole: (spymaster: boolean) => sendNote({ method: 'changeRole', version, params: { spymaster } }), changeRole: (spymaster: boolean) => dispatch({ method: 'changeRole', params: { spymaster } }),
changeTeam: (team: number) => sendNote({ method: 'changeTeam', version, params: { team } }), changeTeam: (team: number) => dispatch({ method: 'changeTeam', params: { team } }),
randomizeTeams: () => sendNote({ method: 'randomizeTeams', version, params: {} }), randomizeTeams: () => dispatch({ method: 'randomizeTeams', params: {} }),
changePack: (num: number, enable: boolean) => changePack: (num: number, enable: boolean) => dispatch({ method: 'changePack', params: { num, enable } }),
sendNote({ method: 'changePack', version, params: { num, enable } }), changeTurnMode: (timed: boolean) => dispatch({ method: 'changeTurnMode', params: { timed } }),
changeTurnMode: (timed: boolean) => sendNote({ method: 'changeTurnMode', version, params: { timed } }), changeTurnTime: (seconds: number) => dispatch({ method: 'changeTurnTime', params: { seconds } }),
changeTurnTime: (seconds: number) => sendNote({ method: 'changeTurnTime', version, params: { seconds } }), addPacks: (packs: WordPack[]) => dispatch({ method: 'addPacks', params: { packs } }),
addPacks: (packs: WordPack[]) => sendNote({ method: 'addPacks', version, params: { packs } }), removePack: (num: number) => dispatch({ method: 'removePack', params: { num } }),
removePack: (num: number) => sendNote({ method: 'removePack', version, params: { num } }), changeHideBomb: (hideBomb: boolean) => dispatch({ method: 'changeHideBomb', params: { hideBomb } }),
changeHideBomb: (hideBomb: boolean) =>
sendNote({ method: 'changeHideBomb', version, params: { hideBomb } }),
}; };
}, [sendNote, version]); }, [dispatch]);
} }
function usePlayer(playerID: string, state?: State): { pState: StatePlayer; pTeam: number } | undefined { function usePlayer(playerID: string, state?: State): { pState: StatePlayer; pTeam: number } | undefined {
@ -71,7 +68,7 @@ function useWS(roomID: string, playerID: string, nickname: string, dead: () => v
// //
// X-CODIES-VERSION would be cleaner, but the WS hook doesn't // X-CODIES-VERSION would be cleaner, but the WS hook doesn't
// support anything but query params. // support anything but query params.
queryParams: { roomID: roomID, playerID: playerID, nickname: nickname, codiesVersion: version }, queryParams: { roomID: roomID, playerID: playerID, nickname: nickname, codiesVersion: codiesVersion },
reconnectAttempts, reconnectAttempts,
onMessage: () => { onMessage: () => {
retry.current = 0; retry.current = 0;
@ -99,35 +96,77 @@ function useWS(roomID: string, playerID: string, nickname: string, dead: () => v
}); });
} }
function syncTime(setOffset: (offset: number) => void) { function useSyncedServerTime() {
const fn = async () => { const { setOffset } = useServerTime();
let bestRTT: number | undefined;
let offset = 0;
for (let i = 0; i < 3; i++) { const syncTime = React.useCallback(() => {
const before = Date.now(); const fn = async () => {
const resp = await fetch('/api/time'); let bestRTT: number | undefined;
const after = Date.now(); let offset = 0;
const body = await resp.json(); for (let i = 0; i < 3; i++) {
if (resp.ok) { const before = Date.now();
const rtt = (after - before) / 2; const resp = await fetch('/api/time');
const after = Date.now();
if (bestRTT !== undefined && rtt > bestRTT) { const body = await resp.json();
continue; if (resp.ok) {
const rtt = (after - before) / 2;
if (bestRTT !== undefined && rtt > bestRTT) {
continue;
}
bestRTT = rtt;
const t = TimeResponse.parse(body);
const serverTime = t.time.getTime() + rtt;
offset = serverTime - Date.now();
} }
bestRTT = rtt;
const t = TimeResponse.parse(body);
const serverTime = t.time.getTime() + rtt;
offset = serverTime - Date.now();
} }
}
setOffset(offset); setOffset(offset);
}; };
fn().catch(noop); fn().catch(noop);
}, [setOffset]);
React.useEffect(() => {
const interval = window.setInterval(() => {
syncTime();
}, 10 * 60 * 1000);
return () => window.clearInterval(interval);
}, [syncTime]);
return syncTime;
}
type StateAction = { method: 'setState'; state: State } | PartialClientNote;
function useStateReducer(sendNote: (r: ClientNote) => void) {
// TODO: Create a new state which contains the server state.
// TODO: Put sendNote in the state instead of reffing it?
const sendNoteRef = React.useRef(sendNote);
sendNoteRef.current = sendNote;
return React.useCallback(
(state: State | undefined, action: StateAction): State | undefined => {
if (state === undefined) {
if (action.method === 'setState') {
return action.state;
}
return state;
}
switch (action.method) {
case 'setState':
return action.state;
default:
sendNoteRef.current({ ...action, version: state.version });
return state;
}
},
[sendNoteRef]
);
} }
export interface GameProps { export interface GameProps {
@ -139,21 +178,13 @@ export interface GameProps {
export const Game = (props: GameProps) => { export const Game = (props: GameProps) => {
const [playerID] = React.useState(v4); const [playerID] = React.useState(v4);
const nickname = React.useRef(props.nickname); // Preserve a nickname for use in reconnects. const nickname = React.useRef(props.nickname); // Preserve a nickname for use in reconnects.
const [state, setState] = React.useState<State | undefined>();
const { setOffset } = useServerTime();
const { sendJsonMessage, lastJsonMessage } = useWS(props.roomID, playerID, nickname.current, props.leave, () => const syncTime = useSyncedServerTime();
syncTime(setOffset) const { sendJsonMessage, lastJsonMessage } = useWS(props.roomID, playerID, nickname.current, props.leave, syncTime);
);
React.useEffect(() => { const reducer = useStateReducer(sendJsonMessage);
const interval = window.setInterval(() => { const [state, dispatch] = React.useReducer(reducer, undefined);
syncTime(setOffset); const send = useSender(dispatch);
}, 10 * 60 * 1000);
return () => window.clearInterval(interval);
}, [setOffset]);
const send = useSender(sendJsonMessage, state?.version ?? 0);
React.useEffect(() => { React.useEffect(() => {
if (!lastJsonMessage) { if (!lastJsonMessage) {
@ -164,7 +195,7 @@ export const Game = (props: GameProps) => {
switch (note.method) { switch (note.method) {
case 'state': case 'state':
setState(note.params); dispatch({ method: 'setState', state: note.params });
break; break;
default: default:
assertNever(note.method); assertNever(note.method);

View File

@ -11,69 +11,71 @@ const WordPack = myzod.object({
words: myzod.array(myzod.string()), words: myzod.array(myzod.string()),
}); });
export type PartialClientNote = Infer<typeof PartialClientNote>;
export type PartialClientNoteSender = (r: PartialClientNote) => void;
const PartialClientNote = myzod.union([
myzod.object({
method: myzod.literal('newGame'),
params: myzod.object({}),
}),
myzod.object({
method: myzod.literal('endTurn'),
params: myzod.object({}),
}),
myzod.object({
method: myzod.literal('randomizeTeams'),
params: myzod.object({}),
}),
myzod.object({
method: myzod.literal('reveal'),
params: myzod.object({ row: myzod.number(), col: myzod.number() }),
}),
myzod.object({
method: myzod.literal('changeTeam'),
params: myzod.object({ team: myzod.number() }),
}),
myzod.object({
method: myzod.literal('changeNickname'),
params: myzod.object({ nickname: myzod.string() }),
}),
myzod.object({
method: myzod.literal('changeRole'),
params: myzod.object({ spymaster: myzod.boolean() }),
}),
myzod.object({
method: myzod.literal('changePack'),
params: myzod.object({ num: myzod.number(), enable: myzod.boolean() }),
}),
myzod.object({
method: myzod.literal('changeTurnMode'),
params: myzod.object({ timed: myzod.boolean() }),
}),
myzod.object({
method: myzod.literal('changeTurnTime'),
params: myzod.object({ seconds: myzod.number() }),
}),
myzod.object({
method: myzod.literal('addPacks'),
params: myzod.object({
packs: myzod.array(WordPack),
}),
}),
myzod.object({
method: myzod.literal('removePack'),
params: myzod.object({ num: myzod.number() }),
}),
myzod.object({
method: myzod.literal('changeHideBomb'),
params: myzod.object({ hideBomb: myzod.boolean() }),
}),
]);
export type ClientNote = Infer<typeof ClientNote>; export type ClientNote = Infer<typeof ClientNote>;
export const ClientNote = myzod export const ClientNote = myzod
.object({ .object({
version: myzod.number(), version: myzod.number(),
}) })
.and( .and(PartialClientNote);
myzod.union([
myzod.object({
method: myzod.literal('newGame'),
params: myzod.object({}),
}),
myzod.object({
method: myzod.literal('endTurn'),
params: myzod.object({}),
}),
myzod.object({
method: myzod.literal('randomizeTeams'),
params: myzod.object({}),
}),
myzod.object({
method: myzod.literal('reveal'),
params: myzod.object({ row: myzod.number(), col: myzod.number() }),
}),
myzod.object({
method: myzod.literal('changeTeam'),
params: myzod.object({ team: myzod.number() }),
}),
myzod.object({
method: myzod.literal('changeNickname'),
params: myzod.object({ nickname: myzod.string() }),
}),
myzod.object({
method: myzod.literal('changeRole'),
params: myzod.object({ spymaster: myzod.boolean() }),
}),
myzod.object({
method: myzod.literal('changePack'),
params: myzod.object({ num: myzod.number(), enable: myzod.boolean() }),
}),
myzod.object({
method: myzod.literal('changeTurnMode'),
params: myzod.object({ timed: myzod.boolean() }),
}),
myzod.object({
method: myzod.literal('changeTurnTime'),
params: myzod.object({ seconds: myzod.number() }),
}),
myzod.object({
method: myzod.literal('addPacks'),
params: myzod.object({
packs: myzod.array(WordPack),
}),
}),
myzod.object({
method: myzod.literal('removePack'),
params: myzod.object({ num: myzod.number() }),
}),
myzod.object({
method: myzod.literal('changeHideBomb'),
params: myzod.object({ hideBomb: myzod.boolean() }),
}),
])
);
// Messages sent from server to client. // Messages sent from server to client.