fix(desktop): avoid repeated pet spritesheet fetches

This commit is contained in:
embwl0x 2026-07-08 08:41:05 -04:00 committed by kshitij
parent 51defb9e29
commit 1573829a7c
5 changed files with 327 additions and 26 deletions

View File

@ -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 {

View File

@ -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')
})
})

View File

@ -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<PetGallery>(request, 'pet.gallery', { localOnly: true }),
petRpc<PetInfo>(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<void> {
try {
let meta: PetInfoMeta | null = null
try {
meta = await petRpc<PetInfoMeta>(request, 'pet.info.meta')
} catch (e) {
if (!isMissingMethod(e)) {
throw e
}
const info = await petRpc<PetInfo>(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<PetInfo>(request, 'pet.info')
if (info) {

View File

@ -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

View File

@ -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