fix(pet): match Codex's per-frame pacing and pointer interactions for imported pets (#8730)

Imported .codex-pet bundles played every animation at a flat 8 fps (~9x too
fast). Mirror Codex's exact per-frame duration tables, render uneven holds as
step-end keyframes, upgrade legacy-persisted pets at render, and add the Codex
mascot's pointer interactions (hover, grab-and-hold on frame 0, horizontal-drag
running). Hardened over several adversarial review rounds: honor explicit fps,
start each row/pet from frame 0, scope drag to its pointer, and guard untrusted
persisted data.

Fixes #8729

Co-authored-by: nasagong <zinho2000@gachon.ac.kr>
This commit is contained in:
Jinho Choi
2026-07-16 21:59:44 -07:00
committed by GitHub
co-authored by nasagong
parent 8ec050a875
commit 13c690b05a
18 changed files with 1334 additions and 94 deletions
+35
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { applyCodexSpriteTimingDefaults } from '../../shared/codex-pet-sprite-defaults'
import {
CODEX_PET_ANIMATIONS,
CODEX_PET_FRAME,
@@ -63,6 +64,40 @@ describe('applyCodexPetDefaults', () => {
expect(manifest.animations).toEqual(CODEX_PET_ANIMATIONS)
})
it('bakes uniform per-frame durations when a Codex-layout bundle pins an explicit fps', () => {
// The requested fps is baked as durations so it is honored rather than
// overridden by the timed table, and so the sprite never enters the
// no-durations legacy retiming path.
const manifest = applyCodexPetDefaults({ id: 'zippy', displayName: 'Zippy', fps: 4 })
expect(manifest.fps).toBe(4)
// 4 fps → 250ms per frame; idle keeps its six frames.
expect(manifest.animations?.idle).toEqual({
row: 0,
frames: 6,
frameDurationsMs: [250, 250, 250, 250, 250, 250]
})
})
it('keeps an explicit fps=8 bundle out of the legacy retiming path', () => {
// fps=8 matches the Codex default and the legacy geometry, but baking
// durations makes it a non-match so the uniform pacing is preserved.
const manifest = applyCodexPetDefaults({ id: 'octo', displayName: 'Octo', fps: 8 })
const sprite = {
frameWidth: 192,
frameHeight: 208,
columns: 8,
rows: 9,
sheetWidth: 1536,
sheetHeight: 1872,
fps: 8,
defaultAnimation: 'idle',
animations: manifest.animations
}
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
expect(sprite.animations?.idle.frameDurationsMs).toEqual([125, 125, 125, 125, 125, 125])
})
it('does not override explicit Orca bundle sprite metadata', () => {
const manifest = applyCodexPetDefaults({
spritesheetPath: 'custom.png',
+26 -18
View File
@@ -1,4 +1,22 @@
import type { SpriteAnimation } from '../../shared/types'
import {
CODEX_PET_ANIMATIONS,
CODEX_PET_DEFAULT_ANIMATION,
CODEX_PET_DEFAULT_FPS,
CODEX_PET_FRAME,
CODEX_PET_SPRITESHEET_PATH,
codexAnimationsAtUniformFps
} from '../../shared/codex-pet-sprite-defaults'
// Re-exported so main-side consumers keep their import path. The tables live
// in shared/ so the renderer can reuse the same fingerprint for upgrades.
export {
CODEX_PET_ANIMATIONS,
CODEX_PET_DEFAULT_ANIMATION,
CODEX_PET_DEFAULT_FPS,
CODEX_PET_FRAME,
CODEX_PET_SPRITESHEET_PATH
}
export type PetManifestLike = {
id?: string
@@ -19,23 +37,6 @@ export type ResolvedPetManifest<T extends PetManifestLike = PetManifestLike> = T
spritesheetPath: string
}
export const CODEX_PET_SPRITESHEET_PATH = 'spritesheet.webp'
export const CODEX_PET_FRAME = { width: 192, height: 208 } as const
export const CODEX_PET_DEFAULT_ANIMATION = 'idle'
export const CODEX_PET_DEFAULT_FPS = 8
export const CODEX_PET_ANIMATIONS: Record<string, SpriteAnimation> = {
idle: { row: 0, frames: 6 },
'running-right': { row: 1, frames: 8 },
'running-left': { row: 2, frames: 8 },
waving: { row: 3, frames: 4 },
jumping: { row: 4, frames: 5 },
failed: { row: 5, frames: 8 },
waiting: { row: 6, frames: 6 },
running: { row: 7, frames: 6 },
review: { row: 8, frames: 6 }
}
function isCodexPetSpritePath(spritesheetPath: string | undefined): boolean {
return spritesheetPath === undefined || /(^|[/\\])spritesheet\.webp$/i.test(spritesheetPath)
}
@@ -61,7 +62,14 @@ export function applyCodexPetDefaults<T extends PetManifestLike>(
frame: manifest.frame ?? CODEX_PET_FRAME,
fps: manifest.fps ?? CODEX_PET_DEFAULT_FPS,
defaultAnimation: manifest.defaultAnimation ?? CODEX_PET_DEFAULT_ANIMATION,
animations: manifest.animations ?? CODEX_PET_ANIMATIONS
// Why: with no fps, bake Codex's intended uneven pacing; with an explicit
// fps, bake that as uniform durations so it is honored instead of being
// overridden by the timed table (and stays out of the legacy retiming path).
animations:
manifest.animations ??
(manifest.fps === undefined
? CODEX_PET_ANIMATIONS
: codexAnimationsAtUniformFps(manifest.fps))
}
}
+63
View File
@@ -109,4 +109,67 @@ describe('registerPetHandlers', () => {
readFile(join(userDataDir, 'sidekicks', 'custom', result.id, 'spritesheet.png'))
).resolves.toEqual(sheetBytes)
})
function webpVp8x(width: number, height: number): Buffer {
const u24 = (value: number): Buffer =>
Buffer.from([value & 0xff, (value >> 8) & 0xff, (value >> 16) & 0xff])
const payload = Buffer.concat([Buffer.from([0, 0, 0, 0]), u24(width - 1), u24(height - 1)])
const size = Buffer.alloc(4)
size.writeUInt32LE(payload.byteLength, 0)
const riffSize = Buffer.alloc(4)
riffSize.writeUInt32LE(4 + 8 + payload.byteLength, 0)
return Buffer.concat([
Buffer.from('RIFF'),
riffSize,
Buffer.from('WEBP'),
Buffer.from('VP8X'),
size,
payload
])
}
async function writeSpriteBundle(
animations: Record<string, { row: number; frames: number; frameDurationsMs?: number[] }>
): Promise<string> {
const bundleDir = join(tempDir, 'durations.codex-pet')
await mkdir(bundleDir, { recursive: true })
await writeFile(
join(bundleDir, 'pet.json'),
JSON.stringify({
id: 'durations',
displayName: 'Durations',
spritesheetPath: 'sheet.webp',
frame: { width: 2, height: 2 },
animations
})
)
await writeFile(join(bundleDir, 'sheet.webp'), webpVp8x(4, 2))
return bundleDir
}
it('imports a bundle whose animations declare per-frame durations', async () => {
const bundleDir = await writeSpriteBundle({
idle: { row: 0, frames: 2, frameDurationsMs: [1680, 1920] }
})
showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [bundleDir] })
const result = (await getHandler('pet:importPetBundle')({ sender: {} })) as CustomPet
expect(result.sprite?.animations?.idle).toEqual({
row: 0,
frames: 2,
frameDurationsMs: [1680, 1920]
})
})
it('rejects a bundle whose frame durations do not match the frame count', async () => {
const bundleDir = await writeSpriteBundle({
idle: { row: 0, frames: 2, frameDurationsMs: [1680] }
})
showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [bundleDir] })
await expect(getHandler('pet:importPetBundle')({ sender: {} })).rejects.toThrow(
'declares 1 frame durations but 2 frames'
)
})
})
+8 -1
View File
@@ -112,7 +112,9 @@ const PetManifestSchema = z
z.string().min(1).max(64),
z.object({
row: z.number().int().min(0).max(256),
frames: z.number().int().positive().max(512)
frames: z.number().int().positive().max(512),
// Why: cap each hold at 60s so a bad manifest can't freeze the overlay.
frameDurationsMs: z.array(z.number().positive().max(60_000)).max(512).optional()
})
)
.optional()
@@ -394,6 +396,11 @@ export function registerPetHandlers(): void {
`Animation "${name}" has ${anim.frames} frames but sheet only has ${columns} columns.`
)
}
if (anim.frameDurationsMs && anim.frameDurationsMs.length !== anim.frames) {
throw new Error(
`Animation "${name}" declares ${anim.frameDurationsMs.length} frame durations but ${anim.frames} frames.`
)
}
}
if (manifest.defaultAnimation && !manifest.animations[manifest.defaultAnimation]) {
throw new Error(`defaultAnimation "${manifest.defaultAnimation}" not in animations.`)
@@ -0,0 +1,112 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const storeState = vi.hoisted(() => ({
agentStatusByPaneKey: {},
agentStatusEpoch: 0,
retainedAgentsByPaneKey: {},
petSize: 180
}))
vi.mock('../../store', () => ({
useAppStore: Object.assign(
<T,>(selector: (state: typeof storeState) => T): T => {
return selector(storeState)
},
{
getState: () => storeState
}
)
}))
vi.mock('./usePetUrl', () => ({
usePetUrl: () => ({
url: 'blob:custom-pet',
ready: true,
sprite: {
frameWidth: 192,
frameHeight: 208,
columns: 8,
rows: 9,
sheetWidth: 1536,
sheetHeight: 1872,
fps: 8,
defaultAnimation: 'idle',
animations: {
// Codex ambient idle pacing: 6.6s cycle with long bookend holds.
idle: { row: 0, frames: 6, frameDurationsMs: [1680, 660, 660, 840, 840, 1920] }
}
},
detected: null
})
}))
import { PetOverlay } from './PetOverlay'
function renderPetOverlay(): { container: HTMLDivElement; root: Root } {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
act(() => {
root.render(<PetOverlay />)
})
return { container, root }
}
function installLocalStorage(): void {
const values = new Map<string, string>()
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {
clear: () => values.clear(),
getItem: (key: string) => values.get(key) ?? null,
removeItem: (key: string) => values.delete(key),
setItem: (key: string, value: string) => values.set(key, value)
}
})
}
describe('PetOverlay per-frame sprite durations', () => {
let root: Root | null = null
let container: HTMLDivElement | null = null
beforeEach(() => {
installLocalStorage()
})
afterEach(() => {
if (root) {
act(() => root?.unmount())
}
container?.remove()
root = null
container = null
})
it('emits one held keyframe stop per frame instead of uniform steps()', () => {
;({ container, root } = renderPetOverlay())
const css = Array.from(container.querySelectorAll('style'))
.map((style) => style.textContent ?? '')
.join('\n')
// Cumulative stops for [1680, 660, 660, 840, 840, 1920] over 6600ms.
expect(css).toContain('0% { background-position: 0px 0px; }')
expect(css).toContain('25.4545%')
expect(css).toContain('35.4545%')
expect(css).toContain('45.4545%')
expect(css).toContain('58.1818%')
expect(css).toContain('70.9091%')
expect(css).not.toContain('to { background-position:')
const spriteDiv = Array.from(container.querySelectorAll('div')).find(
(div) => div.style.backgroundImage !== ''
)
expect(spriteDiv?.style.animation).toContain('6.6s')
expect(spriteDiv?.style.animation).toContain('step-end')
expect(spriteDiv?.style.animation).toContain('infinite')
})
})
@@ -0,0 +1,107 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const storeState = vi.hoisted(() => ({
agentStatusByPaneKey: {},
agentStatusEpoch: 0,
retainedAgentsByPaneKey: {},
petSize: 180
}))
// Why: a mutable pet so a re-render can simulate switching between two cached
// custom pets that both resolve the idle state to different rows.
const petUrlState = vi.hoisted(() => ({
url: 'blob:pet-a',
idleRow: 0
}))
vi.mock('../../store', () => ({
useAppStore: Object.assign(
<T,>(selector: (state: typeof storeState) => T): T => selector(storeState),
{ getState: () => storeState }
)
}))
vi.mock('./usePetUrl', () => ({
usePetUrl: () => ({
url: petUrlState.url,
ready: true,
sprite: {
frameWidth: 192,
frameHeight: 208,
columns: 8,
rows: 9,
sheetWidth: 1536,
sheetHeight: 1872,
fps: 8,
defaultAnimation: 'idle',
animations: { idle: { row: petUrlState.idleRow, frames: 6 } }
},
detected: null
})
}))
import { PetOverlay } from './PetOverlay'
function installLocalStorage(): void {
const values = new Map<string, string>()
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {
clear: () => values.clear(),
getItem: (key: string) => values.get(key) ?? null,
removeItem: (key: string) => values.delete(key),
setItem: (key: string, value: string) => values.set(key, value)
}
})
}
function spriteDiv(container: HTMLElement): HTMLDivElement | undefined {
return Array.from(container.querySelectorAll('div')).find(
(div) => div.style.backgroundImage !== ''
)
}
describe('PetOverlay pet switching', () => {
let root: Root | null = null
let container: HTMLDivElement | null = null
beforeEach(() => {
installLocalStorage()
petUrlState.url = 'blob:pet-a'
petUrlState.idleRow = 0
})
afterEach(() => {
if (root) {
act(() => root?.unmount())
}
container?.remove()
root = null
container = null
})
it('remounts the sprite on a pet change so it cannot inherit the prior timeline', () => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => root?.render(<PetOverlay />))
const before = spriteDiv(container)
expect(before).toBeDefined()
// Switch to a different cached pet whose idle maps to another row.
petUrlState.url = 'blob:pet-b'
petUrlState.idleRow = 2
act(() => root?.render(<PetOverlay />))
// key={url} makes React replace the element rather than update it in place;
// a fresh element starts its CSS animation at frame 0 instead of carrying
// the prior pet's currentTime. Without the key it would be the same node.
const after = spriteDiv(container)
expect(after).toBeDefined()
expect(after).not.toBe(before)
})
})
@@ -0,0 +1,156 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const storeState = vi.hoisted(() => ({
agentStatusByPaneKey: {},
agentStatusEpoch: 0,
retainedAgentsByPaneKey: {},
petSize: 180
}))
vi.mock('../../store', () => ({
useAppStore: Object.assign(
<T,>(selector: (state: typeof storeState) => T): T => selector(storeState),
{ getState: () => storeState }
)
}))
vi.mock('./usePetUrl', () => ({
usePetUrl: () => ({
url: 'blob:custom-pet',
ready: true,
sprite: {
frameWidth: 192,
frameHeight: 208,
columns: 8,
rows: 9,
sheetWidth: 1536,
sheetHeight: 1872,
fps: 8,
defaultAnimation: 'idle',
animations: {
idle: { row: 0, frames: 6, frameDurationsMs: [1680, 660, 660, 840, 840, 1920] },
'running-right': { row: 1, frames: 8 }
}
},
detected: null
})
}))
import { PetOverlay } from './PetOverlay'
function renderPetOverlay(): { container: HTMLDivElement; root: Root } {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
act(() => {
root.render(<PetOverlay />)
})
return { container, root }
}
function installLocalStorage(): void {
const values = new Map<string, string>()
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {
clear: () => values.clear(),
getItem: (key: string) => values.get(key) ?? null,
removeItem: (key: string) => values.delete(key),
setItem: (key: string, value: string) => values.set(key, value)
}
})
}
function spriteDiv(container: HTMLElement): HTMLDivElement {
const div = Array.from(container.querySelectorAll('div')).find(
(candidate) => candidate.style.backgroundImage !== ''
)
if (!div) {
throw new Error('sprite div not found')
}
return div
}
// The @keyframes name is `pet-<useId>-<animationName>-<dragGeneration>`: the
// animation name keeps a switched-to row from reusing the prior timeline, and
// the generation suffix restarts a same-row grab from frame 0.
function animationName(container: HTMLElement): string {
return spriteDiv(container).style.animation.split(' ')[0]
}
function firePointer(target: Element, type: string, clientX: number, clientY: number): void {
act(() => {
target.dispatchEvent(
new PointerEvent(type, { clientX, clientY, button: 0, pointerId: 1, bubbles: true })
)
})
}
describe('PetOverlay grab-and-hold pointer interaction', () => {
let root: Root | null = null
let container: HTMLDivElement | null = null
beforeEach(() => {
installLocalStorage()
})
afterEach(() => {
if (root) {
act(() => root?.unmount())
}
container?.remove()
root = null
container = null
})
it('freezes on a stationary grab, then animates once dragged past the deadzone', () => {
;({ container, root } = renderPetOverlay())
const wrapper = container.querySelector('.pointer-events-auto')
if (!wrapper) {
throw new Error('draggable wrapper not found')
}
// Baseline: the live idle row animates (idle carries per-frame durations, so
// it renders as step-end rather than steps()).
expect(spriteDiv(container).style.animationPlayState).toBe('running')
expect(spriteDiv(container).style.animation).toContain('step-end')
expect(animationName(container)).toContain('idle')
const idleName = animationName(container)
// Grab and hold still: mint a fresh restart (frame 0) and freeze there while
// staying on the live idle row.
firePointer(wrapper, 'pointerdown', 50, 50)
expect(spriteDiv(container).style.animationPlayState).toBe('paused')
expect(spriteDiv(container).style.animation).toContain('step-end')
expect(animationName(container)).not.toBe(idleName)
const heldName = animationName(container)
// A sub-4px twitch stays frozen (deadzone).
firePointer(wrapper, 'pointermove', 52, 51)
expect(spriteDiv(container).style.animationPlayState).toBe('paused')
// A large vertical-only move keeps the hold (no horizontal direction yet).
firePointer(wrapper, 'pointermove', 52, 80)
expect(spriteDiv(container).style.animationPlayState).toBe('paused')
// Drag right past the 4px deadzone: switch to the running-right row (row 1,
// 8 frames, no durations → steps(8)) and resume animating. The keyframes
// name changes with the row, so it starts from frame 0 rather than reusing
// the idle timeline.
firePointer(wrapper, 'pointermove', 70, 80)
expect(spriteDiv(container).style.animationPlayState).toBe('running')
expect(spriteDiv(container).style.animation).toContain('steps(8)')
expect(animationName(container)).toContain('running-right')
expect(animationName(container)).not.toBe(heldName)
// Release restores the live agent state (idle) and resumes animating.
firePointer(wrapper, 'pointerup', 70, 80)
expect(spriteDiv(container).style.animationPlayState).toBe('running')
expect(spriteDiv(container).style.animation).toContain('step-end')
expect(animationName(container)).toContain('idle')
})
})
+51 -59
View File
@@ -5,11 +5,21 @@ import type { DetectedSpriteCacheEntry } from './pet-blob-cache'
import type { CustomPet } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types'
import { selectPetAnimationName, type PetAnimationName } from './pet-agent-state'
import {
selectPetAnimationName,
type PetAnimationName,
type PetDragAnimation
} from './pet-agent-state'
import { usePetPointerInteraction } from './usePetPointerInteraction'
import { buildSpriteAnimationCss } from './sprite-animation-css'
type Sprite = NonNullable<CustomPet['sprite']>
function usePetAnimationName(dragging: boolean): PetAnimationName {
function usePetAnimationName(
dragging: boolean,
dragAnimation: PetDragAnimation,
hovering: boolean
): PetAnimationName {
const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey)
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey)
@@ -22,6 +32,8 @@ function usePetAnimationName(dragging: boolean): PetAnimationName {
entries: Object.values(agentStatusByPaneKey),
retainedCount: Object.keys(retainedAgentsByPaneKey).length,
dragging,
dragAnimation,
hovering,
now: Date.now(),
staleAfterMs: AGENT_STATUS_STALE_AFTER_MS
})
@@ -37,15 +49,21 @@ function SpriteFrame({
sprite,
animate,
maxSize,
animationName
animationName,
restartKey
}: {
url: string
sprite: Sprite
animate: boolean
maxSize: number
animationName: PetAnimationName
// Why: folded into the keyframes name, so bumping it mints a fresh animation
// that restarts from frame 0 even when the state row is unchanged.
restartKey: number
}): React.JSX.Element {
const animKeyframesId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
// Why: name the @keyframes per animation (+restartKey for same-row grabs) so a
// switched-to row starts at frame 0 instead of inheriting the prior timeline.
const animKeyframesId = `${useId().replace(/[^a-zA-Z0-9_-]/g, '')}-${animationName}-${restartKey}`
const anim =
sprite.animations?.[animationName] ||
(sprite.defaultAnimation && sprite.animations?.[sprite.defaultAnimation]) ||
@@ -63,11 +81,15 @@ function SpriteFrame({
const bgH = sprite.sheetHeight * scale
const startX = 0
const startY = -(row * sprite.frameHeight * scale)
const endX = -(frames * sprite.frameWidth * scale)
const duration = Math.max(0.1, frames / Math.max(0.1, sprite.fps))
// Why: sprite keyframes are runtime CSS, not user-visible copy; translated
// CSS keywords make the browser discard the animation.
const keyframesCss = `@keyframes pet-${animKeyframesId} { from { background-position: ${startX}px ${startY}px; } to { background-position: ${endX}px ${startY}px; } }`
const { keyframesCss, animationCss } = buildSpriteAnimationCss({
keyframesId: animKeyframesId,
frames,
fps: sprite.fps,
frameWidth: sprite.frameWidth,
scale,
rowOffsetY: startY,
frameDurationsMs: anim?.frameDurationsMs
})
return (
<>
<style>{keyframesCss}</style>
@@ -80,7 +102,7 @@ function SpriteFrame({
backgroundSize: `${bgW}px ${bgH}px`,
backgroundPosition: `${startX}px ${startY}px`,
imageRendering: 'pixelated',
animation: `pet-${animKeyframesId} ${duration}s steps(${frames}) infinite`,
animation: animationCss,
animationPlayState: animate ? 'running' : 'paused'
}}
/>
@@ -324,8 +346,10 @@ export function PetOverlay(): React.JSX.Element {
},
[size]
)
const [dragging, setDragging] = useState(false)
const dragOffsetRef = useRef<Position>({ x: 0, y: 0 })
const { dragging, dragAnimation, hovering, dragGeneration, handlers } = usePetPointerInteraction(
position,
(next) => setPosition(clampToViewport(next, size))
)
useEffect(() => {
const onResize = (): void => setPosition((prev) => clampToViewport(prev, size))
@@ -344,46 +368,12 @@ export function PetOverlay(): React.JSX.Element {
}
}, [dragging, position])
const animate = documentVisible && !reducedMotion && !dragging
const animationName = usePetAnimationName(dragging)
// Why: setPointerCapture routes subsequent pointer events to this element
// even when the cursor leaves the OS window, so dragging can't get stuck in
// the "true" state if the user releases outside the app.
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>): void => {
if (event.button !== 0) {
return
}
dragOffsetRef.current = {
x: event.clientX - position.x,
y: event.clientY - position.y
}
event.currentTarget.setPointerCapture(event.pointerId)
setDragging(true)
event.preventDefault()
}
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>): void => {
if (!dragging) {
return
}
setPosition(
clampToViewport(
{
x: event.clientX - dragOffsetRef.current.x,
y: event.clientY - dragOffsetRef.current.y
},
size
)
)
}
const endDrag = (event: React.PointerEvent<HTMLDivElement>): void => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
setDragging(false)
}
const motionAllowed = documentVisible && !reducedMotion
// Why: a still/vertical grab freezes on frame 0 (Codex grab-and-hold); a
// horizontal drag keeps animating so the running rows show. Bob always pauses.
const spriteAnimate = motionAllowed && (!dragging || dragAnimation !== null)
const bobAnimate = motionAllowed && !dragging
const animationName = usePetAnimationName(dragging, dragAnimation, hovering)
return (
// Why: the outer box and middle layer stay pointer-events-none so app chrome
@@ -401,15 +391,12 @@ export function PetOverlay(): React.JSX.Element {
>
<div className="pointer-events-none flex size-full items-center justify-end">
<div
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
{...handlers}
className="pointer-events-auto flex h-fit w-fit select-none"
style={{
cursor: dragging ? 'grabbing' : 'grab',
animation: 'pet-bob 1.2s ease-in-out infinite',
animationPlayState: animate ? 'running' : 'paused',
animationPlayState: bobAnimate ? 'running' : 'paused',
touchAction: 'none',
// Why: floor so the wrapper stays grabbable while w-fit/h-fit would
// otherwise collapse to 0×0 during the image-load window.
@@ -419,15 +406,20 @@ export function PetOverlay(): React.JSX.Element {
>
<style>{PET_BOB_KEYFRAMES_CSS}</style>
{sprite ? (
// Why: remount per pet so switching cached sprites can't inherit the
// previous pet's animation timeline (same @keyframes name → carried
// currentTime); each pet starts clean.
<SpriteFrame
key={url}
url={url}
sprite={sprite}
animate={animate}
animate={spriteAnimate}
maxSize={size}
animationName={animationName}
restartKey={dragGeneration}
/>
) : detected ? (
<DetectedSpriteFrame detected={detected} animate={animate} maxSize={size} />
<DetectedSpriteFrame detected={detected} animate={spriteAnimate} maxSize={size} />
) : (
// Why: cap explicitly at the pet size — the w-fit/h-fit wrapper is
// fit-content, so max-w/h-full has no fixed box to resolve against
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types'
import { selectPetAnimationName } from './pet-agent-state'
import { nextPetDragAnimation, selectPetAnimationName } from './pet-agent-state'
const NOW = 1_000
const STALE_AFTER_MS = 500
@@ -28,6 +28,8 @@ function select(
entries,
retainedCount: 0,
dragging: false,
dragAnimation: null,
hovering: false,
now: NOW,
staleAfterMs: STALE_AFTER_MS,
...options
@@ -63,7 +65,57 @@ describe('selectPetAnimationName', () => {
expect(select([entry('working'), entry('done', { interrupted: true })])).toBe('running')
})
it('uses jumping while the pet is being dragged', () => {
expect(select([entry('blocked')], { dragging: true })).toBe('jumping')
it('keeps the live agent state while grabbed and held still (no drag direction)', () => {
expect(select([entry('blocked')], { dragging: true })).toBe('waiting')
expect(select([entry('working')], { dragging: true })).toBe('running')
expect(select([], { dragging: true })).toBe('idle')
})
it('runs toward the drag direction while the pet is dragged horizontally', () => {
expect(select([entry('blocked')], { dragging: true, dragAnimation: 'running-right' })).toBe(
'running-right'
)
expect(select([], { dragging: true, dragAnimation: 'running-left' })).toBe('running-left')
})
it('uses jumping while hovered but not dragging', () => {
expect(select([], { hovering: true })).toBe('jumping')
expect(select([entry('working')], { hovering: true })).toBe('jumping')
})
it('prefers the live state over hover while grabbed (drag suppresses the hover jump)', () => {
expect(select([entry('working')], { dragging: true, hovering: true })).toBe('running')
})
})
describe('nextPetDragAnimation', () => {
it('keeps the direction and baseline for sub-threshold horizontal travel', () => {
expect(nextPetDragAnimation(null, 3)).toEqual({ animation: null, accepted: false })
expect(nextPetDragAnimation('running-right', -3)).toEqual({
animation: 'running-right',
accepted: false
})
})
it('picks the horizontal direction at the 4px threshold and advances the baseline', () => {
expect(nextPetDragAnimation(null, 4)).toEqual({
animation: 'running-right',
accepted: true
})
expect(nextPetDragAnimation(null, -4)).toEqual({
animation: 'running-left',
accepted: true
})
})
it('keeps the last direction without advancing when horizontal travel stays under 4px', () => {
// A vertical/near-vertical drag has ~0 horizontal delta: keep the direction
// but do not reset the baseline, so accumulated horizontal travel still adds
// up toward the threshold on a slow diagonal drag.
expect(nextPetDragAnimation('running-left', 0)).toEqual({
animation: 'running-left',
accepted: false
})
expect(nextPetDragAnimation(null, 0)).toEqual({ animation: null, accepted: false })
})
})
@@ -1,27 +1,50 @@
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
export type PetAnimationName = 'idle' | 'running' | 'waiting' | 'review' | 'jumping'
export type PetAnimationName =
| 'idle'
| 'running'
| 'waiting'
| 'review'
| 'jumping'
| 'running-right'
| 'running-left'
export type PetDragAnimation = 'running-right' | 'running-left' | null
// Why: direction tracks horizontal travel only. `accepted` (advance the
// baseline) fires solely on a >=4px horizontal move, so sub-threshold jitter
// and vertical drags keep the last direction without resetting the baseline —
// otherwise a slow diagonal drag would reset before ever crossing 4px.
export function nextPetDragAnimation(
current: PetDragAnimation,
deltaX: number
): { animation: PetDragAnimation; accepted: boolean } {
if (deltaX >= 4) {
return { animation: 'running-right', accepted: true }
}
if (deltaX <= -4) {
return { animation: 'running-left', accepted: true }
}
return { animation: current, accepted: false }
}
export type PetAnimationInput = {
entries: AgentStatusEntry[]
retainedCount: number
dragging: boolean
dragAnimation: PetDragAnimation
hovering: boolean
now: number
staleAfterMs: number
}
export function selectPetAnimationName({
entries,
retainedCount,
dragging,
now,
staleAfterMs
}: PetAnimationInput): PetAnimationName {
if (dragging) {
return 'jumping'
}
function agentStateAnimation(
entries: AgentStatusEntry[],
retainedCount: number,
now: number,
staleAfterMs: number
): PetAnimationName {
let hasWorking = false
let hasDone = false
@@ -47,3 +70,24 @@ export function selectPetAnimationName({
}
return 'idle'
}
export function selectPetAnimationName({
entries,
retainedCount,
dragging,
dragAnimation,
hovering,
now,
staleAfterMs
}: PetAnimationInput): PetAnimationName {
const base = agentStateAnimation(entries, retainedCount, now, staleAfterMs)
// Why: aligned with Codex. A horizontal drag runs toward the pointer,
// grab-and-hold keeps the live agent state, and only a plain hover jumps.
if (dragging) {
return dragAnimation ?? base
}
if (hovering) {
return 'jumping'
}
return base
}
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest'
import { buildSpriteAnimationCss } from './sprite-animation-css'
const BASE = {
keyframesId: 'kf',
frameWidth: 100,
scale: 1,
rowOffsetY: -200
}
describe('buildSpriteAnimationCss', () => {
it('emits one step-end stop per frame for uneven Codex-style pacing', () => {
const { keyframesCss, animationCss } = buildSpriteAnimationCss({
...BASE,
frames: 6,
fps: 8,
frameDurationsMs: [1680, 660, 660, 840, 840, 1920]
})
// Cumulative stops for [1680, 660, 660, 840, 840, 1920] over 6600ms.
expect(keyframesCss).toContain('0% { background-position: 0px -200px; }')
expect(keyframesCss).toContain('25.4545% { background-position: -100px -200px; }')
expect(keyframesCss).toContain('70.9091% { background-position: -500px -200px; }')
expect(keyframesCss).not.toContain(' to {')
expect(animationCss).toBe('pet-kf 6.6s step-end infinite')
})
it('falls back to uniform steps() when durations are absent, invalid, or corrupt', () => {
for (const frameDurationsMs of [
undefined,
[100, 100],
[100, -1, 100],
[100, Number.NaN, 100],
// Untrusted persisted data: non-arrays with a matching `length` must not
// reach .every and throw.
{ length: 3 } as unknown as number[],
'aaa' as unknown as number[]
]) {
const { keyframesCss, animationCss } = buildSpriteAnimationCss({
...BASE,
frames: 3,
fps: 6,
frameDurationsMs
})
expect(keyframesCss).toBe(
'@keyframes pet-kf { from { background-position: 0px -200px; } to { background-position: -300px -200px; } }'
)
expect(animationCss).toBe('pet-kf 0.5s steps(3) infinite')
}
})
it('degrades to uniform pacing rather than dropping a frame that rounds away', () => {
// A sub-precision frame among a ~minute total loses its own stop: a leading
// one collapses two stops to 0%, a trailing one rounds the final stop to
// 100% (no interval before the loop). Either way, degrade to steps() so
// every frame still renders instead of letting one vanish.
for (const frameDurationsMs of [
[0.001, 60_000],
[60_000, 0.001]
]) {
const { keyframesCss, animationCss } = buildSpriteAnimationCss({
...BASE,
frames: 2,
fps: 8,
frameDurationsMs
})
expect(keyframesCss).not.toContain('step')
expect(keyframesCss).toContain(' to {')
expect(animationCss).toContain('steps(2)')
}
})
it('renders a genuinely short-but-representable frame as its own stop', () => {
const { keyframesCss } = buildSpriteAnimationCss({
...BASE,
frames: 2,
fps: 8,
frameDurationsMs: [10, 990]
})
expect(keyframesCss).toContain('0% { background-position: 0px -200px; }')
expect(keyframesCss).toContain('1% { background-position: -100px -200px; }')
})
})
@@ -0,0 +1,99 @@
// Sprite-sheet keyframe CSS for the pet overlay. Kept as a pure module so the
// pacing math is unit-testable without mounting the overlay or a DOM.
export type SpriteAnimationCss = {
keyframesCss: string
animationCss: string
}
export type SpriteAnimationCssInput = {
// Sanitized `@keyframes` identifier; folded with the restart key by the caller.
keyframesId: string
frames: number
fps: number
frameWidth: number
scale: number
// Vertical offset selecting the animation's row on the sheet.
rowOffsetY: number
// Per-frame holds in ms; uneven pacing renders when they pass validation.
frameDurationsMs: number[] | undefined
}
// Why: sprite keyframes are runtime CSS, not user-visible copy; translated CSS
// keywords make the browser discard the animation, so keep them out of i18n.
export function buildSpriteAnimationCss({
keyframesId,
frames,
fps,
frameWidth,
scale,
rowOffsetY,
frameDurationsMs
}: SpriteAnimationCssInput): SpriteAnimationCss {
const name = `pet-${keyframesId}`
const durations = validFrameDurations(frameDurationsMs, frames)
if (durations) {
const totalMs = durations.reduce((sum, ms) => sum + ms, 0)
// Why: Codex pets hold frames unevenly (idle rests ~1.9s on its last frame).
// steps() can't express that, so emit one step-end stop per frame.
const stops = stepEndStops(durations, totalMs, frameWidth, scale, rowOffsetY)
if (stops) {
return {
keyframesCss: `@keyframes ${name} { ${stops.join(' ')} }`,
animationCss: `${name} ${totalMs / 1000}s step-end infinite`
}
}
}
// Uniform sheet fps: one steps() run across the row.
const duration = Math.max(0.1, frames / Math.max(0.1, fps))
const endX = -(frames * frameWidth * scale)
return {
keyframesCss: `@keyframes ${name} { from { background-position: 0px ${rowOffsetY}px; } to { background-position: ${endX}px ${rowOffsetY}px; } }`,
animationCss: `${name} ${duration}s steps(${frames}) infinite`
}
}
function validFrameDurations(
frameDurationsMs: number[] | undefined,
frames: number
): number[] | null {
// Why: Array.isArray, not a truthiness check — persisted/RPC-synced sprites
// are untrusted, so a corrupt non-array value (e.g. { length: 6 }) must fail
// here rather than throw on .length/.every during render.
if (
Array.isArray(frameDurationsMs) &&
frameDurationsMs.length === frames &&
frameDurationsMs.every((ms) => Number.isFinite(ms) && ms > 0)
) {
return frameDurationsMs
}
return null
}
// Cumulative step-end stops, one per frame. Returns null (→ uniform fallback)
// when a frame is too short to survive our 4-decimal precision: either two
// stops collapse to the same percentage, or the final stop rounds to 100% and
// so has no interval before the loop. Either way the frame would vanish, so we
// degrade to uniform pacing rather than silently drop it.
function stepEndStops(
durations: number[],
totalMs: number,
frameWidth: number,
scale: number,
rowOffsetY: number
): string[] | null {
const stops: string[] = []
let elapsedMs = 0
let previousPct = -1
for (let index = 0; index < durations.length; index++) {
const pct = +((elapsedMs / totalMs) * 100).toFixed(4)
if (pct <= previousPct || pct >= 100) {
return null
}
previousPct = pct
const x = -(index * frameWidth * scale)
stops.push(`${pct}% { background-position: ${x}px ${rowOffsetY}px; }`)
elapsedMs += durations[index]
}
return stops
}
@@ -0,0 +1,142 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { usePetPointerInteraction } from './usePetPointerInteraction'
type HandlerName = keyof ReturnType<typeof usePetPointerInteraction>['handlers']
function fakeTarget(): {
setPointerCapture: (id: number) => void
hasPointerCapture: (id: number) => boolean
releasePointerCapture: (id: number) => void
} {
const captured = new Set<number>()
return {
setPointerCapture: (id) => void captured.add(id),
hasPointerCapture: (id) => captured.has(id),
releasePointerCapture: (id) => void captured.delete(id)
}
}
function setup() {
const moveTo = vi.fn()
const target = fakeTarget()
const { result } = renderHook(() => usePetPointerInteraction({ x: 0, y: 0 }, moveTo))
const event = (props: {
clientX?: number
clientY?: number
pointerId?: number
button?: number
}) =>
({
button: 0,
pointerId: 1,
clientX: 0,
clientY: 0,
currentTarget: target,
preventDefault: () => {},
...props
}) as unknown as Parameters<
ReturnType<typeof usePetPointerInteraction>['handlers']['onPointerDown']
>[0]
const fire = (name: HandlerName, props: Parameters<typeof event>[0] = {}): void => {
act(() => result.current.handlers[name](event(props)))
}
return { result, moveTo, fire, event }
}
describe('usePetPointerInteraction', () => {
it('starts a drag and bumps the restart generation on a primary grab', () => {
const { result, fire } = setup()
fire('onPointerDown', { clientX: 50, clientY: 50 })
expect(result.current.dragging).toBe(true)
expect(result.current.dragAnimation).toBe(null)
expect(result.current.dragGeneration).toBe(1)
})
it('runs toward the horizontal drag direction past the 4px deadzone', () => {
const { result, fire, moveTo } = setup()
fire('onPointerDown', { clientX: 50, clientY: 50 })
fire('onPointerMove', { clientX: 58, clientY: 50 })
expect(result.current.dragAnimation).toBe('running-right')
expect(moveTo).toHaveBeenLastCalledWith({ x: 8, y: 0 })
fire('onPointerMove', { clientX: 40, clientY: 50 })
expect(result.current.dragAnimation).toBe('running-left')
})
it('ignores sub-deadzone jitter and keeps the last direction on vertical moves', () => {
const { result, fire } = setup()
fire('onPointerDown', { clientX: 50, clientY: 50 })
fire('onPointerMove', { clientX: 52, clientY: 52 })
expect(result.current.dragAnimation).toBe(null)
fire('onPointerMove', { clientX: 58, clientY: 52 })
expect(result.current.dragAnimation).toBe('running-right')
fire('onPointerMove', { clientX: 58, clientY: 100 })
expect(result.current.dragAnimation).toBe('running-right')
})
it('accumulates horizontal travel across a slow diagonal drag', () => {
// Regression: the baseline advances only on an accepted direction, so a
// sequence of sub-4px diagonal moves still crosses the threshold instead of
// resetting each time and never triggering.
const { result, fire } = setup()
fire('onPointerDown', { clientX: 50, clientY: 50 })
fire('onPointerMove', { clientX: 53, clientY: 55 })
expect(result.current.dragAnimation).toBe(null)
fire('onPointerMove', { clientX: 56, clientY: 60 })
expect(result.current.dragAnimation).toBe('running-right')
})
it('keeps the latest horizontal direction when two moves land in one commit', () => {
// Regression: the direction lives in a ref, not the state closure, so a
// vertical move batched right after a horizontal one cannot resurrect the
// stale prior direction.
const { result } = renderHook(() => usePetPointerInteraction({ x: 0, y: 0 }, vi.fn()))
const target = fakeTarget()
const ev = (clientX: number, clientY: number) =>
({
button: 0,
pointerId: 1,
clientX,
clientY,
currentTarget: target,
preventDefault: () => {}
}) as never
act(() => result.current.handlers.onPointerDown(ev(50, 50)))
act(() => {
result.current.handlers.onPointerMove(ev(40, 50)) // left
result.current.handlers.onPointerMove(ev(40, 90)) // vertical from the new sample
})
expect(result.current.dragAnimation).toBe('running-left')
})
it('scopes the drag to the owning pointer and ignores a second touch', () => {
const { result, fire, moveTo } = setup()
fire('onPointerDown', { clientX: 50, clientY: 50, pointerId: 1 })
// A second pointer must not hijack the drag or mint a new restart.
fire('onPointerDown', { clientX: 50, clientY: 50, pointerId: 2 })
expect(result.current.dragGeneration).toBe(1)
fire('onPointerMove', { clientX: 90, clientY: 50, pointerId: 2 })
expect(moveTo).not.toHaveBeenCalled()
// Releasing the non-owning pointer leaves the drag alive.
fire('onPointerUp', { pointerId: 2 })
expect(result.current.dragging).toBe(true)
// The owning pointer ends it.
fire('onPointerUp', { pointerId: 1 })
expect(result.current.dragging).toBe(false)
expect(result.current.dragAnimation).toBe(null)
})
it('ends the drag when pointer capture is lost', () => {
const { result, fire } = setup()
fire('onPointerDown', { clientX: 50, clientY: 50 })
fire('onLostPointerCapture', { pointerId: 1 })
expect(result.current.dragging).toBe(false)
expect(result.current.dragAnimation).toBe(null)
})
})
@@ -0,0 +1,119 @@
import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { nextPetDragAnimation, type PetDragAnimation } from './pet-agent-state'
type Point = { x: number; y: number }
export type PetPointerInteraction = {
dragging: boolean
dragAnimation: PetDragAnimation
hovering: boolean
// Why: bumped on grab so the sprite restarts from frame 0, aligned with the
// Codex mascot.
dragGeneration: number
handlers: {
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void
onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void
onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void
onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void
onLostPointerCapture: (event: ReactPointerEvent<HTMLDivElement>) => void
onPointerEnter: () => void
onPointerLeave: () => void
}
}
// Drag/hover state for the pet overlay. `position` is the overlay's current
// top-left corner and `moveTo` receives the unclamped position the drag wants.
export function usePetPointerInteraction(
position: Point,
moveTo: (next: Point) => void
): PetPointerInteraction {
const [dragging, setDragging] = useState(false)
const [dragAnimation, setDragAnimation] = useState<PetDragAnimation>(null)
const [hovering, setHovering] = useState(false)
const [dragGeneration, setDragGeneration] = useState(0)
const dragOffsetRef = useRef<Point>({ x: 0, y: 0 })
// Why: horizontal baseline for the drag-direction hysteresis, advanced only on
// an accepted direction. Kept separate from dragOffsetRef (position math).
const dragBaselineXRef = useRef(0)
// Why: direction and the owning pointer are read+written inside pointer
// handlers, so keep them in refs immune to React's render batching. Reading
// the direction from state would let two coalesced moves in one commit
// resurrect a stale direction; `dragAnimation` state exists only to render.
const dragDirectionRef = useRef<PetDragAnimation>(null)
const activePointerRef = useRef<number | null>(null)
// Why: setPointerCapture routes subsequent pointer events to this element
// even when the cursor leaves the OS window, so dragging can't get stuck in
// the "true" state if the user releases outside the app.
const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>): void => {
// Why: primary button only, and one pointer owns the drag — a second touch
// must not hijack the anchors mid-drag.
if (event.button !== 0 || activePointerRef.current !== null) {
return
}
activePointerRef.current = event.pointerId
dragOffsetRef.current = {
x: event.clientX - position.x,
y: event.clientY - position.y
}
dragBaselineXRef.current = event.clientX
dragDirectionRef.current = null
event.currentTarget.setPointerCapture(event.pointerId)
setDragging(true)
setDragAnimation(null)
setDragGeneration((generation) => generation + 1)
event.preventDefault()
}
const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>): void => {
if (event.pointerId !== activePointerRef.current) {
return
}
const next = nextPetDragAnimation(
dragDirectionRef.current,
event.clientX - dragBaselineXRef.current
)
if (next.accepted) {
dragBaselineXRef.current = event.clientX
if (next.animation !== dragDirectionRef.current) {
dragDirectionRef.current = next.animation
setDragAnimation(next.animation)
}
}
moveTo({
x: event.clientX - dragOffsetRef.current.x,
y: event.clientY - dragOffsetRef.current.y
})
}
const endDrag = (event: ReactPointerEvent<HTMLDivElement>): void => {
if (event.pointerId !== activePointerRef.current) {
return
}
activePointerRef.current = null
dragDirectionRef.current = null
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
setDragging(false)
setDragAnimation(null)
}
return {
dragging,
dragAnimation,
hovering,
dragGeneration,
handlers: {
onPointerDown,
onPointerMove,
onPointerUp: endDrag,
onPointerCancel: endDrag,
// Why: capture can be revoked without a pointerup (e.g. the element loses
// it); treat that as the end of the drag so it can't wedge on.
onLostPointerCapture: endDrag,
onPointerEnter: () => setHovering(true),
onPointerLeave: () => setHovering(false)
}
}
}
+9 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { CustomPet } from '../../../../shared/types'
import { applyCodexSpriteTimingDefaults } from '../../../../shared/codex-pet-sprite-defaults'
import { useAppStore } from '../../store'
import { BUNDLED_PET, findBundledPet, isBundledPetId } from './pet-models'
import {
@@ -114,7 +115,14 @@ export function usePetUrl(): ResolvedPet {
customMeta.sprite.frameHeight > 0 &&
customMeta.sprite.fps > 0
) {
return { url: customUrl, ready: true, sprite: customMeta.sprite, detected: null }
// Why: sprites persisted before per-frame durations existed pace ~9x too
// fast. Upgrade the legacy Codex fingerprint without forcing a re-import.
return {
url: customUrl,
ready: true,
sprite: applyCodexSpriteTimingDefaults(customMeta.sprite),
detected: null
}
}
const detected = detectedSpriteCache.get(customMeta.id)
if (detected) {
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import {
CODEX_PET_ANIMATIONS,
CODEX_PET_DEFAULT_COLUMNS,
CODEX_PET_DEFAULT_FPS,
applyCodexSpriteTimingDefaults,
type CustomPetSprite
} from './codex-pet-sprite-defaults'
function legacyCodexSprite(): CustomPetSprite {
return {
frameWidth: 192,
frameHeight: 208,
columns: 8,
rows: 9,
sheetWidth: 1536,
sheetHeight: 1872,
fps: CODEX_PET_DEFAULT_FPS,
defaultAnimation: 'idle',
// Pre-durations builds baked exactly this shape from the old defaults.
animations: Object.fromEntries(
Object.entries(CODEX_PET_ANIMATIONS).map(([name, anim]) => [
name,
{ row: anim.row, frames: anim.frames }
])
)
}
}
describe('CODEX_PET_ANIMATIONS', () => {
it('ships per-frame durations matching each frame count', () => {
for (const [name, anim] of Object.entries(CODEX_PET_ANIMATIONS)) {
expect(anim.frameDurationsMs, name).toBeDefined()
expect(anim.frameDurationsMs, name).toHaveLength(anim.frames)
for (const ms of anim.frameDurationsMs ?? []) {
expect(ms, name).toBeGreaterThan(0)
}
}
})
it('keeps the Codex ambient idle cycle at 6.6 seconds', () => {
const total = (CODEX_PET_ANIMATIONS.idle.frameDurationsMs ?? []).reduce(
(sum, ms) => sum + ms,
0
)
expect(total).toBe(6600)
})
})
describe('applyCodexSpriteTimingDefaults', () => {
it('upgrades the legacy baked-default fingerprint with durations', () => {
const upgraded = applyCodexSpriteTimingDefaults(legacyCodexSprite())
expect(upgraded.animations).toEqual(CODEX_PET_ANIMATIONS)
expect(upgraded.animations?.idle.frameDurationsMs).toEqual([1680, 660, 660, 840, 840, 1920])
})
it('leaves hand-authored animation layouts untouched', () => {
const sprite = legacyCodexSprite()
sprite.animations = { blink: { row: 0, frames: 2 } }
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
})
it('leaves sprites with a non-default fps untouched', () => {
const sprite = { ...legacyCodexSprite(), fps: 4 }
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
})
it('leaves sprites that already declare durations untouched', () => {
const sprite = legacyCodexSprite()
sprite.animations = {
...sprite.animations,
idle: { row: 0, frames: 6, frameDurationsMs: [100, 100, 100, 100, 100, 100] }
}
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
})
it('leaves sprites without animations untouched', () => {
const sprite = { ...legacyCodexSprite(), animations: undefined }
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
})
it('leaves a hand-authored 8 fps sheet that only reuses the Codex row map untouched', () => {
// Same fps + nine Codex rows, but non-Codex frame geometry — must not retime.
const sprite: CustomPetSprite = {
...legacyCodexSprite(),
frameWidth: 32,
frameHeight: 32,
sheetWidth: CODEX_PET_DEFAULT_COLUMNS * 32,
sheetHeight: 9 * 32
}
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
})
it('leaves a sheet with a non-idle default animation untouched', () => {
const sprite = { ...legacyCodexSprite(), defaultAnimation: 'running' }
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
})
it('leaves a sheet with a non-Codex column count untouched', () => {
const sprite = { ...legacyCodexSprite(), columns: CODEX_PET_DEFAULT_COLUMNS + 2 }
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
})
it('does not throw on a corrupted animation entry that matches the outer fingerprint', () => {
const sprite = legacyCodexSprite()
// Persisted data is untrusted: a null value under a matching key must be a
// non-match, not a crash.
sprite.animations = { ...sprite.animations, idle: null as never }
expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite)
})
})
+100
View File
@@ -0,0 +1,100 @@
import type { CustomPet, SpriteAnimation } from './types'
/** Codex pet sprite layout and pacing, mirroring the tables in Codex CLI's
* `tui/src/pets`. Shared because main bakes these into imported bundles and
* the renderer needs the same fingerprint to upgrade legacy persisted pets. */
export const CODEX_PET_SPRITESHEET_PATH = 'spritesheet.webp'
export const CODEX_PET_FRAME = { width: 192, height: 208 } as const
export const CODEX_PET_DEFAULT_ANIMATION = 'idle'
export const CODEX_PET_DEFAULT_FPS = 8
// Codex sheets are 8 columns wide (its widest rows run 8 frames).
export const CODEX_PET_DEFAULT_COLUMNS = 8
// Codex's `app_state_animation`: every non-final frame holds `frameMs`, the last
// holds `finalMs`. Values below mirror `codex-rs/tui/src/pets/model.rs` exactly.
function appStateDurations(frames: number, frameMs: number, finalMs: number): number[] {
return Array.from({ length: frames }, (_, i) => (i === frames - 1 ? finalMs : frameMs))
}
export const CODEX_PET_ANIMATIONS: Record<string, SpriteAnimation> = {
// Idle is the ambient loop: long holds on the bookend frames, 6.6s cycle.
idle: { row: 0, frames: 6, frameDurationsMs: [1680, 660, 660, 840, 840, 1920] },
'running-right': { row: 1, frames: 8, frameDurationsMs: appStateDurations(8, 120, 220) },
'running-left': { row: 2, frames: 8, frameDurationsMs: appStateDurations(8, 120, 220) },
waving: { row: 3, frames: 4, frameDurationsMs: appStateDurations(4, 140, 280) },
jumping: { row: 4, frames: 5, frameDurationsMs: appStateDurations(5, 140, 280) },
failed: { row: 5, frames: 8, frameDurationsMs: appStateDurations(8, 140, 240) },
waiting: { row: 6, frames: 6, frameDurationsMs: appStateDurations(6, 150, 260) },
running: { row: 7, frames: 6, frameDurationsMs: appStateDurations(6, 120, 220) },
review: { row: 8, frames: 6, frameDurationsMs: appStateDurations(6, 150, 280) }
}
/** The Codex row layout paced uniformly at `fps` (each frame held 1000/fps ms).
* A bundle that pins an explicit fps gets its pacing baked as durations, so the
* fps is honored (SpriteFrame prefers durations over the sheet fps) and the
* sprite carries durations — which keeps it distinct from the no-durations
* legacy fingerprint below, so the render-time upgrade never retimes it. */
export function codexAnimationsAtUniformFps(fps: number): Record<string, SpriteAnimation> {
const frameMs = 1000 / fps
return Object.fromEntries(
Object.entries(CODEX_PET_ANIMATIONS).map(([name, { row, frames }]) => [
name,
{ row, frames, frameDurationsMs: Array.from({ length: frames }, () => frameMs) }
])
)
}
export type CustomPetSprite = NonNullable<CustomPet['sprite']>
/** The exact sprite an old Orca build baked for an imported Codex bundle:
* 192x208 frames on an 8-wide sheet at the flat 8 fps rate, idle default, and
* the nine Codex rows carrying no per-frame durations. Matching the full
* geometry (not just the row map) keeps a hand-authored 8 fps sheet that merely
* reuses those rows from being silently retimed. A pet deliberately built on
* the Codex layout that wants to keep uniform pacing opts out by declaring any
* frameDurationsMs of its own.
*
* Rows/sheet height are intentionally excluded so v2 (11-row) Codex sheets,
* which still bake these nine animations, upgrade too. */
function isLegacyCodexSprite(sprite: CustomPetSprite): boolean {
const animations = sprite.animations
if (
!animations ||
sprite.fps !== CODEX_PET_DEFAULT_FPS ||
sprite.frameWidth !== CODEX_PET_FRAME.width ||
sprite.frameHeight !== CODEX_PET_FRAME.height ||
sprite.columns !== CODEX_PET_DEFAULT_COLUMNS ||
sprite.defaultAnimation !== CODEX_PET_DEFAULT_ANIMATION
) {
return false
}
const names = Object.keys(animations)
if (names.length !== Object.keys(CODEX_PET_ANIMATIONS).length) {
return false
}
return names.every((name) => {
const anim = animations[name]
const preset = CODEX_PET_ANIMATIONS[name]
// Why: `anim` is untrusted persisted data — guard it so a corrupted entry
// (e.g. a null value) is a non-match rather than a render-time throw.
return (
!!preset &&
!!anim &&
anim.row === preset.row &&
anim.frames === preset.frames &&
anim.frameDurationsMs === undefined
)
})
}
/** Pets imported before per-frame durations existed persist the legacy Codex
* fingerprint at the flat 8 fps sheet rate, so swap in the current defaults for
* those. Anything else passes through untouched. Render-time only — persisted
* data is never rewritten, so the upgrade is reversible on downgrade. */
export function applyCodexSpriteTimingDefaults(sprite: CustomPetSprite): CustomPetSprite {
if (!isLegacyCodexSprite(sprite)) {
return sprite
}
return { ...sprite, animations: { ...CODEX_PET_ANIMATIONS } }
}
+2
View File
@@ -3602,6 +3602,8 @@ export type CustomPet = {
export type SpriteAnimation = {
row: number
frames: number
/** Per-frame holds in ms (length === frames). Absent means uniform sheet fps. */
frameDurationsMs?: number[]
}
export type PersistedTrustedOrcaHookEntry = {