From dc17e7b0c35579cd8e8e49d3190ef1150782b882 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Thu, 12 Mar 2026 13:23:33 -0700 Subject: [PATCH 1/8] factory|feat: Add beacon bright shade to orchestrator palette --- .../scripts/sprites/__tests__/palettes.test.ts | 17 +++++++++++++++++ .../sprites/__tests__/svg-renderer.test.ts | 4 ++-- packages/factory/scripts/sprites/palettes.ts | 6 ++++-- 3 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 packages/factory/scripts/sprites/__tests__/palettes.test.ts diff --git a/packages/factory/scripts/sprites/__tests__/palettes.test.ts b/packages/factory/scripts/sprites/__tests__/palettes.test.ts new file mode 100644 index 00000000..6dcfbfc7 --- /dev/null +++ b/packages/factory/scripts/sprites/__tests__/palettes.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { ORCHESTRATOR_PALETTE, SUBAGENT_PALETTE } from '../palettes.ts'; + +describe('ORCHESTRATOR_PALETTE', () => { + it('has 7 entries (transparent + 5 shades + beacon bright)', () => { + expect(ORCHESTRATOR_PALETTE).toHaveLength(7); + expect(ORCHESTRATOR_PALETTE[0]).toBe(''); + expect(ORCHESTRATOR_PALETTE[6]).toBe('#fff5c0'); + }); +}); + +describe('SUBAGENT_PALETTE', () => { + it('has 6 entries (transparent + 5 shades) and is unchanged', () => { + expect(SUBAGENT_PALETTE).toHaveLength(6); + }); +}); diff --git a/packages/factory/scripts/sprites/__tests__/svg-renderer.test.ts b/packages/factory/scripts/sprites/__tests__/svg-renderer.test.ts index ebc98694..2134cd7a 100644 --- a/packages/factory/scripts/sprites/__tests__/svg-renderer.test.ts +++ b/packages/factory/scripts/sprites/__tests__/svg-renderer.test.ts @@ -276,7 +276,7 @@ describe('pose data integrity', () => { } }); - it('all orchestrator pixel values are within the valid palette range [0, 5]', () => { + it('all orchestrator pixel values are within the valid palette range [0, 6]', () => { for (const [poseIndex, pose] of ORCHESTRATOR_POSES.entries()) { for (const [partIndex, part] of pose.entries()) { for (const [rowIndex, row] of part.pixels.entries()) { @@ -288,7 +288,7 @@ describe('pose data integrity', () => { expect( value, `orchestrator pose ${poseIndex}, part ${partIndex}, row ${rowIndex}, col ${colIndex}: value ${value} out of range`, - ).toBeLessThanOrEqual(5); + ).toBeLessThanOrEqual(6); } } } diff --git a/packages/factory/scripts/sprites/palettes.ts b/packages/factory/scripts/sprites/palettes.ts index 0feba87b..8aaddd70 100644 --- a/packages/factory/scripts/sprites/palettes.ts +++ b/packages/factory/scripts/sprites/palettes.ts @@ -85,10 +85,12 @@ export const SUBAGENT_PALETTE: Palette = generatePalette( SUBAGENT_HSL.maxLightness, ); -/** Gold command-unit palette for the orchestrator robot. */ -export const ORCHESTRATOR_PALETTE: Palette = generatePalette( +const basePalette = generatePalette( ORCHESTRATOR_HSL.hue, ORCHESTRATOR_HSL.saturation, ORCHESTRATOR_HSL.minLightness, ORCHESTRATOR_HSL.maxLightness, ); + +/** Gold command-unit palette for the orchestrator robot, with beacon bright at index 6. */ +export const ORCHESTRATOR_PALETTE: Palette = [...basePalette, '#fff5c0'] as Palette; From 294df9fbcc843b93cb5b58cd699ce9110996a98e Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Thu, 12 Mar 2026 13:34:27 -0700 Subject: [PATCH 2/8] factory|feat: Rewrite orchestrator body with tank treads and beacon lamp --- .../__tests__/orchestrator-poses.test.ts | 43 ++ .../scripts/sprites/orchestrator-poses.ts | 371 +++++++----------- .../catwalk/sprites/assets/orchestrator.svg | 2 +- 3 files changed, 177 insertions(+), 239 deletions(-) create mode 100644 packages/factory/scripts/sprites/__tests__/orchestrator-poses.test.ts diff --git a/packages/factory/scripts/sprites/__tests__/orchestrator-poses.test.ts b/packages/factory/scripts/sprites/__tests__/orchestrator-poses.test.ts new file mode 100644 index 00000000..74ee8d57 --- /dev/null +++ b/packages/factory/scripts/sprites/__tests__/orchestrator-poses.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { ORCHESTRATOR_POSES } from '../orchestrator-poses.ts'; + +describe('ORCHESTRATOR_POSES', () => { + it('exports exactly 12 poses', () => { + expect(ORCHESTRATOR_POSES).toHaveLength(12); + }); + + it('every pixel index is within 0-6 (palette range including beacon bright)', () => { + for (const [poseIndex, pose] of ORCHESTRATOR_POSES.entries()) { + for (const [partIndex, part] of pose.entries()) { + for (const [rowIndex, row] of part.pixels.entries()) { + for (const [colIndex, value] of row.entries()) { + expect( + value, + `pose ${poseIndex}, part ${partIndex}, row ${rowIndex}, col ${colIndex}: value ${value} out of range`, + ).toBeGreaterThanOrEqual(0); + expect( + value, + `pose ${poseIndex}, part ${partIndex}, row ${rowIndex}, col ${colIndex}: value ${value} out of range`, + ).toBeLessThanOrEqual(6); + } + } + } + } + }); + + it('every pose has treads at offsetY 26 (bottom-anchored)', () => { + for (const [index, pose] of ORCHESTRATOR_POSES.entries()) { + const treadPart = pose[0]; + expect(treadPart?.offsetY, `pose ${index} treads not at row 26`).toBe(26); + } + }); + + it('beacon on frames use palette index 6', () => { + // Frame 4 (Working 1) should have beacon with index 6 pixels + const beaconPart = ORCHESTRATOR_POSES[4]?.[5]; // last part = beacon + expect(beaconPart).toBeDefined(); + const hasIndex6 = beaconPart!.pixels.some((row) => row.some((value) => value === 6)); + expect(hasIndex6).toBe(true); + }); +}); diff --git a/packages/factory/scripts/sprites/orchestrator-poses.ts b/packages/factory/scripts/sprites/orchestrator-poses.ts index a2b03d5c..030f7856 100644 --- a/packages/factory/scripts/sprites/orchestrator-poses.ts +++ b/packages/factory/scripts/sprites/orchestrator-poses.ts @@ -1,143 +1,142 @@ import type { BodyPart, Pose } from './svg-renderer.ts'; // ── Body parts ────────────────────────────────────────────────────────────── -// Target: ~28px total height (88% of 32), bottom-aligned with feet at row ~30. -// Layout (top to bottom): Antenna rows 2-4, Head rows 5-12, Torso rows 13-23, Legs rows 24-30. +// Target: ~30px total height, bottom-aligned with treads at row 26. +// Layout (top to bottom): Beacon rows 1-2, Head rows 3-10, Torso rows 11-24, Treads rows 26-29. +// Part composition order per pose (painter's algorithm): treads, torso, arms, head, beacon. -// 1x3 antenna above head center -const ANTENNA: BodyPart = { - pixels: [[5], [4], [3]], - offsetX: 15, - offsetY: 2, +// 3×2 beacon lamp (on — bright) +const BEACON_ON: BodyPart = { + pixels: [ + [6, 6, 6], + [5, 4, 5], + ], + offsetX: 14, + offsetY: 1, }; -// 12x8 wider head with visor strip +// 3×2 beacon lamp (off — dark) +const BEACON_OFF: BodyPart = { + pixels: [ + [3, 2, 3], + [2, 1, 2], + ], + offsetX: 14, + offsetY: 1, +}; + +// 3×2 beacon lamp (idle — very dim) +const BEACON_IDLE: BodyPart = { + pixels: [ + [2, 1, 2], + [1, 1, 1], + ], + offsetX: 14, + offsetY: 1, +}; + +// 12×8 wider head with visor strip const HEAD: BodyPart = { pixels: [ [5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3], [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1], [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1], - [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 1], - [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 1], + [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 1], // visor row + [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 1], // visor row [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1], [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], ], offsetX: 10, - offsetY: 5, + offsetY: 3, }; -// 14x11 broader torso with shading -const TORSO: BodyPart = { - pixels: [ - [5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3], - [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1], - [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1], - [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1], - [5, 3, 3, 4, 4, 5, 5, 5, 4, 4, 3, 3, 2, 1], - [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1], - [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1], - [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1], - [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1], - [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1], - [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], - ], - offsetX: 9, - offsetY: 13, +// Head shifted 1px down (slouching/concerned pose) +const HEAD_SLOUCH: BodyPart = { + ...HEAD, + offsetY: HEAD.offsetY + 1, }; -// 2x7 arm hanging at side (left) -const ARM_LEFT_DOWN: BodyPart = { +// Head with dimmed visor (index 3 instead of 5) +const HEAD_VISOR_DIM: BodyPart = { pixels: [ - [4, 3], - [3, 2], - [3, 2], - [3, 2], - [3, 2], - [3, 2], - [1, 1], + [5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3], + [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1], + [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1], + [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1], // dimmed visor + [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1], // dimmed visor + [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1], + [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], ], - offsetX: 7, - offsetY: 14, + offsetX: 10, + offsetY: 3, }; -// 2x7 arm hanging at side (right) -const ARM_RIGHT_DOWN: BodyPart = { +// Head with bright visor (index 6 instead of 5) +const HEAD_VISOR_BRIGHT: BodyPart = { pixels: [ - [3, 4], - [2, 3], - [2, 3], - [2, 3], - [2, 3], - [2, 3], - [1, 1], + [5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3], + [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1], + [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1], + [5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 1], // bright visor + [5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 1], // bright visor + [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1], + [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], ], - offsetX: 23, - offsetY: 14, + offsetX: 10, + offsetY: 3, }; -// 2x7 arm raised partway (left) -const ARM_LEFT_UP: BodyPart = { +// 16×14 broader torso with shading +const TORSO: BodyPart = { pixels: [ - [4, 3], - [3, 2], - [3, 2], - [3, 2], - [3, 2], - [3, 2], - [1, 1], + [5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3], + [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1], + [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1], + [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1], + [5, 3, 3, 4, 4, 5, 5, 5, 5, 5, 4, 4, 3, 3, 2, 1], // chest panel band + [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1], + [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1], + [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1], + [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1], + [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1], + [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1], + [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1], + [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], ], - offsetX: 7, - offsetY: 9, + offsetX: 8, + offsetY: 11, }; -// 2x7 arm raised partway (right) -const ARM_RIGHT_UP: BodyPart = { +// 18×4 tank treads frame A +const TREADS_A: BodyPart = { pixels: [ - [3, 4], - [2, 3], - [2, 3], - [2, 3], - [2, 3], - [2, 3], - [1, 1], - ], - offsetX: 23, - offsetY: 9, -}; - -// 2x7 arm raised high (left, for celebrating) -const ARM_LEFT_RAISED_HIGH: BodyPart = { - pixels: [ - [4, 3], - [3, 2], - [3, 2], - [3, 2], - [3, 2], - [3, 2], - [1, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + [1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1], + [1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 1], // alternating pattern + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], ], offsetX: 7, - offsetY: 4, + offsetY: 26, }; -// 2x7 arm raised high (right, for celebrating) -const ARM_RIGHT_RAISED_HIGH: BodyPart = { +// 18×4 tank treads frame B — row 2 shifted one position +const TREADS_B: BodyPart = { pixels: [ - [3, 4], - [2, 3], - [2, 3], - [2, 3], - [2, 3], - [2, 3], - [1, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + [1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1], + [1, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1], // shifted pattern + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], ], - offsetX: 23, - offsetY: 4, + offsetX: 7, + offsetY: 26, }; -// 2x7 arm raised high and splayed out (left, for V-shape celebrating) -const ARM_LEFT_SPLAYED: BodyPart = { +// 2×8 arm at side (left) +const ARM_LEFT: BodyPart = { pixels: [ [4, 3], [3, 2], @@ -145,14 +144,15 @@ const ARM_LEFT_SPLAYED: BodyPart = { [3, 2], [3, 2], [3, 2], + [3, 2], [1, 1], ], - offsetX: 5, - offsetY: 4, + offsetX: 6, + offsetY: 12, }; -// 2x7 arm raised high and splayed out (right, for V-shape celebrating) -const ARM_RIGHT_SPLAYED: BodyPart = { +// 2×8 arm at side (right) +const ARM_RIGHT: BodyPart = { pixels: [ [3, 4], [2, 3], @@ -160,160 +160,55 @@ const ARM_RIGHT_SPLAYED: BodyPart = { [2, 3], [2, 3], [2, 3], - [1, 1], - ], - offsetX: 25, - offsetY: 4, -}; - -// 7x2 arm extended horizontally (left) -const ARM_LEFT_EXTENDED: BodyPart = { - pixels: [ - [4, 3, 3, 3, 3, 3, 1], - [1, 2, 2, 2, 2, 2, 1], - ], - offsetX: 2, - offsetY: 15, -}; - -// 7x2 arm extended horizontally (right) -const ARM_RIGHT_EXTENDED: BodyPart = { - pixels: [ - [1, 3, 3, 3, 3, 3, 4], - [1, 2, 2, 2, 2, 2, 1], - ], - offsetX: 23, - offsetY: 15, -}; - -// Legs standing straight (pair), wider stance for orchestrator -const LEG_STAND: BodyPart = { - pixels: [ - [0, 0, 3, 0, 0, 0, 0, 0, 0, 3, 0, 0], - [0, 0, 3, 0, 0, 0, 0, 0, 0, 3, 0, 0], - [0, 0, 3, 0, 0, 0, 0, 0, 0, 3, 0, 0], - [0, 0, 2, 0, 0, 0, 0, 0, 0, 2, 0, 0], - [0, 0, 2, 0, 0, 0, 0, 0, 0, 2, 0, 0], - [0, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0], - [1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1], - ], - offsetX: 10, - offsetY: 24, -}; - -// Legs with left leg forward, right leg back (walking mid-stride) -const LEG_FORWARD: BodyPart = { - pixels: [ - [0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 3, 0], - [0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 3, 0], - [0, 3, 2, 0, 0, 0, 0, 0, 0, 0, 2, 0], - [0, 2, 2, 0, 0, 0, 0, 0, 0, 0, 2, 0], - [0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 0], - [1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 0], - [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1], - ], - offsetX: 9, - offsetY: 24, -}; - -// Legs with slight outward splay (for resting) -const LEG_RELAXED: BodyPart = { - pixels: [ - [0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0], - [0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0], - [3, 2, 0, 0, 0, 0, 0, 0, 0, 0, 2, 3], - [2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2], - [2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2], - [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1], - [1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1], - ], - offsetX: 10, - offsetY: 24, -}; - -// Arms pulled in close (for concerned) -const ARM_LEFT_PULLED_IN: BodyPart = { - pixels: [ - [4, 3], - [3, 2], - [3, 2], - [3, 2], - [3, 2], - [1, 1], - ], - offsetX: 8, - offsetY: 15, -}; - -const ARM_RIGHT_PULLED_IN: BodyPart = { - pixels: [ - [3, 4], - [2, 3], - [2, 3], - [2, 3], [2, 3], [1, 1], ], - offsetX: 22, - offsetY: 15, -}; - -// Head shifted down 1px (for slouching/resting) -const HEAD_SLOUCH: BodyPart = { - ...HEAD, - offsetY: HEAD.offsetY + 1, + offsetX: 24, + offsetY: 12, }; -// Antenna shifted down 1px to match slouch -const ANTENNA_SLOUCH: BodyPart = { - ...ANTENNA, - offsetY: ANTENNA.offsetY + 1, -}; +// Shift a body part 1px upward (used for vibration animation) +function vibrate(part: BodyPart): BodyPart { + return { ...part, offsetY: part.offsetY - 1 }; +} // ── 12 poses in sprite-sheet order ────────────────────────────────────────── /** Twelve animation poses for the orchestrator robot, ordered by sprite-sheet frame index. */ export const ORCHESTRATOR_POSES: Pose[] = [ - // Frame 0: Idle 1 — standing, arms down - [LEG_STAND, TORSO, ARM_LEFT_DOWN, ARM_RIGHT_DOWN, HEAD, ANTENNA], + // Frame 0: Idle 1 — beacon dim, visor normal, treads A + [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD, BEACON_OFF], - // Frame 1: Idle 2 — arms shifted 1px (ping-pong variation) - [ - LEG_STAND, - TORSO, - { ...ARM_LEFT_DOWN, offsetX: ARM_LEFT_DOWN.offsetX - 1 }, - { ...ARM_RIGHT_DOWN, offsetX: ARM_RIGHT_DOWN.offsetX + 1 }, - HEAD, - ANTENNA, - ], + // Frame 1: Idle 2 — beacon dim, visor dimmed, treads A + [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD_VISOR_DIM, BEACON_OFF], - // Frame 2: Walking — mid-stride with LEG_FORWARD - [LEG_FORWARD, TORSO, ARM_LEFT_UP, ARM_RIGHT_DOWN, HEAD, ANTENNA], + // Frame 2: Concerned — head slouched, beacon dim + [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD_SLOUCH, BEACON_OFF], - // Frame 3: Resting 1 — relaxed stance - [LEG_RELAXED, TORSO, ARM_LEFT_DOWN, ARM_RIGHT_DOWN, HEAD, ANTENNA], + // Frame 3: (spare) + [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD, BEACON_OFF], - // Frame 4: Working 1 — both arms raised - [LEG_STAND, TORSO, ARM_LEFT_UP, ARM_RIGHT_UP, HEAD, ANTENNA], + // Frame 4: Working 1 — beacon on, body at normal Y + [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD, BEACON_ON], - // Frame 5: Working 2 — arms extended horizontally - [LEG_STAND, TORSO, ARM_LEFT_EXTENDED, ARM_RIGHT_EXTENDED, HEAD, ANTENNA], + // Frame 5: Working 2 — beacon off, upper body Y-1 (vibrate) + [TREADS_A, vibrate(TORSO), vibrate(ARM_LEFT), vibrate(ARM_RIGHT), vibrate(HEAD), vibrate(BEACON_OFF)], - // Frame 6: Working 3 — arms lowering back (one up, one down) - [LEG_STAND, TORSO, ARM_LEFT_UP, ARM_RIGHT_DOWN, HEAD, ANTENNA], + // Frame 6: Walking 1 — beacon on, treads A + [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD, BEACON_ON], - // Frame 7: Resting 2 — slight slouch, head offset 1px down - [LEG_RELAXED, TORSO, ARM_LEFT_DOWN, ARM_RIGHT_DOWN, HEAD_SLOUCH, ANTENNA_SLOUCH], + // Frame 7: Walking 2 — beacon off, treads B + [TREADS_B, TORSO, ARM_LEFT, ARM_RIGHT, HEAD, BEACON_OFF], - // Frame 8: Celebrating 1 — both arms raised high - [LEG_STAND, TORSO, ARM_LEFT_RAISED_HIGH, ARM_RIGHT_RAISED_HIGH, HEAD, ANTENNA], + // Frame 8: Celebrating 1 — beacon on, visor bright, treads A, normal Y + [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD_VISOR_BRIGHT, BEACON_ON], - // Frame 9: Celebrating 2 — arms in V-shape (splayed) - [LEG_STAND, TORSO, ARM_LEFT_SPLAYED, ARM_RIGHT_SPLAYED, HEAD, ANTENNA], + // Frame 9: Celebrating 2 — beacon on, visor normal, treads B, upper body Y-1 + [TREADS_B, vibrate(TORSO), vibrate(ARM_LEFT), vibrate(ARM_RIGHT), vibrate(HEAD), vibrate(BEACON_ON)], - // Frame 10: Concerned — hunched, arms pulled in - [LEG_STAND, TORSO, ARM_LEFT_PULLED_IN, ARM_RIGHT_PULLED_IN, HEAD_SLOUCH, ANTENNA_SLOUCH], + // Frame 10: (spare) + [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD, BEACON_OFF], - // Frame 11: Resting 3 — powered-down, arms at sides - [LEG_RELAXED, TORSO, ARM_LEFT_DOWN, ARM_RIGHT_DOWN, HEAD, ANTENNA], + // Frame 11: (spare) + [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD, BEACON_OFF], ]; diff --git a/packages/factory/src/client/visualizations/catwalk/sprites/assets/orchestrator.svg b/packages/factory/src/client/visualizations/catwalk/sprites/assets/orchestrator.svg index c5c095a6..16e6bf8a 100644 --- a/packages/factory/src/client/visualizations/catwalk/sprites/assets/orchestrator.svg +++ b/packages/factory/src/client/visualizations/catwalk/sprites/assets/orchestrator.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From cb79da3b1133dbe0e3187d5825e8ecbefbf7e8e5 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Thu, 12 Mar 2026 17:42:34 -0700 Subject: [PATCH 3/8] factory|feat: Add orchestrator-specific sprite frame coordinates and timing --- .../__tests__/sprite-definitions.test.ts | 278 ++++++++++++++++++ .../catwalk/sprites/sprite-definitions.ts | 42 +++ 2 files changed, 320 insertions(+) create mode 100644 packages/factory/src/client/visualizations/catwalk/sprites/__tests__/sprite-definitions.test.ts diff --git a/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/sprite-definitions.test.ts b/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/sprite-definitions.test.ts new file mode 100644 index 00000000..3f433a05 --- /dev/null +++ b/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/sprite-definitions.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('excalibur', () => ({ + AnimationStrategy: { + End: 'end', + Loop: 'loop', + PingPong: 'pingpong', + Freeze: 'freeze', + }, +})); + +const { + CELEBRATING_DURATION, + CELEBRATING_FRAME_COORDINATES, + CELEBRATING_STRATEGY, + CONCERNED_DURATION, + CONCERNED_FRAME_COORDINATES, + CONCERNED_STRATEGY, + GRID_COLUMNS, + GRID_ROWS, + IDLE_DURATION, + IDLE_FRAME_COORDINATES, + IDLE_STRATEGY, + ORCH_CELEBRATING_DURATION, + ORCH_CELEBRATING_FRAME_COORDINATES, + ORCH_CELEBRATING_STRATEGY, + ORCH_CONCERNED_FRAME_COORDINATES, + ORCH_CONCERNED_STRATEGY, + ORCH_IDLE_DURATION, + ORCH_IDLE_FRAME_COORDINATES, + ORCH_IDLE_STRATEGY, + ORCH_WALKING_DURATION, + ORCH_WALKING_FRAME_COORDINATES, + ORCH_WALKING_STRATEGY, + ORCH_WORKING_DURATION, + ORCH_WORKING_FRAME_COORDINATES, + ORCH_WORKING_STRATEGY, + RESTING_DURATION, + RESTING_FRAME_COORDINATES, + RESTING_STRATEGY, + WALKING_DURATION, + WALKING_FRAME_COORDINATES, + WALKING_STRATEGY, + WORKING_DURATION, + WORKING_FRAME_COORDINATES, + WORKING_STRATEGY, +} = await import('../sprite-definitions.js'); + +const { AnimationStrategy } = await import('excalibur'); + +describe('sprite definitions', () => { + describe('grid dimensions', () => { + it('has 4 columns', () => { + expect(GRID_COLUMNS).toBe(4); + }); + + it('has 3 rows', () => { + expect(GRID_ROWS).toBe(3); + }); + }); + + describe('idle frame coordinates', () => { + it('contains coordinates within grid bounds', () => { + for (const coord of IDLE_FRAME_COORDINATES) { + expect(coord.x).toBeGreaterThanOrEqual(0); + expect(coord.x).toBeLessThan(GRID_COLUMNS); + expect(coord.y).toBeGreaterThanOrEqual(0); + expect(coord.y).toBeLessThan(GRID_ROWS); + } + }); + + it('has 2 frames', () => { + expect(IDLE_FRAME_COORDINATES).toHaveLength(2); + }); + }); + + describe('walking frame coordinates', () => { + it('contains coordinates within grid bounds', () => { + for (const coord of WALKING_FRAME_COORDINATES) { + expect(coord.x).toBeGreaterThanOrEqual(0); + expect(coord.x).toBeLessThan(GRID_COLUMNS); + expect(coord.y).toBeGreaterThanOrEqual(0); + expect(coord.y).toBeLessThan(GRID_ROWS); + } + }); + + it('has 1 frame', () => { + expect(WALKING_FRAME_COORDINATES).toHaveLength(1); + }); + }); + + describe('working frame coordinates', () => { + it('contains coordinates within grid bounds', () => { + for (const coord of WORKING_FRAME_COORDINATES) { + expect(coord.x).toBeGreaterThanOrEqual(0); + expect(coord.x).toBeLessThan(GRID_COLUMNS); + expect(coord.y).toBeGreaterThanOrEqual(0); + expect(coord.y).toBeLessThan(GRID_ROWS); + } + }); + + it('has 3 frames', () => { + expect(WORKING_FRAME_COORDINATES).toHaveLength(3); + }); + }); + + describe('celebrating frame coordinates', () => { + it('contains coordinates within grid bounds', () => { + for (const coord of CELEBRATING_FRAME_COORDINATES) { + expect(coord.x).toBeGreaterThanOrEqual(0); + expect(coord.x).toBeLessThan(GRID_COLUMNS); + expect(coord.y).toBeGreaterThanOrEqual(0); + expect(coord.y).toBeLessThan(GRID_ROWS); + } + }); + + it('has 2 frames', () => { + expect(CELEBRATING_FRAME_COORDINATES).toHaveLength(2); + }); + }); + + describe('concerned frame coordinates', () => { + it('contains coordinates within grid bounds', () => { + for (const coord of CONCERNED_FRAME_COORDINATES) { + expect(coord.x).toBeGreaterThanOrEqual(0); + expect(coord.x).toBeLessThan(GRID_COLUMNS); + expect(coord.y).toBeGreaterThanOrEqual(0); + expect(coord.y).toBeLessThan(GRID_ROWS); + } + }); + + it('has 1 frame', () => { + expect(CONCERNED_FRAME_COORDINATES).toHaveLength(1); + }); + }); + + describe('resting frame coordinates', () => { + it('contains coordinates within grid bounds', () => { + for (const coord of RESTING_FRAME_COORDINATES) { + expect(coord.x).toBeGreaterThanOrEqual(0); + expect(coord.x).toBeLessThan(GRID_COLUMNS); + expect(coord.y).toBeGreaterThanOrEqual(0); + expect(coord.y).toBeLessThan(GRID_ROWS); + } + }); + + it('has 3 frames', () => { + expect(RESTING_FRAME_COORDINATES).toHaveLength(3); + }); + + it('has a positive duration', () => { + expect(RESTING_DURATION).toBeGreaterThan(0); + }); + + it('uses PingPong strategy', () => { + expect(RESTING_STRATEGY).toBe(AnimationStrategy.PingPong); + }); + }); + + describe('duration constants', () => { + it('has a positive idle duration', () => { + expect(IDLE_DURATION).toBeGreaterThan(0); + }); + + it('has a positive walking duration', () => { + expect(WALKING_DURATION).toBeGreaterThan(0); + }); + + it('has a positive working duration', () => { + expect(WORKING_DURATION).toBeGreaterThan(0); + }); + + it('has a positive celebrating duration', () => { + expect(CELEBRATING_DURATION).toBeGreaterThan(0); + }); + + it('has a positive concerned duration', () => { + expect(CONCERNED_DURATION).toBeGreaterThan(0); + }); + }); + + describe('animation strategies', () => { + it('uses PingPong strategy for idle', () => { + expect(IDLE_STRATEGY).toBe(AnimationStrategy.PingPong); + }); + + it('uses Loop strategy for walking', () => { + expect(WALKING_STRATEGY).toBe(AnimationStrategy.Loop); + }); + + it('uses Loop strategy for working', () => { + expect(WORKING_STRATEGY).toBe(AnimationStrategy.Loop); + }); + + it('uses PingPong strategy for celebrating', () => { + expect(CELEBRATING_STRATEGY).toBe(AnimationStrategy.PingPong); + }); + + it('uses Freeze strategy for concerned', () => { + expect(CONCERNED_STRATEGY).toBe(AnimationStrategy.Freeze); + }); + }); + + describe('orchestrator frame coordinates', () => { + it('ORCH_IDLE_FRAME_COORDINATES has 2 frames at row 0', () => { + expect(ORCH_IDLE_FRAME_COORDINATES).toEqual([ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + ]); + }); + + it('ORCH_WORKING_FRAME_COORDINATES has 2 frames at row 1', () => { + expect(ORCH_WORKING_FRAME_COORDINATES).toEqual([ + { x: 0, y: 1 }, + { x: 1, y: 1 }, + ]); + }); + + it('ORCH_WALKING_FRAME_COORDINATES has 2 frames at row 1', () => { + expect(ORCH_WALKING_FRAME_COORDINATES).toEqual([ + { x: 2, y: 1 }, + { x: 3, y: 1 }, + ]); + }); + + it('ORCH_CELEBRATING_FRAME_COORDINATES has 2 frames at row 2', () => { + expect(ORCH_CELEBRATING_FRAME_COORDINATES).toEqual([ + { x: 0, y: 2 }, + { x: 1, y: 2 }, + ]); + }); + + it('ORCH_CONCERNED_FRAME_COORDINATES has 1 frame at (2,0)', () => { + expect(ORCH_CONCERNED_FRAME_COORDINATES).toEqual([{ x: 2, y: 0 }]); + }); + }); + + describe('orchestrator timing', () => { + it('ORCH_WORKING_DURATION is 500ms', () => { + expect(ORCH_WORKING_DURATION).toBe(500); + }); + + it('ORCH_CELEBRATING_DURATION is 250ms', () => { + expect(ORCH_CELEBRATING_DURATION).toBe(250); + }); + + it('ORCH_IDLE_DURATION is 1200ms', () => { + expect(ORCH_IDLE_DURATION).toBe(1200); + }); + + it('ORCH_WALKING_DURATION is 300ms', () => { + expect(ORCH_WALKING_DURATION).toBe(300); + }); + }); + + describe('orchestrator strategies', () => { + it('ORCH_WORKING_STRATEGY is Loop', () => { + expect(ORCH_WORKING_STRATEGY).toBe(AnimationStrategy.Loop); + }); + + // Note: This deliberately differs from subagent CELEBRATING_STRATEGY (PingPong). + it('ORCH_CELEBRATING_STRATEGY is Loop (not PingPong like subagent)', () => { + expect(ORCH_CELEBRATING_STRATEGY).toBe(AnimationStrategy.Loop); + }); + + it('ORCH_IDLE_STRATEGY is PingPong', () => { + expect(ORCH_IDLE_STRATEGY).toBe(AnimationStrategy.PingPong); + }); + + it('ORCH_WALKING_STRATEGY is Loop', () => { + expect(ORCH_WALKING_STRATEGY).toBe(AnimationStrategy.Loop); + }); + + it('ORCH_CONCERNED_STRATEGY is Freeze', () => { + expect(ORCH_CONCERNED_STRATEGY).toBe(AnimationStrategy.Freeze); + }); + }); +}); diff --git a/packages/factory/src/client/visualizations/catwalk/sprites/sprite-definitions.ts b/packages/factory/src/client/visualizations/catwalk/sprites/sprite-definitions.ts index 4835b580..6af0c446 100644 --- a/packages/factory/src/client/visualizations/catwalk/sprites/sprite-definitions.ts +++ b/packages/factory/src/client/visualizations/catwalk/sprites/sprite-definitions.ts @@ -50,3 +50,45 @@ export const WORKING_STRATEGY = AnimationStrategy.Loop; export const CELEBRATING_STRATEGY = AnimationStrategy.PingPong; export const CONCERNED_STRATEGY = AnimationStrategy.Freeze; export const RESTING_STRATEGY = AnimationStrategy.PingPong; + +// ── Orchestrator-specific frame coordinates ── +// The orchestrator uses a different sprite sheet layout than subagents. +// Row 0: idle 1 (0,0), idle 2 (1,0), concerned (2,0), spare (3,0) +// Row 1: working 1 (0,1), working 2 (1,1), walking 1 (2,1), walking 2 (3,1) +// Row 2: celebrating 1 (0,2), celebrating 2 (1,2), spare (2,2), spare (3,2) + +export const ORCH_IDLE_FRAME_COORDINATES: ReadonlyArray<{ x: number; y: number }> = [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, +]; + +export const ORCH_WORKING_FRAME_COORDINATES: ReadonlyArray<{ x: number; y: number }> = [ + { x: 0, y: 1 }, + { x: 1, y: 1 }, +]; + +export const ORCH_WALKING_FRAME_COORDINATES: ReadonlyArray<{ x: number; y: number }> = [ + { x: 2, y: 1 }, + { x: 3, y: 1 }, +]; + +export const ORCH_CELEBRATING_FRAME_COORDINATES: ReadonlyArray<{ x: number; y: number }> = [ + { x: 0, y: 2 }, + { x: 1, y: 2 }, +]; + +export const ORCH_CONCERNED_FRAME_COORDINATES: ReadonlyArray<{ x: number; y: number }> = [{ x: 2, y: 0 }]; + +// Orchestrator timing (per frame) +export const ORCH_IDLE_DURATION = 1200; +export const ORCH_WORKING_DURATION = 500; +export const ORCH_WALKING_DURATION = 300; +export const ORCH_CELEBRATING_DURATION = 250; +export const ORCH_CONCERNED_DURATION = 600; + +// Orchestrator strategies +export const ORCH_IDLE_STRATEGY = AnimationStrategy.PingPong; +export const ORCH_WORKING_STRATEGY = AnimationStrategy.Loop; +export const ORCH_WALKING_STRATEGY = AnimationStrategy.Loop; +export const ORCH_CELEBRATING_STRATEGY = AnimationStrategy.Loop; +export const ORCH_CONCERNED_STRATEGY = AnimationStrategy.Freeze; From 89557cac7b2b96429e072b6c1a3062e2d70d76b3 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Thu, 12 Mar 2026 17:46:29 -0700 Subject: [PATCH 4/8] factory|feat: Split catwalk sprite frame config by sprite type --- .../__tests__/catwalk-sprite-loader.test.ts | 32 +++++++ .../catwalk/sprites/catwalk-sprite-loader.ts | 96 +++++++++++++++++-- 2 files changed, 121 insertions(+), 7 deletions(-) diff --git a/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/catwalk-sprite-loader.test.ts b/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/catwalk-sprite-loader.test.ts index 6d103e48..a24a4344 100644 --- a/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/catwalk-sprite-loader.test.ts +++ b/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/catwalk-sprite-loader.test.ts @@ -1,5 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ORCH_WORKING_DURATION, + ORCH_WORKING_FRAME_COORDINATES, + ORCH_WORKING_STRATEGY, + WORKING_DURATION, + WORKING_FRAME_COORDINATES, +} from '../../../../game/sprites/sprite-definitions.js'; + const { mockImageSourceConstructor, mockImageSourceLoad, mockSpriteSheetFromImageSource, mockAnimationFromCoords } = vi.hoisted(() => { const mockLoad = vi.fn().mockResolvedValue(undefined); @@ -127,6 +135,30 @@ describe('catwalk-sprite-loader', () => { // 2 calls for the failed attempt + 2 for the retry = 4 ImageSource constructions expect(mockImageSourceConstructor).toHaveBeenCalledTimes(4); }); + + it('orchestrator working animation uses ORCH_WORKING_DURATION (500ms)', () => { + loadAllCatwalkSprites(); + const workingCall = mockAnimationFromCoords.mock.calls.find( + (call) => + call[0]?.frameCoordinates?.[0]?.x === ORCH_WORKING_FRAME_COORDINATES[0].x && + call[0]?.frameCoordinates?.[0]?.y === ORCH_WORKING_FRAME_COORDINATES[0].y && + call[0]?.frameCoordinates?.length === ORCH_WORKING_FRAME_COORDINATES.length, + ); + expect(workingCall).toBeDefined(); + expect(workingCall![0].durationPerFrameMs).toBe(ORCH_WORKING_DURATION); + expect(workingCall![0].strategy).toBe(ORCH_WORKING_STRATEGY); + }); + + it('subagent working animation still uses original WORKING_DURATION (300ms)', () => { + loadAllCatwalkSprites(); + const workingCall = mockAnimationFromCoords.mock.calls.find( + (call) => + call[0]?.frameCoordinates?.[0]?.x === WORKING_FRAME_COORDINATES[0].x && + call[0]?.frameCoordinates?.[0]?.y === WORKING_FRAME_COORDINATES[0].y, + ); + expect(workingCall).toBeDefined(); + expect(workingCall![0].durationPerFrameMs).toBe(WORKING_DURATION); + }); }); describe('getAnimation', () => { diff --git a/packages/factory/src/client/visualizations/catwalk/sprites/catwalk-sprite-loader.ts b/packages/factory/src/client/visualizations/catwalk/sprites/catwalk-sprite-loader.ts index 2d5f464f..b15392ed 100644 --- a/packages/factory/src/client/visualizations/catwalk/sprites/catwalk-sprite-loader.ts +++ b/packages/factory/src/client/visualizations/catwalk/sprites/catwalk-sprite-loader.ts @@ -13,6 +13,21 @@ import { IDLE_DURATION, IDLE_FRAME_COORDINATES, IDLE_STRATEGY, + ORCH_CELEBRATING_DURATION, + ORCH_CELEBRATING_FRAME_COORDINATES, + ORCH_CELEBRATING_STRATEGY, + ORCH_CONCERNED_DURATION, + ORCH_CONCERNED_FRAME_COORDINATES, + ORCH_CONCERNED_STRATEGY, + ORCH_IDLE_DURATION, + ORCH_IDLE_FRAME_COORDINATES, + ORCH_IDLE_STRATEGY, + ORCH_WALKING_DURATION, + ORCH_WALKING_FRAME_COORDINATES, + ORCH_WALKING_STRATEGY, + ORCH_WORKING_DURATION, + ORCH_WORKING_FRAME_COORDINATES, + ORCH_WORKING_STRATEGY, RESTING_DURATION, RESTING_FRAME_COORDINATES, RESTING_STRATEGY, @@ -30,12 +45,68 @@ const SPRITE_TYPES: readonly CatwalkSpriteType[] = ['subagent', 'orchestrator']; let animationCache: Map> | undefined; -/** Resolves frame coordinates and timing for the given animation state. */ -function frameConfigForState(state: AgentAnimationState): { +type FrameConfig = { frameCoordinates: ReadonlyArray<{ x: number; y: number }>; duration: number; strategy: import('excalibur').AnimationStrategy; -} { +}; + +/** Resolves frame coordinates and timing for orchestrator sprite states. */ +function orchestratorFrameConfig(state: AgentAnimationState): FrameConfig { + switch (state) { + case 'idle': + return { + frameCoordinates: ORCH_IDLE_FRAME_COORDINATES, + duration: ORCH_IDLE_DURATION, + strategy: ORCH_IDLE_STRATEGY, + }; + case 'walking': + return { + frameCoordinates: ORCH_WALKING_FRAME_COORDINATES, + duration: ORCH_WALKING_DURATION, + strategy: ORCH_WALKING_STRATEGY, + }; + case 'working': + return { + frameCoordinates: ORCH_WORKING_FRAME_COORDINATES, + duration: ORCH_WORKING_DURATION, + strategy: ORCH_WORKING_STRATEGY, + }; + case 'celebrating': + return { + frameCoordinates: ORCH_CELEBRATING_FRAME_COORDINATES, + duration: ORCH_CELEBRATING_DURATION, + strategy: ORCH_CELEBRATING_STRATEGY, + }; + case 'concerned': + return { + frameCoordinates: ORCH_CONCERNED_FRAME_COORDINATES, + duration: ORCH_CONCERNED_DURATION, + strategy: ORCH_CONCERNED_STRATEGY, + }; + case 'resting': + // Orchestrator never enters resting; reuse idle. + return { + frameCoordinates: ORCH_IDLE_FRAME_COORDINATES, + duration: ORCH_IDLE_DURATION, + strategy: ORCH_IDLE_STRATEGY, + }; + case 'deactivated': + // Deactivated agents reuse the idle animation; opacity is handled by the actor. + return { + frameCoordinates: ORCH_IDLE_FRAME_COORDINATES, + duration: ORCH_IDLE_DURATION, + strategy: ORCH_IDLE_STRATEGY, + }; + default: { + const _exhaustive: never = state; + return _exhaustive; + } + } +} + +/** Resolves frame coordinates and timing for subagent sprite states. */ +function subagentFrameConfig(state: AgentAnimationState): FrameConfig { switch (state) { case 'idle': return { frameCoordinates: IDLE_FRAME_COORDINATES, duration: IDLE_DURATION, strategy: IDLE_STRATEGY }; @@ -67,6 +138,14 @@ function frameConfigForState(state: AgentAnimationState): { } } +/** Resolves frame coordinates and timing for the given animation state and sprite type. */ +function frameConfigForState(state: AgentAnimationState, spriteType: CatwalkSpriteType): FrameConfig { + if (spriteType === 'orchestrator') { + return orchestratorFrameConfig(state); + } + return subagentFrameConfig(state); +} + const ALL_STATES: readonly AgentAnimationState[] = [ 'idle', 'walking', @@ -78,14 +157,17 @@ const ALL_STATES: readonly AgentAnimationState[] = [ ]; /** Build animation objects for every state from the given sprite sheet. */ -function buildAnimationsForSheet(spriteSheet: SpriteSheet): Map { +function buildAnimationsForSheet( + spriteSheet: SpriteSheet, + spriteType: CatwalkSpriteType, +): Map { const map = new Map(); for (const state of ALL_STATES) { - const { frameCoordinates, duration, strategy } = frameConfigForState(state); + const { frameCoordinates, duration, strategy } = frameConfigForState(state, spriteType); const animation = Animation.fromSpriteSheetCoordinates({ spriteSheet, frameCoordinates: [...frameCoordinates], - durationPerFrame: duration, + durationPerFrameMs: duration, strategy, }); map.set(state, animation); @@ -119,7 +201,7 @@ export async function loadAllCatwalkSprites(): Promise { }, }); - cache.set(spriteType, buildAnimationsForSheet(spriteSheet)); + cache.set(spriteType, buildAnimationsForSheet(spriteSheet, spriteType)); } // Populate cache synchronously so getAnimation() works immediately. From dceec3cecf54cc4b5f00f092ffc26ca4d80acf97 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Thu, 12 Mar 2026 17:51:24 -0700 Subject: [PATCH 5/8] factory|refactor: Remove scale pulse from OrchestratorActor --- .../catwalk/actors/OrchestratorActor.ts | 26 +++--------- .../catwalk/actors/__tests__/actors.test.ts | 42 +++++-------------- 2 files changed, 15 insertions(+), 53 deletions(-) diff --git a/packages/factory/src/client/visualizations/catwalk/actors/OrchestratorActor.ts b/packages/factory/src/client/visualizations/catwalk/actors/OrchestratorActor.ts index 86d57354..0cda1114 100644 --- a/packages/factory/src/client/visualizations/catwalk/actors/OrchestratorActor.ts +++ b/packages/factory/src/client/visualizations/catwalk/actors/OrchestratorActor.ts @@ -1,12 +1,6 @@ import { Actor, BaseAlign, Color, Font, Rectangle, Text, TextAlign, vec, type Vector } from 'excalibur'; -import { - ACTIVE_OPACITY, - ORCH_IDLE_OPACITY, - PULSE_FREQUENCY, - SCALE_PULSE_MAX, - SCALE_PULSE_MIN, -} from '../constants/animation.js'; +import { ACTIVE_OPACITY, ORCH_IDLE_OPACITY } from '../constants/animation.js'; import { BADGE_OFFSET_Y, CARRIED_ART_GAP, @@ -23,10 +17,9 @@ export interface OrchestratorActorConfig { working: boolean; } -/** Renders the orchestrator as an animated sprite on the catwalk rail, supporting walk and pulse animations. */ +/** Renders the orchestrator as an animated sprite on the catwalk rail, supporting walk and working animations. */ export class OrchestratorActor extends Actor { private _working = false; - private _elapsed = 0; private _carriedChildren: Actor[] = []; private _badgeChild: Actor | undefined; @@ -48,18 +41,17 @@ export class OrchestratorActor extends Actor { .toPromise(); } - /** Fade the orchestrator out to invisible and stop the working pulse. */ + /** Fade the orchestrator out to invisible and stop the working animation. */ fadeOut(): void { this._working = false; this.actions.fade(0, PAUSE_DURATION); } - /** Toggle the pulsing working glow and switch sprite animation. */ + /** Toggle the working animation and switch sprite animation. */ setWorking(working: boolean): void { this._working = working; const animation = getAnimation('orchestrator', working ? 'working' : 'idle'); this.graphics.use(animation); - this._elapsed = 0; if (working) { this.graphics.opacity = ACTIVE_OPACITY; } else { @@ -68,7 +60,7 @@ export class OrchestratorActor extends Actor { } } - /** Switch to the celebrating sprite animation and stop the working pulse. */ + /** Switch to the celebrating sprite animation and stop the working animation. */ celebrate(): void { this._working = false; this.scale = vec(1, 1); @@ -125,12 +117,4 @@ export class OrchestratorActor extends Actor { this.addChild(badge); this._badgeChild = badge; } - - override onPreUpdate(_engine: unknown, deltaMs: number): void { - if (!this._working) return; - this._elapsed += deltaMs; - const t = Math.sin((this._elapsed * PULSE_FREQUENCY * Math.PI * 2) / 1000); - const s = SCALE_PULSE_MIN + ((SCALE_PULSE_MAX - SCALE_PULSE_MIN) * (t + 1)) / 2; - this.scale = vec(s, s); - } } diff --git a/packages/factory/src/client/visualizations/catwalk/actors/__tests__/actors.test.ts b/packages/factory/src/client/visualizations/catwalk/actors/__tests__/actors.test.ts index 6bd28a65..efbcc70d 100644 --- a/packages/factory/src/client/visualizations/catwalk/actors/__tests__/actors.test.ts +++ b/packages/factory/src/client/visualizations/catwalk/actors/__tests__/actors.test.ts @@ -145,32 +145,25 @@ describe('OrchestratorActor', () => { ); }); - it('setWorking(true) enables pulse flag and restores full opacity', () => { + it('setWorking(true) sets full opacity', () => { const actor = new OrchestratorActor({ working: false }, vec(0, 0)); - expect(actor.graphics.opacity).toBe(ORCH_IDLE_OPACITY); - actor.setWorking(true); - expect(actor.graphics.opacity).toBe(ACTIVE_OPACITY); - - // Simulate onPreUpdate — scale should pulse within range - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Engine param unused in test mock - actor.onPreUpdate(undefined as never, 500); - expect(actor.scale.x).toBeGreaterThanOrEqual(1); - expect(actor.scale.x).toBeLessThanOrEqual(1.08); - expect(actor.scale.y).toBe(actor.scale.x); }); - it('setWorking(false) disables pulse and sets idle opacity', () => { + it('setWorking(false) sets idle opacity and resets scale', () => { const actor = new OrchestratorActor({ working: true }, vec(0, 0)); actor.setWorking(false); - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Engine param unused in test mock - actor.onPreUpdate(undefined as never, 0); expect(actor.graphics.opacity).toBe(ORCH_IDLE_OPACITY); expect(actor.scale).toEqual({ x: 1, y: 1 }); }); + it('does not have onPreUpdate (no scale pulse)', () => { + const actor = new OrchestratorActor({ working: false }, vec(0, 0)); + // onPreUpdate should be inherited default (no-op), not overridden + expect(actor.constructor.prototype.hasOwnProperty('onPreUpdate')).toBe(false); + }); + it('calls getAnimation with orchestrator type and working state', () => { new OrchestratorActor({ working: true }, vec(0, 0)); @@ -304,32 +297,17 @@ describe('OrchestratorActor', () => { expect(mockGetAnimation).toHaveBeenCalledWith('orchestrator', 'celebrating'); }); - it('celebrate() disables working pulse and sets ACTIVE_OPACITY', () => { + it('celebrate() sets ACTIVE_OPACITY and resets scale', () => { const actor = new OrchestratorActor({ working: true }, vec(0, 0)); - actor.celebrate(); - expect(actor.graphics.opacity).toBe(ACTIVE_OPACITY); expect(actor.scale).toEqual({ x: 1, y: 1 }); - - // Pulse should be disabled -- onPreUpdate should not change scale - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Engine param unused in test mock - actor.onPreUpdate(undefined as never, 500); - expect(actor.scale).toEqual({ x: 1, y: 1 }); }); - it('fadeOut fades opacity to 0 and stops the working pulse', () => { + it('fadeOut calls actions.fade with opacity 0', () => { const actor = new OrchestratorActor({ working: true }, vec(0, 0)); - actor.fadeOut(); - expect(actor.actions.fade).toHaveBeenCalledWith(0, expect.any(Number)); - - // Working pulse should be disabled — onPreUpdate should be a no-op - const opacityBefore = actor.graphics.opacity; - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Engine param unused in test mock - actor.onPreUpdate(undefined as never, 500); - expect(actor.graphics.opacity).toBe(opacityBefore); }); }); From a1110582b1fa7ad17f1d40c58c624102f19efbaa Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Thu, 12 Mar 2026 18:11:45 -0700 Subject: [PATCH 6/8] factory|feat: Update preview renderer for orchestrator's new sprite layout --- .../scripts/sprites/preview-renderer.ts | 76 +++++++++++++------ 1 file changed, 54 insertions(+), 22 deletions(-) diff --git a/packages/factory/scripts/sprites/preview-renderer.ts b/packages/factory/scripts/sprites/preview-renderer.ts index c9027469..d4095abc 100644 --- a/packages/factory/scripts/sprites/preview-renderer.ts +++ b/packages/factory/scripts/sprites/preview-renderer.ts @@ -1,8 +1,8 @@ -// Frame labels matching the pose ordering from sprite-definitions.ts +// Frame labels for subagent sprite sheet // Row 0 (frames 0-3): Idle 1, Idle 2, Walking, Resting 1 // Row 1 (frames 4-7): Working 1, Working 2, Working 3, Resting 2 // Row 2 (frames 8-11): Celebrating 1, Celebrating 2, Concerned, Resting 3 -const FRAME_LABELS = [ +const SUBAGENT_FRAME_LABELS = [ 'Idle 1', 'Idle 2', 'Walking', @@ -17,29 +17,53 @@ const FRAME_LABELS = [ 'Resting 3', ]; -const ANIMATION_NAMES = ['Idle', 'Walking', 'Working', 'Celebrating', 'Concerned', 'Resting']; +// Frame labels for orchestrator sprite sheet +// Row 0 (frames 0-3): Idle 1, Idle 2, Concerned, (spare) +// Row 1 (frames 4-7): Working 1, Working 2, Walking 1, Walking 2 +// Row 2 (frames 8-11): Celebrating 1, Celebrating 2, (spare), (spare) +const ORCHESTRATOR_FRAME_LABELS = [ + 'Idle 1', + 'Idle 2', + 'Concerned', + '', + 'Working 1', + 'Working 2', + 'Walking 1', + 'Walking 2', + 'Celebrating 1', + 'Celebrating 2', + '', + '', +]; + +const SUBAGENT_ANIMATION_NAMES = ['Idle', 'Walking', 'Working', 'Celebrating', 'Concerned', 'Resting']; +const ORCHESTRATOR_ANIMATION_NAMES = ['Idle', 'Walking', 'Working', 'Celebrating', 'Concerned']; /** Renders the 4x3 grid of individually labeled sprite frames as HTML. */ -function renderFrameGridHtml(dataUri: string): string { - return FRAME_LABELS.map((label, i) => { - const col = i % 4; - const row = Math.floor(i / 4); - return `
+function renderFrameGridHtml(dataUri: string, frameLabels: readonly string[]): string { + return frameLabels + .map((label, i) => { + const col = i % 4; + const row = Math.floor(i / 4); + return `
${label}
`; - }).join('\n'); + }) + .join('\n'); } /** Renders animation preview cells with element IDs for JS-driven playback. */ -function renderAnimationSectionsHtml(idPrefix: string): string { - return ANIMATION_NAMES.map((name) => { - const id = `${idPrefix}-${name.toLowerCase()}`; - return `
+function renderAnimationSectionsHtml(idPrefix: string, animationNames: readonly string[]): string { + return animationNames + .map((name) => { + const id = `${idPrefix}-${name.toLowerCase()}`; + return `
${name}
`; - }).join('\n'); + }) + .join('\n'); } /** Generates a self-contained HTML preview page displaying labeled sprite frames and animated previews. */ @@ -108,22 +132,22 @@ export function renderPreviewHtml(subagentSvg: string, orchestratorSvg: string):

subagent

Frames

-${renderFrameGridHtml(subagentDataUri)} +${renderFrameGridHtml(subagentDataUri, SUBAGENT_FRAME_LABELS)}

Animations

-${renderAnimationSectionsHtml('subagent')} +${renderAnimationSectionsHtml('subagent', SUBAGENT_ANIMATION_NAMES)}

orchestrator

Frames

-${renderFrameGridHtml(orchestratorDataUri)} +${renderFrameGridHtml(orchestratorDataUri, ORCHESTRATOR_FRAME_LABELS)}

Animations

-${renderAnimationSectionsHtml('orchestrator')} +${renderAnimationSectionsHtml('orchestrator', ORCHESTRATOR_ANIMATION_NAMES)}
@@ -141,7 +165,7 @@ ${renderAnimationSectionsHtml('orchestrator')} var FREEZE = 'Freeze'; // Animation definitions (duplicated from sprite-definitions.ts) - var animations = [ + var subagentAnimations = [ { name: 'idle', frames: [{x:0,y:0},{x:1,y:0}], duration: 600, strategy: PING_PONG }, { name: 'walking', frames: [{x:2,y:0}], duration: 200, strategy: LOOP }, { name: 'working', frames: [{x:0,y:1},{x:1,y:1},{x:2,y:1}], duration: 300, strategy: LOOP }, @@ -150,13 +174,21 @@ ${renderAnimationSectionsHtml('orchestrator')} { name: 'resting', frames: [{x:3,y:0},{x:3,y:1},{x:3,y:2}], duration: 500, strategy: PING_PONG } ]; + var orchestratorAnimations = [ + { name: 'idle', frames: [{x:0,y:0},{x:1,y:0}], duration: 1200, strategy: PING_PONG }, + { name: 'walking', frames: [{x:2,y:1},{x:3,y:1}], duration: 300, strategy: LOOP }, + { name: 'working', frames: [{x:0,y:1},{x:1,y:1}], duration: 500, strategy: LOOP }, + { name: 'celebrating', frames: [{x:0,y:2},{x:1,y:2}], duration: 250, strategy: LOOP }, + { name: 'concerned', frames: [{x:2,y:0}], duration: 600, strategy: FREEZE }, + ]; + var prefixes = [ - { prefix: 'subagent', uri: subagentUri }, - { prefix: 'orchestrator', uri: orchestratorUri } + { prefix: 'subagent', uri: subagentUri, animations: subagentAnimations }, + { prefix: 'orchestrator', uri: orchestratorUri, animations: orchestratorAnimations } ]; prefixes.forEach(function(cfg) { - animations.forEach(function(anim) { + cfg.animations.forEach(function(anim) { var el = document.getElementById(cfg.prefix + '-' + anim.name); if (!el) return; From a059ae9fb8eb8c31455e1a443599709f511a957d Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Thu, 12 Mar 2026 19:29:30 -0700 Subject: [PATCH 7/8] factory|refactor: Fix typecheck and lint errors in orchestrator redesign Use BEACON_IDLE for idle frame poses (matching spec's "beacon dim indices 2/1"), remove dead _working field from OrchestratorActor, fix type safety in tests, change type alias to interface for FrameConfig. --- .../__tests__/orchestrator-poses.test.ts | 2 +- .../scripts/sprites/orchestrator-poses.ts | 8 +-- packages/factory/scripts/sprites/palettes.ts | 1 + .../catwalk/actors/OrchestratorActor.ts | 5 -- .../catwalk/actors/__tests__/actors.test.ts | 5 +- .../__tests__/catwalk-sprite-loader.test.ts | 50 ++++++++++++------- .../catwalk/sprites/assets/orchestrator.svg | 2 +- .../catwalk/sprites/catwalk-sprite-loader.ts | 4 +- 8 files changed, 44 insertions(+), 33 deletions(-) diff --git a/packages/factory/scripts/sprites/__tests__/orchestrator-poses.test.ts b/packages/factory/scripts/sprites/__tests__/orchestrator-poses.test.ts index 74ee8d57..5fa22476 100644 --- a/packages/factory/scripts/sprites/__tests__/orchestrator-poses.test.ts +++ b/packages/factory/scripts/sprites/__tests__/orchestrator-poses.test.ts @@ -37,7 +37,7 @@ describe('ORCHESTRATOR_POSES', () => { // Frame 4 (Working 1) should have beacon with index 6 pixels const beaconPart = ORCHESTRATOR_POSES[4]?.[5]; // last part = beacon expect(beaconPart).toBeDefined(); - const hasIndex6 = beaconPart!.pixels.some((row) => row.some((value) => value === 6)); + const hasIndex6 = beaconPart?.pixels.some((row) => row.includes(6)) ?? false; expect(hasIndex6).toBe(true); }); }); diff --git a/packages/factory/scripts/sprites/orchestrator-poses.ts b/packages/factory/scripts/sprites/orchestrator-poses.ts index 030f7856..a3ac1190 100644 --- a/packages/factory/scripts/sprites/orchestrator-poses.ts +++ b/packages/factory/scripts/sprites/orchestrator-poses.ts @@ -176,11 +176,11 @@ function vibrate(part: BodyPart): BodyPart { /** Twelve animation poses for the orchestrator robot, ordered by sprite-sheet frame index. */ export const ORCHESTRATOR_POSES: Pose[] = [ - // Frame 0: Idle 1 — beacon dim, visor normal, treads A - [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD, BEACON_OFF], + // Frame 0: Idle 1 — beacon dim (indices 2/1), visor normal, treads A + [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD, BEACON_IDLE], - // Frame 1: Idle 2 — beacon dim, visor dimmed, treads A - [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD_VISOR_DIM, BEACON_OFF], + // Frame 1: Idle 2 — beacon dim (indices 2/1), visor dimmed, treads A + [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD_VISOR_DIM, BEACON_IDLE], // Frame 2: Concerned — head slouched, beacon dim [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD_SLOUCH, BEACON_OFF], diff --git a/packages/factory/scripts/sprites/palettes.ts b/packages/factory/scripts/sprites/palettes.ts index 8aaddd70..16456640 100644 --- a/packages/factory/scripts/sprites/palettes.ts +++ b/packages/factory/scripts/sprites/palettes.ts @@ -93,4 +93,5 @@ const basePalette = generatePalette( ); /** Gold command-unit palette for the orchestrator robot, with beacon bright at index 6. */ +// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Palette is a readonly tuple starting with ''; spread loses that refinement export const ORCHESTRATOR_PALETTE: Palette = [...basePalette, '#fff5c0'] as Palette; diff --git a/packages/factory/src/client/visualizations/catwalk/actors/OrchestratorActor.ts b/packages/factory/src/client/visualizations/catwalk/actors/OrchestratorActor.ts index 0cda1114..9f3875e6 100644 --- a/packages/factory/src/client/visualizations/catwalk/actors/OrchestratorActor.ts +++ b/packages/factory/src/client/visualizations/catwalk/actors/OrchestratorActor.ts @@ -19,7 +19,6 @@ export interface OrchestratorActorConfig { /** Renders the orchestrator as an animated sprite on the catwalk rail, supporting walk and working animations. */ export class OrchestratorActor extends Actor { - private _working = false; private _carriedChildren: Actor[] = []; private _badgeChild: Actor | undefined; @@ -30,7 +29,6 @@ export class OrchestratorActor extends Actor { const animation = getAnimation('orchestrator', config.working ? 'working' : 'idle'); this.graphics.use(animation); - this._working = config.working; this.graphics.opacity = config.working ? ACTIVE_OPACITY : ORCH_IDLE_OPACITY; } @@ -43,13 +41,11 @@ export class OrchestratorActor extends Actor { /** Fade the orchestrator out to invisible and stop the working animation. */ fadeOut(): void { - this._working = false; this.actions.fade(0, PAUSE_DURATION); } /** Toggle the working animation and switch sprite animation. */ setWorking(working: boolean): void { - this._working = working; const animation = getAnimation('orchestrator', working ? 'working' : 'idle'); this.graphics.use(animation); if (working) { @@ -62,7 +58,6 @@ export class OrchestratorActor extends Actor { /** Switch to the celebrating sprite animation and stop the working animation. */ celebrate(): void { - this._working = false; this.scale = vec(1, 1); this.graphics.opacity = ACTIVE_OPACITY; const animation = getAnimation('orchestrator', 'celebrating'); diff --git a/packages/factory/src/client/visualizations/catwalk/actors/__tests__/actors.test.ts b/packages/factory/src/client/visualizations/catwalk/actors/__tests__/actors.test.ts index efbcc70d..72f1b6eb 100644 --- a/packages/factory/src/client/visualizations/catwalk/actors/__tests__/actors.test.ts +++ b/packages/factory/src/client/visualizations/catwalk/actors/__tests__/actors.test.ts @@ -159,9 +159,8 @@ describe('OrchestratorActor', () => { }); it('does not have onPreUpdate (no scale pulse)', () => { - const actor = new OrchestratorActor({ working: false }, vec(0, 0)); - // onPreUpdate should be inherited default (no-op), not overridden - expect(actor.constructor.prototype.hasOwnProperty('onPreUpdate')).toBe(false); + const ownMethods = Object.getOwnPropertyNames(OrchestratorActor.prototype); + expect(ownMethods).not.toContain('onPreUpdate'); }); it('calls getAnimation with orchestrator type and working state', () => { diff --git a/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/catwalk-sprite-loader.test.ts b/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/catwalk-sprite-loader.test.ts index a24a4344..73912160 100644 --- a/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/catwalk-sprite-loader.test.ts +++ b/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/catwalk-sprite-loader.test.ts @@ -6,7 +6,7 @@ import { ORCH_WORKING_STRATEGY, WORKING_DURATION, WORKING_FRAME_COORDINATES, -} from '../../../../game/sprites/sprite-definitions.js'; +} from '../sprite-definitions.js'; const { mockImageSourceConstructor, mockImageSourceLoad, mockSpriteSheetFromImageSource, mockAnimationFromCoords } = vi.hoisted(() => { @@ -136,28 +136,44 @@ describe('catwalk-sprite-loader', () => { expect(mockImageSourceConstructor).toHaveBeenCalledTimes(4); }); - it('orchestrator working animation uses ORCH_WORKING_DURATION (500ms)', () => { - loadAllCatwalkSprites(); - const workingCall = mockAnimationFromCoords.mock.calls.find( - (call) => - call[0]?.frameCoordinates?.[0]?.x === ORCH_WORKING_FRAME_COORDINATES[0].x && - call[0]?.frameCoordinates?.[0]?.y === ORCH_WORKING_FRAME_COORDINATES[0].y && - call[0]?.frameCoordinates?.length === ORCH_WORKING_FRAME_COORDINATES.length, + it('orchestrator working animation uses ORCH_WORKING_DURATION (500ms)', async () => { + await loadAllCatwalkSprites(); + + interface AnimCallConfig { + frameCoordinates: Array<{ x: number; y: number }>; + durationPerFrameMs: number; + strategy: string; + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- vi.fn() mock calls are untyped; assertion safe in test code + const calls = mockAnimationFromCoords.mock.calls as Array<[AnimCallConfig]>; + const orchCoord = ORCH_WORKING_FRAME_COORDINATES[0]; + const workingCall = calls.find( + ([config]) => + config.frameCoordinates.length === ORCH_WORKING_FRAME_COORDINATES.length && + config.frameCoordinates[0]?.x === orchCoord?.x && + config.frameCoordinates[0]?.y === orchCoord?.y, ); expect(workingCall).toBeDefined(); - expect(workingCall![0].durationPerFrameMs).toBe(ORCH_WORKING_DURATION); - expect(workingCall![0].strategy).toBe(ORCH_WORKING_STRATEGY); + expect(workingCall?.[0].durationPerFrameMs).toBe(ORCH_WORKING_DURATION); + expect(workingCall?.[0].strategy).toBe(ORCH_WORKING_STRATEGY); }); - it('subagent working animation still uses original WORKING_DURATION (300ms)', () => { - loadAllCatwalkSprites(); - const workingCall = mockAnimationFromCoords.mock.calls.find( - (call) => - call[0]?.frameCoordinates?.[0]?.x === WORKING_FRAME_COORDINATES[0].x && - call[0]?.frameCoordinates?.[0]?.y === WORKING_FRAME_COORDINATES[0].y, + it('subagent working animation still uses original WORKING_DURATION (300ms)', async () => { + await loadAllCatwalkSprites(); + + interface AnimCallConfig { + frameCoordinates: Array<{ x: number; y: number }>; + durationPerFrameMs: number; + strategy: string; + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- vi.fn() mock calls are untyped; assertion safe in test code + const calls = mockAnimationFromCoords.mock.calls as Array<[AnimCallConfig]>; + const subCoord = WORKING_FRAME_COORDINATES[0]; + const workingCall = calls.find( + ([config]) => config.frameCoordinates[0]?.x === subCoord?.x && config.frameCoordinates[0]?.y === subCoord?.y, ); expect(workingCall).toBeDefined(); - expect(workingCall![0].durationPerFrameMs).toBe(WORKING_DURATION); + expect(workingCall?.[0].durationPerFrameMs).toBe(WORKING_DURATION); }); }); diff --git a/packages/factory/src/client/visualizations/catwalk/sprites/assets/orchestrator.svg b/packages/factory/src/client/visualizations/catwalk/sprites/assets/orchestrator.svg index 16e6bf8a..674aed17 100644 --- a/packages/factory/src/client/visualizations/catwalk/sprites/assets/orchestrator.svg +++ b/packages/factory/src/client/visualizations/catwalk/sprites/assets/orchestrator.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/factory/src/client/visualizations/catwalk/sprites/catwalk-sprite-loader.ts b/packages/factory/src/client/visualizations/catwalk/sprites/catwalk-sprite-loader.ts index b15392ed..890a2c3b 100644 --- a/packages/factory/src/client/visualizations/catwalk/sprites/catwalk-sprite-loader.ts +++ b/packages/factory/src/client/visualizations/catwalk/sprites/catwalk-sprite-loader.ts @@ -45,11 +45,11 @@ const SPRITE_TYPES: readonly CatwalkSpriteType[] = ['subagent', 'orchestrator']; let animationCache: Map> | undefined; -type FrameConfig = { +interface FrameConfig { frameCoordinates: ReadonlyArray<{ x: number; y: number }>; duration: number; strategy: import('excalibur').AnimationStrategy; -}; +} /** Resolves frame coordinates and timing for orchestrator sprite states. */ function orchestratorFrameConfig(state: AgentAnimationState): FrameConfig { From 231470ac20d9a7c224271d7ce8bc376ffa8cb5c3 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Thu, 12 Mar 2026 19:56:52 -0700 Subject: [PATCH 8/8] factory|fix: Address code review findings (W1, R1, S2) Add missing ORCH_CONCERNED_DURATION test, rename FrameConfig.duration to durationMs per naming conventions, label spare frames as '(spare)'. --- .../scripts/sprites/preview-renderer.ts | 6 ++-- .../__tests__/sprite-definitions.test.ts | 5 +++ .../catwalk/sprites/catwalk-sprite-loader.ts | 34 +++++++++---------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/packages/factory/scripts/sprites/preview-renderer.ts b/packages/factory/scripts/sprites/preview-renderer.ts index d4095abc..859f7aa6 100644 --- a/packages/factory/scripts/sprites/preview-renderer.ts +++ b/packages/factory/scripts/sprites/preview-renderer.ts @@ -25,15 +25,15 @@ const ORCHESTRATOR_FRAME_LABELS = [ 'Idle 1', 'Idle 2', 'Concerned', - '', + '(spare)', 'Working 1', 'Working 2', 'Walking 1', 'Walking 2', 'Celebrating 1', 'Celebrating 2', - '', - '', + '(spare)', + '(spare)', ]; const SUBAGENT_ANIMATION_NAMES = ['Idle', 'Walking', 'Working', 'Celebrating', 'Concerned', 'Resting']; diff --git a/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/sprite-definitions.test.ts b/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/sprite-definitions.test.ts index 3f433a05..a36b4612 100644 --- a/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/sprite-definitions.test.ts +++ b/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/sprite-definitions.test.ts @@ -24,6 +24,7 @@ const { ORCH_CELEBRATING_DURATION, ORCH_CELEBRATING_FRAME_COORDINATES, ORCH_CELEBRATING_STRATEGY, + ORCH_CONCERNED_DURATION, ORCH_CONCERNED_FRAME_COORDINATES, ORCH_CONCERNED_STRATEGY, ORCH_IDLE_DURATION, @@ -251,6 +252,10 @@ describe('sprite definitions', () => { it('ORCH_WALKING_DURATION is 300ms', () => { expect(ORCH_WALKING_DURATION).toBe(300); }); + + it('ORCH_CONCERNED_DURATION is 600ms', () => { + expect(ORCH_CONCERNED_DURATION).toBe(600); + }); }); describe('orchestrator strategies', () => { diff --git a/packages/factory/src/client/visualizations/catwalk/sprites/catwalk-sprite-loader.ts b/packages/factory/src/client/visualizations/catwalk/sprites/catwalk-sprite-loader.ts index 890a2c3b..5e7935e2 100644 --- a/packages/factory/src/client/visualizations/catwalk/sprites/catwalk-sprite-loader.ts +++ b/packages/factory/src/client/visualizations/catwalk/sprites/catwalk-sprite-loader.ts @@ -47,7 +47,7 @@ let animationCache: Map> interface FrameConfig { frameCoordinates: ReadonlyArray<{ x: number; y: number }>; - duration: number; + durationMs: number; strategy: import('excalibur').AnimationStrategy; } @@ -57,45 +57,45 @@ function orchestratorFrameConfig(state: AgentAnimationState): FrameConfig { case 'idle': return { frameCoordinates: ORCH_IDLE_FRAME_COORDINATES, - duration: ORCH_IDLE_DURATION, + durationMs: ORCH_IDLE_DURATION, strategy: ORCH_IDLE_STRATEGY, }; case 'walking': return { frameCoordinates: ORCH_WALKING_FRAME_COORDINATES, - duration: ORCH_WALKING_DURATION, + durationMs: ORCH_WALKING_DURATION, strategy: ORCH_WALKING_STRATEGY, }; case 'working': return { frameCoordinates: ORCH_WORKING_FRAME_COORDINATES, - duration: ORCH_WORKING_DURATION, + durationMs: ORCH_WORKING_DURATION, strategy: ORCH_WORKING_STRATEGY, }; case 'celebrating': return { frameCoordinates: ORCH_CELEBRATING_FRAME_COORDINATES, - duration: ORCH_CELEBRATING_DURATION, + durationMs: ORCH_CELEBRATING_DURATION, strategy: ORCH_CELEBRATING_STRATEGY, }; case 'concerned': return { frameCoordinates: ORCH_CONCERNED_FRAME_COORDINATES, - duration: ORCH_CONCERNED_DURATION, + durationMs: ORCH_CONCERNED_DURATION, strategy: ORCH_CONCERNED_STRATEGY, }; case 'resting': // Orchestrator never enters resting; reuse idle. return { frameCoordinates: ORCH_IDLE_FRAME_COORDINATES, - duration: ORCH_IDLE_DURATION, + durationMs: ORCH_IDLE_DURATION, strategy: ORCH_IDLE_STRATEGY, }; case 'deactivated': // Deactivated agents reuse the idle animation; opacity is handled by the actor. return { frameCoordinates: ORCH_IDLE_FRAME_COORDINATES, - duration: ORCH_IDLE_DURATION, + durationMs: ORCH_IDLE_DURATION, strategy: ORCH_IDLE_STRATEGY, }; default: { @@ -109,28 +109,28 @@ function orchestratorFrameConfig(state: AgentAnimationState): FrameConfig { function subagentFrameConfig(state: AgentAnimationState): FrameConfig { switch (state) { case 'idle': - return { frameCoordinates: IDLE_FRAME_COORDINATES, duration: IDLE_DURATION, strategy: IDLE_STRATEGY }; + return { frameCoordinates: IDLE_FRAME_COORDINATES, durationMs: IDLE_DURATION, strategy: IDLE_STRATEGY }; case 'walking': - return { frameCoordinates: WALKING_FRAME_COORDINATES, duration: WALKING_DURATION, strategy: WALKING_STRATEGY }; + return { frameCoordinates: WALKING_FRAME_COORDINATES, durationMs: WALKING_DURATION, strategy: WALKING_STRATEGY }; case 'working': - return { frameCoordinates: WORKING_FRAME_COORDINATES, duration: WORKING_DURATION, strategy: WORKING_STRATEGY }; + return { frameCoordinates: WORKING_FRAME_COORDINATES, durationMs: WORKING_DURATION, strategy: WORKING_STRATEGY }; case 'celebrating': return { frameCoordinates: CELEBRATING_FRAME_COORDINATES, - duration: CELEBRATING_DURATION, + durationMs: CELEBRATING_DURATION, strategy: CELEBRATING_STRATEGY, }; case 'concerned': return { frameCoordinates: CONCERNED_FRAME_COORDINATES, - duration: CONCERNED_DURATION, + durationMs: CONCERNED_DURATION, strategy: CONCERNED_STRATEGY, }; case 'resting': - return { frameCoordinates: RESTING_FRAME_COORDINATES, duration: RESTING_DURATION, strategy: RESTING_STRATEGY }; + return { frameCoordinates: RESTING_FRAME_COORDINATES, durationMs: RESTING_DURATION, strategy: RESTING_STRATEGY }; case 'deactivated': // Deactivated agents reuse the idle animation; opacity is handled by the actor. - return { frameCoordinates: IDLE_FRAME_COORDINATES, duration: IDLE_DURATION, strategy: IDLE_STRATEGY }; + return { frameCoordinates: IDLE_FRAME_COORDINATES, durationMs: IDLE_DURATION, strategy: IDLE_STRATEGY }; default: { const _exhaustive: never = state; return _exhaustive; @@ -163,11 +163,11 @@ function buildAnimationsForSheet( ): Map { const map = new Map(); for (const state of ALL_STATES) { - const { frameCoordinates, duration, strategy } = frameConfigForState(state, spriteType); + const { frameCoordinates, durationMs, strategy } = frameConfigForState(state, spriteType); const animation = Animation.fromSpriteSheetCoordinates({ spriteSheet, frameCoordinates: [...frameCoordinates], - durationPerFrameMs: duration, + durationPerFrameMs: durationMs, strategy, }); map.set(state, animation);