diff --git a/.changeset/famous-carrots-marry.md b/.changeset/famous-carrots-marry.md new file mode 100644 index 0000000000..56f1199381 --- /dev/null +++ b/.changeset/famous-carrots-marry.md @@ -0,0 +1,7 @@ +--- +"create-mud": major +--- + +Replaced the `react` template with a basic task list app using the new Zustand storage adapter and sync method. This new template better demonstrates the different ways of building with MUD and has fewer concepts to learn (i.e. just tables and records, no more ECS). + +For ECS-based React apps, you can use `react-ecs` template for the previous RECS storage adapter. diff --git a/.github/workflows/templates.yml b/.github/workflows/templates.yml index e6d4e56a84..dfa26b9d35 100644 --- a/.github/workflows/templates.yml +++ b/.github/workflows/templates.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - template: [vanilla, react, phaser, threejs] + template: [vanilla, react, react-ecs, phaser, threejs] steps: - name: Checkout diff --git a/packages/create-mud/package.json b/packages/create-mud/package.json index 50ac9ff2c0..1bad1af6a7 100644 --- a/packages/create-mud/package.json +++ b/packages/create-mud/package.json @@ -15,10 +15,11 @@ "clean:js": "rimraf dist", "dev": "tsup --watch", "prepublishOnly": "npm run clean && npm run build", - "test": "pnpm run test:vanilla && pnpm run test:react && pnpm run test:phaser && pnpm run test:threejs", + "test": "pnpm run test:vanilla && pnpm run test:react && pnpm run test:react-ecs && pnpm run test:phaser && pnpm run test:threejs", "test:ci": "pnpm run test", "test:phaser": "dist/cli.js test-project --template phaser && rimraf test-project", "test:react": "dist/cli.js test-project --template react && rimraf test-project", + "test:react-ecs": "dist/cli.js test-project --template react-ecs && rimraf test-project", "test:threejs": "dist/cli.js test-project --template threejs && rimraf test-project", "test:vanilla": "dist/cli.js test-project --template vanilla && rimraf test-project" }, diff --git a/templates/react-ecs/.eslintrc b/templates/react-ecs/.eslintrc new file mode 100644 index 0000000000..79bd6ef23f --- /dev/null +++ b/templates/react-ecs/.eslintrc @@ -0,0 +1,10 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended" + ] +} diff --git a/templates/react-ecs/.gitattributes b/templates/react-ecs/.gitattributes new file mode 100644 index 0000000000..9c70dc52f0 --- /dev/null +++ b/templates/react-ecs/.gitattributes @@ -0,0 +1,3 @@ +# suppress diffs for generated files +**/pnpm-lock.yaml linguist-generated=true +**/codegen/**/*.sol linguist-generated=true diff --git a/templates/react-ecs/.gitignore b/templates/react-ecs/.gitignore new file mode 100644 index 0000000000..b512c09d47 --- /dev/null +++ b/templates/react-ecs/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/templates/react-ecs/.vscode/extensions.json b/templates/react-ecs/.vscode/extensions.json new file mode 100644 index 0000000000..7d5d7da3d9 --- /dev/null +++ b/templates/react-ecs/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["NomicFoundation.hardhat-solidity"] +} diff --git a/templates/react-ecs/.vscode/settings.json b/templates/react-ecs/.vscode/settings.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/templates/react-ecs/.vscode/settings.json @@ -0,0 +1 @@ +{} diff --git a/templates/react-ecs/package.json b/templates/react-ecs/package.json new file mode 100644 index 0000000000..86636fec41 --- /dev/null +++ b/templates/react-ecs/package.json @@ -0,0 +1,27 @@ +{ + "name": "mud-template-react-ecs", + "private": true, + "scripts": { + "build": "pnpm recursive run build", + "dev": "concurrently -n contracts,client -c cyan,magenta \"cd packages/contracts && pnpm run dev\" \"cd packages/client && pnpm run dev\"", + "dev:client": "pnpm --filter 'client' run dev", + "dev:contracts": "pnpm --filter 'contracts' dev", + "foundry:up": "curl -L https://foundry.paradigm.xyz | bash && bash $HOME/.foundry/bin/foundryup", + "mud:up": "pnpm mud set-version --tag main && pnpm install", + "prepare": "(forge --version || pnpm foundry:up)", + "test": "pnpm recursive run test" + }, + "devDependencies": { + "@latticexyz/cli": "link:../../packages/cli", + "@typescript-eslint/eslint-plugin": "5.46.1", + "@typescript-eslint/parser": "5.46.1", + "concurrently": "^8.0.1", + "eslint": "8.29.0", + "rimraf": "^3.0.2", + "typescript": "5.1.6" + }, + "engines": { + "node": "18.x", + "pnpm": "8.x" + } +} diff --git a/templates/react-ecs/packages/client/.env b/templates/react-ecs/packages/client/.env new file mode 100644 index 0000000000..3528db8807 --- /dev/null +++ b/templates/react-ecs/packages/client/.env @@ -0,0 +1 @@ +VITE_CHAIN_ID=31337 diff --git a/templates/react-ecs/packages/client/.eslintrc b/templates/react-ecs/packages/client/.eslintrc new file mode 100644 index 0000000000..930af95967 --- /dev/null +++ b/templates/react-ecs/packages/client/.eslintrc @@ -0,0 +1,7 @@ +{ + "extends": ["../../.eslintrc", "plugin:react/recommended", "plugin:react-hooks/recommended"], + "plugins": ["react", "react-hooks"], + "rules": { + "react/react-in-jsx-scope": "off" + } +} diff --git a/templates/react-ecs/packages/client/.gitignore b/templates/react-ecs/packages/client/.gitignore new file mode 100644 index 0000000000..0ca39c007c --- /dev/null +++ b/templates/react-ecs/packages/client/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.DS_Store diff --git a/templates/react-ecs/packages/client/index.html b/templates/react-ecs/packages/client/index.html new file mode 100644 index 0000000000..c3f06eb806 --- /dev/null +++ b/templates/react-ecs/packages/client/index.html @@ -0,0 +1,12 @@ + + + + + + a minimal MUD client + + +
+ + + diff --git a/templates/react-ecs/packages/client/package.json b/templates/react-ecs/packages/client/package.json new file mode 100644 index 0000000000..952996ffa1 --- /dev/null +++ b/templates/react-ecs/packages/client/package.json @@ -0,0 +1,38 @@ +{ + "name": "client", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "build": "vite build", + "dev": "wait-port localhost:8545 && vite", + "preview": "vite preview", + "test": "tsc --noEmit" + }, + "dependencies": { + "@latticexyz/common": "link:../../../../packages/common", + "@latticexyz/dev-tools": "link:../../../../packages/dev-tools", + "@latticexyz/react": "link:../../../../packages/react", + "@latticexyz/recs": "link:../../../../packages/recs", + "@latticexyz/schema-type": "link:../../../../packages/schema-type", + "@latticexyz/services": "link:../../../../packages/services", + "@latticexyz/store-sync": "link:../../../../packages/store-sync", + "@latticexyz/utils": "link:../../../../packages/utils", + "@latticexyz/world": "link:../../../../packages/world", + "contracts": "workspace:*", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "rxjs": "7.5.5", + "viem": "1.14.0" + }, + "devDependencies": { + "@types/react": "18.2.22", + "@types/react-dom": "18.2.7", + "@vitejs/plugin-react": "^3.1.0", + "eslint-plugin-react": "7.31.11", + "eslint-plugin-react-hooks": "4.6.0", + "vite": "^4.2.1", + "wait-port": "^1.0.4" + } +} diff --git a/templates/react-ecs/packages/client/src/App.tsx b/templates/react-ecs/packages/client/src/App.tsx new file mode 100644 index 0000000000..a9660de463 --- /dev/null +++ b/templates/react-ecs/packages/client/src/App.tsx @@ -0,0 +1,29 @@ +import { useComponentValue } from "@latticexyz/react"; +import { useMUD } from "./MUDContext"; +import { singletonEntity } from "@latticexyz/store-sync/recs"; + +export const App = () => { + const { + components: { Counter }, + systemCalls: { increment }, + } = useMUD(); + + const counter = useComponentValue(Counter, singletonEntity); + + return ( + <> +
+ Counter: {counter?.value ?? "??"} +
+ + + ); +}; diff --git a/templates/react-ecs/packages/client/src/MUDContext.tsx b/templates/react-ecs/packages/client/src/MUDContext.tsx new file mode 100644 index 0000000000..7b5637f6a6 --- /dev/null +++ b/templates/react-ecs/packages/client/src/MUDContext.tsx @@ -0,0 +1,21 @@ +import { createContext, ReactNode, useContext } from "react"; +import { SetupResult } from "./mud/setup"; + +const MUDContext = createContext(null); + +type Props = { + children: ReactNode; + value: SetupResult; +}; + +export const MUDProvider = ({ children, value }: Props) => { + const currentValue = useContext(MUDContext); + if (currentValue) throw new Error("MUDProvider can only be used once"); + return {children}; +}; + +export const useMUD = () => { + const value = useContext(MUDContext); + if (!value) throw new Error("Must be used within a MUDProvider"); + return value; +}; diff --git a/templates/react-ecs/packages/client/src/index.tsx b/templates/react-ecs/packages/client/src/index.tsx new file mode 100644 index 0000000000..da8d70f020 --- /dev/null +++ b/templates/react-ecs/packages/client/src/index.tsx @@ -0,0 +1,34 @@ +import ReactDOM from "react-dom/client"; +import { App } from "./App"; +import { setup } from "./mud/setup"; +import { MUDProvider } from "./MUDContext"; +import mudConfig from "contracts/mud.config"; + +const rootElement = document.getElementById("react-root"); +if (!rootElement) throw new Error("React root not found"); +const root = ReactDOM.createRoot(rootElement); + +// TODO: figure out if we actually want this to be async or if we should render something else in the meantime +setup().then(async (result) => { + root.render( + + + + ); + + // https://vitejs.dev/guide/env-and-mode.html + if (import.meta.env.DEV) { + const { mount: mountDevTools } = await import("@latticexyz/dev-tools"); + mountDevTools({ + config: mudConfig, + publicClient: result.network.publicClient, + walletClient: result.network.walletClient, + latestBlock$: result.network.latestBlock$, + storedBlockLogs$: result.network.storedBlockLogs$, + worldAddress: result.network.worldContract.address, + worldAbi: result.network.worldContract.abi, + write$: result.network.write$, + recsWorld: result.network.world, + }); + } +}); diff --git a/templates/react/packages/client/src/mud/createClientComponents.ts b/templates/react-ecs/packages/client/src/mud/createClientComponents.ts similarity index 100% rename from templates/react/packages/client/src/mud/createClientComponents.ts rename to templates/react-ecs/packages/client/src/mud/createClientComponents.ts diff --git a/templates/react-ecs/packages/client/src/mud/createSystemCalls.ts b/templates/react-ecs/packages/client/src/mud/createSystemCalls.ts new file mode 100644 index 0000000000..09e7807c6f --- /dev/null +++ b/templates/react-ecs/packages/client/src/mud/createSystemCalls.ts @@ -0,0 +1,51 @@ +/* + * Create the system calls that the client can use to ask + * for changes in the World state (using the System contracts). + */ + +import { getComponentValue } from "@latticexyz/recs"; +import { ClientComponents } from "./createClientComponents"; +import { SetupNetworkResult } from "./setupNetwork"; +import { singletonEntity } from "@latticexyz/store-sync/recs"; + +export type SystemCalls = ReturnType; + +export function createSystemCalls( + /* + * The parameter list informs TypeScript that: + * + * - The first parameter is expected to be a + * SetupNetworkResult, as defined in setupNetwork.ts + * + * Out of this parameter, we only care about two fields: + * - worldContract (which comes from getContract, see + * https://github.com/latticexyz/mud/blob/main/templates/react/packages/client/src/mud/setupNetwork.ts#L63-L69). + * + * - waitForTransaction (which comes from syncToRecs, see + * https://github.com/latticexyz/mud/blob/main/templates/react/packages/client/src/mud/setupNetwork.ts#L77-L83). + * + * - From the second parameter, which is a ClientComponent, + * we only care about Counter. This parameter comes to use + * through createClientComponents.ts, but it originates in + * syncToRecs + * (https://github.com/latticexyz/mud/blob/main/templates/react/packages/client/src/mud/setupNetwork.ts#L77-L83). + */ + { worldContract, waitForTransaction }: SetupNetworkResult, + { Counter }: ClientComponents +) { + const increment = async () => { + /* + * Because IncrementSystem + * (https://mud.dev/templates/typescript/contracts#incrementsystemsol) + * is in the root namespace, `.increment` can be called directly + * on the World contract. + */ + const tx = await worldContract.write.increment(); + await waitForTransaction(tx); + return getComponentValue(Counter, singletonEntity); + }; + + return { + increment, + }; +} diff --git a/templates/react-ecs/packages/client/src/mud/getNetworkConfig.ts b/templates/react-ecs/packages/client/src/mud/getNetworkConfig.ts new file mode 100644 index 0000000000..61ad962f25 --- /dev/null +++ b/templates/react-ecs/packages/client/src/mud/getNetworkConfig.ts @@ -0,0 +1,91 @@ +/* + * Network specific configuration for the client. + * By default connect to the anvil test network. + * + */ + +/* + * By default the template just creates a temporary wallet + * (called a burner wallet) and uses a faucet (on our test net) + * to get ETH for it. + * + * See https://mud.dev/tutorials/minimal/deploy#wallet-managed-address + * for how to use the user's own address instead. + */ +import { getBurnerPrivateKey } from "@latticexyz/common"; + +/* + * Import the addresses of the World, possibly on multiple chains, + * from packages/contracts/worlds.json. When the contracts package + * deploys a new `World`, it updates this file. + */ +import worlds from "contracts/worlds.json"; + +/* + * The supported chains. + * By default, there are only two chains here: + * + * - mudFoundry, the chain running on anvil that pnpm dev + * starts by default. It is similar to the viem anvil chain + * (see https://viem.sh/docs/clients/test.html), but with the + * basefee set to zero to avoid transaction fees. + * - latticeTestnet, our public test network. + * + * See https://mud.dev/tutorials/minimal/deploy#run-the-user-interface + * for instructions on how to add networks. + */ +import { supportedChains } from "./supportedChains"; + +export async function getNetworkConfig() { + const params = new URLSearchParams(window.location.search); + + /* + * The chain ID is the first item available from this list: + * 1. chainId query parameter + * 2. chainid query parameter + * 3. The VITE_CHAIN_ID environment variable set when the + * vite dev server was started or client was built + * 4. The default, 31337 (anvil) + */ + const chainId = Number(params.get("chainId") || params.get("chainid") || import.meta.env.VITE_CHAIN_ID || 31337); + + /* + * Find the chain (unless it isn't in the list of supported chains). + */ + const chainIndex = supportedChains.findIndex((c) => c.id === chainId); + const chain = supportedChains[chainIndex]; + if (!chain) { + throw new Error(`Chain ${chainId} not found`); + } + + /* + * Get the address of the World. If you want to use a + * different address than the one in worlds.json, + * provide it as worldAddress in the query string. + */ + const world = worlds[chain.id.toString()]; + const worldAddress = params.get("worldAddress") || world?.address; + if (!worldAddress) { + throw new Error(`No world address found for chain ${chainId}. Did you run \`mud deploy\`?`); + } + + /* + * MUD clients use events to synchronize the database, meaning + * they need to look as far back as when the World was started. + * The block number for the World start can be specified either + * on the URL (as initialBlockNumber) or in the worlds.json + * file. If neither has it, it starts at the first block, zero. + */ + const initialBlockNumber = params.has("initialBlockNumber") + ? Number(params.get("initialBlockNumber")) + : world?.blockNumber ?? 0n; + + return { + privateKey: getBurnerPrivateKey(), + chainId, + chain, + faucetServiceUrl: params.get("faucet") ?? chain.faucetUrl, + worldAddress, + initialBlockNumber, + }; +} diff --git a/templates/react-ecs/packages/client/src/mud/setup.ts b/templates/react-ecs/packages/client/src/mud/setup.ts new file mode 100644 index 0000000000..8f9fdbab34 --- /dev/null +++ b/templates/react-ecs/packages/client/src/mud/setup.ts @@ -0,0 +1,21 @@ +/* + * This file sets up all the definitions required for a MUD client. + */ + +import { createClientComponents } from "./createClientComponents"; +import { createSystemCalls } from "./createSystemCalls"; +import { setupNetwork } from "./setupNetwork"; + +export type SetupResult = Awaited>; + +export async function setup() { + const network = await setupNetwork(); + const components = createClientComponents(network); + const systemCalls = createSystemCalls(network, components); + + return { + network, + components, + systemCalls, + }; +} diff --git a/templates/react-ecs/packages/client/src/mud/setupNetwork.ts b/templates/react-ecs/packages/client/src/mud/setupNetwork.ts new file mode 100644 index 0000000000..7aa0031c4d --- /dev/null +++ b/templates/react-ecs/packages/client/src/mud/setupNetwork.ts @@ -0,0 +1,125 @@ +/* + * The MUD client code is built on top of viem + * (https://viem.sh/docs/getting-started.html). + * This line imports the functions we need from it. + */ +import { createPublicClient, fallback, webSocket, http, createWalletClient, Hex, parseEther, ClientConfig } from "viem"; +import { createFaucetService } from "@latticexyz/services/faucet"; +import { encodeEntity, syncToRecs } from "@latticexyz/store-sync/recs"; + +import { getNetworkConfig } from "./getNetworkConfig"; +import { world } from "./world"; +import IWorldAbi from "contracts/out/IWorld.sol/IWorld.abi.json"; +import { createBurnerAccount, getContract, transportObserver, ContractWrite } from "@latticexyz/common"; + +import { Subject, share } from "rxjs"; + +/* + * Import our MUD config, which includes strong types for + * our tables and other config options. We use this to generate + * things like RECS components and get back strong types for them. + * + * See https://mud.dev/templates/typescript/contracts#mudconfigts + * for the source of this information. + */ +import mudConfig from "contracts/mud.config"; + +export type SetupNetworkResult = Awaited>; + +export async function setupNetwork() { + const networkConfig = await getNetworkConfig(); + + /* + * Create a viem public (read only) client + * (https://viem.sh/docs/clients/public.html) + */ + const clientOptions = { + chain: networkConfig.chain, + transport: transportObserver(fallback([webSocket(), http()])), + pollingInterval: 1000, + } as const satisfies ClientConfig; + + const publicClient = createPublicClient(clientOptions); + + /* + * Create a temporary wallet and a viem client for it + * (see https://viem.sh/docs/clients/wallet.html). + */ + const burnerAccount = createBurnerAccount(networkConfig.privateKey as Hex); + const burnerWalletClient = createWalletClient({ + ...clientOptions, + account: burnerAccount, + }); + + /* + * Create an observable for contract writes that we can + * pass into MUD dev tools for transaction observability. + */ + const write$ = new Subject(); + + /* + * Create an object for communicating with the deployed World. + */ + const worldContract = getContract({ + address: networkConfig.worldAddress as Hex, + abi: IWorldAbi, + publicClient, + walletClient: burnerWalletClient, + onWrite: (write) => write$.next(write), + }); + + /* + * Sync on-chain state into RECS and keeps our client in sync. + * Uses the MUD indexer if available, otherwise falls back + * to the viem publicClient to make RPC calls to fetch MUD + * events from the chain. + */ + const { components, latestBlock$, storedBlockLogs$, waitForTransaction } = await syncToRecs({ + world, + config: mudConfig, + address: networkConfig.worldAddress as Hex, + publicClient, + startBlock: BigInt(networkConfig.initialBlockNumber), + }); + + /* + * If there is a faucet, request (test) ETH if you have + * less than 1 ETH. Repeat every 20 seconds to ensure you don't + * run out. + */ + if (networkConfig.faucetServiceUrl) { + const address = burnerAccount.address; + console.info("[Dev Faucet]: Player address -> ", address); + + const faucet = createFaucetService(networkConfig.faucetServiceUrl); + + const requestDrip = async () => { + const balance = await publicClient.getBalance({ address }); + console.info(`[Dev Faucet]: Player balance -> ${balance}`); + const lowBalance = balance < parseEther("1"); + if (lowBalance) { + console.info("[Dev Faucet]: Balance is low, dripping funds to player"); + // Double drip + await faucet.dripDev({ address }); + await faucet.dripDev({ address }); + } + }; + + requestDrip(); + // Request a drip every 20 seconds + setInterval(requestDrip, 20000); + } + + return { + world, + components, + playerEntity: encodeEntity({ address: "address" }, { address: burnerWalletClient.account.address }), + publicClient, + walletClient: burnerWalletClient, + latestBlock$, + storedBlockLogs$, + waitForTransaction, + worldContract, + write$: write$.asObservable().pipe(share()), + }; +} diff --git a/templates/react-ecs/packages/client/src/mud/supportedChains.ts b/templates/react-ecs/packages/client/src/mud/supportedChains.ts new file mode 100644 index 0000000000..614412500e --- /dev/null +++ b/templates/react-ecs/packages/client/src/mud/supportedChains.ts @@ -0,0 +1,20 @@ +/* + * The supported chains. + * By default, there are only two chains here: + * + * - mudFoundry, the chain running on anvil that pnpm dev + * starts by default. It is similar to the viem anvil chain + * (see https://viem.sh/docs/clients/test.html), but with the + * basefee set to zero to avoid transaction fees. + * - latticeTestnet, our public test network. + * + + */ + +import { MUDChain, latticeTestnet, mudFoundry } from "@latticexyz/common/chains"; + +/* + * See https://mud.dev/tutorials/minimal/deploy#run-the-user-interface + * for instructions on how to add networks. + */ +export const supportedChains: MUDChain[] = [mudFoundry, latticeTestnet]; diff --git a/templates/react/packages/client/src/mud/world.ts b/templates/react-ecs/packages/client/src/mud/world.ts similarity index 100% rename from templates/react/packages/client/src/mud/world.ts rename to templates/react-ecs/packages/client/src/mud/world.ts diff --git a/templates/react-ecs/packages/client/tsconfig.json b/templates/react-ecs/packages/client/tsconfig.json new file mode 100644 index 0000000000..5bf43bace8 --- /dev/null +++ b/templates/react-ecs/packages/client/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "types": ["vite/client"], + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "Node", + "strict": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "noEmit": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "jsxImportSource": "react" + }, + "include": ["src"] +} diff --git a/templates/react-ecs/packages/client/vite.config.ts b/templates/react-ecs/packages/client/vite.config.ts new file mode 100644 index 0000000000..a47d70e7a1 --- /dev/null +++ b/templates/react-ecs/packages/client/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + fs: { + strict: false, + }, + }, + build: { + target: "es2022", + minify: true, + sourcemap: true, + }, +}); diff --git a/templates/react-ecs/packages/contracts/.env b/templates/react-ecs/packages/contracts/.env new file mode 100644 index 0000000000..0f8cc4a305 --- /dev/null +++ b/templates/react-ecs/packages/contracts/.env @@ -0,0 +1,8 @@ +# This .env file is for demonstration purposes only. +# +# This should usually be excluded via .gitignore and the env vars attached to +# your deployment enviroment, but we're including this here for ease of local +# development. Please do not commit changes to this file! +# +# Anvil default private key: +PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 diff --git a/templates/react-ecs/packages/contracts/.gitignore b/templates/react-ecs/packages/contracts/.gitignore new file mode 100644 index 0000000000..aea4e54c82 --- /dev/null +++ b/templates/react-ecs/packages/contracts/.gitignore @@ -0,0 +1,9 @@ +out/ +cache/ +node_modules/ +bindings/ +artifacts/ +broadcast/ + +# Ignore MUD deploy artifacts +deploys/**/*.json diff --git a/templates/react-ecs/packages/contracts/.prettierrc b/templates/react-ecs/packages/contracts/.prettierrc new file mode 100644 index 0000000000..bf689e8c48 --- /dev/null +++ b/templates/react-ecs/packages/contracts/.prettierrc @@ -0,0 +1,8 @@ +{ + "plugins": ["prettier-plugin-solidity"], + "printWidth": 120, + "semi": true, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true +} diff --git a/templates/react-ecs/packages/contracts/.solhint.json b/templates/react-ecs/packages/contracts/.solhint.json new file mode 100644 index 0000000000..f3e0b01ffc --- /dev/null +++ b/templates/react-ecs/packages/contracts/.solhint.json @@ -0,0 +1,12 @@ +{ + "extends": ["solhint:recommended", "mud"], + "plugins": ["mud"], + "rules": { + "compiler-version": ["error", ">=0.8.0"], + "avoid-low-level-calls": "off", + "no-inline-assembly": "off", + "func-visibility": ["warn", { "ignoreConstructors": true }], + "no-empty-blocks": "off", + "no-complex-fallback": "off" + } +} diff --git a/templates/react-ecs/packages/contracts/foundry.toml b/templates/react-ecs/packages/contracts/foundry.toml new file mode 100644 index 0000000000..d03fad8043 --- /dev/null +++ b/templates/react-ecs/packages/contracts/foundry.toml @@ -0,0 +1,26 @@ +[profile.default] +solc = "0.8.21" +ffi = false +fuzz_runs = 256 +optimizer = true +optimizer_runs = 3000 +verbosity = 2 +src = "src" +test = "test" +out = "out" +allow_paths = [ + # pnpm symlinks to the project root's node_modules + "../../node_modules", + # template uses linked mud packages from within the mud monorepo + "../../../../packages", + # projects created from this template and using linked mud packages + "../../../mud/packages", +] +extra_output_files = [ + "abi", + "evm.bytecode" +] +fs_permissions = [{ access = "read", path = "./"}] + +[profile.lattice-testnet] +eth_rpc_url = "https://follower.testnet-chain.linfra.xyz" diff --git a/templates/react-ecs/packages/contracts/mud.config.ts b/templates/react-ecs/packages/contracts/mud.config.ts new file mode 100644 index 0000000000..70edda1693 --- /dev/null +++ b/templates/react-ecs/packages/contracts/mud.config.ts @@ -0,0 +1,10 @@ +import { mudConfig } from "@latticexyz/world/register"; + +export default mudConfig({ + tables: { + Counter: { + keySchema: {}, + valueSchema: "uint32", + }, + }, +}); diff --git a/templates/react-ecs/packages/contracts/package.json b/templates/react-ecs/packages/contracts/package.json new file mode 100644 index 0000000000..580cb610f0 --- /dev/null +++ b/templates/react-ecs/packages/contracts/package.json @@ -0,0 +1,36 @@ +{ + "name": "contracts", + "version": "0.0.0", + "private": true, + "license": "MIT", + "scripts": { + "build": "pnpm run build:mud && pnpm run build:abi && pnpm run build:abi-ts", + "build:abi": "forge build", + "build:abi-ts": "mud abi-ts", + "build:mud": "rimraf src/codegen && mud tablegen && mud worldgen", + "deploy:local": "pnpm run build && mud deploy", + "deploy:testnet": "pnpm run build && mud deploy --profile=lattice-testnet", + "dev": "pnpm mud dev-contracts", + "lint": "pnpm run prettier && pnpm run solhint", + "prettier": "prettier --write 'src/**/*.sol'", + "solhint": "solhint --config ./.solhint.json 'src/**/*.sol' --fix", + "test": "tsc --noEmit && mud test" + }, + "dependencies": { + "@latticexyz/cli": "link:../../../../packages/cli", + "@latticexyz/schema-type": "link:../../../../packages/schema-type", + "@latticexyz/store": "link:../../../../packages/store", + "@latticexyz/world": "link:../../../../packages/world", + "@latticexyz/world-modules": "link:../../../../packages/world-modules" + }, + "devDependencies": { + "@types/node": "^18.15.11", + "ds-test": "https://github.com/dapphub/ds-test.git#e282159d5170298eb2455a6c05280ab5a73a4ef0", + "forge-std": "https://github.com/foundry-rs/forge-std.git#74cfb77e308dd188d2f58864aaf44963ae6b88b1", + "prettier": "^2.6.2", + "prettier-plugin-solidity": "^1.0.0-beta.19", + "solhint": "^3.3.7", + "solhint-config-mud": "file:../../../../packages/solhint-config-mud", + "solhint-plugin-mud": "file:../../../../packages/solhint-plugin-mud" + } +} diff --git a/templates/react-ecs/packages/contracts/remappings.txt b/templates/react-ecs/packages/contracts/remappings.txt new file mode 100644 index 0000000000..c4d992480e --- /dev/null +++ b/templates/react-ecs/packages/contracts/remappings.txt @@ -0,0 +1,3 @@ +ds-test/=node_modules/ds-test/src/ +forge-std/=node_modules/forge-std/src/ +@latticexyz/=node_modules/@latticexyz/ diff --git a/templates/react-ecs/packages/contracts/script/PostDeploy.s.sol b/templates/react-ecs/packages/contracts/script/PostDeploy.s.sol new file mode 100644 index 0000000000..1205560c79 --- /dev/null +++ b/templates/react-ecs/packages/contracts/script/PostDeploy.s.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.21; + +import { Script } from "forge-std/Script.sol"; +import { console } from "forge-std/console.sol"; +import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol"; + +import { IWorld } from "../src/codegen/world/IWorld.sol"; + +contract PostDeploy is Script { + function run(address worldAddress) external { + // Specify a store so that you can use tables directly in PostDeploy + StoreSwitch.setStoreAddress(worldAddress); + + // Load the private key from the `PRIVATE_KEY` environment variable (in .env) + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + + // Start broadcasting transactions from the deployer account + vm.startBroadcast(deployerPrivateKey); + + // ------------------ EXAMPLES ------------------ + + // Call increment on the world via the registered function selector + uint32 newValue = IWorld(worldAddress).increment(); + console.log("Increment via IWorld:", newValue); + + vm.stopBroadcast(); + } +} diff --git a/templates/react-ecs/packages/contracts/src/codegen/index.sol b/templates/react-ecs/packages/contracts/src/codegen/index.sol new file mode 100644 index 0000000000..3ac2e18d1d --- /dev/null +++ b/templates/react-ecs/packages/contracts/src/codegen/index.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.21; + +/* Autogenerated file. Do not edit manually. */ + +import { Counter, CounterTableId } from "./tables/Counter.sol"; diff --git a/templates/react/packages/contracts/src/codegen/tables/Counter.sol b/templates/react-ecs/packages/contracts/src/codegen/tables/Counter.sol similarity index 100% rename from templates/react/packages/contracts/src/codegen/tables/Counter.sol rename to templates/react-ecs/packages/contracts/src/codegen/tables/Counter.sol diff --git a/templates/react/packages/contracts/src/codegen/world/IIncrementSystem.sol b/templates/react-ecs/packages/contracts/src/codegen/world/IIncrementSystem.sol similarity index 100% rename from templates/react/packages/contracts/src/codegen/world/IIncrementSystem.sol rename to templates/react-ecs/packages/contracts/src/codegen/world/IIncrementSystem.sol diff --git a/templates/react-ecs/packages/contracts/src/codegen/world/IWorld.sol b/templates/react-ecs/packages/contracts/src/codegen/world/IWorld.sol new file mode 100644 index 0000000000..9798d299cd --- /dev/null +++ b/templates/react-ecs/packages/contracts/src/codegen/world/IWorld.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.21; + +/* Autogenerated file. Do not edit manually. */ + +import { IBaseWorld } from "@latticexyz/world/src/codegen/interfaces/IBaseWorld.sol"; + +import { IIncrementSystem } from "./IIncrementSystem.sol"; + +/** + * @title IWorld + * @notice This interface integrates all systems and associated function selectors + * that are dynamically registered in the World during deployment. + * @dev This is an autogenerated file; do not edit manually. + */ +interface IWorld is IBaseWorld, IIncrementSystem { + +} diff --git a/templates/react/packages/contracts/src/systems/IncrementSystem.sol b/templates/react-ecs/packages/contracts/src/systems/IncrementSystem.sol similarity index 100% rename from templates/react/packages/contracts/src/systems/IncrementSystem.sol rename to templates/react-ecs/packages/contracts/src/systems/IncrementSystem.sol diff --git a/templates/react/packages/contracts/test/CounterTest.t.sol b/templates/react-ecs/packages/contracts/test/CounterTest.t.sol similarity index 100% rename from templates/react/packages/contracts/test/CounterTest.t.sol rename to templates/react-ecs/packages/contracts/test/CounterTest.t.sol diff --git a/templates/react-ecs/packages/contracts/tsconfig.json b/templates/react-ecs/packages/contracts/tsconfig.json new file mode 100644 index 0000000000..270db8fdf6 --- /dev/null +++ b/templates/react-ecs/packages/contracts/tsconfig.json @@ -0,0 +1,13 @@ +// Visit https://aka.ms/tsconfig.json for all config options +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "strict": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node" + } +} diff --git a/templates/react-ecs/packages/contracts/worlds.json b/templates/react-ecs/packages/contracts/worlds.json new file mode 100644 index 0000000000..bd48105fd7 --- /dev/null +++ b/templates/react-ecs/packages/contracts/worlds.json @@ -0,0 +1,5 @@ +{ + "31337": { + "address": "0x5FbDB2315678afecb367f032d93F642f64180aa3" + } +} \ No newline at end of file diff --git a/templates/react-ecs/packages/contracts/worlds.json.d.ts b/templates/react-ecs/packages/contracts/worlds.json.d.ts new file mode 100644 index 0000000000..90ffc786f4 --- /dev/null +++ b/templates/react-ecs/packages/contracts/worlds.json.d.ts @@ -0,0 +1,2 @@ +declare const worlds: Partial>; +export default worlds; diff --git a/templates/react-ecs/pnpm-workspace.yaml b/templates/react-ecs/pnpm-workspace.yaml new file mode 100644 index 0000000000..924b55f42e --- /dev/null +++ b/templates/react-ecs/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/templates/react/packages/client/package.json b/templates/react/packages/client/package.json index 952996ffa1..54e18b5348 100644 --- a/templates/react/packages/client/package.json +++ b/templates/react/packages/client/package.json @@ -14,7 +14,6 @@ "@latticexyz/common": "link:../../../../packages/common", "@latticexyz/dev-tools": "link:../../../../packages/dev-tools", "@latticexyz/react": "link:../../../../packages/react", - "@latticexyz/recs": "link:../../../../packages/recs", "@latticexyz/schema-type": "link:../../../../packages/schema-type", "@latticexyz/services": "link:../../../../packages/services", "@latticexyz/store-sync": "link:../../../../packages/store-sync", diff --git a/templates/react/packages/client/src/App.tsx b/templates/react/packages/client/src/App.tsx index a9660de463..5c672909d2 100644 --- a/templates/react/packages/client/src/App.tsx +++ b/templates/react/packages/client/src/App.tsx @@ -1,29 +1,103 @@ -import { useComponentValue } from "@latticexyz/react"; import { useMUD } from "./MUDContext"; -import { singletonEntity } from "@latticexyz/store-sync/recs"; export const App = () => { const { - components: { Counter }, - systemCalls: { increment }, + network: { tables, useStore }, + systemCalls: { addTask, toggleTask, deleteTask }, } = useMUD(); - const counter = useComponentValue(Counter, singletonEntity); + const tasks = useStore((state) => { + const records = Object.values(state.getRecords(tables.Tasks)); + records.sort((a, b) => Number(a.value.createdAt - b.value.createdAt)); + return records; + }); return ( <> -
- Counter: {counter?.value ?? "??"} -
- + + + {tasks.map((task) => ( + + + + + + ))} + + + + + + + +
+ 0n} + title={task.value.completedAt === 0n ? "Mark task as completed" : "Mark task as incomplete"} + onChange={async (event) => { + event.preventDefault(); + const checkbox = event.currentTarget; + + checkbox.disabled = true; + try { + await toggleTask(task.key.key); + } finally { + checkbox.disabled = false; + } + }} + /> + {task.value.completedAt > 0n ? {task.value.description} : <>{task.value.description}} + +
+ + +
{ + event.preventDefault(); + const form = event.currentTarget; + const fieldset = form.querySelector("fieldset"); + if (!(fieldset instanceof HTMLFieldSetElement)) return; + + const formData = new FormData(form); + const desc = formData.get("description"); + if (typeof desc !== "string") return; + + fieldset.disabled = true; + try { + await addTask(desc); + form.reset(); + } finally { + fieldset.disabled = false; + } + }} + > +
+ {" "} + +
+
+
); }; diff --git a/templates/react/packages/client/src/index.tsx b/templates/react/packages/client/src/index.tsx index da8d70f020..b4de3825ed 100644 --- a/templates/react/packages/client/src/index.tsx +++ b/templates/react/packages/client/src/index.tsx @@ -28,7 +28,6 @@ setup().then(async (result) => { worldAddress: result.network.worldContract.address, worldAbi: result.network.worldContract.abi, write$: result.network.write$, - recsWorld: result.network.world, }); } }); diff --git a/templates/react/packages/client/src/mud/createSystemCalls.ts b/templates/react/packages/client/src/mud/createSystemCalls.ts index 09e7807c6f..7f9cb42e04 100644 --- a/templates/react/packages/client/src/mud/createSystemCalls.ts +++ b/templates/react/packages/client/src/mud/createSystemCalls.ts @@ -3,10 +3,8 @@ * for changes in the World state (using the System contracts). */ -import { getComponentValue } from "@latticexyz/recs"; -import { ClientComponents } from "./createClientComponents"; +import { Hex } from "viem"; import { SetupNetworkResult } from "./setupNetwork"; -import { singletonEntity } from "@latticexyz/store-sync/recs"; export type SystemCalls = ReturnType; @@ -30,22 +28,27 @@ export function createSystemCalls( * syncToRecs * (https://github.com/latticexyz/mud/blob/main/templates/react/packages/client/src/mud/setupNetwork.ts#L77-L83). */ - { worldContract, waitForTransaction }: SetupNetworkResult, - { Counter }: ClientComponents + { tables, useStore, worldContract, waitForTransaction }: SetupNetworkResult ) { - const increment = async () => { - /* - * Because IncrementSystem - * (https://mud.dev/templates/typescript/contracts#incrementsystemsol) - * is in the root namespace, `.increment` can be called directly - * on the World contract. - */ - const tx = await worldContract.write.increment(); + const addTask = async (label: string) => { + const tx = await worldContract.write.addTask([label]); + await waitForTransaction(tx); + }; + + const toggleTask = async (key: Hex) => { + const isComplete = (useStore.getState().getValue(tables.Tasks, { key })?.completedAt ?? 0n) > 0n; + const tx = isComplete ? await worldContract.write.resetTask([key]) : await worldContract.write.completeTask([key]); + await waitForTransaction(tx); + }; + + const deleteTask = async (key: Hex) => { + const tx = await worldContract.write.deleteTask([key]); await waitForTransaction(tx); - return getComponentValue(Counter, singletonEntity); }; return { - increment, + addTask, + toggleTask, + deleteTask, }; } diff --git a/templates/react/packages/client/src/mud/setup.ts b/templates/react/packages/client/src/mud/setup.ts index 8f9fdbab34..6d07134612 100644 --- a/templates/react/packages/client/src/mud/setup.ts +++ b/templates/react/packages/client/src/mud/setup.ts @@ -2,7 +2,6 @@ * This file sets up all the definitions required for a MUD client. */ -import { createClientComponents } from "./createClientComponents"; import { createSystemCalls } from "./createSystemCalls"; import { setupNetwork } from "./setupNetwork"; @@ -10,12 +9,10 @@ export type SetupResult = Awaited>; export async function setup() { const network = await setupNetwork(); - const components = createClientComponents(network); - const systemCalls = createSystemCalls(network, components); + const systemCalls = createSystemCalls(network); return { network, - components, systemCalls, }; } diff --git a/templates/react/packages/client/src/mud/setupNetwork.ts b/templates/react/packages/client/src/mud/setupNetwork.ts index 7aa0031c4d..5cd26c31b1 100644 --- a/templates/react/packages/client/src/mud/setupNetwork.ts +++ b/templates/react/packages/client/src/mud/setupNetwork.ts @@ -5,13 +5,10 @@ */ import { createPublicClient, fallback, webSocket, http, createWalletClient, Hex, parseEther, ClientConfig } from "viem"; import { createFaucetService } from "@latticexyz/services/faucet"; -import { encodeEntity, syncToRecs } from "@latticexyz/store-sync/recs"; - +import { syncToZustand } from "@latticexyz/store-sync/zustand"; import { getNetworkConfig } from "./getNetworkConfig"; -import { world } from "./world"; import IWorldAbi from "contracts/out/IWorld.sol/IWorld.abi.json"; import { createBurnerAccount, getContract, transportObserver, ContractWrite } from "@latticexyz/common"; - import { Subject, share } from "rxjs"; /* @@ -74,8 +71,7 @@ export async function setupNetwork() { * to the viem publicClient to make RPC calls to fetch MUD * events from the chain. */ - const { components, latestBlock$, storedBlockLogs$, waitForTransaction } = await syncToRecs({ - world, + const { tables, useStore, latestBlock$, storedBlockLogs$, waitForTransaction } = await syncToZustand({ config: mudConfig, address: networkConfig.worldAddress as Hex, publicClient, @@ -111,9 +107,8 @@ export async function setupNetwork() { } return { - world, - components, - playerEntity: encodeEntity({ address: "address" }, { address: burnerWalletClient.account.address }), + tables, + useStore, publicClient, walletClient: burnerWalletClient, latestBlock$, diff --git a/templates/react/packages/contracts/mud.config.ts b/templates/react/packages/contracts/mud.config.ts index 70edda1693..b10f2d5893 100644 --- a/templates/react/packages/contracts/mud.config.ts +++ b/templates/react/packages/contracts/mud.config.ts @@ -2,9 +2,12 @@ import { mudConfig } from "@latticexyz/world/register"; export default mudConfig({ tables: { - Counter: { - keySchema: {}, - valueSchema: "uint32", + Tasks: { + valueSchema: { + createdAt: "uint256", + completedAt: "uint256", + description: "string", + }, }, }, }); diff --git a/templates/react/packages/contracts/script/PostDeploy.s.sol b/templates/react/packages/contracts/script/PostDeploy.s.sol index 1205560c79..5366063ee1 100644 --- a/templates/react/packages/contracts/script/PostDeploy.s.sol +++ b/templates/react/packages/contracts/script/PostDeploy.s.sol @@ -6,6 +6,7 @@ import { console } from "forge-std/console.sol"; import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol"; import { IWorld } from "../src/codegen/world/IWorld.sol"; +import { Tasks, TasksData } from "../src/codegen/index.sol"; contract PostDeploy is Script { function run(address worldAddress) external { @@ -18,11 +19,14 @@ contract PostDeploy is Script { // Start broadcasting transactions from the deployer account vm.startBroadcast(deployerPrivateKey); - // ------------------ EXAMPLES ------------------ + // We can set table records directly + Tasks.set("1", TasksData({ description: "Walk the dog", createdAt: block.timestamp, completedAt: 0 })); - // Call increment on the world via the registered function selector - uint32 newValue = IWorld(worldAddress).increment(); - console.log("Increment via IWorld:", newValue); + // Or we can call our own systems + IWorld(worldAddress).addTask("Take out the trash"); + + bytes32 key = IWorld(worldAddress).addTask("Do the dishes"); + IWorld(worldAddress).completeTask(key); vm.stopBroadcast(); } diff --git a/templates/react/packages/contracts/src/codegen/index.sol b/templates/react/packages/contracts/src/codegen/index.sol index 3ac2e18d1d..03c4eefba0 100644 --- a/templates/react/packages/contracts/src/codegen/index.sol +++ b/templates/react/packages/contracts/src/codegen/index.sol @@ -3,4 +3,4 @@ pragma solidity >=0.8.21; /* Autogenerated file. Do not edit manually. */ -import { Counter, CounterTableId } from "./tables/Counter.sol"; +import { Tasks, TasksData, TasksTableId } from "./tables/Tasks.sol"; diff --git a/templates/react/packages/contracts/src/codegen/tables/Tasks.sol b/templates/react/packages/contracts/src/codegen/tables/Tasks.sol new file mode 100644 index 0000000000..ff0552ef06 --- /dev/null +++ b/templates/react/packages/contracts/src/codegen/tables/Tasks.sol @@ -0,0 +1,556 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.21; + +/* Autogenerated file. Do not edit manually. */ + +// Import schema type +import { SchemaType } from "@latticexyz/schema-type/src/solidity/SchemaType.sol"; + +// Import store internals +import { IStore } from "@latticexyz/store/src/IStore.sol"; +import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol"; +import { StoreCore } from "@latticexyz/store/src/StoreCore.sol"; +import { Bytes } from "@latticexyz/store/src/Bytes.sol"; +import { Memory } from "@latticexyz/store/src/Memory.sol"; +import { SliceLib } from "@latticexyz/store/src/Slice.sol"; +import { EncodeArray } from "@latticexyz/store/src/tightcoder/EncodeArray.sol"; +import { FieldLayout, FieldLayoutLib } from "@latticexyz/store/src/FieldLayout.sol"; +import { Schema, SchemaLib } from "@latticexyz/store/src/Schema.sol"; +import { PackedCounter, PackedCounterLib } from "@latticexyz/store/src/PackedCounter.sol"; +import { ResourceId } from "@latticexyz/store/src/ResourceId.sol"; +import { RESOURCE_TABLE, RESOURCE_OFFCHAIN_TABLE } from "@latticexyz/store/src/storeResourceTypes.sol"; + +ResourceId constant _tableId = ResourceId.wrap( + bytes32(abi.encodePacked(RESOURCE_TABLE, bytes14(""), bytes16("Tasks"))) +); +ResourceId constant TasksTableId = _tableId; + +FieldLayout constant _fieldLayout = FieldLayout.wrap( + 0x0040020120200000000000000000000000000000000000000000000000000000 +); + +struct TasksData { + uint256 createdAt; + uint256 completedAt; + string description; +} + +library Tasks { + /** + * @notice Get the table values' field layout. + * @return _fieldLayout The field layout for the table. + */ + function getFieldLayout() internal pure returns (FieldLayout) { + return _fieldLayout; + } + + /** + * @notice Get the table's key schema. + * @return _keySchema The key schema for the table. + */ + function getKeySchema() internal pure returns (Schema) { + SchemaType[] memory _keySchema = new SchemaType[](1); + _keySchema[0] = SchemaType.BYTES32; + + return SchemaLib.encode(_keySchema); + } + + /** + * @notice Get the table's value schema. + * @return _valueSchema The value schema for the table. + */ + function getValueSchema() internal pure returns (Schema) { + SchemaType[] memory _valueSchema = new SchemaType[](3); + _valueSchema[0] = SchemaType.UINT256; + _valueSchema[1] = SchemaType.UINT256; + _valueSchema[2] = SchemaType.STRING; + + return SchemaLib.encode(_valueSchema); + } + + /** + * @notice Get the table's key field names. + * @return keyNames An array of strings with the names of key fields. + */ + function getKeyNames() internal pure returns (string[] memory keyNames) { + keyNames = new string[](1); + keyNames[0] = "key"; + } + + /** + * @notice Get the table's value field names. + * @return fieldNames An array of strings with the names of value fields. + */ + function getFieldNames() internal pure returns (string[] memory fieldNames) { + fieldNames = new string[](3); + fieldNames[0] = "createdAt"; + fieldNames[1] = "completedAt"; + fieldNames[2] = "description"; + } + + /** + * @notice Register the table with its config. + */ + function register() internal { + StoreSwitch.registerTable(_tableId, _fieldLayout, getKeySchema(), getValueSchema(), getKeyNames(), getFieldNames()); + } + + /** + * @notice Register the table with its config. + */ + function _register() internal { + StoreCore.registerTable(_tableId, _fieldLayout, getKeySchema(), getValueSchema(), getKeyNames(), getFieldNames()); + } + + /** + * @notice Get createdAt. + */ + function getCreatedAt(bytes32 key) internal view returns (uint256 createdAt) { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + bytes32 _blob = StoreSwitch.getStaticField(_tableId, _keyTuple, 0, _fieldLayout); + return (uint256(bytes32(_blob))); + } + + /** + * @notice Get createdAt. + */ + function _getCreatedAt(bytes32 key) internal view returns (uint256 createdAt) { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + bytes32 _blob = StoreCore.getStaticField(_tableId, _keyTuple, 0, _fieldLayout); + return (uint256(bytes32(_blob))); + } + + /** + * @notice Set createdAt. + */ + function setCreatedAt(bytes32 key, uint256 createdAt) internal { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + StoreSwitch.setStaticField(_tableId, _keyTuple, 0, abi.encodePacked((createdAt)), _fieldLayout); + } + + /** + * @notice Set createdAt. + */ + function _setCreatedAt(bytes32 key, uint256 createdAt) internal { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + StoreCore.setStaticField(_tableId, _keyTuple, 0, abi.encodePacked((createdAt)), _fieldLayout); + } + + /** + * @notice Get completedAt. + */ + function getCompletedAt(bytes32 key) internal view returns (uint256 completedAt) { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + bytes32 _blob = StoreSwitch.getStaticField(_tableId, _keyTuple, 1, _fieldLayout); + return (uint256(bytes32(_blob))); + } + + /** + * @notice Get completedAt. + */ + function _getCompletedAt(bytes32 key) internal view returns (uint256 completedAt) { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + bytes32 _blob = StoreCore.getStaticField(_tableId, _keyTuple, 1, _fieldLayout); + return (uint256(bytes32(_blob))); + } + + /** + * @notice Set completedAt. + */ + function setCompletedAt(bytes32 key, uint256 completedAt) internal { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + StoreSwitch.setStaticField(_tableId, _keyTuple, 1, abi.encodePacked((completedAt)), _fieldLayout); + } + + /** + * @notice Set completedAt. + */ + function _setCompletedAt(bytes32 key, uint256 completedAt) internal { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + StoreCore.setStaticField(_tableId, _keyTuple, 1, abi.encodePacked((completedAt)), _fieldLayout); + } + + /** + * @notice Get description. + */ + function getDescription(bytes32 key) internal view returns (string memory description) { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + bytes memory _blob = StoreSwitch.getDynamicField(_tableId, _keyTuple, 0); + return (string(_blob)); + } + + /** + * @notice Get description. + */ + function _getDescription(bytes32 key) internal view returns (string memory description) { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + bytes memory _blob = StoreCore.getDynamicField(_tableId, _keyTuple, 0); + return (string(_blob)); + } + + /** + * @notice Set description. + */ + function setDescription(bytes32 key, string memory description) internal { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + StoreSwitch.setDynamicField(_tableId, _keyTuple, 0, bytes((description))); + } + + /** + * @notice Set description. + */ + function _setDescription(bytes32 key, string memory description) internal { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + StoreCore.setDynamicField(_tableId, _keyTuple, 0, bytes((description))); + } + + /** + * @notice Get the length of description. + */ + function lengthDescription(bytes32 key) internal view returns (uint256) { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + uint256 _byteLength = StoreSwitch.getDynamicFieldLength(_tableId, _keyTuple, 0); + unchecked { + return _byteLength / 1; + } + } + + /** + * @notice Get the length of description. + */ + function _lengthDescription(bytes32 key) internal view returns (uint256) { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + uint256 _byteLength = StoreCore.getDynamicFieldLength(_tableId, _keyTuple, 0); + unchecked { + return _byteLength / 1; + } + } + + /** + * @notice Get an item of description. + * @dev Reverts with Store_IndexOutOfBounds if `_index` is out of bounds for the array. + */ + function getItemDescription(bytes32 key, uint256 _index) internal view returns (string memory) { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + unchecked { + bytes memory _blob = StoreSwitch.getDynamicFieldSlice(_tableId, _keyTuple, 0, _index * 1, (_index + 1) * 1); + return (string(_blob)); + } + } + + /** + * @notice Get an item of description. + * @dev Reverts with Store_IndexOutOfBounds if `_index` is out of bounds for the array. + */ + function _getItemDescription(bytes32 key, uint256 _index) internal view returns (string memory) { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + unchecked { + bytes memory _blob = StoreCore.getDynamicFieldSlice(_tableId, _keyTuple, 0, _index * 1, (_index + 1) * 1); + return (string(_blob)); + } + } + + /** + * @notice Push a slice to description. + */ + function pushDescription(bytes32 key, string memory _slice) internal { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + StoreSwitch.pushToDynamicField(_tableId, _keyTuple, 0, bytes((_slice))); + } + + /** + * @notice Push a slice to description. + */ + function _pushDescription(bytes32 key, string memory _slice) internal { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + StoreCore.pushToDynamicField(_tableId, _keyTuple, 0, bytes((_slice))); + } + + /** + * @notice Pop a slice from description. + */ + function popDescription(bytes32 key) internal { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + StoreSwitch.popFromDynamicField(_tableId, _keyTuple, 0, 1); + } + + /** + * @notice Pop a slice from description. + */ + function _popDescription(bytes32 key) internal { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + StoreCore.popFromDynamicField(_tableId, _keyTuple, 0, 1); + } + + /** + * @notice Update a slice of description at `_index`. + */ + function updateDescription(bytes32 key, uint256 _index, string memory _slice) internal { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + unchecked { + bytes memory _encoded = bytes((_slice)); + StoreSwitch.spliceDynamicData(_tableId, _keyTuple, 0, uint40(_index * 1), uint40(_encoded.length), _encoded); + } + } + + /** + * @notice Update a slice of description at `_index`. + */ + function _updateDescription(bytes32 key, uint256 _index, string memory _slice) internal { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + unchecked { + bytes memory _encoded = bytes((_slice)); + StoreCore.spliceDynamicData(_tableId, _keyTuple, 0, uint40(_index * 1), uint40(_encoded.length), _encoded); + } + } + + /** + * @notice Get the full data. + */ + function get(bytes32 key) internal view returns (TasksData memory _table) { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + (bytes memory _staticData, PackedCounter _encodedLengths, bytes memory _dynamicData) = StoreSwitch.getRecord( + _tableId, + _keyTuple, + _fieldLayout + ); + return decode(_staticData, _encodedLengths, _dynamicData); + } + + /** + * @notice Get the full data. + */ + function _get(bytes32 key) internal view returns (TasksData memory _table) { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + (bytes memory _staticData, PackedCounter _encodedLengths, bytes memory _dynamicData) = StoreCore.getRecord( + _tableId, + _keyTuple, + _fieldLayout + ); + return decode(_staticData, _encodedLengths, _dynamicData); + } + + /** + * @notice Set the full data using individual values. + */ + function set(bytes32 key, uint256 createdAt, uint256 completedAt, string memory description) internal { + bytes memory _staticData = encodeStatic(createdAt, completedAt); + + PackedCounter _encodedLengths = encodeLengths(description); + bytes memory _dynamicData = encodeDynamic(description); + + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + StoreSwitch.setRecord(_tableId, _keyTuple, _staticData, _encodedLengths, _dynamicData); + } + + /** + * @notice Set the full data using individual values. + */ + function _set(bytes32 key, uint256 createdAt, uint256 completedAt, string memory description) internal { + bytes memory _staticData = encodeStatic(createdAt, completedAt); + + PackedCounter _encodedLengths = encodeLengths(description); + bytes memory _dynamicData = encodeDynamic(description); + + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + StoreCore.setRecord(_tableId, _keyTuple, _staticData, _encodedLengths, _dynamicData, _fieldLayout); + } + + /** + * @notice Set the full data using the data struct. + */ + function set(bytes32 key, TasksData memory _table) internal { + bytes memory _staticData = encodeStatic(_table.createdAt, _table.completedAt); + + PackedCounter _encodedLengths = encodeLengths(_table.description); + bytes memory _dynamicData = encodeDynamic(_table.description); + + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + StoreSwitch.setRecord(_tableId, _keyTuple, _staticData, _encodedLengths, _dynamicData); + } + + /** + * @notice Set the full data using the data struct. + */ + function _set(bytes32 key, TasksData memory _table) internal { + bytes memory _staticData = encodeStatic(_table.createdAt, _table.completedAt); + + PackedCounter _encodedLengths = encodeLengths(_table.description); + bytes memory _dynamicData = encodeDynamic(_table.description); + + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + StoreCore.setRecord(_tableId, _keyTuple, _staticData, _encodedLengths, _dynamicData, _fieldLayout); + } + + /** + * @notice Decode the tightly packed blob of static data using this table's field layout. + */ + function decodeStatic(bytes memory _blob) internal pure returns (uint256 createdAt, uint256 completedAt) { + createdAt = (uint256(Bytes.slice32(_blob, 0))); + + completedAt = (uint256(Bytes.slice32(_blob, 32))); + } + + /** + * @notice Decode the tightly packed blob of dynamic data using the encoded lengths. + */ + function decodeDynamic( + PackedCounter _encodedLengths, + bytes memory _blob + ) internal pure returns (string memory description) { + uint256 _start; + uint256 _end; + unchecked { + _end = _encodedLengths.atIndex(0); + } + description = (string(SliceLib.getSubslice(_blob, _start, _end).toBytes())); + } + + /** + * @notice Decode the tightly packed blobs using this table's field layout. + * @param _staticData Tightly packed static fields. + * @param _encodedLengths Encoded lengths of dynamic fields. + * @param _dynamicData Tightly packed dynamic fields. + */ + function decode( + bytes memory _staticData, + PackedCounter _encodedLengths, + bytes memory _dynamicData + ) internal pure returns (TasksData memory _table) { + (_table.createdAt, _table.completedAt) = decodeStatic(_staticData); + + (_table.description) = decodeDynamic(_encodedLengths, _dynamicData); + } + + /** + * @notice Delete all data for given keys. + */ + function deleteRecord(bytes32 key) internal { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + StoreSwitch.deleteRecord(_tableId, _keyTuple); + } + + /** + * @notice Delete all data for given keys. + */ + function _deleteRecord(bytes32 key) internal { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + StoreCore.deleteRecord(_tableId, _keyTuple, _fieldLayout); + } + + /** + * @notice Tightly pack static (fixed length) data using this table's schema. + * @return The static data, encoded into a sequence of bytes. + */ + function encodeStatic(uint256 createdAt, uint256 completedAt) internal pure returns (bytes memory) { + return abi.encodePacked(createdAt, completedAt); + } + + /** + * @notice Tightly pack dynamic data lengths using this table's schema. + * @return _encodedLengths The lengths of the dynamic fields (packed into a single bytes32 value). + */ + function encodeLengths(string memory description) internal pure returns (PackedCounter _encodedLengths) { + // Lengths are effectively checked during copy by 2**40 bytes exceeding gas limits + unchecked { + _encodedLengths = PackedCounterLib.pack(bytes(description).length); + } + } + + /** + * @notice Tightly pack dynamic (variable length) data using this table's schema. + * @return The dynamic data, encoded into a sequence of bytes. + */ + function encodeDynamic(string memory description) internal pure returns (bytes memory) { + return abi.encodePacked(bytes((description))); + } + + /** + * @notice Encode all of a record's fields. + * @return The static (fixed length) data, encoded into a sequence of bytes. + * @return The lengths of the dynamic fields (packed into a single bytes32 value). + * @return The dyanmic (variable length) data, encoded into a sequence of bytes. + */ + function encode( + uint256 createdAt, + uint256 completedAt, + string memory description + ) internal pure returns (bytes memory, PackedCounter, bytes memory) { + bytes memory _staticData = encodeStatic(createdAt, completedAt); + + PackedCounter _encodedLengths = encodeLengths(description); + bytes memory _dynamicData = encodeDynamic(description); + + return (_staticData, _encodedLengths, _dynamicData); + } + + /** + * @notice Encode keys as a bytes32 array using this table's field layout. + */ + function encodeKeyTuple(bytes32 key) internal pure returns (bytes32[] memory) { + bytes32[] memory _keyTuple = new bytes32[](1); + _keyTuple[0] = key; + + return _keyTuple; + } +} diff --git a/templates/react/packages/contracts/src/codegen/world/ITasksSystem.sol b/templates/react/packages/contracts/src/codegen/world/ITasksSystem.sol new file mode 100644 index 0000000000..e9bfddc79e --- /dev/null +++ b/templates/react/packages/contracts/src/codegen/world/ITasksSystem.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.21; + +/* Autogenerated file. Do not edit manually. */ + +/** + * @title ITasksSystem + * @dev This interface is automatically generated from the corresponding system contract. Do not edit manually. + */ +interface ITasksSystem { + function addTask(string memory description) external returns (bytes32 key); + + function completeTask(bytes32 key) external; + + function resetTask(bytes32 key) external; + + function deleteTask(bytes32 key) external; +} diff --git a/templates/react/packages/contracts/src/codegen/world/IWorld.sol b/templates/react/packages/contracts/src/codegen/world/IWorld.sol index 9798d299cd..e19da887b0 100644 --- a/templates/react/packages/contracts/src/codegen/world/IWorld.sol +++ b/templates/react/packages/contracts/src/codegen/world/IWorld.sol @@ -5,7 +5,7 @@ pragma solidity >=0.8.21; import { IBaseWorld } from "@latticexyz/world/src/codegen/interfaces/IBaseWorld.sol"; -import { IIncrementSystem } from "./IIncrementSystem.sol"; +import { ITasksSystem } from "./ITasksSystem.sol"; /** * @title IWorld @@ -13,6 +13,6 @@ import { IIncrementSystem } from "./IIncrementSystem.sol"; * that are dynamically registered in the World during deployment. * @dev This is an autogenerated file; do not edit manually. */ -interface IWorld is IBaseWorld, IIncrementSystem { +interface IWorld is IBaseWorld, ITasksSystem { } diff --git a/templates/react/packages/contracts/src/systems/TasksSystem.sol b/templates/react/packages/contracts/src/systems/TasksSystem.sol new file mode 100644 index 0000000000..8638912044 --- /dev/null +++ b/templates/react/packages/contracts/src/systems/TasksSystem.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.21; + +import { System } from "@latticexyz/world/src/System.sol"; +import { Tasks, TasksData } from "../codegen/index.sol"; + +contract TasksSystem is System { + function addTask(string memory description) public returns (bytes32 key) { + key = keccak256(abi.encode(block.prevrandao, _msgSender(), description)); + Tasks.set(key, TasksData({ description: description, createdAt: block.timestamp, completedAt: 0 })); + } + + function completeTask(bytes32 key) public { + Tasks.setCompletedAt(key, block.timestamp); + } + + function resetTask(bytes32 key) public { + Tasks.setCompletedAt(key, 0); + } + + function deleteTask(bytes32 key) public { + Tasks.deleteRecord(key); + } +} diff --git a/templates/react/packages/contracts/test/TasksTest.t.sol b/templates/react/packages/contracts/test/TasksTest.t.sol new file mode 100644 index 0000000000..11f34e62c3 --- /dev/null +++ b/templates/react/packages/contracts/test/TasksTest.t.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.21; + +import "forge-std/Test.sol"; +import { MudTest } from "@latticexyz/world/test/MudTest.t.sol"; + +import { IWorld } from "../src/codegen/world/IWorld.sol"; +import { Tasks, TasksData } from "../src/codegen/index.sol"; + +contract TasksTest is MudTest { + function testWorldExists() public { + uint256 codeSize; + address addr = worldAddress; + assembly { + codeSize := extcodesize(addr) + } + assertTrue(codeSize > 0); + } + + function testTasks() public { + // Expect task to exist that we created during PostDeploy script + TasksData memory task = Tasks.get("1"); + assertEq(task.description, "Walk the dog"); + assertEq(task.completedAt, 0); + + // Expect the task to be completed after calling completeTask from our TasksSystem + IWorld(worldAddress).completeTask("1"); + assertEq(Tasks.getCompletedAt("1"), block.timestamp); + } +} diff --git a/templates/react/packages/contracts/worlds.json b/templates/react/packages/contracts/worlds.json index bd48105fd7..a3a1b09f18 100644 --- a/templates/react/packages/contracts/worlds.json +++ b/templates/react/packages/contracts/worlds.json @@ -1,5 +1,5 @@ { "31337": { - "address": "0x5FbDB2315678afecb367f032d93F642f64180aa3" + "address": "0x6e9474e9c83676b9a71133ff96db43e7aa0a4342" } } \ No newline at end of file