From 2d8cb57cb24cc849692937d9e45b38a78f804eaf Mon Sep 17 00:00:00 2001 From: ponderingdemocritus Date: Thu, 26 Sep 2024 11:43:40 +1000 Subject: [PATCH 1/7] feat: draft zustand state management system --- examples/example-vite-react-sdk/package.json | 10 +- examples/example-vite-react-sdk/src/App.tsx | 79 +++++++------ examples/example-vite-react-sdk/src/state.ts | 118 +++++++++++++++++++ pnpm-lock.yaml | 74 ++++++------ 4 files changed, 208 insertions(+), 73 deletions(-) create mode 100644 examples/example-vite-react-sdk/src/state.ts diff --git a/examples/example-vite-react-sdk/package.json b/examples/example-vite-react-sdk/package.json index 1127bbd6..a9d3e833 100644 --- a/examples/example-vite-react-sdk/package.json +++ b/examples/example-vite-react-sdk/package.json @@ -10,13 +10,17 @@ "preview": "vite preview" }, "dependencies": { - "@dojoengine/core": "1.0.0-alpha.12", + "@dojoengine/core": "workspace:*", "@dojoengine/sdk": "workspace:*", - "@dojoengine/torii-wasm": "1.0.0-alpha.12", + "@dojoengine/torii-wasm": "workspace:*", + "@types/uuid": "^10.0.0", + "immer": "^10.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "uuid": "^10.0.0", "vite-plugin-top-level-await": "^1.4.4", - "vite-plugin-wasm": "^3.3.0" + "vite-plugin-wasm": "^3.3.0", + "zustand": "^4.5.5" }, "devDependencies": { "@eslint/js": "^9.11.1", diff --git a/examples/example-vite-react-sdk/src/App.tsx b/examples/example-vite-react-sdk/src/App.tsx index 158d864d..d7e6151d 100644 --- a/examples/example-vite-react-sdk/src/App.tsx +++ b/examples/example-vite-react-sdk/src/App.tsx @@ -1,10 +1,14 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import "./App.css"; -import { ParsedEntity, SDK } from "@dojoengine/sdk"; +import { SDK } from "@dojoengine/sdk"; import { Schema } from "./bindings.ts"; +import { useGameState } from "./state.ts"; + +import { v4 as uuidv4 } from "uuid"; function App({ db }: { db: SDK }) { - const [entities, setEntities] = useState[]>([]); + const state = useGameState((state) => state); + const entities = useGameState((state) => state.entities); useEffect(() => { let unsubscribe: (() => void) | undefined; @@ -28,15 +32,7 @@ function App({ db }: { db: SDK }) { response.data && response.data[0].entityId !== "0x0" ) { - console.log(response.data); - setEntities((prevEntities) => { - return prevEntities.map((entity) => { - const newEntity = response.data?.find( - (e) => e.entityId === entity.entityId - ); - return newEntity ? newEntity : entity; - }); - }); + state.setEntities(response.data); } }, { logging: true } @@ -54,8 +50,6 @@ function App({ db }: { db: SDK }) { }; }, [db]); - console.log("entities:"); - useEffect(() => { const fetchEntities = async () => { try { @@ -76,23 +70,7 @@ function App({ db }: { db: SDK }) { return; } if (resp.data) { - console.log(resp.data); - setEntities((prevEntities) => { - const updatedEntities = [...prevEntities]; - resp.data?.forEach((newEntity) => { - const index = updatedEntities.findIndex( - (entity) => - entity.entityId === - newEntity.entityId - ); - if (index !== -1) { - updatedEntities[index] = newEntity; - } else { - updatedEntities.push(newEntity); - } - }); - return updatedEntities; - }); + state.setEntities(resp.data); } } ); @@ -104,12 +82,45 @@ function App({ db }: { db: SDK }) { fetchEntities(); }, [db]); + const optimisticUpdate = async () => { + const entityId = + "0x571368d35c8fe136adf81eecf96a72859c43de7efd8fdd3d6f0d17e308df984"; + + const transactionId = uuidv4(); + + state.applyOptimisticUpdate(transactionId, (draft) => { + draft.entities[entityId].models.dojo_starter.Moves!.remaining = 10; + }); + + try { + // Wait for the entity to be updated before full resolving the transaction. Reverts if the condition is not met. + const updatedEntity = await state.waitForEntityChange( + entityId, + (entity) => { + // Define your specific condition here + return entity?.models.dojo_starter.Moves?.can_move === true; + } + ); + + console.log("Entity has been updated to active:", updatedEntity); + + console.log("Updating entities..."); + } catch (error) { + console.error("Error updating entities:", error); + state.revertOptimisticUpdate(transactionId); + } finally { + console.log("Updating entities..."); + state.confirmTransaction(transactionId); + } + }; + return (

Game State

- {entities.map((entity) => ( -
-

Entity {entity.entityId}

+ + {Object.entries(entities).map(([entityId, entity]) => ( +
+

Entity {entityId}

Position

Player:{" "} diff --git a/examples/example-vite-react-sdk/src/state.ts b/examples/example-vite-react-sdk/src/state.ts new file mode 100644 index 00000000..4237b5c3 --- /dev/null +++ b/examples/example-vite-react-sdk/src/state.ts @@ -0,0 +1,118 @@ +import { create } from "zustand"; +import { immer } from "zustand/middleware/immer"; +import { Draft, Patch, applyPatches, produceWithPatches } from "immer"; +import { ParsedEntity } from "@dojoengine/sdk"; +import { Schema } from "./bindings"; + +import { enablePatches } from "immer"; +enablePatches(); + +interface PendingTransaction { + transactionId: string; + patches: Patch[]; + inversePatches: Patch[]; +} + +interface GameState { + entities: Record>; + pendingTransactions: Record; + setEntities: (entities: ParsedEntity[]) => void; + updateEntity: (entity: ParsedEntity) => void; + applyOptimisticUpdate: ( + transactionId: string, + updateFn: (draft: Draft) => void + ) => void; + revertOptimisticUpdate: (transactionId: string) => void; + confirmTransaction: (transactionId: string) => void; + subscribeToEntity: ( + entityId: string, + listener: (entity: ParsedEntity | undefined) => void + ) => () => void; + waitForEntityChange: ( + entityId: string, + predicate: (entity: ParsedEntity | undefined) => boolean, + timeout?: number + ) => Promise | undefined>; +} + +export const useGameState = create()( + immer((set, get) => ({ + entities: {}, + pendingTransactions: {}, + setEntities: (entities: ParsedEntity[]) => { + set((state) => { + entities.forEach((entity) => { + state.entities[entity.entityId] = entity; + }); + }); + }, + updateEntity: (entity: ParsedEntity) => { + set((state) => { + state.entities[entity.entityId] = entity; + }); + }, + applyOptimisticUpdate: (transactionId, updateFn) => { + const currentState = get(); + const [nextState, patches, inversePatches] = produceWithPatches( + currentState, + (draft) => { + updateFn(draft); + } + ); + + set(() => nextState); + + set((state) => { + state.pendingTransactions[transactionId] = { + transactionId, + patches, + inversePatches, + }; + }); + }, + revertOptimisticUpdate: (transactionId) => { + const transaction = get().pendingTransactions[transactionId]; + if (transaction) { + set((state) => applyPatches(state, transaction.inversePatches)); + set((state) => { + delete state.pendingTransactions[transactionId]; + }); + } + }, + confirmTransaction: (transactionId) => { + set((state) => { + delete state.pendingTransactions[transactionId]; + }); + }, + subscribeToEntity: (entityId, listener): (() => void) => { + const unsubscribe: () => void = useGameState.subscribe((state) => { + const entity = state.entities[entityId]; + listener(entity); + }); + return unsubscribe; + }, + waitForEntityChange: (entityId, predicate, timeout = 6000) => { + return new Promise | undefined>( + (resolve, reject) => { + const unsubscribe = useGameState.subscribe((state) => { + const entity = state.entities[entityId]; + if (predicate(entity)) { + clearTimeout(timer); + unsubscribe(); + resolve(entity); + } + }); + + const timer = setTimeout(() => { + unsubscribe(); + reject( + new Error( + `waitForEntityChange: Timeout of ${timeout}ms exceeded` + ) + ); + }, timeout); + } + ); + }, + })) +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15638b5c..5ad07536 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -344,7 +344,7 @@ importers: version: 3.3.0(vite@4.5.5(@types/node@20.16.6)(terser@5.33.0)) zustand: specifier: ^4.5.5 - version: 4.5.5(@types/react@18.3.9)(react@18.3.1) + version: 4.5.5(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1) devDependencies: '@types/node': specifier: ^20.16.6 @@ -507,26 +507,38 @@ importers: examples/example-vite-react-sdk: dependencies: '@dojoengine/core': - specifier: 1.0.0-alpha.12 - version: 1.0.0-alpha.12(starknet@6.11.0(encoding@0.1.13))(typescript@5.6.2) + specifier: workspace:* + version: link:../../packages/core '@dojoengine/sdk': specifier: workspace:* version: link:../../packages/sdk '@dojoengine/torii-wasm': - specifier: 1.0.0-alpha.12 - version: 1.0.0-alpha.12 + specifier: workspace:* + version: link:../../packages/torii-wasm + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + immer: + specifier: ^10.1.1 + version: 10.1.1 react: specifier: ^18.3.1 version: 18.3.1 react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + uuid: + specifier: ^10.0.0 + version: 10.0.0 vite-plugin-top-level-await: specifier: ^1.4.4 version: 1.4.4(rollup@4.22.4)(vite@5.4.7(@types/node@22.6.1)(terser@5.33.0)) vite-plugin-wasm: specifier: ^3.3.0 version: 3.3.0(vite@5.4.7(@types/node@22.6.1)(terser@5.33.0)) + zustand: + specifier: ^4.5.5 + version: 4.5.5(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1) devDependencies: '@eslint/js': specifier: ^9.11.1 @@ -614,7 +626,7 @@ importers: version: 1.1.0(@types/react@18.3.9)(react@18.3.1) '@react-three/drei': specifier: ^9.114.0 - version: 9.114.0(@react-three/fiber@8.17.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1))(@types/react@18.3.9)(@types/three@0.160.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1) + version: 9.114.0(@react-three/fiber@8.17.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1))(@types/react@18.3.9)(@types/three@0.160.0)(immer@10.1.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1) '@react-three/fiber': specifier: ^8.17.8 version: 8.17.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1) @@ -695,7 +707,7 @@ importers: version: 3.3.0(vite@4.5.5(@types/node@20.16.6)(terser@5.33.0)) zustand: specifier: ^4.5.5 - version: 4.5.5(@types/react@18.3.9)(react@18.3.1) + version: 4.5.5(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1) devDependencies: '@storybook/addon-essentials': specifier: ^7.6.20 @@ -1000,7 +1012,7 @@ importers: version: 2.19.0 zustand: specifier: ^4.5.5 - version: 4.5.5(@types/react@18.3.9)(react@18.3.1) + version: 4.5.5(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1) devDependencies: '@babel/core': specifier: ^7.25.2 @@ -1049,7 +1061,7 @@ importers: version: 3.3.0(vite@3.2.11(@types/node@22.6.1)(terser@5.33.0)) zustand: specifier: ^4.5.5 - version: 4.5.5(@types/react@18.3.9)(react@18.3.1) + version: 4.5.5(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1) devDependencies: '@vitest/coverage-v8': specifier: ^1.6.0 @@ -1092,7 +1104,7 @@ importers: version: 1.6.0(@types/node@22.6.1)(jsdom@24.1.3)(terser@5.33.0) zustand: specifier: ^4.5.5 - version: 4.5.5(@types/react@18.3.9)(react@18.3.1) + version: 4.5.5(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1) devDependencies: tsup: specifier: ^8.3.0 @@ -1995,18 +2007,9 @@ packages: resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} - '@dojoengine/core@1.0.0-alpha.12': - resolution: {integrity: sha512-KuinebMRPrsGebpQqW8oXVYRaCiUOdngjr4vN6WaWkUcyvFRfeJqzCemQdgqtOwvZZKjo6UIQNvYWPsuzjcxnA==} - hasBin: true - peerDependencies: - starknet: 6.11.0 - '@dojoengine/recs@2.0.13': resolution: {integrity: sha512-Cgz4Unlnk2FSDoFTYKrJexX/KiSYPMFMxftxQkC+9LUKS5yNGkgFQM7xu4/L1HvpDAenL7NjUmH6ynRAS7Iifw==} - '@dojoengine/torii-wasm@1.0.0-alpha.12': - resolution: {integrity: sha512-GiPlaJkSqjpCzN42xv6F0zv1UJLUcIthiwU8LQYU82DCVqKkODvd/ad0YH00PQ2pB/ILEiMvoJQUQXP108yFqQ==} - '@emnapi/core@1.2.0': resolution: {integrity: sha512-E7Vgw78I93we4ZWdYCb4DGAwRROGkMIXk7/y87UmANR+J6qsWusmC3gLt0H+O0KOt5e6O38U8oJamgbudrES/w==} @@ -4906,6 +4909,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} @@ -7409,6 +7415,9 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immer@10.1.1: + resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==} + immutable@3.7.6: resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==} engines: {node: '>=0.8.0'} @@ -12609,16 +12618,6 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} - '@dojoengine/core@1.0.0-alpha.12(starknet@6.11.0(encoding@0.1.13))(typescript@5.6.2)': - dependencies: - '@dojoengine/recs': 2.0.13(typescript@5.6.2)(zod@3.23.8) - starknet: 6.11.0(encoding@0.1.13) - zod: 3.23.8 - transitivePeerDependencies: - - bufferutil - - typescript - - utf-8-validate - '@dojoengine/recs@2.0.13(typescript@5.6.2)(zod@3.23.8)': dependencies: '@latticexyz/schema-type': 2.0.12(typescript@5.6.2)(zod@3.23.8) @@ -12631,8 +12630,6 @@ snapshots: - utf-8-validate - zod - '@dojoengine/torii-wasm@1.0.0-alpha.12': {} - '@emnapi/core@1.2.0': dependencies: '@emnapi/wasi-threads': 1.0.1 @@ -15062,7 +15059,7 @@ snapshots: '@react-spring/types@9.6.1': {} - '@react-three/drei@9.114.0(@react-three/fiber@8.17.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1))(@types/react@18.3.9)(@types/three@0.160.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1)': + '@react-three/drei@9.114.0(@react-three/fiber@8.17.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1))(@types/react@18.3.9)(@types/three@0.160.0)(immer@10.1.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1)': dependencies: '@babel/runtime': 7.25.6 '@mediapipe/tasks-vision': 0.10.8 @@ -15086,7 +15083,7 @@ snapshots: three-mesh-bvh: 0.7.8(three@0.160.1) three-stdlib: 2.33.0(three@0.160.1) troika-three-text: 0.49.1(three@0.160.1) - tunnel-rat: 0.1.2(@types/react@18.3.9)(react@18.3.1) + tunnel-rat: 0.1.2(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1) utility-types: 3.11.0 uuid: 9.0.1 zustand: 3.7.2(react@18.3.1) @@ -16459,6 +16456,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/uuid@10.0.0': {} + '@types/uuid@9.0.8': {} '@types/web@0.0.114': {} @@ -19524,6 +19523,8 @@ snapshots: immediate@3.0.6: {} + immer@10.1.1: {} + immutable@3.7.6: {} immutable@4.3.7: {} @@ -22714,9 +22715,9 @@ snapshots: dependencies: safe-buffer: 5.2.1 - tunnel-rat@0.1.2(@types/react@18.3.9)(react@18.3.1): + tunnel-rat@0.1.2(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1): dependencies: - zustand: 4.5.5(@types/react@18.3.9)(react@18.3.1) + zustand: 4.5.5(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1) transitivePeerDependencies: - '@types/react' - immer @@ -23734,11 +23735,12 @@ snapshots: optionalDependencies: react: 18.3.1 - zustand@4.5.5(@types/react@18.3.9)(react@18.3.1): + zustand@4.5.5(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1): dependencies: use-sync-external-store: 1.2.2(react@18.3.1) optionalDependencies: '@types/react': 18.3.9 + immer: 10.1.1 react: 18.3.1 zwitch@2.0.4: {} From 46b79fc59fd861e417758e8ed915b25e69e06a25 Mon Sep 17 00:00:00 2001 From: ponderingdemocritus Date: Thu, 26 Sep 2024 13:19:47 +1000 Subject: [PATCH 2/7] feat: move to sdk --- examples/example-vite-react-sdk/src/App.tsx | 11 +- examples/example-vite-react-sdk/src/state.ts | 118 --------------- packages/sdk/package.json | 1 + packages/sdk/src/index.ts | 1 + packages/sdk/src/state/index.ts | 1 + packages/sdk/src/state/zustand.ts | 151 +++++++++++++++++++ packages/sdk/src/types.ts | 4 +- pnpm-lock.yaml | 3 + 8 files changed, 167 insertions(+), 123 deletions(-) delete mode 100644 examples/example-vite-react-sdk/src/state.ts create mode 100644 packages/sdk/src/state/index.ts create mode 100644 packages/sdk/src/state/zustand.ts diff --git a/examples/example-vite-react-sdk/src/App.tsx b/examples/example-vite-react-sdk/src/App.tsx index d7e6151d..3ad7bb99 100644 --- a/examples/example-vite-react-sdk/src/App.tsx +++ b/examples/example-vite-react-sdk/src/App.tsx @@ -1,11 +1,12 @@ import { useEffect } from "react"; import "./App.css"; -import { SDK } from "@dojoengine/sdk"; +import { SDK, createDojoStore } from "@dojoengine/sdk"; import { Schema } from "./bindings.ts"; -import { useGameState } from "./state.ts"; import { v4 as uuidv4 } from "uuid"; +export const useGameState = createDojoStore(); + function App({ db }: { db: SDK }) { const state = useGameState((state) => state); const entities = useGameState((state) => state.entities); @@ -126,9 +127,11 @@ function App({ db }: { db: SDK }) { Player:{" "} {entity.models.dojo_starter.Position?.player ?? "N/A"}
- X: {entity.models.dojo_starter.Position?.vec.x ?? "N/A"} + X:{" "} + {entity.models.dojo_starter.Position?.vec?.x ?? "N/A"}
- Y: {entity.models.dojo_starter.Position?.vec.y ?? "N/A"} + Y:{" "} + {entity.models.dojo_starter.Position?.vec?.y ?? "N/A"}

Moves

diff --git a/examples/example-vite-react-sdk/src/state.ts b/examples/example-vite-react-sdk/src/state.ts deleted file mode 100644 index 4237b5c3..00000000 --- a/examples/example-vite-react-sdk/src/state.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { create } from "zustand"; -import { immer } from "zustand/middleware/immer"; -import { Draft, Patch, applyPatches, produceWithPatches } from "immer"; -import { ParsedEntity } from "@dojoengine/sdk"; -import { Schema } from "./bindings"; - -import { enablePatches } from "immer"; -enablePatches(); - -interface PendingTransaction { - transactionId: string; - patches: Patch[]; - inversePatches: Patch[]; -} - -interface GameState { - entities: Record>; - pendingTransactions: Record; - setEntities: (entities: ParsedEntity[]) => void; - updateEntity: (entity: ParsedEntity) => void; - applyOptimisticUpdate: ( - transactionId: string, - updateFn: (draft: Draft) => void - ) => void; - revertOptimisticUpdate: (transactionId: string) => void; - confirmTransaction: (transactionId: string) => void; - subscribeToEntity: ( - entityId: string, - listener: (entity: ParsedEntity | undefined) => void - ) => () => void; - waitForEntityChange: ( - entityId: string, - predicate: (entity: ParsedEntity | undefined) => boolean, - timeout?: number - ) => Promise | undefined>; -} - -export const useGameState = create()( - immer((set, get) => ({ - entities: {}, - pendingTransactions: {}, - setEntities: (entities: ParsedEntity[]) => { - set((state) => { - entities.forEach((entity) => { - state.entities[entity.entityId] = entity; - }); - }); - }, - updateEntity: (entity: ParsedEntity) => { - set((state) => { - state.entities[entity.entityId] = entity; - }); - }, - applyOptimisticUpdate: (transactionId, updateFn) => { - const currentState = get(); - const [nextState, patches, inversePatches] = produceWithPatches( - currentState, - (draft) => { - updateFn(draft); - } - ); - - set(() => nextState); - - set((state) => { - state.pendingTransactions[transactionId] = { - transactionId, - patches, - inversePatches, - }; - }); - }, - revertOptimisticUpdate: (transactionId) => { - const transaction = get().pendingTransactions[transactionId]; - if (transaction) { - set((state) => applyPatches(state, transaction.inversePatches)); - set((state) => { - delete state.pendingTransactions[transactionId]; - }); - } - }, - confirmTransaction: (transactionId) => { - set((state) => { - delete state.pendingTransactions[transactionId]; - }); - }, - subscribeToEntity: (entityId, listener): (() => void) => { - const unsubscribe: () => void = useGameState.subscribe((state) => { - const entity = state.entities[entityId]; - listener(entity); - }); - return unsubscribe; - }, - waitForEntityChange: (entityId, predicate, timeout = 6000) => { - return new Promise | undefined>( - (resolve, reject) => { - const unsubscribe = useGameState.subscribe((state) => { - const entity = state.entities[entityId]; - if (predicate(entity)) { - clearTimeout(timer); - unsubscribe(); - resolve(entity); - } - }); - - const timer = setTimeout(() => { - unsubscribe(); - reject( - new Error( - `waitForEntityChange: Timeout of ${timeout}ms exceeded` - ) - ); - }, timeout); - } - ); - }, - })) -); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 4f3554c2..803e876f 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -36,6 +36,7 @@ "dependencies": { "@dojoengine/torii-client": "workspace:*", "axios": "^0.27.2", + "immer": "^10.1.1", "lodash": "^4.17.21", "vite-plugin-wasm": "^3.3.0", "zustand": "^4.5.5" diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 6c3f033a..5165fddd 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -8,6 +8,7 @@ import { SchemaType, SDK, UnionOfModelData } from "./types"; import { Account, Signature, StarknetDomain, TypedData } from "starknet"; export * from "./types"; +export * from "./state"; interface SDKConfig { client: torii.ClientConfig; diff --git a/packages/sdk/src/state/index.ts b/packages/sdk/src/state/index.ts new file mode 100644 index 00000000..5524d86e --- /dev/null +++ b/packages/sdk/src/state/index.ts @@ -0,0 +1 @@ +export * from "./zustand"; diff --git a/packages/sdk/src/state/zustand.ts b/packages/sdk/src/state/zustand.ts new file mode 100644 index 00000000..d97f8e36 --- /dev/null +++ b/packages/sdk/src/state/zustand.ts @@ -0,0 +1,151 @@ +import { create } from "zustand"; +import { immer } from "zustand/middleware/immer"; +import { + Draft, + Patch, + WritableDraft, + applyPatches, + produceWithPatches, +} from "immer"; + +import { enablePatches } from "immer"; +import { subscribeWithSelector } from "zustand/middleware"; +import { ParsedEntity, SchemaType } from "../types"; + +enablePatches(); + +interface PendingTransaction { + transactionId: string; + patches: Patch[]; + inversePatches: Patch[]; +} + +interface GameState { + entities: Record>; + pendingTransactions: Record; + setEntities: (entities: ParsedEntity[]) => void; + updateEntity: (entity: Partial>) => void; + applyOptimisticUpdate: ( + transactionId: string, + updateFn: (draft: Draft>) => void + ) => void; + revertOptimisticUpdate: (transactionId: string) => void; + confirmTransaction: (transactionId: string) => void; + subscribeToEntity: ( + entityId: string, + listener: (entity: ParsedEntity | undefined) => void + ) => () => void; + waitForEntityChange: ( + entityId: string, + predicate: (entity: ParsedEntity | undefined) => boolean, + timeout?: number + ) => Promise | undefined>; +} + +/** + * Factory function to create a Zustand store based on a given SchemaType. + * + * @template T - The schema type. + * @returns A Zustand hook tailored to the provided schema. + */ +export function createDojoStore() { + const useStore = create>()( + subscribeWithSelector( + immer((set, get) => ({ + entities: {}, + pendingTransactions: {}, + setEntities: (entities: ParsedEntity[]) => { + set((state: Draft>) => { + entities.forEach((entity) => { + state.entities[entity.entityId] = + entity as WritableDraft>; + }); + }); + }, + updateEntity: (entity: Partial>) => { + set((state: Draft>) => { + if ( + entity.entityId && + state.entities[entity.entityId] + ) { + Object.assign( + state.entities[entity.entityId], + entity + ); + } + }); + }, + applyOptimisticUpdate: (transactionId, updateFn) => { + const currentState = get(); + const [nextState, patches, inversePatches] = + produceWithPatches( + currentState, + (draftState: Draft>) => { + updateFn(draftState); + } + ); + + set(() => nextState); + + set((state: Draft>) => { + state.pendingTransactions[transactionId] = { + transactionId, + patches, + inversePatches, + }; + }); + }, + revertOptimisticUpdate: (transactionId) => { + const transaction = + get().pendingTransactions[transactionId]; + if (transaction) { + set((state: Draft>) => + applyPatches(state, transaction.inversePatches) + ); + set((state: Draft>) => { + delete state.pendingTransactions[transactionId]; + }); + } + }, + confirmTransaction: (transactionId) => { + set((state: Draft>) => { + delete state.pendingTransactions[transactionId]; + }); + }, + subscribeToEntity: (entityId, listener): (() => void) => { + return useStore.subscribe((state) => { + const entity = state.entities[entityId]; + listener(entity); + }); + }, + waitForEntityChange: (entityId, predicate, timeout = 6000) => { + return new Promise | undefined>( + (resolve, reject) => { + const unsubscribe = useStore.subscribe( + (state) => state.entities[entityId], + (entity) => { + if (predicate(entity)) { + clearTimeout(timer); + unsubscribe(); + resolve(entity); + } + } + ); + + const timer = setTimeout(() => { + unsubscribe(); + reject( + new Error( + `waitForEntityChange: Timeout of ${timeout}ms exceeded` + ) + ); + }, timeout); + } + ); + }, + })) + ) + ); + + return useStore; +} diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 877ea35d..e9e6b666 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -226,7 +226,9 @@ export type ParsedEntity = { entityId: string; models: { [K in keyof T]: { - [M in keyof T[K]]?: T[K][M]; + [M in keyof T[K]]?: T[K][M] extends object + ? Partial + : T[K][M]; }; }; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ad07536..6372125e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1050,6 +1050,9 @@ importers: axios: specifier: ^0.27.2 version: 0.27.2 + immer: + specifier: ^10.1.1 + version: 10.1.1 lodash: specifier: ^4.17.21 version: 4.17.21 From 03a5689b3aab14c2d804fb8bd99557dc77ca84de Mon Sep 17 00:00:00 2001 From: ponderingdemocritus Date: Fri, 27 Sep 2024 07:33:33 +1000 Subject: [PATCH 3/7] feat: iteration on state --- examples/example-vite-react-sdk/package.json | 2 + examples/example-vite-react-sdk/src/App.tsx | 6 +- packages/sdk/package.json | 2 + packages/sdk/src/types.ts | 80 ++++++++++++++++++++ pnpm-lock.yaml | 56 ++++++++++++-- 5 files changed, 137 insertions(+), 9 deletions(-) diff --git a/examples/example-vite-react-sdk/package.json b/examples/example-vite-react-sdk/package.json index a9d3e833..b5a5d9ed 100644 --- a/examples/example-vite-react-sdk/package.json +++ b/examples/example-vite-react-sdk/package.json @@ -11,12 +11,14 @@ }, "dependencies": { "@dojoengine/core": "workspace:*", + "@dojoengine/create-burner": "workspace:*", "@dojoengine/sdk": "workspace:*", "@dojoengine/torii-wasm": "workspace:*", "@types/uuid": "^10.0.0", "immer": "^10.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "starknet": "6.11.0", "uuid": "^10.0.0", "vite-plugin-top-level-await": "^1.4.4", "vite-plugin-wasm": "^3.3.0", diff --git a/examples/example-vite-react-sdk/src/App.tsx b/examples/example-vite-react-sdk/src/App.tsx index 3ad7bb99..7d2db059 100644 --- a/examples/example-vite-react-sdk/src/App.tsx +++ b/examples/example-vite-react-sdk/src/App.tsx @@ -5,11 +5,11 @@ import { Schema } from "./bindings.ts"; import { v4 as uuidv4 } from "uuid"; -export const useGameState = createDojoStore(); +export const useDojoStore = createDojoStore(); function App({ db }: { db: SDK }) { - const state = useGameState((state) => state); - const entities = useGameState((state) => state.entities); + const state = useDojoStore((state) => state); + const entities = useDojoStore((state) => state.entities); useEffect(() => { let unsubscribe: (() => void) | undefined; diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 803e876f..a0f15e0b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -22,6 +22,7 @@ "./package.json": "./package.json" }, "devDependencies": { + "@rollup/plugin-commonjs": "^28.0.0", "@vitest/coverage-v8": "^1.6.0", "eslint": "^8.57.1", "prettier": "^2.8.8", @@ -35,6 +36,7 @@ }, "dependencies": { "@dojoengine/torii-client": "workspace:*", + "@dojoengine/create-burner": "workspace:*", "axios": "^0.27.2", "immer": "^10.1.1", "lodash": "^4.17.21", diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index e9e6b666..18385209 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -2,6 +2,7 @@ import * as torii from "@dojoengine/torii-client"; import { Account, StarknetDomain, TypedData } from "starknet"; +import { AccountInterface, RpcProvider } from "starknet"; /** * Utility type to ensure at least one property is present @@ -339,3 +340,82 @@ export interface SDK { ) => TypedData; sendMessage: (data: TypedData, account: Account) => Promise; } + +export type BurnerStorage = { + [address: string]: BurnerRecord; +}; + +export type BurnerRecord = { + chainId: string; + privateKey: string; + publicKey: string; + deployTx: string; + masterAccount: string; + active: boolean; + accountIndex?: number; + metadata?: any; +}; + +export type Burner = { + address: string; + active: boolean; + masterAccount?: string; + accountIndex?: number; +}; + +export interface BurnerManagerOptions { + masterAccount: Account; + accountClassHash: string; + feeTokenAddress: string; + rpcProvider: RpcProvider; +} + +export interface BurnerAccount { + create: (options?: BurnerCreateOptions) => void; + list: () => Burner[]; + get: (address: string) => AccountInterface; + remove: (address: string) => void; + account: Account; + select: (address: string) => void; + deselect: () => void; + isDeploying: boolean; + clear: () => void; + count: number; + copyToClipboard: () => Promise; + applyFromClipboard: () => Promise; + getActiveAccount?: () => Account | null; + generateAddressFromSeed?: (options?: BurnerCreateOptions) => string; + checkIsDeployed: (address: string, deployTx?: string) => Promise; +} + +export interface BurnerCreateOptions { + secret?: string; + index?: number; + metadata?: any; + prefundedAmount?: string; + maxFee?: number; +} + +export interface BurnerKeys { + privateKey: string; + publicKey: string; + address: string; +} + +export type Predeployed = Burner & { name?: string }; + +export type PredeployedStorage = { + [address: string]: PredeployedAccount; +}; + +export interface PredeployedManagerOptions { + rpcProvider: RpcProvider; + predeployedAccounts: PredeployedAccount[]; +} + +export type PredeployedAccount = { + name?: string; + address: string; + privateKey: string; + active: boolean; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6372125e..39326c08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -509,6 +509,9 @@ importers: '@dojoengine/core': specifier: workspace:* version: link:../../packages/core + '@dojoengine/create-burner': + specifier: workspace:* + version: link:../../packages/create-burner '@dojoengine/sdk': specifier: workspace:* version: link:../../packages/sdk @@ -527,6 +530,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + starknet: + specifier: 6.11.0 + version: 6.11.0(encoding@0.1.13) uuid: specifier: ^10.0.0 version: 10.0.0 @@ -1044,6 +1050,9 @@ importers: packages/sdk: dependencies: + '@dojoengine/create-burner': + specifier: workspace:* + version: link:../create-burner '@dojoengine/torii-client': specifier: workspace:* version: link:../torii-client @@ -1066,6 +1075,9 @@ importers: specifier: ^4.5.5 version: 4.5.5(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1) devDependencies: + '@rollup/plugin-commonjs': + specifier: ^28.0.0 + version: 28.0.0(rollup@4.22.4) '@vitest/coverage-v8': specifier: ^1.6.0 version: 1.6.0(vitest@1.6.0(@types/node@22.6.1)(jsdom@24.1.3)(terser@5.33.0)) @@ -4001,6 +4013,15 @@ packages: '@types/babel__core': optional: true + '@rollup/plugin-commonjs@28.0.0': + resolution: {integrity: sha512-BJcu+a+Mpq476DMXG+hevgPSl56bkUoi88dKT8t3RyUp8kGuOh+2bU8Gs7zXDlu+fyZggnJ+iOBGrb/O1SorYg==} + engines: {node: '>=16.0.0 || 14 >= 14.17'} + peerDependencies: + rollup: ^2.68.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/plugin-node-resolve@15.3.0': resolution: {integrity: sha512-9eO5McEICxMzJpDW9OnMYSv4Sta3hmt7VtBFz5zR9273suNOydOyq/FrGeGy+KsTRFm8w0SLVhzig2ILFT63Ag==} engines: {node: '>=14.0.0'} @@ -7640,6 +7661,9 @@ packages: is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + is-reference@1.2.1: + resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + is-regex@1.1.4: resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} engines: {node: '>= 0.4'} @@ -14594,7 +14618,7 @@ snapshots: '@octokit/request-error': 3.0.3 '@octokit/types': 9.3.2 is-plain-object: 5.0.0 - node-fetch: 2.6.7(encoding@0.1.13) + node-fetch: 2.7.0(encoding@0.1.13) universal-user-agent: 6.0.1 transitivePeerDependencies: - encoding @@ -15129,6 +15153,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@rollup/plugin-commonjs@28.0.0(rollup@4.22.4)': + dependencies: + '@rollup/pluginutils': 5.1.2(rollup@4.22.4) + commondir: 1.0.1 + estree-walker: 2.0.2 + fdir: 6.3.0(picomatch@2.3.1) + is-reference: 1.2.1 + magic-string: 0.30.11 + picomatch: 2.3.1 + optionalDependencies: + rollup: 4.22.4 + '@rollup/plugin-node-resolve@15.3.0(rollup@2.79.1)': dependencies: '@rollup/pluginutils': 5.1.2(rollup@2.79.1) @@ -15295,12 +15331,12 @@ snapshots: '@scure/bip32@1.3.2': dependencies: '@noble/curves': 1.2.0 - '@noble/hashes': 1.3.2 + '@noble/hashes': 1.3.3 '@scure/base': 1.1.9 '@scure/bip32@1.4.0': dependencies: - '@noble/curves': 1.4.0 + '@noble/curves': 1.4.2 '@noble/hashes': 1.4.0 '@scure/base': 1.1.9 @@ -15312,7 +15348,7 @@ snapshots: '@scure/bip39@1.2.1': dependencies: - '@noble/hashes': 1.3.2 + '@noble/hashes': 1.3.3 '@scure/base': 1.1.9 '@scure/bip39@1.4.0': @@ -18946,6 +18982,10 @@ snapshots: dependencies: pend: 1.2.0 + fdir@6.3.0(picomatch@2.3.1): + optionalDependencies: + picomatch: 2.3.1 + fdir@6.3.0(picomatch@4.0.2): optionalDependencies: picomatch: 4.0.2 @@ -19725,6 +19765,10 @@ snapshots: is-promise@2.2.2: {} + is-reference@1.2.1: + dependencies: + '@types/estree': 1.0.6 + is-regex@1.1.4: dependencies: call-bind: 1.0.7 @@ -23389,8 +23433,8 @@ snapshots: webauthn-p256@0.0.5: dependencies: - '@noble/curves': 1.4.0 - '@noble/hashes': 1.4.0 + '@noble/curves': 1.6.0 + '@noble/hashes': 1.5.0 webcrypto-core@1.8.0: dependencies: From 89804b556220f6c121d1da5b7c43b1a22a9c1f8a Mon Sep 17 00:00:00 2001 From: ponderingdemocritus Date: Fri, 27 Sep 2024 08:54:57 +1000 Subject: [PATCH 4/7] feat: tests --- packages/sdk/package.json | 7 +- packages/sdk/src/__example__/index.ts | 12 +- packages/sdk/src/__tests__/state.test.ts | 402 ++++++++++++++++++ .../sdk/src/__tests__/zustand.perf.test.ts | 169 ++++++++ packages/sdk/src/state/zustand.ts | 25 ++ pnpm-lock.yaml | 16 + 6 files changed, 623 insertions(+), 8 deletions(-) create mode 100644 packages/sdk/src/__tests__/state.test.ts create mode 100644 packages/sdk/src/__tests__/zustand.perf.test.ts diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a0f15e0b..623f4983 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -29,17 +29,18 @@ "tsup": "^8.3.0", "typescript": "^5.6.2", "vite": "^3.2.11", - "vitest": "^1.6.0" + "vitest": "^1.6.0", + "benchmark": "^2.1.4", + "lodash": "^4.17.21" }, "peerDependencies": { "starknet": "6.11.0" }, "dependencies": { - "@dojoengine/torii-client": "workspace:*", "@dojoengine/create-burner": "workspace:*", + "@dojoengine/torii-client": "workspace:*", "axios": "^0.27.2", "immer": "^10.1.1", - "lodash": "^4.17.21", "vite-plugin-wasm": "^3.3.0", "zustand": "^4.5.5" }, diff --git a/packages/sdk/src/__example__/index.ts b/packages/sdk/src/__example__/index.ts index b39f64ca..463a02f1 100644 --- a/packages/sdk/src/__example__/index.ts +++ b/packages/sdk/src/__example__/index.ts @@ -23,6 +23,12 @@ export interface ItemModel { durability: number; } +export interface GalaxyModel { + fieldOrder: string[]; + id: string; + name: string; +} + export interface MockSchemaType extends SchemaType { world: { player: PlayerModel; @@ -30,11 +36,7 @@ export interface MockSchemaType extends SchemaType { item: ItemModel; }; universe: { - galaxy: { - fieldOrder: string[]; - id: string; - name: string; - }; + galaxy: GalaxyModel; }; } diff --git a/packages/sdk/src/__tests__/state.test.ts b/packages/sdk/src/__tests__/state.test.ts new file mode 100644 index 00000000..07e10342 --- /dev/null +++ b/packages/sdk/src/__tests__/state.test.ts @@ -0,0 +1,402 @@ +import { createDojoStore } from "../state/zustand"; +import { ParsedEntity, SchemaType } from "../types"; +import { Patch } from "immer"; +import { describe, expect, it, beforeEach, test, vi } from "vitest"; +import { + schema, + MockSchemaType, + PlayerModel, + GameModel, + ItemModel, + GalaxyModel, +} from "../__example__/index"; + +interface MockParsedEntity extends ParsedEntity { + entityId: string; + models: { + world: { + player?: Partial; + game?: Partial; + item?: Partial; + }; + universe: { + galaxy?: Partial; + }; + }; +} + +describe("createDojoStore", () => { + let useStore: ReturnType>; + let initialPlayer: MockParsedEntity; + let initialGame: MockParsedEntity; + let initialItem: MockParsedEntity; + let initialGalaxy: MockParsedEntity; + + beforeEach(() => { + useStore = createDojoStore(); + initialPlayer = { + entityId: "player1", + models: { + world: { + player: { + fieldOrder: ["id", "name", "score"], + id: "player1", + name: "Alice", + score: 100, + }, + }, + universe: {}, + }, + }; + initialGame = { + entityId: "game1", + models: { + world: { + game: { + fieldOrder: ["id", "status"], + id: "game1", + status: "active", + }, + }, + universe: {}, + }, + }; + initialItem = { + entityId: "item1", + models: { + world: { + item: { + fieldOrder: ["id", "type", "durability"], + id: "item1", + type: "sword", + durability: 50, + }, + }, + universe: {}, + }, + }; + initialGalaxy = { + entityId: "galaxy1", + models: { + world: {}, + universe: { + galaxy: { + fieldOrder: ["id", "name"], + id: "galaxy1", + name: "Milky Way", + }, + }, + }, + }; + }); + + test("should initialize with empty entities and pendingTransactions", () => { + const state = useStore.getState(); + expect(state.entities).toEqual({}); + expect(state.pendingTransactions).toEqual({}); + }); + + test("setEntities should add entities to the store", () => { + useStore + .getState() + .setEntities([ + initialPlayer, + initialGame, + initialItem, + initialGalaxy, + ]); + const state = useStore.getState(); + expect(state.entities["player1"]).toEqual(initialPlayer); + expect(state.entities["game1"]).toEqual(initialGame); + expect(state.entities["item1"]).toEqual(initialItem); + expect(state.entities["galaxy1"]).toEqual(initialGalaxy); + }); + + test("updateEntity should update an existing entity", () => { + useStore.getState().setEntities([initialPlayer]); + useStore.getState().updateEntity({ + entityId: "player1", + models: { + world: { + player: { + name: "Bob", + }, + }, + universe: {}, + }, + }); + const state = useStore.getState(); + expect(state.entities["player1"].models.world?.player?.name).toEqual( + "Bob" + ); + }); + + test("updateEntity should not add a new entity if entityId does not exist", () => { + useStore.getState().updateEntity({ + entityId: "nonexistent", + models: { + world: { + player: { + name: "Charlie", + }, + }, + universe: {}, + }, + }); + const state = useStore.getState(); + expect(state.entities["nonexistent"]).toBeUndefined(); + }); + + test("applyOptimisticUpdate should apply updates and add to pendingTransactions", () => { + useStore.getState().setEntities([initialItem]); + + const updateFn = (draft: any) => { + draft.entities["item1"].models.world!.item!.durability = 30; + }; + + useStore.getState().applyOptimisticUpdate("txn1", updateFn); + + const state = useStore.getState(); + expect(state.entities["item1"].models.world?.item?.durability).toEqual( + 30 + ); + expect(state.pendingTransactions["txn1"]).toBeDefined(); + expect(state.pendingTransactions["txn1"].transactionId).toBe("txn1"); + }); + + test("revertOptimisticUpdate should revert changes using inverse patches", () => { + useStore.getState().setEntities([initialItem]); + + const updateFn = (draft: any) => { + draft.entities["item1"].models.world!.item!.durability = 30; + }; + + useStore.getState().applyOptimisticUpdate("txn1", updateFn); + // Revert the optimistic update + useStore.getState().revertOptimisticUpdate("txn1"); + + const state = useStore.getState(); + expect(state.entities["item1"].models.world?.item?.durability).toEqual( + 50 + ); + expect(state.pendingTransactions["txn1"]).toBeUndefined(); + }); + + test("confirmTransaction should remove the transaction from pendingTransactions", () => { + useStore.getState().setEntities([initialItem]); + + const updateFn = (draft: any) => { + draft.entities["item1"].models.world!.item!.durability = 30; + }; + + useStore.getState().applyOptimisticUpdate("txn1", updateFn); + // Confirm the transaction + useStore.getState().confirmTransaction("txn1"); + + const state = useStore.getState(); + expect(state.entities["item1"].models.world?.item?.durability).toEqual( + 30 + ); + expect(state.pendingTransactions["txn1"]).toBeUndefined(); + }); + test("subscribeToEntity should call listener on entity updates", () => { + const listener = vi.fn(); + const unsubscribe = useStore + .getState() + .subscribeToEntity("player1", listener); + + // Update entity + useStore.getState().setEntities([initialPlayer]); + + expect(listener).toHaveBeenCalledWith(initialPlayer); + + // Update entity again + const updatedPlayer = { + ...initialPlayer, + models: { + world: { + player: { + ...initialPlayer.models.world!.player!, + name: "Charlie", + }, + }, + universe: {}, // Add the required universe property + }, + }; + useStore.getState().updateEntity(updatedPlayer); + expect(listener).toHaveBeenCalledWith(updatedPlayer); + + unsubscribe(); + // Further updates should not call listener + useStore.getState().updateEntity({ + entityId: "player1", + models: { + world: { + player: { + name: "Dave", + }, + }, + universe: {}, + }, + }); + expect(listener).toHaveBeenCalledTimes(2); + }); + test("waitForEntityChange should resolve when predicate is met", async () => { + useStore.getState().setEntities([initialGame]); + + const promise = useStore + .getState() + .waitForEntityChange( + "game1", + (entity) => entity?.models.world?.game?.status === "completed", + 1000 + ); + + // Simulate async update + setTimeout(() => { + useStore.getState().updateEntity({ + entityId: "game1", + models: { + world: { + game: { + status: "completed", + }, + }, + universe: {}, + }, + }); + }, 100); + + const result = await promise; + expect(result?.models.world?.game?.status).toBe("completed"); + }); + + test("waitForEntityChange should reject on timeout", async () => { + useStore.getState().setEntities([initialGame]); + + const promise = useStore + .getState() + .waitForEntityChange( + "game1", + (entity) => entity?.models.world?.game?.status === "never", + 500 + ); + + // Do not update the entity to meet predicate + + await expect(promise).rejects.toThrow( + "waitForEntityChange: Timeout of 500ms exceeded" + ); + }); + + test("getEntity should return the correct entity", () => { + useStore.getState().setEntities([initialGalaxy]); + const entity = useStore.getState().getEntity("galaxy1"); + expect(entity).toEqual(initialGalaxy); + }); + + test("getEntities should return all entities", () => { + const player2: MockParsedEntity = { + entityId: "player2", + models: { + world: { + player: { + fieldOrder: ["id", "name", "score"], + id: "player2", + name: "Bob", + score: 80, + }, + }, + universe: {}, + }, + }; + useStore + .getState() + .setEntities([ + initialPlayer, + initialGame, + initialItem, + initialGalaxy, + player2, + ]); + const entities = useStore.getState().getEntities(); + expect(entities).toHaveLength(5); + expect(entities).toContainEqual(initialPlayer); + expect(entities).toContainEqual(initialGame); + expect(entities).toContainEqual(initialItem); + expect(entities).toContainEqual(initialGalaxy); + expect(entities).toContainEqual(player2); + }); + + test("getEntities should apply the filter correctly", () => { + const item2: MockParsedEntity = { + entityId: "item2", + models: { + world: { + item: { + fieldOrder: ["id", "type", "durability"], + id: "item2", + type: "shield", + durability: 80, + }, + }, + universe: {}, + }, + }; + useStore.getState().setEntities([initialItem, item2]); + const filtered = useStore + .getState() + .getEntities( + (entity) => (entity.models.world?.item?.durability ?? 0) > 50 + ); + expect(filtered).toHaveLength(1); + expect(filtered[0]).toEqual(item2); + }); + + test("getEntitiesByModel should return entities matching the specified namespace and model", () => { + const player2: MockParsedEntity = { + entityId: "player2", + models: { + world: { + player: { + fieldOrder: ["id", "name", "score"], + id: "player2", + name: "Bob", + score: 80, + }, + }, + universe: {}, + }, + }; + const galaxy2: MockParsedEntity = { + entityId: "galaxy2", + models: { + world: {}, + universe: { + galaxy: { + fieldOrder: ["id", "name"], + id: "galaxy2", + name: "Andromeda", + }, + }, + }, + }; + useStore + .getState() + .setEntities([initialPlayer, player2, initialGalaxy, galaxy2]); + + const resultWorldPlayer = useStore + .getState() + .getEntitiesByModel("world", "player"); + expect(resultWorldPlayer).toHaveLength(2); + expect(resultWorldPlayer).toContainEqual(initialPlayer); + expect(resultWorldPlayer).toContainEqual(player2); + + const resultUniverseGalaxy = useStore + .getState() + .getEntitiesByModel("universe", "galaxy"); + expect(resultUniverseGalaxy).toHaveLength(2); + expect(resultUniverseGalaxy).toContainEqual(initialGalaxy); + expect(resultUniverseGalaxy).toContainEqual(galaxy2); + }); +}); diff --git a/packages/sdk/src/__tests__/zustand.perf.test.ts b/packages/sdk/src/__tests__/zustand.perf.test.ts new file mode 100644 index 00000000..d86cb8e5 --- /dev/null +++ b/packages/sdk/src/__tests__/zustand.perf.test.ts @@ -0,0 +1,169 @@ +import { createDojoStore } from "../state/zustand"; +import { ParsedEntity, SchemaType } from "../types"; +import { describe, it, beforeEach, expect } from "vitest"; +import Benchmark from "benchmark"; +import { + schema, + MockSchemaType, + PlayerModel, + GameModel, + ItemModel, + GalaxyModel, +} from "../__example__/index"; + +interface MockParsedEntity extends ParsedEntity { + entityId: string; + models: { + world: { + player?: Partial; + game?: Partial; + item?: Partial; + }; + universe: { + galaxy?: Partial; + }; + }; +} + +describe("Zustand Store Performance Tests", () => { + let useStore: ReturnType>; + let mockEntities: MockParsedEntity[] = []; + + beforeEach(() => { + useStore = createDojoStore(); + mockEntities = []; // Reset the mockEntities array before each test + // Generate a large number of mock entities for testing + const numberOfEntities = 1000; + for (let i = 0; i < numberOfEntities; i++) { + mockEntities.push({ + entityId: `entity${i}`, + models: { + world: { + player: { + fieldOrder: ["id", "name", "score"], + id: `player${i}`, + name: `Player${i}`, + score: i, + }, + game: { + fieldOrder: ["id", "status"], + id: `game${i}`, + status: i % 2 === 0 ? "active" : "inactive", + }, + item: { + fieldOrder: ["id", "type", "durability"], + id: `item${i}`, + type: i % 3 === 0 ? "sword" : "shield", + durability: 100 - (i % 100), + }, + }, + universe: { + galaxy: { + fieldOrder: ["id", "name"], + id: `galaxy${i}`, + name: `Galaxy${i}`, + }, + }, + }, + }); + } + }); + + it("should benchmark setEntities performance", async () => { + const suite = new Benchmark.Suite(); + + suite + .add("setEntities", () => { + useStore.getState().setEntities(mockEntities); + }) + .on("cycle", (event: any) => { + console.log(String(event.target)); + }) + .on("complete", function () { + console.log("Fastest is " + this.filter("fastest").map("name")); + }) + .run({ async: false }); + + // Optional: Assert that setEntities completes within a reasonable time + // Example: expect(setEntitiesTime).toBeLessThan(100); // in milliseconds + }); + + it("should benchmark updateEntity performance", async () => { + // First, set entities + useStore.getState().setEntities(mockEntities); + + const suite = new Benchmark.Suite(); + + suite + .add("updateEntity", () => { + useStore.getState().updateEntity({ + entityId: "entity500", + models: { + world: { + player: { + name: "UpdatedPlayer500", + score: 999, + }, + }, + universe: {}, + }, + }); + }) + .on("cycle", (event: any) => { + console.log(String(event.target)); + }) + .on("complete", function () { + console.log("Fastest is " + this.filter("fastest").map("name")); + }) + .run({ async: false }); + }); + + it("should benchmark applyOptimisticUpdate performance", async () => { + // First, set entities + useStore.getState().setEntities(mockEntities); + + const suite = new Benchmark.Suite(); + + suite + .add("applyOptimisticUpdate", () => { + useStore + .getState() + .applyOptimisticUpdate("txn_perf", (draft) => { + draft.entities[ + "entity500" + ].models.world!.item!.durability = 75; + }); + }) + .on("cycle", (event: any) => { + console.log(String(event.target)); + }) + .on("complete", function () { + console.log("Fastest is " + this.filter("fastest").map("name")); + }) + .run({ async: false }); + }); + + it("should benchmark revertOptimisticUpdate performance", async () => { + // First, set entities + useStore.getState().setEntities(mockEntities); + + // Apply an optimistic update + useStore.getState().applyOptimisticUpdate("txn_perf", (draft) => { + draft.entities["entity500"].models.world!.item!.durability = 75; + }); + + const suite = new Benchmark.Suite(); + + suite + .add("revertOptimisticUpdate", () => { + useStore.getState().revertOptimisticUpdate("txn_perf"); + }) + .on("cycle", (event: any) => { + console.log(String(event.target)); + }) + .on("complete", function () { + console.log("Fastest is " + this.filter("fastest").map("name")); + }) + .run({ async: false }); + }); +}); diff --git a/packages/sdk/src/state/zustand.ts b/packages/sdk/src/state/zustand.ts index d97f8e36..522899a8 100644 --- a/packages/sdk/src/state/zustand.ts +++ b/packages/sdk/src/state/zustand.ts @@ -40,6 +40,14 @@ interface GameState { predicate: (entity: ParsedEntity | undefined) => boolean, timeout?: number ) => Promise | undefined>; + getEntity: (entityId: string) => ParsedEntity | undefined; + getEntities: ( + filter?: (entity: ParsedEntity) => boolean + ) => ParsedEntity[]; + getEntitiesByModel: ( + namespace: keyof T, + model: keyof T[keyof T] + ) => ParsedEntity[]; } /** @@ -143,6 +151,23 @@ export function createDojoStore() { } ); }, + // Implementing query layer methods + getEntity: (entityId: string) => { + return get().entities[entityId]; + }, + + getEntities: ( + filter?: (entity: ParsedEntity) => boolean + ) => { + const allEntities = Object.values(get().entities); + return filter ? allEntities.filter(filter) : allEntities; + }, + + getEntitiesByModel: (namespace, model) => { + return get().getEntities((entity) => { + return !!entity.models[namespace]?.[model]; + }); + }, })) ) ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39326c08..c4b4e95f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1059,6 +1059,9 @@ importers: axios: specifier: ^0.27.2 version: 0.27.2 + benchmark: + specifier: ^2.1.4 + version: 2.1.4 immer: specifier: ^10.1.1 version: 10.1.1 @@ -5612,6 +5615,9 @@ packages: before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} + benchmark@2.1.4: + resolution: {integrity: sha512-l9MlfN4M1K/H2fbhfMy3B7vJd6AGKJVQn2h6Sg/Yx+KckoUA7ewS5Vv6TjSq18ooE1kS9hhAlQRH3AkXIh/aOQ==} + better-opn@3.0.2: resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} engines: {node: '>=12.0.0'} @@ -9337,6 +9343,9 @@ packages: pkg-types@1.2.0: resolution: {integrity: sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==} + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + polished@4.3.1: resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==} engines: {node: '>=10'} @@ -17374,6 +17383,11 @@ snapshots: before-after-hook@2.2.3: {} + benchmark@2.1.4: + dependencies: + lodash: 4.17.21 + platform: 1.3.6 + better-opn@3.0.2: dependencies: open: 8.4.2 @@ -21352,6 +21366,8 @@ snapshots: mlly: 1.7.1 pathe: 1.1.2 + platform@1.3.6: {} + polished@4.3.1: dependencies: '@babel/runtime': 7.25.6 From 2398139ec178c7196ad3a9bf09eb17a0eb785f22 Mon Sep 17 00:00:00 2001 From: ponderingdemocritus Date: Fri, 27 Sep 2024 09:44:23 +1000 Subject: [PATCH 5/7] fix: lockfile --- pnpm-lock.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4b4e95f..3d1d9d93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1059,15 +1059,9 @@ importers: axios: specifier: ^0.27.2 version: 0.27.2 - benchmark: - specifier: ^2.1.4 - version: 2.1.4 immer: specifier: ^10.1.1 version: 10.1.1 - lodash: - specifier: ^4.17.21 - version: 4.17.21 starknet: specifier: 6.11.0 version: 6.11.0(encoding@0.1.13) @@ -1084,9 +1078,15 @@ importers: '@vitest/coverage-v8': specifier: ^1.6.0 version: 1.6.0(vitest@1.6.0(@types/node@22.6.1)(jsdom@24.1.3)(terser@5.33.0)) + benchmark: + specifier: ^2.1.4 + version: 2.1.4 eslint: specifier: ^8.57.1 version: 8.57.1 + lodash: + specifier: ^4.17.21 + version: 4.17.21 prettier: specifier: ^2.8.8 version: 2.8.8 From aa6d5dfe7dc490c7ec9bf509f34daa5b6df9ad56 Mon Sep 17 00:00:00 2001 From: ponderingdemocritus Date: Fri, 27 Sep 2024 09:44:58 +1000 Subject: [PATCH 6/7] feat: cleanup --- packages/sdk/src/__tests__/state.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/__tests__/state.test.ts b/packages/sdk/src/__tests__/state.test.ts index 07e10342..ffb85fac 100644 --- a/packages/sdk/src/__tests__/state.test.ts +++ b/packages/sdk/src/__tests__/state.test.ts @@ -1,9 +1,7 @@ import { createDojoStore } from "../state/zustand"; -import { ParsedEntity, SchemaType } from "../types"; -import { Patch } from "immer"; -import { describe, expect, it, beforeEach, test, vi } from "vitest"; +import { ParsedEntity } from "../types"; +import { describe, expect, beforeEach, test, vi } from "vitest"; import { - schema, MockSchemaType, PlayerModel, GameModel, From cad1567c5c87063ac2d53af1e5505909a486843a Mon Sep 17 00:00:00 2001 From: ponderingdemocritus Date: Fri, 27 Sep 2024 10:07:23 +1000 Subject: [PATCH 7/7] fix: remove boked CI --- .github/workflows/npx-test.yaml | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 .github/workflows/npx-test.yaml diff --git a/.github/workflows/npx-test.yaml b/.github/workflows/npx-test.yaml deleted file mode 100644 index b1cb23b1..00000000 --- a/.github/workflows/npx-test.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: Dojo npx create - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - build-and-test: - runs-on: ubuntu-latest - - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v2 - with: - node-version: "20" - - - name: Install Dojo CLI - run: npm i @dojoengine/create-dojo -g - - - name: Create Dojo Project - run: npx @dojoengine/create-dojo