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 @@
+
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