diff --git a/packages/webapp/.prettierrc.json b/packages/webapp/.prettierrc.json index c7f66edd..da298910 100644 --- a/packages/webapp/.prettierrc.json +++ b/packages/webapp/.prettierrc.json @@ -1,8 +1,8 @@ { - "semi": false, - "trailingComma": "es5", - "singleQuote": false, - "tabWidth": 4, - "useTabs": false, - "printWidth": 120 + "semi": false, + "trailingComma": "es5", + "singleQuote": false, + "tabWidth": 4, + "useTabs": false, + "printWidth": 120 } diff --git a/packages/webapp/.vscode/settings.json b/packages/webapp/.vscode/settings.json index 255c9f62..f288ef4a 100644 --- a/packages/webapp/.vscode/settings.json +++ b/packages/webapp/.vscode/settings.json @@ -1,4 +1,4 @@ { "javascript.validate.enable": false, "typescript.validate.enable": false -} \ No newline at end of file +} diff --git a/packages/webapp/Makefile b/packages/webapp/Makefile index 031140be..ddc8b88e 100644 --- a/packages/webapp/Makefile +++ b/packages/webapp/Makefile @@ -27,7 +27,7 @@ lint: # Runs code quality checks. check: pnpm eslint . - pnpm prettier --check "**/*.{js,jsx,ts,tsx,json,css}" + pnpm prettier --check "src/**/*.{js,jsx,ts,tsx,json,css}" .PHONY: check # Runs prettier formatting across webapp files with specified file extensions. diff --git a/packages/webapp/package.json b/packages/webapp/package.json index 31561cc1..e373bebe 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -1,59 +1,60 @@ { - "name": "@xfable/webapp", - "version": "0.1.0", - "private": true, - "browser": { - "fs": false, - "path": false, - "os": false - }, - "dependencies": { - "@dnd-kit/core": "^6.0.8", - "@dnd-kit/sortable": "^7.0.2", - "@dnd-kit/utilities": "^3.2.1", - "@emotion/react": "^11.11.1", - "@radix-ui/react-dialog": "^1.0.5", - "@radix-ui/react-navigation-menu": "^1.1.4", - "@radix-ui/react-slot": "^1.0.2", - "circomlibjs": "^0.1.7", - "class-variance-authority": "^0.7.0", - "clsx": "^2.1.0", - "connectkit": "^1.5.3", - "eslint-config-prettier": "^9.1.0", - "jotai": "^2.4.3", - "jotai-devtools": "^0.7.0", - "lodash": "^4.17.21", - "lucide-react": "^0.309.0", - "next": "^13.5.6", - "next-themes": "^0.2.1", - "next-transpile-modules": "^10.0.1", - "react": "18.2.0", - "react-dom": "18.2.0", - "react-icons": "^4.11.0", - "snarkjs": "^0.7.1", - "sonner": "^1.4.0", - "tailwind-merge": "^2.2.0", - "tailwindcss-animate": "^1.0.7", - "viem": "^1.16.6", - "wagmi": "^1.4.5" - }, - "devDependencies": { - "@swc-jotai/debug-label": "^0.1.0", - "@swc-jotai/react-refresh": "^0.1.0", - "@types/eslint": "^8.44.6", - "@types/lodash": "^4.14.200", - "@types/node": "^20.8.7", - "@types/react": "^18.2.31", - "@types/react-dom": "^18.2.14", - "@typescript-eslint/eslint-plugin": "^6.8.0", - "@typescript-eslint/parser": "^6.8.0", - "@wagmi/cli": "^1.5.2", - "@welldone-software/why-did-you-render": "^7.0.1", - "autoprefixer": "^10.4.16", - "eslint": "^8.52.0", - "eslint-config-next": "^13.5.6", - "postcss": "^8.4.31", - "tailwindcss": "^3.3.3", - "typescript": "^5.2.2" - } + "name": "@xfable/webapp", + "version": "0.1.0", + "private": true, + "browser": { + "fs": false, + "path": false, + "os": false + }, + "dependencies": { + "@dnd-kit/core": "^6.0.8", + "@dnd-kit/sortable": "^7.0.2", + "@dnd-kit/utilities": "^3.2.1", + "@emotion/react": "^11.11.1", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-slot": "^1.0.2", + "circomlibjs": "^0.1.7", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "connectkit": "^1.5.3", + "eslint-config-prettier": "^9.1.0", + "jotai": "^2.4.3", + "jotai-devtools": "^0.7.0", + "lodash": "^4.17.21", + "lucide-react": "^0.309.0", + "next": "^13.5.6", + "next-themes": "^0.2.1", + "next-transpile-modules": "^10.0.1", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-icons": "^4.11.0", + "snarkjs": "^0.7.1", + "sonner": "^1.4.0", + "tailwind-merge": "^2.2.0", + "tailwindcss-animate": "^1.0.7", + "viem": "^1.16.6", + "wagmi": "^1.4.5" + }, + "devDependencies": { + "@swc-jotai/debug-label": "^0.1.0", + "@swc-jotai/react-refresh": "^0.1.0", + "@types/eslint": "^8.44.6", + "@types/lodash": "^4.14.200", + "@types/node": "^20.8.7", + "@types/react": "^18.2.31", + "@types/react-dom": "^18.2.14", + "@typescript-eslint/eslint-plugin": "^6.8.0", + "@typescript-eslint/parser": "^6.8.0", + "@wagmi/cli": "^1.5.2", + "@welldone-software/why-did-you-render": "^7.0.1", + "autoprefixer": "^10.4.16", + "eslint": "^8.52.0", + "eslint-config-next": "^13.5.6", + "postcss": "^8.4.31", + "prettier": "2.8.8", + "tailwindcss": "^3.3.3", + "typescript": "^5.2.2" + } } diff --git a/packages/webapp/src/actions/attack.ts b/packages/webapp/src/actions/attack.ts index 8cfca7e0..d1e080c8 100644 --- a/packages/webapp/src/actions/attack.ts +++ b/packages/webapp/src/actions/attack.ts @@ -9,13 +9,13 @@ import { getOpponentIndex } from "src/store/read" // ================================================================================================= export type AttackArgs = { - gameID: bigint - playerAddress: Address - setLoading: (label: string | null) => void - /** - * A list of attacking creatures indexes. - */ - selectedCreaturesIndexes: number[] + gameID: bigint + playerAddress: Address + setLoading: (label: string | null) => void + /** + * A list of attacking creatures indexes. + */ + selectedCreaturesIndexes: number[] } // ------------------------------------------------------------------------------------------------- @@ -27,32 +27,30 @@ export type AttackArgs = { * Returns `true` iff the player successfully declared the attack. */ export async function attack(args: AttackArgs): Promise { - try { - return await attackImpl(args) - } catch (err) { - args.setLoading(null) - return defaultErrorHandling("attack", err) - } + try { + return await attackImpl(args) + } catch (err) { + args.setLoading(null) + return defaultErrorHandling("attack", err) + } } // ------------------------------------------------------------------------------------------------- async function attackImpl(args: AttackArgs): Promise { - - checkFresh(await freshWrap( - contractWriteThrowing({ - contract: deployment.Game, - abi: gameABI, - functionName: "attack", - args: [ - args.gameID, - getOpponentIndex()!, - args.selectedCreaturesIndexes - ], - setLoading: args.setLoading - }))) - - return true + checkFresh( + await freshWrap( + contractWriteThrowing({ + contract: deployment.Game, + abi: gameABI, + functionName: "attack", + args: [args.gameID, getOpponentIndex()!, args.selectedCreaturesIndexes], + setLoading: args.setLoading, + }) + ) + ) + + return true } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/actions/concede.ts b/packages/webapp/src/actions/concede.ts index 3ca6c8f9..e15ac381 100644 --- a/packages/webapp/src/actions/concede.ts +++ b/packages/webapp/src/actions/concede.ts @@ -8,10 +8,10 @@ import { gameABI } from "src/generated" // ================================================================================================= export type ConcedeArgs = { - gameID: bigint - playerAddress: Address - setLoading: (label: string | null) => void - onSuccess: () => void + gameID: bigint + playerAddress: Address + setLoading: (label: string | null) => void + onSuccess: () => void } // ------------------------------------------------------------------------------------------------- @@ -22,31 +22,31 @@ export type ConcedeArgs = { * Returns `true` iff the player successfully conceded the defenders. */ export async function concede(args: ConcedeArgs): Promise { - try { - return await concedeImpl(args) - } catch (err) { - args.setLoading(null) - return defaultErrorHandling("concede", err) - } + try { + return await concedeImpl(args) + } catch (err) { + args.setLoading(null) + return defaultErrorHandling("concede", err) + } } // ------------------------------------------------------------------------------------------------- async function concedeImpl(args: ConcedeArgs): Promise { - - checkFresh(await freshWrap( - contractWriteThrowing({ - contract: deployment.Game, - abi: gameABI, - functionName: "concedeGame", - args: [ - args.gameID, - ], - setLoading: args.setLoading - }))) - - args.onSuccess() - return true + checkFresh( + await freshWrap( + contractWriteThrowing({ + contract: deployment.Game, + abi: gameABI, + functionName: "concedeGame", + args: [args.gameID], + setLoading: args.setLoading, + }) + ) + ) + + args.onSuccess() + return true } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/actions/defend.ts b/packages/webapp/src/actions/defend.ts index cc08f1b2..48d465a6 100644 --- a/packages/webapp/src/actions/defend.ts +++ b/packages/webapp/src/actions/defend.ts @@ -8,15 +8,15 @@ import { gameABI } from "src/generated" // ================================================================================================= export type DefendArgs = { - gameID: bigint - playerAddress: Address - setLoading: (label: string | null) => void - /** - * A list of defending creatures indexes. This array must be the same length as the list of - * attacking creatures, and maybe contain 0 to signal that an attacking creature should not be - * blocked. - */ - defendingCreaturesIndexes: number[] + gameID: bigint + playerAddress: Address + setLoading: (label: string | null) => void + /** + * A list of defending creatures indexes. This array must be the same length as the list of + * attacking creatures, and maybe contain 0 to signal that an attacking creature should not be + * blocked. + */ + defendingCreaturesIndexes: number[] } // ------------------------------------------------------------------------------------------------- @@ -27,31 +27,30 @@ export type DefendArgs = { * Returns `true` iff the player successfully declared the defenders. */ export async function defend(args: DefendArgs): Promise { - try { - return await defendImpl(args) - } catch (err) { - args.setLoading(null) - return defaultErrorHandling("defend", err) - } + try { + return await defendImpl(args) + } catch (err) { + args.setLoading(null) + return defaultErrorHandling("defend", err) + } } // ------------------------------------------------------------------------------------------------- async function defendImpl(args: DefendArgs): Promise { - - checkFresh(await freshWrap( - contractWriteThrowing({ - contract: deployment.Game, - abi: gameABI, - functionName: "defend", - args: [ - args.gameID, - args.defendingCreaturesIndexes - ], - setLoading: args.setLoading - }))) - - return true + checkFresh( + await freshWrap( + contractWriteThrowing({ + contract: deployment.Game, + abi: gameABI, + functionName: "defend", + args: [args.gameID, args.defendingCreaturesIndexes], + setLoading: args.setLoading, + }) + ) + ) + + return true } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/actions/drawCard.ts b/packages/webapp/src/actions/drawCard.ts index bf536e77..e21bcbae 100644 --- a/packages/webapp/src/actions/drawCard.ts +++ b/packages/webapp/src/actions/drawCard.ts @@ -13,12 +13,12 @@ import { packCards } from "src/game/fableProofs" import { gameABI } from "src/generated" import { getOrInitPrivateInfo, setPrivateInfo } from "src/store/write" import { - getCards, - getCurrentPlayerAddress, - getDeckSize, - getGameData, - getGameID, - getPlayerAddress + getCards, + getCurrentPlayerAddress, + getDeckSize, + getGameData, + getGameID, + getPlayerAddress, } from "src/store/read" import { GameStep, PrivateInfo } from "src/store/types" import { FAKE_PROOF, proveInWorker, SHOULD_GENERATE_PROOFS } from "src/utils/zkproofs" @@ -31,10 +31,10 @@ import { checkFresh, freshWrap } from "src/store/checkFresh" // ================================================================================================= export type DrawCardArgs = { - gameID: bigint - playerAddress: Address - setLoading: (label: string | null) => void - cancellationHandler: CancellationHandler + gameID: bigint + playerAddress: Address + setLoading: (label: string | null) => void + cancellationHandler: CancellationHandler } // ================================================================================================= @@ -46,118 +46,111 @@ export type DrawCardArgs = { * Returns `true` iff the player successfully drew the card. */ export async function drawCard(args: DrawCardArgs): Promise { - try { - return await drawCardImpl(args) - } catch (err) { - args.setLoading(null) - return defaultErrorHandling("drawCard", err) - } + try { + return await drawCardImpl(args) + } catch (err) { + args.setLoading(null) + return defaultErrorHandling("drawCard", err) + } } /** Intentionally left blank to ignore any loading message. * This function accepts a message parameter but does nothing with it. */ -function setLoading(_message: string|null|undefined) {} +function setLoading(_message: string | null | undefined) {} async function drawCardImpl(args: DrawCardArgs): Promise { - - const gameID = getGameID() - const playerAddress = getPlayerAddress() - const gameData = getGameData() - - if (gameID !== args.gameID || playerAddress !== args.playerAddress || gameData === null) - return false // old/stale call - - if (getCurrentPlayerAddress(gameData) !== playerAddress || gameData.currentStep !== GameStep.DRAW) - return false // old/stale call - - const privateInfo: PrivateInfo = getOrInitPrivateInfo(gameID, playerAddress) - - // Select random card from deck and update deck and hand accordingly. - const randomness = mimcHash([privateInfo.salt, gameData.publicRandomness]) - const deckSize = getDeckSize(privateInfo) - const selectedCardIndex = Number(randomness % BigInt(deckSize)) - const selectedCard = privateInfo.deckIndexes[selectedCardIndex] - const newHand = [...privateInfo.handIndexes] - const newHandLastIndex = newHand.indexOf(255) - if (newHandLastIndex < 0) - throw new Error("Hand is full") // TODO handle this more fundamentally - newHand[newHandLastIndex] = selectedCard - const newDeck = [... privateInfo.deckIndexes] - newDeck[selectedCardIndex] = newDeck[deckSize - 1] - newDeck[deckSize - 1] = 255 - // TODO use constant - // TODO abstract this logic away - - const deckRootInputs = [...packCards(newDeck), privateInfo.salt] - const newDeckRoot: HexString = `0x${bigintToHexString(mimcHash(deckRootInputs), 32)}` - const handRootInputs = [...packCards(newHand), privateInfo.salt] - const newHandRoot: HexString = `0x${bigintToHexString(mimcHash(handRootInputs), 32)}` - - const cards = getCards()! - console.log(`drew card ${cards[selectedCard]}`) - - setLoading("Generating draw proof ...") - - const tmpHandSize = privateInfo.handIndexes.indexOf(255) - const initialHandSize = tmpHandSize < 0 - ? BigInt(privateInfo.handIndexes.length) - : BigInt(tmpHandSize) - - let proof = FAKE_PROOF - - if (SHOULD_GENERATE_PROOFS) { - const { promise, cancel } = proveInWorker("Draw", { - // public inputs - deckRoot: privateInfo.deckRoot, - newDeckRoot: newDeckRoot, - handRoot: privateInfo.handRoot, - newHandRoot: newHandRoot, - saltHash: privateInfo.saltHash, - publicRandom: gameData.publicRandomness, - initialHandSize, - lastIndex: BigInt(deckSize - 1), - // private inputs - salt: privateInfo.salt, - deck: packCards(privateInfo.deckIndexes), - hand: packCards(privateInfo.handIndexes), - newDeck: packCards(newDeck), - newHand: packCards(newHand) - }, DRAW_CARD_PROOF_TIMEOUT) - - args.cancellationHandler.register(cancel) - proof = checkFresh(await freshWrap(promise)) - args.cancellationHandler.deregister(cancel) - } - - checkFresh(await freshWrap( - contractWriteThrowing({ - contract: deployment.Game, - abi: gameABI, - functionName: "drawCard", - args: [ - gameID, - newHandRoot, - newDeckRoot, - proof.proof_a, - proof.proof_b, - proof.proof_c - ], - setLoading: setLoading - }))) - - // TODO: this should be put in an optimistic store, before proof generation - setPrivateInfo(gameID, playerAddress, { - ...privateInfo, - handIndexes: newHand, - handRoot: newHandRoot, - deckIndexes: newDeck, - deckRoot: newDeckRoot - }) - - return true + const gameID = getGameID() + const playerAddress = getPlayerAddress() + const gameData = getGameData() + + if (gameID !== args.gameID || playerAddress !== args.playerAddress || gameData === null) return false // old/stale call + + if (getCurrentPlayerAddress(gameData) !== playerAddress || gameData.currentStep !== GameStep.DRAW) return false // old/stale call + + const privateInfo: PrivateInfo = getOrInitPrivateInfo(gameID, playerAddress) + + // Select random card from deck and update deck and hand accordingly. + const randomness = mimcHash([privateInfo.salt, gameData.publicRandomness]) + const deckSize = getDeckSize(privateInfo) + const selectedCardIndex = Number(randomness % BigInt(deckSize)) + const selectedCard = privateInfo.deckIndexes[selectedCardIndex] + const newHand = [...privateInfo.handIndexes] + const newHandLastIndex = newHand.indexOf(255) + if (newHandLastIndex < 0) throw new Error("Hand is full") // TODO handle this more fundamentally + newHand[newHandLastIndex] = selectedCard + const newDeck = [...privateInfo.deckIndexes] + newDeck[selectedCardIndex] = newDeck[deckSize - 1] + newDeck[deckSize - 1] = 255 + // TODO use constant + // TODO abstract this logic away + + const deckRootInputs = [...packCards(newDeck), privateInfo.salt] + const newDeckRoot: HexString = `0x${bigintToHexString(mimcHash(deckRootInputs), 32)}` + const handRootInputs = [...packCards(newHand), privateInfo.salt] + const newHandRoot: HexString = `0x${bigintToHexString(mimcHash(handRootInputs), 32)}` + + const cards = getCards()! + console.log(`drew card ${cards[selectedCard]}`) + + setLoading("Generating draw proof ...") + + const tmpHandSize = privateInfo.handIndexes.indexOf(255) + const initialHandSize = tmpHandSize < 0 ? BigInt(privateInfo.handIndexes.length) : BigInt(tmpHandSize) + + let proof = FAKE_PROOF + + if (SHOULD_GENERATE_PROOFS) { + const { promise, cancel } = proveInWorker( + "Draw", + { + // public inputs + deckRoot: privateInfo.deckRoot, + newDeckRoot: newDeckRoot, + handRoot: privateInfo.handRoot, + newHandRoot: newHandRoot, + saltHash: privateInfo.saltHash, + publicRandom: gameData.publicRandomness, + initialHandSize, + lastIndex: BigInt(deckSize - 1), + // private inputs + salt: privateInfo.salt, + deck: packCards(privateInfo.deckIndexes), + hand: packCards(privateInfo.handIndexes), + newDeck: packCards(newDeck), + newHand: packCards(newHand), + }, + DRAW_CARD_PROOF_TIMEOUT + ) + + args.cancellationHandler.register(cancel) + proof = checkFresh(await freshWrap(promise)) + args.cancellationHandler.deregister(cancel) + } + + checkFresh( + await freshWrap( + contractWriteThrowing({ + contract: deployment.Game, + abi: gameABI, + functionName: "drawCard", + args: [gameID, newHandRoot, newDeckRoot, proof.proof_a, proof.proof_b, proof.proof_c], + setLoading: setLoading, + }) + ) + ) + + // TODO: this should be put in an optimistic store, before proof generation + setPrivateInfo(gameID, playerAddress, { + ...privateInfo, + handIndexes: newHand, + handRoot: newHandRoot, + deckIndexes: newDeck, + deckRoot: newDeckRoot, + }) + + return true } -if (typeof window !== "undefined") - (window as any).drawCard = drawCard +if (typeof window !== "undefined") (window as any).drawCard = drawCard -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/actions/endTurn.ts b/packages/webapp/src/actions/endTurn.ts index 9baaa23f..14e3a24d 100644 --- a/packages/webapp/src/actions/endTurn.ts +++ b/packages/webapp/src/actions/endTurn.ts @@ -10,22 +10,16 @@ import { contractWriteThrowing } from "src/actions/libContractWrite" import { Address } from "src/chain" import { deployment } from "src/deployment" import { gameABI } from "src/generated" -import { - getCurrentPlayerAddress, - getGameData, - getGameID, - getPlayerAddress -} from "src/store/read" +import { getCurrentPlayerAddress, getGameData, getGameID, getPlayerAddress } from "src/store/read" import { GameStep } from "src/store/types" import { checkFresh, freshWrap } from "src/store/checkFresh" - // ================================================================================================= export type EndTurnArgs = { - gameID: bigint - playerAddress: Address - setLoading: (label: string | null) => void + gameID: bigint + playerAddress: Address + setLoading: (label: string | null) => void } // ================================================================================================= @@ -35,39 +29,39 @@ export type EndTurnArgs = { * Returns `true` iff the transaction was successfully sent. */ export async function endTurn(args: EndTurnArgs): Promise { - try { - return await skipTurnImpl(args) - } catch (err) { - return defaultErrorHandling("skipTurn", err) - } + try { + return await skipTurnImpl(args) + } catch (err) { + return defaultErrorHandling("skipTurn", err) + } } // ================================================================================================= export async function skipTurnImpl(args: EndTurnArgs): Promise { - const gameID = getGameID() - const playerAddress = getPlayerAddress() - const gameData = getGameData() + const gameID = getGameID() + const playerAddress = getPlayerAddress() + const gameData = getGameData() - if (gameID !== args.gameID || playerAddress !== args.playerAddress || gameData === null) - return false // old/stale call + if (gameID !== args.gameID || playerAddress !== args.playerAddress || gameData === null) return false // old/stale call - if (getCurrentPlayerAddress(gameData) !== playerAddress) - return false // old/stale call + if (getCurrentPlayerAddress(gameData) !== playerAddress) return false // old/stale call - if (![GameStep.PLAY, GameStep.ATTACK].includes(gameData.currentStep)) - return false // old/stale call + if (![GameStep.PLAY, GameStep.ATTACK].includes(gameData.currentStep)) return false // old/stale call - checkFresh(await freshWrap( - contractWriteThrowing({ - contract: deployment.Game, - abi: gameABI, - functionName: "endTurn", - args: [ gameID], - setLoading: args.setLoading - }))) + checkFresh( + await freshWrap( + contractWriteThrowing({ + contract: deployment.Game, + abi: gameABI, + functionName: "endTurn", + args: [gameID], + setLoading: args.setLoading, + }) + ) + ) - return true + return true } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/actions/errors.ts b/packages/webapp/src/actions/errors.ts index e13284bc..fe8fca64 100644 --- a/packages/webapp/src/actions/errors.ts +++ b/packages/webapp/src/actions/errors.ts @@ -27,9 +27,9 @@ import { ProofCancelled, ProofError, ProofTimeoutError } from "src/utils/zkproof * Thrown when an network request times out. */ export class FableRequestTimeout extends TimeoutError { - constructor(msg: string) { - super(msg) - } + constructor(msg: string) { + super(msg) + } } // ------------------------------------------------------------------------------------------------- @@ -41,9 +41,9 @@ export class FableRequestTimeout extends TimeoutError { * the state shifted underneath an async operation). */ export class InconsistentGameStateError extends Error { - constructor(msg: string) { - super(msg) - } + constructor(msg: string) { + super(msg) + } } // ================================================================================================= @@ -63,53 +63,50 @@ export class InconsistentGameStateError extends Error { * store changes that caused the error to be thrown. */ export function defaultErrorHandling(actionName: string, err: unknown): false { + if (err instanceof StaleError) return false + + if (err instanceof FableRequestTimeout) { + setError({ + title: "Request timed out.", + message: (err as Error).message + " Please try again.", + buttons: [DISMISS_BUTTON], + }) + return false + } - if (err instanceof StaleError) - return false - - if (err instanceof FableRequestTimeout) { - setError({ - title: "Request timed out.", - message: (err as Error).message + " Please try again.", - buttons: [DISMISS_BUTTON] - }) - return false - } - - if (err instanceof ProofTimeoutError) { - setError({ - title: "ZK proof generation timed out.", - message: "Please try again.", - buttons: [DISMISS_BUTTON] - }) - return false - } + if (err instanceof ProofTimeoutError) { + setError({ + title: "ZK proof generation timed out.", + message: "Please try again.", + buttons: [DISMISS_BUTTON], + }) + return false + } - if (err instanceof ProofError) { - setError({ - title: "Could not generate ZK proof.", - message: "Please reload page and try again.", - buttons: [RELOAD_BUTTON, DISMISS_BUTTON] - }) - // This is most likely gibberish, but you never known. - console.error(err.cause) - return false - } + if (err instanceof ProofError) { + setError({ + title: "Could not generate ZK proof.", + message: "Please reload page and try again.", + buttons: [RELOAD_BUTTON, DISMISS_BUTTON], + }) + // This is most likely gibberish, but you never known. + console.error(err.cause) + return false + } - if (err instanceof ProofCancelled) { - // This is actually not an error, do nothing except signal the action failed. - return false - } + if (err instanceof ProofCancelled) { + // This is actually not an error, do nothing except signal the action failed. + return false + } - if (err instanceof ContractWriteError) - return defaultContractWriteErrorHandling(err) + if (err instanceof ContractWriteError) return defaultContractWriteErrorHandling(err) - if (err instanceof InconsistentGameStateError) { - reportInconsistentGameState(err) - return false - } + if (err instanceof InconsistentGameStateError) { + reportInconsistentGameState(err) + return false + } - throw err + throw err } // ------------------------------------------------------------------------------------------------- @@ -125,74 +122,74 @@ export function defaultErrorHandling(actionName: string, err: unknown): false { * never rethrown. */ export function defaultContractWriteErrorHandling(err: ContractWriteError): false { + if (err.result.revert) { + // Ethereum nodes do not store the revert reason at all, so there is no way to be more + // precise than this. These errors can only occur when the call succeeded at simulation time + // but failed at executing on-chain, which should make them rare. This also means they are + // caused by a race condition in the contract, where another action changed the state such + // that a previously successful call now fails. We should try to avoid these as a matter of + // design. + + // It's also hard to know if the error was due to a logic revert or to running + // out of gas. This is a viem issue, see here for the explanation: + // https://twitter.com/norswap/status/1687491839320842240 + // In both cases, the problem is triggered by an underlying state change. + + // Ideally, we override this and handle it in a custom manner only for actions where these + // races are possible. + + setError({ + title: "Contract execution error", + message: + `Transaction reverted (${err.args.functionName}) at execution time ` + + "but not at simulation time. Please try again.", + buttons: [DISMISS_BUTTON], + }) + return false + } - if (err.result.revert) { - // Ethereum nodes do not store the revert reason at all, so there is no way to be more - // precise than this. These errors can only occur when the call succeeded at simulation time - // but failed at executing on-chain, which should make them rare. This also means they are - // caused by a race condition in the contract, where another action changed the state such - // that a previously successful call now fails. We should try to avoid these as a matter of - // design. + const error = err.result.error - // It's also hard to know if the error was due to a logic revert or to running - // out of gas. This is a viem issue, see here for the explanation: - // https://twitter.com/norswap/status/1687491839320842240 - // In both cases, the problem is triggered by an underlying state change. + if (error instanceof UserRejectedRequestError) { + // The user rejected the execution, he's probably well aware of the fact. + return false + } - // Ideally, we override this and handle it in a custom manner only for actions where these - // races are possible. + if (error instanceof ContractFunctionRevertedError) { + if (error.data) { + // The revert was parsed, because the function signature was found in the ABI of the + // calling contract (meaning it was declared *inside* the contract). + setError({ + title: "Contract execution error", + message: + `Transaction reverted (${err.args.functionName}) with ` + + `${error.data.errorName}(${error.data.args})`, + buttons: [DISMISS_BUTTON], + }) + } else { + // The revert wasn't parsed, probably because the error isn't declared *inside* the + // contract, but comes from another contract and wasn't redeclared. + + const signatureMsg = error.signature ? `with signature ${error.signature}` : "with no signature" + + setError({ + title: "Contract execution error", + message: + `Transaction reverted (${err.args.functionName}) ${signatureMsg}. ` + + `Please report to ${GIT_ISSUES}`, + buttons: [DISMISS_BUTTON], + }) + } + return false + } setError({ - title: "Contract execution error", - message: `Transaction reverted (${err.args.functionName}) at execution time ` - + "but not at simulation time. Please try again.", - buttons: [DISMISS_BUTTON] + title: "Contract execution error", + message: error.toString(), + buttons: [DISMISS_BUTTON], }) - return false - } - - const error = err.result.error - - if (error instanceof UserRejectedRequestError) { - // The user rejected the execution, he's probably well aware of the fact. - return false - } - if (error instanceof ContractFunctionRevertedError) { - if (error.data) { - // The revert was parsed, because the function signature was found in the ABI of the - // calling contract (meaning it was declared *inside* the contract). - setError({ - title: "Contract execution error", - message: `Transaction reverted (${err.args.functionName}) with ` - +`${error.data.errorName}(${error.data.args})`, - buttons: [DISMISS_BUTTON] - }) - } else { - // The revert wasn't parsed, probably because the error isn't declared *inside* the - // contract, but comes from another contract and wasn't redeclared. - - const signatureMsg = error.signature - ? `with signature ${error.signature}` - : "with no signature" - - setError({ - title: "Contract execution error", - message: `Transaction reverted (${err.args.functionName}) ${signatureMsg}. ` - + `Please report to ${GIT_ISSUES}`, - buttons: [DISMISS_BUTTON] - }) - } return false - } - - setError({ - title: "Contract execution error", - message: error.toString(), - buttons: [DISMISS_BUTTON] - }) - - return false } // ------------------------------------------------------------------------------------------------- @@ -203,13 +200,13 @@ export function defaultContractWriteErrorHandling(err: ContractWriteError): fals * As state in {@link InconsistentGameStateError}, this should never occur in practice, and * implies an implementation bug or weakness. */ -export function reportInconsistentGameState(err: InconsistentGameStateError|string) { - const message = typeof err === "string" ? err : err.message - setError({ - title: "Error: Inconsistent game state.", - message: `${message}\n\nPlease reload the page.`, - buttons: [RELOAD_BUTTON, DISMISS_BUTTON] - }) +export function reportInconsistentGameState(err: InconsistentGameStateError | string) { + const message = typeof err === "string" ? err : err.message + setError({ + title: "Error: Inconsistent game state.", + message: `${message}\n\nPlease reload the page.`, + buttons: [RELOAD_BUTTON, DISMISS_BUTTON], + }) } // ================================================================================================= @@ -227,8 +224,8 @@ export const DISMISS_BUTTON = { text: "Dismiss", onClick: () => setError(null) } /** * A button meant to be used as a component in {@link ErrorConfig.buttons} when passed to {@link - * setError}. It reloads the page when clicked. + * setError}. It reloads the page when clicked. */ export const RELOAD_BUTTON = { text: "Refresh", onClick: () => window.location.reload() } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/actions/index.ts b/packages/webapp/src/actions/index.ts index c06d927a..b89c32cd 100644 --- a/packages/webapp/src/actions/index.ts +++ b/packages/webapp/src/actions/index.ts @@ -10,4 +10,4 @@ */ export { joinGame } from "actions/joinGame" -export { reportInconsistentGameState } from "actions/errors" \ No newline at end of file +export { reportInconsistentGameState } from "actions/errors" diff --git a/packages/webapp/src/actions/joinGame.ts b/packages/webapp/src/actions/joinGame.ts index 13497062..c4a3c235 100644 --- a/packages/webapp/src/actions/joinGame.ts +++ b/packages/webapp/src/actions/joinGame.ts @@ -7,39 +7,26 @@ import { decodeEventLog } from "viem" -import { - defaultErrorHandling, - FableRequestTimeout, - InconsistentGameStateError -} from "src/actions/errors" +import { defaultErrorHandling, FableRequestTimeout, InconsistentGameStateError } from "src/actions/errors" import { contractWriteThrowing } from "src/actions/libContractWrite" import { Address } from "src/chain" import { deployment } from "src/deployment" import { drawInitialHand } from "src/game/drawInitialHand" import { gameABI } from "src/generated" import { waitForUpdate } from "src/store/update" +import { getOrInitPrivateInfo, setGameID, setPrivateInfo } from "src/store/write" import { - getOrInitPrivateInfo, - setGameID, - setPrivateInfo -} from "src/store/write" -import { - getCards, - getDeck, - getGameData, - getGameID, - getGameStatus, - getPlayerData, - getPrivateInfo, - isGameReadyToStart + getCards, + getDeck, + getGameData, + getGameID, + getGameStatus, + getPlayerData, + getPrivateInfo, + isGameReadyToStart, } from "src/store/read" import { FetchedGameData, GameStatus, PlayerData, PrivateInfo } from "src/store/types" -import { - SHOULD_GENERATE_PROOFS, - FAKE_PROOF, - ProofOutput, - proveInWorker -} from "src/utils/zkproofs" +import { SHOULD_GENERATE_PROOFS, FAKE_PROOF, ProofOutput, proveInWorker } from "src/utils/zkproofs" import { NUM_CARDS_FOR_PROOF } from "src/game/constants" import { packCards } from "src/game/fableProofs" import { DRAW_HAND_PROOF_TIMEOUT } from "src/constants" @@ -50,10 +37,10 @@ import { getPlayerHand } from "src/store/derive" // ================================================================================================= export type JoinGameArgs = { - gameID: bigint - playerAddress: Address - setLoading: (label: string | null) => void - cancellationHandler: CancellationHandler + gameID: bigint + playerAddress: Address + setLoading: (label: string | null) => void + cancellationHandler: CancellationHandler } // ------------------------------------------------------------------------------------------------- @@ -67,86 +54,82 @@ export type JoinGameArgs = { * Returns `true` iff the player successfully joined the game. */ export async function joinGame(args: JoinGameArgs): Promise { - try { - return await joinGameImpl(args) - } catch (err) { - args.setLoading(null) - return defaultErrorHandling("joinGame", err) - } + try { + return await joinGameImpl(args) + } catch (err) { + args.setLoading(null) + return defaultErrorHandling("joinGame", err) + } } // ------------------------------------------------------------------------------------------------- async function joinGameImpl(args: JoinGameArgs): Promise { + const gameID = getGameID() + const gameStatus = getGameStatus() - const gameID = getGameID() - const gameStatus = getGameStatus() + if (gameID !== null && gameID !== args.gameID) return false // old/stale call - if (gameID !== null && gameID !== args.gameID) - return false // old/stale call + if (gameStatus >= GameStatus.HAND_DRAWN) + // We already drew, no need to display an error, this is a stale call, + // and most likely we're in an aberrant state anwyay. + return false - if (gameStatus >= GameStatus.HAND_DRAWN) - // We already drew, no need to display an error, this is a stale call, - // and most likely we're in an aberrant state anwyay. - return false + let privateInfo: PrivateInfo | null = getOrInitPrivateInfo(args.gameID, args.playerAddress) - let privateInfo: PrivateInfo | null = getOrInitPrivateInfo(args.gameID, args.playerAddress) + // NOTE: If we used the creation block for randomness, we could already drawing cards and start + // generating the proof now. The reason why we don't is that this lets players simulate their + // hands before joining, which lets them select which games to join. If the creator participates, + // it lets him create and cancel many games to find one that will advantage him. - // NOTE: If we used the creation block for randomness, we could already drawing cards and start - // generating the proof now. The reason why we don't is that this lets players simulate their - // hands before joining, which lets them select which games to join. If the creator participates, - // it lets him create and cancel many games to find one that will advantage him. + if (gameStatus < GameStatus.JOINED) { + // we can skip the join step if already performed + const promise = doJoinGameTransaction(args, privateInfo.saltHash) + if (gameID === null) await promise // gameID starts null and the call will set it + else checkFresh(await freshWrap(promise)) + } - if (gameStatus < GameStatus.JOINED) { // we can skip the join step if already performed - const promise = doJoinGameTransaction(args, privateInfo.saltHash) - if (gameID === null) - await promise // gameID starts null and the call will set it - else - checkFresh(await freshWrap(promise)) - } + args.setLoading("Drawing cards...") - args.setLoading("Drawing cards...") + const gameData = getGameData() + const cards = getCards() + if (gameData === null) + // should be impossible due to checkFresh usage + throw new InconsistentGameStateError("Missing game data.") - const gameData = getGameData() - const cards = getCards() - if (gameData === null) // should be impossible due to checkFresh usage - throw new InconsistentGameStateError("Missing game data.") + privateInfo = getPrivateInfo(args.gameID, args.playerAddress) + if (privateInfo === null) + // should be impossible due to checkFresh usage + throw new InconsistentGameStateError("Missing private info.") - privateInfo = getPrivateInfo(args.gameID, args.playerAddress) - if (privateInfo === null) // should be impossible due to checkFresh usage - throw new InconsistentGameStateError("Missing private info.") + const playerData = getPlayerData(gameData, args.playerAddress) + if (playerData === null) throw new InconsistentGameStateError("Missing player data.") - const playerData = getPlayerData(gameData, args.playerAddress) - if (playerData === null) - throw new InconsistentGameStateError("Missing player data.") + const deck = getDeck(playerData, cards) + if (deck === null) + // should be impossible due to checkFresh usage + throw new InconsistentGameStateError("Missing player deck.") - const deck = getDeck(playerData, cards) - if (deck === null) // should be impossible due to checkFresh usage - throw new InconsistentGameStateError("Missing player deck.") + const handDeckInfo = drawInitialHand(deck, playerData.deckStart, privateInfo.salt, gameData.publicRandomness) - const handDeckInfo = drawInitialHand( - deck, playerData.deckStart, privateInfo.salt, gameData.publicRandomness) + privateInfo = { + salt: privateInfo.salt, + saltHash: privateInfo.saltHash, + ...handDeckInfo, + } - privateInfo = { - salt: privateInfo.salt, - saltHash: privateInfo.saltHash, - ...handDeckInfo - } + setPrivateInfo(args.gameID, args.playerAddress, privateInfo) - setPrivateInfo(args.gameID, args.playerAddress, privateInfo) + const hand = getPlayerHand(gameData, privateInfo) + console.log(`drew initial hand: ${hand}`) + args.setLoading("Generating draw proof — may take a minute ...") - const hand = getPlayerHand(gameData, privateInfo) - console.log(`drew initial hand: ${hand}`) - args.setLoading("Generating draw proof — may take a minute ...") + const proof = SHOULD_GENERATE_PROOFS + ? await generateDrawInitialHandProof(deck, privateInfo, gameData, playerData, args.cancellationHandler) + : FAKE_PROOF - const proof = SHOULD_GENERATE_PROOFS - ? await generateDrawInitialHandProof( - deck, privateInfo, gameData, playerData, - args.cancellationHandler) - : FAKE_PROOF - - await doDrawInitialHandTransaction(args, privateInfo, proof) - return true + await doDrawInitialHandTransaction(args, privateInfo, proof) + return true } // ------------------------------------------------------------------------------------------------- @@ -157,42 +140,44 @@ async function joinGameImpl(args: JoinGameArgs): Promise { * @throws {StaleError} if the store shifts underneath the transaction. */ async function doJoinGameTransaction(args: JoinGameArgs, saltHash: bigint) { - - const result = checkFresh(await freshWrap( - contractWriteThrowing({ - contract: deployment.Game, - abi: gameABI, - functionName: "joinGame", - args: [ - args.gameID, - 0, // deckID - saltHash, - // data for join-check callback — currently unused, just use 1 - "0x0000000000000000000000000000000000000000000000000000000000000001", - ], - setLoading: args.setLoading - }))) - - if (getGameID() === null) { - // gameID is null and needs to be set - const logs = result.receipt.logs - const event = decodeEventLog({ - abi: gameABI, - data: logs[0].data, - topics: logs[0]["topics"] - }) - setGameID((event.args as any).gameID) - } - - // We need to get the player data in order to draw cards. - // NOTE: No UI way to navigate away, so no risk to write to a stale component state. - args.setLoading("Waiting for update...") - const gameData = checkFresh(await freshWrap(waitForUpdate(result.receipt.blockNumber))) - - if (gameData === null) - // Null can also be returned if the game data was cleared, but this should be caught by - // checkFresh, and an exception thrown. - throw new FableRequestTimeout("Timed out waiting for up to date game data.") + const result = checkFresh( + await freshWrap( + contractWriteThrowing({ + contract: deployment.Game, + abi: gameABI, + functionName: "joinGame", + args: [ + args.gameID, + 0, // deckID + saltHash, + // data for join-check callback — currently unused, just use 1 + "0x0000000000000000000000000000000000000000000000000000000000000001", + ], + setLoading: args.setLoading, + }) + ) + ) + + if (getGameID() === null) { + // gameID is null and needs to be set + const logs = result.receipt.logs + const event = decodeEventLog({ + abi: gameABI, + data: logs[0].data, + topics: logs[0]["topics"], + }) + setGameID((event.args as any).gameID) + } + + // We need to get the player data in order to draw cards. + // NOTE: No UI way to navigate away, so no risk to write to a stale component state. + args.setLoading("Waiting for update...") + const gameData = checkFresh(await freshWrap(waitForUpdate(result.receipt.blockNumber))) + + if (gameData === null) + // Null can also be returned if the game data was cleared, but this should be caught by + // checkFresh, and an exception thrown. + throw new FableRequestTimeout("Timed out waiting for up to date game data.") } // ------------------------------------------------------------------------------------------------- @@ -202,33 +187,36 @@ async function generateDrawInitialHandProof( privateInfo: PrivateInfo, gameData: FetchedGameData, playerData: PlayerData, - cancellationHandler: CancellationHandler) - : Promise { - - const initialDeckOrdering = new Array(NUM_CARDS_FOR_PROOF) - - // initialDeckOrdering = [deckStart .. deckStart + deck.length] + pad with 255 - for (let i = 0; i < deck.length; i++) initialDeckOrdering[i] = playerData.deckStart + i - for (let i = deck.length; i < initialDeckOrdering.length; i++) initialDeckOrdering[i] = 255 - - const { promise, cancel } = proveInWorker("DrawHand", { - // public inputs - initialDeck: packCards(initialDeckOrdering), - lastIndex: BigInt(deck.length - 1), - deckRoot: privateInfo.deckRoot, - handRoot: privateInfo.handRoot, - saltHash: privateInfo.saltHash, - publicRandom: gameData.publicRandomness, - // private inputs - salt: privateInfo.salt, - deck: packCards(privateInfo.deckIndexes), - hand: packCards(privateInfo.handIndexes) - }, DRAW_HAND_PROOF_TIMEOUT) - - cancellationHandler.register(cancel) - await promise - cancellationHandler.deregister(cancel) - return promise + cancellationHandler: CancellationHandler +): Promise { + const initialDeckOrdering = new Array(NUM_CARDS_FOR_PROOF) + + // initialDeckOrdering = [deckStart .. deckStart + deck.length] + pad with 255 + for (let i = 0; i < deck.length; i++) initialDeckOrdering[i] = playerData.deckStart + i + for (let i = deck.length; i < initialDeckOrdering.length; i++) initialDeckOrdering[i] = 255 + + const { promise, cancel } = proveInWorker( + "DrawHand", + { + // public inputs + initialDeck: packCards(initialDeckOrdering), + lastIndex: BigInt(deck.length - 1), + deckRoot: privateInfo.deckRoot, + handRoot: privateInfo.handRoot, + saltHash: privateInfo.saltHash, + publicRandom: gameData.publicRandomness, + // private inputs + salt: privateInfo.salt, + deck: packCards(privateInfo.deckIndexes), + hand: packCards(privateInfo.handIndexes), + }, + DRAW_HAND_PROOF_TIMEOUT + ) + + cancellationHandler.register(cancel) + await promise + cancellationHandler.deregister(cancel) + return promise } // ------------------------------------------------------------------------------------------------- @@ -237,37 +225,39 @@ async function generateDrawInitialHandProof( * Sends the `drawInitialHand` transaction, then sets the loading state to "Loading game..." if * the game is ready to start. */ -async function doDrawInitialHandTransaction - (args: JoinGameArgs, privateInfo: PrivateInfo, proof: ProofOutput) { - - // function drawInitialHand(uint256 gameID, bytes32 handRoot, bytes32 deckRoot, bytes calldata proof) - const result = checkFresh(await freshWrap( - contractWriteThrowing({ - contract: deployment.Game, - abi: gameABI, - functionName: "drawInitialHand", - args: [ - args.gameID, - privateInfo.handRoot, - privateInfo.deckRoot, - proof.proof_a, - proof.proof_b, - proof.proof_c - ], - setLoading: args.setLoading - }))) - - const gameData = getGameData() - if (gameData === null) // should be impossible due to checkFresh - throw new InconsistentGameStateError("Missing game data.") - - // Assuming two players, if we're the last to draw, we just need to wait for (1) the data - // refresh and (2) loading of the play page. Not displaying a loading modal would just show - // the old screen, which is janky (feels like our join didnt work). - // The alternative is an optimistic update of the game status & data. - if (isGameReadyToStart(gameData, result.receipt.blockNumber)) - // NOTE: possible stale component site - args.setLoading("Loading game...") +async function doDrawInitialHandTransaction(args: JoinGameArgs, privateInfo: PrivateInfo, proof: ProofOutput) { + // function drawInitialHand(uint256 gameID, bytes32 handRoot, bytes32 deckRoot, bytes calldata proof) + const result = checkFresh( + await freshWrap( + contractWriteThrowing({ + contract: deployment.Game, + abi: gameABI, + functionName: "drawInitialHand", + args: [ + args.gameID, + privateInfo.handRoot, + privateInfo.deckRoot, + proof.proof_a, + proof.proof_b, + proof.proof_c, + ], + setLoading: args.setLoading, + }) + ) + ) + + const gameData = getGameData() + if (gameData === null) + // should be impossible due to checkFresh + throw new InconsistentGameStateError("Missing game data.") + + // Assuming two players, if we're the last to draw, we just need to wait for (1) the data + // refresh and (2) loading of the play page. Not displaying a loading modal would just show + // the old screen, which is janky (feels like our join didnt work). + // The alternative is an optimistic update of the game status & data. + if (isGameReadyToStart(gameData, result.receipt.blockNumber)) + // NOTE: possible stale component site + args.setLoading("Loading game...") } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/actions/libContractWrite.ts b/packages/webapp/src/actions/libContractWrite.ts index 1e4564ab..883352b2 100644 --- a/packages/webapp/src/actions/libContractWrite.ts +++ b/packages/webapp/src/actions/libContractWrite.ts @@ -5,13 +5,13 @@ */ import { - Abi, - ContractFunctionExecutionError, - ContractFunctionResult, - EstimateGasExecutionError, - GetFunctionArgs, - TransactionExecutionError, - TransactionReceipt + Abi, + ContractFunctionExecutionError, + ContractFunctionResult, + EstimateGasExecutionError, + GetFunctionArgs, + TransactionExecutionError, + TransactionReceipt, } from "viem" import { prepareWriteContract, waitForTransaction, writeContract } from "wagmi/actions" @@ -21,38 +21,35 @@ import type { Address, Hash } from "src/chain" // ================================================================================================= /** Arguments to {@link contractWrite}. */ -export type ContractWriteArgs< - TAbi extends Abi, - TFunctionName extends string> = { - - /** Called contract address. */ - contract: Address - - /** Called contract ABI. */ - abi: TAbi - - /** Called function name. */ - functionName: TFunctionName, - - /** Args to call the function with. */ - args: GetFunctionArgs['args'] - - /** - * If provided, this will be called with the result of the simulated call. This is the only way to - * get the return value of a write call, however beware that nothing guarantees that the actual - * on-chain call will return the same value (and there is no way to retrieve the on-chain returned - * value). - */ - onSimulated?: (result: ContractFunctionResult) => void - - /** Called after the transaction is signed, with the transaction hash. */ - onSigned?: (hash: Hash) => void - - /** - * Called with a label indicating the current state of processing the transaction, suitable for - * display in the UI, or null when the transaction has finished processing. - */ - setLoading?: (label: string|null) => void +export type ContractWriteArgs = { + /** Called contract address. */ + contract: Address + + /** Called contract ABI. */ + abi: TAbi + + /** Called function name. */ + functionName: TFunctionName + + /** Args to call the function with. */ + args: GetFunctionArgs["args"] + + /** + * If provided, this will be called with the result of the simulated call. This is the only way to + * get the return value of a write call, however beware that nothing guarantees that the actual + * on-chain call will return the same value (and there is no way to retrieve the on-chain returned + * value). + */ + onSimulated?: (result: ContractFunctionResult) => void + + /** Called after the transaction is signed, with the transaction hash. */ + onSigned?: (hash: Hash) => void + + /** + * Called with a label indicating the current state of processing the transaction, suitable for + * display in the UI, or null when the transaction has finished processing. + */ + setLoading?: (label: string | null) => void } // ------------------------------------------------------------------------------------------------- @@ -83,14 +80,14 @@ export type ContractWriteArgsTerse = * Returned by {@link contractWrite} when the write was successful. */ export type ContractWriteResultSuccess = { - success: true - receipt: TransactionReceipt - /** - * If the call was simulated, the result of the SIMULATED call, otherwise null. It is impossible - * to retrieve the actual returned value of an on-chain transaction — transacions have no retun - * only value, only an exit code. - */ - simulatedResult: ContractFunctionResult|null + success: true + receipt: TransactionReceipt + /** + * If the call was simulated, the result of the SIMULATED call, otherwise null. It is impossible + * to retrieve the actual returned value of an on-chain transaction — transacions have no retun + * only value, only an exit code. + */ + simulatedResult: ContractFunctionResult | null } // ------------------------------------------------------------------------------------------------- @@ -100,9 +97,9 @@ export type ContractWriteResultSuccess - = ContractWriteResultSuccess - | ContractWriteResultError - | ContractWriteResultRevert +export type ContractWriteResult = + | ContractWriteResultSuccess + | ContractWriteResultError + | ContractWriteResultRevert // ================================================================================================= @@ -153,25 +150,24 @@ export type ContractWriteResult * * If you want failure cases to throw, use {@link contractWriteThrowing} instead. */ -export async function contractWrite - (args: ContractWriteArgs) - : Promise> { - - args.setLoading?.("Waiting for signature...") - - try { - // NOTE: Even if we don't run this, `writeContract` will call it anyway — this is good - // because we want to catch possible errors before on-chain execution. - const config = await prepareWriteContract({ - address: args.contract, - abi: args.abi, - functionName: args.functionName, - args: args.args - } as any) +export async function contractWrite( + args: ContractWriteArgs +): Promise> { + args.setLoading?.("Waiting for signature...") + + try { + // NOTE: Even if we don't run this, `writeContract` will call it anyway — this is good + // because we want to catch possible errors before on-chain execution. + const config = await prepareWriteContract({ + address: args.contract, + abi: args.abi, + functionName: args.functionName, + args: args.args, + } as any) - args.onSimulated?.(config.result as any) + args.onSimulated?.(config.result as any) - /* + /* // NOTE(norswap): here's how we could retrieve the gas here. // Capturing the gas estimation and passing it to the user would be useful in order to @@ -196,45 +192,45 @@ export async function contractWrite|null - } - } else { - args.setLoading?.(null) - return { - success: false, - revert: true, - receipt - } - } - } - catch (e) { - - if ( e instanceof ContractFunctionExecutionError - || e instanceof EstimateGasExecutionError - || e instanceof TransactionExecutionError) { - return { - success: false, - revert: false, - error: e.cause - } - } - return { - success: false, - revert: false, - error: e + const { hash } = await writeContract(config) + + args.setLoading?.("Waiting for on-chain inclusion...") + args.onSigned?.(hash) + + const receipt: TransactionReceipt = await waitForTransaction({ hash }) + if (receipt.status === "success") { + args.setLoading?.(null) + return { + success: true, + receipt, + simulatedResult: config?.result as ContractFunctionResult | null, + } + } else { + args.setLoading?.(null) + return { + success: false, + revert: true, + receipt, + } + } + } catch (e) { + if ( + e instanceof ContractFunctionExecutionError || + e instanceof EstimateGasExecutionError || + e instanceof TransactionExecutionError + ) { + return { + success: false, + revert: false, + error: e.cause, + } + } + return { + success: false, + revert: false, + error: e, + } } - } } // ------------------------------------------------------------------------------------------------- @@ -243,15 +239,14 @@ export async function contractWrite - (args: ContractWriteArgs) - : Promise> { - - const result = await contractWrite(args) - if (!result.success) { - throw new ContractWriteError(args as any, result) - } - return result +export async function contractWriteThrowing( + args: ContractWriteArgs +): Promise> { + const result = await contractWrite(args) + if (!result.success) { + throw new ContractWriteError(args as any, result) + } + return result } // ================================================================================================= @@ -260,18 +255,17 @@ export async function contractWriteThrowing - /** The arguments to the {@link contractWrite} call. */ - args: ContractWriteArgs - - /** The failing result of the {@link contractWrite} call. */ - result: ContractWriteResultFailed + /** The failing result of the {@link contractWrite} call. */ + result: ContractWriteResultFailed - constructor(args: ContractWriteArgs, result: ContractWriteResultFailed) { - super("Contract write failed.") - this.args = args - this.result = result - } + constructor(args: ContractWriteArgs, result: ContractWriteResultFailed) { + super("Contract write failed.") + this.args = args + this.result = result + } } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/actions/playCard.ts b/packages/webapp/src/actions/playCard.ts index 1e0e7f13..da37e81e 100644 --- a/packages/webapp/src/actions/playCard.ts +++ b/packages/webapp/src/actions/playCard.ts @@ -12,13 +12,7 @@ import { deployment } from "src/deployment" import { packCards } from "src/game/fableProofs" import { gameABI } from "src/generated" import { getOrInitPrivateInfo, setPrivateInfo } from "src/store/write" -import { - getCards, - getCurrentPlayerAddress, - getGameData, - getGameID, - getPlayerAddress -} from "src/store/read" +import { getCards, getCurrentPlayerAddress, getGameData, getGameID, getPlayerAddress } from "src/store/read" import { GameStep, PrivateInfo } from "src/store/types" import { FAKE_PROOF, proveInWorker, SHOULD_GENERATE_PROOFS } from "src/utils/zkproofs" import { bigintToHexString } from "src/utils/js-utils" @@ -30,11 +24,11 @@ import { checkFresh, freshWrap } from "src/store/checkFresh" // ================================================================================================= export type PlayGameArgs = { - gameID: bigint - playerAddress: Address - cardIndexInHand: number - setLoading: (label: string | null) => void - cancellationHandler: CancellationHandler + gameID: bigint + playerAddress: Address + cardIndexInHand: number + setLoading: (label: string | null) => void + cancellationHandler: CancellationHandler } // ================================================================================================= @@ -45,94 +39,91 @@ export type PlayGameArgs = { * Returns `true` iff the player successfully played the card. */ export async function playCard(args: PlayGameArgs): Promise { - try { - return await playCardImpl(args) - } catch (err) { - args.setLoading(null) - return defaultErrorHandling("playCard", err) - } + try { + return await playCardImpl(args) + } catch (err) { + args.setLoading(null) + return defaultErrorHandling("playCard", err) + } } // ------------------------------------------------------------------------------------------------- async function playCardImpl(args: PlayGameArgs): Promise { - const gameID = getGameID() - const playerAddress = getPlayerAddress() - const gameData = getGameData() - - if (gameID !== args.gameID || playerAddress !== args.playerAddress || gameData === null) - return false // old/stale call - - if (getCurrentPlayerAddress(gameData) !== playerAddress || gameData.currentStep !== GameStep.PLAY) - return false // old/stale call - - const privateInfo: PrivateInfo = getOrInitPrivateInfo(gameID, playerAddress) - - let lastIndex = privateInfo.handIndexes.findIndex((card) => card === 255) - 1 - if (lastIndex < 0) lastIndex = privateInfo.handIndexes.length - 1 - - const hand = [...privateInfo.handIndexes] - const card = hand[args.cardIndexInHand] - hand[args.cardIndexInHand] = hand[lastIndex] - hand[lastIndex] = 255 - - const oldHandRoot = privateInfo.handRoot - - const handRootInputs = [...packCards(hand), privateInfo.salt] - const newHandRoot: HexString = `0x${bigintToHexString(mimcHash(handRootInputs), 32)}` - - const cards = getCards()! - console.log(`played card ${cards[card]}`) - - args.setLoading("Generating play proof ...") - - let proof = FAKE_PROOF - - if (SHOULD_GENERATE_PROOFS) { - const { promise, cancel } = proveInWorker("Play", { - // public inputs - handRoot: oldHandRoot, - newHandRoot: newHandRoot, - saltHash: privateInfo.saltHash, - cardIndex: BigInt(args.cardIndexInHand), - lastIndex: BigInt(lastIndex), - playedCard: BigInt(card), - // private inputs - salt: privateInfo.salt, - hand: packCards(privateInfo.handIndexes), - newHand: packCards(hand) - }, PLAY_CARD_PROOF_TIMEOUT) - - args.cancellationHandler.register(cancel) - proof = checkFresh(await freshWrap(promise)) - args.cancellationHandler.deregister(cancel) - } - - checkFresh(await freshWrap( - contractWriteThrowing({ - contract: deployment.Game, - abi: gameABI, - functionName: "playCard", - args: [ - gameID, - newHandRoot, - args.cardIndexInHand, - card, - proof.proof_a, - proof.proof_b, - proof.proof_c - ], - setLoading: args.setLoading - }))) - - // TODO: This should be put in an optimistic store before sending the transaction. - setPrivateInfo(gameID, playerAddress, { - ...privateInfo, - handIndexes: hand, - handRoot: newHandRoot - }) - - return true + const gameID = getGameID() + const playerAddress = getPlayerAddress() + const gameData = getGameData() + + if (gameID !== args.gameID || playerAddress !== args.playerAddress || gameData === null) return false // old/stale call + + if (getCurrentPlayerAddress(gameData) !== playerAddress || gameData.currentStep !== GameStep.PLAY) return false // old/stale call + + const privateInfo: PrivateInfo = getOrInitPrivateInfo(gameID, playerAddress) + + let lastIndex = privateInfo.handIndexes.findIndex((card) => card === 255) - 1 + if (lastIndex < 0) lastIndex = privateInfo.handIndexes.length - 1 + + const hand = [...privateInfo.handIndexes] + const card = hand[args.cardIndexInHand] + hand[args.cardIndexInHand] = hand[lastIndex] + hand[lastIndex] = 255 + + const oldHandRoot = privateInfo.handRoot + + const handRootInputs = [...packCards(hand), privateInfo.salt] + const newHandRoot: HexString = `0x${bigintToHexString(mimcHash(handRootInputs), 32)}` + + const cards = getCards()! + console.log(`played card ${cards[card]}`) + + args.setLoading("Generating play proof ...") + + let proof = FAKE_PROOF + + if (SHOULD_GENERATE_PROOFS) { + const { promise, cancel } = proveInWorker( + "Play", + { + // public inputs + handRoot: oldHandRoot, + newHandRoot: newHandRoot, + saltHash: privateInfo.saltHash, + cardIndex: BigInt(args.cardIndexInHand), + lastIndex: BigInt(lastIndex), + playedCard: BigInt(card), + // private inputs + salt: privateInfo.salt, + hand: packCards(privateInfo.handIndexes), + newHand: packCards(hand), + }, + PLAY_CARD_PROOF_TIMEOUT + ) + + args.cancellationHandler.register(cancel) + proof = checkFresh(await freshWrap(promise)) + args.cancellationHandler.deregister(cancel) + } + + checkFresh( + await freshWrap( + contractWriteThrowing({ + contract: deployment.Game, + abi: gameABI, + functionName: "playCard", + args: [gameID, newHandRoot, args.cardIndexInHand, card, proof.proof_a, proof.proof_b, proof.proof_c], + setLoading: args.setLoading, + }) + ) + ) + + // TODO: This should be put in an optimistic store before sending the transaction. + setPrivateInfo(gameID, playerAddress, { + ...privateInfo, + handIndexes: hand, + handRoot: newHandRoot, + }) + + return true } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/chain.ts b/packages/webapp/src/chain.ts index 2e70a9c0..348f8eb3 100644 --- a/packages/webapp/src/chain.ts +++ b/packages/webapp/src/chain.ts @@ -31,34 +31,33 @@ const burnerConnectors = process.env.NODE_ENV === "development" ? [new BurnerCon // ------------------------------------------------------------------------------------------------- const metadata = { - name: "0xFable", - description: "Wizards & shit", - // url: "https://0xFable.org", - // icon: "https://0xFable.org/favicon.png", + name: "0xFable", + description: "Wizards & shit", + // url: "https://0xFable.org", + // icon: "https://0xFable.org/favicon.png", } const metaConfig = { - walletConnectProjectId, - chains, - appName: metadata.name, - appDescription: metadata.description, - // appUrl: metadata.url, - // appIcon: metadata.icon, - app: metadata + walletConnectProjectId, + chains, + appName: metadata.name, + appDescription: metadata.description, + // appUrl: metadata.url, + // appIcon: metadata.icon, + app: metadata, } /** Wagmi's configuration, to be passed to the React WagmiConfig provider. */ export const wagmiConfig = createConfig( - getDefaultConfig({ - ...metaConfig, - // In dev, we probably want to use the ?index=X parameters, and autoconnect causes - // race conditions, leading to connecting via the parameter, disconnecting via autoconnect, - // then reconnecting via the parameter. - autoConnect: process.env.NODE_ENV !== "development", - connectors: [ - ...getDefaultConnectors(metaConfig), - ...burnerConnectors], -})) + getDefaultConfig({ + ...metaConfig, + // In dev, we probably want to use the ?index=X parameters, and autoconnect causes + // race conditions, leading to connecting via the parameter, disconnecting via autoconnect, + // then reconnecting via the parameter. + autoConnect: process.env.NODE_ENV !== "development", + connectors: [...getDefaultConnectors(metaConfig), ...burnerConnectors], + }) +) // ================================================================================================= // TYPES @@ -89,8 +88,8 @@ export type HexString = `0x${string}` * Simplification of wagmi's unexported GetAccountResult. */ export type AccountResult = { - status: 'disconnected' | 'connecting' | 'connected' | 'reconnecting' - address?: Address + status: "disconnected" | "connecting" | "connected" | "reconnecting" + address?: Address } // ------------------------------------------------------------------------------------------------- @@ -99,7 +98,7 @@ export type AccountResult = { * Simplification of wagmi's unexported GetNetworkResult. */ export type NetworkResult = { - chain?: Chain + chain?: Chain } // ================================================================================================= @@ -109,7 +108,7 @@ export type NetworkResult = { * disconnecting from another Wagmi connector if necessary. */ export async function ensureLocalAccountIndex(index: number) { - await burnerConnectors[0].ensureConnectedToIndex(index) + await burnerConnectors[0].ensureConnectedToIndex(index) } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/components/cards/boardCard.tsx b/packages/webapp/src/components/cards/boardCard.tsx index d4b271fa..3c672e86 100644 --- a/packages/webapp/src/components/cards/boardCard.tsx +++ b/packages/webapp/src/components/cards/boardCard.tsx @@ -1,60 +1,56 @@ -import { forwardRef, useState } from 'react' -import Image from 'next/image' -import React from 'react' -import { testCards } from 'src/utils/card-list' +import { forwardRef, useState } from "react" +import Image from "next/image" +import React from "react" +import { testCards } from "src/utils/card-list" interface BoardCardProps { - id: number -} + id: number +} const BoardCard = forwardRef(({ id }, ref) => { - const [ showCardName, setShowCardName ] = useState(false) + const [showCardName, setShowCardName] = useState(false) - return ( -
- setShowCardName(true) - } - onMouseLeave={() => - setShowCardName(false) - } - > - {`${id}`} - {showCardName && ( - <> -
-
- {`${testCards[id]?.attack}`} -
-
- {`${testCards[id]?.defense}`} -
-
+ return ( +
setShowCardName(true)} + onMouseLeave={() => setShowCardName(false)} + > + {`${id}`} + {showCardName && ( + <> +
+
+ {`${testCards[id]?.attack}`} +
+
+ {`${testCards[id]?.defense}`} +
+
- - {`${testCards[id]?.name}`} - - - )} -
- ) + + {`${testCards[id]?.name}`} + + + )} +
+ ) }) BoardCard.displayName = "BoardCard" -export default BoardCard \ No newline at end of file +export default BoardCard diff --git a/packages/webapp/src/components/cards/cardContainer.tsx b/packages/webapp/src/components/cards/cardContainer.tsx index b473aaf4..6267b9ca 100644 --- a/packages/webapp/src/components/cards/cardContainer.tsx +++ b/packages/webapp/src/components/cards/cardContainer.tsx @@ -8,68 +8,56 @@ import { CSS } from "@dnd-kit/utilities" import { convertStringToSafeNumber } from "src/utils/js-utils" interface BaseCardProps { - id: string - className?: string - handHovered?: boolean - placement: CardPlacement - cardGlow?: boolean + id: string + className?: string + handHovered?: boolean + placement: CardPlacement + cardGlow?: boolean } -const CardContainer: React.FC = ({ - id, - handHovered, - placement, - cardGlow -}) => { - const { - attributes, - listeners, - setNodeRef, - isDragging, - transform, - transition, - } = useSortable({ - id: placement === CardPlacement.BOARD ? `B-${id}` : `H-${id}`, - }) +const CardContainer: React.FC = ({ id, handHovered, placement, cardGlow }) => { + const { attributes, listeners, setNodeRef, isDragging, transform, transition } = useSortable({ + id: placement === CardPlacement.BOARD ? `B-${id}` : `H-${id}`, + }) - const sortableStyle = { - transform: CSS.Transform.toString(transform), - transition, - } + const sortableStyle = { + transform: CSS.Transform.toString(transform), + transition, + } - const idAsNum = convertStringToSafeNumber(id); // to refer to cards in JSON file + const idAsNum = convertStringToSafeNumber(id) // to refer to cards in JSON file - const renderCardContent = () => { - switch (placement) { - case CardPlacement.HAND: - return ( - - ) - case CardPlacement.BOARD: - return - case CardPlacement.DRAGGED: - return - default: - return null + const renderCardContent = () => { + switch (placement) { + case CardPlacement.HAND: + return ( + + ) + case CardPlacement.BOARD: + return + case CardPlacement.DRAGGED: + return + default: + return null + } } - } - return ( -
- {renderCardContent()} -
- ) + return ( +
+ {renderCardContent()} +
+ ) } export default CardContainer diff --git a/packages/webapp/src/components/cards/draggedCard.tsx b/packages/webapp/src/components/cards/draggedCard.tsx index 123c52e3..bf136701 100644 --- a/packages/webapp/src/components/cards/draggedCard.tsx +++ b/packages/webapp/src/components/cards/draggedCard.tsx @@ -3,25 +3,25 @@ import React, { forwardRef } from "react" import { testCards } from "src/utils/card-list" interface DraggedCardProps { - id: number + id: number } const DraggedCard = forwardRef(({ id }, ref) => { - return ( - <> - {`${id}`} - - ) + return ( + <> + {`${id}`} + + ) }) DraggedCard.displayName = "DraggedCard" diff --git a/packages/webapp/src/components/cards/handCard.tsx b/packages/webapp/src/components/cards/handCard.tsx index bfcf3839..61ada527 100644 --- a/packages/webapp/src/components/cards/handCard.tsx +++ b/packages/webapp/src/components/cards/handCard.tsx @@ -3,74 +3,73 @@ import Image from "next/image" import { testCards } from "src/utils/card-list" interface HandCardProps { - id: number - handHovered?: boolean - isDragging: boolean - cardGlow?: boolean + id: number + handHovered?: boolean + isDragging: boolean + cardGlow?: boolean } -const HandCard = forwardRef( - ({ id, isDragging, handHovered, cardGlow }, ref) => { - const [ cardHover, setCardHover ] = useState(false) - const [ isDetailsVisible, setIsDetailsVisible ] = useState(false) +const HandCard = forwardRef(({ id, isDragging, handHovered, cardGlow }, ref) => { + const [cardHover, setCardHover] = useState(false) + const [isDetailsVisible, setIsDetailsVisible] = useState(false) const showingDetails = isDetailsVisible && !isDragging return ( -
{ - setIsDetailsVisible(!isDetailsVisible) - }} - onMouseEnter={() => setCardHover(true)} - onMouseLeave={() => { - setCardHover(false) - setIsDetailsVisible(false) - }} - ref={ref} - > - { + setIsDetailsVisible(!isDetailsVisible) + }} + onMouseEnter={() => setCardHover(true)} + onMouseLeave={() => { + setCardHover(false) + setIsDetailsVisible(false) + }} + ref={ref} > - {testCards[id]?.name} - - {`${id}`} - {showingDetails && ( - <> -

- {testCards[id]?.description} -

-
-
-

⚔️ {testCards[id]?.attack}

-

🛡 {testCards[id]?.defense}

-
- - )} -
+ + {testCards[id]?.name} + + {`${id}`} + {showingDetails && ( + <> +

+ {testCards[id]?.description} +

+
+
+

⚔️ {testCards[id]?.attack}

+

🛡 {testCards[id]?.defense}

+
+ + )} + ) - } -) +}) HandCard.displayName = "HandCard" -export default HandCard \ No newline at end of file +export default HandCard diff --git a/packages/webapp/src/components/collection/cardCollectionDisplay.tsx b/packages/webapp/src/components/collection/cardCollectionDisplay.tsx index 912c8fbf..0a2faadc 100644 --- a/packages/webapp/src/components/collection/cardCollectionDisplay.tsx +++ b/packages/webapp/src/components/collection/cardCollectionDisplay.tsx @@ -1,56 +1,75 @@ -import React from 'react' -import Image from 'next/image' -import { Card } from 'src/store/types' -import { MintDeckModal } from 'src/components/modals/mintDeckModal' -import { testCards } from 'src/utils/card-list' - -interface CardCollectionDisplayProps { - cards: Card[] - isHydrated: boolean - setSelectedCard: (card: Card | null) => void - onCardToggle: (card: Card) => void - selectedCards: Card[] - isEditing: boolean -} - -const CardCollectionDisplay: React.FC = ({ cards, isHydrated, setSelectedCard, selectedCards, onCardToggle, isEditing }) => { - return ( - <> -
- {isHydrated && cards.length === 0 && ( -
- -
- )} - - {isHydrated && cards.length > 0 && ( -
- {cards.map((card, index) => ( -
c.id === card.id) ? 'shadow-highlight shadow-orange-300' : '' - } hover:bg-slate-800 rounded-lg p-4 border-4 border-slate-900 grow w-[220px] max-w-[330px]`} - onMouseEnter={() => setSelectedCard(card)} - onClick={() => { - if (isEditing) { - onCardToggle(card) - } - }} - > - Number(tc.id) === index + 1)?.image || ""} alt={card.lore.name} width={256} height={256} /> -
{card.lore.name}
-
-
{card.stats.attack}
-
{card.stats.defense}
-
-
- ))} -
- )} -
- - ) -} - -export default CardCollectionDisplay +import React from "react" +import Image from "next/image" +import { Card } from "src/store/types" +import { MintDeckModal } from "src/components/modals/mintDeckModal" +import { testCards } from "src/utils/card-list" + +interface CardCollectionDisplayProps { + cards: Card[] + isHydrated: boolean + setSelectedCard: (card: Card | null) => void + onCardToggle: (card: Card) => void + selectedCards: Card[] + isEditing: boolean +} + +const CardCollectionDisplay: React.FC = ({ + cards, + isHydrated, + setSelectedCard, + selectedCards, + onCardToggle, + isEditing, +}) => { + return ( + <> +
+ {isHydrated && cards.length === 0 && ( +
+ +
+ )} + + {isHydrated && cards.length > 0 && ( +
+ {cards.map((card, index) => ( +
c.id === card.id) + ? "shadow-highlight shadow-orange-300" + : "" + } hover:bg-slate-800 rounded-lg p-4 border-4 border-slate-900 grow w-[220px] max-w-[330px]`} + onMouseEnter={() => setSelectedCard(card)} + onClick={() => { + if (isEditing) { + onCardToggle(card) + } + }} + > + Number(tc.id) === index + 1)?.image || ""} + alt={card.lore.name} + width={256} + height={256} + /> +
{card.lore.name}
+
+
+ {card.stats.attack} +
+
+ {card.stats.defense} +
+
+
+ ))} +
+ )} +
+ + ) +} + +export default CardCollectionDisplay diff --git a/packages/webapp/src/components/collection/deckList.tsx b/packages/webapp/src/components/collection/deckList.tsx index c4fcf0f6..0d6c312a 100644 --- a/packages/webapp/src/components/collection/deckList.tsx +++ b/packages/webapp/src/components/collection/deckList.tsx @@ -1,36 +1,40 @@ -import React from 'react' -import Link from "src/components/link" -import { Deck } from 'src/store/types' -import { Button } from "src/components/ui/button" - -interface DeckCollectionDisplayProps { - decks: Deck[] - onDeckSelect: (deckID: number) => void -} - -const DeckCollectionDisplay: React.FC = ({ decks, onDeckSelect }) => { - return ( -
- {/* New Deck Button */} -
- -
- - {/* Deck Buttons */} - {decks.map((deck, deckID) => ( - - ))} -
- ) -} - -export default DeckCollectionDisplay \ No newline at end of file +import React from "react" +import Link from "src/components/link" +import { Deck } from "src/store/types" +import { Button } from "src/components/ui/button" + +interface DeckCollectionDisplayProps { + decks: Deck[] + onDeckSelect: (deckID: number) => void +} + +const DeckCollectionDisplay: React.FC = ({ decks, onDeckSelect }) => { + return ( +
+ {/* New Deck Button */} +
+ +
+ + {/* Deck Buttons */} + {decks.map((deck, deckID) => ( + + ))} +
+ ) +} + +export default DeckCollectionDisplay diff --git a/packages/webapp/src/components/collection/deckPanel.tsx b/packages/webapp/src/components/collection/deckPanel.tsx index f9faeac1..058f8ea2 100644 --- a/packages/webapp/src/components/collection/deckPanel.tsx +++ b/packages/webapp/src/components/collection/deckPanel.tsx @@ -1,88 +1,105 @@ -import React, { useState } from 'react' -import { Deck, Card } from 'src/store/types' -import Image from 'next/image' -import { testCards } from 'src/utils/card-list' -import { Button } from "src/components/ui/button" - -interface DeckConstructionPanelProps { - deck: Deck - selectedCards: Card[] - onCardSelect: (card: Card) => void - onSave: (deck: Deck) => void - onCancel: () => void - } - - - const DeckConstructionPanel : React.FC = ({ deck, selectedCards = [], onCardSelect, onSave, onCancel }) => { - const [ deckName, setDeckName ] = useState(deck.name) - const [ deckNameValid, setIsDeckNameValid ] = useState(false) - - const nameValid = (name: string) => name.trim().length > 0 - - const handleDeckNameChange = (event: React.ChangeEvent) => { - const newName = event.target.value - setDeckName(event.target.value) - setIsDeckNameValid(nameValid(newName)) - } - - const handleSave = () => { - if(!nameValid(deckName)) return - - const newDeck = { - name: deckName.trim(), - cards: selectedCards - } - - onSave(newDeck) - } - - return ( -
- {/* Deck Name Input */} -
- -
- - {/* Save and Cancel Buttons */} -
- - -
- - {/* List of Cards in the Deck */} -
- {selectedCards.length > 0 ? ( - selectedCards.map((card, index) => ( -
onCardSelect(card)} - > -
- tc.id === Number(card.id))?.image || '/card_art/1.jpg'} alt="Card art" width={40} height={40} className="object-cover rounded-full" /> - {card.lore.name} -
-
- )) - ) : ( -
- Click on cards to add them to the deck. -
- )} -
-
- ) -} - -export default DeckConstructionPanel \ No newline at end of file +import React, { useState } from "react" +import { Deck, Card } from "src/store/types" +import Image from "next/image" +import { testCards } from "src/utils/card-list" +import { Button } from "src/components/ui/button" + +interface DeckConstructionPanelProps { + deck: Deck + selectedCards: Card[] + onCardSelect: (card: Card) => void + onSave: (deck: Deck) => void + onCancel: () => void +} + +const DeckConstructionPanel: React.FC = ({ + deck, + selectedCards = [], + onCardSelect, + onSave, + onCancel, +}) => { + const [deckName, setDeckName] = useState(deck.name) + const [deckNameValid, setIsDeckNameValid] = useState(false) + + const nameValid = (name: string) => name.trim().length > 0 + + const handleDeckNameChange = (event: React.ChangeEvent) => { + const newName = event.target.value + setDeckName(event.target.value) + setIsDeckNameValid(nameValid(newName)) + } + + const handleSave = () => { + if (!nameValid(deckName)) return + + const newDeck = { + name: deckName.trim(), + cards: selectedCards, + } + + onSave(newDeck) + } + + return ( +
+ {/* Deck Name Input */} +
+ +
+ + {/* Save and Cancel Buttons */} +
+ + +
+ + {/* List of Cards in the Deck */} +
+ {selectedCards.length > 0 ? ( + selectedCards.map((card, index) => ( +
onCardSelect(card)} + > +
+ tc.id === Number(card.id))?.image || "/card_art/1.jpg"} + alt="Card art" + width={40} + height={40} + className="object-cover rounded-full" + /> + {card.lore.name} +
+
+ )) + ) : ( +
Click on cards to add them to the deck.
+ )} +
+
+ ) +} + +export default DeckConstructionPanel diff --git a/packages/webapp/src/components/collection/filterPanel.tsx b/packages/webapp/src/components/collection/filterPanel.tsx index 43b00274..5897943f 100644 --- a/packages/webapp/src/components/collection/filterPanel.tsx +++ b/packages/webapp/src/components/collection/filterPanel.tsx @@ -1,84 +1,91 @@ -import React from 'react' -import Image from 'next/image' -import { Card } from 'src/store/types' - -interface FilterPanelProps { - effects: string[] - types: string[] - effectMap: { [key: string]: boolean } - typeMap: { [key: string]: boolean } - handleEffectClick: (index: number) => void - handleTypeClick: (index: number) => void - handleInputChange: (event: React.ChangeEvent) => void - selectedCard: Card | null -} - -const FilterPanel: React.FC = ({ - effects, - types, - effectMap, - typeMap, - handleEffectClick, - handleTypeClick, - handleInputChange, - selectedCard -}) => { - const cardName = selectedCard?.lore.name || "Select a card" - const cardFlavor = selectedCard?.lore.flavor || "Select a card to see its details" - - return ( -
-
- {/* Search */} -

Search

-
- -
- - {/* Effects */} -

Effects

-
- {effects.map((effect, index) => ( - ) - )} -
- - {/* Types */} -

Types

-
- {types.map((type, index) => ( - ) - )} -
- - {/* todo @eviterin: makes sense to add a filter for the card collection display to only show one of each card. */} - - {/* Selected Card Display */} -
-

Card details

-
- {cardName} -
{cardName}
-
-
{cardFlavor}
-
-
-
- ) -} - -export default FilterPanel \ No newline at end of file +import React from "react" +import Image from "next/image" +import { Card } from "src/store/types" + +interface FilterPanelProps { + effects: string[] + types: string[] + effectMap: { [key: string]: boolean } + typeMap: { [key: string]: boolean } + handleEffectClick: (index: number) => void + handleTypeClick: (index: number) => void + handleInputChange: (event: React.ChangeEvent) => void + selectedCard: Card | null +} + +const FilterPanel: React.FC = ({ + effects, + types, + effectMap, + typeMap, + handleEffectClick, + handleTypeClick, + handleInputChange, + selectedCard, +}) => { + const cardName = selectedCard?.lore.name || "Select a card" + const cardFlavor = selectedCard?.lore.flavor || "Select a card to see its details" + + return ( +
+
+ {/* Search */} +

Search

+
+ +
+ + {/* Effects */} +

Effects

+
+ {effects.map((effect, index) => ( + + ))} +
+ + {/* Types */} +

Types

+
+ {types.map((type, index) => ( + + ))} +
+ + {/* todo @eviterin: makes sense to add a filter for the card collection display to only show one of each card. */} + + {/* Selected Card Display */} +
+

Card details

+
+ {cardName} +
{cardName}
+
+
{cardFlavor}
+
+
+
+ ) +} + +export default FilterPanel diff --git a/packages/webapp/src/components/hand.tsx b/packages/webapp/src/components/hand.tsx index c3fddfd2..f2724475 100644 --- a/packages/webapp/src/components/hand.tsx +++ b/packages/webapp/src/components/hand.tsx @@ -1,105 +1,95 @@ import { useEffect, useRef, useState } from "react" import { AiOutlineLeft, AiOutlineRight } from "react-icons/ai" import useScrollBox from "../hooks/useScrollBox" -import { - SortableContext, - horizontalListSortingStrategy, - useSortable, -} from "@dnd-kit/sortable" +import { SortableContext, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable" import { CardPlacement } from "src/store/types" import CardContainer from "./cards/cardContainer" import { convertBigIntArrayToStringArray } from "src/utils/js-utils" import { CancellationHandler } from "src/components/modals/loadingModal" const Hand = ({ - cards, - className, + cards, + className, }: { - cards: readonly bigint[] | null - className?: string - setLoading: (label: string | null) => void - cancellationHandler: CancellationHandler + cards: readonly bigint[] | null + className?: string + setLoading: (label: string | null) => void + cancellationHandler: CancellationHandler }) => { - const [isFocused, setIsFocused] = useState(false) - const scrollWrapperRef = useRef(null) - const { - showLeftArrow, - scrollLeft, - showRightArrow, - scrollRight, - isLastCardGlowing, - } = useScrollBox(scrollWrapperRef, cards) + const [isFocused, setIsFocused] = useState(false) + const scrollWrapperRef = useRef(null) + const { showLeftArrow, scrollLeft, showRightArrow, scrollRight, isLastCardGlowing } = useScrollBox( + scrollWrapperRef, + cards + ) - const { setNodeRef } = useSortable({ - id: CardPlacement.HAND, - }) + const { setNodeRef } = useSortable({ + id: CardPlacement.HAND, + }) - const convertedCards = convertBigIntArrayToStringArray(cards) - const range = convertedCards?.map((_, index) => index + 1) ?? [] + const convertedCards = convertBigIntArrayToStringArray(cards) + const range = convertedCards?.map((_, index) => index + 1) ?? [] - useEffect(() => { - const handleResize = () => { - setIsFocused(true) - } - window.addEventListener("resize", handleResize) - return () => { - window.removeEventListener("resize", handleResize) - } - }, []) + useEffect(() => { + const handleResize = () => { + setIsFocused(true) + } + window.addEventListener("resize", handleResize) + return () => { + window.removeEventListener("resize", handleResize) + } + }, []) - return ( -
{ - setIsFocused(true) - }} - onMouseLeave={() => { - setIsFocused(false) - }} - > - {showLeftArrow && isFocused && ( + return (
{ + setIsFocused(true) + }} + onMouseLeave={() => { + setIsFocused(false) + }} > - -
- )} -
-
-
-
- - {range.map((index) => ( -
- -
- ))} -
+ {showLeftArrow && isFocused && ( +
+ +
+ )} +
+
+
+
+ + {range.map((index) => ( +
+ +
+ ))} +
+
+
+
-
-
-
- {showRightArrow && isFocused && ( -
- + {showRightArrow && isFocused && ( +
+ +
+ )}
- )} -
- ) + ) } export default Hand diff --git a/packages/webapp/src/components/lib/jotaiDebug.tsx b/packages/webapp/src/components/lib/jotaiDebug.tsx index fac1284f..6cd9e35e 100644 --- a/packages/webapp/src/components/lib/jotaiDebug.tsx +++ b/packages/webapp/src/components/lib/jotaiDebug.tsx @@ -1,21 +1,19 @@ import { useAtomsDebugValue, useAtomsDevtools } from "jotai-devtools" const JotaiDebug = () => { - // An atom that contains a list of all the names and values of all the atoms in the app. - // This enables inspecting them the in the React devtool extension. - // (By default in Next, the atoms are listed but they don't have their proper names.) - // Note that the naming here relies on atoms having their `debugLabel` properties set. - useAtomsDebugValue() - // Enables tracking atom value changes in the Redux dev tool, as well as time travelling, etc - // The Redux dev tool needs to be open and a state change to happen for it to display anything. - useAtomsDevtools("atomDevtools") - return null + // An atom that contains a list of all the names and values of all the atoms in the app. + // This enables inspecting them the in the React devtool extension. + // (By default in Next, the atoms are listed but they don't have their proper names.) + // Note that the naming here relies on atoms having their `debugLabel` properties set. + useAtomsDebugValue() + // Enables tracking atom value changes in the Redux dev tool, as well as time travelling, etc + // The Redux dev tool needs to be open and a state change to happen for it to display anything. + useAtomsDevtools("atomDevtools") + return null } export default function jotaiDebug() { - // The first clause guards against server-side rendering. - if (typeof window !== "undefined" && process.env.NODE_ENV === "development") - return - else - return null -} \ No newline at end of file + // The first clause guards against server-side rendering. + if (typeof window !== "undefined" && process.env.NODE_ENV === "development") return + else return null +} diff --git a/packages/webapp/src/components/lib/modal.tsx b/packages/webapp/src/components/lib/modal.tsx index 23019dc2..cf118bae 100644 --- a/packages/webapp/src/components/lib/modal.tsx +++ b/packages/webapp/src/components/lib/modal.tsx @@ -44,85 +44,78 @@ import { createPortal } from "react-dom" * built-in modal look and, the passed down modal's children. It can also hide the modal depending * on the value of `displayed`. */ -export const Modal = ({ ctrl, children }: { ctrl: ModalController, children: ReactNode }) => { +export const Modal = ({ ctrl, children }: { ctrl: ModalController; children: ReactNode }) => { + const [loaded, setLoaded] = useState(ctrl.state.loaded) + const isMounted = useIsMounted() - const [ loaded, setLoaded ] = useState(ctrl.state.loaded) - const isMounted = useIsMounted() + ctrl.setLoaded = setLoaded + ctrl.isMounted = isMounted - ctrl.setLoaded = setLoaded - ctrl.isMounted = isMounted - - return <> - {loaded && createPortal( - - {children} - , - document.body)} - + return <>{loaded && createPortal({children}, document.body)} } // ----------------------------------------------------------------------------------------------- -const ModalInner = ({ ctrl, children }: { ctrl: ModalController, children: ReactNode }) => { - - const [ state, setState ] = useState(ctrl.state) - ctrl.setState = setState - - const errorConfig = useErrorConfig() - const dialogRef = useRef(null as HTMLDialogElement|null) - - // If an errorConfig is set, the modal should be hidden. - const displayed = state.displayed && (!errorConfig || state.displayedOnError) - - // Doing the below dance to show the modal via `showModal` buys us a few things: - // - focus is restricted to the modal - // - ESC key closes the modal without custom code (though it's easy to setup) if desired - - const dialogEscapeHandler = (event: Event) => { - if (!ctrl.state.closeable) // state from useState might be stale! - // don't close dialog on ESC if it's not closeable - event.preventDefault() - else - // in addition to closing the dialog (default behaviour), this will update the controller state - ctrl.close() - } - - const dialogRefCallback = (dialog: HTMLDialogElement|null) => { - if (dialog === null) return - dialogRef.current = dialog - dialog.addEventListener('cancel', dialogEscapeHandler) - if (displayed && dialog.getAttribute("open") === null) - // If the modal should be displayed but isn't. - dialog.showModal() - if (!displayed && dialog.getAttribute("open") !== null) - // If the modal should be hidden but isn't. - dialog.close() - } - - if (!state.loaded) - console.error("Modal rendered but its loaded property is false") - - // ----------------------------------------------------------------------------------------------- - - return <> - -
- {state.closeable && - } - {/* The onClick handler here is crucial to avoid click on buttons etc inside the modal +const ModalInner = ({ ctrl, children }: { ctrl: ModalController; children: ReactNode }) => { + const [state, setState] = useState(ctrl.state) + ctrl.setState = setState + + const errorConfig = useErrorConfig() + const dialogRef = useRef(null as HTMLDialogElement | null) + + // If an errorConfig is set, the modal should be hidden. + const displayed = state.displayed && (!errorConfig || state.displayedOnError) + + // Doing the below dance to show the modal via `showModal` buys us a few things: + // - focus is restricted to the modal + // - ESC key closes the modal without custom code (though it's easy to setup) if desired + + const dialogEscapeHandler = (event: Event) => { + if (!ctrl.state.closeable) + // state from useState might be stale! + // don't close dialog on ESC if it's not closeable + event.preventDefault() + // in addition to closing the dialog (default behaviour), this will update the controller state + else ctrl.close() + } + + const dialogRefCallback = (dialog: HTMLDialogElement | null) => { + if (dialog === null) return + dialogRef.current = dialog + dialog.addEventListener("cancel", dialogEscapeHandler) + if (displayed && dialog.getAttribute("open") === null) + // If the modal should be displayed but isn't. + dialog.showModal() + if (!displayed && dialog.getAttribute("open") !== null) + // If the modal should be hidden but isn't. + dialog.close() + } + + if (!state.loaded) console.error("Modal rendered but its loaded property is false") + + // ----------------------------------------------------------------------------------------------- + + return ( + <> + +
+ {state.closeable && ( + + )} + {/* The onClick handler here is crucial to avoid click on buttons etc inside the modal from toggling the modal. */} -
e.stopPropagation()}> - {children} -
-
-
- +
e.stopPropagation()}>{children}
+
+
+ + ) } // ================================================================================================= @@ -132,8 +125,8 @@ const ModalInner = ({ ctrl, children }: { ctrl: ModalController, children: React * makes sure there is only one controller over the lifetime of the calling component. */ export function useModalController(initial: Partial): ModalController { - const [ ctrl ] = useState(() => new ModalController(initial)) - return ctrl + const [ctrl] = useState(() => new ModalController(initial)) + return ctrl } // ================================================================================================= @@ -142,36 +135,36 @@ export function useModalController(initial: Partial): ModalControlle * The state of a modal. See {@link Modal} for more detail on the modal's operation. */ export type ModalState = { - /** - * Whether the modal is loaded (render function called, has React state). - */ - loaded: boolean - /** - * Whether the modal is displayed if loaded (default: true). - * - * The modal could still be hidden despite its `displayed` property being true if there is an - * error (i.e. an {@link ErrorConfig} is set). - */ - displayed: boolean - /** - * Whether the modal is closeable (default: true). - */ - closeable: boolean - /** - * Whether the modal is closeable by clicking outside it, if closeable at all - * (default: true). - */ - surroundCloseable: boolean - /** - * Whether closing the modal hides it instead of closing it, keeping it rendered in the DOM - * (default: false). - */ - closingHides: boolean - /** - * Whether the modal should be displayed even if we're displaying an error, i.e. an {@link - * ErrorConfig} is set (default: false). - */ - displayedOnError: boolean + /** + * Whether the modal is loaded (render function called, has React state). + */ + loaded: boolean + /** + * Whether the modal is displayed if loaded (default: true). + * + * The modal could still be hidden despite its `displayed` property being true if there is an + * error (i.e. an {@link ErrorConfig} is set). + */ + displayed: boolean + /** + * Whether the modal is closeable (default: true). + */ + closeable: boolean + /** + * Whether the modal is closeable by clicking outside it, if closeable at all + * (default: true). + */ + surroundCloseable: boolean + /** + * Whether closing the modal hides it instead of closing it, keeping it rendered in the DOM + * (default: false). + */ + closingHides: boolean + /** + * Whether the modal should be displayed even if we're displaying an error, i.e. an {@link + * ErrorConfig} is set (default: false). + */ + displayedOnError: boolean } // ------------------------------------------------------------------------------------------------- @@ -184,147 +177,151 @@ export type ModalState = { * controller. */ export class ModalController { - private state_: ModalState - // @ts-ignore - private setLoaded_: (_: boolean) => void - // @ts-ignore - private setState_: (_: ModalState) => void - - /** Returns a copy of the modal state. */ - get state(): ModalState { return { ...this.state_ } } - - /** Whether the modal is currently displayed. */ - get displayed(): boolean { return this.state_.displayed } - - /** - * Sets the function needed to udpate the the `loaded` state in the outer modal. - * Only for use in the modal implementation! - */ - set setLoaded(setLoaded: (_: boolean) => void) { this.setLoaded_ = setLoaded } - - /** - * Sets the function needed to udpate the the `state` in the inner modal. - * Only for use in the modal implementation! - */ - set setState(setState: (_: ModalState) => void) { this.setState_ = setState } - - /** - * A reference indicating whether the modal is currently mounted (loaded). - * This is more reliable than the `state.loaded` because the modal can also unmount if its parent - * unmount for example. - */ - // @ts-ignore - isMounted: RefObject - - /** Creates a new modal controller. A new modal controller must be created for every modal. */ - constructor(initial: Partial) { - this.state_ = { - loaded: true, - displayed: true, - closeable: true, - surroundCloseable: true, - closingHides: false, - displayedOnError: false, - ...initial + private state_: ModalState + // @ts-ignore + private setLoaded_: (_: boolean) => void + // @ts-ignore + private setState_: (_: ModalState) => void + + /** Returns a copy of the modal state. */ + get state(): ModalState { + return { ...this.state_ } } - if (!this.state_.loaded) this.state_.displayed = false - if (!this.state_.closeable) this.state_.surroundCloseable = false - } - /** - * All state and `loaded` updates must flow through this function. - */ - private updateState = (stateUpdate: Partial) => { + /** Whether the modal is currently displayed. */ + get displayed(): boolean { + return this.state_.displayed + } - // No state to update, this might be a late callback. - if (!this.isMounted || !this.isMounted.current) return + /** + * Sets the function needed to udpate the the `loaded` state in the outer modal. + * Only for use in the modal implementation! + */ + set setLoaded(setLoaded: (_: boolean) => void) { + this.setLoaded_ = setLoaded + } - const loaded = this.state_.loaded + /** + * Sets the function needed to udpate the the `state` in the inner modal. + * Only for use in the modal implementation! + */ + set setState(setState: (_: ModalState) => void) { + this.setState_ = setState + } - // If loaded is different from current value... - if (stateUpdate.loaded !== undefined && stateUpdate.loaded !== loaded) { - this.setLoaded_(stateUpdate.loaded) + /** + * A reference indicating whether the modal is currently mounted (loaded). + * This is more reliable than the `state.loaded` because the modal can also unmount if its parent + * unmount for example. + */ + // @ts-ignore + isMounted: RefObject + + /** Creates a new modal controller. A new modal controller must be created for every modal. */ + constructor(initial: Partial) { + this.state_ = { + loaded: true, + displayed: true, + closeable: true, + surroundCloseable: true, + closingHides: false, + displayedOnError: false, + ...initial, + } + if (!this.state_.loaded) this.state_.displayed = false + if (!this.state_.closeable) this.state_.surroundCloseable = false + } - // We need to update this because we might not need to call setState if this is the only - // update or if this is going to false. - this.state_.loaded = stateUpdate.loaded + /** + * All state and `loaded` updates must flow through this function. + */ + private updateState = (stateUpdate: Partial) => { + // No state to update, this might be a late callback. + if (!this.isMounted || !this.isMounted.current) return + + const loaded = this.state_.loaded + + // If loaded is different from current value... + if (stateUpdate.loaded !== undefined && stateUpdate.loaded !== loaded) { + this.setLoaded_(stateUpdate.loaded) + + // We need to update this because we might not need to call setState if this is the only + // update or if this is going to false. + this.state_.loaded = stateUpdate.loaded + + // Nothing else to do! + if (Object.keys(stateUpdate).length === 1) return + } + + const newState = { ...this.state_, ...stateUpdate } + // Don't setState if there isn't (yet), or won't be an inner modal to update. + if (loaded && this.state_.loaded) { + this.setState_(newState) + } + // But still set the state here so that the next time the modal is loaded, the state will be + // change according to the request. + this.state_ = newState + } + + /** + * Loads the modal if not yet loaded, computing its React state and DOM elements, but keeping it + * hidden. + */ + readonly load = () => { + if (this.state_.loaded) return // already in target state + this.updateState({ loaded: true }) + } + + /** + * Displays the modal, loading it and making it visible as necessary. + */ + readonly display = () => { + if (this.state_.loaded && this.state_.displayed) return // already in target state + this.updateState({ loaded: true, displayed: true }) + } + + /** + * Hides the modal, hiding it from view, but keeping its React state and DOM elements. + */ + readonly hide = () => { + if (!this.state_.displayed) return // already in target state + this.updateState({ displayed: false }) + } + + /** + * Attempt to close the modal by closing it. Note that this will work even if the modal is not + * closeable! If `closingHides` is true, the modal is hidden instead of closed. + */ + readonly close = () => { + // note that !loaded implies !displayed + if (!this.state_.loaded) return // already in target state + if (this.state_.closingHides) this.updateState({ displayed: false }) + else this.updateState({ loaded: false, displayed: false }) + } - // Nothing else to do! - if (Object.keys(stateUpdate).length === 1) return + /** Define whether the modal can be closed or hidden. */ + set closeable(closeable: boolean) { + if (this.state_.closeable === closeable) return // already in target state + this.updateState({ closeable, surroundCloseable: closeable && this.state_.surroundCloseable }) } - const newState = { ...this.state_, ...stateUpdate } - // Don't setState if there isn't (yet), or won't be an inner modal to update. - if (loaded && this.state_.loaded) { - this.setState_(newState) + /** + * Define whether the modal can be closed or hidden by clicking outside of it. If the parameter is + * true, this will also make the modal closeable if it isn't already. + */ + set surroundCloseable(surroundCloseable: boolean) { + if (this.state_.surroundCloseable === surroundCloseable) return // already in target state + this.updateState({ surroundCloseable, closeable: this.state_.closeable || surroundCloseable }) + } + + /** + * Defines both closeability and surround-closeability at once. + * This is useful because toggling both properties would otherwise require two state updates. + */ + set closeableAndSurroundCloseable(closeable: boolean) { + if (this.state_.closeable === closeable && this.state_.surroundCloseable === closeable) return // already in target state + this.updateState({ closeable, surroundCloseable: closeable }) } - // But still set the state here so that the next time the modal is loaded, the state will be - // change according to the request. - this.state_ = newState - } - - /** - * Loads the modal if not yet loaded, computing its React state and DOM elements, but keeping it - * hidden. - */ - readonly load = () => { - if (this.state_.loaded) return // already in target state - this.updateState({ loaded: true }) - } - - /** - * Displays the modal, loading it and making it visible as necessary. - */ - readonly display = () => { - if (this.state_.loaded && this.state_.displayed) return // already in target state - this.updateState({ loaded: true, displayed: true }) - } - - /** - * Hides the modal, hiding it from view, but keeping its React state and DOM elements. - */ - readonly hide = () => { - if (!this.state_.displayed) return // already in target state - this.updateState({ displayed: false}) - } - - /** - * Attempt to close the modal by closing it. Note that this will work even if the modal is not - * closeable! If `closingHides` is true, the modal is hidden instead of closed. - */ - readonly close = () => { - // note that !loaded implies !displayed - if (!this.state_.loaded) return // already in target state - if (this.state_.closingHides) - this.updateState({ displayed: false}) - else - this.updateState({ loaded: false, displayed: false }) - } - - /** Define whether the modal can be closed or hidden. */ - set closeable(closeable: boolean) { - if (this.state_.closeable === closeable) return // already in target state - this.updateState({ closeable, surroundCloseable: closeable && this.state_.surroundCloseable }) - } - - /** - * Define whether the modal can be closed or hidden by clicking outside of it. If the parameter is - * true, this will also make the modal closeable if it isn't already. - */ - set surroundCloseable(surroundCloseable: boolean) { - if (this.state_.surroundCloseable === surroundCloseable) return // already in target state - this.updateState({ surroundCloseable, closeable: this.state_.closeable || surroundCloseable }) - } - - /** - * Defines both closeability and surround-closeability at once. - * This is useful because toggling both properties would otherwise require two state updates. - */ - set closeableAndSurroundCloseable(closeable: boolean) { - if (this.state_.closeable === closeable && this.state_.surroundCloseable === closeable) - return // already in target state - this.updateState({ closeable, surroundCloseable: closeable }) - } } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/components/lib/modalElements.tsx b/packages/webapp/src/components/lib/modalElements.tsx index 4d88a8f1..9e315eb7 100644 --- a/packages/webapp/src/components/lib/modalElements.tsx +++ b/packages/webapp/src/components/lib/modalElements.tsx @@ -4,32 +4,25 @@ import Image from "next/image" import { Button } from "src/components/ui/button" export const Spinner = () => { - return ( -
- loading -
- ) + return ( +
+ loading +
+ ) } // ------------------------------------------------------------------------------------------------- -export const ModalMenuButton = ({ - display, - label, -}: { - display: () => void - label: string -}) => { - return ( - - ) +export const ModalMenuButton = ({ display, label }: { display: () => void; label: string }) => { + return ( + + ) } // ================================================================================================= diff --git a/packages/webapp/src/components/link.tsx b/packages/webapp/src/components/link.tsx index 34f81147..9cdc831b 100644 --- a/packages/webapp/src/components/link.tsx +++ b/packages/webapp/src/components/link.tsx @@ -1,31 +1,27 @@ -import React from "react" -import { useRouter } from "next/router" -import Link from "next/link" - -interface QueryParamLinkProps { - children: React.ReactNode - href: string -} - -/** - * A Link component wrapper that appends a 'index' query parameter to the URL in development mode. - * This is used to persist state across navigation during testing. - */ -const QueryParamLink : React.FC = ({ children, href }) => { - const router = useRouter() - - let url = href - - if (process.env.NODE_ENV === "development") { - const index = parseInt(router.query.index as string) - if (index !== undefined && !isNaN(index) && 0 <= index && index <= 9) - url += (url.includes("?") ? "&" : "?") + `index=${index}` - } - return ( - - {children} - - ) -} - -export default QueryParamLink \ No newline at end of file +import React from "react" +import { useRouter } from "next/router" +import Link from "next/link" + +interface QueryParamLinkProps { + children: React.ReactNode + href: string +} + +/** + * A Link component wrapper that appends a 'index' query parameter to the URL in development mode. + * This is used to persist state across navigation during testing. + */ +const QueryParamLink: React.FC = ({ children, href }) => { + const router = useRouter() + + let url = href + + if (process.env.NODE_ENV === "development") { + const index = parseInt(router.query.index as string) + if (index !== undefined && !isNaN(index) && 0 <= index && index <= 9) + url += (url.includes("?") ? "&" : "?") + `index=${index}` + } + return {children} +} + +export default QueryParamLink diff --git a/packages/webapp/src/components/modals/createGameModal.tsx b/packages/webapp/src/components/modals/createGameModal.tsx index de68ea3a..da174081 100644 --- a/packages/webapp/src/components/modals/createGameModal.tsx +++ b/packages/webapp/src/components/modals/createGameModal.tsx @@ -13,205 +13,201 @@ import { GameStatus } from "src/store/types" import { navigate } from "src/utils/navigate" import { useCancellationHandler } from "src/hooks/useCancellationHandler" import { concede } from "src/actions/concede" -import { - Dialog, - DialogContent, - DialogDescription, - DialogTitle, - DialogTrigger, -} from "src/components/ui/dialog" +import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "src/components/ui/dialog" import { Button } from "src/components/ui/button" interface CreateGameModalContentProps { - loading: string|null; - setLoading: React.Dispatch>; - gameStatus: GameStatus + loading: string | null + setLoading: React.Dispatch> + gameStatus: GameStatus } // ================================================================================================= export const CreateGameModal = () => { - const [ open, setOpen ] = useState(false); - const [ loading, setLoading ] = useState(null); - const isGameCreator = store.useIsGameCreator() - const gameStatus = store.useGameStatus() - - useEffect(() => { - // If we're on the home page and we're the game creator, this modal should be displayed. - if (isGameCreator && !open) - setOpen(true) - }, [isGameCreator, open]) - - - const canCloseExternally = loading == null && gameStatus < GameStatus.CREATED - return ( - // If we're on the home page and we're the game creator, this modal should be displayed. - - - - - - - - - ) + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(null) + const isGameCreator = store.useIsGameCreator() + const gameStatus = store.useGameStatus() + + useEffect(() => { + // If we're on the home page and we're the game creator, this modal should be displayed. + if (isGameCreator && !open) setOpen(true) + }, [isGameCreator, open]) + + const canCloseExternally = loading == null && gameStatus < GameStatus.CREATED + return ( + // If we're on the home page and we're the game creator, this modal should be displayed. + + + + + + + + + ) } // ================================================================================================= const CreateGameModalContent: React.FC = ({ loading, setLoading, gameStatus }) => { - const playerAddress = store.usePlayerAddress() - const [ gameID, setGameID ] = store.useGameID() - const allPlayersJoined = store.useAllPlayersJoined() - const [ hasVisitedBoard ] = store.useHasVisitedBoard() - const [ drawCompleted, setDrawCompleted ] = useState(false) - const router = useRouter() - - // Decompose in boolean to help sharing code. - const created = gameStatus >= GameStatus.CREATED - const joined = gameStatus >= GameStatus.HAND_DRAWN || drawCompleted - const started = gameStatus >= GameStatus.STARTED && gameStatus < GameStatus.ENDED - - // Load game board game once the game start, unless we've visited it for this game already. - useEffect(() => { - if (!hasVisitedBoard && started) void navigate(router, "/play") - }, [hasVisitedBoard, router, started]) - - // ----------------------------------------------------------------------------------------------- - // NOTE(norswap): This is how to compute the encoding of the joincheck callback, however, ethers - // will block us from using it, and will not provide built-in things for encoding it. - // - // const fragment = gameContract.interface.getFunction("allowAnyPlayerAndDeck"); - // const sigHash = gameContract.interface.getSighash(fragment); - // - // const hash = ( - // deployment.game + sigHash.slice(2) - // ).padEnd(66, "0") - // ----------------------------------------------------------------------------------------------- - - const { write: create } = useGameWrite({ - functionName: "createGame", - args: [2], // we only handle two players - enabled: !created, - setLoading, - onSuccess(data) { - const event = decodeEventLog({ - abi: gameABI, - data: data.logs[0].data, - topics: data.logs[0]["topics"] - }) - setGameID(event.args["gameID"]) - } - }) - - const cancellationHandler = useCancellationHandler(loading) - - const join = useCallback(async () => { - if (gameID === null || playerAddress === null) - return reportInconsistentGameState("Not tracking a game or player disconnected.") - - const success = await joinGame({ - gameID, - playerAddress, + const playerAddress = store.usePlayerAddress() + const [gameID, setGameID] = store.useGameID() + const allPlayersJoined = store.useAllPlayersJoined() + const [hasVisitedBoard] = store.useHasVisitedBoard() + const [drawCompleted, setDrawCompleted] = useState(false) + const router = useRouter() + + // Decompose in boolean to help sharing code. + const created = gameStatus >= GameStatus.CREATED + const joined = gameStatus >= GameStatus.HAND_DRAWN || drawCompleted + const started = gameStatus >= GameStatus.STARTED && gameStatus < GameStatus.ENDED + + // Load game board game once the game start, unless we've visited it for this game already. + useEffect(() => { + if (!hasVisitedBoard && started) void navigate(router, "/play") + }, [hasVisitedBoard, router, started]) + + // ----------------------------------------------------------------------------------------------- + // NOTE(norswap): This is how to compute the encoding of the joincheck callback, however, ethers + // will block us from using it, and will not provide built-in things for encoding it. + // + // const fragment = gameContract.interface.getFunction("allowAnyPlayerAndDeck"); + // const sigHash = gameContract.interface.getSighash(fragment); + // + // const hash = ( + // deployment.game + sigHash.slice(2) + // ).padEnd(66, "0") + // ----------------------------------------------------------------------------------------------- + + const { write: create } = useGameWrite({ + functionName: "createGame", + args: [2], // we only handle two players + enabled: !created, setLoading, - cancellationHandler - }) - - if (success) - // Optimistically transition to the next modal state as we know the tx succeeded, and the - // game data refresh will follow. - setDrawCompleted(true) - }, - [gameID, playerAddress, setLoading, cancellationHandler]) - - const { write: cancel } = useGameWrite({ - functionName: "cancelGame", - args: [gameID], - enabled: created && !allPlayersJoined, - setLoading, - onSuccess() { - setGameID(null) - } - }) - - const doConcede = useCallback( - () => concede({ - gameID: gameID!, - playerAddress: playerAddress!, - setLoading, - onSuccess: () => { - setGameID(null) - } - }), - [gameID, playerAddress, setGameID, setLoading]) - - // ----------------------------------------------------------------------------------------------- - - if (loading) - return - - if (!created) - return ( - <> - Create Game - -

- Once a game is created, you can invite your friends to join with the - game ID. -

-
- -
-
- - ) - - if (created && !started) - return ( - <> - - {joined ? "Waiting for other player..." : "Game Created"} - - -

- Share the following code to invite players to battle: -

-

- {`${gameID}`} -

- {!joined && ( -
- - -
- )} - {joined && ( -
- - {!allPlayersJoined && ( - - )} -
- )} -
- + onSuccess(data) { + const event = decodeEventLog({ + abi: gameABI, + data: data.logs[0].data, + topics: data.logs[0]["topics"], + }) + setGameID(event.args["gameID"]) + }, + }) + + const cancellationHandler = useCancellationHandler(loading) + + const join = useCallback(async () => { + if (gameID === null || playerAddress === null) + return reportInconsistentGameState("Not tracking a game or player disconnected.") + + const success = await joinGame({ + gameID, + playerAddress, + setLoading, + cancellationHandler, + }) + + if (success) + // Optimistically transition to the next modal state as we know the tx succeeded, and the + // game data refresh will follow. + setDrawCompleted(true) + }, [gameID, playerAddress, setLoading, cancellationHandler]) + + const { write: cancel } = useGameWrite({ + functionName: "cancelGame", + args: [gameID], + enabled: created && !allPlayersJoined, + setLoading, + onSuccess() { + setGameID(null) + }, + }) + + const doConcede = useCallback( + () => + concede({ + gameID: gameID!, + playerAddress: playerAddress!, + setLoading, + onSuccess: () => { + setGameID(null) + }, + }), + [gameID, playerAddress, setGameID, setLoading] ) - if (started) return + // ----------------------------------------------------------------------------------------------- + + if (loading) + return ( + + ) + + if (!created) + return ( + <> + Create Game + +

+ Once a game is created, you can invite your friends to join with the game ID. +

+
+ +
+
+ + ) + + if (created && !started) + return ( + <> + + {joined ? "Waiting for other player..." : "Game Created"} + + +

Share the following code to invite players to battle:

+

+ {`${gameID}`} +

+ {!joined && ( +
+ + +
+ )} + {joined && ( +
+ + {!allPlayersJoined && ( + + )} +
+ )} +
+ + ) + + if (started) return } // ================================================================================================= diff --git a/packages/webapp/src/components/modals/gameEndedModal.tsx b/packages/webapp/src/components/modals/gameEndedModal.tsx index 11f4ee05..6926b3e5 100644 --- a/packages/webapp/src/components/modals/gameEndedModal.tsx +++ b/packages/webapp/src/components/modals/gameEndedModal.tsx @@ -3,12 +3,7 @@ import { useCallback, useState } from "react" import { useGameData, useGameID } from "src/store/hooks" import { navigate } from "src/utils/navigate" -import { - Dialog, - DialogDescription, - DialogTitle, - DialogContent, -} from "src/components/ui/dialog" +import { Dialog, DialogDescription, DialogTitle, DialogContent } from "src/components/ui/dialog" import { Button } from "src/components/ui/button" /** @@ -16,44 +11,38 @@ import { Button } from "src/components/ui/button" * player wishes to view the final state of the game board, after which he can still go back * to the menu through a button on the game board. */ -export const GameEndedModal = ({ - closeCallback, -}: { - closeCallback: () => void -}) => { - const router = useRouter() - const [ , setGameID ] = useGameID() - const gameData = useGameData() - const [open, isOpen] = useState(true) +export const GameEndedModal = ({ closeCallback }: { closeCallback: () => void }) => { + const router = useRouter() + const [, setGameID] = useGameID() + const gameData = useGameData() + const [open, isOpen] = useState(true) - const exitToMenu = useCallback(() => { - setGameID(null) - void navigate(router, "/") - }, [router, setGameID]) + const exitToMenu = useCallback(() => { + setGameID(null) + void navigate(router, "/") + }, [router, setGameID]) - const viewGame = useCallback(() => { - isOpen(false) - closeCallback() - }, [closeCallback]) + const viewGame = useCallback(() => { + isOpen(false) + closeCallback() + }, [closeCallback]) - return ( - - - Game Ended - -

- Winner: {gameData?.players[gameData.livePlayers[0]]} -

-
- - -
-
-
-
- ) + return ( + + + Game Ended + +

Winner: {gameData?.players[gameData.livePlayers[0]]}

+
+ + +
+
+
+
+ ) } diff --git a/packages/webapp/src/components/modals/globalErrorModal.tsx b/packages/webapp/src/components/modals/globalErrorModal.tsx index 20f43bf9..0b8bf72b 100644 --- a/packages/webapp/src/components/modals/globalErrorModal.tsx +++ b/packages/webapp/src/components/modals/globalErrorModal.tsx @@ -1,10 +1,6 @@ import { ErrorConfig } from "src/store/types" -import { - Dialog, - DialogContent, - DialogTitle, -} from "../ui/dialog" +import { Dialog, DialogContent, DialogTitle } from "../ui/dialog" import { Button } from "src/components/ui/button" import { useEffect, useState } from "react" @@ -13,31 +9,29 @@ import { useEffect, useState } from "react" * This modal can be dismissed by setting the errorConfig state to null. */ export const GlobalErrorModal = ({ config }: { config: ErrorConfig }) => { - // Maybe in the future we might want to store the error somewhere and make it surfaceable in the - // UI. This is good practice as it lets the user figure out what happened. Really not a priority - // at the moment, and the error should be systematically logged to the console instead, for - // debugging purposes. - const [ open, setOpen ] = useState(false) - useEffect(() => { - if(config !== null && !open) setOpen(true) - else setOpen(false) - }, [config, open]) + // Maybe in the future we might want to store the error somewhere and make it surfaceable in the + // UI. This is good practice as it lets the user figure out what happened. Really not a priority + // at the moment, and the error should be systematically logged to the console instead, for + // debugging purposes. + const [open, setOpen] = useState(false) + useEffect(() => { + if (config !== null && !open) setOpen(true) + else setOpen(false) + }, [config, open]) - return ( - - {config.title} - - {config.message !== "" && ( -

{config.message}

- )} -
- {config.buttons.map((button, i) => ( - - ))} -
-
-
- ) + return ( + + {config.title} + + {config.message !== "" &&

{config.message}

} +
+ {config.buttons.map((button, i) => ( + + ))} +
+
+
+ ) } diff --git a/packages/webapp/src/components/modals/inGameMenuModalContent.tsx b/packages/webapp/src/components/modals/inGameMenuModalContent.tsx index 85e94577..9909422f 100644 --- a/packages/webapp/src/components/modals/inGameMenuModalContent.tsx +++ b/packages/webapp/src/components/modals/inGameMenuModalContent.tsx @@ -10,26 +10,22 @@ import { Button } from "src/components/ui/button" * * @param {{concede}} concede - The function to call to concede the game. */ -export const InGameMenuModalContent = ({ - concede, -}: { - concede?: () => void -}) => { - return ( - <> - Game in progress! - -
- - - - -
-
- - ) +export const InGameMenuModalContent = ({ concede }: { concede?: () => void }) => { + return ( + <> + Game in progress! + +
+ + + + +
+
+ + ) } diff --git a/packages/webapp/src/components/modals/joinGameModal.tsx b/packages/webapp/src/components/modals/joinGameModal.tsx index 131c45ef..910cd306 100644 --- a/packages/webapp/src/components/modals/joinGameModal.tsx +++ b/packages/webapp/src/components/modals/joinGameModal.tsx @@ -14,167 +14,159 @@ import { navigate } from "src/utils/navigate" import { useCancellationHandler } from "src/hooks/useCancellationHandler" import { concede } from "src/actions/concede" -import { - Dialog, - DialogContent, - DialogDescription, - DialogTitle, - DialogTrigger, -} from "../ui/dialog" +import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "../ui/dialog" import { Button } from "src/components/ui/button" import { Input } from "src/components/ui/input" interface JoinGameModalContentProps { - loading: string | null; - setLoading: React.Dispatch>; - gameStatus: GameStatus + loading: string | null + setLoading: React.Dispatch> + gameStatus: GameStatus } // ================================================================================================= export const JoinGameModal = () => { - const [ open, setOpen ] = useState(false); - const [ loading, setLoading ] = useState(null) - const isGameJoiner = store.useIsGameJoiner() - const gameStatus = store.useGameStatus() - - const canCloseExternally = loading == null && gameStatus < GameStatus.JOINED - - useEffect(() => { - // If we're on the home page and we have joined a game we didn't create, this modal - // should be displayed. - if (isGameJoiner && !open) - setOpen(true) - }, [isGameJoiner, open]) - - return ( - - - - - - - - - ) + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(null) + const isGameJoiner = store.useIsGameJoiner() + const gameStatus = store.useGameStatus() + + const canCloseExternally = loading == null && gameStatus < GameStatus.JOINED + + useEffect(() => { + // If we're on the home page and we have joined a game we didn't create, this modal + // should be displayed. + if (isGameJoiner && !open) setOpen(true) + }, [isGameJoiner, open]) + + return ( + + + + + + + + + ) } // ================================================================================================= const JoinGameModalContent: React.FC = ({ loading, setLoading, gameStatus }) => { - const [ gameID, setGameID ] = store.useGameID() - const playerAddress = store.usePlayerAddress() - const [ hasVisitedBoard ] = store.useHasVisitedBoard() - const [ inputGameID, setInputGameID ] = useState(null) - - const [ drawCompleted, setDrawCompleted ] = useState(false) - const router = useRouter() - - // Decompose in boolean to help sharing code. - const joined = gameStatus >= GameStatus.HAND_DRAWN || drawCompleted - const started = gameStatus >= GameStatus.STARTED && gameStatus < GameStatus.ENDED - - // Load game board game once upon game start. - useEffect(() => { - if (!hasVisitedBoard && started) - void navigate(router, "/play") - }, [hasVisitedBoard, router, started]) - - const cancellationHandler = useCancellationHandler(loading) - - const join = async () => { - if (inputGameID === null || playerAddress === null) - return reportInconsistentGameState("Not tracking a game or player disconnected.") - - const parsedGameID = parseBigIntOrNull(inputGameID) as bigint - if (parsedGameID === null) - return setError({ - title: "Game ID must be a plain number", - message: "", - buttons: [{ text: "OK", onClick: () => setError(null) }] - }) - - const success = await joinGame({ - gameID: parsedGameID, - playerAddress, - setLoading, - cancellationHandler - }) - - if (success) { - setDrawCompleted(true) - setLoading("Waiting for other player...") + const [gameID, setGameID] = store.useGameID() + const playerAddress = store.usePlayerAddress() + const [hasVisitedBoard] = store.useHasVisitedBoard() + const [inputGameID, setInputGameID] = useState(null) + + const [drawCompleted, setDrawCompleted] = useState(false) + const router = useRouter() + + // Decompose in boolean to help sharing code. + const joined = gameStatus >= GameStatus.HAND_DRAWN || drawCompleted + const started = gameStatus >= GameStatus.STARTED && gameStatus < GameStatus.ENDED + + // Load game board game once upon game start. + useEffect(() => { + if (!hasVisitedBoard && started) void navigate(router, "/play") + }, [hasVisitedBoard, router, started]) + + const cancellationHandler = useCancellationHandler(loading) + + const join = async () => { + if (inputGameID === null || playerAddress === null) + return reportInconsistentGameState("Not tracking a game or player disconnected.") + + const parsedGameID = parseBigIntOrNull(inputGameID) as bigint + if (parsedGameID === null) + return setError({ + title: "Game ID must be a plain number", + message: "", + buttons: [{ text: "OK", onClick: () => setError(null) }], + }) + + const success = await joinGame({ + gameID: parsedGameID, + playerAddress, + setLoading, + cancellationHandler, + }) + + if (success) { + setDrawCompleted(true) + setLoading("Waiting for other player...") + } } - } - - const doConcede = !started - ? undefined - : () => concede({ - gameID: gameID!, - playerAddress: playerAddress!, - setLoading, - onSuccess: () => { - setGameID(null) - } - }) - - function handleInputChangeBouncy(e: React.ChangeEvent) { - e.stopPropagation() - if (isStringPositiveInteger(e.target.value)) - setInputGameID(e.target.value) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - const handleInputChange = useMemo(() => debounce(handleInputChangeBouncy, 300), []) - - // ----------------------------------------------------------------------------------------------- - - if (loading) - return - - if (started) return - - return ( - <> - {joined && ( - <> - - Waiting for other player... - - - - )} - {!joined && ( + + const doConcede = !started + ? undefined + : () => + concede({ + gameID: gameID!, + playerAddress: playerAddress!, + setLoading, + onSuccess: () => { + setGameID(null) + }, + }) + + function handleInputChangeBouncy(e: React.ChangeEvent) { + e.stopPropagation() + if (isStringPositiveInteger(e.target.value)) setInputGameID(e.target.value) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + const handleInputChange = useMemo(() => debounce(handleInputChangeBouncy, 300), []) + + // ----------------------------------------------------------------------------------------------- + + if (loading) + return ( + + ) + + if (started) return + + return ( <> - - Joining Game... - - -

- Enter the game ID you want to join. -

-
- - -
-
+ {joined && ( + <> + Waiting for other player... + + + )} + {!joined && ( + <> + Joining Game... + +

Enter the game ID you want to join.

+
+ + +
+
+ + )} - )} - - ) + ) } // ================================================================================================= diff --git a/packages/webapp/src/components/modals/loadingModal.tsx b/packages/webapp/src/components/modals/loadingModal.tsx index 27daee50..579eb8b5 100644 --- a/packages/webapp/src/components/modals/loadingModal.tsx +++ b/packages/webapp/src/components/modals/loadingModal.tsx @@ -1,12 +1,7 @@ import { ReactNode, useCallback } from "react" import { Spinner } from "src/components/lib/modalElements" -import { - Dialog, - DialogDescription, - DialogTitle, - DialogContent, -} from "src/components/ui/dialog" +import { Dialog, DialogDescription, DialogTitle, DialogContent } from "src/components/ui/dialog" import { Button } from "src/components/ui/button" // ================================================================================================= @@ -19,42 +14,42 @@ import { Button } from "src/components/ui/button" * module:hooks/useCancellationHandler#useCancellationHandler} hook. */ export class CancellationHandler { - private callbacks: (() => void)[] = [] + private callbacks: (() => void)[] = [] - /** Register a callback to be called when the modal is cancelled. */ - register = (callback: () => void) => { - this.callbacks.push(callback) - } + /** Register a callback to be called when the modal is cancelled. */ + register = (callback: () => void) => { + this.callbacks.push(callback) + } - /** Deregister a callback. */ - deregister = (callback: () => void) => { - this.callbacks = this.callbacks.filter((cb) => cb !== callback) - } + /** Deregister a callback. */ + deregister = (callback: () => void) => { + this.callbacks = this.callbacks.filter((cb) => cb !== callback) + } - /** Call all registered callbacks. */ - cancel = () => { - this.callbacks.forEach((cb) => cb()) - } + /** Call all registered callbacks. */ + cancel = () => { + this.callbacks.forEach((cb) => cb()) + } } // ================================================================================================= export type LoadingModalProps = { - /** Whether the modal can be dismissed via a cancel button. */ - cancellable?: boolean - /** A string that is displayed as the title of the modal. */ - loading: string | null - /** - * A way to change the loading string. It's assumed that when this is set to null, the modal will - * be dismissed. - */ - setLoading: (_: string | null) => void - /** - * A cancellation handler that can be used to register callbacks to be called when the modal is - * cancelled via its "cancel" button. - */ - cancellationHandler?: CancellationHandler - children?: ReactNode + /** Whether the modal can be dismissed via a cancel button. */ + cancellable?: boolean + /** A string that is displayed as the title of the modal. */ + loading: string | null + /** + * A way to change the loading string. It's assumed that when this is set to null, the modal will + * be dismissed. + */ + setLoading: (_: string | null) => void + /** + * A cancellation handler that can be used to register callbacks to be called when the modal is + * cancelled via its "cancel" button. + */ + cancellationHandler?: CancellationHandler + children?: ReactNode } // ================================================================================================= @@ -67,13 +62,13 @@ export type LoadingModalProps = { * based on whether the loading state is populated or not. */ export const LoadingModal = (props: LoadingModalProps) => { - return ( - - - - - - ) + return ( + + + + + + ) } // ================================================================================================= @@ -86,33 +81,33 @@ export const LoadingModal = (props: LoadingModalProps) => { * grandparent, depending on whether the loading state is populated or not. */ export const LoadingModalContent = ({ - cancellable = true, - loading, - setLoading, - cancellationHandler, - children, + cancellable = true, + loading, + setLoading, + cancellationHandler, + children, }: LoadingModalProps) => { - const cancel = useCallback(() => { - setLoading(null) - cancellationHandler?.cancel() - }, [setLoading, cancellationHandler]) + const cancel = useCallback(() => { + setLoading(null) + cancellationHandler?.cancel() + }, [setLoading, cancellationHandler]) - return ( - <> - {loading} - - {children} - - {cancellable && ( -
- -
- )} -
- - ) + return ( + <> + {loading} + + {children} + + {cancellable && ( +
+ +
+ )} +
+ + ) } // ================================================================================================= diff --git a/packages/webapp/src/components/modals/mintDeckModal.tsx b/packages/webapp/src/components/modals/mintDeckModal.tsx index 67b7fbac..0d0cfd9b 100644 --- a/packages/webapp/src/components/modals/mintDeckModal.tsx +++ b/packages/webapp/src/components/modals/mintDeckModal.tsx @@ -2,91 +2,81 @@ import { useState } from "react" import { useDeckAirdropWrite } from "src/hooks/useFableWrite" import { LoadingModalContent } from "src/components/modals/loadingModal" -import { - Dialog, - DialogContent, - DialogDescription, - DialogTitle, - DialogTrigger, -} from "src/components/ui/dialog" +import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "src/components/ui/dialog" import { Button } from "src/components/ui/button" interface MintDeckModalContentProps { - loading: string | null; - setLoading: React.Dispatch>; - callback: () => void + loading: string | null + setLoading: React.Dispatch> + callback: () => void } // ================================================================================================= export const MintDeckModal = ({ callback = () => {} }) => { - const [ loading, setLoading ] = useState(null) + const [loading, setLoading] = useState(null) - return ( - - - - - - - - - ) + return ( + + + + + + + + + ) } // ================================================================================================= const MintDeckModalContent: React.FC = ({ loading, setLoading, callback }) => { - const [ success, setSuccess ] = useState(false) + const [success, setSuccess] = useState(false) - const { write: claim } = useDeckAirdropWrite({ - functionName: "claimAirdrop", - enabled: true, - setLoading, - onSuccess() { - callback?.() - setSuccess(true) - }, - }) + const { write: claim } = useDeckAirdropWrite({ + functionName: "claimAirdrop", + enabled: true, + setLoading, + onSuccess() { + callback?.() + setSuccess(true) + }, + }) - // ----------------------------------------------------------------------------------------------- + // ----------------------------------------------------------------------------------------------- - if (loading) - return + if (loading) return - return ( - <> - {!success && ( + return ( <> - - Minting Deck... - - -

- Mint a deck of cards to play the game with your friends. -

-
- -
-
+ {!success && ( + <> + Minting Deck... + +

Mint a deck of cards to play the game with your friends.

+
+ +
+
+ + )} + {success && ( + <> + Deck Minted Successfully + +

Go enjoy the game!

+
+ + )} - )} - {success && ( - <> - - Deck Minted Successfully - - -

Go enjoy the game!

-
- - )} - - ) + ) } // ================================================================================================= diff --git a/packages/webapp/src/components/navbar.tsx b/packages/webapp/src/components/navbar.tsx index e786e5fe..2006f88c 100644 --- a/packages/webapp/src/components/navbar.tsx +++ b/packages/webapp/src/components/navbar.tsx @@ -1,38 +1,34 @@ import Link from "next/link" import { ConnectKitButton } from "connectkit" import { Button } from "./ui/button" -import { - NavigationMenu, - NavigationMenuList, - NavigationMenuItem, -} from "src/components/ui/navigation-menu" +import { NavigationMenu, NavigationMenuList, NavigationMenuItem } from "src/components/ui/navigation-menu" export const Navbar = () => { - return ( - - - - - - - - + return ( + + + + + + + + - - - - - - - - - - - - ) + + + + + + + + + + + + ) } diff --git a/packages/webapp/src/components/playerBoard.tsx b/packages/webapp/src/components/playerBoard.tsx index 307ae31c..500985e9 100644 --- a/packages/webapp/src/components/playerBoard.tsx +++ b/packages/webapp/src/components/playerBoard.tsx @@ -1,72 +1,57 @@ import * as store from "src/store/hooks" import { convertBigIntArrayToStringArray, shortenAddress } from "src/utils/js-utils" -import { - horizontalListSortingStrategy, - SortableContext, - useSortable, -} from "@dnd-kit/sortable" +import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable" import { CardPlacement } from "src/store/types" import CardContainer from "./cards/cardContainer" interface PlayerBoardProps { - playerAddress: `0x${string}` | undefined | null - playedCards: readonly bigint[] | null + playerAddress: `0x${string}` | undefined | null + playedCards: readonly bigint[] | null } -const PlayerBoard: React.FC = ({ - playerAddress, - playedCards, -}) => { - const { setNodeRef, isOver } = useSortable({ - id: CardPlacement.BOARD, - }) - - - const currentPlayerAddress = store.usePlayerAddress() - const playerActive = isOver && playerAddress === currentPlayerAddress - const convertedCards = convertBigIntArrayToStringArray(playedCards) - return ( -
-
-
-

- {`🛡 ${shortenAddress(playerAddress)}`} -

-

♥️ 100

-
+const PlayerBoard: React.FC = ({ playerAddress, playedCards }) => { + const { setNodeRef, isOver } = useSortable({ + id: CardPlacement.BOARD, + }) + const currentPlayerAddress = store.usePlayerAddress() + const playerActive = isOver && playerAddress === currentPlayerAddress + const convertedCards = convertBigIntArrayToStringArray(playedCards) + return (
- - {convertedCards?.map((card) => ( - - ))} - +
+
+

+ {`🛡 ${shortenAddress(playerAddress)}`} +

+

♥️ 100

+
+ +
+ + {convertedCards?.map((card) => ( + + ))} + +
+
-
-
- ) + ) } export default PlayerBoard diff --git a/packages/webapp/src/components/ui/button.tsx b/packages/webapp/src/components/ui/button.tsx index bf9393be..0648de01 100644 --- a/packages/webapp/src/components/ui/button.tsx +++ b/packages/webapp/src/components/ui/button.tsx @@ -6,55 +6,46 @@ import { cn } from "src/utils/ui-utils" // ref: https://ui.shadcn.com/docs/components/button const buttonVariants = cva( - "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/90", - outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-10 px-4 py-2", - sm: "h-9 rounded-md px-3", - lg: "h-11 rounded-md px-8", - icon: "h-10 w-10", - }, - width: { - full: "w-full", - auto: "w-auto", - } - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + width: { + full: "w-full", + auto: "w-auto", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } ) export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { - asChild?: boolean + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean } const Button = React.forwardRef( - ({ className, variant, size, width, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button" - return ( - - ) - } + ({ className, variant, size, width, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return + } ) Button.displayName = "Button" diff --git a/packages/webapp/src/components/ui/dialog.tsx b/packages/webapp/src/components/ui/dialog.tsx index 3e91eb98..dcdc5d8e 100644 --- a/packages/webapp/src/components/ui/dialog.tsx +++ b/packages/webapp/src/components/ui/dialog.tsx @@ -14,118 +14,91 @@ const DialogClose = DialogPrimitive.Close // ref: https://ui.shadcn.com/docs/components/dialog const DialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )) DialogOverlay.displayName = DialogPrimitive.Overlay.displayName - interface CustomDialogContentProps extends React.ComponentPropsWithoutRef { - // extend the existing type definition to allow for an additional prop - canCloseExternally?: boolean; + // extend the existing type definition to allow for an additional prop + canCloseExternally?: boolean } -const DialogContent = React.forwardRef< - React.ElementRef, - CustomDialogContentProps ->(({ className, children, canCloseExternally = true, ...props }, ref) => ( - - - !canCloseExternally ? e.preventDefault() : null} - onEscapeKeyDown={(e) => !canCloseExternally ? e.preventDefault() : null} - {...props} - > - {children} - {canCloseExternally && ( - - - Close - - )} - - -)) +const DialogContent = React.forwardRef, CustomDialogContentProps>( + ({ className, children, canCloseExternally = true, ...props }, ref) => ( + + + (!canCloseExternally ? e.preventDefault() : null)} + onEscapeKeyDown={(e) => (!canCloseExternally ? e.preventDefault() : null)} + {...props} + > + {children} + {canCloseExternally && ( + + + Close + + )} + + + ) +) DialogContent.displayName = DialogPrimitive.Content.displayName -const DialogHeader = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
+const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
) DialogHeader.displayName = "DialogHeader" -const DialogFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
+const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
) DialogFooter.displayName = "DialogFooter" const DialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )) DialogTitle.displayName = DialogPrimitive.Title.displayName const DialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )) DialogDescription.displayName = DialogPrimitive.Description.displayName export { - Dialog, - DialogPortal, - DialogOverlay, - DialogClose, - DialogTrigger, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription, + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, } diff --git a/packages/webapp/src/components/ui/input.tsx b/packages/webapp/src/components/ui/input.tsx index 6199bbe9..b6fbf8f9 100644 --- a/packages/webapp/src/components/ui/input.tsx +++ b/packages/webapp/src/components/ui/input.tsx @@ -2,25 +2,22 @@ import * as React from "react" import { cn } from "src/utils/ui-utils" -export interface InputProps - extends React.InputHTMLAttributes {} +export interface InputProps extends React.InputHTMLAttributes {} // ref: https://ui.shadcn.com/docs/components/input -const Input = React.forwardRef( - ({ className, type, ...props }, ref) => { +const Input = React.forwardRef(({ className, type, ...props }, ref) => { return ( - + ) - } -) +}) Input.displayName = "Input" export { Input } diff --git a/packages/webapp/src/components/ui/navigation-menu.tsx b/packages/webapp/src/components/ui/navigation-menu.tsx index 89de10d3..08458a2e 100644 --- a/packages/webapp/src/components/ui/navigation-menu.tsx +++ b/packages/webapp/src/components/ui/navigation-menu.tsx @@ -7,123 +7,115 @@ import { cn } from "src/utils/ui-utils" // ref: https://ui.shadcn.com/docs/components/navigation-menu const NavigationMenu = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( - - {children} - - + + {children} + + )) NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName const NavigationMenuList = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )) NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName const NavigationMenuItem = NavigationMenuPrimitive.Item const navigationMenuTriggerStyle = cva( - "group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50" + "group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50" ) const NavigationMenuTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( - - {children}{" "} - + + {children}{" "} + )) NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName const NavigationMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )) NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName const NavigationMenuLink = NavigationMenuPrimitive.Link const NavigationMenuViewport = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( -
- -
+
+ +
)) -NavigationMenuViewport.displayName = - NavigationMenuPrimitive.Viewport.displayName +NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName const NavigationMenuIndicator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - -
- + +
+ )) -NavigationMenuIndicator.displayName = - NavigationMenuPrimitive.Indicator.displayName +NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName export { - navigationMenuTriggerStyle, - NavigationMenu, - NavigationMenuList, - NavigationMenuItem, - NavigationMenuContent, - NavigationMenuTrigger, - NavigationMenuLink, - NavigationMenuIndicator, - NavigationMenuViewport, + navigationMenuTriggerStyle, + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuContent, + NavigationMenuTrigger, + NavigationMenuLink, + NavigationMenuIndicator, + NavigationMenuViewport, } diff --git a/packages/webapp/src/components/ui/sonner.tsx b/packages/webapp/src/components/ui/sonner.tsx index da6a59a6..bb08b5c0 100644 --- a/packages/webapp/src/components/ui/sonner.tsx +++ b/packages/webapp/src/components/ui/sonner.tsx @@ -6,26 +6,23 @@ type ToasterProps = React.ComponentProps // ref: https://ui.shadcn.com/docs/components/sonner // docs: https://sonner.emilkowal.ski/ const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme() + const { theme = "system" } = useTheme() - return ( - - ) + return ( + + ) } export { Toaster } diff --git a/packages/webapp/src/constants.ts b/packages/webapp/src/constants.ts index d8cd289a..4b01ca46 100644 --- a/packages/webapp/src/constants.ts +++ b/packages/webapp/src/constants.ts @@ -17,7 +17,7 @@ export const DRAW_CARD_PROOF_TIMEOUT = 30 export const PLAY_CARD_PROOF_TIMEOUT = 30 /** The default throttle period (minimum time between two on-chain fetches) in milliseconds. */ -export const DEFAULT_THROTTLE_PERIOD = 2000 +export const DEFAULT_THROTTLE_PERIOD = 2000 /** * How often to refresh the state of the game (in milliseconds) — note the state will usually @@ -26,4 +26,4 @@ export const DEFAULT_THROTTLE_PERIOD = 2000 * Also note that the fetched are throttled to max one per {@link DEFAULT_THROTTLE_PERIOD} via * {@link module:throttledFetch}. */ -export const GAME_DATA_REFRESH_INTERVAL = 5000 \ No newline at end of file +export const GAME_DATA_REFRESH_INTERVAL = 5000 diff --git a/packages/webapp/src/deployment.ts b/packages/webapp/src/deployment.ts index 32e252d2..e97311e1 100644 --- a/packages/webapp/src/deployment.ts +++ b/packages/webapp/src/deployment.ts @@ -9,18 +9,19 @@ import type { Address } from "wagmi" import * as deployment_ from "contracts/out/deployment.json" assert { type: "json" } export interface Deployment { - CardsCollection: Address - Inventory: Address - InventoryCardsCollection: Address - Game: Address - DeckAirdrop: Address - Multicall3: Address + CardsCollection: Address + Inventory: Address + InventoryCardsCollection: Address + Game: Address + DeckAirdrop: Address + Multicall3: Address } // NOTE: This silly `default` affair is required for running the e2e tests which cause // `deployment_` to have the type `{ default: Deployment }` instead of `Deployment`. // Maybe Next doesn't process things the same as the vanilla Node/TS config ?? -export const deployment = (deployment_ as any).default === undefined - ? deployment_ as Deployment - : (deployment_ as any).default as Deployment \ No newline at end of file +export const deployment = + (deployment_ as any).default === undefined + ? (deployment_ as Deployment) + : ((deployment_ as any).default as Deployment) diff --git a/packages/webapp/src/game/constants.ts b/packages/webapp/src/game/constants.ts index b5c165ec..091d8ff5 100644 --- a/packages/webapp/src/game/constants.ts +++ b/packages/webapp/src/game/constants.ts @@ -45,7 +45,6 @@ export const NUM_CARDS_FOR_PROOF = NUM_FELTS_FOR_CARDS * FELT_SIZE * The prime that bounds the field used by our proof scheme of choice. * Currently, this is for Plonk. */ -export const PROOF_CURVE_ORDER = - 21888242871839275222246405745257275088548364400416034343698204186575808495617n +export const PROOF_CURVE_ORDER = 21888242871839275222246405745257275088548364400416034343698204186575808495617n -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/game/drawInitialHand.ts b/packages/webapp/src/game/drawInitialHand.ts index c39478af..2e9601d7 100644 --- a/packages/webapp/src/game/drawInitialHand.ts +++ b/packages/webapp/src/game/drawInitialHand.ts @@ -19,49 +19,47 @@ import { bigintToHexString, parseBigInt } from "src/utils/js-utils" * Returns a structure containing the new hand, the updated deck, and the new deck and hand roots, * suitable for updating the player's private info. */ -export function drawInitialHand - (initialDeck: readonly bigint[], deckStartIndex: number, salt: bigint, publicRandomness: bigint) - : Omit { +export function drawInitialHand( + initialDeck: readonly bigint[], + deckStartIndex: number, + salt: bigint, + publicRandomness: bigint +): Omit { + const randomness = mimcHash([salt, publicRandomness]) - const randomness = mimcHash([salt, publicRandomness]) + // draw cards and update deck - // draw cards and update deck + const deckIndexes = new Array(MAX_DECK_SIZE) + const handIndexes = new Array(MAX_HAND_SIZE) - const deckIndexes = new Array(MAX_DECK_SIZE) - const handIndexes = new Array(MAX_HAND_SIZE) + for (let i = 0; i < initialDeck.length; i++) deckIndexes[i] = deckStartIndex + i + for (let i = initialDeck.length; i < deckIndexes.length; i++) deckIndexes[i] = 255 + handIndexes.fill(255) - for (let i = 0; i < initialDeck.length; i++) - deckIndexes[i] = deckStartIndex + i - for (let i = initialDeck.length; i < deckIndexes.length; i++) - deckIndexes[i] = 255 - handIndexes.fill(255) + for (let i = 0; i < INITIAL_HAND_SIZE; i++) { + const deckLength = initialDeck.length - i + const cardIndex = Number(randomness % BigInt(deckLength)) + handIndexes[i] = deckIndexes[cardIndex] + deckIndexes[cardIndex] = deckIndexes[deckLength - 1] + deckIndexes[deckLength - 1] = 255 + } - for (let i = 0; i < INITIAL_HAND_SIZE; i++) { - const deckLength = initialDeck.length - i - const cardIndex = Number(randomness % BigInt(deckLength)) - handIndexes[i] = deckIndexes[cardIndex] - deckIndexes[cardIndex] = deckIndexes[deckLength - 1] - deckIndexes[deckLength - 1] = 255 - } + const deckRootInputs = [] + const handRootInputs = [] - const deckRootInputs = [] - const handRootInputs = [] + // Pack the deck and hand indexes into FELT_SIZE-byte chunks. + for (let i = 0; i * FELT_SIZE < MAX_DECK_SIZE; i++) + deckRootInputs.push(parseBigInt(deckIndexes.slice(i * FELT_SIZE, (i + 1) * FELT_SIZE), "little")) + for (let i = 0; i * FELT_SIZE < MAX_HAND_SIZE; i++) + handRootInputs.push(parseBigInt(handIndexes.slice(i * FELT_SIZE, (i + 1) * FELT_SIZE), "little")) - // Pack the deck and hand indexes into FELT_SIZE-byte chunks. - for (let i = 0; i * FELT_SIZE < MAX_DECK_SIZE; i++) - deckRootInputs.push(parseBigInt( - deckIndexes.slice(i * FELT_SIZE, (i + 1) * FELT_SIZE), "little")) - for (let i = 0; i * FELT_SIZE < MAX_HAND_SIZE; i++) - handRootInputs.push(parseBigInt( - handIndexes.slice(i * FELT_SIZE, (i + 1) * FELT_SIZE), "little")) + deckRootInputs.push(salt) + handRootInputs.push(salt) - deckRootInputs.push(salt) - handRootInputs.push(salt) + const deckRoot: Hash = `0x${bigintToHexString(mimcHash(deckRootInputs), 32)}` + const handRoot: Hash = `0x${bigintToHexString(mimcHash(handRootInputs), 32)}` - const deckRoot: Hash = `0x${bigintToHexString(mimcHash(deckRootInputs), 32)}` - const handRoot: Hash = `0x${bigintToHexString(mimcHash(handRootInputs), 32)}` - - return { handIndexes, deckIndexes, deckRoot, handRoot } + return { handIndexes, deckIndexes, deckRoot, handRoot } } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/game/fableProofs.ts b/packages/webapp/src/game/fableProofs.ts index 5658786b..68a77948 100644 --- a/packages/webapp/src/game/fableProofs.ts +++ b/packages/webapp/src/game/fableProofs.ts @@ -16,7 +16,7 @@ import { FELT_SIZE, NUM_FELTS_FOR_CARDS } from "src/game/constants" * Fills in the parameters specific to our ZK scheme and to this encoding. */ export function packCards(cards: number[]): bigint[] { - return packBytes(cards, NUM_FELTS_FOR_CARDS, FELT_SIZE) + return packBytes(cards, NUM_FELTS_FOR_CARDS, FELT_SIZE) } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/game/misc.ts b/packages/webapp/src/game/misc.ts index e9549efb..e6ce7af7 100644 --- a/packages/webapp/src/game/misc.ts +++ b/packages/webapp/src/game/misc.ts @@ -10,7 +10,7 @@ import { Address } from "src/chain" * Returns true iff it is legal to end a turn in the given game step. */ export function isEndingTurn(gameStep: GameStep): boolean { - return gameStep === GameStep.PLAY || gameStep === GameStep.ATTACK || gameStep === GameStep.END_TURN + return gameStep === GameStep.PLAY || gameStep === GameStep.ATTACK || gameStep === GameStep.END_TURN } // ------------------------------------------------------------------------------------------------- @@ -19,7 +19,7 @@ export function isEndingTurn(gameStep: GameStep): boolean { * Return the current player's address. */ export function currentPlayer(gameData: FetchedGameData): Address { - return gameData.players[gameData.currentPlayer] + return gameData.players[gameData.currentPlayer] } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/hooks/useCancellationHandler.ts b/packages/webapp/src/hooks/useCancellationHandler.ts index e5daa5ba..da193ef5 100644 --- a/packages/webapp/src/hooks/useCancellationHandler.ts +++ b/packages/webapp/src/hooks/useCancellationHandler.ts @@ -17,27 +17,27 @@ import { useEffect, useRef } from "react" * cancellation handlers obsolete). */ export function useCancellationHandler(loading: string | null): CancellationHandler { - const previous = useRef(loading) - const cancellationHandler = useRef(null) - - // If the loading state changes from non-null to null, then discard the old cancellation handler, - // and create a new one. - if (previous !== null && loading === null) { - cancellationHandler.current = new CancellationHandler() - } - - // This is only to initialize the very first cancellation handler, and avoid calling the - // constructor every time the hook is invoked. - if (cancellationHandler.current === null) { - cancellationHandler.current = new CancellationHandler() - } - - // Update previous value. - useEffect(() => { - previous.current = loading - }, [loading]) - - return cancellationHandler.current + const previous = useRef(loading) + const cancellationHandler = useRef(null) + + // If the loading state changes from non-null to null, then discard the old cancellation handler, + // and create a new one. + if (previous !== null && loading === null) { + cancellationHandler.current = new CancellationHandler() + } + + // This is only to initialize the very first cancellation handler, and avoid calling the + // constructor every time the hook is invoked. + if (cancellationHandler.current === null) { + cancellationHandler.current = new CancellationHandler() + } + + // Update previous value. + useEffect(() => { + previous.current = loading + }, [loading]) + + return cancellationHandler.current } // ================================================================================================= diff --git a/packages/webapp/src/hooks/useChainWrite.ts b/packages/webapp/src/hooks/useChainWrite.ts index 73ffa0e3..2e24a8bc 100644 --- a/packages/webapp/src/hooks/useChainWrite.ts +++ b/packages/webapp/src/hooks/useChainWrite.ts @@ -1,4 +1,3 @@ - import { useContractWrite, usePrepareContractWrite, useWaitForTransaction } from "wagmi" import { type TransactionReceipt } from "viem" import { Address, Hash } from "src/chain" @@ -19,24 +18,24 @@ import { Address, Hash } from "src/chain" * the transaction was signed by the user, and `onSuccess` after the transaction lands on-chain */ export type UseWriteParams = { - contract: Address - abi: any - functionName: string - args?: any[] - onWrite?: () => void - onSuccess?: (data: TransactionReceipt) => void - onSigned?: (data: { hash: Hash }) => void - onError?: (err: Error) => void - setLoading?: (label: string|null) => void - enabled?: boolean - prepare?: boolean + contract: Address + abi: any + functionName: string + args?: any[] + onWrite?: () => void + onSuccess?: (data: TransactionReceipt) => void + onSigned?: (data: { hash: Hash }) => void + onError?: (err: Error) => void + setLoading?: (label: string | null) => void + enabled?: boolean + prepare?: boolean } // ------------------------------------------------------------------------------------------------- /** Result type for {@link useChainWrite}. Type to allow to expand returned value in the future. */ export type UseWriteResult = { - write?: () => void + write?: () => void } // ------------------------------------------------------------------------------------------------- @@ -45,49 +44,51 @@ export type UseWriteResult = { * Completes the parameters for {@link useChainWrite} with default values. */ function completeParams(params: UseWriteParams): UseWriteParams { - const result = { ...params } - if (result.enabled === undefined) result.enabled = true - if (result.prepare === undefined) result.prepare = false - - const setLoading = result.setLoading - if (setLoading) { - const { onWrite, onSigned, onSuccess, onError } = params - - result.onWrite = () => { - setLoading("Waiting for signature...") - onWrite?.() - } - result.onSigned = (data) => { - setLoading("Waiting for on-chain inclusion...") - onSigned?.(data) - } - result.onSuccess = (data) => { - setLoading(null) - onSuccess?.(data) - } - result.onError = (error) => { - setLoading(null) - if (onError) onError(error) - else { - // It would be nice to combine these two, however, interpolating `error` or event - // `error.stack` into the string doesn't give the nice builtin formatting + clickable links - // in browsers' consoles. - console.log(`Error in useWrite (${result.functionName}):`) - console.log(error) - } + const result = { ...params } + if (result.enabled === undefined) result.enabled = true + if (result.prepare === undefined) result.prepare = false + + const setLoading = result.setLoading + if (setLoading) { + const { onWrite, onSigned, onSuccess, onError } = params + + result.onWrite = () => { + setLoading("Waiting for signature...") + onWrite?.() + } + result.onSigned = (data) => { + setLoading("Waiting for on-chain inclusion...") + onSigned?.(data) + } + result.onSuccess = (data) => { + setLoading(null) + onSuccess?.(data) + } + result.onError = (error) => { + setLoading(null) + if (onError) onError(error) + else { + // It would be nice to combine these two, however, interpolating `error` or event + // `error.stack` into the string doesn't give the nice builtin formatting + clickable links + // in browsers' consoles. + console.log(`Error in useWrite (${result.functionName}):`) + console.log(error) + } + } + } else { + const noop = () => {} + result.onWrite = result.onWrite || noop + result.onSigned = result.onSigned || noop + result.onSuccess = result.onSuccess || noop + result.onError = + result.onError || + ((error) => { + console.log(`Error in useWrite (${result.functionName}):`) + console.log(error) + }) } - } else { - const noop = () => {} - result.onWrite = result.onWrite || noop - result.onSigned = result.onSigned || noop - result.onSuccess = result.onSuccess || noop - result.onError = result.onError || (error => { - console.log(`Error in useWrite (${result.functionName}):`) - console.log(error) - }) - } - - return result + + return result } // ------------------------------------------------------------------------------------------------- @@ -96,67 +97,65 @@ function completeParams(params: UseWriteParams): UseWriteParams { * Send a transaction to the chain, based on the parameters. */ export function useChainWrite(_params: UseWriteParams): UseWriteResult { - - // Unlikely that this is more work than memoization would incur! - const { - contract, abi, functionName, args, enabled, prepare, onSigned, onWrite, onSuccess, onError - } = completeParams(_params) - - // TODO(norswap): It could be good to include some generic error handling / preprocessing here. - // This will require disentangling what can happen when and what to do about it. - // cf. https://twitter.com/norswap/status/1640409794409316361 - - const config = prepare - // Prepare the transaction configuration: this will call the RPC to simulate the call and get - // the estimated gas cost. This might decrease the time to perform a tx, but might increase RPC - // costs, and might cause errors depending on the caching policy (which I haven't investigated). - // eslint-disable-next-line - ? usePrepareContractWrite({ - address: contract, - abi, - functionName, - args: args || [], - enabled, - onError - } as any).config // type safety blah blah, then it doesn't even type correctly — midwit shit - : { - address: contract, - abi, - functionName, - args: args || [], - enabled - } - - // Uses the configuration to get a write function which will send the transaction. After `write` - // is called and the user signs the transaction in the wallet, the `data` will be populated with a - // transaction hash (the hash is known before the transaction lands on chain). - const { data, write } = useContractWrite({ - ...config, - onMutate: onWrite, - onSuccess: onSigned, - onError - } as any) - - // `data` reflects the last invocation of `write` with the same configuration. - // - // Crucially, this means that if the configuration changes, the `data` for an in-flight `write` - // will become inaccessible. One should refrain from changing the parameters of `useWrite` until - // the transaction has landed on-chain. However, the configuration also includes the gas price and - // the nonce, which can be updated more frequently. I'm not really sure how this is supposed to - // be safe (seems like a potential race condition where the config is updated before we can get - // the data from a write), but it seems to work in practice. If not, `onSuccess` and `onError` - // callbacks could be used to avoid the issue. - // - // Because write commits to a certain nonce, `write` should only be called once per render cycle. - - // Waits for the transaction — this will only be enabled if the hash is defined. - useWaitForTransaction({ - hash: data?.hash, - onSuccess, - onError - } as any) // remove the deprecation errors for onSuccess and onError — absolutely undocumented - - return { write } + // Unlikely that this is more work than memoization would incur! + const { contract, abi, functionName, args, enabled, prepare, onSigned, onWrite, onSuccess, onError } = + completeParams(_params) + + // TODO(norswap): It could be good to include some generic error handling / preprocessing here. + // This will require disentangling what can happen when and what to do about it. + // cf. https://twitter.com/norswap/status/1640409794409316361 + + const config = prepare + ? // Prepare the transaction configuration: this will call the RPC to simulate the call and get + // the estimated gas cost. This might decrease the time to perform a tx, but might increase RPC + // costs, and might cause errors depending on the caching policy (which I haven't investigated). + // eslint-disable-next-line + usePrepareContractWrite({ + address: contract, + abi, + functionName, + args: args || [], + enabled, + onError, + } as any).config // type safety blah blah, then it doesn't even type correctly — midwit shit + : { + address: contract, + abi, + functionName, + args: args || [], + enabled, + } + + // Uses the configuration to get a write function which will send the transaction. After `write` + // is called and the user signs the transaction in the wallet, the `data` will be populated with a + // transaction hash (the hash is known before the transaction lands on chain). + const { data, write } = useContractWrite({ + ...config, + onMutate: onWrite, + onSuccess: onSigned, + onError, + } as any) + + // `data` reflects the last invocation of `write` with the same configuration. + // + // Crucially, this means that if the configuration changes, the `data` for an in-flight `write` + // will become inaccessible. One should refrain from changing the parameters of `useWrite` until + // the transaction has landed on-chain. However, the configuration also includes the gas price and + // the nonce, which can be updated more frequently. I'm not really sure how this is supposed to + // be safe (seems like a potential race condition where the config is updated before we can get + // the data from a write), but it seems to work in practice. If not, `onSuccess` and `onError` + // callbacks could be used to avoid the issue. + // + // Because write commits to a certain nonce, `write` should only be called once per render cycle. + + // Waits for the transaction — this will only be enabled if the hash is defined. + useWaitForTransaction({ + hash: data?.hash, + onSuccess, + onError, + } as any) // remove the deprecation errors for onSuccess and onError — absolutely undocumented + + return { write } } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/hooks/useDebug.ts b/packages/webapp/src/hooks/useDebug.ts index 3311902f..1abb1621 100644 --- a/packages/webapp/src/hooks/useDebug.ts +++ b/packages/webapp/src/hooks/useDebug.ts @@ -9,7 +9,7 @@ import { toString } from "src/utils/js-utils" * can be used at the top level of a component. */ export function useDebugValues(dict: Record) { - useDebugValue(dict) + useDebugValue(dict) } // ------------------------------------------------------------------------------------------------- @@ -23,9 +23,9 @@ export function useDebugValues(dict: Record) { * To preserve state but still help with debugging, use {@link useDebugValues} instead. */ export function useDebugState(initial: T, label?: string) { - const [value, setValue] = useState(initial) - useDebugValue(label ? `${label}: ${toString(value)}` : value) - return [value, setValue] + const [value, setValue] = useState(initial) + useDebugValue(label ? `${label}: ${toString(value)}` : value) + return [value, setValue] } -// ------------------------------------------------------------------------------------------------- \ No newline at end of file +// ------------------------------------------------------------------------------------------------- diff --git a/packages/webapp/src/hooks/useDragEvents.ts b/packages/webapp/src/hooks/useDragEvents.ts index 8157725e..f5ded6c7 100644 --- a/packages/webapp/src/hooks/useDragEvents.ts +++ b/packages/webapp/src/hooks/useDragEvents.ts @@ -7,56 +7,54 @@ import { CancellationHandler } from "src/components/modals/loadingModal" import { playCard } from "src/actions/playCard" function useDragEvents( - setActiveId: (id: UniqueIdentifier | null) => void, - setLoading: (loading: string | null) => void, - cancellationHandler: CancellationHandler + setActiveId: (id: UniqueIdentifier | null) => void, + setLoading: (loading: string | null) => void, + cancellationHandler: CancellationHandler ) { - const playerAddress = store.usePlayerAddress() - const [gameID, _] = store.useGameID() - const playerBattlefield = store.usePlayerBattlefield() + const playerAddress = store.usePlayerAddress() + const [gameID, _] = store.useGameID() + const playerBattlefield = store.usePlayerBattlefield() - const handleDragStart = useCallback( - ({ active }: DragStartEvent) => { - const matchedCardId = extractCardID(active.id as unknown as string) - setActiveId(matchedCardId) - }, - [setActiveId] - ) + const handleDragStart = useCallback( + ({ active }: DragStartEvent) => { + const matchedCardId = extractCardID(active.id as unknown as string) + setActiveId(matchedCardId) + }, + [setActiveId] + ) - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { over, active } = event - if (over && over.id === CardPlacement.BOARD) { - const cardID = extractCardID(active.id as unknown as string) - const cardIndex = playerBattlefield!.findIndex( - (card) => card === BigInt(cardID as string) - ) - void playCard({ - gameID: gameID!, - playerAddress: playerAddress!, - cardIndexInHand: cardIndex >= 0 ? cardIndex : 0, - setLoading: setLoading, - cancellationHandler: cancellationHandler, - }) - } else if (over && over.id === CardPlacement.HAND) { - return - } - }, - [cancellationHandler, setLoading, playerBattlefield, gameID, playerAddress] - ) + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { over, active } = event + if (over && over.id === CardPlacement.BOARD) { + const cardID = extractCardID(active.id as unknown as string) + const cardIndex = playerBattlefield!.findIndex((card) => card === BigInt(cardID as string)) + void playCard({ + gameID: gameID!, + playerAddress: playerAddress!, + cardIndexInHand: cardIndex >= 0 ? cardIndex : 0, + setLoading: setLoading, + cancellationHandler: cancellationHandler, + }) + } else if (over && over.id === CardPlacement.HAND) { + return + } + }, + [cancellationHandler, setLoading, playerBattlefield, gameID, playerAddress] + ) - const handleDragCancel = useCallback( - ({}: DragStartEvent) => { - setActiveId(null) - }, - [setActiveId] - ) + const handleDragCancel = useCallback( + ({}: DragStartEvent) => { + setActiveId(null) + }, + [setActiveId] + ) - return { - handleDragStart, - handleDragEnd, - handleDragCancel, - } + return { + handleDragStart, + handleDragEnd, + handleDragCancel, + } } -export default useDragEvents \ No newline at end of file +export default useDragEvents diff --git a/packages/webapp/src/hooks/useFableWrite.ts b/packages/webapp/src/hooks/useFableWrite.ts index 0b1d5245..13441ed7 100644 --- a/packages/webapp/src/hooks/useFableWrite.ts +++ b/packages/webapp/src/hooks/useFableWrite.ts @@ -10,53 +10,53 @@ import { Hash } from "src/chain" // useWrite: just `useWrite` with the contract address and ABI already set. export type UseContractSpecificWriteParams = { - functionName: string, - args?: any[], - onWrite?: () => void, - onSigned?: (data: { hash: Hash }) => void, - onSuccess?: (data: TransactionReceipt) => void, - onError?: (err: Error) => void, - setLoading?: (label: string|null) => void, - enabled?: boolean + functionName: string + args?: any[] + onWrite?: () => void + onSigned?: (data: { hash: Hash }) => void + onSuccess?: (data: TransactionReceipt) => void + onError?: (err: Error) => void + setLoading?: (label: string | null) => void + enabled?: boolean } // ------------------------------------------------------------------------------------------------- export function useGameWrite(params: UseContractSpecificWriteParams): UseWriteResult { - try { - return useChainWrite({...params, contract: deployment.Game, abi: gameABI}) - } catch (e) { - return { write: undefined } - } + try { + return useChainWrite({ ...params, contract: deployment.Game, abi: gameABI }) + } catch (e) { + return { write: undefined } + } } // ------------------------------------------------------------------------------------------------- export function useCardsCollectionWrite(params: UseContractSpecificWriteParams): UseWriteResult { - try { - return useChainWrite({...params, contract: deployment.CardsCollection, abi: cardsCollectionABI}) - } catch (e) { - return { write: undefined } - } + try { + return useChainWrite({ ...params, contract: deployment.CardsCollection, abi: cardsCollectionABI }) + } catch (e) { + return { write: undefined } + } } // ------------------------------------------------------------------------------------------------- export function useInventoryWrite(params: UseContractSpecificWriteParams): UseWriteResult { - try { - return useChainWrite({...params, contract: deployment.Inventory, abi: inventoryABI}) - } catch (e) { - return { write: undefined } - } + try { + return useChainWrite({ ...params, contract: deployment.Inventory, abi: inventoryABI }) + } catch (e) { + return { write: undefined } + } } // ------------------------------------------------------------------------------------------------- export function useDeckAirdropWrite(params: UseContractSpecificWriteParams): UseWriteResult { - try { - return useChainWrite({...params, contract: deployment.DeckAirdrop, abi: deckAirdropABI}) - } catch (e) { - return { write: undefined } - } + try { + return useChainWrite({ ...params, contract: deployment.DeckAirdrop, abi: deckAirdropABI }) + } catch (e) { + return { write: undefined } + } } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/hooks/useIsHydrated.ts b/packages/webapp/src/hooks/useIsHydrated.ts index 8d658c05..c93882c5 100644 --- a/packages/webapp/src/hooks/useIsHydrated.ts +++ b/packages/webapp/src/hooks/useIsHydrated.ts @@ -8,11 +8,11 @@ import { useEffect, useState } from "react" * `if (typeof window !== "undefined") // not server side-rendering`. */ export const useIsHydrated = () => { - const [isHydrated, setIsHydrated] = useState(false) + const [isHydrated, setIsHydrated] = useState(false) - useEffect(() => { - setIsHydrated(true); - }, []) + useEffect(() => { + setIsHydrated(true) + }, []) - return isHydrated + return isHydrated } diff --git a/packages/webapp/src/hooks/useIsMounted.ts b/packages/webapp/src/hooks/useIsMounted.ts index 0e39d1ae..205ffdb5 100644 --- a/packages/webapp/src/hooks/useIsMounted.ts +++ b/packages/webapp/src/hooks/useIsMounted.ts @@ -12,17 +12,16 @@ * Source: https://usehooks-ts.com/react-hook/use-is-mounted */ - import { RefObject, useEffect, useRef } from "react" export function useIsMounted(): RefObject { - const isMounted = useRef(true) - useEffect(() => { - isMounted.current = true - return () => { - isMounted.current = false - } - }, []) + const isMounted = useRef(true) + useEffect(() => { + isMounted.current = true + return () => { + isMounted.current = false + } + }, []) - return isMounted -} \ No newline at end of file + return isMounted +} diff --git a/packages/webapp/src/hooks/useScrollBox.ts b/packages/webapp/src/hooks/useScrollBox.ts index c1c45fc0..3b4597cf 100644 --- a/packages/webapp/src/hooks/useScrollBox.ts +++ b/packages/webapp/src/hooks/useScrollBox.ts @@ -5,158 +5,154 @@ import { toast } from "sonner" const timing = (1 / 60) * 1000 function useScrollBox(scrollRef: RefObject, cards: readonly bigint[] | null) { - // Stores the last horizontal scroll position. - const [ lastScrollX, setLastScrollX ] = useState(0) + // Stores the last horizontal scroll position. + const [lastScrollX, setLastScrollX] = useState(0) - // Determines the visibility of navigation arrows based on scroll position. - const [ showLeftArrow, setShowLeftArrow ] = useState(false) - const [ showRightArrow, setShowRightArrow ] = useState(false) + // Determines the visibility of navigation arrows based on scroll position. + const [showLeftArrow, setShowLeftArrow] = useState(false) + const [showRightArrow, setShowRightArrow] = useState(false) - const [ isLastCardGlowing, setIsLastCardGlowing ] = useState(false) + const [isLastCardGlowing, setIsLastCardGlowing] = useState(false) - const scrollWrapperCurrent = scrollRef.current + const scrollWrapperCurrent = scrollRef.current - const cardWidth = 200 // width of card when not in focus - const scrollAmount = 2 * cardWidth - const duration = 300 + const cardWidth = 200 // width of card when not in focus + const scrollAmount = 2 * cardWidth + const duration = 300 - /** Checks and updates the arrow visibility states based on the scroll position. */ - const checkArrowsVisibility = () => { - if (!scrollRef.current) return - const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current - setShowLeftArrow(scrollLeft > 0) - setShowRightArrow(scrollLeft < scrollWidth - clientWidth) - } + /** Checks and updates the arrow visibility states based on the scroll position. */ + const checkArrowsVisibility = () => { + if (!scrollRef.current) return + const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current + setShowLeftArrow(scrollLeft > 0) + setShowRightArrow(scrollLeft < scrollWidth - clientWidth) + } + + /** Performs a smooth scrolling animation to a specified target position. + * Accepts a target scroll position and an optional callback to execute after completion. */ + const smoothScroll = useCallback((target: number, callback?: () => void) => { + if (!scrollRef.current) return - /** Performs a smooth scrolling animation to a specified target position. - * Accepts a target scroll position and an optional callback to execute after completion. */ - const smoothScroll = useCallback((target: number, callback?: () => void) => { - if (!scrollRef.current) return + const start = scrollRef.current.scrollLeft + const startTime = Date.now() - const start = scrollRef.current.scrollLeft - const startTime = Date.now() + const animateScroll = () => { + const now = Date.now() + const time = Math.min(1, (now - startTime) / duration) - const animateScroll = () => { - const now = Date.now() - const time = Math.min(1, (now - startTime) / duration) + scrollRef.current!.scrollLeft = start + time * (target - start) - scrollRef.current!.scrollLeft = start + time * (target - start) + if (time < 1) { + requestAnimationFrame(animateScroll) + } else { + checkArrowsVisibility() + if (callback) callback() // Execute callback after the scroll animation completes + } + } - if (time < 1) { requestAnimationFrame(animateScroll) - } else { - checkArrowsVisibility() - if (callback) callback() // Execute callback after the scroll animation completes - } + }, []) + + /** Scrolls the container a fixed distance to the left or right with animation. */ + const scrollLeft = () => { + if (!scrollRef.current) return + const target = Math.max(0, scrollRef.current.scrollLeft - scrollAmount) + smoothScroll(target) } - requestAnimationFrame(animateScroll) - }, []) - - /** Scrolls the container a fixed distance to the left or right with animation. */ - const scrollLeft = () => { - if (!scrollRef.current) return - const target = Math.max(0, scrollRef.current.scrollLeft - scrollAmount) - smoothScroll(target) - } - - const scrollRight = () => { - if (!scrollRef.current) return - const maxScrollLeft = - scrollRef.current.scrollWidth - scrollRef.current.clientWidth - const target = Math.min( - maxScrollLeft, - scrollRef.current.scrollLeft + scrollAmount - ) - smoothScroll(target) - } - - /** Throttled function to update the last horizontal scroll position, minimizing performance impact. */ - const handleLastScrollX = useCallback( - throttle((screenX) => { - setLastScrollX(screenX) - }, timing), - [] - ) - - /** Handles the wheel event to adjust the scrollLeft property, enabling horizontal scrolling. */ - const handleScroll = (e: WheelEvent) => { - if (scrollRef.current) { - // Adjust the scrollLeft property based on the deltaY value - scrollRef.current.scrollLeft += e.deltaY + const scrollRight = () => { + if (!scrollRef.current) return + const maxScrollLeft = scrollRef.current.scrollWidth - scrollRef.current.clientWidth + const target = Math.min(maxScrollLeft, scrollRef.current.scrollLeft + scrollAmount) + smoothScroll(target) } - } - - /** Responds to window resize events to update arrow visibility states. */ - const handleResize = () => { - setShowLeftArrow(true) - setShowRightArrow(true) - } - - /** Smoothly scrolls to the rightmost end of the container, - * triggers a glow in the last card added. */ - const smoothScrollToRightThenLeft = useCallback(() => { - const element = scrollRef.current - if (!element) return - - const targetRight = element.scrollWidth - element.clientWidth - smoothScroll(targetRight, () => { - triggerLastCardGlow() - }) - }, [scrollRef]) - - const triggerLastCardGlow = useCallback(() => { - setIsLastCardGlowing(true) - // dismiss the toast displaying draw status - toast.dismiss("DRAW_CARD_TOAST") - setTimeout(() => { - setIsLastCardGlowing(false) - }, 2500) - }, []) - - /** Sets up and cleans up event listeners for resize, scroll, and wheel events. */ - useEffect(() => { - if (scrollRef.current) { - checkArrowsVisibility() - - window.addEventListener("resize", handleResize) - - const scrollWrapper = scrollRef.current - if (scrollWrapper) { - scrollWrapper.addEventListener("scroll", checkArrowsVisibility) - scrollWrapper.addEventListener("wheel", handleScroll) - } - - // Cleanup function - return () => { - window.removeEventListener("resize", handleResize) - - if (scrollWrapper) { - scrollWrapper.removeEventListener("scroll", checkArrowsVisibility) - scrollWrapper.removeEventListener("wheel", handleScroll) + + /** Throttled function to update the last horizontal scroll position, minimizing performance impact. */ + const handleLastScrollX = useCallback( + throttle((screenX) => { + setLastScrollX(screenX) + }, timing), + [] + ) + + /** Handles the wheel event to adjust the scrollLeft property, enabling horizontal scrolling. */ + const handleScroll = (e: WheelEvent) => { + if (scrollRef.current) { + // Adjust the scrollLeft property based on the deltaY value + scrollRef.current.scrollLeft += e.deltaY } - } } - }, [scrollWrapperCurrent, handleLastScrollX, lastScrollX]) - // Detects changes in the `cards` array to trigger the pop-up effect and initiate smooth scrolling to highlight new content. - useEffect(() => { - if (cards && cards.length > 0) { - const timer = setTimeout(() => { - smoothScrollToRightThenLeft() - }, 3000) + /** Responds to window resize events to update arrow visibility states. */ + const handleResize = () => { + setShowLeftArrow(true) + setShowRightArrow(true) + } + + /** Smoothly scrolls to the rightmost end of the container, + * triggers a glow in the last card added. */ + const smoothScrollToRightThenLeft = useCallback(() => { + const element = scrollRef.current + if (!element) return + + const targetRight = element.scrollWidth - element.clientWidth + smoothScroll(targetRight, () => { + triggerLastCardGlow() + }) + }, [scrollRef]) + + const triggerLastCardGlow = useCallback(() => { + setIsLastCardGlowing(true) + // dismiss the toast displaying draw status + toast.dismiss("DRAW_CARD_TOAST") + setTimeout(() => { + setIsLastCardGlowing(false) + }, 2500) + }, []) + + /** Sets up and cleans up event listeners for resize, scroll, and wheel events. */ + useEffect(() => { + if (scrollRef.current) { + checkArrowsVisibility() + + window.addEventListener("resize", handleResize) + + const scrollWrapper = scrollRef.current + if (scrollWrapper) { + scrollWrapper.addEventListener("scroll", checkArrowsVisibility) + scrollWrapper.addEventListener("wheel", handleScroll) + } + + // Cleanup function + return () => { + window.removeEventListener("resize", handleResize) + + if (scrollWrapper) { + scrollWrapper.removeEventListener("scroll", checkArrowsVisibility) + scrollWrapper.removeEventListener("wheel", handleScroll) + } + } + } + }, [scrollWrapperCurrent, handleLastScrollX, lastScrollX]) + + // Detects changes in the `cards` array to trigger the pop-up effect and initiate smooth scrolling to highlight new content. + useEffect(() => { + if (cards && cards.length > 0) { + const timer = setTimeout(() => { + smoothScrollToRightThenLeft() + }, 3000) - return () => clearTimeout(timer) + return () => clearTimeout(timer) + } + }, [cards, smoothScrollToRightThenLeft]) + + return { + showLeftArrow, + scrollLeft, + showRightArrow, + scrollRight, + isLastCardGlowing, } - }, [cards, smoothScrollToRightThenLeft]) - - return { - showLeftArrow, - scrollLeft, - showRightArrow, - scrollRight, - isLastCardGlowing, - } } export default useScrollBox diff --git a/packages/webapp/src/pages/_app.tsx b/packages/webapp/src/pages/_app.tsx index c5388cbe..792fb2d4 100644 --- a/packages/webapp/src/pages/_app.tsx +++ b/packages/webapp/src/pages/_app.tsx @@ -29,29 +29,23 @@ export type FablePage = NextPage<{ isHydrated: boolean }> // ================================================================================================= const MyApp: AppType = ({ Component, pageProps }) => { + return ( + <> + + 0xFable + + + - return ( - <> - - 0xFable - - - - - - - {jotaiDebug()} - - - - - - ) + + + {jotaiDebug()} + + + + + + ) } export default MyApp @@ -62,42 +56,39 @@ export default MyApp * Wrapper for the main app component. This is necessary because we want to use the Wagmi account * and the `useAccount` hook can only be used within a WagmiConfig. */ -const ComponentWrapper = ({ - Component, - pageProps, -}: { - Component: ComponentType - pageProps: any -}) => { - const { address } = useAccount() - const isHydrated = useIsHydrated() - const errorConfig = useErrorConfig() +const ComponentWrapper = ({ Component, pageProps }: { Component: ComponentType; pageProps: any }) => { + const { address } = useAccount() + const isHydrated = useIsHydrated() + const errorConfig = useErrorConfig() - if (process.env.NODE_ENV === "development") { // constant - // eslint-disable-next-line react-hooks/rules-of-hooks - const router = useRouter() - const accountIndex = parseInt(router.query.index as string) - // eslint-disable-next-line react-hooks/rules-of-hooks - useEffect(() => { - if (accountIndex === undefined || isNaN(accountIndex)) return - if (accountIndex < 0 || 9 < accountIndex) return - void ensureLocalAccountIndex(accountIndex) - }, [accountIndex, address]) + if (process.env.NODE_ENV === "development") { + // constant + // eslint-disable-next-line react-hooks/rules-of-hooks + const router = useRouter() + const accountIndex = parseInt(router.query.index as string) + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (accountIndex === undefined || isNaN(accountIndex)) return + if (accountIndex < 0 || 9 < accountIndex) return + void ensureLocalAccountIndex(accountIndex) + }, [accountIndex, address]) - // It's necessary to update this on address, as Web3Modal (and possibly other wallet frameworks) - // will ignore our existence and try to override us with their own account (depending on how - // async code scheduling ends up working out). + // It's necessary to update this on address, as Web3Modal (and possibly other wallet frameworks) + // will ignore our existence and try to override us with their own account (depending on how + // async code scheduling ends up working out). - // To carry the `index` query parameter to other parts of the app, be sure to either use: - // - the `navigate` function from `utils/navigate.ts` instead of `router.push`. - // - the `link` component from `components/link.tsx` instead of `next/link` - } + // To carry the `index` query parameter to other parts of the app, be sure to either use: + // - the `navigate` function from `utils/navigate.ts` instead of `router.push`. + // - the `link` component from `components/link.tsx` instead of `next/link` + } - return <> - - {/* Global error modal for errors that don't have obvious in-flow resolutions. */} - {isHydrated && errorConfig && } - + return ( + <> + + {/* Global error modal for errors that don't have obvious in-flow resolutions. */} + {isHydrated && errorConfig && } + + ) } // ================================================================================================= diff --git a/packages/webapp/src/pages/collection.tsx b/packages/webapp/src/pages/collection.tsx index e9463bf7..4cc00f70 100644 --- a/packages/webapp/src/pages/collection.tsx +++ b/packages/webapp/src/pages/collection.tsx @@ -11,209 +11,205 @@ import { useInventoryCardsCollectionGetCollection } from "src/generated" import { Deck, Card } from "src/store/types" import { Address } from "src/chain" import { FablePage } from "src/pages/_app" -import { useRouter } from 'next/router' +import { useRouter } from "next/router" import { navigate } from "utils/navigate" -import FilterPanel from 'src/components/collection/filterPanel' -import CardCollectionDisplay from 'src/components/collection/cardCollectionDisplay' -import DeckList from 'src/components/collection/deckList' -import DeckPanel from 'src/components/collection/deckPanel' - +import FilterPanel from "src/components/collection/filterPanel" +import CardCollectionDisplay from "src/components/collection/cardCollectionDisplay" +import DeckList from "src/components/collection/deckList" +import DeckPanel from "src/components/collection/deckPanel" // NOTE(norswap & geniusgarlic): Just an example, when the game actually has effects & types, // fetch those from the chain instead of hardcoding them here. type Effect = string -const effects: Effect[] = ['Charge', 'Flight', 'Courage', 'Undying', 'Frenzy', 'Enlightened'] -const initialEffectMap = Object.assign({}, ...effects.map(name => ({[name]: false}))) +const effects: Effect[] = ["Charge", "Flight", "Courage", "Undying", "Frenzy", "Enlightened"] +const initialEffectMap = Object.assign({}, ...effects.map((name) => ({ [name]: false }))) -const types = ['Creature', 'Magic', 'Weapon'] -const initialTypeMap = Object.assign({}, ...types.map(name => ({[name]: false}))) +const types = ["Creature", "Magic", "Weapon"] +const initialTypeMap = Object.assign({}, ...types.map((name) => ({ [name]: false }))) const Collection: FablePage = ({ isHydrated }) => { - - const router = useRouter() - const { address } = useAccount() - const [ isEditing, setIsEditing ] = useState(false) - - // Filter Panel / Sorting Panel - const [ searchInput, setSearchInput ] = useState('') - const [ effectMap, setEffectMap ] = useState(initialEffectMap) - const [ typeMap, setTypeMap ] = useState(initialTypeMap) - const [ selectedCard, setSelectedCard ] = useState(null) - - // Deck Collection Display - const [ editingDeckIndex, setEditingDeckIndex ] = useState(null) - const [decks, setDecks] = useState([]) - - // Deck Construction Panel - const [ currentDeck, setCurrentDeck] = useState(null) - const [ selectedCards, setSelectedCards ] = useState([]) - - const activeEffects = Object.keys(effectMap).filter(key => effectMap[key]) - const activeTypes = Object.keys(typeMap).filter(key => typeMap[key]) - - const { data: unfilteredCards } = useInventoryCardsCollectionGetCollection({ - address: deployment.InventoryCardsCollection, - args: [address as Address] // TODO not ideal but safe in practice - }) as { - // make the wagmi type soup understandable, there are many more fields in reality - data: readonly Card[], - } - - const cards: Card[] = (unfilteredCards || []).filter(card => { - // TODO(norswap): it would look like this if the card had effects & types - // const cardEffects = card.stats.effects || [] - // const cardTypes = card.stats.types || [] - const cardEffects: Effect[] = [] - const cardTypes: Effect[] = [] - return activeEffects.every(effect => cardEffects.includes(effect)) - && activeTypes.every(type => cardTypes.includes(type)) - && card.lore.name.toLowerCase().includes(searchInput.toLowerCase()) - }) - - const handleInputChangeBouncy = (event: React.ChangeEvent) => { - setSearchInput(event.target.value) - } - const handleInputChange = useMemo(() => debounce(handleInputChangeBouncy, 300), []) - - const handleEffectClick = (effectIndex: number) => { - const effect = effects[effectIndex] - setEffectMap({...effectMap, [effect]: !effectMap[effect]}) - } - - const handleTypeClick = (typeIndex: number) => { - const type = types[typeIndex] - setTypeMap({...typeMap, [type]: !typeMap[type]}) - } - - const handleDeckSelect = (deckID: number) => { - const selectedDeck = decks[deckID] - setCurrentDeck(selectedDeck) - setEditingDeckIndex(deckID) - setIsEditing(true) - setSelectedCards(selectedDeck.cards) - } - - const handleSaveDeck = (updatedDeck: Deck) => { - const updatedDecks = [...(decks || [])] - if (editingDeckIndex !== null) { - // Update existing deck - updatedDecks[editingDeckIndex] = updatedDeck - } else { - // Add the new deck to the list - updatedDecks.push(updatedDeck) + const router = useRouter() + const { address } = useAccount() + const [isEditing, setIsEditing] = useState(false) + + // Filter Panel / Sorting Panel + const [searchInput, setSearchInput] = useState("") + const [effectMap, setEffectMap] = useState(initialEffectMap) + const [typeMap, setTypeMap] = useState(initialTypeMap) + const [selectedCard, setSelectedCard] = useState(null) + + // Deck Collection Display + const [editingDeckIndex, setEditingDeckIndex] = useState(null) + const [decks, setDecks] = useState([]) + + // Deck Construction Panel + const [currentDeck, setCurrentDeck] = useState(null) + const [selectedCards, setSelectedCards] = useState([]) + + const activeEffects = Object.keys(effectMap).filter((key) => effectMap[key]) + const activeTypes = Object.keys(typeMap).filter((key) => typeMap[key]) + + const { data: unfilteredCards } = useInventoryCardsCollectionGetCollection({ + address: deployment.InventoryCardsCollection, + args: [address as Address], // TODO not ideal but safe in practice + }) as { + // make the wagmi type soup understandable, there are many more fields in reality + data: readonly Card[] } - setDecks(updatedDecks) - setIsEditing(false) - setSelectedCards([]) - void navigate(router, '/collection') - } - - const handleCancelEditing = () => { - setIsEditing(false) - setSelectedCards([]) - void navigate(router, '/collection') - } - - const addToDeck = (card: Card) => { - setSelectedCards(prevSelectedCards => { - // Add or remove card from the selectedCards - const isCardSelected = prevSelectedCards.some(selectedCard => selectedCard.id === card.id) - if (isCardSelected) { - return prevSelectedCards.filter(selectedCard => selectedCard.id !== card.id) - } else { - return [...prevSelectedCards, card] - } - }) - } - const onCardToggle = (card: Card) => { - setSelectedCards((prevSelectedCards) => { - if (prevSelectedCards.some(selectedCard => selectedCard.id === card.id)) { - // Remove the card if it's already selected - return prevSelectedCards.filter(selectedCard => selectedCard.id !== card.id) - } else { - // Add the card if it's not already selected - return [...prevSelectedCards, card] - } + const cards: Card[] = (unfilteredCards || []).filter((card) => { + // TODO(norswap): it would look like this if the card had effects & types + // const cardEffects = card.stats.effects || [] + // const cardTypes = card.stats.types || [] + const cardEffects: Effect[] = [] + const cardTypes: Effect[] = [] + return ( + activeEffects.every((effect) => cardEffects.includes(effect)) && + activeTypes.every((type) => cardTypes.includes(type)) && + card.lore.name.toLowerCase().includes(searchInput.toLowerCase()) + ) }) - } + const handleInputChangeBouncy = (event: React.ChangeEvent) => { + setSearchInput(event.target.value) + } + const handleInputChange = useMemo(() => debounce(handleInputChangeBouncy, 300), []) - // Sets up an event listener for route changes when deck editor is rendered. - useEffect(() => { - const handleRouteChange = () => { - if (router.query.newDeck) { - setCurrentDeck({ name: '', cards: [] }) + const handleEffectClick = (effectIndex: number) => { + const effect = effects[effectIndex] + setEffectMap({ ...effectMap, [effect]: !effectMap[effect] }) + } + + const handleTypeClick = (typeIndex: number) => { + const type = types[typeIndex] + setTypeMap({ ...typeMap, [type]: !typeMap[type] }) + } + + const handleDeckSelect = (deckID: number) => { + const selectedDeck = decks[deckID] + setCurrentDeck(selectedDeck) + setEditingDeckIndex(deckID) setIsEditing(true) - setEditingDeckIndex(null) - } + setSelectedCards(selectedDeck.cards) + } + + const handleSaveDeck = (updatedDeck: Deck) => { + const updatedDecks = [...(decks || [])] + if (editingDeckIndex !== null) { + // Update existing deck + updatedDecks[editingDeckIndex] = updatedDeck + } else { + // Add the new deck to the list + updatedDecks.push(updatedDeck) + } + setDecks(updatedDecks) + setIsEditing(false) + setSelectedCards([]) + void navigate(router, "/collection") } - router.events.on('routeChangeComplete', handleRouteChange) + const handleCancelEditing = () => { + setIsEditing(false) + setSelectedCards([]) + void navigate(router, "/collection") + } + + const addToDeck = (card: Card) => { + setSelectedCards((prevSelectedCards) => { + // Add or remove card from the selectedCards + const isCardSelected = prevSelectedCards.some((selectedCard) => selectedCard.id === card.id) + if (isCardSelected) { + return prevSelectedCards.filter((selectedCard) => selectedCard.id !== card.id) + } else { + return [...prevSelectedCards, card] + } + }) + } - // Clean up the event listener when exiting the deck editor. - return () => { - router.events.off('routeChangeComplete', handleRouteChange) + const onCardToggle = (card: Card) => { + setSelectedCards((prevSelectedCards) => { + if (prevSelectedCards.some((selectedCard) => selectedCard.id === card.id)) { + // Remove the card if it's already selected + return prevSelectedCards.filter((selectedCard) => selectedCard.id !== card.id) + } else { + // Add the card if it's not already selected + return [...prevSelectedCards, card] + } + }) } - }, [router.events, router.query.newDeck]) - - return ( - <> - - 0xFable: My Collection - - {jotaiDebug()} -
- -
- {/* Left Panel - Search and Filters */} -
- -
- - {/* Middle Panel - Card Collection Display */} -
- -
- - {/* Right Panel - Deck List */} -
- {isEditing && currentDeck ? ( - - ) : ( - - )} -
-
-
- - ) + + // Sets up an event listener for route changes when deck editor is rendered. + useEffect(() => { + const handleRouteChange = () => { + if (router.query.newDeck) { + setCurrentDeck({ name: "", cards: [] }) + setIsEditing(true) + setEditingDeckIndex(null) + } + } + + router.events.on("routeChangeComplete", handleRouteChange) + + // Clean up the event listener when exiting the deck editor. + return () => { + router.events.off("routeChangeComplete", handleRouteChange) + } + }, [router.events, router.query.newDeck]) + + return ( + <> + + 0xFable: My Collection + + {jotaiDebug()} +
+ +
+ {/* Left Panel - Search and Filters */} +
+ +
+ + {/* Middle Panel - Card Collection Display */} +
+ +
+ + {/* Right Panel - Deck List */} +
+ {isEditing && currentDeck ? ( + + ) : ( + + )} +
+
+
+ + ) } export default Collection diff --git a/packages/webapp/src/pages/index.tsx b/packages/webapp/src/pages/index.tsx index 48256f94..243e64eb 100644 --- a/packages/webapp/src/pages/index.tsx +++ b/packages/webapp/src/pages/index.tsx @@ -13,63 +13,72 @@ import { Button } from "src/components/ui/button" import Link from "src/components/link" const Home: FablePage = ({ isHydrated }) => { - const { address } = useAccount() - const { setOpen } = useModal() - const { chain: usedChain } = useNetwork() - const [_gameID, setGameID] = useGameID() + const { address } = useAccount() + const { setOpen } = useModal() + const { chain: usedChain } = useNetwork() + const [_gameID, setGameID] = useGameID() - // Refresh game ID and put it in the store. - // noinspection JSDeprecatedSymbols - useGameInGame({ - address: deployment.Game, - args: [address as Address], - enabled: !!address, - onSuccess: (gameID) => { - // 0 means we're not in a game - if (gameID !== 0n) setGameID(gameID) - }, - }) + // Refresh game ID and put it in the store. + // noinspection JSDeprecatedSymbols + useGameInGame({ + address: deployment.Game, + args: [address as Address], + enabled: !!address, + onSuccess: (gameID) => { + // 0 means we're not in a game + if (gameID !== 0n) setGameID(gameID) + }, + }) - const chainSupported = chains.some((chain) => chain.id === usedChain?.id) + const chainSupported = chains.some((chain) => chain.id === usedChain?.id) - // These three states are mutually exclusive. One of them is always true. - const notConnected = !isHydrated || !address - const isRightNetwork = !notConnected && chainSupported - const isWrongNetwork = !notConnected && !chainSupported + // These three states are mutually exclusive. One of them is always true. + const notConnected = !isHydrated || !address + const isRightNetwork = !notConnected && chainSupported + const isWrongNetwork = !notConnected && !chainSupported - return ( -
-
-

- 0xFABLE -

+ return ( +
+
+

+ 0xFABLE +

- {notConnected && ( -
- -
- )} + {notConnected && ( +
+ +
+ )} - {isWrongNetwork && } + {isWrongNetwork && } - {isRightNetwork && <> -
- - - - - - -
- - } -
-
- ) + {isRightNetwork && ( + <> +
+ + + + + + +
+ + + )} +
+
+ ) } export default Home diff --git a/packages/webapp/src/pages/play.tsx b/packages/webapp/src/pages/play.tsx index 12d42cf9..c6688294 100644 --- a/packages/webapp/src/pages/play.tsx +++ b/packages/webapp/src/pages/play.tsx @@ -22,16 +22,16 @@ import { currentPlayer, isEndingTurn } from "src/game/misc" import { useCancellationHandler } from "src/hooks/useCancellationHandler" import { usePlayerHand } from "src/store/hooks" import { - DndContext, - DragOverlay, - DropAnimation, - MeasuringStrategy, - MouseSensor, - UniqueIdentifier, - closestCenter, - defaultDropAnimationSideEffects, - useSensor, - useSensors, + DndContext, + DragOverlay, + DropAnimation, + MeasuringStrategy, + MouseSensor, + UniqueIdentifier, + closestCenter, + defaultDropAnimationSideEffects, + useSensor, + useSensors, } from "@dnd-kit/core" import PlayerBoard from "src/components/playerBoard" import { createPortal } from "react-dom" @@ -41,261 +41,267 @@ import { Button } from "src/components/ui/button" import { toast } from "sonner" const Play: FablePage = ({ isHydrated }) => { - const [ gameID, setGameID ] = store.useGameID() - const gameStatus = store.useGameStatus() - - const playerAddress = store.usePlayerAddress() - const opponentAddress = store.useOpponentAddress() - const playerBattlefield = store.usePlayerBattlefield() - const opponentBattlefield = store.useOpponentBattlefield() - const router = useRouter() - const privateInfo = store.usePrivateInfo() - const [ hasVisitedBoard, visitBoard ] = store.useHasVisitedBoard() - useEffect(visitBoard, [visitBoard, hasVisitedBoard]) - - // state variables - const [ loading, setLoading ] = useState(null) - const [ hideResults, setHideResults ] = useState(false) - const [ concedeCompleted, setConcedeCompleted ] = useState(false) - const [ activeId, setActiveId ] = useState(null) - const [ showDrawButton, setShowDrawButton ] = useState(false) - - const gameData = store.useGameData() - const playerHand = usePlayerHand() - - const dropAnimation: DropAnimation = { - sideEffects: defaultDropAnimationSideEffects({ - styles: { - active: { - opacity: "0.5", - }, - }, - }), - } - - useEffect(() => { - // If the game ID is null, fetch it from the contract. If still null, we're not in a game, - // navigate back to homepage. - - const fetchGameID = async () => { - const fetchedGameID = await readContract({ - address: deployment.Game, - abi: gameABI, - functionName: "inGame", - args: [playerAddress as Address], - }) - - if (fetchedGameID > 0n) setGameID(fetchedGameID) - else void navigate(router, "/") - } - - // Back to home screen if player disconnects. - if (playerAddress === null) void navigate(router, "/") - else if (gameID === null) void fetchGameID() - }, [gameID, setGameID, playerAddress, router]) - - const ended = gameStatus === GameStatus.ENDED || concedeCompleted - - useEffect(() => { - // This avoids overlapping the concede loading modal with the game ended modal. This tends to - // happen because we receive the game ended event before the confirmation that the concede - // transaction succeeded. - if (ended) setLoading(null) - }, [ended]) - - const missingData = gameID === null || playerAddress === null || gameData === null - const cantTakeActions = missingData || currentPlayer(gameData) !== playerAddress - const cancellationHandler = useCancellationHandler(loading) - - const cantDrawCard = cantTakeActions || gameData.currentStep !== GameStep.DRAW - const doDrawCard = useCallback(() => - drawCard({ - gameID: gameID!, - playerAddress: playerAddress!, - setLoading, - cancellationHandler, - }), - [gameID, playerAddress, setLoading, cancellationHandler]) - - useEffect(() => { - // Automatically submit the card draw transaction when it's our turn - if (gameData && currentPlayer(gameData) === playerAddress && !cantDrawCard) { - toast.promise(doDrawCard, { - id: "DRAW_CARD_TOAST", - loading: "Your Turn - Drawing Card...", - success: () => { - if (showDrawButton) setShowDrawButton(false) - return "Card Drawn Successfully!" - }, - error: () => { - if(!showDrawButton) setShowDrawButton(true) - return null as any // don't trigger the toast - }, - dismissible: true - }) + const [gameID, setGameID] = store.useGameID() + const gameStatus = store.useGameStatus() + + const playerAddress = store.usePlayerAddress() + const opponentAddress = store.useOpponentAddress() + const playerBattlefield = store.usePlayerBattlefield() + const opponentBattlefield = store.useOpponentBattlefield() + const router = useRouter() + const privateInfo = store.usePrivateInfo() + const [hasVisitedBoard, visitBoard] = store.useHasVisitedBoard() + useEffect(visitBoard, [visitBoard, hasVisitedBoard]) + + // state variables + const [loading, setLoading] = useState(null) + const [hideResults, setHideResults] = useState(false) + const [concedeCompleted, setConcedeCompleted] = useState(false) + const [activeId, setActiveId] = useState(null) + const [showDrawButton, setShowDrawButton] = useState(false) + + const gameData = store.useGameData() + const playerHand = usePlayerHand() + + const dropAnimation: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { + active: { + opacity: "0.5", + }, + }, + }), } - }, [cancellationHandler, cantDrawCard, gameID, playerAddress, doDrawCard, gameData, showDrawButton]) - const cantEndTurn = cantTakeActions || !isEndingTurn(gameData.currentStep) - const doEndTurn = useCallback( - () => endTurn({ - gameID: gameID!, - playerAddress: playerAddress!, - setLoading, - }), - [gameID, playerAddress, setLoading]) - - const cantConcede = missingData - const doConcede = useCallback( - () => concede({ - gameID: gameID!, - playerAddress: playerAddress!, + useEffect(() => { + // If the game ID is null, fetch it from the contract. If still null, we're not in a game, + // navigate back to homepage. + + const fetchGameID = async () => { + const fetchedGameID = await readContract({ + address: deployment.Game, + abi: gameABI, + functionName: "inGame", + args: [playerAddress as Address], + }) + + if (fetchedGameID > 0n) setGameID(fetchedGameID) + else void navigate(router, "/") + } + + // Back to home screen if player disconnects. + if (playerAddress === null) void navigate(router, "/") + else if (gameID === null) void fetchGameID() + }, [gameID, setGameID, playerAddress, router]) + + const ended = gameStatus === GameStatus.ENDED || concedeCompleted + + useEffect(() => { + // This avoids overlapping the concede loading modal with the game ended modal. This tends to + // happen because we receive the game ended event before the confirmation that the concede + // transaction succeeded. + if (ended) setLoading(null) + }, [ended]) + + const missingData = gameID === null || playerAddress === null || gameData === null + const cantTakeActions = missingData || currentPlayer(gameData) !== playerAddress + const cancellationHandler = useCancellationHandler(loading) + + const cantDrawCard = cantTakeActions || gameData.currentStep !== GameStep.DRAW + const doDrawCard = useCallback( + () => + drawCard({ + gameID: gameID!, + playerAddress: playerAddress!, + setLoading, + cancellationHandler, + }), + [gameID, playerAddress, setLoading, cancellationHandler] + ) + + useEffect(() => { + // Automatically submit the card draw transaction when it's our turn + if (gameData && currentPlayer(gameData) === playerAddress && !cantDrawCard) { + toast.promise(doDrawCard, { + id: "DRAW_CARD_TOAST", + loading: "Your Turn - Drawing Card...", + success: () => { + if (showDrawButton) setShowDrawButton(false) + return "Card Drawn Successfully!" + }, + error: () => { + if (!showDrawButton) setShowDrawButton(true) + return null as any // don't trigger the toast + }, + dismissible: true, + }) + } + }, [cancellationHandler, cantDrawCard, gameID, playerAddress, doDrawCard, gameData, showDrawButton]) + + const cantEndTurn = cantTakeActions || !isEndingTurn(gameData.currentStep) + const doEndTurn = useCallback( + () => + endTurn({ + gameID: gameID!, + playerAddress: playerAddress!, + setLoading, + }), + [gameID, playerAddress, setLoading] + ) + + const cantConcede = missingData + const doConcede = useCallback( + () => + concede({ + gameID: gameID!, + playerAddress: playerAddress!, + setLoading, + onSuccess: () => setConcedeCompleted(true), + }), + [gameID, playerAddress] + ) + + const doHideResults = useCallback(() => setHideResults(true), [setHideResults]) + const doShowResults = useCallback(() => setHideResults(false), [setHideResults]) + useEffect(() => { + if (gameID !== null && playerAddress !== null && !privateInfo) { + setError({ + title: "Hand information is missing", + message: + "Keep playing on the device where you started the game, and do not clear your " + + "browser data while a game is in progress.", + buttons: [ + DISMISS_BUTTON, + { + text: "Concede", + onClick: () => { + void doConcede!() + setError(null) + }, + }, + ], + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [privateInfo]) + + // dnd setup + const { handleDragStart, handleDragEnd, handleDragCancel } = useDragEvents( + setActiveId, setLoading, - onSuccess: () => setConcedeCompleted(true), - }), - [gameID, playerAddress]) - - const doHideResults = useCallback(() => setHideResults(true), [setHideResults]) - const doShowResults = useCallback(() => setHideResults(false), [setHideResults]) - useEffect(() => { - if (gameID !== null && playerAddress !== null && !privateInfo) { - setError({ - title: "Hand information is missing", - message: - "Keep playing on the device where you started the game, and do not clear your " + - "browser data while a game is in progress.", - buttons: [DISMISS_BUTTON, { text: "Concede", onClick: () => { - void doConcede!() - setError(null) - }, - }, - ], - }) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [privateInfo]) - - // dnd setup - const { handleDragStart, handleDragEnd, handleDragCancel } = useDragEvents( - setActiveId, - setLoading, - cancellationHandler - ) - const sensors = useSensors( - // waits for a drag of 20 pixels before the UX assumes a card is being played - useSensor(MouseSensor, { activationConstraint: { distance: 20 } }) - ) - // ----------------------------------------------------------------------------------------------- - - if (!isHydrated) return <> - - return ( - <> - - {/* The !ended here hides the loading modal to avoid it superimposing with the game ending + cancellationHandler + ) + const sensors = useSensors( + // waits for a drag of 20 pixels before the UX assumes a card is being played + useSensor(MouseSensor, { activationConstraint: { distance: 20 } }) + ) + // ----------------------------------------------------------------------------------------------- + + if (!isHydrated) return <> + + return ( + <> + + {/* The !ended here hides the loading modal to avoid it superimposing with the game ending modal, which can happen when we learn the game has ended because of a data refresh that precedes the inclusion confirmation. */} - {loading && !ended && ( - - )} - - {gameID === 0n && ( - - )} - - {ended && !hideResults && ( - - )} - -
- - - -
- - - {!ended && ( - <> - {showDrawButton && - - } - - - - - - )} - - {/* TODO avoid the bump by grouping buttons in a container that is translated, then no need for the translation here and the important */} - {ended && ( - <> - - - )} - - - - {createPortal( - - - , - document.body - )} -
-
-
- - ) + {loading && !ended && } + + {gameID === 0n && } + + {ended && !hideResults && } + +
+ + + +
+ + + {!ended && ( + <> + {showDrawButton && ( + + )} + + + + + + )} + + {/* TODO avoid the bump by grouping buttons in a container that is translated, then no need for the translation here and the important */} + {ended && ( + <> + + + )} + + + + {createPortal( + + + , + document.body + )} +
+
+
+ + ) } export default Play diff --git a/packages/webapp/src/setup.ts b/packages/webapp/src/setup.ts index 062e5ebd..3d2c7bfe 100644 --- a/packages/webapp/src/setup.ts +++ b/packages/webapp/src/setup.ts @@ -13,15 +13,14 @@ // Called at bottom of this file. function setup() { - setupFilterErrorMessages() - setupFilterWarningMessages() - setupFilterInfoMessages() + setupFilterErrorMessages() + setupFilterWarningMessages() + setupFilterInfoMessages() - // Only in dev mode, because that's where the annoying messages occur for now. - if (process.env.NODE_ENV === "development") - setupFilterLogMessages() + // Only in dev mode, because that's where the annoying messages occur for now. + if (process.env.NODE_ENV === "development") setupFilterLogMessages() - setupBigintSerialization() + setupBigintSerialization() } // ================================================================================================= @@ -30,12 +29,11 @@ function setup() { * Replaces an object's function, for instance: * `console.log = replaceFunction(console, "log", (old) => (...args) => old("LOGGING: ", ...args))` */ -export function replaceFunction - (obj: any, name: string, replacement: (old: T) => T): T { - const old = obj[name]["0xFable_oldFunction"] ?? obj[name] - const result = replacement(old) as any - result["0xFable_oldFunction"] = old - return result +export function replaceFunction(obj: any, name: string, replacement: (old: T) => T): T { + const old = obj[name]["0xFable_oldFunction"] ?? obj[name] + const result = replacement(old) as any + result["0xFable_oldFunction"] = old + return result } // ================================================================================================= @@ -45,32 +43,32 @@ export function replaceFunction // isolated from the window context. const filteredErrorCodes = [ - // we can handle this via error handlers - "UNPREDICTABLE_GAS_LIMIT", + // we can handle this via error handlers + "UNPREDICTABLE_GAS_LIMIT", ] -const filteredErrorMessages: (string|RegExp)[] = [ - "ChainDoesNotSupportContract: Chain \"Localhost\" does not support contract \"ensUniversalResolver\"." +const filteredErrorMessages: (string | RegExp)[] = [ + 'ChainDoesNotSupportContract: Chain "Localhost" does not support contract "ensUniversalResolver".', ] const filteredWarningMessages = [ - "Lit is in dev mode.", - // React in dev mode, in Playwright - "Please install/enable Redux devtools extension", - // WalletConnect - "SingleFile is hooking the IntersectionObserver API to detect and load deferred images", - // NextJS — I can tell and things should be designed to work - "[Fast Refresh] performing full reload" + "Lit is in dev mode.", + // React in dev mode, in Playwright + "Please install/enable Redux devtools extension", + // WalletConnect + "SingleFile is hooking the IntersectionObserver API to detect and load deferred images", + // NextJS — I can tell and things should be designed to work + "[Fast Refresh] performing full reload", ] const filteredInfoMessages = [ - // WalletConnect - "Unsuccessful attempt at preloading some images" + // WalletConnect + "Unsuccessful attempt at preloading some images", ] const filteredLogMessages = [ - // NextJS — used by FastRefresh - "[HMR] connected" + // NextJS — used by FastRefresh + "[HMR] connected", ] // Logs I can't suppress: @@ -95,37 +93,32 @@ const filteredLogMessages = [ // ------------------------------------------------------------------------------------------------- -function matchFilter(err?: string, filter: string|RegExp): boolean { - if (err === undefined) return false - return typeof filter === "string" - ? err.startsWith(filter) - : err.match(filter).length > 0 +function matchFilter(err?: string, filter: string | RegExp): boolean { + if (err === undefined) return false + return typeof filter === "string" ? err.startsWith(filter) : err.match(filter).length > 0 } // ------------------------------------------------------------------------------------------------- -function setupFiltering( - level: string, filteredMessages: (string|RegExp)[], filteredCodes?: string[]) { +function setupFiltering(level: string, filteredMessages: (string | RegExp)[], filteredCodes?: string[]) { + console[level] = replaceFunction(console, level, (oldFunction) => (msg, ...args) => { + if (typeof msg === "string" && args.length > 0) + // Very imperfect implementation of string substitutions, good enough for us. + msg.replace(/%s/g, () => args.shift()) - console[level] = replaceFunction(console, level, (oldFunction) => (msg, ...args) => { + const msgStr = msg?.toString() - if (typeof msg === "string" && args.length > 0) - // Very imperfect implementation of string substitutions, good enough for us. - msg.replace(/%s/g, () => args.shift()) + const filteredMsg = filteredMessages.some((filter) => matchFilter(msgStr, filter)) + const filteredCode = filteredCodes && filteredCodes.includes(msg?.code) - const msgStr = msg?.toString() - - const filteredMsg = filteredMessages.some((filter) => matchFilter(msgStr, filter)) - const filteredCode = filteredCodes && filteredCodes.includes(msg?.code) - - if (filteredMsg || filteredCode) { - const suppressed = `suppressed${level[0].toUpperCase()}${level.slice(1)}s` - console[suppressed] ||= [] - console[suppressed].push(msgStr) - } else { - oldFunction(msg, ...args) - } - }) + if (filteredMsg || filteredCode) { + const suppressed = `suppressed${level[0].toUpperCase()}${level.slice(1)}s` + console[suppressed] ||= [] + console[suppressed].push(msgStr) + } else { + oldFunction(msg, ...args) + } + }) } // ------------------------------------------------------------------------------------------------- @@ -137,7 +130,7 @@ function setupFiltering( * `console.suppressedErrors`. */ function setupFilterErrorMessages() { - setupFiltering("error", filteredErrorMessages, filteredErrorCodes) + setupFiltering("error", filteredErrorMessages, filteredErrorCodes) } // ------------------------------------------------------------------------------------------------- @@ -148,7 +141,7 @@ function setupFilterErrorMessages() { * `console.suppressedWarnings`. */ function setupFilterWarningMessages() { - setupFiltering("warn", filteredWarningMessages) + setupFiltering("warn", filteredWarningMessages) } // ------------------------------------------------------------------------------------------------- @@ -159,7 +152,7 @@ function setupFilterWarningMessages() { * `console.suppressedInfos`. */ function setupFilterInfoMessages() { - setupFiltering("info", filteredInfoMessages) + setupFiltering("info", filteredInfoMessages) } // ------------------------------------------------------------------------------------------------- @@ -170,7 +163,7 @@ function setupFilterInfoMessages() { * `console.suppressedLogs`. */ function setupFilterLogMessages() { - setupFiltering("log", filteredLogMessages) + setupFiltering("log", filteredLogMessages) } // ================================================================================================= @@ -180,43 +173,40 @@ function setupFilterLogMessages() { // This is needed for debug tools to handle BigInt in React state, and is just a lot more convenient // than adding explicit parsing everywhere in general. function setupBigintSerialization() { - - // Same behaviour as wagmi serialize/deserialize, but hand-rolled because redefining - // stringify/parse in terms of the wagmi function creates infinite recursion. - - // Serialization - const oldStringify = JSON.stringify["oldStringify"] ?? JSON.stringify - JSON.stringify = (value, replacer, space) => { - return oldStringify(value, (key, value) => { - if (typeof value === "bigint") - return `#bigint.${value}` - else if (typeof replacer === "function") - return replacer(key, value) - else - return value - }, space) - } - JSON.stringify["oldStringify"] = oldStringify - - // Deserialization - const oldParse = JSON.parse["oldParse"] ?? JSON.parse - JSON.parse = (text, reviver) => { - return oldParse(text, (key, value) => { - // We only values of shape "#bigint." - if (typeof value === "string" && value.startsWith("#bigint.")) - return BigInt(value.slice(8)).valueOf() - // Otherwise fallback to normal behavior - if (typeof reviver === "function") - return reviver(key, value) - else - return value - }) - } - JSON.parse["oldParse"] = oldParse + // Same behaviour as wagmi serialize/deserialize, but hand-rolled because redefining + // stringify/parse in terms of the wagmi function creates infinite recursion. + + // Serialization + const oldStringify = JSON.stringify["oldStringify"] ?? JSON.stringify + JSON.stringify = (value, replacer, space) => { + return oldStringify( + value, + (key, value) => { + if (typeof value === "bigint") return `#bigint.${value}` + else if (typeof replacer === "function") return replacer(key, value) + else return value + }, + space + ) + } + JSON.stringify["oldStringify"] = oldStringify + + // Deserialization + const oldParse = JSON.parse["oldParse"] ?? JSON.parse + JSON.parse = (text, reviver) => { + return oldParse(text, (key, value) => { + // We only values of shape "#bigint." + if (typeof value === "string" && value.startsWith("#bigint.")) return BigInt(value.slice(8)).valueOf() + // Otherwise fallback to normal behavior + if (typeof reviver === "function") return reviver(key, value) + else return value + }) + } + JSON.parse["oldParse"] = oldParse } // ================================================================================================= setup() -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/store/atoms.ts b/packages/webapp/src/store/atoms.ts index e98eb3c8..00c072a8 100644 --- a/packages/webapp/src/store/atoms.ts +++ b/packages/webapp/src/store/atoms.ts @@ -11,13 +11,7 @@ import { atom, getDefaultStore } from "jotai" import { Address } from "src/chain" import { atomWithStorage } from "jotai/utils" import * as derive from "src/store/derive" -import type { - ErrorConfig, - FetchedGameData, - PlayerData, - PrivateInfo, - PrivateInfoStore -} from "src/store/types" +import type { ErrorConfig, FetchedGameData, PlayerData, PrivateInfo, PrivateInfoStore } from "src/store/types" import { GameStatus } from "src/store/types" import { cachedAtom } from "src/utils/jotai" @@ -34,7 +28,7 @@ export const get = store.get // READ/WRITE ATOMS /** Player address — the connected wallet address. */ -export const playerAddress = atom(null as Address|null) +export const playerAddress = atom(null as Address | null) // NOTE: This isn't atom(null) because that selects the WriteableAtom overload. // The same applies to the other atoms below. @@ -44,12 +38,12 @@ export const playerAddress = atom(null as Address|null) /** * ID of the game the player is currently participating in (creating, joined, or playing). */ -export const gameID = atom(null as bigint|null) +export const gameID = atom(null as bigint | null) // ------------------------------------------------------------------------------------------------- /** The current state of the game. */ -export const gameData = atom(null as FetchedGameData|null) +export const gameData = atom(null as FetchedGameData | null) // ------------------------------------------------------------------------------------------------- @@ -62,11 +56,12 @@ export const gameData = atom(null as FetchedGameData|null) * to browser storage in order to survive page reloads. */ export const privateInfoStore = atomWithStorage( - "0xFable::privateInfoStore", - {} as PrivateInfoStore, - undefined, - // Necessary, otherwise the storage only gets read when the atom is mounted in React. - { unstable_getOnInit: true }) + "0xFable::privateInfoStore", + {} as PrivateInfoStore, + undefined, + // Necessary, otherwise the storage only gets read when the atom is mounted in React. + { unstable_getOnInit: true } +) // Without this, Jotai does not seem to pickup in updates from other tabs. store.sub(privateInfoStore, () => {}) @@ -82,7 +77,7 @@ export const hasVisitedBoard = atom(false) // ------------------------------------------------------------------------------------------------- /** If non-null, an error modal will be displayed with the given configuration. */ -export const errorConfig = atom(null as ErrorConfig|null) +export const errorConfig = atom(null as ErrorConfig | null) // ================================================================================================= // DERIVED ATOMS @@ -94,7 +89,7 @@ export const errorConfig = atom(null as ErrorConfig|null) * @see module:store/hooks#useGameStatus */ export const gameStatus = atom((get) => { - return derive.getGameStatus(get(gameData), get(playerAddress)) + return derive.getGameStatus(get(gameData), get(playerAddress)) }) // ------------------------------------------------------------------------------------------------- @@ -103,7 +98,7 @@ export const gameStatus = atom((get) => { * @see module:store/hooks#useAllPlayersJoined */ export const allPlayersJoined = atom((get) => { - return derive.didAllPlayersJoin(get(gameData)) + return derive.didAllPlayersJoin(get(gameData)) }) // ------------------------------------------------------------------------------------------------- @@ -112,7 +107,7 @@ export const allPlayersJoined = atom((get) => { * @see module:store/hooks#useIsGameCreator */ export const isGameCreator = atom((get) => { - return derive.isGameCreator(get(gameData), get(playerAddress)) + return derive.isGameCreator(get(gameData), get(playerAddress)) }) // ------------------------------------------------------------------------------------------------- @@ -121,7 +116,7 @@ export const isGameCreator = atom((get) => { * @see module:store/hooks#useIsGameJoiner */ export const isGameJoiner = atom((get) => { - return derive.isGameJoiner(get(gameData), get(playerAddress)) + return derive.isGameJoiner(get(gameData), get(playerAddress)) }) // ------------------------------------------------------------------------------------------------- @@ -129,9 +124,9 @@ export const isGameJoiner = atom((get) => { /** * @see module:store/read#getCards */ -export const cards = atom((get) => { - // No need to cache: the array is copied over by {@link fetchGameData} when it doesn't change. - return derive.getCards(get(gameData)) +export const cards = atom((get) => { + // No need to cache: the array is copied over by {@link fetchGameData} when it doesn't change. + return derive.getCards(get(gameData)) }) // ------------------------------------------------------------------------------------------------- @@ -140,8 +135,8 @@ export const cards = atom((get) => { * @see module:store/read#getCurrentPlayerAddress * @see module:store/hooks#useCurrentPlayerAddress */ -export const currentPlayerAddress = cachedAtom((get) => { - return derive.getCurrentPlayerAddress(get(gameData)) +export const currentPlayerAddress = cachedAtom
((get) => { + return derive.getCurrentPlayerAddress(get(gameData)) }) // ------------------------------------------------------------------------------------------------- @@ -150,8 +145,8 @@ export const currentPlayerAddress = cachedAtom((get) => { * @see module:store/read#getPlayerData * @see module:store/hooks#usePlayerData */ -export const playerData = cachedAtom((get) => { - return derive.getPlayerData(get(gameData), get(playerAddress)) +export const playerData = cachedAtom((get) => { + return derive.getPlayerData(get(gameData), get(playerAddress)) }) // ------------------------------------------------------------------------------------------------- @@ -159,8 +154,8 @@ export const playerData = cachedAtom((get) => { /** * @see module:store/hooks#useOpponentAddress */ -export const opponentAddress = cachedAtom((get) => { - return derive.getOpponentAddress(get(gameData), get(playerAddress)) +export const opponentAddress = cachedAtom
((get) => { + return derive.getOpponentAddress(get(gameData), get(playerAddress)) }) // ------------------------------------------------------------------------------------------------- @@ -168,8 +163,8 @@ export const opponentAddress = cachedAtom((get) => { /** * @see module:store/hooks#useOpponentData */ -export const opponentData = cachedAtom((get) => { - return derive.getOpponentData(get(gameData), get(playerAddress)) +export const opponentData = cachedAtom((get) => { + return derive.getOpponentData(get(gameData), get(playerAddress)) }) // ------------------------------------------------------------------------------------------------- @@ -177,8 +172,8 @@ export const opponentData = cachedAtom((get) => { /** * @see module:store/hooks#usePrivateInfo */ -export const privateInfo = cachedAtom((get) => { - return derive.getPrivateInfo(get(gameID), get(playerAddress), get(privateInfoStore)) +export const privateInfo = cachedAtom((get) => { + return derive.getPrivateInfo(get(gameID), get(playerAddress), get(privateInfoStore)) }) // ------------------------------------------------------------------------------------------------- @@ -186,8 +181,8 @@ export const privateInfo = cachedAtom((get) => { /** * @see module:store/hooks#usePlayerHand */ -export const playerHand = cachedAtom((get) => { - return derive.getPlayerHand(get(gameData), get(privateInfo)) +export const playerHand = cachedAtom((get) => { + return derive.getPlayerHand(get(gameData), get(privateInfo)) }) // ------------------------------------------------------------------------------------------------- @@ -195,8 +190,8 @@ export const playerHand = cachedAtom((get) => { /** * @see module:store/hooks#usePlayerBattlefield */ -export const playerBattlefield = cachedAtom((get) => { - return derive.getBattlefield(get(playerData), get(cards)) +export const playerBattlefield = cachedAtom((get) => { + return derive.getBattlefield(get(playerData), get(cards)) }) // ------------------------------------------------------------------------------------------------- @@ -204,32 +199,32 @@ export const playerBattlefield = cachedAtom((get) => { /** * @see module:store/hooks#useOpponentBattlefield */ -export const opponentBattlefield = cachedAtom((get) => { - return derive.getBattlefield(get(opponentData), get(cards)) +export const opponentBattlefield = cachedAtom((get) => { + return derive.getBattlefield(get(opponentData), get(cards)) }) // ================================================================================================= // DEBUG LABELS -playerAddress.debugLabel = "playerAddress" -gameID.debugLabel = "gameID" -gameData.debugLabel = "gameData" -privateInfoStore.debugLabel = "privateInfoStore" -hasVisitedBoard.debugLabel = "hasVisitedBoard" -errorConfig.debugLabel = "errorConfig" - -gameStatus.debugLabel = "gameStatus" -allPlayersJoined.debugLabel = "allPlayersJoined" -isGameCreator.debugLabel = "isGameCreator" -isGameJoiner.debugLabel = "isGameJoiner" -cards.debugLabel = "cards" +playerAddress.debugLabel = "playerAddress" +gameID.debugLabel = "gameID" +gameData.debugLabel = "gameData" +privateInfoStore.debugLabel = "privateInfoStore" +hasVisitedBoard.debugLabel = "hasVisitedBoard" +errorConfig.debugLabel = "errorConfig" + +gameStatus.debugLabel = "gameStatus" +allPlayersJoined.debugLabel = "allPlayersJoined" +isGameCreator.debugLabel = "isGameCreator" +isGameJoiner.debugLabel = "isGameJoiner" +cards.debugLabel = "cards" currentPlayerAddress.debugLabel = "currentPlayerAddress" -playerData.debugLabel = "playerData" -opponentAddress.debugLabel = "opponentAddress" -opponentData.debugLabel = "opponentData" -privateInfo.debugLabel = "privateInfo" -playerHand.debugLabel = "playerHand" -playerBattlefield.debugLabel = "playerBattlefield" -opponentBattlefield.debugLabel = "opponentBattlefield" - -// ================================================================================================= \ No newline at end of file +playerData.debugLabel = "playerData" +opponentAddress.debugLabel = "opponentAddress" +opponentData.debugLabel = "opponentData" +privateInfo.debugLabel = "privateInfo" +playerHand.debugLabel = "playerHand" +playerBattlefield.debugLabel = "playerBattlefield" +opponentBattlefield.debugLabel = "opponentBattlefield" + +// ================================================================================================= diff --git a/packages/webapp/src/store/checkFresh.ts b/packages/webapp/src/store/checkFresh.ts index 97df8aa8..a39aafe3 100644 --- a/packages/webapp/src/store/checkFresh.ts +++ b/packages/webapp/src/store/checkFresh.ts @@ -27,13 +27,14 @@ import { FetchedGameData } from "src/store/types" * The game state checking is currently unused in the codebase. */ function isStale(gameID: bigint | null, player: Address, gameData?: FetchedGameData): boolean { - const gameID2 = store.get(store.gameID) - const player2 = store.get(store.playerAddress) - const gameData2 = store.get(store.gameData) - return gameID2 !== gameID - || player2 !== player - || (gameData !== undefined - && (gameData2 === null || gameData2.lastBlockNum !== gameData.lastBlockNum)) + const gameID2 = store.get(store.gameID) + const player2 = store.get(store.playerAddress) + const gameData2 = store.get(store.gameData) + return ( + gameID2 !== gameID || + player2 !== player || + (gameData !== undefined && (gameData2 === null || gameData2.lastBlockNum !== gameData.lastBlockNum)) + ) } // ------------------------------------------------------------------------------------------------- @@ -46,9 +47,9 @@ function isStale(gameID: bigint | null, player: Address, gameData?: FetchedGameD * to date with a store that has changed while the async operation was in progress. */ export class StaleError extends Error { - constructor() { - super("Stale") - } + constructor() { + super("Stale") + } } // ------------------------------------------------------------------------------------------------- @@ -57,8 +58,7 @@ export class StaleError extends Error { * If {@link isStale} returns true, throws a {@link StaleError}. */ export function checkStale(gameID: bigint | null, player: Address, gameData?: FetchedGameData) { - if (isStale(gameID, player, gameData)) - throw new StaleError() + if (isStale(gameID, player, gameData)) throw new StaleError() } // ------------------------------------------------------------------------------------------------- @@ -78,19 +78,19 @@ export function checkStale(gameID: bigint | null, player: Address, gameData?: Fe * `freshWrap` returns and the time the corresponding `await` resumes. */ export async function freshWrap(promise: Promise): Promise<() => T> { - const gameID = store.get(store.gameID) - const player = store.get(store.playerAddress) - if (player === null) return () => { - throw new StaleError() - } + const gameID = store.get(store.gameID) + const player = store.get(store.playerAddress) + if (player === null) + return () => { + throw new StaleError() + } - const result = await promise + const result = await promise - return () => { - if (isStale(gameID, player)) - throw new StaleError() - return result - } + return () => { + if (isStale(gameID, player)) throw new StaleError() + return result + } } // ------------------------------------------------------------------------------------------------- @@ -106,4 +106,4 @@ export async function freshWrap(promise: Promise): Promise<() => T> { */ export const checkFresh = (fn: () => T) => fn() -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/store/derive.ts b/packages/webapp/src/store/derive.ts index d449fa99..5f73e708 100644 --- a/packages/webapp/src/store/derive.ts +++ b/packages/webapp/src/store/derive.ts @@ -15,25 +15,19 @@ import { FetchedGameData, GameStatus, GameStep, PlayerData, PrivateInfo, Private /** * @see module:store/read#getGameStatus */ -export function getGameStatus(gdata: FetchedGameData|null, player: Address|null): GameStatus { - if (gdata === null || player === null) - return GameStatus.UNKNOWN +export function getGameStatus(gdata: FetchedGameData | null, player: Address | null): GameStatus { + if (gdata === null || player === null) return GameStatus.UNKNOWN - if (gdata.lastBlockNum === 0n) - throw new Error("Empty game data object — shouldn't be from Game contract which checks this.") + if (gdata.lastBlockNum === 0n) + throw new Error("Empty game data object — shouldn't be from Game contract which checks this.") - if (gdata.currentStep === GameStep.UNINITIALIZED) - if (!gdata.players.includes(player)) - return GameStatus.CREATED - else if (gdata.livePlayers.includes(gdata.players.indexOf(player))) - return GameStatus.HAND_DRAWN - else - return GameStatus.JOINED + if (gdata.currentStep === GameStep.UNINITIALIZED) + if (!gdata.players.includes(player)) return GameStatus.CREATED + else if (gdata.livePlayers.includes(gdata.players.indexOf(player))) return GameStatus.HAND_DRAWN + else return GameStatus.JOINED - if (gdata.currentStep === GameStep.ENDED) - return GameStatus.ENDED - else - return GameStatus.STARTED + if (gdata.currentStep === GameStep.ENDED) return GameStatus.ENDED + else return GameStatus.STARTED } // ------------------------------------------------------------------------------------------------- @@ -41,8 +35,8 @@ export function getGameStatus(gdata: FetchedGameData|null, player: Address|null) /** * @see module:store/hooks#useAllPlayersJoined */ -export function didAllPlayersJoin(gdata: FetchedGameData|null): boolean { - return gdata?.playersLeftToJoin === 0 +export function didAllPlayersJoin(gdata: FetchedGameData | null): boolean { + return gdata?.playersLeftToJoin === 0 } // ------------------------------------------------------------------------------------------------- @@ -50,8 +44,8 @@ export function didAllPlayersJoin(gdata: FetchedGameData|null): boolean { /** * @see module:store/hooks#useIsGameCreator */ -export function isGameCreator(gdata: FetchedGameData|null, player: Address|null): boolean { - return player != null && player === gdata?.gameCreator +export function isGameCreator(gdata: FetchedGameData | null, player: Address | null): boolean { + return player != null && player === gdata?.gameCreator } // ------------------------------------------------------------------------------------------------- @@ -59,9 +53,9 @@ export function isGameCreator(gdata: FetchedGameData|null, player: Address|null) /** * @see module:store/hooks#useIsGameJoiner */ -export function isGameJoiner(gdata: FetchedGameData|null, player: Address|null): boolean { - const createdGame = isGameCreator(gdata, player) - return !!(player != null && !createdGame && gdata?.players?.includes(player)) +export function isGameJoiner(gdata: FetchedGameData | null, player: Address | null): boolean { + const createdGame = isGameCreator(gdata, player) + return !!(player != null && !createdGame && gdata?.players?.includes(player)) } // ------------------------------------------------------------------------------------------------- @@ -69,8 +63,8 @@ export function isGameJoiner(gdata: FetchedGameData|null, player: Address|null): /** * @see module:store/read#getCards */ -export function getCards(gdata: FetchedGameData|null): readonly bigint[]|null { - return gdata?.cards ?? null +export function getCards(gdata: FetchedGameData | null): readonly bigint[] | null { + return gdata?.cards ?? null } // ------------------------------------------------------------------------------------------------- @@ -79,9 +73,9 @@ export function getCards(gdata: FetchedGameData|null): readonly bigint[]|null { * @see module:store/read#getCurrentPlayerAddress * @see module:store/hooks#useCurrentPlayerAddress */ -export function getCurrentPlayerAddress(gdata: FetchedGameData|null): Address|null { - if (gdata === null) return null - return gdata.players[gdata.currentPlayer] +export function getCurrentPlayerAddress(gdata: FetchedGameData | null): Address | null { + if (gdata === null) return null + return gdata.players[gdata.currentPlayer] } // ------------------------------------------------------------------------------------------------- @@ -90,11 +84,11 @@ export function getCurrentPlayerAddress(gdata: FetchedGameData|null): Address|nu * @see module:store/read#getPlayerData * @see module:store/hooks#usePlayerData */ -export function getPlayerData(gdata: FetchedGameData|null, player: Address|null): PlayerData|null { - if (gdata === null || player === null) return null - const index = gdata.players.indexOf(player) - if (index < 0) return null - return gdata.playerData[index] ?? null +export function getPlayerData(gdata: FetchedGameData | null, player: Address | null): PlayerData | null { + if (gdata === null || player === null) return null + const index = gdata.players.indexOf(player) + if (index < 0) return null + return gdata.playerData[index] ?? null } // ------------------------------------------------------------------------------------------------- @@ -102,14 +96,12 @@ export function getPlayerData(gdata: FetchedGameData|null, player: Address|null) /** * @see module:store/hooks#useOpponentAddress */ -export function getOpponentAddress(gdata: FetchedGameData|null, player: Address|null): Address|null { - - if (gdata == null || player == null) return null - if (gdata.players.length !== 2) - throw new Error("Wrong assumption: game doesn't have exactly 2 players.") - const localIndex = gdata.players.indexOf(player) - if (localIndex < 0) return null - return gdata.players[(localIndex + 1) % 2] +export function getOpponentAddress(gdata: FetchedGameData | null, player: Address | null): Address | null { + if (gdata == null || player == null) return null + if (gdata.players.length !== 2) throw new Error("Wrong assumption: game doesn't have exactly 2 players.") + const localIndex = gdata.players.indexOf(player) + if (localIndex < 0) return null + return gdata.players[(localIndex + 1) % 2] } // ------------------------------------------------------------------------------------------------- @@ -117,9 +109,9 @@ export function getOpponentAddress(gdata: FetchedGameData|null, player: Address| /** * @see module:store/hooks#useOpponentData */ -export function getOpponentData(gdata: FetchedGameData|null, player: Address|null): PlayerData|null { - const oppponentAddress = getOpponentAddress(gdata, player) - return getPlayerData(gdata, oppponentAddress) +export function getOpponentData(gdata: FetchedGameData | null, player: Address | null): PlayerData | null { + const oppponentAddress = getOpponentAddress(gdata, player) + return getPlayerData(gdata, oppponentAddress) } // ------------------------------------------------------------------------------------------------- @@ -127,11 +119,13 @@ export function getOpponentData(gdata: FetchedGameData|null, player: Address|nul /** * @see module:store/read#getPrivateInfo */ -export function getPrivateInfo(gameID: bigint | null, player: Address | null, privateInfoStore: PrivateInfoStore): PrivateInfo | null { - // Directly return null if either gameID or player is null, or if the specific info does not exist in the store. - return gameID !== null && player !== null ? - privateInfoStore[gameID.toString()]?.[player] || null - : null; +export function getPrivateInfo( + gameID: bigint | null, + player: Address | null, + privateInfoStore: PrivateInfoStore +): PrivateInfo | null { + // Directly return null if either gameID or player is null, or if the specific info does not exist in the store. + return gameID !== null && player !== null ? privateInfoStore[gameID.toString()]?.[player] || null : null } // ------------------------------------------------------------------------------------------------- @@ -140,16 +134,14 @@ export function getPrivateInfo(gameID: bigint | null, player: Address | null, pr * @see module:store/hooks#usePlayerHand */ export function getPlayerHand( - gdata: FetchedGameData|null, - privateInfo: PrivateInfo|null -): readonly bigint[]|null { - if (gdata === null || privateInfo === null) return null - const handIndexes = privateInfo.handIndexes - const firstEmpty = handIndexes.indexOf(255) - const handSize = firstEmpty < 0 ? handIndexes.length : firstEmpty - return handIndexes - .slice(0, handSize) - .map((index) => gdata.cards[index]) + gdata: FetchedGameData | null, + privateInfo: PrivateInfo | null +): readonly bigint[] | null { + if (gdata === null || privateInfo === null) return null + const handIndexes = privateInfo.handIndexes + const firstEmpty = handIndexes.indexOf(255) + const handSize = firstEmpty < 0 ? handIndexes.length : firstEmpty + return handIndexes.slice(0, handSize).map((index) => gdata.cards[index]) } // ------------------------------------------------------------------------------------------------- @@ -158,8 +150,8 @@ export function getPlayerHand( * @see module:store/read#getDeckSize */ export function getDeckSize(privateInfo: PrivateInfo): number { - const firstEmpty = privateInfo.deckIndexes.indexOf(255) - return firstEmpty < 0 ? privateInfo.deckIndexes.length : firstEmpty + const firstEmpty = privateInfo.deckIndexes.indexOf(255) + return firstEmpty < 0 ? privateInfo.deckIndexes.length : firstEmpty } // ------------------------------------------------------------------------------------------------- @@ -167,13 +159,12 @@ export function getDeckSize(privateInfo: PrivateInfo): number { /** * @see module:store/read#getOpponentIndex */ -export function getOpponentIndex(gdata: FetchedGameData|null, player: Address|null): number|null { - if (gdata == null || player == null) return null - if (gdata.players.length !== 2) - throw new Error("Wrong assumption: game doesn't have exactly 2 players.") - const localIndex = gdata.players.indexOf(player) - if (localIndex < 0) return null - return (localIndex + 1) % 2 +export function getOpponentIndex(gdata: FetchedGameData | null, player: Address | null): number | null { + if (gdata == null || player == null) return null + if (gdata.players.length !== 2) throw new Error("Wrong assumption: game doesn't have exactly 2 players.") + const localIndex = gdata.players.indexOf(player) + if (localIndex < 0) return null + return (localIndex + 1) % 2 } // ------------------------------------------------------------------------------------------------- @@ -181,12 +172,9 @@ export function getOpponentIndex(gdata: FetchedGameData|null, player: Address|nu /** * @see module:store/read#getDeck */ -export function getDeck( - pdata: PlayerData|null, - cards: readonly bigint[]|null, -): bigint[]|null { - if (pdata === null || cards === null) return null - return cards.slice(pdata.deckStart, pdata.deckEnd) +export function getDeck(pdata: PlayerData | null, cards: readonly bigint[] | null): bigint[] | null { + if (pdata === null || cards === null) return null + return cards.slice(pdata.deckStart, pdata.deckEnd) } // ------------------------------------------------------------------------------------------------- @@ -195,12 +183,14 @@ export function getDeck( * @see module:store/read#isGameReadyToStart */ export function isGameReadyToStart(gdata: FetchedGameData, blockNumber: bigint): boolean { - return gdata.playersLeftToJoin === 0 && - (gdata.lastBlockNum >= blockNumber - // Depending on whether the game data has already been updated with the results of the - // drawInitialHand call. - ? gdata.livePlayers.length === gdata.players.length - : gdata.livePlayers.length === gdata.players.length - 1) + return ( + gdata.playersLeftToJoin === 0 && + (gdata.lastBlockNum >= blockNumber + ? // Depending on whether the game data has already been updated with the results of the + // drawInitialHand call. + gdata.livePlayers.length === gdata.players.length + : gdata.livePlayers.length === gdata.players.length - 1) + ) } // ------------------------------------------------------------------------------------------------- @@ -209,16 +199,12 @@ export function isGameReadyToStart(gdata: FetchedGameData, blockNumber: bigint): * @see module:store/hooks#usePlayerBattlefield * @see module:store/hooks#useOpponentBattlefield */ -export function getBattlefield( - pdata: PlayerData|null, - cards: readonly bigint[]|null -): readonly bigint[]|null { - if (pdata === null || cards === null) return null - const battlefield = [] - for (let i = pdata.deckStart; i < pdata.deckEnd; i++) - if (pdata.battlefield & (1n << BigInt(i))) - battlefield.push(cards[i]) - return battlefield +export function getBattlefield(pdata: PlayerData | null, cards: readonly bigint[] | null): readonly bigint[] | null { + if (pdata === null || cards === null) return null + const battlefield = [] + for (let i = pdata.deckStart; i < pdata.deckEnd; i++) + if (pdata.battlefield & (1n << BigInt(i))) battlefield.push(cards[i]) + return battlefield } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/store/hooks.ts b/packages/webapp/src/store/hooks.ts index e50c8534..65d36ea3 100644 --- a/packages/webapp/src/store/hooks.ts +++ b/packages/webapp/src/store/hooks.ts @@ -16,8 +16,8 @@ import { ErrorConfig, FetchedGameData, GameStatus, PlayerData, PrivateInfo } fro // ================================================================================================= /** Player address — the connected wallet address. */ -export function usePlayerAddress(): Address|null { - return useAtomValue(store.playerAddress) +export function usePlayerAddress(): Address | null { + return useAtomValue(store.playerAddress) } // ------------------------------------------------------------------------------------------------- @@ -28,15 +28,15 @@ export function usePlayerAddress(): Address|null { * Returns the current value and a setter that can be used to transition to a different ID, or * to clear the game data (by passing null). */ -export function useGameID(): [bigint|null, (ID: bigint|null) => void] { - return useAtom(store.gameID) +export function useGameID(): [bigint | null, (ID: bigint | null) => void] { + return useAtom(store.gameID) } // ------------------------------------------------------------------------------------------------- /** The current state of the game. */ -export function useGameData(): FetchedGameData|null { - return useAtomValue(store.gameData) +export function useGameData(): FetchedGameData | null { + return useAtomValue(store.gameData) } // ------------------------------------------------------------------------------------------------- @@ -48,15 +48,15 @@ export function useGameData(): FetchedGameData|null { * Returns the current value and a function that can be used to indicate we visited the game board. */ export function useHasVisitedBoard(): [boolean, () => void] { - const [ value, setValue ] = useAtom(store.hasVisitedBoard) - return [ value, () => setValue(true) ] + const [value, setValue] = useAtom(store.hasVisitedBoard) + return [value, () => setValue(true)] } // ------------------------------------------------------------------------------------------------- /** If non-null, the configuration of an error modal to be displayed. */ -export function useErrorConfig(): ErrorConfig|null { - return useAtomValue(store.errorConfig) +export function useErrorConfig(): ErrorConfig | null { + return useAtomValue(store.errorConfig) } // ------------------------------------------------------------------------------------------------- @@ -66,7 +66,7 @@ export function useErrorConfig(): ErrorConfig|null { * Will be {@link GameStatus.UNKNOWN} (= 0) if the some data is missing. */ export function useGameStatus(): GameStatus { - return useAtomValue(store.gameStatus) + return useAtomValue(store.gameStatus) } // ------------------------------------------------------------------------------------------------- @@ -76,7 +76,7 @@ export function useGameStatus(): GameStatus { * game creator can no longer cancel the game. False if data is missing. */ export function useAllPlayersJoined(): boolean { - return useAtomValue(store.allPlayersJoined) + return useAtomValue(store.allPlayersJoined) } // ------------------------------------------------------------------------------------------------- @@ -85,7 +85,7 @@ export function useAllPlayersJoined(): boolean { * True if the local player is the game creator. False if data is missing. */ export function useIsGameCreator(): boolean { - return useAtomValue(store.isGameCreator) + return useAtomValue(store.isGameCreator) } // ------------------------------------------------------------------------------------------------- @@ -95,7 +95,7 @@ export function useIsGameCreator(): boolean { * False if data is missing. */ export function useIsGameJoiner(): boolean { - return useAtomValue(store.isGameJoiner) + return useAtomValue(store.isGameJoiner) } // ------------------------------------------------------------------------------------------------- @@ -104,8 +104,8 @@ export function useIsGameJoiner(): boolean { * The address of the current player (whose turn it is in the game). Will be null if data is * missing. This value is only meaningful if the game status is >= {@link GameStatus.STARTED}. */ -export function useCurrentPlayerAddress(): Address|null { - return useAtomValue(store.currentPlayerAddress) +export function useCurrentPlayerAddress(): Address | null { + return useAtomValue(store.currentPlayerAddress) } // ------------------------------------------------------------------------------------------------- @@ -114,8 +114,8 @@ export function useCurrentPlayerAddress(): Address|null { * Returns the {@link PlayerData} for the local player, or null if the player is not set, game * data is missing, or the player is not in the game. Returns null if data is missing. */ -export function usePlayerData(): PlayerData|null { - return useAtomValue(store.playerData) +export function usePlayerData(): PlayerData | null { + return useAtomValue(store.playerData) } // ------------------------------------------------------------------------------------------------- @@ -124,8 +124,8 @@ export function usePlayerData(): PlayerData|null { * The address of the opponent of the local player (assumes a two-player game). * Returns null if data is missing. */ -export function useOpponentAddress(): Address|null { - return useAtomValue(store.opponentAddress) +export function useOpponentAddress(): Address | null { + return useAtomValue(store.opponentAddress) } // ------------------------------------------------------------------------------------------------- @@ -134,8 +134,8 @@ export function useOpponentAddress(): Address|null { * Returns the {@link PlayerData} for the opponent (assumes a two-player game). Returns null if the * local player is not set, game data is missing, or the local player is not in the game. */ -export function useOpponentData(): PlayerData|null { - return useAtomValue(store.opponentData) +export function useOpponentData(): PlayerData | null { + return useAtomValue(store.opponentData) } // ------------------------------------------------------------------------------------------------- @@ -143,8 +143,8 @@ export function useOpponentData(): PlayerData|null { /** * Returns the local player's hand, or null if data is missing. */ -export function usePlayerHand(): readonly bigint[]|null { - return useAtomValue(store.playerHand) +export function usePlayerHand(): readonly bigint[] | null { + return useAtomValue(store.playerHand) } // ------------------------------------------------------------------------------------------------- @@ -153,8 +153,8 @@ export function usePlayerHand(): readonly bigint[]|null { * The private information pertaining to the local player. * Will be null if data is missing. */ -export function usePrivateInfo(): PrivateInfo|null { - return useAtomValue(store.privateInfo) +export function usePrivateInfo(): PrivateInfo | null { + return useAtomValue(store.privateInfo) } // ------------------------------------------------------------------------------------------------- @@ -162,8 +162,8 @@ export function usePrivateInfo(): PrivateInfo|null { /** * The cards controlled by the local player on the battlefield. */ -export function usePlayerBattlefield(): readonly bigint[]|null { - return useAtomValue(store.playerBattlefield) +export function usePlayerBattlefield(): readonly bigint[] | null { + return useAtomValue(store.playerBattlefield) } // ------------------------------------------------------------------------------------------------- @@ -172,8 +172,8 @@ export function usePlayerBattlefield(): readonly bigint[]|null { * The cards controlled by the opponent on the battlefield. * Will be null if data is missing. */ -export function useOpponentBattlefield(): readonly bigint[]|null { - return useAtomValue(store.opponentBattlefield) +export function useOpponentBattlefield(): readonly bigint[] | null { + return useAtomValue(store.opponentBattlefield) } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/store/network.ts b/packages/webapp/src/store/network.ts index 80175e8b..6d9c1eaf 100644 --- a/packages/webapp/src/store/network.ts +++ b/packages/webapp/src/store/network.ts @@ -19,17 +19,20 @@ import { type Address } from "src/chain" * Fetches the game data, handling throttling and zombie updates, as well as retries (via wagmi). * Returns null in case of throttling or zombie. */ -export const fetchGameData: - (gameID: bigint, player: Address, shouldFetchCards: boolean) - => Promise> = - throttledFetch(async (gameID: bigint, player: Address, shouldFetchCards: boolean) => { - return readContract({ - address: deployment.Game, - abi: gameABI, - functionName: "fetchGameData", - args: [gameID, player, shouldFetchCards] - }) - }) +export const fetchGameData: ( + gameID: bigint, + player: Address, + shouldFetchCards: boolean +) => Promise> = throttledFetch( + async (gameID: bigint, player: Address, shouldFetchCards: boolean) => { + return readContract({ + address: deployment.Game, + abi: gameABI, + functionName: "fetchGameData", + args: [gameID, player, shouldFetchCards], + }) + } +) // ------------------------------------------------------------------------------------------------- @@ -39,12 +42,12 @@ export const fetchGameData: * Never called at the moment. Doesn't handle throttling and zombies. */ export async function fetchDeck(player: Address, deckID: number): Promise { - return readContract({ - address: deployment.Inventory, - abi: inventoryABI, - functionName: "getDeck", - args: [player, deckID] - }) + return readContract({ + address: deployment.Inventory, + abi: inventoryABI, + functionName: "getDeck", + args: [player, deckID], + }) } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/store/read.ts b/packages/webapp/src/store/read.ts index 89779d26..f0540d3c 100644 --- a/packages/webapp/src/store/read.ts +++ b/packages/webapp/src/store/read.ts @@ -17,22 +17,22 @@ import { GameStatus } from "src/store/types" // ------------------------------------------------------------------------------------------------- /** Return the ID of the game we're created or joined. */ -export function getGameID(): bigint|null { - return store.get(store.gameID) +export function getGameID(): bigint | null { + return store.get(store.gameID) } // ------------------------------------------------------------------------------------------------- /** Return the player's address. */ -export function getPlayerAddress(): Address|null { - return store.get(store.playerAddress) +export function getPlayerAddress(): Address | null { + return store.get(store.playerAddress) } // ------------------------------------------------------------------------------------------------- /** The current state of the game. */ -export function getGameData(): FetchedGameData|null { - return store.get(store.gameData) +export function getGameData(): FetchedGameData | null { + return store.get(store.gameData) } // ================================================================================================= @@ -45,10 +45,10 @@ export function getGameData(): FetchedGameData|null { * Returns {@link GameStatus.UNKNOWN} (= 0) if the some data is missing. */ export function getGameStatus( - gdata: FetchedGameData|null = store.get(store.gameData), - player: Address|null = store.get(store.playerAddress) + gdata: FetchedGameData | null = store.get(store.gameData), + player: Address | null = store.get(store.playerAddress) ): GameStatus { - return derive.getGameStatus(gdata, player) + return derive.getGameStatus(gdata, player) } // ------------------------------------------------------------------------------------------------- @@ -56,10 +56,8 @@ export function getGameStatus( /** * The list of cards that can be used in the game, or null if data is missing. */ -export function getCards( - gdata: FetchedGameData|null = store.get(store.gameData) -): readonly bigint[]|null { - return derive.getCards(gdata) +export function getCards(gdata: FetchedGameData | null = store.get(store.gameData)): readonly bigint[] | null { + return derive.getCards(gdata) } // ------------------------------------------------------------------------------------------------- @@ -68,10 +66,8 @@ export function getCards( * Returns the address of the current player (whose turn it is in the game). Returns null if data is * missing. This value is only meaningful if the game status is >= {@link GameStatus.STARTED}. */ -export function getCurrentPlayerAddress( - gdata: FetchedGameData|null = store.get(store.gameData) -): Address|null { - return derive.getCurrentPlayerAddress(gdata) +export function getCurrentPlayerAddress(gdata: FetchedGameData | null = store.get(store.gameData)): Address | null { + return derive.getCurrentPlayerAddress(gdata) } // ------------------------------------------------------------------------------------------------- @@ -81,10 +77,10 @@ export function getCurrentPlayerAddress( * we're tracking / whose data we've passed in), or null (also if data is missing). */ export function getPlayerData( - gdata: FetchedGameData|null = getGameData(), + gdata: FetchedGameData | null = getGameData(), player: Address | null = getPlayerAddress() ): PlayerData | null { - return derive.getPlayerData(gdata, player) + return derive.getPlayerData(gdata, player) } // ------------------------------------------------------------------------------------------------- @@ -93,11 +89,11 @@ export function getPlayerData( * Returns the private info for the given game and player, or null if some data is missing. */ export function getPrivateInfo( - gameID: bigint|null = store.get(store.gameID), - player: Address|null = store.get(store.playerAddress), + gameID: bigint | null = store.get(store.gameID), + player: Address | null = store.get(store.playerAddress), privateInfoStore: PrivateInfoStore = store.get(store.privateInfoStore) -): PrivateInfo|null { - return derive.getPrivateInfo(gameID, player, privateInfoStore) +): PrivateInfo | null { + return derive.getPrivateInfo(gameID, player, privateInfoStore) } // ================================================================================================= @@ -110,10 +106,10 @@ export function getPrivateInfo( * data is missing. */ export function getOpponentIndex( - gdata: FetchedGameData|null = getGameData(), - player: Address|null = getPlayerAddress() -): number|null { - return derive.getOpponentIndex(gdata, player) + gdata: FetchedGameData | null = getGameData(), + player: Address | null = getPlayerAddress() +): number | null { + return derive.getOpponentIndex(gdata, player) } // ------------------------------------------------------------------------------------------------- @@ -123,10 +119,10 @@ export function getOpponentIndex( * data we've passed in), or null. */ export function getDeck( - pdata: PlayerData|null = getPlayerData(), - cards: readonly bigint[]|null = getCards() -): bigint[]|null { - return derive.getDeck(pdata, cards) + pdata: PlayerData | null = getPlayerData(), + cards: readonly bigint[] | null = getCards() +): bigint[] | null { + return derive.getDeck(pdata, cards) } // ------------------------------------------------------------------------------------------------- @@ -135,9 +131,9 @@ export function getDeck( * Returns the number of cards left in the deck. * Returns -1 if data is missing. */ -export function getDeckSize(privateInfo: PrivateInfo|null = getPrivateInfo()): number { - if (privateInfo === null) return -1 - return derive.getDeckSize(privateInfo) +export function getDeckSize(privateInfo: PrivateInfo | null = getPrivateInfo()): number { + if (privateInfo === null) return -1 + return derive.getDeckSize(privateInfo) } // ------------------------------------------------------------------------------------------------- @@ -151,7 +147,7 @@ export function getDeckSize(privateInfo: PrivateInfo|null = getPrivateInfo()): n * proof, and the game data hasn't updated accordingly yet. */ export function isGameReadyToStart(gameData: FetchedGameData, blockNumber: bigint): boolean { - return derive.isGameReadyToStart(gameData, blockNumber) + return derive.isGameReadyToStart(gameData, blockNumber) } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/store/setup.ts b/packages/webapp/src/store/setup.ts index 2d6c61b6..9216e13d 100644 --- a/packages/webapp/src/store/setup.ts +++ b/packages/webapp/src/store/setup.ts @@ -10,53 +10,44 @@ import { getAccount, watchAccount, watchNetwork } from "wagmi/actions" import * as store from "src/store/atoms" -import { - gameIDListener, - refreshGameData, - updateNetwork, - updatePlayerAddress -} from "src/store/update" +import { gameIDListener, refreshGameData, updateNetwork, updatePlayerAddress } from "src/store/update" import { GAME_DATA_REFRESH_INTERVAL } from "src/constants" // ================================================================================================= function setupStore() { - - if (typeof window === "undefined") - // Do not set up subscriptions and timers on the server. - return - - // Whenever the connected wallet address changes, update the player address. - watchAccount(updatePlayerAddress) - - // Make sure to clear game data if we switch to an unsupported network. - watchNetwork(updateNetwork) - - // Make sure we don't miss the initial value, if already set. - updatePlayerAddress(getAccount()) - - // Update / clear game data whenever the game ID changes. - store.store.sub(store.gameID, () => { - gameIDListener(store.get(store.gameID)) - }) - - // Make sure we don't miss the initial value, if already set. - const gameID = store.get(store.gameID) - if (gameID !== null) - gameIDListener(gameID) - - // Periodically refresh game data. - setInterval(() => { - const gameID = store.get(store.gameID) - const player = store.get(store.playerAddress) - if (gameID !== null && player !== null) - void refreshGameData() - }, - GAME_DATA_REFRESH_INTERVAL) + if (typeof window === "undefined") + // Do not set up subscriptions and timers on the server. + return + + // Whenever the connected wallet address changes, update the player address. + watchAccount(updatePlayerAddress) + + // Make sure to clear game data if we switch to an unsupported network. + watchNetwork(updateNetwork) + + // Make sure we don't miss the initial value, if already set. + updatePlayerAddress(getAccount()) + + // Update / clear game data whenever the game ID changes. + store.store.sub(store.gameID, () => { + gameIDListener(store.get(store.gameID)) + }) + + // Make sure we don't miss the initial value, if already set. + const gameID = store.get(store.gameID) + if (gameID !== null) gameIDListener(gameID) + + // Periodically refresh game data. + setInterval(() => { + const gameID = store.get(store.gameID) + const player = store.get(store.playerAddress) + if (gameID !== null && player !== null) void refreshGameData() + }, GAME_DATA_REFRESH_INTERVAL) } // ================================================================================================= setupStore() -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/store/subscriptions.ts b/packages/webapp/src/store/subscriptions.ts index 4abde7cc..03087dcf 100644 --- a/packages/webapp/src/store/subscriptions.ts +++ b/packages/webapp/src/store/subscriptions.ts @@ -30,26 +30,26 @@ import { DISMISS_BUTTON } from "src/actions/errors" /** List of events we want to listen to. */ const eventNames = [ - "CardDrawn", - "CardPlayed", - "Champion", - "GameCancelled", - "GameStarted", - "MissingPlayers", - "PlayerAttacked", - "PlayerConceded", - "PlayerDefeated", - "PlayerDrewHand", - "PlayerDefended", - "PlayerJoined", - "PlayerTimedOut", - "TurnEnded" + "CardDrawn", + "CardPlayed", + "Champion", + "GameCancelled", + "GameStarted", + "MissingPlayers", + "PlayerAttacked", + "PlayerConceded", + "PlayerDefeated", + "PlayerDrewHand", + "PlayerDefended", + "PlayerJoined", + "PlayerTimedOut", + "TurnEnded", ] // ------------------------------------------------------------------------------------------------- /** ID of the game we are currently subscribed to, or null if we are not subscribed. */ -let currentlySubscribedID: bigint|null = null +let currentlySubscribedID: bigint | null = null /** List of function to call to unsubscribe from game updates. */ let unsubFunctions: (() => void)[] = [] @@ -60,31 +60,32 @@ let unsubFunctions: (() => void)[] = [] * Subscribe to all game events for the specified game ID. If the ID is null, unsubscribe from all * events we are currently subscribed to instead. */ -export function subscribeToGame(ID: bigint|null) { - - const publicClient = getPublicClient() - const needsUnsub = currentlySubscribedID !== null && ID !== currentlySubscribedID - const needsSub = ID !== null && ID !== currentlySubscribedID - - if (needsUnsub) { - // remove subscription - unsubFunctions.forEach(unsub => unsub()) - unsubFunctions = [] - console.log(`unsubscribed from game events for game ID ${currentlySubscribedID}`) - currentlySubscribedID = null - } - if (needsSub) { - currentlySubscribedID = ID - eventNames.forEach(eventName => { - unsubFunctions.push(publicClient.watchContractEvent({ - address: deployment.Game, - abi: gameABI, - eventName: eventName as any, - args: { gameID: ID }, - onLogs: logs => gameEventListener(eventName, logs) - })) - }) - } +export function subscribeToGame(ID: bigint | null) { + const publicClient = getPublicClient() + const needsUnsub = currentlySubscribedID !== null && ID !== currentlySubscribedID + const needsSub = ID !== null && ID !== currentlySubscribedID + + if (needsUnsub) { + // remove subscription + unsubFunctions.forEach((unsub) => unsub()) + unsubFunctions = [] + console.log(`unsubscribed from game events for game ID ${currentlySubscribedID}`) + currentlySubscribedID = null + } + if (needsSub) { + currentlySubscribedID = ID + eventNames.forEach((eventName) => { + unsubFunctions.push( + publicClient.watchContractEvent({ + address: deployment.Game, + abi: gameABI, + eventName: eventName as any, + args: { gameID: ID }, + onLogs: (logs) => gameEventListener(eventName, logs), + }) + ) + }) + } } // ================================================================================================= @@ -94,91 +95,94 @@ type GameEventArgs = { gameID: bigint } & any type GameEventLog = { args: GameEventArgs } & Log function gameEventListener(name: string, logs: readonly GameEventLog[]) { - for (const log of logs) - handleEvent(name, log.args) + for (const log of logs) handleEvent(name, log.args) } // ------------------------------------------------------------------------------------------------- function handleEvent(name: string, args: GameEventArgs) { - console.log(`event fired ${name}(${format(args, true)})`) - - const ID = store.get(store.gameID) - - // Event is not for the game we're tracking, ignore. - if (ID != args.gameID) return - - switch (name) { - case "CardDrawn": { - const { _player } = args - void refreshGameData() - break - } case "CardPlayed": { - const { _player, _card } = args - void refreshGameData() - break - } case "PlayerAttacked": { - const { _attacking, _defending } = args - break - } case "PlayerDefended": { - const { _attacking, _defending } = args - break - } case "PlayerPassed": { - const { _player } = args - break - } case "PlayerJoined": { - const { _player } = args - // Refetch game data to get up to date player list and update the status. - void refreshGameData() - break - } - case "PlayerDrewHand": { - void refreshGameData() - break - } - case "GameStarted": { - // No need to refetch game data, game started is triggered by a player drawing his hand, which - // refreshes the game data. - break - } - case "PlayerConceded": { - void refreshGameData() - break - } - case "PlayerDefeated": { - void refreshGameData() - break + console.log(`event fired ${name}(${format(args, true)})`) + + const ID = store.get(store.gameID) + + // Event is not for the game we're tracking, ignore. + if (ID != args.gameID) return + + switch (name) { + case "CardDrawn": { + const { _player } = args + void refreshGameData() + break + } + case "CardPlayed": { + const { _player, _card } = args + void refreshGameData() + break + } + case "PlayerAttacked": { + const { _attacking, _defending } = args + break + } + case "PlayerDefended": { + const { _attacking, _defending } = args + break + } + case "PlayerPassed": { + const { _player } = args + break + } + case "PlayerJoined": { + const { _player } = args + // Refetch game data to get up to date player list and update the status. + void refreshGameData() + break + } + case "PlayerDrewHand": { + void refreshGameData() + break + } + case "GameStarted": { + // No need to refetch game data, game started is triggered by a player drawing his hand, which + // refreshes the game data. + break + } + case "PlayerConceded": { + void refreshGameData() + break + } + case "PlayerDefeated": { + void refreshGameData() + break + } + case "PlayerTimedOut": { + void refreshGameData() + break + } + case "Champion": { + // No need to refetch game data, a player winning is triggered by a player conceding, timing + // out, or being defeated, which refreshes the game data. + break + } + case "MissingPlayers": { + // TODO: temporary message, need to do better flow handling for these scenarios + // TODO: might also be good to close the createGame modal after this + setError({ + title: "Missing players", + message: "Some players didn't join within the time limit, " + "the game got cancelled as a result.", + buttons: [DISMISS_BUTTON], + }) + store.set(store.gameID, null) // clears game data + break + } + case "GameCancelled": { + void refreshGameData() + break + } + case "TurnEnded": { + void refreshGameData() + break + } } - case "PlayerTimedOut": { - void refreshGameData() - break - } - case "Champion": { - // No need to refetch game data, a player winning is triggered by a player conceding, timing - // out, or being defeated, which refreshes the game data. - break - } - case "MissingPlayers": { - // TODO: temporary message, need to do better flow handling for these scenarios - // TODO: might also be good to close the createGame modal after this - setError({ - title: "Missing players", - message: "Some players didn't join within the time limit, " + - "the game got cancelled as a result.", - buttons: [DISMISS_BUTTON] - }) - store.set(store.gameID, null) // clears game data - break - } - case "GameCancelled": { - void refreshGameData() - break - } - case "TurnEnded": { - void refreshGameData() - break - } - } } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/store/types.ts b/packages/webapp/src/store/types.ts index f2bb64b9..ac89bc93 100644 --- a/packages/webapp/src/store/types.ts +++ b/packages/webapp/src/store/types.ts @@ -12,73 +12,73 @@ import { Address, Hash } from "src/chain" // ------------------------------------------------------------------------------------------------- export type Card = { - id: bigint - lore: { - name: string, - flavor: string, - URL: string - } - stats: { - attack: number - defense: number - } + id: bigint + lore: { + name: string + flavor: string + URL: string + } + stats: { + attack: number + defense: number + } } // ------------------------------------------------------------------------------------------------- export type Deck = { - name: string; - cards: Card[]; + name: string + cards: Card[] } // ------------------------------------------------------------------------------------------------- export enum GameStep { - UNINITIALIZED, - DRAW, - PLAY, - ATTACK, - DEFEND, - END_TURN, - ENDED + UNINITIALIZED, + DRAW, + PLAY, + ATTACK, + DEFEND, + END_TURN, + ENDED, } // ------------------------------------------------------------------------------------------------- export type PlayerData = { - health: number - defeated: boolean - deckStart: number - deckEnd: number - handSize: number - deckSize: number - joinBlockNum: bigint - saltHash: bigint - handRoot: Hash - deckRoot: Hash - // Bitfield of cards in the player's battlefield, for each bit: 1 if the card at the same - // index as the bit in `FetchedGameDataWithCards.cards` if on the battlefield, 0 otherwise. - battlefield: bigint - // Bitfield of cards in the player's graveyard (same thing as `battlefield`). - graveyard: bigint - attacking: readonly number[] + health: number + defeated: boolean + deckStart: number + deckEnd: number + handSize: number + deckSize: number + joinBlockNum: bigint + saltHash: bigint + handRoot: Hash + deckRoot: Hash + // Bitfield of cards in the player's battlefield, for each bit: 1 if the card at the same + // index as the bit in `FetchedGameDataWithCards.cards` if on the battlefield, 0 otherwise. + battlefield: bigint + // Bitfield of cards in the player's graveyard (same thing as `battlefield`). + graveyard: bigint + attacking: readonly number[] } // ------------------------------------------------------------------------------------------------- export type FetchedGameData = { - gameID: bigint - gameCreator: Address - players: readonly Address[] - playerData: readonly PlayerData[] - lastBlockNum: bigint - publicRandomness: bigint - playersLeftToJoin: number - livePlayers: readonly number[] - currentPlayer: number - currentStep: GameStep - attackingPlayer: Address - cards: readonly bigint[] + gameID: bigint + gameCreator: Address + players: readonly Address[] + playerData: readonly PlayerData[] + lastBlockNum: bigint + publicRandomness: bigint + playersLeftToJoin: number + livePlayers: readonly number[] + currentPlayer: number + currentStep: GameStep + attackingPlayer: Address + cards: readonly bigint[] } // ------------------------------------------------------------------------------------------------- @@ -87,18 +87,18 @@ export type FetchedGameData = { * Represent major phases of the game setup and breakdown, relative to the current player. */ export enum GameStatus { - /** Default value, for use with missing game data. */ - UNKNOWN, - /** The game has been created, but the player hasn't joined yet. */ - CREATED, - /** The player joined the game. */ - JOINED, - /** The player has drawn their initial hand. */ - HAND_DRAWN, - /** The game has started (all players have drawn their initial hand). */ - STARTED, - /** The game has ended (could be cancellation, timeout, or only one player left standing). */ - ENDED + /** Default value, for use with missing game data. */ + UNKNOWN, + /** The game has been created, but the player hasn't joined yet. */ + CREATED, + /** The player joined the game. */ + JOINED, + /** The player has drawn their initial hand. */ + HAND_DRAWN, + /** The game has started (all players have drawn their initial hand). */ + STARTED, + /** The game has ended (could be cancellation, timeout, or only one player left standing). */ + ENDED, } // ------------------------------------------------------------------------------------------------- @@ -110,28 +110,28 @@ export enum GameStatus { * This information cannot be derived from on-chain data. */ export type PrivateInfo = { - /** The player's secret salt, necessary to hide information. */ - salt: bigint - /** MimcHash of {@link salt}. */ - saltHash: bigint - /** - * The player's current's hand ordering (indexes into the game's card array - * ({@link FetchedGameData.cards}). Used for proofs. - */ - handIndexes: number[] - /** - * The player's current's deck ordering (indexes into the game's card array - * ({@link FetchedGameData.cards}). Used for proofs. - */ - deckIndexes: number[] - /** - * Hash of {@link handIndexes}, packed over a few field elements, in conjnction with {@link hash}. - */ - handRoot: Hash - /** - * Hash of {@link deckIndexes}, packed over a few field elements, in conjnction with {@link hash}. - */ - deckRoot: Hash + /** The player's secret salt, necessary to hide information. */ + salt: bigint + /** MimcHash of {@link salt}. */ + saltHash: bigint + /** + * The player's current's hand ordering (indexes into the game's card array + * ({@link FetchedGameData.cards}). Used for proofs. + */ + handIndexes: number[] + /** + * The player's current's deck ordering (indexes into the game's card array + * ({@link FetchedGameData.cards}). Used for proofs. + */ + deckIndexes: number[] + /** + * Hash of {@link handIndexes}, packed over a few field elements, in conjnction with {@link hash}. + */ + handRoot: Hash + /** + * Hash of {@link deckIndexes}, packed over a few field elements, in conjnction with {@link hash}. + */ + deckRoot: Hash } // ------------------------------------------------------------------------------------------------- @@ -140,7 +140,7 @@ export type PrivateInfo = { * For storing {@link PrivateInfo} in local storage, keyed by gameID (stringified) and player. */ export type PrivateInfoStore = { - [gameID: string]: { [player: Address]: PrivateInfo } + [gameID: string]: { [player: Address]: PrivateInfo } } // ------------------------------------------------------------------------------------------------- @@ -149,16 +149,16 @@ export type PrivateInfoStore = { * Public view of the game board, derived from {@link FetchedGameData}. */ export type GameBoard = { - /** - * Cards in each player's graveyard in the game. - * Players are ordered as in {@link FetchedGameData.players}. - */ - graveyard: bigint[][] - /** - * Cards on the battlefield, under the control of each player. - * Players are ordered as in {@link FetchedGameData.players}. - */ - battlefield: bigint[][] + /** + * Cards in each player's graveyard in the game. + * Players are ordered as in {@link FetchedGameData.players}. + */ + graveyard: bigint[][] + /** + * Cards on the battlefield, under the control of each player. + * Players are ordered as in {@link FetchedGameData.players}. + */ + battlefield: bigint[][] } // ================================================================================================= @@ -168,21 +168,21 @@ export type GameBoard = { /** This configure the global error modal to display an error message. */ export type ErrorConfig = { - title: string - message: string - buttons: readonly { - text: string - onClick: () => void - }[] + title: string + message: string + buttons: readonly { + text: string + onClick: () => void + }[] } // ================================================================================================= /** Represent placement of cards when in game. */ export enum CardPlacement { - HAND = 'HAND', - BOARD = 'BOARD', - DRAGGED = 'DRAGGED' + HAND = "HAND", + BOARD = "BOARD", + DRAGGED = "DRAGGED", } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/store/update.ts b/packages/webapp/src/store/update.ts index a9d397df..7b38eea7 100644 --- a/packages/webapp/src/store/update.ts +++ b/packages/webapp/src/store/update.ts @@ -31,33 +31,31 @@ import { getGameStatus, getPlayerData } from "src/store/read" * wallet is disconnected, as well as the game data. */ export function updatePlayerAddress(result: AccountResult) { - const oldAddress = store.get(store.playerAddress) - const newAddress = result.status === 'disconnected' || !isNetworkValid() - ? null - : (result.address || null) // undefined --> null - - if (oldAddress !== newAddress) { - console.log(`player address changed from ${oldAddress} to ${newAddress}`) - store.set(store.playerAddress, newAddress) - - // TODO the below should go away if we stop saving the gameID in browser storage and just fetch - // the gameID a player is in instead - - // The check is important: on a page load when already connected, the address will transition - // from null to the connected address, and we don't want to throw away the game ID. - // - // If the old address is null, there is also nothing to reset (excepted at load). - if (oldAddress !== null) - // New player means current game & game data is stale, reset it. - store.set(store.gameID, null) - } + const oldAddress = store.get(store.playerAddress) + const newAddress = result.status === "disconnected" || !isNetworkValid() ? null : result.address || null // undefined --> null + + if (oldAddress !== newAddress) { + console.log(`player address changed from ${oldAddress} to ${newAddress}`) + store.set(store.playerAddress, newAddress) + + // TODO the below should go away if we stop saving the gameID in browser storage and just fetch + // the gameID a player is in instead + + // The check is important: on a page load when already connected, the address will transition + // from null to the connected address, and we don't want to throw away the game ID. + // + // If the old address is null, there is also nothing to reset (excepted at load). + if (oldAddress !== null) + // New player means current game & game data is stale, reset it. + store.set(store.gameID, null) + } } // ------------------------------------------------------------------------------------------------- /** Returns true if the network we are connected to is the one we support ({@link chains}). */ function isNetworkValid(network: NetworkResult = getNetwork()) { - return chains.some(chain => chain.id === network.chain?.id) + return chains.some((chain) => chain.id === network.chain?.id) } // ------------------------------------------------------------------------------------------------- @@ -67,18 +65,15 @@ function isNetworkValid(network: NetworkResult = getNetwork()) { * network is unsupported. */ export function updateNetwork(result: NetworkResult) { - if (result.chain === undefined) - console.log("disconnected from network") - else - console.log(`network changed to chain with ID ${result.chain?.id}`) - - if (!isNetworkValid(result)) - store.set(store.gameID, null) // resets all game data - - // Update player address, setting it or clearing it depending on whether the network is supported. - // Note the account listener won't fire by itself because the address (wagmi-level) didn't change, - // but our invariant is that is that playerAddress === null if not connected to a supported chain. - updatePlayerAddress(getAccount()) + if (result.chain === undefined) console.log("disconnected from network") + else console.log(`network changed to chain with ID ${result.chain?.id}`) + + if (!isNetworkValid(result)) store.set(store.gameID, null) // resets all game data + + // Update player address, setting it or clearing it depending on whether the network is supported. + // Note the account listener won't fire by itself because the address (wagmi-level) didn't change, + // but our invariant is that is that playerAddress === null if not connected to a supported chain. + updatePlayerAddress(getAccount()) } // ------------------------------------------------------------------------------------------------- @@ -95,18 +90,18 @@ export function updateNetwork(result: NetworkResult) { * It never causes race conditions or weird data states: this resets all associated states, and if * a refresh lands with another ID, it will be ignored. */ -export function gameIDListener(ID: bigint|null) { - console.log(`transitioning to game ID ${ID}`) +export function gameIDListener(ID: bigint | null) { + console.log(`transitioning to game ID ${ID}`) - // avoid using inconsistent data - store.set(store.gameData, null) - store.set(store.hasVisitedBoard, false) + // avoid using inconsistent data + store.set(store.gameData, null) + store.set(store.hasVisitedBoard, false) - subscribeToGame(ID) // will unusubscribe if ID is null - if (ID === null) return // no need to refresh data + subscribeToGame(ID) // will unusubscribe if ID is null + if (ID === null) return // no need to refresh data - // We might be jumping into an in-progress game, so fetch cards. - void refreshGameData() + // We might be jumping into an in-progress game, so fetch cards. + void refreshGameData() } // ================================================================================================= @@ -120,17 +115,17 @@ export function gameIDListener(ID: bigint|null) { * fetched data should be discarded. */ function isStaleVerbose(ID: bigint, player: Address): boolean { - const storeID = store.get(store.gameID) - const storePlayer = store.get(store.playerAddress) - if (player !== storePlayer) { - console.log(`Rejected stale data with player ${player} (current: ${storePlayer})`) - return true - } - if (ID !== storeID) { - console.log(`Rejected stale data with game ID ${ID} (current: ${storeID})`) - return true - } - return false + const storeID = store.get(store.gameID) + const storePlayer = store.get(store.playerAddress) + if (player !== storePlayer) { + console.log(`Rejected stale data with player ${player} (current: ${storePlayer})`) + return true + } + if (ID !== storeID) { + console.log(`Rejected stale data with game ID ${ID} (current: ${storeID})`) + return true + } + return false } // ------------------------------------------------------------------------------------------------- @@ -145,149 +140,156 @@ function isStaleVerbose(ID: bigint, player: Address): boolean { * Can throw wagmi/actions/GetBlockErrorType errors and wagmi/actions/ReadContractErrorType errors. */ export async function refreshGameData() { - const gameID = store.get(store.gameID) - const player = store.get(store.playerAddress) - let status = store.get(store.gameStatus) - - if (gameID === null) { - console.error("refreshGameData called with null ID") - return - } else if (player === null) { - console.error("refreshGameData called with null player") - return - } - - // Always fetch cards before game is started (easier). Don't fetch after, as they won't change, - // but fetch if missing (e.g. browser refresh). - const cards = store.get(store.cards) - const shouldFetchCards = status < GameStatus.STARTED || cards === null - - const gameData = await net.fetchGameData(gameID, player, shouldFetchCards) - - if (gameData === ZOMBIE || gameData == THROTTLED || isStaleVerbose(gameID, player)) - // Either game changed (stale), or there should be a request in flight that will give us the - // data (throttled), or we should have more recent data (zombie). - return - - const oldGameData = store.get(store.gameData) - if (oldGameData !== null && oldGameData.lastBlockNum >= gameData.lastBlockNum) - // We already have more or as recent data, no need to trigger a store update. - return oldGameData - - status = getGameStatus(gameData, player) - // It should be impossible for this to be UNKNOWN (needs null gameData or null player). - - if (status === GameStatus.ENDED && gameData.playersLeftToJoin > 0) { - // The game was cancelled before starting. - // We do not do this when the game ends after starting, as we may still want to peruse the - // game board. - store.set(store.gameID, null) - // The above will cause the gameData to be cleared, return early. - return - } - - if (gameData.publicRandomness === 0n && status !== GameStatus.ENDED) { - // A public randomness of 0 on an otherwise live game can mean two things: - // 1. The block used for randomness is too old (> 256 blocks in the past), meaning the game - // is timed out. - // 2. The blockhash is not available because the `fetchGameData` contract call was made in the - // context of the block `gameData.lastBlockNum`, meaning the block hash wasn't available on - // chain. In this case we need to recompute the public randomness ourselves. - - const block = await getBlock(getPublicClient()) - const pdata = getPlayerData(gameData, player)! - - const blockNum = pdata.saltHash != 0n && pdata.handRoot == ZeroHash - ? pdata.joinBlockNum // joined, but hand not drawn - : gameData.lastBlockNum - - if (blockNum < block.number - 256n) { - // Scenario 1 (see above) - - // TODO This is a kludge and needs to be handled differently. - // - We shouldn't use the global setError, instead set values in the store, and let the - // pages display the relevant info. - // - This logic throws exceptions which we do not handle. - - // NOTE: We could handle timeouts slightly more gracefully. For instance, if we're on the - // block right before the timeout, then enabling player action is meaningless, since the - // timeout will trigger on the next block, failing the action. We should check that boundary - // condition. Similarly, we should handle failures of in-flight actions due to timeouts. - - const status = getGameStatus(gameData, player) - const currentPlayer = gameData.players[gameData.currentPlayer] - - const sendTimeout = async () => { - // TODO: this has no loading indicators while the game is cancelling - await contractWriteThrowing({ - contract: deployment.Game, - abi: gameABI, - functionName: "timeout", - args: [gameID] - }) - setError(null) - } - - if (status === GameStatus.STARTED) { - if (currentPlayer === player) { - setError({ - title: "Time out", - message: "You didn't take an action within the time limit, and lost as a result.", - buttons: [{ text: "Leave Game", onClick: sendTimeout }]}) - } else { - setError({ - title: "Your opponent timed out", - message: "Your opponent didn't take an action within the time limit, " + - "and you won as a result.", - buttons: [{ text: "Claim Victory", onClick: sendTimeout }]})} - } else { - // The game hasn't started yet, but some player didn't join. - if (status === GameStatus.HAND_DRAWN) { - // We've done our part, it's some other player that didn't join. - setError({ - title: "Missing players", - message: "Some players didn't join within the time limit, " + - "the game got cancelled as a result.", - buttons: [{ text: "Leave Game", onClick: sendTimeout }]}) + const gameID = store.get(store.gameID) + const player = store.get(store.playerAddress) + let status = store.get(store.gameStatus) + + if (gameID === null) { + console.error("refreshGameData called with null ID") + return + } else if (player === null) { + console.error("refreshGameData called with null player") + return + } + + // Always fetch cards before game is started (easier). Don't fetch after, as they won't change, + // but fetch if missing (e.g. browser refresh). + const cards = store.get(store.cards) + const shouldFetchCards = status < GameStatus.STARTED || cards === null + + const gameData = await net.fetchGameData(gameID, player, shouldFetchCards) + + if (gameData === ZOMBIE || gameData == THROTTLED || isStaleVerbose(gameID, player)) + // Either game changed (stale), or there should be a request in flight that will give us the + // data (throttled), or we should have more recent data (zombie). + return + + const oldGameData = store.get(store.gameData) + if (oldGameData !== null && oldGameData.lastBlockNum >= gameData.lastBlockNum) + // We already have more or as recent data, no need to trigger a store update. + return oldGameData + + status = getGameStatus(gameData, player) + // It should be impossible for this to be UNKNOWN (needs null gameData or null player). + + if (status === GameStatus.ENDED && gameData.playersLeftToJoin > 0) { + // The game was cancelled before starting. + // We do not do this when the game ends after starting, as we may still want to peruse the + // game board. + store.set(store.gameID, null) + // The above will cause the gameData to be cleared, return early. + return + } + + if (gameData.publicRandomness === 0n && status !== GameStatus.ENDED) { + // A public randomness of 0 on an otherwise live game can mean two things: + // 1. The block used for randomness is too old (> 256 blocks in the past), meaning the game + // is timed out. + // 2. The blockhash is not available because the `fetchGameData` contract call was made in the + // context of the block `gameData.lastBlockNum`, meaning the block hash wasn't available on + // chain. In this case we need to recompute the public randomness ourselves. + + const block = await getBlock(getPublicClient()) + const pdata = getPlayerData(gameData, player)! + + const blockNum = + pdata.saltHash != 0n && pdata.handRoot == ZeroHash + ? pdata.joinBlockNum // joined, but hand not drawn + : gameData.lastBlockNum + + if (blockNum < block.number - 256n) { + // Scenario 1 (see above) + + // TODO This is a kludge and needs to be handled differently. + // - We shouldn't use the global setError, instead set values in the store, and let the + // pages display the relevant info. + // - This logic throws exceptions which we do not handle. + + // NOTE: We could handle timeouts slightly more gracefully. For instance, if we're on the + // block right before the timeout, then enabling player action is meaningless, since the + // timeout will trigger on the next block, failing the action. We should check that boundary + // condition. Similarly, we should handle failures of in-flight actions due to timeouts. + + const status = getGameStatus(gameData, player) + const currentPlayer = gameData.players[gameData.currentPlayer] + + const sendTimeout = async () => { + // TODO: this has no loading indicators while the game is cancelling + await contractWriteThrowing({ + contract: deployment.Game, + abi: gameABI, + functionName: "timeout", + args: [gameID], + }) + setError(null) + } + + if (status === GameStatus.STARTED) { + if (currentPlayer === player) { + setError({ + title: "Time out", + message: "You didn't take an action within the time limit, and lost as a result.", + buttons: [{ text: "Leave Game", onClick: sendTimeout }], + }) + } else { + setError({ + title: "Your opponent timed out", + message: + "Your opponent didn't take an action within the time limit, " + "and you won as a result.", + buttons: [{ text: "Claim Victory", onClick: sendTimeout }], + }) + } + } else { + // The game hasn't started yet, but some player didn't join. + if (status === GameStatus.HAND_DRAWN) { + // We've done our part, it's some other player that didn't join. + setError({ + title: "Missing players", + message: + "Some players didn't join within the time limit, " + "the game got cancelled as a result.", + buttons: [{ text: "Leave Game", onClick: sendTimeout }], + }) + } else { + setError({ + title: "Time out", + message: + "You couldn't join the game within the time limit, " + + "the game got cancelled as a result.", + buttons: [ + { + text: "Leave Game", + onClick: sendTimeout, + }, + ], + }) + } + } } else { - setError({ - title: "Time out", - message: "You couldn't join the game within the time limit, " + - "the game got cancelled as a result.", - buttons: [{ - text: "Leave Game", - onClick: sendTimeout}]}) + // Scenario 2 (see above) + + const lastGameBlock = + block.number === gameData.lastBlockNum + ? block + : await getBlock(getPublicClient(), { blockNumber: gameData.lastBlockNum }) + + // Note that this will also works when the publicRandomness is separate for players drawing + // their hands: in the case where we're on the very last block, `gameData.lastBlockNum === + // playerData.joinBlockNum`. + gameData.publicRandomness = parseBigInt(lastGameBlock.hash) % PROOF_CURVE_ORDER } - } - } else { - // Scenario 2 (see above) - - const lastGameBlock = block.number === gameData.lastBlockNum - ? block - : await getBlock(getPublicClient(), { blockNumber: gameData.lastBlockNum }) - - // Note that this will also works when the publicRandomness is separate for players drawing - // their hands: in the case where we're on the very last block, `gameData.lastBlockNum === - // playerData.joinBlockNum`. - gameData.publicRandomness = parseBigInt(lastGameBlock.hash) % PROOF_CURVE_ORDER } - } + if (gameData.cards.length > 0) store.set(store.gameData, gameData) + else store.set(store.gameData, { ...gameData, cards: store.get(store.cards) as any }) - if (gameData.cards.length > 0) - store.set(store.gameData, gameData) - else - store.set(store.gameData, { ...gameData, cards: store.get(store.cards) as any }) + const timestamp = Date.now() + console.groupCollapsed( + "updated game data " + (shouldFetchCards ? "(including cards) " : "") + `(at ${formatTimestamp(timestamp)})` + ) + console.dir(gameData) + console.groupEnd() - const timestamp = Date.now() - console.groupCollapsed( - "updated game data " + - (shouldFetchCards ? "(including cards) " : "") + - `(at ${formatTimestamp(timestamp)})`) - console.dir(gameData) - console.groupEnd() - - return gameData + return gameData } // ================================================================================================= @@ -301,31 +303,26 @@ export async function refreshGameData() { * Note that some operations (e.g. `drawInitialHand`) do not update the `lastBlockNum` field of the * game data and as such `waitForUpdate` is not suitable for use with these operations. */ -export async function waitForUpdate(blockNum: bigint, timeout: number = 15) - : Promise { +export async function waitForUpdate(blockNum: bigint, timeout: number = 15): Promise { + return new Promise((resolve, _reject) => { + const unsubAndResolve = (result: FetchedGameData | null) => { + unsub() + resolve(result) + } - return new Promise((resolve, _reject) => { + // Subscribe to the game data, resolve when receiving a state that satisfies blockNum req. + const unsub = store.store.sub(store.gameData, () => { + const gameData = store.get(store.gameData) + if (gameData === null || gameData.lastBlockNum >= blockNum) unsubAndResolve(gameData) + }) - const unsubAndResolve = (result: FetchedGameData|null) => { - unsub() - resolve(result) - } + // Maybe the game data is already up to date. + const gameData = store.get(store.gameData) + if (gameData !== null && gameData.lastBlockNum >= blockNum) return unsubAndResolve(gameData) - // Subscribe to the game data, resolve when receiving a state that satisfies blockNum req. - const unsub = store.store.sub(store.gameData, () => { - const gameData = store.get(store.gameData) - if (gameData === null || gameData.lastBlockNum >= blockNum) - unsubAndResolve(gameData) + // Initiate timeout. + setTimeout(() => unsubAndResolve(null), timeout * 1000) }) - - // Maybe the game data is already up to date. - const gameData = store.get(store.gameData) - if (gameData !== null && gameData.lastBlockNum >= blockNum) - return unsubAndResolve(gameData) - - // Initiate timeout. - setTimeout(() => unsubAndResolve(null), timeout * 1000) - }) } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/store/write.ts b/packages/webapp/src/store/write.ts index e0a6e7df..287b0646 100644 --- a/packages/webapp/src/store/write.ts +++ b/packages/webapp/src/store/write.ts @@ -23,7 +23,7 @@ const set = store.set * Sets the game ID in the store. */ export function setGameID(gameID: bigint) { - set(store.gameID, gameID) + set(store.gameID, gameID) } // ------------------------------------------------------------------------------------------------- @@ -31,9 +31,9 @@ export function setGameID(gameID: bigint) { /** * Triggers the display a global UI error, or clears the error if `null` is passed. */ -export function setError(error: ErrorConfig|null) { - console.log(`setting error modal: ${JSON.stringify(error)}`) - set(store.errorConfig, error) +export function setError(error: ErrorConfig | null) { + console.log(`setting error modal: ${JSON.stringify(error)}`) + set(store.errorConfig, error) } // ================================================================================================= @@ -45,15 +45,15 @@ export function setError(error: ErrorConfig|null) { * Sets the private information specific to the given game and player in the preivate info store. */ export function setPrivateInfo(gameID: bigint, player: Address, privateInfo: PrivateInfo) { - const privateInfoStore = get(store.privateInfoStore) - const strID = gameID.toString() - set(store.privateInfoStore, { - ... privateInfoStore, - [strID]: { - ... privateInfoStore[strID], - [player]: privateInfo - } - }) + const privateInfoStore = get(store.privateInfoStore) + const strID = gameID.toString() + set(store.privateInfoStore, { + ...privateInfoStore, + [strID]: { + ...privateInfoStore[strID], + [player]: privateInfo, + }, + }) } // ------------------------------------------------------------------------------------------------- @@ -63,28 +63,26 @@ export function setPrivateInfo(gameID: bigint, player: Address, privateInfo: Pri * it doesn't exist yet. Meant to be called when joining a game. */ export function getOrInitPrivateInfo(gameID: bigint, playerAddress: Address): PrivateInfo { + const privateInfoStore = store.get(store.privateInfoStore) + let privateInfo = privateInfoStore[gameID.toString()]?.[playerAddress] + + if (privateInfo !== undefined) return privateInfo + + // The player's secret salt, necessary to hide information. + const salt = randomUint256() % PROOF_CURVE_ORDER + + privateInfo = { + salt, + saltHash: mimcHash([salt]), + // dummy values + handIndexes: [], + deckIndexes: [], + handRoot: `0x0`, + deckRoot: `0x0`, + } - const privateInfoStore = store.get(store.privateInfoStore) - let privateInfo = privateInfoStore[gameID.toString()]?.[playerAddress] - - if (privateInfo !== undefined) + setPrivateInfo(gameID, playerAddress, privateInfo) return privateInfo - - // The player's secret salt, necessary to hide information. - const salt = randomUint256() % PROOF_CURVE_ORDER - - privateInfo = { - salt, - saltHash: mimcHash([salt]), - // dummy values - handIndexes: [], - deckIndexes: [], - handRoot: `0x0`, - deckRoot: `0x0` - } - - setPrivateInfo(gameID, playerAddress, privateInfo) - return privateInfo } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/styles/globals.css b/packages/webapp/src/styles/globals.css index c2a8eb10..d40c9714 100644 --- a/packages/webapp/src/styles/globals.css +++ b/packages/webapp/src/styles/globals.css @@ -3,104 +3,104 @@ @tailwind utilities; body { - @apply overflow-y-hidden; + @apply overflow-y-hidden; } @font-face { - font-family: "fable"; - src: url("/font/BluuNext-Bold.otf") format("opentype"); - font-weight: normal; - font-style: normal; - font-display: swap; + font-family: "fable"; + src: url("/font/BluuNext-Bold.otf") format("opentype"); + font-weight: normal; + font-style: normal; + font-display: swap; } @layer utilities { - @layer responsive { - /* Hide scrollbar for Chrome, Safari, and Opera */ - .no-scrollbar::-webkit-scrollbar { - display: none; + @layer responsive { + /* Hide scrollbar for Chrome, Safari, and Opera */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + + /* Hide scrollbar for IE, Edge, and Firefox */ + .no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } } - - /* Hide scrollbar for IE, Edge, and Firefox */ - .no-scrollbar { - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ - } - } } /* this can be visualized and changed here: https://ui.shadcn.com/themes -- these values are read by tailwind.config.cjs */ @layer base { - :root { - --background: 305 19% 11%; - --foreground: 182 6% 83%; + :root { + --background: 305 19% 11%; + --foreground: 182 6% 83%; - --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; - --primary: 30 67% 51%; - --primary-foreground: 28 35% 13%; + --primary: 30 67% 51%; + --primary-foreground: 28 35% 13%; - --secondary: 182 25% 13%; - --secondary-foreground: 182 6% 83%; + --secondary: 182 25% 13%; + --secondary-foreground: 182 6% 83%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 3.9%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; - --radius: 0.5rem; - } + --radius: 0.5rem; + } - .dark { - --background: 0 0% 3.9%; - --foreground: 0 0% 98%; + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; - --card: 0 0% 3.9%; - --card-foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; - --popover-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; - --border: 0 0% 14.9%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; - } + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + } } @layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } } diff --git a/packages/webapp/src/utils/asyncLock.ts b/packages/webapp/src/utils/asyncLock.ts index 0181b743..8bbd972b 100644 --- a/packages/webapp/src/utils/asyncLock.ts +++ b/packages/webapp/src/utils/asyncLock.ts @@ -11,13 +11,13 @@ export class AsyncLock { constructor() { this.#resolve = () => {} // shut up bogus warnings - this.#promise = new Promise(resolve => this.#resolve = resolve) + this.#promise = new Promise((resolve) => (this.#resolve = resolve)) this.#resolve() } async take() { await this.#promise - this.#promise = new Promise(resolve => this.#resolve = resolve) + this.#promise = new Promise((resolve) => (this.#resolve = resolve)) } release() { @@ -32,4 +32,4 @@ export class AsyncLock { this.release() } } -} \ No newline at end of file +} diff --git a/packages/webapp/src/utils/card-list.ts b/packages/webapp/src/utils/card-list.ts index e903946c..2f0bb6f4 100644 --- a/packages/webapp/src/utils/card-list.ts +++ b/packages/webapp/src/utils/card-list.ts @@ -1,2043 +1,2043 @@ // quick fix for hackathon export const testCards = [ - { - id: 1, - name: "Goblin Spawn, The Damned", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 2, - name: "Eternal Smite, Unborn Legacy", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 3, - name: "Goblin", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 4, - name: "Goblin", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 5, - name: "Goblin", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 6, - name: "Goblin", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 7, - name: "Goblin", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 8, - name: "Goblin", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 9, - name: "Goblin", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 10, - name: "Goblin", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 11, - name: "Goblin", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 12, - name: "Goblin", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 13, - name: "Goblin #13", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 14, - name: "Goblin #14", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 15, - name: "Goblin #15", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 16, - name: "Goblin #16", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 17, - name: "Goblin #17", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 18, - name: "Goblin #18", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 19, - name: "Goblin #19", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 20, - name: "Goblin #20", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 21, - name: "Goblin #21", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 22, - name: "Goblin #22", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 23, - name: "Goblin #23", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 24, - name: "Goblin #24", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 25, - name: "Goblin #25", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 26, - name: "Goblin #26", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 27, - name: "Goblin #27", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 28, - name: "Goblin #28", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 29, - name: "Goblin #29", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 30, - name: "Goblin #30", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 31, - name: "Goblin #31", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 32, - name: "Goblin #32", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 33, - name: "Goblin #33", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 34, - name: "Goblin #34", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 35, - name: "Goblin #35", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 36, - name: "Goblin #36", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 37, - name: "Goblin #37", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 38, - name: "Goblin #38", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 39, - name: "Goblin #39", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 40, - name: "Goblin #40", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 41, - name: "Goblin #41", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 42, - name: "Goblin #42", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 43, - name: "Goblin #43", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 44, - name: "Goblin #44", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 45, - name: "Goblin #45", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 46, - name: "Goblin #46", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 47, - name: "Goblin #47", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 48, - name: "Goblin #48", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 49, - name: "Goblin #49", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 50, - name: "Goblin #50", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 51, - name: "Goblin #51", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 52, - name: "Goblin #52", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 53, - name: "Goblin #53", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 54, - name: "Goblin #54", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 55, - name: "Goblin #55", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 56, - name: "Goblin #56", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 57, - name: "Goblin #57", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 58, - name: "Goblin #58", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 59, - name: "Goblin #59", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 60, - name: "Goblin #60", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 61, - name: "Goblin #61", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 62, - name: "Goblin #62", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 63, - name: "Goblin #63", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 64, - name: "Goblin #64", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 65, - name: "Goblin #65", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 66, - name: "Goblin #66", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 67, - name: "Goblin #67", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 68, - name: "Goblin #68", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 69, - name: "Goblin #69", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 70, - name: "Goblin #70", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 71, - name: "Goblin #71", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 72, - name: "Goblin #72", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 73, - name: "Goblin #73", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 74, - name: "Goblin #74", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 75, - name: "Goblin #75", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 76, - name: "Goblin #76", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 77, - name: "Goblin #77", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 78, - name: "Goblin #78", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 79, - name: "Goblin #79", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 80, - name: "Goblin #80", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 81, - name: "Goblin #81", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 82, - name: "Goblin #82", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 83, - name: "Goblin #83", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 84, - name: "Goblin #84", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 85, - name: "Goblin #85", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 86, - name: "Goblin #86", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 87, - name: "Goblin #87", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 88, - name: "Goblin #88", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 89, - name: "Goblin #89", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 90, - name: "Goblin #90", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 91, - name: "Goblin #91", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 92, - name: "Goblin #92", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 93, - name: "Goblin #93", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 94, - name: "Goblin #94", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 95, - name: "Goblin #95", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 96, - name: "Goblin #96", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 97, - name: "Goblin #97", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 98, - name: "Goblin #98", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 99, - name: "Goblin #99", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 100, - name: "Goblin #100", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 101, - name: "Goblin #101", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 102, - name: "Goblin #102", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 103, - name: "Goblin #103", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 104, - name: "Goblin #104", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 105, - name: "Goblin #105", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 106, - name: "Goblin #106", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 107, - name: "Goblin #107", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 108, - name: "Goblin #108", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 109, - name: "Goblin #109", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 110, - name: "Goblin #110", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 111, - name: "Goblin #111", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 112, - name: "Goblin #112", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 113, - name: "Goblin #113", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 114, - name: "Goblin #114", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 115, - name: "Goblin #115", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 116, - name: "Goblin #116", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 117, - name: "Goblin #117", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 118, - name: "Goblin #118", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 119, - name: "Goblin #119", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 120, - name: "Goblin #120", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 121, - name: "Goblin #121", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 122, - name: "Goblin #122", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 123, - name: "Goblin #123", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 124, - name: "Goblin #124", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 125, - name: "Goblin #125", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 126, - name: "Goblin #126", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 127, - name: "Goblin #127", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 128, - name: "Goblin #128", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 129, - name: "Goblin #129", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 130, - name: "Goblin #130", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 131, - name: "Goblin #131", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 132, - name: "Goblin #132", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 133, - name: "Goblin #133", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 134, - name: "Goblin #134", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 135, - name: "Goblin #135", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 136, - name: "Goblin #136", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 137, - name: "Goblin #137", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 138, - name: "Goblin #138", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 139, - name: "Goblin #139", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 140, - name: "Goblin #140", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 141, - name: "Goblin #141", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 142, - name: "Goblin #142", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 143, - name: "Goblin #143", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 144, - name: "Goblin #144", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 145, - name: "Goblin #145", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 146, - name: "Goblin #146", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 147, - name: "Goblin #147", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 148, - name: "Goblin #148", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 149, - name: "Goblin #149", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 150, - name: "Goblin #150", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 151, - name: "Goblin #151", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 152, - name: "Goblin #152", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 153, - name: "Goblin #153", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 154, - name: "Goblin #154", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 155, - name: "Goblin #155", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 156, - name: "Goblin #156", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 157, - name: "Goblin #157", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 158, - name: "Goblin #158", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 159, - name: "Goblin #159", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 160, - name: "Goblin #160", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 161, - name: "Goblin #161", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 162, - name: "Goblin #162", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 163, - name: "Goblin #163", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 164, - name: "Goblin #164", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 165, - name: "Goblin #165", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 166, - name: "Goblin #166", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 167, - name: "Goblin #167", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 168, - name: "Goblin #168", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 169, - name: "Goblin #169", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 170, - name: "Goblin #170", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 171, - name: "Goblin #171", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 172, - name: "Goblin #172", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 173, - name: "Goblin #173", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 174, - name: "Goblin #174", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 175, - name: "Goblin #175", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 176, - name: "Goblin #176", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 177, - name: "Goblin #177", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 178, - name: "Goblin #178", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 179, - name: "Goblin #179", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 180, - name: "Goblin #180", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 181, - name: "Goblin #181", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 182, - name: "Goblin #182", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 183, - name: "Goblin #183", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 184, - name: "Goblin #184", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 185, - name: "Goblin #185", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 186, - name: "Goblin #186", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 187, - name: "Goblin #187", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 188, - name: "Goblin #188", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 189, - name: "Goblin #189", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 190, - name: "Goblin #190", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 191, - name: "Goblin #191", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 192, - name: "Goblin #192", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 193, - name: "Goblin #193", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 194, - name: "Goblin #194", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 195, - name: "Goblin #195", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 196, - name: "Goblin #196", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 197, - name: "Goblin #197", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 198, - name: "Goblin #198", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 199, - name: "Goblin #199", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 200, - name: "Goblin #200", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 201, - name: "Goblin #201", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 202, - name: "Goblin #202", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 203, - name: "Goblin #203", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 204, - name: "Goblin #204", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 205, - name: "Goblin #205", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 206, - name: "Goblin #206", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 207, - name: "Goblin #207", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 208, - name: "Goblin #208", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 209, - name: "Goblin #209", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 210, - name: "Goblin #210", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 211, - name: "Goblin #211", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 212, - name: "Goblin #212", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 213, - name: "Goblin #213", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 214, - name: "Goblin #214", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 215, - name: "Goblin #215", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 216, - name: "Goblin #216", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 217, - name: "Goblin #217", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 218, - name: "Goblin #218", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 219, - name: "Goblin #219", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 220, - name: "Goblin #220", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 221, - name: "Goblin #221", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 222, - name: "Goblin #222", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 223, - name: "Goblin #223", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 224, - name: "Goblin #224", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 225, - name: "Goblin #225", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 226, - name: "Goblin #226", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 227, - name: "Goblin #227", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 228, - name: "Goblin #228", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 229, - name: "Goblin #229", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 230, - name: "Goblin #230", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 231, - name: "Goblin #231", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 232, - name: "Goblin #232", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 233, - name: "Goblin #233", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 234, - name: "Goblin #234", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 235, - name: "Goblin #235", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 236, - name: "Goblin #236", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 237, - name: "Goblin #237", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 238, - name: "Goblin #238", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 239, - name: "Goblin #239", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 240, - name: "Goblin #240", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 241, - name: "Goblin #241", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 242, - name: "Goblin #242", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 243, - name: "Goblin #243", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 244, - name: "Goblin #244", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 245, - name: "Goblin #245", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, - { - id: 246, - name: "Goblin #246", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/5.jpg", - }, - { - id: 247, - name: "Goblin #247", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/6.jpg", - }, - { - id: 248, - name: "Goblin #248", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/7.jpg", - }, - { - id: 249, - name: "Goblin #249", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/8.jpg", - }, - { - id: 250, - name: "Goblin #250", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/9.jpg", - }, - { - id: 251, - name: "Goblin #251", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/0.jpg", - }, - { - id: 252, - name: "Goblin #252", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/1.jpg", - }, - { - id: 253, - name: "Goblin #253", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/2.jpg", - }, - { - id: 254, - name: "Goblin #254", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/3.jpg", - }, - { - id: 255, - name: "Goblin #255", - description: "A small, green, mean, and ugly creature.", - attack: 1, - defense: 1, - image: "/card_art/4.jpg", - }, + { + id: 1, + name: "Goblin Spawn, The Damned", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 2, + name: "Eternal Smite, Unborn Legacy", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 3, + name: "Goblin", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 4, + name: "Goblin", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 5, + name: "Goblin", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 6, + name: "Goblin", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 7, + name: "Goblin", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 8, + name: "Goblin", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 9, + name: "Goblin", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 10, + name: "Goblin", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 11, + name: "Goblin", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 12, + name: "Goblin", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 13, + name: "Goblin #13", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 14, + name: "Goblin #14", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 15, + name: "Goblin #15", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 16, + name: "Goblin #16", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 17, + name: "Goblin #17", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 18, + name: "Goblin #18", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 19, + name: "Goblin #19", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 20, + name: "Goblin #20", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 21, + name: "Goblin #21", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 22, + name: "Goblin #22", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 23, + name: "Goblin #23", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 24, + name: "Goblin #24", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 25, + name: "Goblin #25", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 26, + name: "Goblin #26", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 27, + name: "Goblin #27", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 28, + name: "Goblin #28", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 29, + name: "Goblin #29", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 30, + name: "Goblin #30", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 31, + name: "Goblin #31", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 32, + name: "Goblin #32", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 33, + name: "Goblin #33", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 34, + name: "Goblin #34", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 35, + name: "Goblin #35", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 36, + name: "Goblin #36", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 37, + name: "Goblin #37", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 38, + name: "Goblin #38", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 39, + name: "Goblin #39", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 40, + name: "Goblin #40", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 41, + name: "Goblin #41", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 42, + name: "Goblin #42", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 43, + name: "Goblin #43", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 44, + name: "Goblin #44", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 45, + name: "Goblin #45", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 46, + name: "Goblin #46", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 47, + name: "Goblin #47", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 48, + name: "Goblin #48", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 49, + name: "Goblin #49", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 50, + name: "Goblin #50", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 51, + name: "Goblin #51", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 52, + name: "Goblin #52", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 53, + name: "Goblin #53", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 54, + name: "Goblin #54", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 55, + name: "Goblin #55", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 56, + name: "Goblin #56", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 57, + name: "Goblin #57", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 58, + name: "Goblin #58", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 59, + name: "Goblin #59", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 60, + name: "Goblin #60", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 61, + name: "Goblin #61", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 62, + name: "Goblin #62", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 63, + name: "Goblin #63", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 64, + name: "Goblin #64", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 65, + name: "Goblin #65", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 66, + name: "Goblin #66", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 67, + name: "Goblin #67", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 68, + name: "Goblin #68", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 69, + name: "Goblin #69", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 70, + name: "Goblin #70", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 71, + name: "Goblin #71", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 72, + name: "Goblin #72", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 73, + name: "Goblin #73", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 74, + name: "Goblin #74", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 75, + name: "Goblin #75", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 76, + name: "Goblin #76", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 77, + name: "Goblin #77", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 78, + name: "Goblin #78", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 79, + name: "Goblin #79", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 80, + name: "Goblin #80", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 81, + name: "Goblin #81", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 82, + name: "Goblin #82", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 83, + name: "Goblin #83", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 84, + name: "Goblin #84", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 85, + name: "Goblin #85", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 86, + name: "Goblin #86", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 87, + name: "Goblin #87", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 88, + name: "Goblin #88", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 89, + name: "Goblin #89", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 90, + name: "Goblin #90", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 91, + name: "Goblin #91", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 92, + name: "Goblin #92", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 93, + name: "Goblin #93", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 94, + name: "Goblin #94", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 95, + name: "Goblin #95", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 96, + name: "Goblin #96", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 97, + name: "Goblin #97", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 98, + name: "Goblin #98", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 99, + name: "Goblin #99", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 100, + name: "Goblin #100", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 101, + name: "Goblin #101", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 102, + name: "Goblin #102", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 103, + name: "Goblin #103", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 104, + name: "Goblin #104", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 105, + name: "Goblin #105", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 106, + name: "Goblin #106", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 107, + name: "Goblin #107", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 108, + name: "Goblin #108", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 109, + name: "Goblin #109", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 110, + name: "Goblin #110", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 111, + name: "Goblin #111", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 112, + name: "Goblin #112", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 113, + name: "Goblin #113", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 114, + name: "Goblin #114", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 115, + name: "Goblin #115", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 116, + name: "Goblin #116", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 117, + name: "Goblin #117", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 118, + name: "Goblin #118", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 119, + name: "Goblin #119", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 120, + name: "Goblin #120", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 121, + name: "Goblin #121", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 122, + name: "Goblin #122", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 123, + name: "Goblin #123", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 124, + name: "Goblin #124", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 125, + name: "Goblin #125", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 126, + name: "Goblin #126", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 127, + name: "Goblin #127", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 128, + name: "Goblin #128", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 129, + name: "Goblin #129", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 130, + name: "Goblin #130", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 131, + name: "Goblin #131", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 132, + name: "Goblin #132", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 133, + name: "Goblin #133", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 134, + name: "Goblin #134", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 135, + name: "Goblin #135", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 136, + name: "Goblin #136", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 137, + name: "Goblin #137", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 138, + name: "Goblin #138", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 139, + name: "Goblin #139", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 140, + name: "Goblin #140", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 141, + name: "Goblin #141", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 142, + name: "Goblin #142", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 143, + name: "Goblin #143", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 144, + name: "Goblin #144", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 145, + name: "Goblin #145", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 146, + name: "Goblin #146", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 147, + name: "Goblin #147", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 148, + name: "Goblin #148", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 149, + name: "Goblin #149", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 150, + name: "Goblin #150", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 151, + name: "Goblin #151", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 152, + name: "Goblin #152", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 153, + name: "Goblin #153", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 154, + name: "Goblin #154", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 155, + name: "Goblin #155", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 156, + name: "Goblin #156", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 157, + name: "Goblin #157", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 158, + name: "Goblin #158", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 159, + name: "Goblin #159", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 160, + name: "Goblin #160", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 161, + name: "Goblin #161", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 162, + name: "Goblin #162", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 163, + name: "Goblin #163", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 164, + name: "Goblin #164", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 165, + name: "Goblin #165", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 166, + name: "Goblin #166", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 167, + name: "Goblin #167", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 168, + name: "Goblin #168", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 169, + name: "Goblin #169", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 170, + name: "Goblin #170", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 171, + name: "Goblin #171", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 172, + name: "Goblin #172", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 173, + name: "Goblin #173", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 174, + name: "Goblin #174", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 175, + name: "Goblin #175", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 176, + name: "Goblin #176", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 177, + name: "Goblin #177", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 178, + name: "Goblin #178", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 179, + name: "Goblin #179", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 180, + name: "Goblin #180", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 181, + name: "Goblin #181", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 182, + name: "Goblin #182", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 183, + name: "Goblin #183", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 184, + name: "Goblin #184", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 185, + name: "Goblin #185", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 186, + name: "Goblin #186", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 187, + name: "Goblin #187", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 188, + name: "Goblin #188", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 189, + name: "Goblin #189", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 190, + name: "Goblin #190", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 191, + name: "Goblin #191", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 192, + name: "Goblin #192", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 193, + name: "Goblin #193", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 194, + name: "Goblin #194", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 195, + name: "Goblin #195", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 196, + name: "Goblin #196", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 197, + name: "Goblin #197", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 198, + name: "Goblin #198", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 199, + name: "Goblin #199", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 200, + name: "Goblin #200", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 201, + name: "Goblin #201", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 202, + name: "Goblin #202", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 203, + name: "Goblin #203", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 204, + name: "Goblin #204", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 205, + name: "Goblin #205", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 206, + name: "Goblin #206", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 207, + name: "Goblin #207", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 208, + name: "Goblin #208", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 209, + name: "Goblin #209", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 210, + name: "Goblin #210", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 211, + name: "Goblin #211", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 212, + name: "Goblin #212", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 213, + name: "Goblin #213", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 214, + name: "Goblin #214", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 215, + name: "Goblin #215", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 216, + name: "Goblin #216", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 217, + name: "Goblin #217", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 218, + name: "Goblin #218", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 219, + name: "Goblin #219", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 220, + name: "Goblin #220", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 221, + name: "Goblin #221", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 222, + name: "Goblin #222", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 223, + name: "Goblin #223", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 224, + name: "Goblin #224", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 225, + name: "Goblin #225", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 226, + name: "Goblin #226", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 227, + name: "Goblin #227", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 228, + name: "Goblin #228", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 229, + name: "Goblin #229", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 230, + name: "Goblin #230", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 231, + name: "Goblin #231", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 232, + name: "Goblin #232", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 233, + name: "Goblin #233", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 234, + name: "Goblin #234", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 235, + name: "Goblin #235", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 236, + name: "Goblin #236", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 237, + name: "Goblin #237", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 238, + name: "Goblin #238", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 239, + name: "Goblin #239", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 240, + name: "Goblin #240", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 241, + name: "Goblin #241", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 242, + name: "Goblin #242", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 243, + name: "Goblin #243", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 244, + name: "Goblin #244", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 245, + name: "Goblin #245", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, + { + id: 246, + name: "Goblin #246", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/5.jpg", + }, + { + id: 247, + name: "Goblin #247", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/6.jpg", + }, + { + id: 248, + name: "Goblin #248", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/7.jpg", + }, + { + id: 249, + name: "Goblin #249", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/8.jpg", + }, + { + id: 250, + name: "Goblin #250", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/9.jpg", + }, + { + id: 251, + name: "Goblin #251", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/0.jpg", + }, + { + id: 252, + name: "Goblin #252", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/1.jpg", + }, + { + id: 253, + name: "Goblin #253", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/2.jpg", + }, + { + id: 254, + name: "Goblin #254", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/3.jpg", + }, + { + id: 255, + name: "Goblin #255", + description: "A small, green, mean, and ugly creature.", + attack: 1, + defense: 1, + image: "/card_art/4.jpg", + }, ] diff --git a/packages/webapp/src/utils/errors.ts b/packages/webapp/src/utils/errors.ts index 2d4fe5f7..14a15ac3 100644 --- a/packages/webapp/src/utils/errors.ts +++ b/packages/webapp/src/utils/errors.ts @@ -10,9 +10,9 @@ * Thrown when an operation times out. */ export class TimeoutError extends Error { - constructor(msg: string) { - super(msg) - } + constructor(msg: string) { + super(msg) + } } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/utils/extensions.ts b/packages/webapp/src/utils/extensions.ts index 79d70106..064ecfd9 100644 --- a/packages/webapp/src/utils/extensions.ts +++ b/packages/webapp/src/utils/extensions.ts @@ -9,26 +9,26 @@ export {} // ================================================================================================= declare global { - interface Array { - last(): T - setLast(item: T): void - } + interface Array { + last(): T + setLast(item: T): void + } } Object.defineProperty(Array.prototype, "last", { - enumerable: false, // don't include in for...in loops - configurable: true, // enable redefinition — good for hotloading - value: function() { - return this[this.length - 1] - } + enumerable: false, // don't include in for...in loops + configurable: true, // enable redefinition — good for hotloading + value: function () { + return this[this.length - 1] + }, }) Object.defineProperty(Array.prototype, "setLast", { - enumerable: false, // don't include in for...in loops - configurable: true, // enable redefinition — good for hotloading - value: function(item: any) { - this[this.length - 1] = item - } + enumerable: false, // don't include in for...in loops + configurable: true, // enable redefinition — good for hotloading + value: function (item: any) { + this[this.length - 1] = item + }, }) -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/utils/hashing.ts b/packages/webapp/src/utils/hashing.ts index 2aa4d383..15b979a4 100644 --- a/packages/webapp/src/utils/hashing.ts +++ b/packages/webapp/src/utils/hashing.ts @@ -17,7 +17,7 @@ const mimcSponge = await buildMimcSponge() * The MiMCSponge hash function. */ export function mimcHash(inputs: readonly bigint[]): bigint { - return mimcSponge.F.toObject(mimcSponge.multiHash(inputs)) + return mimcSponge.F.toObject(mimcSponge.multiHash(inputs)) } // ================================================================================================= @@ -36,18 +36,13 @@ export const fillerValue = 255n * Returns the MiMC-based Merkle root of `items`, after extending it to size `size` by filling it up * with `filler`. `size` must be a power of two. */ -export function merkleize - (size: number, items: readonly bigint[], filler: bigint = fillerValue) - : bigint { +export function merkleize(size: number, items: readonly bigint[], filler: bigint = fillerValue): bigint { + if (size & (size - 1) || size == 0) throw new Error("size must be a power of 2") - if (size & (size - 1) || size == 0) - throw new Error("size must be a power of 2") + const extended = [...items] + for (let i = items.length; i < size; i++) extended.push(filler) - const extended = [...items] - for (let i = items.length; i < size; i++) - extended.push(filler) - - return _merkleize(extended) + return _merkleize(extended) } // ------------------------------------------------------------------------------------------------- @@ -56,13 +51,9 @@ export function merkleize * Merkleize `items`, assuming its size is a power of 2. */ function _merkleize(items: readonly bigint[]): bigint { - if (items.length === 1) - return items[0] - const half = items.length / 2 - return mimcHash([ - _merkleize(items.slice(0, half)), - _merkleize(items.slice(half)) - ]) + if (items.length === 1) return items[0] + const half = items.length / 2 + return mimcHash([_merkleize(items.slice(0, half)), _merkleize(items.slice(half))]) } // ================================================================================================= diff --git a/packages/webapp/src/utils/jotai.ts b/packages/webapp/src/utils/jotai.ts index 40d91764..880d62d7 100644 --- a/packages/webapp/src/utils/jotai.ts +++ b/packages/webapp/src/utils/jotai.ts @@ -20,12 +20,12 @@ export type Read = (get: Getter) => Value * to avoid spurious re-renders. */ export function cachedAtom(read: Read): Atom { - let cache: Value | null = null - return atom((get) => { - const value = read(get as any) - if (!isEqual(value, cache)) cache = value - return cache! - }) + let cache: Value | null = null + return atom((get) => { + const value = read(get as any) + if (!isEqual(value, cache)) cache = value + return cache! + }) } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/utils/js-utils.ts b/packages/webapp/src/utils/js-utils.ts index cdd96225..046d5df6 100644 --- a/packages/webapp/src/utils/js-utils.ts +++ b/packages/webapp/src/utils/js-utils.ts @@ -14,33 +14,32 @@ * single-line. */ export function format(obj: any, multiline = false, indent = 0) { - const linePrefix = multiline ? " ".repeat(indent + 2) : undefined - const separator = multiline ? ",\n" : ", " - const pairs = [] - - for (const key in obj) { - // not obj.hasOwnProperty: https://eslint.org/docs/latest/rules/no-prototype-builtins - if (!Object.prototype.hasOwnProperty.call(obj, key)) - continue - - const value = obj[key] - let pair = multiline ? `${linePrefix}${key}: ` : `${key}: ` - - if (typeof value === "object" && value !== null) { - pair += format(value, multiline, indent + 2) - } else if (typeof value === "string") { - pair += `"${value}"` - } else { - pair += value + const linePrefix = multiline ? " ".repeat(indent + 2) : undefined + const separator = multiline ? ",\n" : ", " + const pairs = [] + + for (const key in obj) { + // not obj.hasOwnProperty: https://eslint.org/docs/latest/rules/no-prototype-builtins + if (!Object.prototype.hasOwnProperty.call(obj, key)) continue + + const value = obj[key] + let pair = multiline ? `${linePrefix}${key}: ` : `${key}: ` + + if (typeof value === "object" && value !== null) { + pair += format(value, multiline, indent + 2) + } else if (typeof value === "string") { + pair += `"${value}"` + } else { + pair += value + } + + pairs.push(pair) } - pairs.push(pair) - } + const openingBracket = multiline ? "{\n" : "{ " + const closingBracket = multiline ? `\n${" ".repeat(indent)}}` : " }" - const openingBracket = multiline ? "{\n" : "{ " - const closingBracket = multiline ? `\n${" ".repeat(indent)}}` : " }" - - return `${openingBracket}${pairs.join(separator)}${closingBracket}` + return `${openingBracket}${pairs.join(separator)}${closingBracket}` } // ------------------------------------------------------------------------------------------------- @@ -50,7 +49,7 @@ export function format(obj: any, multiline = false, indent = 0) { * {@linkcode format(obj)} for objects (`typeof value === "object"`). */ export function toString(obj: any) { - return typeof obj === "object" ? format(obj) : `${obj}` + return typeof obj === "object" ? format(obj) : `${obj}` } // ------------------------------------------------------------------------------------------------- @@ -59,17 +58,14 @@ export function toString(obj: any) { * Shallowly compares two objects with depth 1 by checking the equality of their members. */ export function shallowCompare(obj1: any, obj2: any): boolean { - const keys1 = Object.keys(obj1) - const keys2 = Object.keys(obj2) + const keys1 = Object.keys(obj1) + const keys2 = Object.keys(obj2) - if (keys1.length !== keys2.length) - return false + if (keys1.length !== keys2.length) return false - for (const key of keys1) - if (obj1[key] !== obj2[key]) - return false + for (const key of keys1) if (obj1[key] !== obj2[key]) return false - return true + return true } // ------------------------------------------------------------------------------------------------- @@ -82,12 +78,8 @@ export function shallowCompare(obj1: any, obj2: any): boolean { * leading zero if needed to give it even string length (i.e. no half bytes). */ export function bigintToHexString(n: bigint, extendTo?: number): string { - const str = n.toString(16) - return extendTo !== undefined - ? str.padStart(extendTo * 2, "0") - : str.length % 2 === 1 - ? "0" + str - : str + const str = n.toString(16) + return extendTo !== undefined ? str.padStart(extendTo * 2, "0") : str.length % 2 === 1 ? "0" + str : str } // ------------------------------------------------------------------------------------------------- @@ -96,9 +88,11 @@ export function bigintToHexString(n: bigint, extendTo?: number): string { * Converts a byte array to a hex string. The string does *not* start with 0x, and has length * `2 * array.length`. */ -export function bytesToHexString(array: Uint8Array|number[]): string { - if (!Array.isArray(array)) array = Array.from(array) - return Array.from(array).map(byte => byte.toString(16).padStart(2, "0")).join("") +export function bytesToHexString(array: Uint8Array | number[]): string { + if (!Array.isArray(array)) array = Array.from(array) + return Array.from(array) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") } // ------------------------------------------------------------------------------------------------- @@ -111,20 +105,19 @@ export function bytesToHexString(array: Uint8Array|number[]): string { * assumes big-endianness (normal "written" representation: high-order bytes on the left / start of * arrays). */ -export function parseBigInt - (value: string|number|bigint|Uint8Array|number[], endianness: "big"|"little" = "big"): bigint { - - if (value instanceof Uint8Array || Array.isArray(value)) { - if (endianness == "little") - value = value.reverse() - return BigInt("0x" + bytesToHexString(value)) - } +export function parseBigInt( + value: string | number | bigint | Uint8Array | number[], + endianness: "big" | "little" = "big" +): bigint { + if (value instanceof Uint8Array || Array.isArray(value)) { + if (endianness == "little") value = value.reverse() + return BigInt("0x" + bytesToHexString(value)) + } - if (endianness == "little") - throw new Error("little-endianness only supported with Uint8Array or number[] argument") + if (endianness == "little") throw new Error("little-endianness only supported with Uint8Array or number[] argument") - if (typeof value === "bigint") return value - return BigInt(value).valueOf() + if (typeof value === "bigint") return value + return BigInt(value).valueOf() } // ------------------------------------------------------------------------------------------------- @@ -133,23 +126,21 @@ export function parseBigInt * Parses a bigint-compatible value into a bigint. * Returns null if the value is null or if it cannot be parsed. */ -export function parseBigIntOrNull(value: string|number|bigint|Uint8Array|number[]|null) - : bigint|null { - - if (value === null) return null - try { - return parseBigInt(value) - } catch(e) { - return null - } +export function parseBigIntOrNull(value: string | number | bigint | Uint8Array | number[] | null): bigint | null { + if (value === null) return null + try { + return parseBigInt(value) + } catch (e) { + return null + } } // ------------------------------------------------------------------------------------------------- /** Check if the string represents a positive integer. */ export function isStringPositiveInteger(str: string): boolean { - const n = Math.floor(Number(str)) - return n !== Infinity && String(n) === str && n >= 0 + const n = Math.floor(Number(str)) + return n !== Infinity && String(n) === str && n >= 0 } // ------------------------------------------------------------------------------------------------- @@ -158,26 +149,24 @@ export function isStringPositiveInteger(str: string): boolean { * Formats a UNIX timestamp as a string in the format "HH:MM:SS". */ export function formatTimestamp(timestamp: number) { - const date = new Date(timestamp) - return [date.getHours(), date.getMinutes(), date.getSeconds()] - .map((num) => num < 10 ? "0" + num : num) - .join(":") + const date = new Date(timestamp) + return [date.getHours(), date.getMinutes(), date.getSeconds()].map((num) => (num < 10 ? "0" + num : num)).join(":") } // ------------------------------------------------------------------------------------------------- /** Async function that waits for the given number of milliseconds. */ export async function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) + return new Promise((resolve) => setTimeout(resolve, ms)) } // ------------------------------------------------------------------------------------------------- /** Returns a random 256-bit unsigned integer using {@link crypto.getRandomValues}. */ export function randomUint256(): bigint { - const bytes = new Uint8Array(32) - crypto.getRandomValues(bytes) - return parseBigInt(bytes) + const bytes = new Uint8Array(32) + crypto.getRandomValues(bytes) + return parseBigInt(bytes) } // ================================================================================================= @@ -187,20 +176,16 @@ export function randomUint256(): bigint { * and the last `digits` characters. */ export const shortenAddress = (address?: `0x${string}` | null, digits = 5) => { - if (!address) return "" - return ( - address.substring(0, digits) + - "..." + - address.substring(address.length - digits) - ) + if (!address) return "" + return address.substring(0, digits) + "..." + address.substring(address.length - digits) } // ================================================================================================= /** Takes a string as input and returns the first sequence of digits found in the string, or null if no digits are present. */ export const extractCardID = (idString: string) => { - const numberMatch = idString.match(/\d+/) - return numberMatch ? numberMatch[0] : null + const numberMatch = idString.match(/\d+/) + return numberMatch ? numberMatch[0] : null } // ================================================================================================= @@ -209,22 +194,18 @@ export const extractCardID = (idString: string) => { /** Converts a string to a number if within the JavaScript safe integer range. */ export const convertStringToSafeNumber = (str: string): number => { - const num = BigInt(str); - if (num >= BigInt(Number.MIN_SAFE_INTEGER) && num <= BigInt(Number.MAX_SAFE_INTEGER)) { - return Number(num); - } - return 0; -}; - - + const num = BigInt(str) + if (num >= BigInt(Number.MIN_SAFE_INTEGER) && num <= BigInt(Number.MAX_SAFE_INTEGER)) { + return Number(num) + } + return 0 +} // ================================================================================================= /** Converts an array of bigints to their string representations, returns an empty array if input is null. */ -export const convertBigIntArrayToStringArray = ( - cardsList: readonly bigint[] | null -): string[] => { - return cardsList?.map(bigIntValue => bigIntValue.toString()) || []; -}; +export const convertBigIntArrayToStringArray = (cardsList: readonly bigint[] | null): string[] => { + return cardsList?.map((bigIntValue) => bigIntValue.toString()) || [] +} -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/utils/navigate.ts b/packages/webapp/src/utils/navigate.ts index 7ad5f50c..f1b25a68 100644 --- a/packages/webapp/src/utils/navigate.ts +++ b/packages/webapp/src/utils/navigate.ts @@ -13,12 +13,12 @@ import { NextRouter } from "next/router" * mode. */ export async function navigate(router: NextRouter, url: string): Promise { - if (process.env.NODE_ENV === "development") { - const index = parseInt(router.query.index as string) - if (index !== undefined && !isNaN(index) && 0 <= index && index <= 9) - url = url + (url.includes("?") ? "&" : "?") + `index=${index}` - } - return router.push(url) + if (process.env.NODE_ENV === "development") { + const index = parseInt(router.query.index as string) + if (index !== undefined && !isNaN(index) && 0 <= index && index <= 9) + url = url + (url.includes("?") ? "&" : "?") + `index=${index}` + } + return router.push(url) } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/utils/react-utils.ts b/packages/webapp/src/utils/react-utils.ts index a918fd05..29cbc070 100644 --- a/packages/webapp/src/utils/react-utils.ts +++ b/packages/webapp/src/utils/react-utils.ts @@ -17,18 +17,16 @@ import { atom, type Atom, type Getter, type Setter, type WritableAtom } from "jo */ export type JotaiRead = (get: Getter) => Value - /** * Simplified version of the unexported Jotai `Write` type (parameter to some {@link atom} * overloads), meant to work with {@link writeableAtom} and its {@link WAtom} return type. */ -export type JotaiWrite - = (get: Getter, set: Setter, ...args: [Value]) => void +export type JotaiWrite = (get: Getter, set: Setter, ...args: [Value]) => void // ================================================================================================= export function readOnlyAtom(readWriteAtom: Atom): Atom { - return atom((get) => get(readWriteAtom)) + return atom((get) => get(readWriteAtom)) } // ------------------------------------------------------------------------------------------------- @@ -43,7 +41,7 @@ export type WAtom = WritableAtom /** Just an alias for the overload of {@link atom} that returns a {@link WAtom}. */ export function writeableAtom(read: JotaiRead, write: JotaiWrite): WAtom { - return atom(read, write) + return atom(read, write) } // ------------------------------------------------------------------------------------------------- @@ -54,7 +52,7 @@ export function writeableAtom(read: JotaiRead, write: JotaiWrite(read: (get: Getter) => Promise): Atom> { - return atom(read) + return atom(read) } // ------------------------------------------------------------------------------------------------- @@ -65,10 +63,10 @@ export function asyncAtom(read: (get: Getter) => Promise): Atom( - read: (get: Getter) => Promise, - write: (get: Getter, set: Setter, value: Value) => void) - : WritableAtom, [Value], void> { - return atom(read, write) + read: (get: Getter) => Promise, + write: (get: Getter, set: Setter, value: Value) => void +): WritableAtom, [Value], void> { + return atom(read, write) } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/utils/throttledFetch.ts b/packages/webapp/src/utils/throttledFetch.ts index acdf0474..871568e8 100644 --- a/packages/webapp/src/utils/throttledFetch.ts +++ b/packages/webapp/src/utils/throttledFetch.ts @@ -42,48 +42,45 @@ export type Fetched = Result | typeof THROTTLED | typeof ZOMBIE * * Throttled and ignored fetches return null. */ -export function throttledFetch - - (fetchFn: (...args: Params) => Promise, throttlePeriod: number = DEFAULT_THROTTLE_PERIOD) - : (...args: Params) => Promise> { - - // Used for throttling - let lastRequestTimestamp = 0 - - // used to avoid "zombie" updates: old data overwriting newer game data. - let sequenceNumber = 1 - let lastCompletedNumber = 0 - - return async (...args: Params) => { - - const seqNum = sequenceNumber++ - - // Throttle - const timestamp = Date.now() - if (timestamp - lastRequestTimestamp < throttlePeriod) - return THROTTLED // there is a recent-ish refresh in flight - lastRequestTimestamp = timestamp - - let result: Result - let lastCompletedNumberAfterFetch: number - try { - result = await fetchFn(...args) - } catch (e) { - throw e - } finally { - // Bookkeeping for zombie filtering - lastCompletedNumberAfterFetch = lastCompletedNumber - lastCompletedNumber = seqNum - - // Allow another fetch immediately - lastRequestTimestamp = 0 +export function throttledFetch( + fetchFn: (...args: Params) => Promise, + throttlePeriod: number = DEFAULT_THROTTLE_PERIOD +): (...args: Params) => Promise> { + // Used for throttling + let lastRequestTimestamp = 0 + + // used to avoid "zombie" updates: old data overwriting newer game data. + let sequenceNumber = 1 + let lastCompletedNumber = 0 + + return async (...args: Params) => { + const seqNum = sequenceNumber++ + + // Throttle + const timestamp = Date.now() + if (timestamp - lastRequestTimestamp < throttlePeriod) return THROTTLED // there is a recent-ish refresh in flight + lastRequestTimestamp = timestamp + + let result: Result + let lastCompletedNumberAfterFetch: number + try { + result = await fetchFn(...args) + } catch (e) { + throw e + } finally { + // Bookkeeping for zombie filtering + lastCompletedNumberAfterFetch = lastCompletedNumber + lastCompletedNumber = seqNum + + // Allow another fetch immediately + lastRequestTimestamp = 0 + } + + // Filter zombie updates + if (seqNum < lastCompletedNumberAfterFetch) return ZOMBIE + + return result } - - // Filter zombie updates - if (seqNum < lastCompletedNumberAfterFetch) return ZOMBIE - - return result - } } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/utils/ui-utils.ts b/packages/webapp/src/utils/ui-utils.ts index 20d6ed64..d73227ba 100644 --- a/packages/webapp/src/utils/ui-utils.ts +++ b/packages/webapp/src/utils/ui-utils.ts @@ -21,6 +21,5 @@ import { twMerge } from "tailwind-merge" * // Returns a string of class names, e.g., 'text-center py-2 bg-red-500 hover:bg-blue-500' */ export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return twMerge(clsx(inputs)) } - diff --git a/packages/webapp/src/utils/zkproofs/index.ts b/packages/webapp/src/utils/zkproofs/index.ts index 6a678503..daffe9d3 100644 --- a/packages/webapp/src/utils/zkproofs/index.ts +++ b/packages/webapp/src/utils/zkproofs/index.ts @@ -10,4 +10,4 @@ // in chunk identities, or something like that, which apparently can cause caching problems). export * from "src/utils/zkproofs/proofs" -export { proveInWorker } from "src/utils/zkproofs/proveInWorker" \ No newline at end of file +export { proveInWorker } from "src/utils/zkproofs/proveInWorker" diff --git a/packages/webapp/src/utils/zkproofs/proofWorker.ts b/packages/webapp/src/utils/zkproofs/proofWorker.ts index f6f54153..27f983f8 100644 --- a/packages/webapp/src/utils/zkproofs/proofWorker.ts +++ b/packages/webapp/src/utils/zkproofs/proofWorker.ts @@ -9,20 +9,20 @@ import { prove, type ProofInputs } from "src/utils/zkproofs/proofs" // ================================================================================================= type ProofSpec = { - circuitName: string - inputs: ProofInputs + circuitName: string + inputs: ProofInputs } // ------------------------------------------------------------------------------------------------- -addEventListener('message', async (event: MessageEvent) => { - try { - const output = await prove(event.data.circuitName, event.data.inputs) - postMessage(output) - } catch (error) { - // This will be a ProofError - postMessage(error) - } +addEventListener("message", async (event: MessageEvent) => { + try { + const output = await prove(event.data.circuitName, event.data.inputs) + postMessage(output) + } catch (error) { + // This will be a ProofError + postMessage(error) + } }) -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/utils/zkproofs/proofs.ts b/packages/webapp/src/utils/zkproofs/proofs.ts index 3e786678..fa97eb4a 100644 --- a/packages/webapp/src/utils/zkproofs/proofs.ts +++ b/packages/webapp/src/utils/zkproofs/proofs.ts @@ -24,28 +24,32 @@ export const SHOULD_GENERATE_PROOFS = !process.env["NEXT_PUBLIC_NO_PROOFS"] * Stand-in value for proofs, used when {@link SHOULD_GENERATE_PROOFS} is false. */ export const FAKE_PROOF: ProofOutput = { - proof_a: [1n, 1n], - proof_b: [[1n, 1n], [1n, 1n]], - proof_c: [1n, 1n] + proof_a: [1n, 1n], + proof_b: [ + [1n, 1n], + [1n, 1n], + ], + proof_c: [1n, 1n], } // ================================================================================================= -export type ProofInputs = Record +export type ProofInputs = Record // ------------------------------------------------------------------------------------------------- export type ProofOutput = { - proof_a: readonly [bigint, bigint], - proof_b: readonly [readonly [bigint, bigint], readonly [bigint, bigint]], - proof_c: readonly [bigint, bigint] + proof_a: readonly [bigint, bigint] + proof_b: readonly [readonly [bigint, bigint], readonly [bigint, bigint]] + proof_c: readonly [bigint, bigint] } // ------------------------------------------------------------------------------------------------- export function isProofOutput(value: any): value is ProofOutput { - return value !== undefined && value.proof_a !== undefined && - value.proof_b !== undefined && value.proof_c !== undefined + return ( + value !== undefined && value.proof_a !== undefined && value.proof_b !== undefined && value.proof_c !== undefined + ) } // ------------------------------------------------------------------------------------------------- @@ -54,11 +58,11 @@ export function isProofOutput(value: any): value is ProofOutput { * Wraps errors thrown during proof computation. */ export class ProofError extends Error { - cause: unknown - constructor(cause: unknown) { - super(`Error while computing proof: ${cause}`) - this.cause = cause - } + cause: unknown + constructor(cause: unknown) { + super(`Error while computing proof: ${cause}`) + this.cause = cause + } } // ================================================================================================= @@ -67,46 +71,50 @@ export class ProofError extends Error { /** * Generates a proof for the given circuit, with the given inputs. */ -export async function prove - (circuitName: string, inputs: Record) - : Promise { - - const timestampStart = Date.now() - console.log(`start proving (at ${formatTimestamp(timestampStart)})`) - - try { - const { _publicSignals, proof } = await snarkjs.groth16.fullProve(inputs, - `${self.origin}/proofs/${circuitName}.wasm`, - `${self.origin}/proofs/${circuitName}.zkey`) - - const timestampEnd = Date.now() - console.log(`end proving (at ${formatTimestamp(timestampEnd)} — ` + - `time: ${(timestampEnd - timestampStart) / 1000}s)`) - - // NOTE: proof can be verified with - // `verify(circuitName, _publicSignals, proof)` - - return { - proof_a: proof["pi_a"].slice(0,2), - proof_b: [ - // The snarkjs-generated verifier uses a different endianess than the one used by the - // snarkjs-generated prover. - [proof["pi_b"][0][1], proof["pi_b"][0][0]], - [proof["pi_b"][1][1], proof["pi_b"][1][0]] - ], - proof_c: proof["pi_c"].slice(0,2) +export async function prove( + circuitName: string, + inputs: Record +): Promise { + const timestampStart = Date.now() + console.log(`start proving (at ${formatTimestamp(timestampStart)})`) + + try { + const { _publicSignals, proof } = await snarkjs.groth16.fullProve( + inputs, + `${self.origin}/proofs/${circuitName}.wasm`, + `${self.origin}/proofs/${circuitName}.zkey` + ) + + const timestampEnd = Date.now() + console.log( + `end proving (at ${formatTimestamp(timestampEnd)} — ` + `time: ${(timestampEnd - timestampStart) / 1000}s)` + ) + + // NOTE: proof can be verified with + // `verify(circuitName, _publicSignals, proof)` + + return { + proof_a: proof["pi_a"].slice(0, 2), + proof_b: [ + // The snarkjs-generated verifier uses a different endianess than the one used by the + // snarkjs-generated prover. + [proof["pi_b"][0][1], proof["pi_b"][0][0]], + [proof["pi_b"][1][1], proof["pi_b"][1][0]], + ], + proof_c: proof["pi_c"].slice(0, 2), + } + + // NOTE: We could have copied the ordering of proof items from the `exportSolidityCallData` + // function and read them from the `proof` object directly. The risk is we need to change the + // code if the ordering changes (but now we need to change the code if the formatting changes). + } catch (error) { + const timestampEnd = Date.now() + console.log( + `end proving with exception (at ${formatTimestamp(timestampEnd)} — ` + + `time: ${(timestampEnd - timestampStart) / 1000}s)` + ) + throw new ProofError(error) } - - // NOTE: We could have copied the ordering of proof items from the `exportSolidityCallData` - // function and read them from the `proof` object directly. The risk is we need to change the - // code if the ordering changes (but now we need to change the code if the formatting changes). - } - catch (error) { - const timestampEnd = Date.now() - console.log(`end proving with exception (at ${formatTimestamp(timestampEnd)} — ` + - `time: ${(timestampEnd - timestampStart) / 1000}s)`) - throw new ProofError(error) - } } // ------------------------------------------------------------------------------------------------- @@ -115,9 +123,9 @@ export async function prove * Thrown when a proof times out. */ export class ProofTimeoutError extends TimeoutError { - constructor(msg: string) { - super(msg) - } + constructor(msg: string) { + super(msg) + } } // ------------------------------------------------------------------------------------------------- @@ -126,9 +134,9 @@ export class ProofTimeoutError extends TimeoutError { * Thrown when a proof is cancelled */ export class ProofCancelled extends Error { - constructor(msg: string) { - super(msg) - } + constructor(msg: string) { + super(msg) + } } // ================================================================================================= @@ -139,12 +147,10 @@ export class ProofCancelled extends Error { * * This is only used fort testing purposes. */ -export async function verify(circuitName: string, publicSignals: readonly string[], proof: any) - : Promise { - - // If this were to be used in production, we would need to cache the vkey. - const vKey = await fetch(`${self.origin}/proofs/${circuitName}.vkey.json`).then(it => it.json()) - return await snarkjs.groth16.verify(vKey, publicSignals, proof) +export async function verify(circuitName: string, publicSignals: readonly string[], proof: any): Promise { + // If this were to be used in production, we would need to cache the vkey. + const vKey = await fetch(`${self.origin}/proofs/${circuitName}.vkey.json`).then((it) => it.json()) + return await snarkjs.groth16.verify(vKey, publicSignals, proof) } // ================================================================================================= @@ -163,33 +169,31 @@ export async function verify(circuitName: string, publicSignals: readonly string * there are too few bytes to fill the given number of field elements, the remaining space is * padded with 0s. */ -export function packBytes(bytes: Uint8Array|number[], numFelts: number, itemsPerFelt: number) - : bigint[] { - - if (bytes.length > numFelts * itemsPerFelt) - throw new Error(`too many bytes to pack into ${numFelts} field elements`) - - const felts = [] - - // for each field element - for (let i = 0; i < numFelts; i++) { - let felt = "" - - // For the next `itemsPerFelt` bytes in the array (if they exist, otherwise 0), - // prepend the byte (two hex chars) to the `felt` string. - for (let j = i * itemsPerFelt; j < (i+1) * itemsPerFelt; j++) { - if (j >= bytes.length) { - felt = "00" + felt // ensure there is at least one zero in the string - break - } else { - const byte = bytes[j].toString(16) - felt = (byte.length < 2 ? "0" : "") + byte + felt - } +export function packBytes(bytes: Uint8Array | number[], numFelts: number, itemsPerFelt: number): bigint[] { + if (bytes.length > numFelts * itemsPerFelt) + throw new Error(`too many bytes to pack into ${numFelts} field elements`) + + const felts = [] + + // for each field element + for (let i = 0; i < numFelts; i++) { + let felt = "" + + // For the next `itemsPerFelt` bytes in the array (if they exist, otherwise 0), + // prepend the byte (two hex chars) to the `felt` string. + for (let j = i * itemsPerFelt; j < (i + 1) * itemsPerFelt; j++) { + if (j >= bytes.length) { + felt = "00" + felt // ensure there is at least one zero in the string + break + } else { + const byte = bytes[j].toString(16) + felt = (byte.length < 2 ? "0" : "") + byte + felt + } + } + + felts.push(BigInt("0x" + felt)) } - - felts.push(BigInt("0x" + felt)) - } - return felts + return felts } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/src/utils/zkproofs/proveInWorker.ts b/packages/webapp/src/utils/zkproofs/proveInWorker.ts index 34d0061b..aea8d17f 100644 --- a/packages/webapp/src/utils/zkproofs/proveInWorker.ts +++ b/packages/webapp/src/utils/zkproofs/proveInWorker.ts @@ -7,12 +7,7 @@ * Import this from {@link module:utils/zkproofs} instead. */ -import { - isProofOutput, - ProofCancelled, - ProofOutput, - ProofTimeoutError -} from "src/utils/zkproofs/proofs" +import { isProofOutput, ProofCancelled, ProofOutput, ProofTimeoutError } from "src/utils/zkproofs/proofs" // ================================================================================================= @@ -26,41 +21,41 @@ import { * In additiona to the promise, this returns a `cancel` function which can be used to terminate the * worker (and hence cancel the proof). */ -export function proveInWorker -(circuitName: string, inputs: Record, timeout: number = 0) - : { promise: Promise, cancel: () => void } -{ - const proofWorker = new Worker(new URL("proofWorker.ts", import.meta.url)) - - let timeoutID: ReturnType|undefined = undefined - let reject: (reason: Error) => void - - const promise = new Promise((resolve, _reject) => { - reject = _reject - - proofWorker.onmessage = (event: MessageEvent) => { - if (isProofOutput(event.data)) - resolve(event.data) - else - reject(event.data) - } - - if (timeout > 0) - timeoutID = setTimeout(() => { - proofWorker.terminate() - reject(new ProofTimeoutError(`proof timed out after ${timeout}s`)) - }, timeout * 1000) - }) - - proofWorker.postMessage({ circuitName, inputs }) - - return { - promise, cancel: () => { - if (timeoutID !== undefined) clearTimeout(timeoutID) - proofWorker.terminate() - reject(new ProofCancelled("proof cancelled by user")) +export function proveInWorker( + circuitName: string, + inputs: Record, + timeout: number = 0 +): { promise: Promise; cancel: () => void } { + const proofWorker = new Worker(new URL("proofWorker.ts", import.meta.url)) + + let timeoutID: ReturnType | undefined = undefined + let reject: (reason: Error) => void + + const promise = new Promise((resolve, _reject) => { + reject = _reject + + proofWorker.onmessage = (event: MessageEvent) => { + if (isProofOutput(event.data)) resolve(event.data) + else reject(event.data) + } + + if (timeout > 0) + timeoutID = setTimeout(() => { + proofWorker.terminate() + reject(new ProofTimeoutError(`proof timed out after ${timeout}s`)) + }, timeout * 1000) + }) + + proofWorker.postMessage({ circuitName, inputs }) + + return { + promise, + cancel: () => { + if (timeoutID !== undefined) clearTimeout(timeoutID) + proofWorker.terminate() + reject(new ProofCancelled("proof cancelled by user")) + }, } - } } // ================================================================================================= diff --git a/packages/webapp/src/wagmi/BurnerConnector.ts b/packages/webapp/src/wagmi/BurnerConnector.ts index 44651a98..4203f05e 100644 --- a/packages/webapp/src/wagmi/BurnerConnector.ts +++ b/packages/webapp/src/wagmi/BurnerConnector.ts @@ -19,16 +19,16 @@ type PrivateKey = `0x${string}` * to their index in this array. */ const privateKeys: PrivateKey[] = [ - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", - "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", - "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6", - "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a", - "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba", - "0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e", - "0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356", - "0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97", - "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6" + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", + "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6", + "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a", + "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba", + "0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e", + "0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356", + "0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97", + "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6", ] // ================================================================================================= @@ -39,122 +39,119 @@ const privateKeys: PrivateKey[] = [ * Right now, we're testing this with a hardcoded Anvil private key. */ export class BurnerConnector extends Connector { - readonly id = "0xFable-burner" - readonly name = "0xFable Burner Wallet" - ready = false - - #chain = localhost - #connected = false - #connectLock = new AsyncLock() - #privKey: PrivateKey = undefined as any - #account: PrivateKeyAccount = undefined as any - #walletClient: WalletClient = undefined as any - - constructor() { - super({ - chains: [localhost], - options: {} - }) - } - - private setKey(privkey: PrivateKey) { - this.#privKey = privkey - this.#account = privateKeyToAccount(this.#privKey) - this.#walletClient = createWalletClient({ - account: this.#account, - chain: this.#chain, - transport: http() - }) - this.ready = true - if (this.#connected) - this.onAccountsChanged([this.#account.address]) - } - - async getAccount(): Promise
{ - return this.#account.address - } - - async getChainId(): Promise { - return this.#chain.id - } - - async getProvider(_config: { chainId?: number } | undefined): Promise { - return undefined - } - - /** - * Ensure that this connector is used to connect to one of the Anvil ("test ... junk" mnemonic) - * private keys, disconnecting from another connector if necessary. - */ - async ensureConnectedToIndex(keyIndex: number) { - if (keyIndex < 0 || keyIndex >= privateKeys.length) - throw new Error(`Invalid private key index: ${keyIndex}`) - - if (getAccount().address !== this.#account?.address) - // Necessary because Web3Connect (possibly others) don't play nice with others and don't - // disconnect before connecting. - this.#connected = false - - // Pitfall: if you use a wallet to connect to the same account, you might end up being connected - // via the wallet, as there is no way to detect how we are connected via a specific connector. - // - // It's actually possible to fix this by setting up a listener on the address, and disconnecting - // whenever we encounter a change that was not triggered by this class. But this is a debugging - // help anyway, let's just assume that you'll disconnect the Anvil account from your wallet. - - if (this.#privKey !== privateKeys[keyIndex]) - this.setKey(privateKeys[keyIndex]) - - await this.#connectLock.protect(async () => { - if (!this.#connected) { - // The next two functions are wagmi actions, not methods of this class! - - // Unconditional disconnet to avoid issues: `getAccount().isConnect == false` - // but we still get an `AlreadyConnectedException` if we don't disconnect. - await disconnect() - await connect({connector: this}) - } - }) - } - - async connect(_config: { chainId?: number } | undefined): Promise> { - const data = { - chain: { - id: this.#chain.id, - unsupported: false, - }, - account: this.#account.address + readonly id = "0xFable-burner" + readonly name = "0xFable Burner Wallet" + ready = false + + #chain = localhost + #connected = false + #connectLock = new AsyncLock() + #privKey: PrivateKey = undefined as any + #account: PrivateKeyAccount = undefined as any + #walletClient: WalletClient = undefined as any + + constructor() { + super({ + chains: [localhost], + options: {}, + }) + } + + private setKey(privkey: PrivateKey) { + this.#privKey = privkey + this.#account = privateKeyToAccount(this.#privKey) + this.#walletClient = createWalletClient({ + account: this.#account, + chain: this.#chain, + transport: http(), + }) + this.ready = true + if (this.#connected) this.onAccountsChanged([this.#account.address]) + } + + async getAccount(): Promise
{ + return this.#account.address + } + + async getChainId(): Promise { + return this.#chain.id + } + + async getProvider(_config: { chainId?: number } | undefined): Promise { + return undefined + } + + /** + * Ensure that this connector is used to connect to one of the Anvil ("test ... junk" mnemonic) + * private keys, disconnecting from another connector if necessary. + */ + async ensureConnectedToIndex(keyIndex: number) { + if (keyIndex < 0 || keyIndex >= privateKeys.length) throw new Error(`Invalid private key index: ${keyIndex}`) + + if (getAccount().address !== this.#account?.address) + // Necessary because Web3Connect (possibly others) don't play nice with others and don't + // disconnect before connecting. + this.#connected = false + + // Pitfall: if you use a wallet to connect to the same account, you might end up being connected + // via the wallet, as there is no way to detect how we are connected via a specific connector. + // + // It's actually possible to fix this by setting up a listener on the address, and disconnecting + // whenever we encounter a change that was not triggered by this class. But this is a debugging + // help anyway, let's just assume that you'll disconnect the Anvil account from your wallet. + + if (this.#privKey !== privateKeys[keyIndex]) this.setKey(privateKeys[keyIndex]) + + await this.#connectLock.protect(async () => { + if (!this.#connected) { + // The next two functions are wagmi actions, not methods of this class! + + // Unconditional disconnect to avoid issues: `getAccount().isConnect == false` + // but we still get an `AlreadyConnectedException` if we don't disconnect. + await disconnect() + await connect({ connector: this }) + } + }) + } + + async connect(_config: { chainId?: number } | undefined): Promise> { + const data = { + chain: { + id: this.#chain.id, + unsupported: false, + }, + account: this.#account.address, + } + this.emit("connect", data) + this.#connected = true + this.onAccountsChanged([this.#account.address]) + return data + } + + async disconnect(): Promise { + this.#connected = false + return + } + + async getWalletClient(_config: { chainId?: number } | undefined): Promise { + return this.#walletClient + } + + async isAuthorized(): Promise { + return true + } + + protected onAccountsChanged(_accounts: Address[]): void { + this.emit("change", { account: this.#account.address }) + } + + protected onChainChanged(_chain: number | string): void { + this.emit("change", { chain: { id: this.#chain.id, unsupported: false } }) + } + + protected onDisconnect(_error: Error): void { + this.emit("disconnect") } - this.emit("connect", data) - this.#connected = true - this.onAccountsChanged([this.#account.address]) - return data - } - - async disconnect(): Promise { - this.#connected = false - return - } - - async getWalletClient(_config: { chainId?: number } | undefined): Promise { - return this.#walletClient - } - - async isAuthorized(): Promise { - return true - } - - protected onAccountsChanged(_accounts: Address[]): void { - this.emit("change", { account: this.#account.address }) - } - - protected onChainChanged(_chain: number | string): void { - this.emit("change", { chain: { id: this.#chain.id, unsupported: false } }) - } - - protected onDisconnect(_error: Error): void { - this.emit("disconnect") - } } -// ================================================================================================= \ No newline at end of file +// ================================================================================================= diff --git a/packages/webapp/tsconfig.json b/packages/webapp/tsconfig.json index 7bbb147c..9a654266 100644 --- a/packages/webapp/tsconfig.json +++ b/packages/webapp/tsconfig.json @@ -1,29 +1,29 @@ { - "compilerOptions": { - "target": "es2022", - "baseUrl": "./src", - "paths": { - "src/*": ["./*"], - "contracts/*": ["../../contracts/*"] - }, - "lib": ["dom", "dom.iterable", "es2022"], - "allowJs": true, - "checkJs": true, - "skipLibCheck": true, - "strict": true, - "alwaysStrict": true, - "forceConsistentCasingInFileNames": true, - "noEmit": true, - "incremental": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "node", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve" - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": [ - //"node_modules" - ] + "compilerOptions": { + "target": "es2022", + "baseUrl": "./src", + "paths": { + "src/*": ["./*"], + "contracts/*": ["../../contracts/*"] + }, + "lib": ["dom", "dom.iterable", "es2022"], + "allowJs": true, + "checkJs": true, + "skipLibCheck": true, + "strict": true, + "alwaysStrict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "incremental": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve" + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": [ + //"node_modules" + ] } diff --git a/packages/webapp/wagmi.config.ts b/packages/webapp/wagmi.config.ts index 1b168a62..99988a41 100644 --- a/packages/webapp/wagmi.config.ts +++ b/packages/webapp/wagmi.config.ts @@ -3,18 +3,18 @@ import { react } from "@wagmi/cli/plugins" import { foundry } from "@wagmi/cli/plugins" export default defineConfig({ - out: "src/generated.ts", - plugins: [ - react(), - foundry({ - project: "../contracts", - include: [ - "CardsCollection.sol/**/*.json", - "Game.sol/**/*.json", - "Inventory.sol/**/*.json", - "InventoryCardsCollection.sol/**/*.json", - "DeckAirdrop.sol/**/*.json" - ] - }), - ], + out: "src/generated.ts", + plugins: [ + react(), + foundry({ + project: "../contracts", + include: [ + "CardsCollection.sol/**/*.json", + "Game.sol/**/*.json", + "Inventory.sol/**/*.json", + "InventoryCardsCollection.sol/**/*.json", + "DeckAirdrop.sol/**/*.json", + ], + }), + ], }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 150dcc5c..916b12c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: postcss: specifier: ^8.4.31 version: 8.4.31 + prettier: + specifier: 2.8.8 + version: 2.8.8 tailwindcss: specifier: ^3.3.3 version: 3.3.3