diff --git a/Makefile b/Makefile index 6ece6c0..f657822 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,6 @@ +.PHONY: all +all: fix check test + .PHONY: install install: pnpm install diff --git a/games/game1-v2.7.0/index.html b/games/game1-v2.7.0/index.html index 7e8e45f..e763f41 100644 --- a/games/game1-v2.7.0/index.html +++ b/games/game1-v2.7.0/index.html @@ -5,6 +5,12 @@ +
diff --git a/games/game1-v2.7.0/src/Board.tsx b/games/game1-v2.7.0/src/Board.tsx index d02b0cc..1bdf64a 100644 --- a/games/game1-v2.7.0/src/Board.tsx +++ b/games/game1-v2.7.0/src/Board.tsx @@ -87,26 +87,47 @@ const EndMatchCountDown = () => { return ; }; -function Board() { +const Sum = () => { + const sum = useSelector((state) => state.board.sum); + return
Sum: {sum}
; +}; + +const Buttons = () => { const makeMove = useMakeMove(); + const itsMyTurn = useSelector( + (state) => getCurrentPlayer(state.board) === state.userId, + ); + return ( + <> + + + + ); +}; + +function Board() { const players = useSelectorShallow((state) => Object.keys(state.board.players), ); const matchSettings = useSelector((state) => state.board.matchSettings); - const sum = useSelector((state) => state.board.sum); - - const itsMyTurn = useSelector( - (state) => getCurrentPlayer(state.board) === state.userId, - ); - return (
The template game -
Sum: {sum}
+ {Object.entries(matchSettings).map(([key, value]) => (
@@ -118,20 +139,7 @@ function Board() { ))}
- <> - - - +
); diff --git a/games/game1-v2.7.0/src/game.ts b/games/game1-v2.7.0/src/game.ts index 1e5ab5b..18abb7e 100644 --- a/games/game1-v2.7.0/src/game.ts +++ b/games/game1-v2.7.0/src/game.ts @@ -112,7 +112,10 @@ const goToNextPlayer = ({ }; const pass: PM = { - executeNow({ board, turns }) { + executeNow({ board, turns, userId }) { + if (getCurrentPlayer(board) !== userId) { + throw new Error("not your turn!"); + } goToNextPlayer({ board, turns }); }, }; diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index fd34fb7..a479ce2 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -64,7 +64,6 @@ "classnames": "^2.5.1", "immer": "^10.1.1", "json-edit-react": "^1.13.3", - "valtio": "^2.1.5", "zustand": "^4.5.4" }, "postcss": { diff --git a/packages/dev-server/src/App.tsx b/packages/dev-server/src/App.tsx index 518341d..2c60f6e 100644 --- a/packages/dev-server/src/App.tsx +++ b/packages/dev-server/src/App.tsx @@ -7,7 +7,9 @@ import classNames from "classnames"; import { enablePatches, Patch } from "immer"; import { JsonEditor } from "json-edit-react"; import { ReactNode, RefObject, useEffect, useRef, useState } from "react"; -import { proxy, snapshot, useSnapshot } from "valtio"; +import { useStore as useStoreZustand } from "zustand"; +import { shallow } from "zustand/shallow"; +import { useStoreWithEqualityFn } from "zustand/traditional"; import { GameId, @@ -15,7 +17,6 @@ import { GameSetting, GameSettings_, Locale, - Meta, UserId, } from "@lefun/core"; import { @@ -42,7 +43,7 @@ import { OptimisticBoards } from "./moves"; import { useStore } from "./store"; import { generateId } from "./utils"; -const LATENCY = 100; +const LATENCY = 500; enablePatches(); @@ -80,22 +81,20 @@ const BoardForPlayer = ({ }, [locale, messages]); const [loading, setLoading] = useState(true); + const { store: mainStore } = backend; + const { users } = mainStore; - // Here we use `valtio` as a test to see how well it works as a local state - // management solution. So far it's pretty good, perhaps we should also use it for the dev-server settings! const optimisticBoards = useRef( - proxy( - new OptimisticBoards({ - board: backend.store.board, - playerboard: backend.store.playerboards[userId] || null, - meta: backend.store.meta, - }), - ), + new OptimisticBoards({ + board: backend.store.board, + playerboard: backend.store.playerboards[userId] || null, + meta: backend.store.meta, + userId, + users, + }), ); useEffect(() => { - const { store: mainStore } = backend; - backend.addEventListener(patchesForUserEvent(userId), (event: any) => { if (!event) { return; @@ -127,23 +126,23 @@ const BoardForPlayer = ({ let result: MoveExecutionOutput | null = null; + const { board, playerboard, meta } = + optimisticBoards.current.store.getState(); + try { result = executePlayerMove({ name, payload, game: backend.game, userId, - board: optimisticBoards.current.board, - playerboards: { [userId]: optimisticBoards.current.playerboard }, + board, + playerboards: { [userId]: playerboard }, secretboard: null, now: new Date().getTime(), - random: backend.random, onlyExecuteNow: true, // Note that technically we should not use anything from // `match.store` as this represents the DB. - matchData: backend.store.matchData, - gameData: backend.store.gameData, - meta: backend.store.meta, + meta, isExpiration: false, }); } catch (e) { @@ -164,26 +163,12 @@ const BoardForPlayer = ({ backend.makeMove({ userId, name, payload, moveId, isExpiration: false }); }); - const { users } = mainStore; - const _useSelector = (): UseSelector => { // We wrap it to respect the rules of hooks. const useSelector = ( selector: Selector, ): T => { - const snapshot = useSnapshot(optimisticBoards.current); - const board = snapshot.board; - const playerboard = snapshot.playerboard; - const meta = snapshot.meta as Meta; - return selector({ - board, - playerboard, - meta, - userId, - users, - timeDelta: 0, - timeLatency: 0, - }); + return useStoreZustand(optimisticBoards.current.store, selector); }; return useSelector; }; @@ -191,23 +176,27 @@ const BoardForPlayer = ({ setUseSelector(_useSelector); setUseStore(() => { - const playerboard = optimisticBoards.current.playerboard; - return { - board: snapshot(optimisticBoards.current.board), - playerboard: playerboard === null ? null : snapshot(playerboard), - meta: snapshot(optimisticBoards.current.meta) as Meta, - userId, - users, - timeDelta: 0, - timeLatency: 0, - }; + return optimisticBoards.current.store.getState(); }); - // As far as I know, valtio does not support shallow selectors, but if I understand correctly it's not a concern with Valtio. - setUseSelectorShallow(_useSelector); + const _useSelectorShallow = (): UseSelector => { + // We wrap it to respect the rules of hooks. + const useSelector = ( + selector: Selector, + ): T => { + return useStoreWithEqualityFn( + optimisticBoards.current.store, + selector, + shallow, + ); + }; + return useSelector; + }; + + setUseSelectorShallow(_useSelectorShallow); setLoading(false); - }, [userId, backend, gameId]); + }, [userId, backend, gameId, users]); if (loading) { return
Loading player...
; diff --git a/packages/dev-server/src/backend.ts b/packages/dev-server/src/backend.ts index b4779b4..07667e2 100644 --- a/packages/dev-server/src/backend.ts +++ b/packages/dev-server/src/backend.ts @@ -17,7 +17,6 @@ import { Game_, MoveExecutionOutput, Random, - updateMetaWithTurnInfo, } from "@lefun/game"; import { User } from "@lefun/ui"; @@ -338,23 +337,13 @@ class Backend extends EventTarget { // Update the store. { - const { board, playerboards, secretboard } = result; + const { board, playerboards, secretboard, meta } = result; store.board = board; store.playerboards = playerboards; store.secretboard = secretboard; + store.meta = meta; } - // Update meta - const { beginTurn, endTurn } = result; - const { meta: newMeta, patches: metaPatches } = updateMetaWithTurnInfo({ - meta, - beginTurn, - endTurn, - now, - }); - - store.meta = newMeta; - // Send out patches to users. { const userIds = store.meta.players.allIds; @@ -366,12 +355,12 @@ class Backend extends EventTarget { const { patches } = result; separatePatchesByUser({ - patches: [...patches, ...metaPatches], + patches, userIds, patchesOut: patchesByUserId, }); - // Add the `meta` patches to everyone. + // Add the patches to everyone. for (const [userId, patches] of Object.entries(patchesByUserId)) { if (patches.length === 0) { @@ -459,7 +448,7 @@ class Backend extends EventTarget { try { this._makeMove({ name, payload, userId, moveId, isExpiration }); } catch (e) { - console.error("There was an error executing move", name, e); + console.warn("BACKEND: There was an error executing move", name, e); this.dispatchEvent( new CustomEvent(REVERT_MOVE_EVENT, { detail: { moveId } }), ); diff --git a/packages/dev-server/src/moves.ts b/packages/dev-server/src/moves.ts index e62d3cc..708c698 100644 --- a/packages/dev-server/src/moves.ts +++ b/packages/dev-server/src/moves.ts @@ -1,9 +1,19 @@ import { applyPatches, Patch } from "immer"; +import { createStore, StoreApi } from "zustand"; -import { Meta } from "@lefun/core"; +import { Meta, UserId } from "@lefun/core"; import { GameStateBase } from "@lefun/game"; +import { User } from "@lefun/ui"; -import { deepCopy } from "./utils"; +type Store = { + board: GS["B"]; + playerboard: GS["PB"]; + meta: Meta; + userId: UserId; + users: Record; + timeDelta: number; + timeLatency: number; +}; export class OptimisticBoards { _confirmedBoard: GS["B"]; @@ -11,33 +21,51 @@ export class OptimisticBoards { _confirmedMeta: Meta; // The player's moves that have not been confirmed yet _pendingMoves: { moveId: string; patches: Patch[] }[]; - // ConfirmedBoard + pending moves updates: this is what we will display. - board: GS["B"]; - // `null` for spectators. Note that GS["PB"] can itself be `null` for games without playerboards. - playerboard: GS["PB"] | null; - meta: Meta; + store: StoreApi>; constructor({ board, playerboard, meta, + userId, + users, }: { board: GS["B"]; playerboard: GS["PB"] | null; meta: Meta; + userId: UserId; + users: Record; }) { - board = deepCopy(board); - playerboard = deepCopy(playerboard); - meta = deepCopy(meta); - this._confirmedBoard = board; this._confirmedPlayerboard = playerboard; this._confirmedMeta = meta; this._pendingMoves = []; - this.board = board; - this.playerboard = playerboard; - this.meta = meta; + + this.store = this._createStore({ userId, users }); + } + + /* Create the zustand store */ + _createStore({ + userId, + users, + }: { + userId: UserId; + users: Record; + }) { + const store = createStore>()(() => ({ + // These will contain the confirmed boards + patches from the pending moves. + board: this._confirmedBoard, + playerboard: this._confirmedPlayerboard, + meta: this._confirmedMeta, + // + userId, + users, + timeDelta: 0, + timeLatency: 0, + })); + + return store; } _getMoveIndex(moveId: string): number { @@ -51,18 +79,18 @@ export class OptimisticBoards { } _replay() { - let board = this._confirmedBoard; - let playerboard = this._confirmedPlayerboard; - let meta = this._confirmedMeta; - for (const { patches } of this._pendingMoves) { - ({ board, playerboard, meta } = applyPatches( - { board, playerboard, meta }, - patches, - )); - } - this.board = board; - this.playerboard = playerboard; - this.meta = meta; + this.store.setState((oldState) => { + let newState = { + board: this._confirmedBoard, + playerboard: this._confirmedPlayerboard, + meta: this._confirmedMeta, + }; + + for (const { patches } of this._pendingMoves) { + newState = applyPatches(newState, patches); + } + return { ...oldState, ...newState }; + }); } makeMove(moveId: string, patches: Patch[]) { @@ -86,12 +114,13 @@ export class OptimisticBoards { * have the `executeNow` patches. */ confirmMove({ moveId, patches }: { moveId?: string; patches: Patch[] }) { - // Start from the confirmed boards + // Add the patches to the confirmedBoard, changing at few fields as possible (using Immer). const { _confirmedBoard: board, _confirmedPlayerboard: playerboard, _confirmedMeta: meta, } = this; + { const state = applyPatches({ board, playerboard, meta }, patches); this._confirmedBoard = state.board; diff --git a/packages/dev-server/src/utils.ts b/packages/dev-server/src/utils.ts index 9f6c4e2..029bcc7 100644 --- a/packages/dev-server/src/utils.ts +++ b/packages/dev-server/src/utils.ts @@ -1,14 +1,3 @@ -/* - * Deep copy an object. - */ -export function deepCopy(obj: T): T { - if (obj === undefined) { - return obj; - } - - return JSON.parse(JSON.stringify(obj)); -} - let _counter = 0; export function generateId() { return `${new Date().getTime()}-${_counter++}`; diff --git a/packages/game/src/execution.ts b/packages/game/src/execution.ts index 722cb68..50bac68 100644 --- a/packages/game/src/execution.ts +++ b/packages/game/src/execution.ts @@ -85,6 +85,7 @@ export type MoveExecutionOutput = { board: object; playerboards: Record; secretboard: object | null; + meta: Meta; patches: Patch[]; } & SideEffectResults; @@ -104,28 +105,55 @@ type BoardMoveExecutionInput = { isExpiration: boolean; }; -type PlayerMoveExecutionInput = - BoardMoveExecutionInput & { - userId: UserId; - onlyExecuteNow?: boolean; - }; +type PlayerMoveExecutionInput = { + name: string; + payload: unknown; + game: Game_; + board: GS["B"]; + playerboards: Record; + // `secretboard` is not strictly required for player moves, but we allow passing `null` already. + secretboard: GS["SB"]; + meta: Meta; + isExpiration: boolean; + userId: UserId; + now: number; +} & ( + | { + // If onlyExecuteNow is `false` (or missing), then we need everything. + onlyExecuteNow?: false; + matchData: unknown; + gameData: unknown; + random: Random; + } + | { + // If onlyExecuteNow is `true`, then the "execute"-only options are + // not required. + onlyExecuteNow: true; + // We still make them optional to make the calling more flexible. + matchData?: unknown; + gameData?: unknown; + random?: Random; + } +); + +export function executePlayerMove( + options: PlayerMoveExecutionInput, +): MoveExecutionOutput { + const { + name, + payload, + game, + matchData, + gameData, + userId, + now, + isExpiration, + random, + onlyExecuteNow = false, + } = options; + + let { board, playerboards, secretboard, meta } = options; -export function executePlayerMove({ - name, - payload, - game, - userId, - board, - playerboards, - secretboard, - matchData, - gameData, - now, - random, - onlyExecuteNow = false, - meta, - isExpiration, -}: PlayerMoveExecutionInput): MoveExecutionOutput { // This is a "normal" player move. const moveDef = game.playerMoves[name]; @@ -181,11 +209,13 @@ export function executePlayerMove({ playerboard: playerboards[userId]!, userId, payload, + ts: now, _: moveSideEffects, ...moveSideEffects, }); }, ); + if (error) { throw error as Error; } @@ -196,6 +226,10 @@ export function executePlayerMove({ } if (execute && retValue !== false && !onlyExecuteNow) { + if (random === undefined) { + throw new Error('"random" is required to run "execute"'); + } + const { output, patches, error } = tryProduceWithPatches( { board, playerboards, secretboard }, ({ board, playerboards, secretboard }) => { @@ -223,10 +257,26 @@ export function executePlayerMove({ allPatches.push(...patches); } + // Deal with the turns which modify `meta`. + { + const { beginTurn, endTurn } = sideEffectResults; + + const { meta: newMeta, patches } = updateMetaWithTurnInfo({ + meta, + beginTurn, + endTurn, + now, + }); + + meta = newMeta; + allPatches.push(...patches); + } + return { board, playerboards, secretboard, + meta, patches: allPatches, ...sideEffectResults, }; @@ -461,10 +511,26 @@ export function executeBoardMove({ ({ board, playerboards, secretboard } = output); + // Deal with the turns which modify `meta`. + { + const { beginTurn, endTurn } = sideEffectResults; + + const { meta: newMeta, patches: metaPatches } = updateMetaWithTurnInfo({ + meta, + beginTurn, + endTurn, + now, + }); + + meta = newMeta; + patches.push(...metaPatches); + } + return { board, playerboards, secretboard, + meta, patches, ...sideEffectResults, }; diff --git a/packages/game/src/gameDef.ts b/packages/game/src/gameDef.ts index e833cc5..5741c88 100644 --- a/packages/game/src/gameDef.ts +++ b/packages/game/src/gameDef.ts @@ -147,8 +147,6 @@ export type MoveSideEffects< logMatchStat: LogMatchStat; }; -// TODO Add `ts` in the options here -// https://github.com/lefun-fun/lefun/issues/52 type ExecuteNowOptions< GS extends GameStateBase, P, @@ -162,6 +160,7 @@ type ExecuteNowOptions< // Assume that the game developer has defined the playerboard if they're using it. playerboard: GS["PB"]; payload: P; + ts: number; _: MoveSideEffects; } & MoveSideEffects; @@ -275,6 +274,7 @@ export type InitialPlayerboardOptions = { gameData: any; matchData?: any; }; + /* * Object that `autoMove` can return to help train reinforcement learning models. */ diff --git a/packages/game/src/utils.ts b/packages/game/src/utils.ts index 576c1f9..4324e11 100644 --- a/packages/game/src/utils.ts +++ b/packages/game/src/utils.ts @@ -17,7 +17,3 @@ export function parseMove( const [name, payload] = move; return { name, payload }; } - -export function deepCopy(obj: T): T { - return JSON.parse(JSON.stringify(obj)); -} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 637d8ab..3521ca3 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -124,13 +124,10 @@ export function makeUseMakeMove>() { /* * Util to check if the user is a player (if not they are a spectator). */ -export const useIsPlayer = () => { - // Currently, the user is a player iif its playerboard is defined. - const hasPlayerboard = useSelector((state: UIState) => { - return !!state.playerboard; +export const useIsPlayer = () => + useSelector((state: UIState) => { + return state.meta.players.byId[state.userId] !== undefined; }); - return hasPlayerboard; -}; type TimeAdjust = "none" | "after" | "before"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb5eb68..4a246df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,9 +154,6 @@ importers: json-edit-react: specifier: ^1.13.3 version: 1.13.3(react@18.3.1) - valtio: - specifier: ^2.1.5 - version: 2.1.5(@types/react@18.3.3)(react@18.3.1) zustand: specifier: ^4.5.4 version: 4.5.4(@types/react@18.3.3)(immer@10.1.1)(react@18.3.1) @@ -3977,9 +3974,6 @@ packages: protocols@2.0.1: resolution: {integrity: sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q==} - proxy-compare@3.0.1: - resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==} - proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -4599,18 +4593,6 @@ packages: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - valtio@2.1.5: - resolution: {integrity: sha512-vsh1Ixu5mT0pJFZm+Jspvhga5GzHUTYv0/+Th203pLfh3/wbHwxhu/Z2OkZDXIgHfjnjBns7SN9HNcbDvPmaGw==} - engines: {node: '>=12.20.0'} - peerDependencies: - '@types/react': '>=18.0.0' - react: '>=18.0.0' - peerDependenciesMeta: - '@types/react': - optional: true - react: - optional: true - vite-node@2.0.5: resolution: {integrity: sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==} engines: {node: ^18.0.0 || >=20.0.0} @@ -9046,8 +9028,6 @@ snapshots: protocols@2.0.1: {} - proxy-compare@3.0.1: {} - proxy-from-env@1.1.0: {} pseudolocale@2.1.0: @@ -9725,13 +9705,6 @@ snapshots: validate-npm-package-name@5.0.1: {} - valtio@2.1.5(@types/react@18.3.3)(react@18.3.1): - dependencies: - proxy-compare: 3.0.1 - optionalDependencies: - '@types/react': 18.3.3 - react: 18.3.1 - vite-node@2.0.5(@types/node@20.14.10): dependencies: cac: 6.7.14