Switch sending/state changes to reducer to prep for optimizations

zikaeroh created

Change summary

frontend/src/pages/game.tsx    | 143 +++++++++++++++++++++--------------
frontend/src/protocol/index.ts | 118 +++++++++++++++--------------
2 files changed, 147 insertions(+), 114 deletions(-)

Detailed changes

frontend/src/pages/game.tsx 🔗

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

frontend/src/protocol/index.ts 🔗

@@ -11,69 +11,71 @@ const WordPack = myzod.object({
     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 const ClientNote = myzod
     .object({
         version: myzod.number(),
     })
-    .and(
-        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() }),
-            }),
-        ])
-    );
+    .and(PartialClientNote);
 
 // Messages sent from server to client.