From 98d9a98fc1ab6c15b787395bbd6ca47c92d35255 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Tue, 4 Aug 2026 18:20:57 -0400 Subject: [PATCH 01/32] feat: render generated pet art in the web and mobile clients --- CLAUDE.md | 7 +- frontend/env.example | 4 + frontend/src/components/pet/pet-art.tsx | 18 +-- .../pet/collection/pet-gallery.test.tsx | 2 + .../pet/interactions/panels/breed.test.tsx | 2 + .../pet/interactions/panels/level-up.test.tsx | 2 + .../pet/interactions/panels/rename.test.tsx | 2 + .../pet/interactions/panels/train.test.tsx | 2 + image-generator/README.md | 30 ++++- image-generator/src/chain.test.ts | 46 ++++---- image-generator/src/chain.ts | 23 ++-- image-generator/src/integration.test.ts | 20 ++-- image-generator/src/metadata.ts | 11 +- mobile/env.d.ts | 6 + mobile/env.example | 5 + mobile/src/components/PetArt.tsx | 103 ++++++++++++++++++ mobile/src/components/PetList.tsx | 12 ++ package.json | 3 +- render.yaml | 44 ++++++++ shared/src/utils/pets/index.ts | 1 + shared/src/utils/pets/petArtUrl.ts | 27 +++++ shared/tests/utils/pets/petArtUrl.test.ts | 37 +++++++ 22 files changed, 343 insertions(+), 64 deletions(-) create mode 100644 mobile/src/components/PetArt.tsx create mode 100644 shared/src/utils/pets/petArtUrl.ts create mode 100644 shared/tests/utils/pets/petArtUrl.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index f336491b..f76732a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,12 +33,13 @@ Run from repo root unless noted. Package manager is **pnpm** (`packageManager: p ### Install / dev ```bash pnpm install # root only -pnpm install:all # root + frontend + website + backend + mobile + contracts/ethereum +pnpm install:all # root + frontend + website + backend + mobile + contracts/ethereum + image-generator pnpm dev # backend + frontend (concurrent) pnpm dev:fe # frontend only pnpm dev:be # backend only pnpm dev:mobile # mobile only pnpm dev:web # website only +pnpm dev:art # image-generator only (pet art, :8787) pnpm eth:node # local Hardhat network pnpm eth:deploy # deploy contracts to it pnpm sol:docker # start Solana validator (docker-compose) @@ -47,6 +48,8 @@ pnpm fe:eth:local # HH node + deploy + VRF watcher + backend + fronte pnpm mobile:eth:local # same, with mobile instead of frontend pnpm fe:sol:local # backend + frontend + Solana docker/ngrok, concurrently ``` +> `pnpm dev:art` is deliberately not folded into `pnpm dev` or the `fe:*:local` stacks. Those run under `concurrently --kill-others-on-fail`, and the image service exits at boot when `CF_ACCOUNT_ID`/`CF_API_TOKEN` are unset, which would take the whole stack down over an optional dependency. Run it in its own terminal when you want art. + > `pnpm eth:deploy` and `pnpm eth:vrf:watch` currently reference `deploy:inject` and `vrf:watch` scripts that **no longer exist** in `contracts/ethereum/package.json` (that package was refactored to `scripts/deploy.ts` plus Hardhat Ignition). If these fail, deploy directly with `pnpm --prefix contracts/ethereum deploy` (or `deploy:sepolia` / `deploy:base-sepolia`) instead of chasing the root wrapper script. `DEVELOPMENT.md` and `contracts/ethereum/README.md` also document a few commands (`pnpm clean`, `pnpm vrf:watch`, and an older "start everything" meaning of `pnpm dev`) that don't match the current root scripts, so treat those docs as partially stale and trust `package.json` scripts blocks over prose. ### Lint / test / build (root aggregates) @@ -96,7 +99,7 @@ pnpm build # compile contracts + build backend + frontend + we | `image-generator` | Node.js, TypeScript, Cloudflare Workers AI, R2 | Standalone service rendering pet NFT art + ERC-721 metadata. **Not a pnpm workspace member** (see below) | ### Data flow -On-chain pet state (EVM via subgraph watermark polling, Solana via WebSocket push + backfill) is mirrored into Prisma-owned Postgres (`pet_roster`) by **`indexer-go`, which is the only indexer**. The backend's built-in Node `RosterIndexer` no longer exists β€” nothing in `backend/src` indexes chain state, so a local stack that needs a populated roster has to run `indexer-go`. `battle_history` is not indexed at all: the backend writes it from its own signed receipts. `indexer-go` also answers pet-state reads and win estimates over gRPC; if it is down the backend falls back to reading Postgres directly (`ROSTER_READ_SOURCE` controls `grpc` vs `postgres`, and matchmaking always reads Postgres). Frontend, mobile, and website all talk to the backend via REST + GraphQL; none of them read chain state directly. The one thing outside that path is pet art: the frontend requests images straight from `image-generator` (`VITE_IMAGE_SERVICE_URL`), which reads `PetCore` over RPC itself rather than trusting the indexer, because a stale `dna` there would not render an outdated pet but a *different* one, and cache that permanently. Art is optional by construction: unset the variable, or let the service be down, and pets fall back to their emoji avatars. See `docs/architecture.md`, `backend/API.md`, `indexer-go/README.md`, `image-generator/README.md`. +On-chain pet state (EVM via subgraph watermark polling, Solana via WebSocket push + backfill) is mirrored into Prisma-owned Postgres (`pet_roster`) by **`indexer-go`, which is the only indexer**. The backend's built-in Node `RosterIndexer` no longer exists β€” nothing in `backend/src` indexes chain state, so a local stack that needs a populated roster has to run `indexer-go`. `battle_history` is not indexed at all: the backend writes it from its own signed receipts. `indexer-go` also answers pet-state reads and win estimates over gRPC; if it is down the backend falls back to reading Postgres directly (`ROSTER_READ_SOURCE` controls `grpc` vs `postgres`, and matchmaking always reads Postgres). Frontend, mobile, and website all talk to the backend via REST + GraphQL; none of them read chain state directly. The one thing outside that path is pet art: the frontend and mobile app request images straight from `image-generator` (`VITE_IMAGE_SERVICE_URL` / mobile's `IMAGE_SERVICE_URL`), which reads `PetCore` over RPC itself rather than trusting the indexer, because a stale `dna` there would not render an outdated pet but a *different* one, and cache that permanently. Both clients build the URL with `petArtUrl` from `@shared/core`, so the route shape (numeric id on EVM, Core asset pubkey on Solana) is written down once; only the environment read is per-platform. Art is optional by construction: unset the variable, or let the service be down, and pets fall back to their emoji avatars. Note the service transcribes `PetCore.Pet` by hand in `src/chain.ts` rather than importing an ABI, so a change to that struct silently breaks every EVM read here until it is copied across. See `docs/architecture.md`, `backend/API.md`, `indexer-go/README.md`, `image-generator/README.md`. Note: two dangling doc links. `docs/architecture.md` does not exist, though `docs/README.md` and `AGENTS.md` both point at it; the component map and data flow live in this file's Architecture section instead. `docs/README.md` also links to `indexer-go/ARCHITECTURE.md`, which does not exist either; the real doc is `indexer-go/README.md`. diff --git a/frontend/env.example b/frontend/env.example index 21d300ea..2f767196 100644 --- a/frontend/env.example +++ b/frontend/env.example @@ -23,4 +23,8 @@ VITE_API_URL=http://localhost:3001 # Pet art service (image-generator). Optional: leave unset and pets keep their # emoji avatars. When set, each card requests its pet's generated art and falls # back to the emoji while loading or if the image fails. +# +# Local dev: `pnpm dev:art` serves the service on :8787, and the value below is +# what talks to it. Point at the deployed service in production. +VITE_IMAGE_SERVICE_URL=http://localhost:8787 # VITE_IMAGE_SERVICE_URL=https://art.cryptopets.io diff --git a/frontend/src/components/pet/pet-art.tsx b/frontend/src/components/pet/pet-art.tsx index e7787de2..25799203 100644 --- a/frontend/src/components/pet/pet-art.tsx +++ b/frontend/src/components/pet/pet-art.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; -import { getPetAvatar, type Pet } from '@shared/core'; +import { getPetAvatar, petArtUrl as buildPetArtUrl, type Pet } from '@shared/core'; /** * A pet's generated art, falling back to its emoji avatar. @@ -33,24 +33,16 @@ import { getPetAvatar, type Pet } from '@shared/core'; const RETRY_AFTER_MS = 30_000; /** - * Pets are addressed differently per chain, matching the service's routes: - * a numeric id on EVM, the Metaplex Core asset pubkey on Solana. A Solana pet - * without an assetKey has nothing to look up, so it keeps the emoji. + * The route shape lives in `@shared/core` so the mobile app addresses pets the + * same way; only the environment read is web-specific. */ -export const petArtUrl = (pet: Pick): string | null => { +export const petArtUrl = (pet: Pick): string | null => // Read here rather than at module scope. Vite substitutes import.meta.env at // build time either way, so this costs nothing, and it means a test can stub // the variable without re-importing the module: doing that per test forced // vi.resetModules() and a dynamic import, which under a full parallel run was // slow enough to hit the default timeout and fail at random. - const serviceUrl: string | undefined = import.meta.env.VITE_IMAGE_SERVICE_URL; - if (!serviceUrl) return null; - - const identifier = pet.chain === 'solana' ? pet.assetKey : pet.id; - if (!identifier) return null; - - return `${serviceUrl.replace(/\/+$/, '')}/image/${pet.chain}/${identifier}.png`; -}; + buildPetArtUrl(pet, import.meta.env.VITE_IMAGE_SERVICE_URL); type PetArtProps = { pet: Pick; diff --git a/frontend/tests/components/pet/collection/pet-gallery.test.tsx b/frontend/tests/components/pet/collection/pet-gallery.test.tsx index 37680421..e5cc552d 100644 --- a/frontend/tests/components/pet/collection/pet-gallery.test.tsx +++ b/frontend/tests/components/pet/collection/pet-gallery.test.tsx @@ -48,6 +48,8 @@ vi.mock('@shared/core', () => ({ getXpNumbers: () => ({ xpCurrent: 10, xpMax: 100 }), getXpPercent: () => 10, getPetAvatar: () => 'avatar', + // No art service in these tests: PetArt renders the emoji alone. + petArtUrl: () => null, getPetClass: () => 'Warrior', getPetProperties: () => ({ life: 5, attack: 6, defense: 7, intelligence: 8 }), getPetSkill: () => null, diff --git a/frontend/tests/components/pet/interactions/panels/breed.test.tsx b/frontend/tests/components/pet/interactions/panels/breed.test.tsx index 8dc3ef77..132fc057 100644 --- a/frontend/tests/components/pet/interactions/panels/breed.test.tsx +++ b/frontend/tests/components/pet/interactions/panels/breed.test.tsx @@ -53,6 +53,8 @@ const capabilities = { randomness: { provider: 'vrf' }, kind: 'solana' }; vi.mock('@shared/core', () => ({ // DNA-derived helpers stubbed so the parent/DNA cards render without real DNA. getPetAvatar: () => 'πŸ‰', + // No art service in these tests: PetArt renders the emoji alone. + petArtUrl: () => null, getPetClass: () => 'Warrior', getPetProperties: () => ({ life: 70, attack: 50, defense: 40, intelligence: 60 }), getRarityColor: () => '#8aa0ff', diff --git a/frontend/tests/components/pet/interactions/panels/level-up.test.tsx b/frontend/tests/components/pet/interactions/panels/level-up.test.tsx index 181c320b..13b11032 100644 --- a/frontend/tests/components/pet/interactions/panels/level-up.test.tsx +++ b/frontend/tests/components/pet/interactions/panels/level-up.test.tsx @@ -30,6 +30,8 @@ const capabilities = { levelUpFee: null as { amount: number; symbol: string } | vi.mock('@shared/core', () => ({ getPetAvatar: () => 'πŸ‰', + // No art service in these tests: PetArt renders the emoji alone. + petArtUrl: () => null, getPetClass: () => 'Warrior', getXpNumbers: () => ({ xpCurrent: 10, xpMax: 100 }), getXpPercent: () => 10, diff --git a/frontend/tests/components/pet/interactions/panels/rename.test.tsx b/frontend/tests/components/pet/interactions/panels/rename.test.tsx index 34aba269..66a072b3 100644 --- a/frontend/tests/components/pet/interactions/panels/rename.test.tsx +++ b/frontend/tests/components/pet/interactions/panels/rename.test.tsx @@ -30,6 +30,8 @@ const capabilities = { renameMinLevel: 1, isConnected: true }; vi.mock('@shared/core', () => ({ getPetAvatar: () => 'πŸ‰', + // No art service in these tests: PetArt renders the emoji alone. + petArtUrl: () => null, getPetClass: () => 'Warrior', getXpNumbers: () => ({ xpCurrent: 10, xpMax: 100 }), getXpPercent: () => 10, diff --git a/frontend/tests/components/pet/interactions/panels/train.test.tsx b/frontend/tests/components/pet/interactions/panels/train.test.tsx index 4c251f49..960a9f9d 100644 --- a/frontend/tests/components/pet/interactions/panels/train.test.tsx +++ b/frontend/tests/components/pet/interactions/panels/train.test.tsx @@ -35,6 +35,8 @@ const petList = { vi.mock('@shared/core', () => ({ useChainCapabilities: () => ({ isConnected: true }), getPetAvatar: () => 'πŸ‰', + // No art service in these tests: PetArt renders the emoji alone. + petArtUrl: () => null, getPetClass: () => 'Warrior', getXpNumbers: () => ({ xpCurrent: 10, xpMax: 100 }), getXpPercent: () => 10, diff --git a/image-generator/README.md b/image-generator/README.md index ff801345..c3d18669 100644 --- a/image-generator/README.md +++ b/image-generator/README.md @@ -24,7 +24,20 @@ by the number of pets rather than by traffic. It shares no code with `backend`, `frontend`, or `shared`, and nothing in the monorepo imports it. That is deliberate: the service is deployable and testable -on its own, and work here does not touch files other branches are editing. The +on its own, and work here does not touch files other branches are editing. +Clients depend on its *routes*, never its code: `petArtUrl` in `@shared/core` +builds the URL, and `VITE_IMAGE_SERVICE_URL` / `IMAGE_SERVICE_URL` point at it. + +That isolation has a cost this service already paid once. `PET_CORE_ABI` in +`src/chain.ts` is a hand-copied transcription of `PetCore.Pet`, and when the +retired battle fields left that struct, nothing here noticed: the fixtures +encoded the same stale layout the reader decoded, so the suite agreed with +itself while every live EVM read threw. `src/chain.test.ts` now transcribes the +struct independently of the fixture, which is the only guard available without +importing from the contracts package. Re-check it against +`contracts/ethereum/src/PetCore.sol` whenever that struct changes. + +The one thing it re-implements is `digitPair` (two-digit decimal slicing of DNA, canonically `contracts/ethereum/src/DnaLib.sol`); it is not combat math, so there is no golden-vector obligation and nothing to drift. @@ -50,7 +63,14 @@ Built incrementally. Done so far: - [x] Readiness probe that exercises the store and RPC (`src/readiness.ts`) - [x] Integration tests against fake chain RPCs, a fake S3, and a fake Workers AI - [x] `pnpm smoke`: boots the built server against fake upstreams end to end -- [ ] A live generation run: no real image has been produced yet +- [x] Clients wired up: `frontend` and `mobile` both render pet art from here +- [x] Verified against live Base Sepolia and Solana devnet: `/ready` passes all + three checks and `/metadata/evm/1` returns a real pet +- [ ] A live generation run: no real image has been produced yet. Everything up + to the Workers AI call is confirmed working against the real chain; with a + placeholder token `/image/evm/1.png` answers 502 (the generation call + being rejected) and an unminted id answers 404 before spending anything. + Real `CF_ACCOUNT_ID` / `CF_API_TOKEN` are the only thing still missing - [ ] Solana verified against a real cluster: the decode is covered by fixtures only, since there is no validator in the environment this was written in @@ -359,9 +379,9 @@ machine this was written on, so alpine and layer specifics need one real ### Render -Render reads only the repo-root `render.yaml`, so this block is documented here -rather than left as inert config in this directory. Adding it is the one edit -outside `image-generator/`, deliberately deferred so this branch stays isolated: +Render reads only the repo-root `render.yaml`, and the +`do-not-stop-image-generator` block now lives there. It is reproduced here +because this is where the reasoning behind it belongs: ```yaml - type: web diff --git a/image-generator/src/chain.test.ts b/image-generator/src/chain.test.ts index 4c10f7db..53c0bb51 100644 --- a/image-generator/src/chain.test.ts +++ b/image-generator/src/chain.test.ts @@ -20,8 +20,6 @@ const PET = { dna: 79_34_05_61_88_13_42_07n, level: 4, readyTime: 0, - winCount: 3, - lossCount: 1, rarity: 3, xp: 120, generation: 1, @@ -31,8 +29,6 @@ const PET = { speciesId: 6, parent1Id: 0n, parent2Id: 0n, - lastOpponentId: 0n, - sameOpponentStreak: 0, }; /** getPet and totalPets go through the same readContract; dispatch on which. */ @@ -52,29 +48,39 @@ const ZERO_PET = { name: '', dna: 0n, level: 0, - winCount: 0, - lossCount: 0, rarity: 0, speciesId: 0, generation: 0, }; describe('PET_CORE_ABI', () => { - it('spells out the full Pet struct so the tuple decodes positionally', () => { - // A missing component would silently shift dna onto another field, which - // would cache the wrong art forever. + // These expectations are transcribed from contracts/ethereum/src/PetCore.sol. + // Writing them out rather than deriving them from PET is the point: a test + // that checks the ABI against a fixture built from the same assumption + // agrees with itself no matter how far both have drifted from the contract. + // That is exactly how the retired battle fields (winCount, lossCount, + // lastOpponentId, sameOpponentStreak) survived here after PetCore dropped + // them, failing every live EVM read while the suite stayed green. + it('matches the Pet struct field for field, in order', () => { + // A wrong component list does not lose one attribute: viem decodes the + // tuple positionally, so it shifts dna onto another field and caches the + // wrong art forever, or runs off the end and throws. const components = PET_CORE_ABI[0].outputs[0]!.components; - expect(components).toHaveLength(17); - expect(components.map((c) => c.name).slice(0, 7)).toEqual([ - 'name', - 'dna', - 'level', - 'readyTime', - 'winCount', - 'lossCount', - 'rarity', + expect(components.map((c) => `${c.name}:${c.type}`)).toEqual([ + 'name:string', + 'dna:uint256', + 'level:uint32', + 'readyTime:uint32', + 'rarity:uint8', + 'xp:uint32', + 'generation:uint8', + 'breedCount:uint8', + 'breedReadyAt:uint32', + 'trainReadyAt:uint32', + 'speciesId:uint16', + 'parent1Id:uint256', + 'parent2Id:uint256', ]); - expect(components[12]!.name).toBe('speciesId'); }); }); @@ -91,8 +97,6 @@ describe('EvmPetReader', () => { speciesId: 6, level: 4, generation: 1, - winCount: 3, - lossCount: 1, }); expect(vi.mocked(c.readContract).mock.calls[0]![0]).toMatchObject({ address: CONFIG.petCoreAddress, diff --git a/image-generator/src/chain.ts b/image-generator/src/chain.ts index db275199..4a00680a 100644 --- a/image-generator/src/chain.ts +++ b/image-generator/src/chain.ts @@ -18,7 +18,14 @@ import { createPublicClient, getAddress, http, type Address, type PublicClient } /** Minimal ABI. Only getPet is needed, but the Pet struct must be spelled out * in full: viem decodes the returned tuple positionally, so an abbreviated * component list would silently misalign dna with some other field. - * Mirrors contracts/ethereum/src/PetCore.sol's Pet struct. */ + * Mirrors contracts/ethereum/src/PetCore.sol's Pet struct. + * + * Battle state is deliberately absent. `winCount`, `lossCount`, + * `lastOpponentId` and `sameOpponentStreak` left this struct when per-battle + * on-chain settlement was retired, and the live record moved to the backend's + * `pet_battle_progress`. A stale copy of them here is not a missing attribute + * but a decode failure: viem reads the tuple positionally, so four extra + * components run off the end of the returned data and every EVM read throws. */ export const PET_CORE_ABI = [ { type: 'function', @@ -33,8 +40,6 @@ export const PET_CORE_ABI = [ { name: 'dna', type: 'uint256' }, { name: 'level', type: 'uint32' }, { name: 'readyTime', type: 'uint32' }, - { name: 'winCount', type: 'uint16' }, - { name: 'lossCount', type: 'uint16' }, { name: 'rarity', type: 'uint8' }, { name: 'xp', type: 'uint32' }, { name: 'generation', type: 'uint8' }, @@ -44,8 +49,6 @@ export const PET_CORE_ABI = [ { name: 'speciesId', type: 'uint16' }, { name: 'parent1Id', type: 'uint256' }, { name: 'parent2Id', type: 'uint256' }, - { name: 'lastOpponentId', type: 'uint256' }, - { name: 'sameOpponentStreak', type: 'uint8' }, ], }, ], @@ -96,8 +99,12 @@ export interface OnChainPet { speciesId?: number; level: number; generation: number; - winCount: number; - lossCount: number; + /** Absent on chains that no longer carry a battle record on chain. EVM + * dropped both when per-battle settlement was retired; Solana's account + * layout still has them. Metadata omits the attributes when absent rather + * than reporting a 0-0 record the chain never claimed. */ + winCount?: number; + lossCount?: number; } /** @@ -157,8 +164,6 @@ export class EvmPetReader implements PetReader { speciesId: pet.speciesId, level: pet.level, generation: pet.generation, - winCount: pet.winCount, - lossCount: pet.lossCount, }; } } diff --git a/image-generator/src/integration.test.ts b/image-generator/src/integration.test.ts index 76229388..417dddf1 100644 --- a/image-generator/src/integration.test.ts +++ b/image-generator/src/integration.test.ts @@ -171,27 +171,25 @@ describe('evm, over a real socket', () => { * legitimately changes; a failure here otherwise means this service's ABI has * drifted from the contract. * - * Encodes: name Sparky, dna 7934056188134207, level 4, winCount 3, - * lossCount 1, rarity 3, generation 1, speciesId 6. + * Encodes: name Sparky, dna 7934056188134207, level 4, readyTime 0, + * rarity 3, xp 120, generation 1, breedCount 0, breedReadyAt 0, + * trainReadyAt 0, speciesId 6, parent1Id 0, parent2Id 0. */ const GET_PET_RESULT = '0x' + '0000000000000000000000000000000000000000000000000000000000000020000000000000' - + '0000000000000000000000000000000000000000000000000220000000000000000000000000' + + '00000000000000000000000000000000000000000000000001a0000000000000000000000000' + '000000000000000000000000001c2ffb68b8c33f000000000000000000000000000000000000' + '0000000000000000000000000004000000000000000000000000000000000000000000000000' + '0000000000000000000000000000000000000000000000000000000000000000000000000000' - + '0003000000000000000000000000000000000000000000000000000000000000000100000000' - + '0000000000000000000000000000000000000000000000000000000300000000000000000000' + + '0003000000000000000000000000000000000000000000000000000000000000007800000000' + + '0000000000000000000000000000000000000000000000000000000100000000000000000000' + '0000000000000000000000000000000000000000000000000000000000000000000000000000' - + '0000000000000000000000000000000100000000000000000000000000000000000000000000' + '0000000000000000000000000000000000000000000000000000000000000000000000000000' + '0000000000000000000000000000000000000000000000000000000000000000000000000000' - + '0000000000000000000000000000000000000000000000000000000000060000000000000000' + + '0000000600000000000000000000000000000000000000000000000000000000000000000000' + '0000000000000000000000000000000000000000000000000000000000000000000000000000' - + '0000000000000000000000000000000000000000000000000000000000000000000000000000' - + '0000000000000000000000000000000000000000000000000000000000000000000000000000' - + '0000000000000000000000000000000000000000000000000000000000000000000000000006' - + '537061726b790000000000000000000000000000000000000000000000000000'; + + '000000000000000000000000000000000000000000000006537061726b790000000000000000' + + '000000000000000000000000000000000000'; const evmApp = async (totalPets = 10n) => { const rpc = jsonRpc((method, params) => { diff --git a/image-generator/src/metadata.ts b/image-generator/src/metadata.ts index ef0674c9..ffeb0307 100644 --- a/image-generator/src/metadata.ts +++ b/image-generator/src/metadata.ts @@ -45,8 +45,15 @@ export const buildPetMetadata = (pet: OnChainPet, options: MetadataOptions): Pet ...describePetVisualTraits(traits).map((t) => ({ trait_type: t.trait, value: t.value })), { trait_type: 'Level', value: pet.level, display_type: 'number' }, { trait_type: 'Generation', value: pet.generation, display_type: 'number' }, - { trait_type: 'Wins', value: pet.winCount, display_type: 'number' }, - { trait_type: 'Losses', value: pet.lossCount, display_type: 'number' }, + // Only for chains that still keep a battle record on chain. Emitting 0-0 + // for a chain that stopped writing one would state a record as fact when + // the real one lives in the backend and is not readable from here. + ...(pet.winCount === undefined + ? [] + : [{ trait_type: 'Wins', value: pet.winCount, display_type: 'number' } as const]), + ...(pet.lossCount === undefined + ? [] + : [{ trait_type: 'Losses', value: pet.lossCount, display_type: 'number' } as const]), ]; return { diff --git a/mobile/env.d.ts b/mobile/env.d.ts index 52670e69..46c038d2 100644 --- a/mobile/env.d.ts +++ b/mobile/env.d.ts @@ -12,4 +12,10 @@ declare module '@env' { export const CRYPTOPETS_PROGRAM_ID: string | undefined; /** Optional custom RPC; default is public Solana devnet if unset. */ export const CRYPTOPETS_SOLANA_RPC: string | undefined; + /** + * Pet art service (image-generator), same as frontend `VITE_IMAGE_SERVICE_URL`. + * Optional: leave unset and pets keep their emoji avatars. A phone cannot + * reach `localhost`, so use your machine's LAN IP for a local service. + */ + export const IMAGE_SERVICE_URL: string | undefined; } diff --git a/mobile/env.example b/mobile/env.example index ba00dd7e..a0685039 100644 --- a/mobile/env.example +++ b/mobile/env.example @@ -16,3 +16,8 @@ CRYPTOPETS_PROGRAM_ID= API_URL=http://localhost:3001 CONTRACT_ADDRESS=0x0000000000000000000000000000000000000000 + +# Pet art service (image-generator), same as frontend VITE_IMAGE_SERVICE_URL. +# Optional: leave unset and pets keep their emoji avatars. A phone cannot reach +# localhost, so point at your machine's LAN IP when running the service locally. +# IMAGE_SERVICE_URL=http://192.168.1.5:8787 diff --git a/mobile/src/components/PetArt.tsx b/mobile/src/components/PetArt.tsx new file mode 100644 index 00000000..c94dfa9b --- /dev/null +++ b/mobile/src/components/PetArt.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Image, StyleSheet, Text, View } from 'react-native'; +import { IMAGE_SERVICE_URL } from '@env'; +import { getPetAvatar, petArtUrl, type Pet } from '@shared/core'; + +/** + * A pet's generated art, falling back to its emoji avatar. + * + * Progressive enhancement, matching the web app's `pet-art.tsx`. The emoji is + * what every pet has; the image is layered on when `IMAGE_SERVICE_URL` is + * configured, the pet has an identifier the service can resolve, and the image + * actually loads. Any of those failing leaves the card as it was rather than + * showing a broken frame. + * + * The route shape itself lives in `@shared/core`, so both clients address pets + * identically and only the environment read differs. + */ + +/** + * How long to wait before the single retry, matching the `Retry-After: 30` the + * service sends while a pet's art is still generating. + * + * An reports only that it failed, never why, so a 503 meaning "come + * back shortly" is indistinguishable from a 404 that never will be. One retry + * covers the first case: without it the first viewer of a cold pet sees the + * emoji until they reload, even though the art finished seconds later. One is + * also the limit, so a list of genuinely broken images does not retry forever. + */ +const RETRY_AFTER_MS = 30_000; + +type Props = { + pet: Pick; + /** Square edge in px. Also sizes the emoji, so the two swap without reflow. */ + size?: number; +}; + +export default function PetArt({ pet, size = 44 }: Props) { + const [failed, setFailed] = useState(false); + const [loaded, setLoaded] = useState(false); + const [attempt, setAttempt] = useState(0); + const timer = useRef>(undefined); + + // Cleared on unmount so a list scrolled away leaves no timers behind, and + // React never sets state on an unmounted component. + useEffect(() => () => clearTimeout(timer.current), []); + + const url = petArtUrl(pet, IMAGE_SERVICE_URL); + const emoji = getPetAvatar(pet.dna); + + const onError = () => { + if (attempt > 0) { + setFailed(true); + return; + } + timer.current = setTimeout(() => setAttempt(1), RETRY_AFTER_MS); + }; + + const box = { width: size, height: size }; + const emojiText = { fontSize: size * 0.72, lineHeight: size }; + + if (!url || failed) { + return ( + + {emoji} + + ); + } + + // The emoji stays mounted underneath until the image reports it loaded, so + // the swap costs no layout pass and a slow first generation shows something + // rather than an empty square. + return ( + + {loaded ? null : {emoji}} + setLoaded(true)} + onError={onError} + style={[styles.image, box, loaded ? null : styles.hidden]} + /> + + ); +} + +const styles = StyleSheet.create({ + frame: { + alignItems: 'center', + justifyContent: 'center', + }, + image: { + position: 'absolute', + top: 0, + left: 0, + }, + hidden: { + opacity: 0, + }, +}); diff --git a/mobile/src/components/PetList.tsx b/mobile/src/components/PetList.tsx index 4c5c79a0..a8a4d91f 100644 --- a/mobile/src/components/PetList.tsx +++ b/mobile/src/components/PetList.tsx @@ -9,6 +9,7 @@ import { } from 'react-native'; import type { Pet } from '@shared/core'; import { neon, neonGlow } from '../theme/neon'; +import PetArt from './PetArt'; type Props = { pets: Pet[]; @@ -108,6 +109,16 @@ export default function PetList({ return ( + {/* + * Addressed from petIds, not pet.id: these pets come + * straight off the EVM PetCore read, whose tuple carries + * no id or chain of its own. The id lives alongside in + * petIds, and this screen is EVM-only, so both are known + * here even though the pet object does not carry them. + */} + {pet.name} @@ -172,6 +183,7 @@ const styles = StyleSheet.create({ fontWeight: '800', color: neon.text, flex: 1, + marginLeft: 10, }, rarityBadge: { borderWidth: 1, diff --git a/package.json b/package.json index 6cc47824..7bceee1c 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,12 @@ "packageManager": "pnpm@9.15.9", "type": "module", "scripts": { - "install:all": "pnpm install && pnpm --prefix frontend install && pnpm --prefix website install && pnpm --prefix backend install && pnpm --prefix mobile install && pnpm --prefix contracts/ethereum install", + "install:all": "pnpm install && pnpm --prefix frontend install && pnpm --prefix website install && pnpm --prefix backend install && pnpm --prefix mobile install && pnpm --prefix contracts/ethereum install && pnpm --prefix image-generator install --ignore-workspace", "dev:fe": "pnpm --prefix frontend dev", "dev:web": "pnpm --prefix website dev", "dev:be": "pnpm --prefix backend dev", "dev:mobile": "pnpm --prefix mobile start", + "dev:art": "pnpm --prefix image-generator dev", "eth:node": "pnpm --prefix contracts/ethereum node", "eth:deploy": "pnpm --prefix contracts/ethereum deploy:inject", "eth:vrf:watch": "pnpm --prefix contracts/ethereum vrf:watch", diff --git a/render.yaml b/render.yaml index 53e3b81e..043a72ff 100644 --- a/render.yaml +++ b/render.yaml @@ -67,3 +67,47 @@ services: envVars: - key: LOG_FORMAT value: json + + # image-generator: pet NFT art (see image-generator/README.md). + # + # rootDir is the whole story here: this package is NOT a pnpm workspace member + # and keeps its own lockfile, so --ignore-workspace stops the install walking + # up to the monorepo root and installing that instead, which would leave this + # service with no node_modules. + # + # Art is optional by construction: leave VITE_IMAGE_SERVICE_URL unset on the + # frontend, or let this service sleep, and pets fall back to emoji avatars. + # Nothing else degrades. + - type: web + name: do-not-stop-image-generator + runtime: node + plan: free + rootDir: image-generator + # Do not set NODE_ENV=production here β€” it skips the TypeScript + # devDependency the build needs. Same corepack workaround as the API service + # above: install a standalone pnpm rather than touching the read-only shim. + buildCommand: | + export PNPM_PREFIX="$HOME/.npm-global" + export PATH="$PNPM_PREFIX/bin:$PATH" + npm install -g --prefix "$PNPM_PREFIX" pnpm@9.15.9 + pnpm install --ignore-workspace --frozen-lockfile && pnpm build + startCommand: node dist/main.js + # /health, not /ready: /ready calls out to R2 and every chain RPC, so wiring + # the platform probe to it lets an upstream blip cycle a healthy service. + # Check /ready yourself once after deploying instead. + healthCheckPath: /health + envVars: + # Must match this service's own URL, or metadata hands marketplaces + # image links that 404. + - key: PUBLIC_BASE_URL + sync: false + # r2, not filesystem: free web services sleep when idle, and a service + # that slept on local disk wakes up and regenerates art every pet owner + # has already seen. Generated art must survive the instance. + - key: IMAGE_STORE + value: r2 + # Set in the dashboard, not here: CF_ACCOUNT_ID, CF_API_TOKEN, R2_BUCKET, + # R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, EVM_RPC_URL, PETCORE_ADDRESS, + # and SOLANA_RPC_URL + SOLANA_PROGRAM_ID together if serving Solana pets. + # Note the Solana lookup uses getProgramAccounts, which some hosted + # providers disable. diff --git a/shared/src/utils/pets/index.ts b/shared/src/utils/pets/index.ts index 75d9eb73..5af877c3 100644 --- a/shared/src/utils/pets/index.ts +++ b/shared/src/utils/pets/index.ts @@ -2,5 +2,6 @@ export { mapEvmPet, type EvmRawPet } from './mapEvmPet'; export { mapSolanaPet, type SolanaPetAccountRow } from './mapSolanaPet'; export { getRarityColor, getRarityName, isPetReadyAt } from './cosmetics'; export { getPetSkill, type PetSkill } from './skills'; +export { petArtUrl, type PetArtIdentity } from './petArtUrl'; export { getReadyPets as getReadyPetsUnified, type ReadyPet } from './readyPets'; export { NoActiveChainError } from './errors'; diff --git a/shared/src/utils/pets/petArtUrl.ts b/shared/src/utils/pets/petArtUrl.ts new file mode 100644 index 00000000..fbaae431 --- /dev/null +++ b/shared/src/utils/pets/petArtUrl.ts @@ -0,0 +1,27 @@ +import type { Pet } from '../../types/pet'; + +/** The fields the image service needs to address a pet. */ +export type PetArtIdentity = Pick; + +/** + * URL of a pet's generated art on the image service, or `null` when the pet + * cannot be addressed there. + * + * `serviceUrl` is passed in rather than read from the environment, because the + * two callers read it differently: the web app gets `VITE_IMAGE_SERVICE_URL` + * from `import.meta.env`, the mobile app gets `IMAGE_SERVICE_URL` from `@env`. + * Keeping the environment read at the edge leaves this a pure function that + * both platforms share, so the route shape is written down once. + * + * Pets are addressed differently per chain, matching the service's routes: a + * numeric id on EVM, the Metaplex Core asset pubkey on Solana. A Solana pet + * without an `assetKey` has nothing to look up, so it gets no URL. + */ +export const petArtUrl = (pet: PetArtIdentity, serviceUrl: string | undefined): string | null => { + if (!serviceUrl) return null; + + const identifier = pet.chain === 'solana' ? pet.assetKey : pet.id; + if (!identifier) return null; + + return `${serviceUrl.replace(/\/+$/, '')}/image/${pet.chain}/${identifier}.png`; +}; diff --git a/shared/tests/utils/pets/petArtUrl.test.ts b/shared/tests/utils/pets/petArtUrl.test.ts new file mode 100644 index 00000000..67eb0830 --- /dev/null +++ b/shared/tests/utils/pets/petArtUrl.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { petArtUrl } from '../../../src/utils/pets/petArtUrl'; + +const SERVICE = 'https://art.example.com'; + +describe('petArtUrl', () => { + it('addresses an EVM pet by its numeric id', () => { + expect(petArtUrl({ id: '42', chain: 'evm' }, SERVICE)).toBe( + 'https://art.example.com/image/evm/42.png', + ); + }); + + it('addresses a Solana pet by its Core asset pubkey, not its id', () => { + const url = petArtUrl( + { id: '7', chain: 'solana', assetKey: 'Bfp1ZjoYJ8pSgWbVrpxPYMRYe7x2SxQovc821gB2Yq3w' }, + SERVICE, + ); + expect(url).toBe( + 'https://art.example.com/image/solana/Bfp1ZjoYJ8pSgWbVrpxPYMRYe7x2SxQovc821gB2Yq3w.png', + ); + }); + + it('returns null when the service is not configured', () => { + expect(petArtUrl({ id: '42', chain: 'evm' }, undefined)).toBeNull(); + expect(petArtUrl({ id: '42', chain: 'evm' }, '')).toBeNull(); + }); + + it('returns null for a Solana pet with no asset key, which is unaddressable', () => { + expect(petArtUrl({ id: '7', chain: 'solana' }, SERVICE)).toBeNull(); + }); + + it('does not double the separator on a service URL with a trailing slash', () => { + expect(petArtUrl({ id: '42', chain: 'evm' }, `${SERVICE}/`)).toBe( + 'https://art.example.com/image/evm/42.png', + ); + }); +}); From 90db55fce69439b481b2887bf04efef9c253c0c1 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Tue, 4 Aug 2026 18:49:02 -0400 Subject: [PATCH 02/32] fix(shared): stop the EVM mint stranding at the settle phase --- .../chains/ethereum/usePolledContractEvent.ts | 45 +++++-- .../ethereum/useWatchEntropyFulfillment.ts | 14 +- shared/src/hooks/useCreatePet.ts | 12 +- .../hooks/usePolledContractEvent.test.tsx | 123 ++++++++++++++++++ .../hooks/useWatchEntropyFulfillment.test.tsx | 93 +++++++++++++ 5 files changed, 274 insertions(+), 13 deletions(-) create mode 100644 shared/tests/hooks/usePolledContractEvent.test.tsx create mode 100644 shared/tests/hooks/useWatchEntropyFulfillment.test.tsx diff --git a/shared/src/hooks/chains/ethereum/usePolledContractEvent.ts b/shared/src/hooks/chains/ethereum/usePolledContractEvent.ts index 01636063..99945245 100644 --- a/shared/src/hooks/chains/ethereum/usePolledContractEvent.ts +++ b/shared/src/hooks/chains/ethereum/usePolledContractEvent.ts @@ -4,6 +4,22 @@ import type { Abi, Address, Log } from 'viem'; const DEFAULT_POLLING_INTERVAL_MS = 4_000; +/** + * Widest span asked for in one `eth_getLogs`. Public RPCs cap this β€” Base + * Sepolia's default endpoint (which is what `wagmi.ts` configures, via + * `chain.rpcUrls.default`) rejects anything wider with "Invalid parameters", + * not with a partial result. + * + * The cap is why the range is walked in chunks rather than queried in one + * span. A single failed poll used to leave `fromBlock` where it was, so the + * window grew by roughly one interval's worth of blocks every tick; once it + * crossed the cap, every subsequent poll failed for the same reason and the + * watcher never delivered another log. That is silent: the caller sees no + * error, just an event that never arrives, so a mint waiting on Entropy's + * `Revealed` would sit at "Awaiting randomness..." forever. + */ +const MAX_BLOCK_SPAN = 450n; + export interface UsePolledContractEventParams { address?: Address; abi: Abi; @@ -60,18 +76,25 @@ export function usePolledContractEvent({ fromBlock = latest + 1n; return; } - if (latest < fromBlock) return; - const logs = await publicClient.getContractEvents({ - address, - abi, - eventName: eventName as never, - fromBlock, - toBlock: latest, - }); - fromBlock = latest + 1n; - if (!cancelled && logs.length > 0) onLogsRef.current(logs); + // Walk the backlog in capped chunks, committing progress after each + // one. A chunk that throws leaves the blocks it covered unread and + // retries next tick, but everything already read stays read, so a + // transient error costs one interval instead of stranding the watch. + while (!cancelled && fromBlock <= latest) { + const end = fromBlock + MAX_BLOCK_SPAN - 1n; + const toBlock = end < latest ? end : latest; + const logs = await publicClient.getContractEvents({ + address, + abi, + eventName: eventName as never, + fromBlock, + toBlock, + }); + fromBlock = toBlock + 1n; + if (!cancelled && logs.length > 0) onLogsRef.current(logs); + } } catch { - // Transient RPC error β€” try again next tick. + // Transient RPC error β€” resume from the first unread block next tick. } finally { inFlight = false; } diff --git a/shared/src/hooks/chains/ethereum/useWatchEntropyFulfillment.ts b/shared/src/hooks/chains/ethereum/useWatchEntropyFulfillment.ts index 12a10e8c..c44ec29c 100644 --- a/shared/src/hooks/chains/ethereum/useWatchEntropyFulfillment.ts +++ b/shared/src/hooks/chains/ethereum/useWatchEntropyFulfillment.ts @@ -65,12 +65,24 @@ export const useWatchEntropyFulfillment = ({ const gl = gameLogicRef.current?.toLowerCase(); if (want == null || !gl) return; const typed = logs as unknown as { - args: { caller?: string; sequenceNumber?: bigint; randomNumber?: `0x${string}` }; + args: { + caller?: string; + sequenceNumber?: bigint; + randomNumber?: `0x${string}`; + callbackFailed?: boolean; + }; }[]; for (const log of typed) { if (log.args.caller?.toLowerCase() !== gl) continue; if (log.args.sequenceNumber !== want) continue; if (log.args.randomNumber == null) continue; + // Entropy reveals whether the consumer callback reverted. Only the + // callback sets GameLogic's `fulfilled` flag, and settleBreed / + // settleMint both require it, so acting on a failed callback + // prompts the player for a transaction that reverts with + // "Entropy not yet fulfilled". Wait instead: the reveal is not + // the same thing as the request being settleable. + if (log.args.callbackFailed === true) continue; handlerRef.current?.(want, log.args.randomNumber); return; } diff --git a/shared/src/hooks/useCreatePet.ts b/shared/src/hooks/useCreatePet.ts index 0a5a9f08..8dee3bfc 100644 --- a/shared/src/hooks/useCreatePet.ts +++ b/shared/src/hooks/useCreatePet.ts @@ -90,7 +90,17 @@ export const useCreatePet = (options?: PetMutationOptions): PetMutationResult console.error('[settleMint]', e) }, + { + onError: (e) => { + // Re-arm. The flag exists to stop one reveal sending two settles, + // not to make a rejected or reverted settle permanent: settleMint + // is permissionless and retryable by design, and the request stays + // pending on chain until it lands. Leaving it set stranded the + // flow with the mint fee already spent and no way to finish it. + settleSentRef.current = false; + console.error('[settleMint]', e); + }, + }, ); }, [evm?.gameLogic.address, evm?.gameLogic.abi, evm?.chainId, settle]); diff --git a/shared/tests/hooks/usePolledContractEvent.test.tsx b/shared/tests/hooks/usePolledContractEvent.test.tsx new file mode 100644 index 00000000..12f88e9f --- /dev/null +++ b/shared/tests/hooks/usePolledContractEvent.test.tsx @@ -0,0 +1,123 @@ +// @vitest-environment jsdom +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; + +const publicClient = { + getBlockNumber: vi.fn(), + getContractEvents: vi.fn(), +}; +vi.mock('wagmi', () => ({ usePublicClient: () => publicClient })); + +import { usePolledContractEvent } from '../../src/hooks/chains/ethereum/usePolledContractEvent'; + +const ADDRESS = '0xcontract' as `0x${string}`; + +const setup = (onLogs: (logs: unknown[]) => void) => + renderHook(() => + usePolledContractEvent({ + address: ADDRESS, + abi: [], + eventName: 'Revealed', + enabled: true, + onLogs: onLogs as never, + }), + ); + +/** Lets the hook's async tick chain settle between fake-timer advances. */ +const flush = async () => { + for (let i = 0; i < 12; i++) await Promise.resolve(); +}; + +const spans = () => + publicClient.getContractEvents.mock.calls.map( + ([a]: [{ fromBlock: bigint; toBlock: bigint }]) => [a.fromBlock, a.toBlock], + ); + +beforeEach(() => { + vi.useFakeTimers(); + publicClient.getBlockNumber.mockReset(); + publicClient.getContractEvents.mockReset(); + publicClient.getContractEvents.mockResolvedValue([]); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('usePolledContractEvent', () => { + it('starts from the next block, so it does not replay chain history', async () => { + publicClient.getBlockNumber.mockResolvedValue(1000n); + setup(vi.fn()); + await flush(); + + // First tick only establishes the watermark. + expect(publicClient.getContractEvents).not.toHaveBeenCalled(); + + publicClient.getBlockNumber.mockResolvedValue(1004n); + await vi.advanceTimersByTimeAsync(4_000); + await flush(); + + expect(spans()).toEqual([[1001n, 1004n]]); + }); + + it('never asks for a span wider than the public RPC accepts', async () => { + publicClient.getBlockNumber.mockResolvedValue(1000n); + setup(vi.fn()); + await flush(); + + // A long stall, then a poll: the backlog is far wider than the cap. + publicClient.getBlockNumber.mockResolvedValue(3000n); + await vi.advanceTimersByTimeAsync(4_000); + await flush(); + + const widest = spans().reduce( + (max, [from, to]) => (to - from + 1n > max ? to - from + 1n : max), + 0n, + ); + expect(widest).toBeLessThanOrEqual(450n); + // The whole backlog is still covered, contiguously. + expect(spans()[0]![0]).toBe(1001n); + expect(spans()[spans().length - 1]![1]).toBe(3000n); + }); + + it('keeps the progress a partly-failed poll already made', async () => { + // The regression this guards: fromBlock used to stay put whenever a poll + // threw, so the requested span grew every tick until it passed the RPC's + // cap, after which every poll failed for the same reason and no log was + // ever delivered again. Silent β€” the caller just waits forever. + publicClient.getBlockNumber.mockResolvedValue(1000n); + setup(vi.fn()); + await flush(); + + publicClient.getContractEvents + .mockResolvedValueOnce([]) // 1001-1450 reads fine + .mockRejectedValueOnce(new Error('rpc blip')); // next chunk fails + + publicClient.getBlockNumber.mockResolvedValue(2000n); + await vi.advanceTimersByTimeAsync(4_000); + await flush(); + + publicClient.getContractEvents.mockResolvedValue([]); + await vi.advanceTimersByTimeAsync(4_000); + await flush(); + + // Resumes at the first unread block, not back at 1001. + const afterFailure = spans().slice(2); + expect(afterFailure[0]![0]).toBe(1451n); + }); + + it('delivers logs to the latest callback without restarting the poll', async () => { + publicClient.getBlockNumber.mockResolvedValue(1000n); + const onLogs = vi.fn(); + setup(onLogs); + await flush(); + + publicClient.getContractEvents.mockResolvedValue([{ args: { sequenceNumber: 7n } }]); + publicClient.getBlockNumber.mockResolvedValue(1002n); + await vi.advanceTimersByTimeAsync(4_000); + await flush(); + + expect(onLogs).toHaveBeenCalledTimes(1); + expect(onLogs.mock.calls[0]![0]).toHaveLength(1); + }); +}); diff --git a/shared/tests/hooks/useWatchEntropyFulfillment.test.tsx b/shared/tests/hooks/useWatchEntropyFulfillment.test.tsx new file mode 100644 index 00000000..d9134f12 --- /dev/null +++ b/shared/tests/hooks/useWatchEntropyFulfillment.test.tsx @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; + +let captured: { + enabled: boolean; + onLogs: (logs: unknown[]) => void; +} | undefined; +vi.mock('../../src/hooks/chains/ethereum/usePolledContractEvent', () => ({ + usePolledContractEvent: (config: typeof captured) => { + captured = config; + }, +})); + +import { useWatchEntropyFulfillment } from '../../src/hooks/chains/ethereum/useWatchEntropyFulfillment'; + +const ENTROPY = '0xentropy' as `0x${string}`; +const GAME_LOGIC = '0xGaMeLoGiC' as `0x${string}`; +const RANDOM = `0x${'ab'.repeat(32)}` as `0x${string}`; + +const setup = (over: Partial[0]> = {}) => { + const onFulfilled = vi.fn(); + renderHook(() => + useWatchEntropyFulfillment({ + entropyAddress: ENTROPY, + gameLogicAddress: GAME_LOGIC, + requestId: 42n, + onFulfilled, + ...over, + }), + ); + return onFulfilled; +}; + +const log = (over: Record = {}) => ({ + args: { + caller: GAME_LOGIC, + sequenceNumber: 42n, + randomNumber: RANDOM, + callbackFailed: false, + ...over, + }, +}); + +beforeEach(() => { + captured = undefined; +}); + +describe('useWatchEntropyFulfillment', () => { + it('is disabled until a request id and both addresses are set', () => { + setup({ requestId: null }); + expect(captured?.enabled).toBe(false); + + setup(); + expect(captured?.enabled).toBe(true); + }); + + it('fires for a matching reveal (caller case-insensitive)', () => { + const onFulfilled = setup(); + captured?.onLogs([log({ caller: GAME_LOGIC.toLowerCase() })]); + expect(onFulfilled).toHaveBeenCalledWith(42n, RANDOM); + }); + + it('ignores a reveal called by a different contract', () => { + const onFulfilled = setup(); + captured?.onLogs([log({ caller: '0xsomeoneelse' })]); + expect(onFulfilled).not.toHaveBeenCalled(); + }); + + it('ignores a sequence number mismatch', () => { + const onFulfilled = setup(); + captured?.onLogs([log({ sequenceNumber: 43n })]); + expect(onFulfilled).not.toHaveBeenCalled(); + }); + + it('ignores a reveal whose consumer callback reverted', () => { + // Only entropyCallback sets GameLogic's `fulfilled`, and settleMint / + // settleBreed both require it. Acting on a failed callback prompts the + // player to sign a transaction that reverts with "Entropy not yet + // fulfilled" β€” a reveal is not the same thing as a settleable request. + const onFulfilled = setup(); + captured?.onLogs([log({ callbackFailed: true })]); + expect(onFulfilled).not.toHaveBeenCalled(); + }); + + it('still fires when the reveal carries no callbackFailed field', () => { + // Absent is not failed: an older Entropy ABI or a partial decode must not + // silently stall every mint. + const onFulfilled = setup(); + captured?.onLogs([log({ callbackFailed: undefined })]); + expect(onFulfilled).toHaveBeenCalledWith(42n, RANDOM); + }); +}); From e86d216b1f20228bb2aaba0e0b6c4ada987e3b4e Mon Sep 17 00:00:00 2001 From: heyradcode Date: Tue, 4 Aug 2026 19:10:18 -0400 Subject: [PATCH 03/32] feat(frontend): fill the pet card's upper panel with generated art --- .../collection/pet-gallery/index.module.css | 24 ++++++++++- .../collection/pet-gallery/parts/pet-card.tsx | 2 +- frontend/src/components/pet/pet-art.tsx | 40 ++++++++++++++---- .../tests/components/pet/pet-art.test.tsx | 41 +++++++++++++++++++ .../chains/ethereum/usePolledContractEvent.ts | 12 ++++-- 5 files changed, 104 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/pet/collection/pet-gallery/index.module.css b/frontend/src/components/pet/collection/pet-gallery/index.module.css index 8c3be666..07aa2cc9 100644 --- a/frontend/src/components/pet/collection/pet-gallery/index.module.css +++ b/frontend/src/components/pet/collection/pet-gallery/index.module.css @@ -211,7 +211,10 @@ .petCard { display: flex; flex-direction: column; - min-height: 280px; + + /* Tracks the taller `.visual` above so the floor still sits just under the + card's own content rather than leaving a gap below the actions. */ + min-height: 330px; border-radius: 12px; border: 1px solid rgb(101 131 255 / 22%); background: var(--cp-surface); @@ -237,7 +240,12 @@ .visual { position: relative; - height: 130px; + + /* Sized for the generated art, which is square. Cards are ~230px wide, so a + 130px frame cropped roughly half the height off a cover-fitted image and + cut the pet in two. 180px keeps the crop shallow while still leaving the + card's stats above the fold. */ + height: 180px; flex-shrink: 0; display: flex; align-items: center; @@ -247,6 +255,10 @@ border-bottom: 1px solid rgb(101 131 255 / 12%); } +/* Holds the emoji fallback only. The generated image is taken out of flow by + PetArt's `fill` and covers `.visual` instead, so the float animation and glow + here apply to the emoji and never to the art, which would show seams at the + frame's edges as it moved. */ .avatar { font-size: 3.4rem; line-height: 1; @@ -254,6 +266,14 @@ filter: drop-shadow(0 0 16px rgb(181 140 255 / 50%)); } +/* Above the full-bleed art: the badges are positioned but come earlier in the + DOM, so without this the image paints over them. */ +.rarity, +.level, +.skill { + z-index: 1; +} + .rarity { position: absolute; top: 8px; diff --git a/frontend/src/components/pet/collection/pet-gallery/parts/pet-card.tsx b/frontend/src/components/pet/collection/pet-gallery/parts/pet-card.tsx index c4c3dedc..aef856c6 100644 --- a/frontend/src/components/pet/collection/pet-gallery/parts/pet-card.tsx +++ b/frontend/src/components/pet/collection/pet-gallery/parts/pet-card.tsx @@ -65,7 +65,7 @@ const PetCard: React.FC = ({ pet, cooldown, onBattle, onSendClick {skill.name} ) : null} -
+
diff --git a/frontend/src/components/pet/pet-art.tsx b/frontend/src/components/pet/pet-art.tsx index 25799203..28d46da5 100644 --- a/frontend/src/components/pet/pet-art.tsx +++ b/frontend/src/components/pet/pet-art.tsx @@ -46,9 +46,18 @@ export const petArtUrl = (pet: Pick): string | type PetArtProps = { pet: Pick; + /** + * Cover the nearest positioned ancestor instead of sizing to 1em. + * + * Only the image breaks out; the emoji stays inline and keeps whatever + * font-size and animation the caller already gave it. That split is the + * point: a card wants art bleeding to its edges, but an emoji stretched + * to the same box would just be a huge glyph on a large empty field. + */ + fill?: boolean; }; -const PetArt: React.FC = ({ pet }) => { +const PetArt: React.FC = ({ pet, fill = false }) => { const [failed, setFailed] = useState(false); const [loaded, setLoaded] = useState(false); const [attempt, setAttempt] = useState(0); @@ -96,13 +105,28 @@ const PetArt: React.FC = ({ pet }) => { decoding="async" onLoad={() => setLoaded(true)} onError={onError} - style={{ - gridArea: '1 / 1', - width: '1em', - height: '1em', - objectFit: 'contain', - opacity: loaded ? 1 : 0, - }} + style={ + fill + ? { + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', + // cover, not contain: the art is square and the frame + // is wider than it is tall, so contain would letterbox + // it down to the frame's height and end up barely + // larger than the emoji it replaced. + objectFit: 'cover', + opacity: loaded ? 1 : 0, + } + : { + gridArea: '1 / 1', + width: '1em', + height: '1em', + objectFit: 'contain', + opacity: loaded ? 1 : 0, + } + } /> ); diff --git a/frontend/tests/components/pet/pet-art.test.tsx b/frontend/tests/components/pet/pet-art.test.tsx index 6db3ff08..f27c25b0 100644 --- a/frontend/tests/components/pet/pet-art.test.tsx +++ b/frontend/tests/components/pet/pet-art.test.tsx @@ -147,4 +147,45 @@ describe('PetArt', () => { expect(screen.getByRole('img')).toHaveAttribute('alt', 'Ada'); }); + + describe('fill', () => { + it('sizes to 1em by default, inheriting the surrounding avatar font-size', async () => { + const PetArt = await loadPetArt(); + render(); + + expect(screen.getByRole('img')).toHaveStyle({ width: '1em', height: '1em' }); + }); + + it('covers the positioned ancestor when filling', async () => { + const PetArt = await loadPetArt(); + render(); + + expect(screen.getByRole('img')).toHaveStyle({ + position: 'absolute', + width: '100%', + height: '100%', + objectFit: 'cover', + }); + }); + + it('still falls back to the emoji when filling and the image fails', async () => { + // Filling changes only the image. A pet whose art never loads must + // still get its emoji, at the caller's font-size rather than + // stretched across the frame the art would have covered. + vi.useFakeTimers(); + try { + const PetArt = await loadPetArt(); + render(); + + fireEvent.error(screen.getByRole('img')); + act(() => { vi.advanceTimersByTime(30_000); }); + fireEvent.error(screen.getByRole('img')); + + expect(screen.queryByRole('img')).toBeNull(); + expect(screen.getByText('πŸ¦‰')).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + }); }); diff --git a/shared/src/hooks/chains/ethereum/usePolledContractEvent.ts b/shared/src/hooks/chains/ethereum/usePolledContractEvent.ts index 99945245..1249ae5b 100644 --- a/shared/src/hooks/chains/ethereum/usePolledContractEvent.ts +++ b/shared/src/hooks/chains/ethereum/usePolledContractEvent.ts @@ -80,17 +80,21 @@ export function usePolledContractEvent({ // one. A chunk that throws leaves the blocks it covered unread and // retries next tick, but everything already read stays read, so a // transient error costs one interval instead of stranding the watch. - while (!cancelled && fromBlock <= latest) { - const end = fromBlock + MAX_BLOCK_SPAN - 1n; + // `cursor` carries the narrowing the loop would otherwise lose, + // since `fromBlock` is a reassigned closure variable. + let cursor: bigint = fromBlock; + while (!cancelled && cursor <= latest) { + const end = cursor + MAX_BLOCK_SPAN - 1n; const toBlock = end < latest ? end : latest; const logs = await publicClient.getContractEvents({ address, abi, eventName: eventName as never, - fromBlock, + fromBlock: cursor, toBlock, }); - fromBlock = toBlock + 1n; + cursor = toBlock + 1n; + fromBlock = cursor; if (!cancelled && logs.length > 0) onLogsRef.current(logs); } } catch { From 7db757d1f879ad90e05b1fce6365e2421b397d12 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Tue, 4 Aug 2026 19:32:16 -0400 Subject: [PATCH 04/32] fix(frontend): let the card art actually reach the frame --- .../collection/pet-gallery/index.module.css | 10 +++++---- .../collection/pet-gallery/parts/pet-card.tsx | 6 ++++- frontend/src/components/pet/pet-art.tsx | 22 ++++++++++++++++--- .../tests/components/pet/pet-art.test.tsx | 13 +++++++++++ 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/pet/collection/pet-gallery/index.module.css b/frontend/src/components/pet/collection/pet-gallery/index.module.css index 07aa2cc9..0e18caab 100644 --- a/frontend/src/components/pet/collection/pet-gallery/index.module.css +++ b/frontend/src/components/pet/collection/pet-gallery/index.module.css @@ -255,11 +255,13 @@ border-bottom: 1px solid rgb(101 131 255 / 12%); } -/* Holds the emoji fallback only. The generated image is taken out of flow by - PetArt's `fill` and covers `.visual` instead, so the float animation and glow - here apply to the emoji and never to the art, which would show seams at the - frame's edges as it moved. */ +/* The emoji fallback itself, not a wrapper around the art. + `filter` and the transform `cp-float` animates both make an element a + containing block for absolutely positioned descendants, so while this sat + around PetArt it captured the filling image and held it at emoji size β€” the + art never reached the frame. Keep this on the glyph only. */ .avatar { + display: inline-block; font-size: 3.4rem; line-height: 1; animation: cp-float 3.8s ease-in-out infinite; diff --git a/frontend/src/components/pet/collection/pet-gallery/parts/pet-card.tsx b/frontend/src/components/pet/collection/pet-gallery/parts/pet-card.tsx index aef856c6..b7407876 100644 --- a/frontend/src/components/pet/collection/pet-gallery/parts/pet-card.tsx +++ b/frontend/src/components/pet/collection/pet-gallery/parts/pet-card.tsx @@ -65,7 +65,11 @@ const PetCard: React.FC = ({ pet, cooldown, onBattle, onSendClick {skill.name}
) : null} -
+ {/* Not wrapped in `.avatar`: that class carries a drop-shadow and + an animated transform, either of which would become the + containing block for the filling image and pin it to the + emoji's size. It goes on the emoji itself instead. */} +
diff --git a/frontend/src/components/pet/pet-art.tsx b/frontend/src/components/pet/pet-art.tsx index 28d46da5..4daf73cc 100644 --- a/frontend/src/components/pet/pet-art.tsx +++ b/frontend/src/components/pet/pet-art.tsx @@ -53,11 +53,23 @@ type PetArtProps = { * font-size and animation the caller already gave it. That split is the * point: a card wants art bleeding to its edges, but an emoji stretched * to the same box would just be a huge glyph on a large empty field. + * + * The frame is the nearest ancestor that establishes a containing block, so + * with `fill` the caller must put any `filter`, `transform` or animated + * transform on the emoji (see `emojiClassName`) rather than on a wrapper + * around this component. All three make an element a containing block for + * absolutely positioned descendants, which silently traps the image at the + * wrapper's size instead of the frame's. */ fill?: boolean; + /** + * Applied to the emoji fallback only, so decoration meant for the glyph + * (size, glow, float) does not land on the art. See `fill`. + */ + emojiClassName?: string; }; -const PetArt: React.FC = ({ pet, fill = false }) => { +const PetArt: React.FC = ({ pet, fill = false, emojiClassName }) => { const [failed, setFailed] = useState(false); const [loaded, setLoaded] = useState(false); const [attempt, setAttempt] = useState(0); @@ -78,7 +90,11 @@ const PetArt: React.FC = ({ pet, fill = false }) => { timer.current = setTimeout(() => setAttempt(1), RETRY_AFTER_MS); }; - if (!url || failed) return <>{emoji}; + // Bare text when the caller has no class for it, so the many callers that + // style an ancestor instead keep exactly the DOM they had. + const emojiNode = emojiClassName ? {emoji} : emoji; + + if (!url || failed) return <>{emojiNode}; // The emoji and the image share one grid cell, so they stack without a // wrapper that reserves its own space, and swapping them causes no layout @@ -88,7 +104,7 @@ const PetArt: React.FC = ({ pet, fill = false }) => { // nothing to intersect, so hiding it that way risks never loading it at all. return ( - {loaded ? null : {emoji}} + {loaded ? null : {emojiNode}} { }); }); + it('puts the caller class on the emoji, not on a wrapper around the image', async () => { + // The trap this guards: `.avatar` carries a drop-shadow and an + // animated transform. Both make an element a containing block for + // absolutely positioned descendants, so wrapping PetArt in it + // trapped the filling image at emoji size and the art never reached + // the frame. The class has to land on the glyph. + const PetArt = await loadPetArt(); + render(); + + expect(screen.getByText('πŸ¦‰')).toHaveClass('avatar-cls'); + expect(screen.getByRole('img').closest('.avatar-cls')).toBeNull(); + }); + it('still falls back to the emoji when filling and the image fails', async () => { // Filling changes only the image. A pet whose art never loads must // still get its emoji, at the caller's font-size rather than From 7299cdad5dcc234155de6ed0ce49d3a8ae1a31a9 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Tue, 4 Aug 2026 19:41:25 -0400 Subject: [PATCH 05/32] fix(frontend): stop pet cards overflowing a squeezed gallery --- .../collection/pet-gallery/index.module.css | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/pet/collection/pet-gallery/index.module.css b/frontend/src/components/pet/collection/pet-gallery/index.module.css index 0e18caab..b21acc03 100644 --- a/frontend/src/components/pet/collection/pet-gallery/index.module.css +++ b/frontend/src/components/pet/collection/pet-gallery/index.module.css @@ -191,7 +191,12 @@ min-height: 0; overflow-y: auto; display: grid; - grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + /* `min(230px, 100%)`, not a bare 230px: a minmax floor is a hard floor, so + a track stays 230px wide even when the grid itself is narrower, and the + cards overflow their container instead of shrinking. That is why the + Battle and Send buttons spilled once the side panel expanded and squeezed + this column. The min() caps the floor at the available width. */ + grid-template-columns: repeat(auto-fill, minmax(min(230px, 100%), 1fr)); gap: 14px; align-content: start; padding-bottom: 4px; @@ -216,6 +221,11 @@ card's own content rather than leaving a gap below the actions. */ min-height: 330px; border-radius: 12px; + + /* Backstop for the rounded corners: the sizing rules below are what keep + content inside the card, this only makes a future regression clip rather + than spill over the border. */ + overflow: hidden; border: 1px solid rgb(101 131 255 / 22%); background: var(--cp-surface); box-shadow: 0 4px 20px rgb(0 0 0 / 45%); @@ -334,18 +344,30 @@ gap: 8px; } +/* The name block yields to the fixed-width HP pill instead of pushing it out: + a flex item will not shrink past its own content unless told to. */ +.head > div:first-child { + min-width: 0; +} + .name { font-family: var(--cp-title-font); font-size: 13px; font-weight: 700; letter-spacing: 0.4px; color: #f6f3ff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .petClass { font-size: 10px; color: rgb(195 210 255 / 50%); margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .hp { @@ -444,6 +466,10 @@ border-radius: 4px; padding: 5px 4px; text-align: center; + + /* Four equal tracks only stay equal if each may shrink past its content. */ + min-width: 0; + overflow: hidden; } .tileLabel { @@ -488,11 +514,17 @@ .battleBtn { flex: 1; + + /* Without this the button refuses to shrink below its own label, so in a + narrow card it pushes Send past the card's edge. */ + min-width: 0; + overflow: hidden; + white-space: nowrap; display: flex; align-items: center; justify-content: center; gap: 6px; - padding: 8px; + padding: 8px 6px; border-radius: 5px; font-size: 11px; font-weight: 700; From f5880358b8f16e0b1fa169df4df8e1dead499938 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Tue, 4 Aug 2026 19:51:33 -0400 Subject: [PATCH 06/32] fix(frontend): let the pet card size to its content --- .../collection/pet-gallery/index.module.css | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/pet/collection/pet-gallery/index.module.css b/frontend/src/components/pet/collection/pet-gallery/index.module.css index b21acc03..548eccad 100644 --- a/frontend/src/components/pet/collection/pet-gallery/index.module.css +++ b/frontend/src/components/pet/collection/pet-gallery/index.module.css @@ -217,15 +217,11 @@ display: flex; flex-direction: column; - /* Tracks the taller `.visual` above so the floor still sits just under the - card's own content rather than leaving a gap below the actions. */ - min-height: 330px; + /* Height follows content. No min-height and no overflow clip: the art panel + below scales with the card's width, so the total height depends on how + wide the column ended up, and pinning either end of that just cut the + actions off the bottom. */ border-radius: 12px; - - /* Backstop for the rounded corners: the sizing rules below are what keep - content inside the card, this only makes a future regression clip rather - than spill over the border. */ - overflow: hidden; border: 1px solid rgb(101 131 255 / 22%); background: var(--cp-surface); box-shadow: 0 4px 20px rgb(0 0 0 / 45%); @@ -251,11 +247,11 @@ .visual { position: relative; - /* Sized for the generated art, which is square. Cards are ~230px wide, so a - 130px frame cropped roughly half the height off a cover-fitted image and - cut the pet in two. 180px keeps the crop shallow while still leaving the - card's stats above the fold. */ - height: 180px; + /* Scales with the card instead of a fixed height, so the art keeps its + proportion at any column width. 4:3 against square art crops a little off + the top and bottom, which is shallow enough to keep the pet intact while + still leaving the stats above the fold. */ + aspect-ratio: 4 / 3; flex-shrink: 0; display: flex; align-items: center; From 95424a9a31f4fe0f6de4e780f1b2e1ab01635f12 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Tue, 4 Aug 2026 20:04:46 -0400 Subject: [PATCH 07/32] feat(frontend): reveal the new pet's art in the create dialog --- .../create-pet-modal/index.module.css | 46 +++++++++ .../pet/creation/create-pet-modal/index.tsx | 47 ++++++--- .../create-pet-modal/parts/minted-pet-art.tsx | 97 +++++++++++++++++++ .../pet/creation/create-pet-modal.test.tsx | 23 ++++- .../pet/creation/minted-pet-art.test.tsx | 93 ++++++++++++++++++ shared/src/hooks/useCreatePet.ts | 36 +++++-- 6 files changed, 320 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/pet/creation/create-pet-modal/parts/minted-pet-art.tsx create mode 100644 frontend/tests/components/pet/creation/minted-pet-art.test.tsx diff --git a/frontend/src/components/pet/creation/create-pet-modal/index.module.css b/frontend/src/components/pet/creation/create-pet-modal/index.module.css index 7b96d309..38b23b97 100644 --- a/frontend/src/components/pet/creation/create-pet-modal/index.module.css +++ b/frontend/src/components/pet/creation/create-pet-modal/index.module.css @@ -18,6 +18,52 @@ gap: var(--cp-spacing-lg); } + /* Square, because the generated art is square: a portrait frame of any other + ratio would crop the one image the player opened this dialog to see. */ + .portrait { + position: relative; + align-self: center; + width: 168px; + aspect-ratio: 1; + display: grid; + place-items: center; + overflow: hidden; + border: 1px solid rgb(125 214 255 / 24%); + border-radius: 10px; + background: + radial-gradient(circle at 30% 30%, rgb(181 140 255 / 16%), transparent 60%), + rgb(4 10 26 / 96%); + } + + .portraitImg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + + /* Faded in rather than swapped: the art can arrive at any point across a + two-minute wait, and a hard cut reads as a glitch. */ + transition: opacity 0.4s ease; + } + + .portraitMark { + font-size: 56px; + font-weight: 700; + line-height: 1; + color: rgb(125 214 255 / 32%); + } + + .portraitHint { + position: absolute; + bottom: 9px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.6px; + text-transform: uppercase; + color: rgb(125 214 255 / 55%); + } + .field { display: flex; flex-direction: column; diff --git a/frontend/src/components/pet/creation/create-pet-modal/index.tsx b/frontend/src/components/pet/creation/create-pet-modal/index.tsx index 8be54970..f007f728 100644 --- a/frontend/src/components/pet/creation/create-pet-modal/index.tsx +++ b/frontend/src/components/pet/creation/create-pet-modal/index.tsx @@ -8,6 +8,7 @@ import NeonModal from '@components/ui/neon-modal'; import TransactionStatus from '@components/common/transaction-status'; import { useNotifyError } from '@hooks/useNotifyError'; import { useTxErrorToast } from '@hooks/useTxErrorToast'; +import MintedPetArt from './parts/minted-pet-art'; import styles from './index.module.css'; interface CreatePetModalProps { @@ -16,7 +17,7 @@ interface CreatePetModalProps { } const CreatePetModal: React.FC = ({ isOpen, onClose }) => { - const { isConnected } = useChainCapabilities(); + const { isConnected, kind } = useChainCapabilities(); const queryClient = useQueryClient(); const notifyError = useNotifyError(); @@ -28,15 +29,18 @@ const CreatePetModal: React.FC = ({ isOpen, onClose }) => { const [success, setSuccess] = useState(null); // Settlement is lifecycle-driven (EVM: receipt confirmed; Solana: resolve). + // + // The dialog deliberately stays open. The pet's art is generated on first + // request, so closing here would drop the player back to a gallery card that + // shows an emoji for the next several seconds β€” they would never see what + // they minted. This is the one place worth waiting. const handleCreateComplete = () => { setSuccess(`Pet "${petName.trim()}" created successfully!`); - setPetName(''); // Bust the entire contract-read cache so the gallery picks up the new pet // immediately β€” avoids stale reads when the wallet's chain differs from // the contract's chain (useReadContracts overwrites chainId with the wallet's). void queryClient.invalidateQueries({ queryKey: ['readContract'] }); void queryClient.invalidateQueries({ queryKey: ['readContracts'] }); - onClose(); }; const { @@ -44,6 +48,7 @@ const CreatePetModal: React.FC = ({ isOpen, onClose }) => { isPending, isAwaitingFulfillment, isSettling, + mintedPetId, error: hookError, reset, lifecycle, @@ -116,6 +121,12 @@ const CreatePetModal: React.FC = ({ isOpen, onClose }) => {

+ {/* `?` until the mint settles, and it is not a placeholder for + missing data: commit-reveal fixes the DNA at the entropy + reveal, so until then nobody β€” not even the contract β€” knows + what this pet looks like. */} + +
= ({ isOpen, onClose }) => { onChange={(e) => setPetName(e.target.value)} placeholder="Enter pet name..." maxLength={20} - disabled={isInProgress} + disabled={isInProgress || Boolean(success)} />
- {mintCost &&

Mint cost: {mintCost}

} - - {/* Creating a pet is fully on-chain (Switchboard VRF + program) and - needs no backend session β€” gate on wallet connection only, not SIWS auth. */} - - {buttonLabel} - + {!success && mintCost &&

Mint cost: {mintCost}

} + + {success ? ( + + Done + + ) : ( + /* Creating a pet is fully on-chain (Switchboard VRF + program) and + needs no backend session β€” gate on wallet connection only, not SIWS auth. */ + + {buttonLabel} + + )} {isAwaitingFulfillment && (

diff --git a/frontend/src/components/pet/creation/create-pet-modal/parts/minted-pet-art.tsx b/frontend/src/components/pet/creation/create-pet-modal/parts/minted-pet-art.tsx new file mode 100644 index 00000000..bab7a029 --- /dev/null +++ b/frontend/src/components/pet/creation/create-pet-modal/parts/minted-pet-art.tsx @@ -0,0 +1,97 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { petArtUrl, type PetChain } from '@shared/core'; +import styles from '../index.module.css'; + +/** + * The new pet's portrait, shown inside the create dialog. + * + * Deliberately not `PetArt`. That component is progressive enhancement for a + * pet that already exists: it falls back to the emoji and retries once, because + * a gallery of cards must not sit waiting on art. Here the art *is* the point β€” + * the player is watching to see what they minted β€” so this waits, keeps asking + * while the service generates, and shows `?` in the meantime. + * + * A `?` is also the right pre-mint state: the DNA is fixed by the entropy + * reveal, so before settlement there is genuinely nothing to show. Commit-reveal + * means not even the contract knows what this pet looks like yet. + */ + +/** + * Backoff between attempts, in ms. The service answers 503 with `Retry-After: 30` + * while a pet's art generates, but an reports only that it failed, so the + * schedule is ours to choose. Front-loaded because a cache hit answers at once + * and most misses finish within a few seconds; the tail covers a cold service + * queueing behind its own concurrency limit. Runs ~2 minutes, then stops: + * an unbounded retry against a genuinely broken service is just a hot loop. + */ +const RETRY_SCHEDULE_MS = [1_500, 3_000, 5_000, 8_000, 13_000, 21_000, 30_000, 30_000]; + +type Props = { + /** Null until the mint settles; renders the placeholder. */ + petId: string | null; + chain: PetChain; +}; + +const MintedPetArt: React.FC = ({ petId, chain }) => { + const [attempt, setAttempt] = useState(0); + const [loaded, setLoaded] = useState(false); + const [gaveUp, setGaveUp] = useState(false); + const timer = useRef>(undefined); + + // A dialog closed mid-generation must not leave a timer behind. + useEffect(() => () => clearTimeout(timer.current), []); + + // A second mint in the same session starts over rather than showing the + // previous pet's portrait while the new one generates. + useEffect(() => { + clearTimeout(timer.current); + setAttempt(0); + setLoaded(false); + setGaveUp(false); + }, [petId]); + + const url = petId ? petArtUrl({ id: petId, chain }, import.meta.env.VITE_IMAGE_SERVICE_URL) : null; + + const onError = () => { + const delay = RETRY_SCHEDULE_MS[attempt]; + if (delay === undefined) { + setGaveUp(true); + return; + } + timer.current = setTimeout(() => setAttempt((n) => n + 1), delay); + }; + + const waiting = Boolean(petId) && !loaded && !gaveUp; + + return ( +

+ {url && !gaveUp ? ( + setLoaded(true)} + onError={onError} + /> + ) : null} + + {loaded ? null : ( + + ? + + )} + + {waiting ? Painting your pet… : null} + {gaveUp ? Portrait still rendering : null} +
+ ); +}; + +export default MintedPetArt; diff --git a/frontend/tests/components/pet/creation/create-pet-modal.test.tsx b/frontend/tests/components/pet/creation/create-pet-modal.test.tsx index a8e32d36..7994662b 100644 --- a/frontend/tests/components/pet/creation/create-pet-modal.test.tsx +++ b/frontend/tests/components/pet/creation/create-pet-modal.test.tsx @@ -87,7 +87,10 @@ describe('CreatePetModal', () => { expect(createPet.mutate).toHaveBeenCalledWith({ name: 'Sparky' }); }); - it('shows success, refetches and closes once settled', async () => { + // Art is generated on first request, so closing on settlement would drop the + // player back to a card showing an emoji for the next several seconds. The + // dialog waits instead, and the player dismisses it. + it('shows success and stays open once settled, so the pet can be seen', async () => { const onClose = renderModal(); await userEvent.type(screen.getByPlaceholderText('Enter pet name...'), 'Sparky'); @@ -96,9 +99,27 @@ describe('CreatePetModal', () => { }); expect(screen.getByText('Pet "Sparky" created successfully!')).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('closes on Done once the pet has settled', async () => { + const onClose = renderModal(); + await userEvent.type(screen.getByPlaceholderText('Enter pet name...'), 'Sparky'); + + act(() => { + capturedOnSuccess?.(); + }); + await userEvent.click(screen.getByRole('button', { name: 'Done' })); + + expect(createPet.reset).toHaveBeenCalled(); expect(onClose).toHaveBeenCalledOnce(); }); + it('shows a placeholder until the pet exists, since its DNA is not fixed yet', () => { + renderModal(); + expect(screen.getByText('?')).toBeInTheDocument(); + }); + it('closes and resets via the close button', async () => { const onClose = renderModal(); await userEvent.click(screen.getByRole('button', { name: 'Close modal' })); diff --git a/frontend/tests/components/pet/creation/minted-pet-art.test.tsx b/frontend/tests/components/pet/creation/minted-pet-art.test.tsx new file mode 100644 index 00000000..15240844 --- /dev/null +++ b/frontend/tests/components/pet/creation/minted-pet-art.test.tsx @@ -0,0 +1,93 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import MintedPetArt from '@components/pet/creation/create-pet-modal/parts/minted-pet-art'; + +const SERVICE = 'https://art.example.com'; + +beforeEach(() => { + vi.stubEnv('VITE_IMAGE_SERVICE_URL', SERVICE); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); +}); + +describe('MintedPetArt', () => { + it('shows the placeholder and requests nothing before the mint settles', () => { + render(); + + expect(screen.getByText('?')).toBeInTheDocument(); + expect(screen.queryByRole('img')).toBeNull(); + }); + + it('requests the new pet art once its id is known', () => { + render(); + + expect(screen.getByRole('img')).toHaveAttribute('src', `${SERVICE}/image/evm/42.png`); + }); + + it('keeps the placeholder up while the art is still generating', () => { + render(); + + expect(screen.getByText('?')).toBeInTheDocument(); + expect(screen.getByText(/Painting your pet/i)).toBeInTheDocument(); + + fireEvent.load(screen.getByRole('img')); + + expect(screen.queryByText('?')).toBeNull(); + expect(screen.queryByText(/Painting your pet/i)).toBeNull(); + }); + + // The whole point of this component over PetArt: art is generated on demand, + // so the first request for a brand-new pet is a miss and answers 503 while + // the service works. One retry is not enough to see it through. + it('retries on a schedule rather than giving up after one attempt', () => { + render(); + + for (const delay of [1_500, 3_000, 5_000]) { + fireEvent.error(screen.getByRole('img')); + act(() => { vi.advanceTimersByTime(delay); }); + expect(screen.getByRole('img')).toBeInTheDocument(); + } + + // Still trying, and still showing the player something. + expect(screen.getByText('?')).toBeInTheDocument(); + }); + + it('stops retrying eventually instead of looping on a broken service', () => { + render(); + + for (let i = 0; i < 12; i++) { + const img = screen.queryByRole('img'); + if (!img) break; + fireEvent.error(img); + act(() => { vi.advanceTimersByTime(30_000); }); + } + + expect(screen.queryByRole('img')).toBeNull(); + expect(screen.getByText(/still rendering/i)).toBeInTheDocument(); + }); + + it('starts over for a second pet minted in the same session', () => { + const { rerender } = render(); + fireEvent.load(screen.getByRole('img')); + expect(screen.queryByText('?')).toBeNull(); + + rerender(); + + // Not still showing the previous pet while the new one generates. + expect(screen.getByText('?')).toBeInTheDocument(); + expect(screen.getByRole('img')).toHaveAttribute('src', `${SERVICE}/image/evm/43.png`); + }); + + it('leaves no timer behind when the dialog closes mid-generation', () => { + const { unmount } = render(); + + fireEvent.error(screen.getByRole('img')); + unmount(); + + expect(() => act(() => { vi.advanceTimersByTime(120_000); })).not.toThrow(); + }); +}); diff --git a/shared/src/hooks/useCreatePet.ts b/shared/src/hooks/useCreatePet.ts index 8dee3bfc..152bc6ab 100644 --- a/shared/src/hooks/useCreatePet.ts +++ b/shared/src/hooks/useCreatePet.ts @@ -34,6 +34,11 @@ export interface PetMutationResult { isAwaitingFulfillment?: boolean; /** EVM async mint only: true while the settleMint tx is in-flight. */ isSettling?: boolean; + /** + * EVM async mint only: the new pet's id, from `MintSettled`, once it lands. + * Null until then, and on Solana, whose mint surfaces no identifier. + */ + mintedPetId?: string | null; } /** @@ -113,21 +118,37 @@ export const useCreatePet = (options?: PetMutationOptions): PetMutationResult { + // MintSettled carries the new pet's id, which is the only way to name the + // pet that was just minted: DNA is fixed by the reveal, so nothing before + // settlement identifies it. The caller needs it to show the pet off. + const [mintedPetId, setMintedPetId] = useState(null); + const handleMintSettled = useCallback((petId?: bigint) => { if (successFiredRef.current) return; successFiredRef.current = true; + if (petId != null) setMintedPetId(petId.toString()); setPendingRequestId(null); onSuccessRef.current?.(); }, []); // 4a. Primary: resolve from settle tx receipt (we sent settleMint, so MintSettled is in its logs). - const { isSuccess: settleConfirmed } = useWaitForTransactionReceipt({ + const { data: settleReceipt, isSuccess: settleConfirmed } = useWaitForTransactionReceipt({ hash: settle.data, query: { enabled: !!settle.data }, }); useEffect(() => { - if (isEvm && settleConfirmed) handleMintSettled(); - }, [isEvm, settleConfirmed, handleMintSettled]); + if (!isEvm || !settleConfirmed) return; + let petId: bigint | undefined; + try { + const logs = parseEventLogs({ + abi: evm?.gameLogic.abi ?? [], + logs: settleReceipt?.logs ?? [], + eventName: 'MintSettled', + strict: false, + }) as unknown as { args: { petId?: bigint } }[]; + petId = logs[0]?.args.petId; + } catch { /* settle landed regardless; the id is a bonus, not a gate */ } + handleMintSettled(petId); + }, [isEvm, settleConfirmed, settleReceipt, evm?.gameLogic.abi, handleMintSettled]); // 4b. Secondary: watch MintSettled event (covers a settle sent outside this hook). usePolledContractEvent({ @@ -138,8 +159,9 @@ export const useCreatePet = (options?: PetMutationOptions): PetMutationResult l.args.requestId === pendingRequestId)) handleMintSettled(); + const typed = logs as unknown as { args: { requestId?: bigint; petId?: bigint } }[]; + const mine = typed.find((l) => l.args.requestId === pendingRequestId); + if (mine) handleMintSettled(mine.args.petId); }, }); @@ -153,6 +175,7 @@ export const useCreatePet = (options?: PetMutationOptions): PetMutationResult { setPendingRequestId(null); setPreWriteError(null); + setMintedPetId(null); settleSentRef.current = false; successFiredRef.current = false; settle.reset(); @@ -181,5 +204,6 @@ export const useCreatePet = (options?: PetMutationOptions): PetMutationResult Date: Tue, 4 Aug 2026 20:13:04 -0400 Subject: [PATCH 08/32] feat(frontend): let the sidebar be pinned open --- .../layout/sidebar/index.module.css | 62 ++++++++++++++++++- .../src/components/layout/sidebar/index.tsx | 51 ++++++++++----- frontend/src/components/ui/icon/index.tsx | 12 +++- frontend/src/hooks/useSidebarPin.ts | 36 +++++++++++ .../tests/components/layout/sidebar.test.tsx | 58 +++++++++++++++++ 5 files changed, 202 insertions(+), 17 deletions(-) create mode 100644 frontend/src/hooks/useSidebarPin.ts create mode 100644 frontend/tests/components/layout/sidebar.test.tsx diff --git a/frontend/src/components/layout/sidebar/index.module.css b/frontend/src/components/layout/sidebar/index.module.css index 69206f05..778f0fc7 100644 --- a/frontend/src/components/layout/sidebar/index.module.css +++ b/frontend/src/components/layout/sidebar/index.module.css @@ -19,12 +19,72 @@ } .sidebar:hover, -.sidebar:focus-within { +.sidebar:focus-within, +.sidebar.isPinned { --label-opacity: 1; width: var(--cp-shell-sidebar-expanded); min-width: var(--cp-shell-sidebar-expanded); } +/* Brand and pin share the header strip. The pin sits outside the brand button + because it is a separate action: nesting it would make the whole row one + control, and clicking the pin would also navigate home. */ +.brandRow { + display: flex; + align-items: center; + height: var(--cp-shell-header-height); + border-bottom: 1px solid rgb(101 131 255 / 10%); + flex-shrink: 0; + overflow: hidden; +} + +.brandRow .brand { + flex: 1; + min-width: 0; + height: 100%; + border-bottom: none; +} + +/* The row owns the divider now; without this the brand's own hover rule paints + a second line directly on top of it. */ +.brandRow .brand:hover { + border-bottom-color: transparent; +} + +.pin { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + margin-right: 10px; + padding: 0; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: rgb(125 214 255 / 55%); + cursor: pointer; + transition: color 0.18s ease, background 0.18s ease, border-color 0.18s ease; + + /* Fades with the labels: in the collapsed rail there is no room for it, and + a pin the user cannot see is one they cannot mis-click either. Hovering + the rail expands it, which is when the control becomes reachable. */ + opacity: var(--label-opacity); +} + +.pin:hover { + color: var(--cp-cyan); + background: rgb(125 214 255 / 10%); + border-color: rgb(125 214 255 / 28%); +} + +.pin[aria-pressed='true'] { + color: var(--cp-cyan); + border-color: rgb(125 214 255 / 34%); + background: rgb(125 214 255 / 12%); +} + /* Labels fade with the rail; kept out of layout flow width so text doesn't wrap */ .wordmark, .navLabel, diff --git a/frontend/src/components/layout/sidebar/index.tsx b/frontend/src/components/layout/sidebar/index.tsx index ee5f0384..5db8b0be 100644 --- a/frontend/src/components/layout/sidebar/index.tsx +++ b/frontend/src/components/layout/sidebar/index.tsx @@ -2,15 +2,17 @@ import React from 'react'; import clsx from 'clsx'; import { useLocation, useNavigate } from 'react-router-dom'; -import Icon, { DragonIcon } from '@components/ui/icon'; +import Icon, { DragonIcon, PinFilledIcon, PinIcon } from '@components/ui/icon'; import { DASHBOARD_HOME } from '@constants/interactionRoutes'; +import { useSidebarPin } from '@hooks/useSidebarPin'; import { NAV_ITEMS } from './nav-items'; import styles from './index.module.css'; /** * Collapsible left navigation for the app shell. Collapsed to an icon rail by - * default; expands on hover / keyboard focus (CSS-driven). Nav items drive the - * existing router so deep-links keep working; the logo returns to the gallery. + * default; expands on hover / keyboard focus (CSS-driven), or stays open when + * pinned. Nav items drive the existing router so deep-links keep working; the + * logo returns to the gallery. * * Daily Quests and the rank footer are static placeholders pending real data * (see FRONTEND_REDESIGN_PLAN.md Β§8 Q3). @@ -19,20 +21,39 @@ const Sidebar: React.FC = () => { const navigate = useNavigate(); const location = useLocation(); const currentPath = location.pathname.replace(/\/$/, '') || '/'; + const { pinned, toggle } = useSidebarPin(); return ( -