diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx index 4175427150dc1..67872c6a47997 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -13,7 +13,10 @@ import { $petRoam, $petRoamDir, clearPetUnread, + hasPetSpriteForMeta, + mergePetInfoMeta, type PetInfo, + type PetInfoMeta, petProfile, setPetInfo } from '@/store/pet' @@ -39,25 +42,6 @@ interface Point { y: number } -interface PetInfoMeta { - enabled: boolean - slug?: string - displayName?: string - scale?: number - spritesheetRevision?: string -} - -function samePetRevision(info: PetInfo, meta: PetInfoMeta): boolean { - return ( - info.enabled && - Boolean(info.spritesheetBase64) && - info.slug === meta.slug && - info.displayName === meta.displayName && - info.scale === meta.scale && - info.spritesheetRevision === meta.spritesheetRevision - ) -} - // Keep a w×h box fully inside the viewport. Pre-pet-load callers pass a nominal // size; the live size flows in once `info` arrives. function clampPoint(x: number, y: number, w: number, h: number): Point { @@ -184,7 +168,11 @@ export function FloatingPet() { return } - if (samePetRevision($petInfo.get(), meta)) { + const current = $petInfo.get() + + if (hasPetSpriteForMeta(current, meta)) { + setPetInfo(mergePetInfoMeta(current, meta)) + return } } catch { diff --git a/apps/desktop/src/store/pet-gallery.test.ts b/apps/desktop/src/store/pet-gallery.test.ts new file mode 100644 index 0000000000000..08243270e79f9 --- /dev/null +++ b/apps/desktop/src/store/pet-gallery.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $petInfo, setPetInfo } from './pet' +import { $petGallery, adoptPet, loadPetGallery, resetPetGallery, type GatewayRequest } from './pet-gallery' + +function localGallery() { + return { + enabled: true, + active: 'boba', + pets: [{ slug: 'boba', displayName: 'Boba', installed: true }] + } +} + +describe('pet gallery pet.info sync', () => { + beforeEach(() => { + resetPetGallery() + setPetInfo({ enabled: false }) + }) + + afterEach(() => { + resetPetGallery() + setPetInfo({ enabled: false }) + vi.restoreAllMocks() + }) + + it('uses pet.info.meta and keeps the cached spritesheet when the revision is current', async () => { + setPetInfo({ + enabled: true, + slug: 'boba', + displayName: 'Old Boba', + scale: 0.33, + spritesheetBase64: 'large-sprite-payload', + spritesheetRevision: '100:2048', + frameW: 192, + frameH: 208 + }) + + const requestMock = vi.fn(async (method: string) => { + if (method === 'pet.gallery') { + return localGallery() + } + + if (method === 'pet.info.meta') { + return { + enabled: true, + slug: 'boba', + displayName: 'Boba', + scale: 0.5, + spritesheetRevision: '100:2048' + } + } + + if (method === 'pet.info') { + throw new Error('full pet.info should not be called for an unchanged sprite') + } + + throw new Error(`unexpected method: ${method}`) + }) + const request = requestMock as unknown as GatewayRequest + + await loadPetGallery(request) + + const methods = requestMock.mock.calls.map(([method]) => method) + expect(methods).toContain('pet.info.meta') + expect(methods).not.toContain('pet.info') + expect($petInfo.get()).toMatchObject({ + enabled: true, + slug: 'boba', + displayName: 'Boba', + scale: 0.5, + spritesheetBase64: 'large-sprite-payload', + spritesheetRevision: '100:2048', + frameW: 192, + frameH: 208 + }) + }) + + it('fetches full pet.info when metadata reports a new spritesheet revision', async () => { + setPetInfo({ + enabled: true, + slug: 'boba', + displayName: 'Boba', + scale: 0.33, + spritesheetBase64: 'old-sprite-payload', + spritesheetRevision: '100:2048' + }) + + const requestMock = vi.fn(async (method: string) => { + if (method === 'pet.gallery') { + return localGallery() + } + + if (method === 'pet.info.meta') { + return { + enabled: true, + slug: 'boba', + displayName: 'Boba', + scale: 0.33, + spritesheetRevision: '101:4096' + } + } + + if (method === 'pet.info') { + return { + enabled: true, + slug: 'boba', + displayName: 'Boba', + scale: 0.33, + spritesheetBase64: 'new-sprite-payload', + spritesheetRevision: '101:4096' + } + } + + throw new Error(`unexpected method: ${method}`) + }) + const request = requestMock as unknown as GatewayRequest + + await loadPetGallery(request) + + const methods = requestMock.mock.calls.map(([method]) => method) + expect(methods).toContain('pet.info.meta') + expect(methods).toContain('pet.info') + expect($petInfo.get().spritesheetBase64).toBe('new-sprite-payload') + expect($petInfo.get().spritesheetRevision).toBe('101:4096') + }) + + it('falls back to full pet.info when an older gateway lacks metadata', async () => { + const requestMock = vi.fn(async (method: string) => { + if (method === 'pet.gallery') { + return localGallery() + } + + if (method === 'pet.info.meta') { + throw new Error('JSON-RPC -32601: Method not found') + } + + if (method === 'pet.info') { + return { + enabled: true, + slug: 'boba', + displayName: 'Boba from legacy gateway', + scale: 0.4, + spritesheetBase64: 'legacy-full-payload', + spritesheetRevision: '99:1024' + } + } + + throw new Error(`unexpected method: ${method}`) + }) + const request = requestMock as unknown as GatewayRequest + + await loadPetGallery(request) + + const methods = requestMock.mock.calls.map(([method]) => method) + expect(methods).toContain('pet.info.meta') + expect(methods).toContain('pet.info') + expect($petInfo.get()).toMatchObject({ + enabled: true, + slug: 'boba', + displayName: 'Boba from legacy gateway', + spritesheetBase64: 'legacy-full-payload', + spritesheetRevision: '99:1024' + }) + }) + + it('keeps mutation sync on metadata when the selected pet sprite is unchanged', async () => { + $petGallery.set(localGallery()) + setPetInfo({ + enabled: true, + slug: 'boba', + displayName: 'Boba', + scale: 0.33, + spritesheetBase64: 'large-sprite-payload', + spritesheetRevision: '100:2048' + }) + + const requestMock = vi.fn(async (method: string) => { + if (method === 'pet.select') { + return { ok: true, slug: 'boba', displayName: 'Boba' } + } + + if (method === 'pet.info.meta') { + return { + enabled: true, + slug: 'boba', + displayName: 'Boba', + scale: 0.33, + spritesheetRevision: '100:2048' + } + } + + if (method === 'pet.info') { + throw new Error('full pet.info should not be called after an unchanged select') + } + + throw new Error(`unexpected method: ${method}`) + }) + const request = requestMock as unknown as GatewayRequest + + await expect(adoptPet(request, 'boba', 'Could not adopt pet.')).resolves.toBe(true) + + const methods = requestMock.mock.calls.map(([method]) => method) + expect(methods).toEqual(['pet.select', 'pet.info.meta']) + expect($petInfo.get().spritesheetBase64).toBe('large-sprite-payload') + }) +}) diff --git a/apps/desktop/src/store/pet-gallery.ts b/apps/desktop/src/store/pet-gallery.ts index 40cb420e95b43..01b555b90c597 100644 --- a/apps/desktop/src/store/pet-gallery.ts +++ b/apps/desktop/src/store/pet-gallery.ts @@ -1,7 +1,15 @@ import { atom } from 'nanostores' import { normalize } from '@/lib/text' -import { $petInfo, type PetInfo, petProfile, setPetInfo } from '@/store/pet' +import { + $petInfo, + hasPetSpriteForMeta, + mergePetInfoMeta, + type PetInfo, + type PetInfoMeta, + petProfile, + setPetInfo +} from '@/store/pet' /** * Feature store for the petdex gallery picker (Cmd+K "Pets…" + Settings). @@ -128,9 +136,9 @@ export function loadPetGallery(request: GatewayRequest, options: { force?: boole try { // Phase 1: local pets only — instant, never blocks on the remote petdex // manifest. The user's own/generated pets render right away. - const [local, info] = await Promise.all([ + const [local] = await Promise.all([ petRpc(request, 'pet.gallery', { localOnly: true }), - petRpc(request, 'pet.info') + syncInfo(request) ]) if (local) { @@ -140,9 +148,6 @@ export function loadPetGallery(request: GatewayRequest, options: { force?: boole localOk = true } - if (info) { - setPetInfo(info) - } } catch (e) { if (isMissingMethod(e)) { $petGalleryStatus.set('stale') @@ -179,6 +184,42 @@ export function loadPetGallery(request: GatewayRequest, options: { force?: boole // network gallery — the floating pet repaints, the picker keeps its cache. async function syncInfo(request: GatewayRequest): Promise { try { + let meta: PetInfoMeta | null = null + + try { + meta = await petRpc(request, 'pet.info.meta') + } catch (e) { + if (!isMissingMethod(e)) { + throw e + } + + const info = await petRpc(request, 'pet.info') + + if (info) { + setPetInfo(info) + } + + return + } + + if (!meta) { + return + } + + if (!meta.enabled) { + setPetInfo({ enabled: false }) + + return + } + + const current = $petInfo.get() + + if (hasPetSpriteForMeta(current, meta)) { + setPetInfo(mergePetInfoMeta(current, meta)) + + return + } + const info = await petRpc(request, 'pet.info') if (info) { diff --git a/apps/desktop/src/store/pet.test.ts b/apps/desktop/src/store/pet.test.ts index ce2327becb49d..fcee70c622bba 100644 --- a/apps/desktop/src/store/pet.test.ts +++ b/apps/desktop/src/store/pet.test.ts @@ -7,6 +7,8 @@ import { $petState, derivePetState, flashPetActivity, + hasPetSpriteForMeta, + mergePetInfoMeta, setPetActivity } from './pet' @@ -75,6 +77,36 @@ describe('roam motion', () => { }) }) +describe('pet info metadata cache helpers', () => { + it('treats matching slug and spritesheet revision as a reusable sprite payload', () => { + const current = { + enabled: true, + slug: 'boba', + displayName: 'Old Boba', + scale: 0.33, + spritesheetBase64: 'large-sprite-payload', + spritesheetRevision: '100:2048' + } + const meta = { + enabled: true, + slug: 'boba', + displayName: 'Boba', + scale: 0.5, + spritesheetRevision: '100:2048' + } + + expect(hasPetSpriteForMeta(current, meta)).toBe(true) + expect(mergePetInfoMeta(current, meta)).toMatchObject({ + enabled: true, + slug: 'boba', + displayName: 'Boba', + scale: 0.5, + spritesheetBase64: 'large-sprite-payload', + spritesheetRevision: '100:2048' + }) + }) +}) + describe('flashPetActivity', () => { it('clears stale sibling beats so a completion never inherits a prior error', () => { // A turn errors (sad), then the next turn finishes cleanly. The celebrate diff --git a/apps/desktop/src/store/pet.ts b/apps/desktop/src/store/pet.ts index b1e2e9d214e52..2c0c06581b29f 100644 --- a/apps/desktop/src/store/pet.ts +++ b/apps/desktop/src/store/pet.ts @@ -39,6 +39,40 @@ export interface PetInfo { stateRows?: string[] } +export interface PetInfoMeta { + enabled: boolean + slug?: string + displayName?: string + scale?: number + spritesheetRevision?: string +} + +export function hasPetSpriteForMeta(info: PetInfo, meta: PetInfoMeta): boolean { + return ( + meta.enabled && + info.enabled && + Boolean(info.spritesheetBase64) && + info.slug === meta.slug && + Boolean(info.spritesheetRevision) && + info.spritesheetRevision === meta.spritesheetRevision + ) +} + +export function mergePetInfoMeta(info: PetInfo, meta: PetInfoMeta): PetInfo { + if (!meta.enabled) { + return { enabled: false } + } + + return { + ...info, + enabled: true, + slug: meta.slug, + displayName: meta.displayName, + scale: meta.scale, + spritesheetRevision: meta.spritesheetRevision + } +} + export interface PetActivity { busy?: boolean awaitingInput?: boolean