From 3c0f11e6538d9b45d77147c2ad924471cbcc38da Mon Sep 17 00:00:00 2001 From: Kevin Ingersoll Date: Mon, 11 Mar 2024 14:51:47 +0000 Subject: [PATCH] feat(store-sync): initial query client (#2355) --- packages/store-sync/package.json | 1 + packages/store-sync/src/query-cache/common.ts | 52 ++ .../src/query-cache/findSubjects.ts | 70 ++ .../src/query-cache/matchesCondition.ts | 46 ++ .../store-sync/src/query-cache/query.test.ts | 140 ++++ packages/store-sync/src/query-cache/query.ts | 28 + .../src/query-cache/subscribeToQuery.test.ts | 772 ++++++++++++++++++ .../src/query-cache/subscribeToQuery.ts | 102 +++ .../src/query-cache/syncToQueryCache.ts | 62 ++ .../query-cache/test/createHydratedStore.ts | 49 ++ .../src/query-cache/test/minePending.ts | 9 + .../query-cache/test/waitForTransaction.ts | 13 + packages/store-sync/src/zustand/common.ts | 2 +- .../src/zustand/createStorageAdapter.test.ts | 17 +- .../src/zustand/createStorageAdapter.ts | 4 +- packages/store-sync/test/common.ts | 12 +- packages/store-sync/test/deployMockGame.ts | 33 - packages/store-sync/test/globalSetup.ts | 9 +- packages/store-sync/test/mockGame.ts | 38 + packages/store-sync/test/setup.ts | 18 + packages/store-sync/vitest.config.ts | 5 + pnpm-lock.yaml | 3 + .../src/codegen/world/IMoveSystem.sol | 13 + .../src/codegen/world/IWorld.sol | 4 +- .../src/systems/MoveSystem.sol | 11 + 25 files changed, 1461 insertions(+), 52 deletions(-) create mode 100644 packages/store-sync/src/query-cache/common.ts create mode 100644 packages/store-sync/src/query-cache/findSubjects.ts create mode 100644 packages/store-sync/src/query-cache/matchesCondition.ts create mode 100644 packages/store-sync/src/query-cache/query.test.ts create mode 100644 packages/store-sync/src/query-cache/query.ts create mode 100644 packages/store-sync/src/query-cache/subscribeToQuery.test.ts create mode 100644 packages/store-sync/src/query-cache/subscribeToQuery.ts create mode 100644 packages/store-sync/src/query-cache/syncToQueryCache.ts create mode 100644 packages/store-sync/src/query-cache/test/createHydratedStore.ts create mode 100644 packages/store-sync/src/query-cache/test/minePending.ts create mode 100644 packages/store-sync/src/query-cache/test/waitForTransaction.ts delete mode 100644 packages/store-sync/test/deployMockGame.ts create mode 100644 packages/store-sync/test/mockGame.ts create mode 100644 packages/store-sync/test/setup.ts create mode 100644 test/mock-game-contracts/src/codegen/world/IMoveSystem.sol create mode 100644 test/mock-game-contracts/src/systems/MoveSystem.sol diff --git a/packages/store-sync/package.json b/packages/store-sync/package.json index 06b3bfb507..d7916c88e6 100644 --- a/packages/store-sync/package.json +++ b/packages/store-sync/package.json @@ -71,6 +71,7 @@ "change-case": "^5.2.0", "debug": "^4.3.4", "drizzle-orm": "^0.28.5", + "fast-deep-equal": "^3.1.3", "kysely": "^0.26.3", "postgres": "^3.3.5", "rxjs": "7.5.5", diff --git a/packages/store-sync/src/query-cache/common.ts b/packages/store-sync/src/query-cache/common.ts new file mode 100644 index 0000000000..5fe5369e92 --- /dev/null +++ b/packages/store-sync/src/query-cache/common.ts @@ -0,0 +1,52 @@ +import { StoreConfig, Tables, ResolvedStoreConfig } from "@latticexyz/store"; +import { Hex } from "viem"; +import { storeTables, worldTables } from "../common"; +import { StaticPrimitiveType, DynamicPrimitiveType } from "@latticexyz/schema-type"; + +// TODO: move to some common utils file/module/package +export type satisfy = t; + +// TODO: make this better with new config resolver +export type AllTables< + config extends StoreConfig, + extraTables extends Tables | undefined = undefined, +> = ResolvedStoreConfig["tables"] & + (extraTables extends Tables ? extraTables : Record) & + typeof storeTables & + typeof worldTables; + +export type TableField = { + readonly tableId: Hex; + readonly field: string; +}; + +export type TableSubject = { + readonly tableId: Hex; + readonly subject: readonly string[]; +}; + +export type ConditionLiteral = boolean | number | bigint | string; + +export type ComparisonCondition = { + readonly left: TableField; + readonly op: "<" | "<=" | "=" | ">" | ">=" | "!="; + // TODO: add support for TableField + readonly right: ConditionLiteral; +}; + +export type InCondition = { + readonly left: TableField; + readonly op: "in"; + readonly right: readonly ConditionLiteral[]; +}; + +export type QueryCondition = satisfy<{ readonly op: string }, ComparisonCondition | InCondition>; + +// TODO: move this into some "wire" type and then make this more client specific (uses config to validate) +export type Query = { + readonly from: readonly TableSubject[]; + readonly except?: readonly TableSubject[]; + readonly where?: readonly QueryCondition[]; +}; + +export type QueryResultSubject = readonly (StaticPrimitiveType | DynamicPrimitiveType)[]; diff --git a/packages/store-sync/src/query-cache/findSubjects.ts b/packages/store-sync/src/query-cache/findSubjects.ts new file mode 100644 index 0000000000..f268a0bd6a --- /dev/null +++ b/packages/store-sync/src/query-cache/findSubjects.ts @@ -0,0 +1,70 @@ +import { TableRecord } from "../zustand/common"; +import { Query, QueryResultSubject } from "./common"; +import { Table } from "@latticexyz/store"; +import { groupBy, uniqueBy } from "@latticexyz/common/utils"; +import { encodeAbiParameters } from "viem"; +import { matchesCondition } from "./matchesCondition"; + +// This assumes upstream has fully validated query +// This also assumes we have full records, which may not always be the case and we may need some way to request records for a given table subject +// We don't carry around config types here for ease, they get handled by the wrapping `query` function + +type QueryParameters = { + readonly records: readonly TableRecord
[]; + readonly query: Query; +}; + +// TODO: make condition types smarter, so condition literal matches the field primitive type + +export function findSubjects
({ + records: initialRecords, + query, +}: QueryParameters
): readonly QueryResultSubject[] { + const targetTables = Object.fromEntries( + uniqueBy([...query.from, ...(query.except ?? [])], (subject) => subject.tableId).map((subject) => [ + subject.tableId, + subject.subject, + ]), + ); + const fromTableIds = new Set(query.from.map((subject) => subject.tableId)); + const exceptTableIds = new Set((query.except ?? []).map((subject) => subject.tableId)); + + // TODO: store/lookup subjects separately rather than mapping each time so we can "memoize" better? + const records = initialRecords + .filter((record) => targetTables[record.table.tableId]) + .map((record) => { + const subjectFields = targetTables[record.table.tableId]; + const schema = { ...record.table.keySchema, ...record.table.valueSchema }; + const fields = { ...record.key, ...record.value }; + const subject = subjectFields.map((field) => fields[field]); + const subjectSchema = subjectFields.map((field) => schema[field]); + const id = encodeAbiParameters(subjectSchema, subject); + return { + ...record, + schema, + fields, + subjectSchema, + subject, + id, + }; + }); + + const matchedSubjects = Array.from(groupBy(records, (record) => record.id).values()) + .map((records) => ({ + id: records[0].id, + subject: records[0].subject, + records, + })) + .filter(({ records }) => { + // make sure our matched subject has no records in `query.except` tables + return exceptTableIds.size ? !records.some((record) => exceptTableIds.has(record.table.tableId)) : true; + }) + .filter(({ records }) => { + // make sure our matched subject has records in all `query.from` tables + const tableIds = new Set(records.map((record) => record.table.tableId)); + return tableIds.size === fromTableIds.size; + }) + .filter((match) => (query.where ? query.where.every((condition) => matchesCondition(condition, match)) : true)); + + return matchedSubjects.map((match) => match.subject); +} diff --git a/packages/store-sync/src/query-cache/matchesCondition.ts b/packages/store-sync/src/query-cache/matchesCondition.ts new file mode 100644 index 0000000000..18e740a776 --- /dev/null +++ b/packages/store-sync/src/query-cache/matchesCondition.ts @@ -0,0 +1,46 @@ +import { Table } from "@latticexyz/store"; +import { TableRecord } from "../zustand/common"; +import { ComparisonCondition, ConditionLiteral, QueryCondition, TableSubject } from "./common"; + +type MatchedSubjectRecord
= TableRecord
& { + fields: TableRecord
["key"] & TableRecord
["value"]; +}; + +type MatchedSubject
= { + readonly subject: TableSubject; + readonly records: readonly MatchedSubjectRecord
[]; +}; + +const comparisons = { + "<": (left, right) => left < right, + "<=": (left, right) => left <= right, + "=": (left, right) => left === right, + ">": (left, right) => left > right, + ">=": (left, right) => left >= right, + "!=": (left, right) => left !== right, +} as const satisfies Record boolean>; + +export function matchesCondition
( + condition: QueryCondition, + subject: MatchedSubject
, +): boolean { + switch (condition.op) { + case "<": + case "<=": + case "=": + case ">": + case ">=": + case "!=": + return subject.records.some( + (record) => + record.table.tableId === condition.left.tableId && + comparisons[condition.op](record.fields[condition.left.field], condition.right), + ); + case "in": + return subject.records.some( + (record) => + record.table.tableId === condition.left.tableId && + condition.right.includes(record.fields[condition.left.field]), + ); + } +} diff --git a/packages/store-sync/src/query-cache/query.test.ts b/packages/store-sync/src/query-cache/query.test.ts new file mode 100644 index 0000000000..cb3f1b38c3 --- /dev/null +++ b/packages/store-sync/src/query-cache/query.test.ts @@ -0,0 +1,140 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { createHydratedStore, tables } from "./test/createHydratedStore"; +import { query } from "./query"; +import { deployMockGame } from "../../test/mockGame"; +import { Address } from "viem"; + +describe("query", async () => { + let worldAddress: Address; + beforeAll(async () => { + worldAddress = await deployMockGame(); + }); + + it("can get players with a position", async () => { + const { store } = await createHydratedStore(worldAddress); + const result = await query(store, { + from: [{ tableId: tables.Position.tableId, subject: ["player"] }], + }); + + expect(result).toMatchInlineSnapshot(` + [ + [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + [ + "0xdBa86119a787422C593ceF119E40887f396024E2", + ], + ] + `); + }); + + it("can get players at position (3, 5)", async () => { + const { store } = await createHydratedStore(worldAddress); + const result = await query(store, { + from: [{ tableId: tables.Position.tableId, subject: ["player"] }], + where: [ + { left: { tableId: tables.Position.tableId, field: "x" }, op: "=", right: 3 }, + { left: { tableId: tables.Position.tableId, field: "y" }, op: "=", right: 5 }, + ], + }); + + expect(result).toMatchInlineSnapshot(` + [ + [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + ] + `); + }); + + it("can get players within the bounds of (-5, -5) and (5, 5)", async () => { + const { store } = await createHydratedStore(worldAddress); + const result = await query(store, { + from: [{ tableId: tables.Position.tableId, subject: ["player"] }], + where: [ + { left: { tableId: tables.Position.tableId, field: "x" }, op: ">=", right: -5 }, + { left: { tableId: tables.Position.tableId, field: "x" }, op: "<=", right: 5 }, + { left: { tableId: tables.Position.tableId, field: "y" }, op: ">=", right: -5 }, + { left: { tableId: tables.Position.tableId, field: "y" }, op: "<=", right: 5 }, + ], + }); + + expect(result).toMatchInlineSnapshot(` + [ + [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + ] + `); + }); + + it("can get players that are still alive", async () => { + const { store } = await createHydratedStore(worldAddress); + const result = await query(store, { + from: [ + { tableId: tables.Position.tableId, subject: ["player"] }, + { tableId: tables.Health.tableId, subject: ["player"] }, + ], + where: [{ left: { tableId: tables.Health.tableId, field: "health" }, op: "!=", right: 0n }], + }); + + expect(result).toMatchInlineSnapshot(` + [ + [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + ] + `); + }); + + it("can get all players in grassland", async () => { + const { store } = await createHydratedStore(worldAddress); + const result = await query(store, { + from: [{ tableId: tables.Terrain.tableId, subject: ["x", "y"] }], + where: [{ left: { tableId: tables.Terrain.tableId, field: "terrainType" }, op: "=", right: 2 }], + }); + + expect(result).toMatchInlineSnapshot(` + [ + [ + 3, + 5, + ], + ] + `); + }); + + it("can get all players without health (e.g. spectator)", async () => { + const { store } = await createHydratedStore(worldAddress); + const result = await query(store, { + from: [{ tableId: tables.Position.tableId, subject: ["player"] }], + except: [{ tableId: tables.Health.tableId, subject: ["player"] }], + }); + + expect(result).toMatchInlineSnapshot(` + [ + [ + "0xdBa86119a787422C593ceF119E40887f396024E2", + ], + ] + `); + }); +}); diff --git a/packages/store-sync/src/query-cache/query.ts b/packages/store-sync/src/query-cache/query.ts new file mode 100644 index 0000000000..d98ebbb810 --- /dev/null +++ b/packages/store-sync/src/query-cache/query.ts @@ -0,0 +1,28 @@ +import { ZustandStore } from "../zustand"; +import { AllTables, Query, QueryResultSubject } from "./common"; +import { StoreConfig, Tables } from "@latticexyz/store"; +import { findSubjects } from "./findSubjects"; + +// TODO: validate query +// - one subject per table +// - underlying subject field types match +// - only keys as subjects for now? +// - subjects and conditions all have valid fields +// - can only compare like types? +// - `where` tables are in `from` + +// TODO: make query smarter/config aware for shorthand +// TODO: make condition types smarter, so condition literal matches the field primitive type +// TODO: return matching records alongside subjects? because the record subset may be smaller than what querying for records with matching subjects + +type QueryResult = readonly QueryResultSubject[]; + +export async function query( + store: ZustandStore>, + query: Query, +): Promise> { + const records = Object.values(store.getState().records); + const matches = findSubjects({ records, query }); + + return matches; +} diff --git a/packages/store-sync/src/query-cache/subscribeToQuery.test.ts b/packages/store-sync/src/query-cache/subscribeToQuery.test.ts new file mode 100644 index 0000000000..2b333bcf43 --- /dev/null +++ b/packages/store-sync/src/query-cache/subscribeToQuery.test.ts @@ -0,0 +1,772 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { createHydratedStore, tables } from "./test/createHydratedStore"; +import { QueryResultSubjectChange, subscribeToQuery } from "./subscribeToQuery"; +import { deployMockGame, worldAbi } from "../../test/mockGame"; +import { waitForTransactionReceipt, writeContract } from "viem/actions"; +import { Address, keccak256, parseEther, stringToHex } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { testClient } from "../../test/common"; +import { combineLatest, filter, firstValueFrom, map, scan, shareReplay, tap } from "rxjs"; +import { QueryResultSubject } from "./common"; +import { waitForTransaction } from "./test/waitForTransaction"; + +const henryAccount = privateKeyToAccount(keccak256(stringToHex("henry"))); + +describe("subscribeToQuery", async () => { + let worldAddress: Address; + beforeAll(async () => { + await testClient.setBalance({ address: henryAccount.address, value: parseEther("1") }); + worldAddress = await deployMockGame(); + }); + + it("can get players with a position", async () => { + const { store, fetchLatestLogs } = await createHydratedStore(worldAddress); + + const { subjects, subjects$, subjectChanges$ } = await subscribeToQuery(store, { + from: [{ tableId: tables.Position.tableId, subject: ["player"] }], + }); + + const latest$ = combineLatest({ + subjects$: subjects$.pipe( + scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubject[])[]), + map((values) => ({ count: values.length, value: values.at(-1) })), + ), + subjectChanges$: subjectChanges$.pipe( + scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubjectChange[])[]), + map((values) => ({ count: values.length, value: values.at(-1) })), + ), + }).pipe(shareReplay(1)); + + expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` + { + "subjectChanges$": { + "count": 1, + "value": [ + { + "subject": [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + "type": "enter", + }, + { + "subject": [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + "type": "enter", + }, + { + "subject": [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + "type": "enter", + }, + { + "subject": [ + "0xdBa86119a787422C593ceF119E40887f396024E2", + ], + "type": "enter", + }, + ], + }, + "subjects$": { + "count": 1, + "value": [ + [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + [ + "0xdBa86119a787422C593ceF119E40887f396024E2", + ], + ], + }, + } + `); + + waitForTransaction( + await writeContract(testClient, { + account: henryAccount, + chain: null, + address: worldAddress, + abi: worldAbi, + functionName: "move", + args: [1, 2], + }), + ); + await fetchLatestLogs(); + + expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` + { + "subjectChanges$": { + "count": 2, + "value": [ + { + "subject": [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + "type": "enter", + }, + ], + }, + "subjects$": { + "count": 2, + "value": [ + [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + [ + "0xdBa86119a787422C593ceF119E40887f396024E2", + ], + [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + ], + }, + } + `); + }); + + it("can get players at position (3, 5)", async () => { + const { store, fetchLatestLogs } = await createHydratedStore(worldAddress); + + const { subjects, subjects$, subjectChanges$ } = await subscribeToQuery(store, { + from: [{ tableId: tables.Position.tableId, subject: ["player"] }], + where: [ + { left: { tableId: tables.Position.tableId, field: "x" }, op: "=", right: 3 }, + { left: { tableId: tables.Position.tableId, field: "y" }, op: "=", right: 5 }, + ], + }); + + const latest$ = combineLatest({ + subjects$: subjects$.pipe( + scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubject[])[]), + map((values) => ({ count: values.length, value: values.at(-1) })), + ), + subjectChanges$: subjectChanges$.pipe( + scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubjectChange[])[]), + map((values) => ({ count: values.length, value: values.at(-1) })), + ), + }).pipe(shareReplay(1)); + + expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` + { + "subjectChanges$": { + "count": 1, + "value": [ + { + "subject": [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + "type": "enter", + }, + { + "subject": [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + "type": "enter", + }, + ], + }, + "subjects$": { + "count": 1, + "value": [ + [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + ], + }, + } + `); + + waitForTransaction( + await writeContract(testClient, { + account: henryAccount, + chain: null, + address: worldAddress, + abi: worldAbi, + functionName: "move", + args: [3, 5], + }), + ); + await fetchLatestLogs(); + + expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` + { + "subjectChanges$": { + "count": 2, + "value": [ + { + "subject": [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + "type": "enter", + }, + ], + }, + "subjects$": { + "count": 2, + "value": [ + [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + ], + }, + } + `); + + waitForTransaction( + await writeContract(testClient, { + account: henryAccount, + chain: null, + address: worldAddress, + abi: worldAbi, + functionName: "move", + args: [2, 4], + }), + ); + await fetchLatestLogs(); + + expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` + { + "subjectChanges$": { + "count": 3, + "value": [ + { + "subject": [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + "type": "exit", + }, + ], + }, + "subjects$": { + "count": 3, + "value": [ + [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + ], + }, + } + `); + }); + + it("can get players within the bounds of (-5, -5) and (5, 5)", async () => { + const { store, fetchLatestLogs } = await createHydratedStore(worldAddress); + + const { subjects, subjects$, subjectChanges$ } = await subscribeToQuery(store, { + from: [{ tableId: tables.Position.tableId, subject: ["player"] }], + where: [ + { left: { tableId: tables.Position.tableId, field: "x" }, op: ">=", right: -5 }, + { left: { tableId: tables.Position.tableId, field: "x" }, op: "<=", right: 5 }, + { left: { tableId: tables.Position.tableId, field: "y" }, op: ">=", right: -5 }, + { left: { tableId: tables.Position.tableId, field: "y" }, op: "<=", right: 5 }, + ], + }); + + const latest$ = combineLatest({ + subjects$: subjects$.pipe( + scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubject[])[]), + map((values) => ({ count: values.length, value: values.at(-1) })), + ), + subjectChanges$: subjectChanges$.pipe( + scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubjectChange[])[]), + map((values) => ({ count: values.length, value: values.at(-1) })), + ), + }).pipe(shareReplay(1)); + + expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` + { + "subjectChanges$": { + "count": 1, + "value": [ + { + "subject": [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + "type": "enter", + }, + { + "subject": [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + "type": "enter", + }, + { + "subject": [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + "type": "enter", + }, + ], + }, + "subjects$": { + "count": 1, + "value": [ + [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + ], + }, + } + `); + + waitForTransaction( + await writeContract(testClient, { + account: henryAccount, + chain: null, + address: worldAddress, + abi: worldAbi, + functionName: "move", + args: [3, 5], + }), + ); + await fetchLatestLogs(); + + expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` + { + "subjectChanges$": { + "count": 2, + "value": [ + { + "subject": [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + "type": "enter", + }, + ], + }, + "subjects$": { + "count": 2, + "value": [ + [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + ], + }, + } + `); + + waitForTransaction( + await writeContract(testClient, { + account: henryAccount, + chain: null, + address: worldAddress, + abi: worldAbi, + functionName: "move", + args: [100, 100], + }), + ); + await fetchLatestLogs(); + + expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` + { + "subjectChanges$": { + "count": 3, + "value": [ + { + "subject": [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + "type": "exit", + }, + ], + }, + "subjects$": { + "count": 3, + "value": [ + [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + ], + }, + } + `); + }); + + it("can get players that are still alive", async () => { + const { store, fetchLatestLogs } = await createHydratedStore(worldAddress); + + const { subjects, subjects$, subjectChanges$ } = await subscribeToQuery(store, { + from: [ + { tableId: tables.Position.tableId, subject: ["player"] }, + { tableId: tables.Health.tableId, subject: ["player"] }, + ], + where: [{ left: { tableId: tables.Health.tableId, field: "health" }, op: "!=", right: 0n }], + }); + + const latest$ = combineLatest({ + subjects$: subjects$.pipe( + scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubject[])[]), + map((values) => ({ count: values.length, value: values.at(-1) })), + ), + subjectChanges$: subjectChanges$.pipe( + scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubjectChange[])[]), + map((values) => ({ count: values.length, value: values.at(-1) })), + ), + }).pipe(shareReplay(1)); + + expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` + { + "subjectChanges$": { + "count": 1, + "value": [ + { + "subject": [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + "type": "enter", + }, + { + "subject": [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + "type": "enter", + }, + ], + }, + "subjects$": { + "count": 1, + "value": [ + [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + ], + }, + } + `); + }); + + it("can get all players in grassland", async () => { + const { store, fetchLatestLogs } = await createHydratedStore(worldAddress); + + const { subjects, subjects$, subjectChanges$ } = await subscribeToQuery(store, { + from: [{ tableId: tables.Terrain.tableId, subject: ["x", "y"] }], + where: [{ left: { tableId: tables.Terrain.tableId, field: "terrainType" }, op: "=", right: 2 }], + }); + + const latest$ = combineLatest({ + subjects$: subjects$.pipe( + scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubject[])[]), + map((values) => ({ count: values.length, value: values.at(-1) })), + ), + subjectChanges$: subjectChanges$.pipe( + scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubjectChange[])[]), + map((values) => ({ count: values.length, value: values.at(-1) })), + ), + }).pipe(shareReplay(1)); + + expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` + { + "subjectChanges$": { + "count": 1, + "value": [ + { + "subject": [ + 3, + 5, + ], + "type": "enter", + }, + ], + }, + "subjects$": { + "count": 1, + "value": [ + [ + 3, + 5, + ], + ], + }, + } + `); + }); + + it("can get all players without health (e.g. spectator)", async () => { + const { store, fetchLatestLogs } = await createHydratedStore(worldAddress); + + const { subjects, subjects$, subjectChanges$ } = await subscribeToQuery(store, { + from: [{ tableId: tables.Position.tableId, subject: ["player"] }], + except: [{ tableId: tables.Health.tableId, subject: ["player"] }], + }); + + const latest$ = combineLatest({ + subjects$: subjects$.pipe( + scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubject[])[]), + map((values) => ({ count: values.length, value: values.at(-1) })), + ), + subjectChanges$: subjectChanges$.pipe( + scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubjectChange[])[]), + map((values) => ({ count: values.length, value: values.at(-1) })), + ), + }).pipe(shareReplay(1)); + + expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` + { + "subjectChanges$": { + "count": 1, + "value": [ + { + "subject": [ + "0xdBa86119a787422C593ceF119E40887f396024E2", + ], + "type": "enter", + }, + ], + }, + "subjects$": { + "count": 1, + "value": [ + [ + "0xdBa86119a787422C593ceF119E40887f396024E2", + ], + ], + }, + } + `); + }); + + it("emits new subjects when initial matching set is empty", async () => { + const { store, fetchLatestLogs } = await createHydratedStore(worldAddress); + + const { subjects, subjects$, subjectChanges$ } = await subscribeToQuery(store, { + from: [{ tableId: tables.Position.tableId, subject: ["player"] }], + where: [ + { left: { tableId: tables.Position.tableId, field: "x" }, op: "=", right: 999 }, + { left: { tableId: tables.Position.tableId, field: "y" }, op: "=", right: 999 }, + ], + }); + + const latest$ = combineLatest({ + subjects$: subjects$.pipe( + scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubject[])[]), + map((values) => ({ count: values.length, value: values.at(-1) })), + ), + subjectChanges$: subjectChanges$.pipe( + scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubjectChange[])[]), + map((values) => ({ count: values.length, value: values.at(-1) })), + ), + }).pipe(shareReplay(1)); + + expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` + { + "subjectChanges$": { + "count": 1, + "value": [], + }, + "subjects$": { + "count": 1, + "value": [], + }, + } + `); + + waitForTransaction( + await writeContract(testClient, { + account: henryAccount, + chain: null, + address: worldAddress, + abi: worldAbi, + functionName: "move", + args: [999, 999], + }), + ); + await fetchLatestLogs(); + + expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` + { + "subjectChanges$": { + "count": 2, + "value": [ + { + "subject": [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + "type": "enter", + }, + ], + }, + "subjects$": { + "count": 2, + "value": [ + [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + ], + }, + } + `); + }); + + it("emits changed subjects when subscribing some time after initial query", async () => { + const { store, fetchLatestLogs } = await createHydratedStore(worldAddress); + + const { subjects, subjects$, subjectChanges$ } = await subscribeToQuery(store, { + from: [{ tableId: tables.Position.tableId, subject: ["player"] }], + where: [ + { left: { tableId: tables.Position.tableId, field: "x" }, op: "=", right: 3 }, + { left: { tableId: tables.Position.tableId, field: "y" }, op: "=", right: 5 }, + ], + }); + + expect(subjects).toMatchInlineSnapshot(` + [ + [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + ] + `); + + waitForTransaction( + await writeContract(testClient, { + account: henryAccount, + chain: null, + address: worldAddress, + abi: worldAbi, + functionName: "move", + args: [3, 5], + }), + ); + await fetchLatestLogs(); + + const latest$ = combineLatest({ + subjects$: subjects$.pipe( + scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubject[])[]), + map((values) => ({ count: values.length, value: values.at(-1) })), + ), + subjectChanges$: subjectChanges$.pipe( + scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubjectChange[])[]), + map((values) => ({ count: values.length, value: values.at(-1) })), + ), + }).pipe(shareReplay(1)); + + // we expect two emissions for by this point: initial subjects + subjects changed since starting the subscriptions + expect( + await firstValueFrom( + latest$.pipe(filter((latest) => latest.subjects$.count === 2 && latest.subjectChanges$.count === 2)), + ), + ).toMatchInlineSnapshot(` + { + "subjectChanges$": { + "count": 2, + "value": [ + { + "subject": [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + "type": "enter", + }, + ], + }, + "subjects$": { + "count": 2, + "value": [ + [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + ], + }, + } + `); + + waitForTransaction( + await writeContract(testClient, { + account: henryAccount, + chain: null, + address: worldAddress, + abi: worldAbi, + functionName: "move", + args: [2, 4], + }), + ); + await fetchLatestLogs(); + + expect( + await firstValueFrom( + latest$.pipe(filter((latest) => latest.subjects$.count === 3 && latest.subjectChanges$.count === 3)), + ), + ).toMatchInlineSnapshot(` + { + "subjectChanges$": { + "count": 3, + "value": [ + { + "subject": [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + "type": "exit", + }, + ], + }, + "subjects$": { + "count": 3, + "value": [ + [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + ], + }, + } + `); + }); +}); diff --git a/packages/store-sync/src/query-cache/subscribeToQuery.ts b/packages/store-sync/src/query-cache/subscribeToQuery.ts new file mode 100644 index 0000000000..3b31e563ed --- /dev/null +++ b/packages/store-sync/src/query-cache/subscribeToQuery.ts @@ -0,0 +1,102 @@ +import { ZustandStore } from "../zustand"; +import { AllTables, Query, QueryResultSubject } from "./common"; +import { StoreConfig, Tables } from "@latticexyz/store"; +import { findSubjects } from "./findSubjects"; +import { Observable, distinctUntilChanged, map, scan } from "rxjs"; +import isEqual from "fast-deep-equal"; + +export type QueryResultSubjectChange = { + // TODO: naming + // is enter/exit better than add/remove? what about enter/exit vs entered/exited? in/out? + readonly type: "enter" | "exit"; + readonly subject: QueryResultSubject; +}; + +// TODO: decide if this whole thing is returned in a promise or just `subjects` +// TODO: return matching records alongside subjects? because the record subset may be smaller than what querying for records with matching subjects +type SubscribeToQueryResult = { + /** + * Set of initial matching subjects for query. + */ + subjects: readonly QueryResultSubject[]; + /** + * Stream of matching subjects for query. First emission is is the same as `subjects`. + */ + subjects$: Observable; + /** + * Stream of subject changes for query. + * First emission will be an `enter` for each item in `subjects`, or an empty array if no matches. + * Each emission after that will only be the subjects that have changed (have entered or exited the result set). + */ + subjectChanges$: Observable; +}; + +export async function subscribeToQuery( + store: ZustandStore>, + query: Query, +): Promise> { + const initialRecords = store.getState().records; + const initialSubjects = findSubjects({ + records: Object.values(initialRecords), + query, + }); + + function createSubjectStream(): Observable { + return new Observable(function subscribe(subscriber) { + // return initial results immediately + subscriber.next(initialSubjects); + + // if records have changed between query and subscription, reevaluate + const { records } = store.getState(); + if (records !== initialRecords) { + subscriber.next( + findSubjects({ + records: Object.values(records), + query, + }), + ); + } + + // then listen for changes to records and reevaluate + const unsub = store.subscribe((state, prevState) => { + if (state.records !== prevState.records) { + subscriber.next( + findSubjects({ + records: Object.values(state.records), + query, + }), + ); + } + }); + + return () => void unsub(); + }).pipe(distinctUntilChanged(isEqual)); + } + + const subjects$ = createSubjectStream(); + + const subjectChanges$ = createSubjectStream().pipe( + scan( + (acc, curr) => ({ prev: acc.curr, curr }), + { prev: [], curr: [] }, + ), + map(({ prev, curr }) => { + const prevSet = new Set(prev.map((subject) => JSON.stringify(subject))); + const currSet = new Set(curr.map((subject) => JSON.stringify(subject))); + + const enter = curr.filter((subject) => !prevSet.has(JSON.stringify(subject))); + const exit = prev.filter((subject) => !currSet.has(JSON.stringify(subject))); + + return [ + ...enter.map((subject) => ({ type: "enter" as const, subject })), + ...exit.map((subject) => ({ type: "exit" as const, subject })), + ]; + }), + ); + + return { + subjects: initialSubjects, + subjects$, + subjectChanges$, + }; +} diff --git a/packages/store-sync/src/query-cache/syncToQueryCache.ts b/packages/store-sync/src/query-cache/syncToQueryCache.ts new file mode 100644 index 0000000000..6ff19e8c11 --- /dev/null +++ b/packages/store-sync/src/query-cache/syncToQueryCache.ts @@ -0,0 +1,62 @@ +import { StoreConfig, Tables, resolveConfig } from "@latticexyz/store"; +import { SyncOptions, SyncResult, storeTables, worldTables } from "../common"; +import { createStoreSync } from "../createStoreSync"; +import { createStore } from "../zustand/createStore"; +import { createStorageAdapter } from "../zustand/createStorageAdapter"; +import { Address } from "viem"; +import { SyncStep } from "../SyncStep"; +import { AllTables } from "./common"; + +type SyncToQueryCacheOptions = SyncOptions & { + // require address for now to keep the data model + retrieval simpler + address: Address; + config: config; + tables?: extraTables; + startSync?: boolean; +}; + +type SyncToQueryCacheResult = SyncResult & { + tables: AllTables; + stopSync: () => void; +}; + +export async function syncToQueryCache({ + config, + tables: extraTables, + startSync = true, + ...syncOptions +}: SyncToQueryCacheOptions): Promise> { + // TODO: migrate this once we redo config to return fully resolved tables (https://github.com/latticexyz/mud/issues/1668) + // TODO: move store/world tables into `resolveConfig` + const resolvedConfig = resolveConfig(config); + const tables = { + ...resolvedConfig.tables, + ...extraTables, + ...storeTables, + ...worldTables, + } as unknown as AllTables; + + const useStore = createStore({ tables }); + const storageAdapter = createStorageAdapter({ store: useStore }); + + const storeSync = await createStoreSync({ + storageAdapter, + ...syncOptions, + onProgress: (syncProgress) => { + // already live, no need for more progress updates + if (useStore.getState().syncProgress.step === SyncStep.LIVE) return; + useStore.setState(() => ({ syncProgress })); + }, + }); + + const sub = startSync ? storeSync.storedBlockLogs$.subscribe() : null; + const stopSync = (): void => { + sub?.unsubscribe(); + }; + + return { + ...storeSync, + tables, + stopSync, + }; +} diff --git a/packages/store-sync/src/query-cache/test/createHydratedStore.ts b/packages/store-sync/src/query-cache/test/createHydratedStore.ts new file mode 100644 index 0000000000..0cd0a1f4e4 --- /dev/null +++ b/packages/store-sync/src/query-cache/test/createHydratedStore.ts @@ -0,0 +1,49 @@ +import { storeEventsAbi } from "@latticexyz/store"; +import { createStorageAdapter } from "../../zustand/createStorageAdapter"; +import { ZustandStore, createStore } from "../../zustand/createStore"; +import { config, deprecatedConfig } from "../../../test/mockGame"; +import { fetchAndStoreLogs } from "../../fetchAndStoreLogs"; +import { testClient } from "../../../test/common"; +import { storeTables, worldTables } from "../../common"; +import { AllTables } from "../common"; +import { Address } from "viem"; +import { getBlock, getBlockNumber } from "viem/actions"; + +export const tables = { + ...config.tables, + ...storeTables, + ...worldTables, +} as unknown as AllTables; + +export async function createHydratedStore(worldAddress: Address): Promise<{ + store: ZustandStore; + fetchLatestLogs: () => Promise; +}> { + const store = createStore({ tables }); + const storageAdapter = createStorageAdapter({ store }); + + let lastBlockProcessed = (await getBlock(testClient, { blockTag: "earliest" })).number - 1n; + async function fetchLatestLogs(): Promise { + const toBlock = await getBlockNumber(testClient); + if (toBlock > lastBlockProcessed) { + const fromBlock = lastBlockProcessed + 1n; + // console.log("fetching blocks", fromBlock, "to", toBlock); + for await (const block of fetchAndStoreLogs({ + storageAdapter, + publicClient: testClient, + address: worldAddress, + events: storeEventsAbi, + fromBlock, + toBlock, + })) { + // console.log("got block logs", block.blockNumber, block.logs.length); + } + lastBlockProcessed = toBlock; + } + return toBlock; + } + + await fetchLatestLogs(); + + return { store, fetchLatestLogs }; +} diff --git a/packages/store-sync/src/query-cache/test/minePending.ts b/packages/store-sync/src/query-cache/test/minePending.ts new file mode 100644 index 0000000000..eb0d1524ee --- /dev/null +++ b/packages/store-sync/src/query-cache/test/minePending.ts @@ -0,0 +1,9 @@ +import { testClient } from "../../../test/common"; + +export async function minePending(): Promise { + const content = await testClient.getTxpoolContent(); + if (!Object.keys(content.pending).length) return; + + await testClient.mine({ blocks: 1 }); + await minePending(); +} diff --git a/packages/store-sync/src/query-cache/test/waitForTransaction.ts b/packages/store-sync/src/query-cache/test/waitForTransaction.ts new file mode 100644 index 0000000000..8f5de8e9b6 --- /dev/null +++ b/packages/store-sync/src/query-cache/test/waitForTransaction.ts @@ -0,0 +1,13 @@ +import { Hex } from "viem"; +import { getTransactionReceipt } from "viem/actions"; +import { testClient } from "../../../test/common"; +import { minePending } from "./minePending"; + +export async function waitForTransaction(hash: Hex): Promise { + await minePending(); + const receipt = await getTransactionReceipt(testClient, { hash }); + if (receipt.status === "reverted") { + // TODO: better error + throw new Error(`Transaction reverted (${hash})`); + } +} diff --git a/packages/store-sync/src/zustand/common.ts b/packages/store-sync/src/zustand/common.ts index 80598c24bb..d06a75047a 100644 --- a/packages/store-sync/src/zustand/common.ts +++ b/packages/store-sync/src/zustand/common.ts @@ -11,7 +11,7 @@ export type RawRecord = { readonly dynamicData: Hex; }; -export type TableRecord
= { +export type TableRecord
= { /** Internal unique ID */ readonly id: string; readonly table: table; diff --git a/packages/store-sync/src/zustand/createStorageAdapter.test.ts b/packages/store-sync/src/zustand/createStorageAdapter.test.ts index e6a844675d..99b7ddf722 100644 --- a/packages/store-sync/src/zustand/createStorageAdapter.test.ts +++ b/packages/store-sync/src/zustand/createStorageAdapter.test.ts @@ -1,13 +1,18 @@ -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { storeEventsAbi } from "@latticexyz/store"; import { createStorageAdapter } from "./createStorageAdapter"; import { createStore } from "./createStore"; -import { config, deployMockGame } from "../../test/deployMockGame"; +import { config, deployMockGame } from "../../test/mockGame"; import { fetchAndStoreLogs } from "../fetchAndStoreLogs"; -import { publicClient } from "../../test/common"; +import { testClient } from "../../test/common"; +import { getBlockNumber } from "viem/actions"; +import { Address } from "viem"; describe("createStorageAdapter", async () => { - await deployMockGame(); + let worldAddress: Address; + beforeAll(async () => { + worldAddress = await deployMockGame(); + }); it("sets component values from logs", async () => { const useStore = createStore({ tables: config.tables }); @@ -16,10 +21,10 @@ describe("createStorageAdapter", async () => { console.log("fetching blocks"); for await (const block of fetchAndStoreLogs({ storageAdapter, - publicClient, + publicClient: testClient, events: storeEventsAbi, fromBlock: 0n, - toBlock: await publicClient.getBlockNumber(), + toBlock: await getBlockNumber(testClient), })) { // console.log("got block", block.blockNumber); } diff --git a/packages/store-sync/src/zustand/createStorageAdapter.ts b/packages/store-sync/src/zustand/createStorageAdapter.ts index 8a649c0216..a789274c58 100644 --- a/packages/store-sync/src/zustand/createStorageAdapter.ts +++ b/packages/store-sync/src/zustand/createStorageAdapter.ts @@ -1,6 +1,6 @@ import { Tables } from "@latticexyz/store"; import { StorageAdapter } from "../common"; -import { RawRecord } from "./common"; +import { RawRecord, TableRecord } from "./common"; import { ZustandStore } from "./createStore"; import { hexToResource, resourceToLabel, spliceHex } from "@latticexyz/common"; import { debug } from "./debug"; @@ -141,7 +141,7 @@ export function createStorageAdapter({ keyTuple: rawRecord.keyTuple, key: decodeKey(flattenSchema(table.keySchema), rawRecord.keyTuple), value: decodeValueArgs(flattenSchema(table.valueSchema), rawRecord), - }, + } satisfies TableRecord, ]; }) .filter(isDefined), diff --git a/packages/store-sync/test/common.ts b/packages/store-sync/test/common.ts index 62b6bc70f1..d61f512394 100644 --- a/packages/store-sync/test/common.ts +++ b/packages/store-sync/test/common.ts @@ -1,4 +1,4 @@ -import { createPublicClient, createTestClient, http } from "viem"; +import { createTestClient, http, publicActions, walletActions } from "viem"; export const anvilHost = "127.0.0.1"; export const anvilPort = 8555; @@ -10,9 +10,9 @@ export const anvilRpcUrl = `http://${anvilHost}:${anvilPort}/${poolId}`; export const testClient = createTestClient({ mode: "anvil", + // TODO: if tests get slow, try switching to websockets? transport: http(anvilRpcUrl), -}); - -export const publicClient = createPublicClient({ - transport: http(anvilRpcUrl), -}); + pollingInterval: 10, +}) + .extend(publicActions) + .extend(walletActions); diff --git a/packages/store-sync/test/deployMockGame.ts b/packages/store-sync/test/deployMockGame.ts deleted file mode 100644 index 99bf103743..0000000000 --- a/packages/store-sync/test/deployMockGame.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { execa } from "execa"; -import { anvilRpcUrl, testClient } from "./common"; -import mudConfig from "mock-game-contracts/mud.config"; -import { resolveConfig } from "@latticexyz/store"; - -export const config = resolveConfig(mudConfig); - -export async function deployMockGame(): Promise { - const automine = await testClient.getAutomine(); - - if (!automine) { - console.log("turning on automine for deploy"); - await testClient.setAutomine(true); - } - - // TODO: build in globalSetup so we don't have to build here? - console.log("deploying mud"); - const { stdout, stderr } = await execa("pnpm", ["mud", "deploy", "--rpc", anvilRpcUrl, "--saveDeployment", "false"], { - cwd: `${__dirname}/../../../test/mock-game-contracts`, - env: { - // anvil default account - PRIVATE_KEY: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - DEBUG: "mud:*", - }, - }); - if (stderr) console.error(stderr); - if (stdout) console.log(stdout); - - if (!automine) { - console.log("turning off automine"); - await testClient.setAutomine(false); - } -} diff --git a/packages/store-sync/test/globalSetup.ts b/packages/store-sync/test/globalSetup.ts index dd8ed21839..926984f2eb 100644 --- a/packages/store-sync/test/globalSetup.ts +++ b/packages/store-sync/test/globalSetup.ts @@ -1,13 +1,16 @@ import { startProxy as startAnvilProxy } from "@viem/anvil"; import { anvilHost, anvilPort } from "./common"; +import { execa } from "execa"; export default async function globalSetup(): Promise<() => Promise> { + console.log("building mock game"); + await execa("pnpm", ["run", "build"], { + cwd: `${__dirname}/../../../test/mock-game-contracts`, + }); + const shutdownAnvilProxy = await startAnvilProxy({ host: anvilHost, port: anvilPort, - options: { - noMining: true, - }, }); return async () => { diff --git a/packages/store-sync/test/mockGame.ts b/packages/store-sync/test/mockGame.ts new file mode 100644 index 0000000000..e71abd0708 --- /dev/null +++ b/packages/store-sync/test/mockGame.ts @@ -0,0 +1,38 @@ +import { execa } from "execa"; +import { anvilRpcUrl } from "./common"; +import mudConfig from "mock-game-contracts/mud.config"; +import { resolveConfig } from "@latticexyz/store"; +import { Hex, isHex } from "viem"; +import worldAbi from "mock-game-contracts/out/IWorld.sol/IWorld.abi.json"; + +export const deprecatedConfig = mudConfig; +export const config = resolveConfig(mudConfig); +export { worldAbi }; + +export async function deployMockGame(): Promise { + console.log("deploying mock game to", anvilRpcUrl); + const { stdout, stderr } = await execa( + "pnpm", + // skip build because its slow and we do it in global setup + // if we don't skip build here, it regenerates ABIs which cause the tests to re-run (because we import the ABI here), which re-runs this deploy... + ["mud", "deploy", "--rpc", anvilRpcUrl, "--saveDeployment", "false", "--skipBuild"], + { + cwd: `${__dirname}/../../../test/mock-game-contracts`, + env: { + // anvil default account + PRIVATE_KEY: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + DEBUG: "mud:*", + }, + }, + ); + if (stderr) console.error(stderr); + if (stdout) console.log(stdout); + + const [, worldAddress] = stdout.match(/worldAddress: '(0x[0-9a-f]+)'/i) ?? []; + if (!isHex(worldAddress)) { + throw new Error("world address not found in output, did the deploy fail?"); + } + console.log("deployed mock game", worldAddress); + + return worldAddress; +} diff --git a/packages/store-sync/test/setup.ts b/packages/store-sync/test/setup.ts new file mode 100644 index 0000000000..35d83f4304 --- /dev/null +++ b/packages/store-sync/test/setup.ts @@ -0,0 +1,18 @@ +import { beforeAll, beforeEach } from "vitest"; +import { testClient } from "./common"; + +// Some test suites deploy contracts in a `beforeAll` handler, so we restore chain state here. +beforeAll(async () => { + const state = await testClient.dumpState(); + return async (): Promise => { + await testClient.loadState({ state }); + }; +}); + +// Some tests execute transactions, so we restore chain state here. +beforeEach(async () => { + const state = await testClient.dumpState(); + return async (): Promise => { + await testClient.loadState({ state }); + }; +}); diff --git a/packages/store-sync/vitest.config.ts b/packages/store-sync/vitest.config.ts index 1628ff996c..61f51f284f 100644 --- a/packages/store-sync/vitest.config.ts +++ b/packages/store-sync/vitest.config.ts @@ -3,5 +3,10 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globalSetup: ["test/globalSetup.ts"], + setupFiles: ["test/setup.ts"], + // Temporarily set a low teardown timeout because anvil hangs otherwise + // Could move this timeout to anvil setup after https://github.com/wevm/anvil.js/pull/46 + teardownTimeout: 500, + hookTimeout: 15000, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98e9d8303f..736b5583f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -923,6 +923,9 @@ importers: drizzle-orm: specifier: ^0.28.5 version: 0.28.5(@types/sql.js@1.4.4)(kysely@0.26.3)(postgres@3.3.5)(sql.js@1.8.0) + fast-deep-equal: + specifier: ^3.1.3 + version: 3.1.3 kysely: specifier: ^0.26.3 version: 0.26.3 diff --git a/test/mock-game-contracts/src/codegen/world/IMoveSystem.sol b/test/mock-game-contracts/src/codegen/world/IMoveSystem.sol new file mode 100644 index 0000000000..56cfc24e74 --- /dev/null +++ b/test/mock-game-contracts/src/codegen/world/IMoveSystem.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.24; + +/* Autogenerated file. Do not edit manually. */ + +/** + * @title IMoveSystem + * @author MUD (https://mud.dev) by Lattice (https://lattice.xyz) + * @dev This interface is automatically generated from the corresponding system contract. Do not edit manually. + */ +interface IMoveSystem { + function move(int32 x, int32 y) external; +} diff --git a/test/mock-game-contracts/src/codegen/world/IWorld.sol b/test/mock-game-contracts/src/codegen/world/IWorld.sol index 4761e84790..d27f33eb9a 100644 --- a/test/mock-game-contracts/src/codegen/world/IWorld.sol +++ b/test/mock-game-contracts/src/codegen/world/IWorld.sol @@ -5,6 +5,8 @@ pragma solidity >=0.8.24; import { IBaseWorld } from "@latticexyz/world/src/codegen/interfaces/IBaseWorld.sol"; +import { IMoveSystem } from "./IMoveSystem.sol"; + /** * @title IWorld * @author MUD (https://mud.dev) by Lattice (https://lattice.xyz) @@ -12,4 +14,4 @@ import { IBaseWorld } from "@latticexyz/world/src/codegen/interfaces/IBaseWorld. * that are dynamically registered in the World during deployment. * @dev This is an autogenerated file; do not edit manually. */ -interface IWorld is IBaseWorld {} +interface IWorld is IBaseWorld, IMoveSystem {} diff --git a/test/mock-game-contracts/src/systems/MoveSystem.sol b/test/mock-game-contracts/src/systems/MoveSystem.sol new file mode 100644 index 0000000000..dc4ef646f4 --- /dev/null +++ b/test/mock-game-contracts/src/systems/MoveSystem.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.24; + +import { System } from "@latticexyz/world/src/System.sol"; +import { Position } from "../codegen/index.sol"; + +contract MoveSystem is System { + function move(int32 x, int32 y) public { + Position.set(_msgSender(), x, y); + } +}