fix(desktop): avoid repeated pet spritesheet fetches
This commit is contained in:
parent
51defb9e29
commit
1573829a7c
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue