diff --git a/packages/factory/src/client/visualizations/office/__tests__/office-layout.test.ts b/packages/factory/src/client/visualizations/office/__tests__/office-layout.test.ts index e31c1001..9a13db12 100644 --- a/packages/factory/src/client/visualizations/office/__tests__/office-layout.test.ts +++ b/packages/factory/src/client/visualizations/office/__tests__/office-layout.test.ts @@ -106,6 +106,21 @@ describe(createOfficeLayout, () => { }); }); + describe('slotDefinition', () => { + it('returns the correct definition for a known slot', () => { + const slot = layout.slotDefinition('workshop-desk-1'); + expect(slot).toBeDefined(); + expect(slot?.id).toBe('workshop-desk-1'); + expect(slot?.type).toBe('workstation'); + expect(slot?.tile).toEqual({ col: 21, row: 7 }); + expect(slot?.facing).toBe('down'); + }); + + it('returns undefined for an unknown slot ID', () => { + expect(layout.slotDefinition('nonexistent')).toBeUndefined(); + }); + }); + describe('zones', () => { it('exposes all zone definitions', () => { expect(layout.zones).toHaveLength(3); diff --git a/packages/factory/src/client/visualizations/office/__tests__/office-scene.test.ts b/packages/factory/src/client/visualizations/office/__tests__/office-scene.test.ts index 99c0954f..2fb58c47 100644 --- a/packages/factory/src/client/visualizations/office/__tests__/office-scene.test.ts +++ b/packages/factory/src/client/visualizations/office/__tests__/office-scene.test.ts @@ -1,19 +1,52 @@ import { describe, expect, it, vi } from 'vitest'; import type { CanonicalRunStatus, Phases } from '../../../../shared/types/canonical.js'; +import type { AnimationHandle, TransitionContext } from '../transitions/transition-executor.js'; +import type { TransitionPlan } from '../types.js'; // Track actors added/removed through the mock let actorCount = 0; +// Capture transition plans passed to executeTransitions for assertion +let capturedPlans: TransitionPlan[] = []; +const mockExecuteTransitions = vi.fn((plan: TransitionPlan, _context: TransitionContext): AnimationHandle => { + capturedPlans.push(plan); + return { cancel: vi.fn() }; +}); + +vi.mock('../transitions/transition-executor.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + executeTransitions: (plan: TransitionPlan, context: TransitionContext): AnimationHandle => + mockExecuteTransitions(plan, context), + }; +}); + // Mock Excalibur to avoid canvas/WebGL dependencies in tests vi.mock('excalibur', () => { class MockGraphic { opacity = 1; use() {} } + + /** Create chainable mock actions object. */ + function createMockActions() { + const actions = { + moveTo: vi.fn().mockReturnThis(), + fade: vi.fn().mockReturnThis(), + scaleTo: vi.fn().mockReturnThis(), + callMethod: vi.fn().mockReturnThis(), + clearActions: vi.fn(), + }; + return actions; + } + class MockActor { pos = { x: 0, y: 0 }; + scale = { x: 1, y: 1 }; graphics = new MockGraphic(); + actions = createMockActions(); z = 0; constructor(opts?: { pos?: { x: number; y: number }; anchor?: { x: number; y: number }; z?: number }) { if (opts?.pos) this.pos = opts.pos; @@ -162,13 +195,14 @@ describe('OfficeScene', () => { expect(actorCount).toBeGreaterThanOrEqual(MIN_ACTOR_COUNT_AFTER_INIT); }); - it('places agents from updateStatus', () => { + it('triggers transitions with fade_in when agents are added via updateStatus', () => { + mockExecuteTransitions.mockClear(); + capturedPlans = []; actorCount = 0; const scene = new OfficeScene(buildMinimalStatus()); scene.onInitialize(); - const initialCount = actorCount; - // Add reviewers via parallelReview to get more agents + // Add reviewers via parallelReview to trigger agent fade-in transitions scene.updateStatus( buildMinimalStatus({ phases: emptyPhases({ @@ -208,8 +242,10 @@ describe('OfficeScene', () => { }), ); - // With 3 reviewers (up from the default 1), there should be at least 2 more actors - expect(actorCount).toBeGreaterThanOrEqual(initialCount + 2); + expect(mockExecuteTransitions).toHaveBeenCalledTimes(1); + expect(capturedPlans).toHaveLength(1); + const fadeIns = capturedPlans[0]?.transitions.filter((t) => t.type === 'fade_in') ?? []; + expect(fadeIns.length).toBeGreaterThan(0); }); it('handles empty state gracefully', () => { @@ -224,8 +260,11 @@ describe('OfficeScene', () => { expect(actorCount).toBe(initialCount); }); - it('clears and replaces entities on subsequent updateStatus calls', () => { + it('uses transition executor for subsequent updateStatus calls with changes', () => { + mockExecuteTransitions.mockClear(); + capturedPlans = []; actorCount = 0; + // Start with 3 reviewers const statusWith3Reviewers = buildMinimalStatus({ phases: emptyPhases({ @@ -265,21 +304,85 @@ describe('OfficeScene', () => { }); const scene = new OfficeScene(statusWith3Reviewers); scene.onInitialize(); - const withReviewersCount = actorCount; - // Reduce to 1 default reviewer + // executeTransitions should not have been called for initial render + expect(mockExecuteTransitions).not.toHaveBeenCalled(); + + // Reduce to minimal status — this should trigger the transition executor scene.updateStatus(buildMinimalStatus()); - const withDefaultCount = actorCount; - // Fewer actors because 3 reviewers -> 1 default reviewer - expect(withDefaultCount).toBeLessThan(withReviewersCount); + expect(mockExecuteTransitions).toHaveBeenCalledTimes(1); + + // Verify the plan contains fade_out transitions for removed reviewers + expect(capturedPlans).toHaveLength(1); + const fadeOuts = capturedPlans[0]?.transitions.filter((t) => t.type === 'fade_out') ?? []; + expect(fadeOuts.length).toBeGreaterThan(0); }); - it('places artifacts at assigned positions', () => { + it('cancels active animation when a new updateStatus arrives mid-animation', () => { + mockExecuteTransitions.mockClear(); + capturedPlans = []; + actorCount = 0; + const scene = new OfficeScene(buildMinimalStatus()); + scene.onInitialize(); + + // First update: add reviewers -> triggers executeTransitions + const cancelMock = vi.fn(); + mockExecuteTransitions.mockReturnValueOnce({ cancel: cancelMock }); + + scene.updateStatus( + buildMinimalStatus({ + phases: emptyPhases({ + parallelReview: { + aggregatedCriticality: undefined, + reviewRoundsUsed: 1, + coderFixCycleRan: false, + selectiveReReview: undefined, + reviewers: { + codeReviewer: { + ran: true, + status: 'completed', + criticality: 'low', + reason: undefined, + reReviewCriticality: undefined, + reReviewError: undefined, + }, + silentFailure: { + ran: true, + status: 'completed', + criticality: 'low', + reason: undefined, + reReviewCriticality: undefined, + reReviewError: undefined, + }, + testReviewer: { + ran: true, + status: 'completed', + criticality: 'low', + reason: undefined, + reReviewCriticality: undefined, + reReviewError: undefined, + }, + }, + }, + }), + }), + ); + + expect(mockExecuteTransitions).toHaveBeenCalledTimes(1); + + // Second update while first animation is still "active" — should cancel it + scene.updateStatus(buildMinimalStatus()); + + expect(cancelMock).toHaveBeenCalled(); + }); + + it('triggers transitions with artifact_appear when artifacts are added', () => { + mockExecuteTransitions.mockClear(); + capturedPlans = []; actorCount = 0; const scene = new OfficeScene(buildMinimalStatus()); scene.onInitialize(); - const initialCount = actorCount; scene.updateStatus( buildMinimalStatus({ @@ -305,7 +408,9 @@ describe('OfficeScene', () => { }), ); - // Should have added at least the artifact - expect(actorCount).toBeGreaterThan(initialCount); + expect(mockExecuteTransitions).toHaveBeenCalledTimes(1); + expect(capturedPlans).toHaveLength(1); + const artifactAppears = capturedPlans[0]?.transitions.filter((t) => t.type === 'artifact_appear') ?? []; + expect(artifactAppears.length).toBeGreaterThan(0); }); }); diff --git a/packages/factory/src/client/visualizations/office/__tests__/transition-executor.test.ts b/packages/factory/src/client/visualizations/office/__tests__/transition-executor.test.ts new file mode 100644 index 00000000..3a6a2c95 --- /dev/null +++ b/packages/factory/src/client/visualizations/office/__tests__/transition-executor.test.ts @@ -0,0 +1,650 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock Excalibur before importing executor +vi.mock('excalibur', () => { + return { + vec: (x: number, y: number) => ({ x, y }), + }; +}); + +const { computeDirection, executeTransitions } = await import('../transitions/transition-executor.js'); +const { DIR_DOWN, DIR_LEFT, DIR_RIGHT, DIR_UP } = await import('../sprites/sprite-definitions.js'); + +import type { TransitionContext } from '../transitions/transition-executor.js'; +import type { FacilityLayout, OfficeSceneConfig, Position, TransitionPlan } from '../types.js'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +/** Create a mock actor with chainable actions. */ +function createMockActor() { + const actions = { + moveTo: vi.fn().mockReturnThis(), + fade: vi.fn().mockReturnThis(), + scaleTo: vi.fn().mockReturnThis(), + callMethod: vi.fn().mockImplementation(function (this: unknown, fn?: () => void) { + if (fn !== undefined) fn(); + return actions; + }), + clearActions: vi.fn(), + }; + + return { + pos: { x: 0, y: 0 }, + scale: { x: 1, y: 1 }, + graphics: { opacity: 1, use: vi.fn() }, + actions, + }; +} + +/** Build a minimal TransitionContext for testing. */ +function buildContext(overrides: Partial = {}): TransitionContext { + const mockLayout: FacilityLayout = { + slotPosition: vi.fn().mockReturnValue({ x: 0, y: 0 }), + zoneCenter: vi.fn().mockReturnValue({ x: 0, y: 0 }), + slotsInZone: vi.fn().mockReturnValue([]), + corridorPath: vi.fn().mockReturnValue([]), + slotDefinition: vi.fn().mockReturnValue(undefined), + zones: [], + }; + + const config: OfficeSceneConfig = { + orchestrator: { + status: 'idle', + carriedArtifacts: [], + codeBadge: null, + waiting: false, + zoneId: 'governor', + }, + agents: [], + artifacts: [], + zones: [], + }; + + return { + findActor: vi.fn().mockReturnValue(undefined), + createAgent: vi.fn().mockReturnValue(createMockActor()), + createArtifact: vi.fn().mockReturnValue(createMockActor()), + createOrchestrator: vi.fn().mockReturnValue(createMockActor()), + removeActor: vi.fn(), + updateSprite: vi.fn(), + config, + nextPositions: { agents: new Map(), artifacts: new Map(), orchestrator: { x: 0, y: 0 } }, + layout: mockLayout, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// computeDirection +// --------------------------------------------------------------------------- + +describe(computeDirection, () => { + it('returns DIR_DOWN when moving south', () => { + expect(computeDirection({ x: 0, y: 0 }, { x: 0, y: 10 })).toBe(DIR_DOWN); + }); + + it('returns DIR_UP when moving north', () => { + expect(computeDirection({ x: 0, y: 10 }, { x: 0, y: 0 })).toBe(DIR_UP); + }); + + it('returns DIR_RIGHT when moving east', () => { + expect(computeDirection({ x: 0, y: 0 }, { x: 10, y: 0 })).toBe(DIR_RIGHT); + }); + + it('returns DIR_LEFT when moving west', () => { + expect(computeDirection({ x: 10, y: 0 }, { x: 0, y: 0 })).toBe(DIR_LEFT); + }); + + it('favors vertical on tie (returns DIR_DOWN for equal positive dx/dy)', () => { + expect(computeDirection({ x: 0, y: 0 }, { x: 5, y: 5 })).toBe(DIR_DOWN); + }); + + it('returns DIR_DOWN when from equals to', () => { + expect(computeDirection({ x: 5, y: 5 }, { x: 5, y: 5 })).toBe(DIR_DOWN); + }); +}); + +// --------------------------------------------------------------------------- +// executeTransitions — dispatch by type +// --------------------------------------------------------------------------- + +describe(executeTransitions, () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('dispatches walk transitions with moveTo calls', () => { + const actor = createMockActor(); + const context = buildContext({ + findActor: vi.fn().mockReturnValue(actor), + }); + + const plan: TransitionPlan = { + transitions: [ + { + type: 'walk', + entityId: 'agent-1', + entityKind: 'agent', + delayMs: 0, + waypoints: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + ], + }, + ], + }; + + executeTransitions(plan, context); + vi.advanceTimersByTime(0); + + // 2 segments = 2 moveTo calls + 2 direction callMethods + 1 resting callMethod + expect(actor.actions.moveTo).toHaveBeenCalledTimes(2); + }); + + it('dispatches fade_in transitions and creates actor', () => { + const mockActor = createMockActor(); + const createAgent = vi.fn().mockReturnValue(mockActor); + const context = buildContext({ + createAgent, + config: { + orchestrator: { status: 'idle', carriedArtifacts: [], codeBadge: null, waiting: false, zoneId: 'governor' }, + agents: [ + { + id: 'agent-1', + role: 'coder', + roleType: 'author', + phase: 'implementation', + status: 'working', + zoneId: 'workshop', + slotId: 'workshop-desk-0', + }, + ], + artifacts: [], + zones: [], + }, + }); + + const plan: TransitionPlan = { + transitions: [ + { + type: 'fade_in', + entityId: 'agent-1', + entityKind: 'agent', + delayMs: 0, + waypoints: [{ x: 50, y: 50 }], + }, + ], + }; + + executeTransitions(plan, context); + vi.advanceTimersByTime(0); + + expect(createAgent).toHaveBeenCalledWith('agent-1', { x: 50, y: 50 }, 'implementation'); + expect(mockActor.graphics.opacity).toBe(0); + expect(mockActor.actions.fade).toHaveBeenCalledWith(1, expect.any(Number)); + }); + + it('dispatches fade_in for orchestrator entity kind', () => { + const mockActor = createMockActor(); + const createOrchestrator = vi.fn().mockReturnValue(mockActor); + const context = buildContext({ createOrchestrator }); + + const plan: TransitionPlan = { + transitions: [ + { + type: 'fade_in', + entityId: 'orchestrator', + entityKind: 'orchestrator', + delayMs: 0, + waypoints: [{ x: 100, y: 200 }], + }, + ], + }; + + executeTransitions(plan, context); + vi.advanceTimersByTime(0); + + expect(createOrchestrator).toHaveBeenCalledWith({ x: 100, y: 200 }); + expect(mockActor.graphics.opacity).toBe(0); + expect(mockActor.actions.fade).toHaveBeenCalledWith(1, expect.any(Number)); + }); + + it('dispatches fade_out transitions and removes actor', () => { + const actor = createMockActor(); + const removeActor = vi.fn(); + const context = buildContext({ + findActor: vi.fn().mockReturnValue(actor), + removeActor, + }); + + const plan: TransitionPlan = { + transitions: [ + { + type: 'fade_out', + entityId: 'agent-1', + entityKind: 'agent', + delayMs: 0, + waypoints: [{ x: 50, y: 50 }], + }, + ], + }; + + executeTransitions(plan, context); + vi.advanceTimersByTime(0); + + expect(actor.actions.fade).toHaveBeenCalledWith(0, expect.any(Number)); + // callMethod invoked removeActor synchronously in the mock + expect(removeActor).toHaveBeenCalledWith('agent-1', 'agent'); + }); + + it('dispatches state_change transitions with pulse animation', () => { + const actor = createMockActor(); + const context = buildContext({ + findActor: vi.fn().mockReturnValue(actor), + }); + + const plan: TransitionPlan = { + transitions: [ + { + type: 'state_change', + entityId: 'agent-1', + entityKind: 'agent', + delayMs: 0, + from: 'idle', + to: 'active', + }, + ], + }; + + executeTransitions(plan, context); + vi.advanceTimersByTime(0); + + // Two fade calls: dim then restore + expect(actor.actions.fade).toHaveBeenCalledTimes(2); + expect(actor.actions.fade).toHaveBeenNthCalledWith(1, 0.5, expect.any(Number)); + expect(actor.actions.fade).toHaveBeenNthCalledWith(2, 1, expect.any(Number)); + }); + + it('dispatches artifact_appear transitions with scale animation', () => { + const mockActor = createMockActor(); + const createArtifact = vi.fn().mockReturnValue(mockActor); + const context = buildContext({ + createArtifact, + config: { + orchestrator: { status: 'idle', carriedArtifacts: [], codeBadge: null, waiting: false, zoneId: 'governor' }, + agents: [], + artifacts: [ + { + id: 'artifact-1', + label: 'plan', + color: '#ff0000', + status: 'created', + producerPhase: 'planning', + zoneId: 'workshop', + slotId: 'workshop-desk-0', + }, + ], + zones: [], + }, + }); + + const plan: TransitionPlan = { + transitions: [ + { + type: 'artifact_appear', + entityId: 'artifact-1', + entityKind: 'artifact', + delayMs: 0, + waypoints: [{ x: 200, y: 100 }], + }, + ], + }; + + executeTransitions(plan, context); + vi.advanceTimersByTime(0); + + expect(createArtifact).toHaveBeenCalledWith('artifact-1', { x: 200, y: 100 }, '#ff0000'); + expect(mockActor.scale).toEqual({ x: 0, y: 0 }); + expect(mockActor.actions.scaleTo).toHaveBeenCalledTimes(2); + }); + + it('dispatches artifact_deliver transitions with moveTo', () => { + const actor = createMockActor(); + const deliveryPos: Position = { x: 300, y: 150 }; + const artifactPositions = new Map([['artifact-1', deliveryPos]]); + + const context = buildContext({ + findActor: vi.fn().mockReturnValue(actor), + nextPositions: { agents: new Map(), artifacts: artifactPositions, orchestrator: { x: 0, y: 0 } }, + }); + + const plan: TransitionPlan = { + transitions: [ + { + type: 'artifact_deliver', + entityId: 'artifact-1', + entityKind: 'artifact', + delayMs: 0, + from: 'available', + to: 'delivered', + }, + ], + }; + + executeTransitions(plan, context); + vi.advanceTimersByTime(0); + + expect(actor.actions.moveTo).toHaveBeenCalledWith({ x: 300, y: 150 }, expect.any(Number)); + }); + + // --------------------------------------------------------------------------- + // Edge cases — silent early returns + // --------------------------------------------------------------------------- + + it('does nothing for walk transition with fewer than 2 waypoints', () => { + const actor = createMockActor(); + const context = buildContext({ + findActor: vi.fn().mockReturnValue(actor), + }); + + const plan: TransitionPlan = { + transitions: [ + { + type: 'walk', + entityId: 'agent-1', + entityKind: 'agent', + delayMs: 0, + waypoints: [{ x: 0, y: 0 }], + }, + ], + }; + + executeTransitions(plan, context); + vi.advanceTimersByTime(0); + + expect(actor.actions.moveTo).not.toHaveBeenCalled(); + }); + + it('does nothing for artifact_deliver with missing target position', () => { + const actor = createMockActor(); + const context = buildContext({ + findActor: vi.fn().mockReturnValue(actor), + nextPositions: { agents: new Map(), artifacts: new Map(), orchestrator: { x: 0, y: 0 } }, + }); + + const plan: TransitionPlan = { + transitions: [ + { + type: 'artifact_deliver', + entityId: 'artifact-missing', + entityKind: 'artifact', + delayMs: 0, + }, + ], + }; + + executeTransitions(plan, context); + vi.advanceTimersByTime(0); + + expect(actor.actions.moveTo).not.toHaveBeenCalled(); + }); + + // --------------------------------------------------------------------------- + // Stagger timing + // --------------------------------------------------------------------------- + + it('respects delayMs stagger timing', () => { + const actor = createMockActor(); + const context = buildContext({ + findActor: vi.fn().mockReturnValue(actor), + }); + + const plan: TransitionPlan = { + transitions: [ + { type: 'state_change', entityId: 'a1', entityKind: 'agent', delayMs: 0 }, + { type: 'state_change', entityId: 'a2', entityKind: 'agent', delayMs: 150 }, + { type: 'state_change', entityId: 'a3', entityKind: 'agent', delayMs: 300 }, + ], + }; + + executeTransitions(plan, context); + + // At t=0, only the first transition should have fired + vi.advanceTimersByTime(0); + expect(actor.actions.fade).toHaveBeenCalledTimes(2); // 1 pulse = 2 fades + + // At t=150, second transition fires + vi.advanceTimersByTime(150); + expect(actor.actions.fade).toHaveBeenCalledTimes(4); + + // At t=300, third transition fires + vi.advanceTimersByTime(150); + expect(actor.actions.fade).toHaveBeenCalledTimes(6); + }); + + // --------------------------------------------------------------------------- + // Cancel behavior + // --------------------------------------------------------------------------- + + it('cancel clears pending timers and actions', () => { + const actor = createMockActor(); + const context = buildContext({ + findActor: vi.fn().mockReturnValue(actor), + }); + + const plan: TransitionPlan = { + transitions: [ + { type: 'state_change', entityId: 'a1', entityKind: 'agent', delayMs: 0 }, + { type: 'state_change', entityId: 'a2', entityKind: 'agent', delayMs: 500 }, + ], + }; + + const handle = executeTransitions(plan, context); + + // Fire the first transition + vi.advanceTimersByTime(0); + expect(actor.actions.fade).toHaveBeenCalledTimes(2); + + // Cancel before the second fires + handle.cancel(); + expect(actor.actions.clearActions).toHaveBeenCalled(); + + // Advance past the second transition's delay — should not fire + vi.advanceTimersByTime(600); + expect(actor.actions.fade).toHaveBeenCalledTimes(2); + }); + + it('cancel clears actions on actors created by fade_in transitions', () => { + const createdActor = createMockActor(); + const createAgent = vi.fn().mockReturnValue(createdActor); + const context = buildContext({ + findActor: vi.fn().mockImplementation((id: string) => { + // After creation, findActor resolves the new actor + if (id === 'new-agent') return createdActor; + return undefined; + }), + createAgent, + config: { + orchestrator: { status: 'idle', carriedArtifacts: [], codeBadge: null, waiting: false, zoneId: 'governor' }, + agents: [ + { + id: 'new-agent', + role: 'coder', + roleType: 'author', + phase: 'implementation', + status: 'working', + zoneId: 'workshop', + slotId: 'workshop-desk-0', + }, + ], + artifacts: [], + zones: [], + }, + }); + + const plan: TransitionPlan = { + transitions: [ + { + type: 'fade_in', + entityId: 'new-agent', + entityKind: 'agent', + delayMs: 0, + waypoints: [{ x: 50, y: 50 }], + }, + { type: 'state_change', entityId: 'new-agent', entityKind: 'agent', delayMs: 500 }, + ], + }; + + const handle = executeTransitions(plan, context); + vi.advanceTimersByTime(0); + + expect(createAgent).toHaveBeenCalled(); + + handle.cancel(); + expect(createdActor.actions.clearActions).toHaveBeenCalled(); + + // Second transition should not fire + vi.advanceTimersByTime(600); + expect(createdActor.actions.fade).toHaveBeenCalledTimes(1); // Only the initial fade_in + }); + + // --------------------------------------------------------------------------- + // Resting direction resolution + // --------------------------------------------------------------------------- + + it('sets resting direction to DIR_DOWN for orchestrator after walk', () => { + const actor = createMockActor(); + const updateSprite = vi.fn(); + const context = buildContext({ + findActor: vi.fn().mockReturnValue(actor), + updateSprite, + }); + + const plan: TransitionPlan = { + transitions: [ + { + type: 'walk', + entityId: 'orchestrator', + entityKind: 'orchestrator', + delayMs: 0, + waypoints: [ + { x: 0, y: 0 }, + { x: 100, y: 100 }, + ], + }, + ], + }; + + executeTransitions(plan, context); + vi.advanceTimersByTime(0); + + // Last updateSprite call is the resting direction + const lastCall = updateSprite.mock.calls.at(-1); + expect(lastCall).toBeDefined(); + expect(lastCall?.[3]).toBe(DIR_DOWN); + }); + + it('uses DIR_UP as fallback resting direction when agent is absent from config', () => { + const actor = createMockActor(); + const updateSprite = vi.fn(); + const context = buildContext({ + findActor: vi.fn().mockReturnValue(actor), + updateSprite, + config: { + orchestrator: { status: 'idle', carriedArtifacts: [], codeBadge: null, waiting: false, zoneId: 'governor' }, + agents: [], // agent-1 intentionally absent + artifacts: [], + zones: [], + }, + }); + + const plan: TransitionPlan = { + transitions: [ + { + type: 'walk', + entityId: 'agent-1', + entityKind: 'agent', + delayMs: 0, + waypoints: [ + { x: 0, y: 0 }, + { x: 100, y: 100 }, + ], + }, + ], + }; + + executeTransitions(plan, context); + vi.advanceTimersByTime(0); + + const lastCall = updateSprite.mock.calls.at(-1); + expect(lastCall?.[3]).toBe(DIR_UP); + }); + + it('resolves agent resting direction from slot facing metadata', () => { + const actor = createMockActor(); + const updateSprite = vi.fn(); + const slotDefinition = vi + .fn() + .mockReturnValue({ id: 'workshop-desk-1', type: 'workstation', tile: { col: 21, row: 7 }, facing: 'down' }); + + const context = buildContext({ + findActor: vi.fn().mockReturnValue(actor), + updateSprite, + config: { + orchestrator: { status: 'idle', carriedArtifacts: [], codeBadge: null, waiting: false, zoneId: 'governor' }, + agents: [ + { + id: 'agent-1', + role: 'reviewer', + roleType: 'reviewer', + phase: 'review', + status: 'working', + zoneId: 'workshop', + slotId: 'workshop-desk-1', + }, + ], + artifacts: [], + zones: [], + }, + layout: { + slotPosition: vi.fn().mockReturnValue({ x: 0, y: 0 }), + zoneCenter: vi.fn().mockReturnValue({ x: 0, y: 0 }), + slotsInZone: vi.fn().mockReturnValue([]), + corridorPath: vi.fn().mockReturnValue([]), + slotDefinition, + zones: [], + }, + }); + + const plan: TransitionPlan = { + transitions: [ + { + type: 'walk', + entityId: 'agent-1', + entityKind: 'agent', + delayMs: 0, + waypoints: [ + { x: 0, y: 0 }, + { x: 100, y: 100 }, + ], + }, + ], + }; + + executeTransitions(plan, context); + vi.advanceTimersByTime(0); + + // Last updateSprite call should be the resting direction from slot.facing + const lastCall = updateSprite.mock.calls.at(-1); + expect(lastCall).toBeDefined(); + expect(lastCall?.[3]).toBe(DIR_DOWN); + expect(slotDefinition).toHaveBeenCalledWith('workshop-desk-1'); + }); +}); diff --git a/packages/factory/src/client/visualizations/office/constants/__tests__/animation.test.ts b/packages/factory/src/client/visualizations/office/constants/__tests__/animation.test.ts new file mode 100644 index 00000000..5a2f761d --- /dev/null +++ b/packages/factory/src/client/visualizations/office/constants/__tests__/animation.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { + ARTIFACT_APPEAR_PHASE_MS, + ARTIFACT_APPEAR_SCALE_SPEED, + ARTIFACT_DELIVER_SPEED_PX_PER_SEC, + ARTIFACT_OVERSHOOT_SCALE, + FADE_DURATION_MS, + STATE_CHANGE_PULSE_HALF_MS, + STATE_CHANGE_PULSE_OPACITY, + WALK_SPEED_PX_PER_SEC, +} from '../animation.js'; + +describe('animation constants', () => { + it('exports positive walk speed', () => { + expect(WALK_SPEED_PX_PER_SEC).toBeGreaterThan(0); + }); + + it('exports positive fade duration', () => { + expect(FADE_DURATION_MS).toBeGreaterThan(0); + }); + + it('exports pulse opacity between 0 and 1', () => { + expect(STATE_CHANGE_PULSE_OPACITY).toBeGreaterThan(0); + expect(STATE_CHANGE_PULSE_OPACITY).toBeLessThan(1); + }); + + it('exports positive pulse half duration', () => { + expect(STATE_CHANGE_PULSE_HALF_MS).toBeGreaterThan(0); + }); + + it('exports overshoot scale above 1', () => { + expect(ARTIFACT_OVERSHOOT_SCALE).toBeGreaterThan(1); + }); + + it('exports positive artifact appear phase duration', () => { + expect(ARTIFACT_APPEAR_PHASE_MS).toBeGreaterThan(0); + }); + + it('derives scale speed from phase duration and overshoot', () => { + const expected = ARTIFACT_OVERSHOOT_SCALE / (ARTIFACT_APPEAR_PHASE_MS / 1000); + expect(ARTIFACT_APPEAR_SCALE_SPEED).toBeCloseTo(expected); + }); + + it('exports positive delivery speed', () => { + expect(ARTIFACT_DELIVER_SPEED_PX_PER_SEC).toBeGreaterThan(0); + }); +}); diff --git a/packages/factory/src/client/visualizations/office/constants/animation.ts b/packages/factory/src/client/visualizations/office/constants/animation.ts new file mode 100644 index 00000000..810330b1 --- /dev/null +++ b/packages/factory/src/client/visualizations/office/constants/animation.ts @@ -0,0 +1,29 @@ +// --------------------------------------------------------------------------- +// Animation constants +// --------------------------------------------------------------------------- +// Centralized timing, speed, opacity, and scale values for office transitions. +// Tweak values here to adjust the feel of all office animations. + +/** Walk speed in pixels per second (~3.5 tiles/sec at 32px/tile). */ +export const WALK_SPEED_PX_PER_SEC = 112; + +/** Duration for fade-in and fade-out transitions in milliseconds. */ +export const FADE_DURATION_MS = 300; + +/** Target opacity during the dim phase of a state-change pulse. */ +export const STATE_CHANGE_PULSE_OPACITY = 0.5; + +/** Duration of each half of a state-change pulse (dim then restore) in milliseconds. */ +export const STATE_CHANGE_PULSE_HALF_MS = 250; + +/** Overshoot scale for the artifact-appear bounce effect. */ +export const ARTIFACT_OVERSHOOT_SCALE = 1.2; + +/** Duration of each phase of the artifact-appear animation (overshoot, settle) in milliseconds. */ +export const ARTIFACT_APPEAR_PHASE_MS = 200; + +/** Speed for computing `scaleTo` velocity during artifact-appear (scale units per second). */ +export const ARTIFACT_APPEAR_SCALE_SPEED = ARTIFACT_OVERSHOOT_SCALE / (ARTIFACT_APPEAR_PHASE_MS / 1000); + +/** Movement speed for artifact delivery in pixels per second. */ +export const ARTIFACT_DELIVER_SPEED_PX_PER_SEC = 80; diff --git a/packages/factory/src/client/visualizations/office/constants/zone-definitions.ts b/packages/factory/src/client/visualizations/office/constants/zone-definitions.ts index d9097e5b..2ff5e852 100644 --- a/packages/factory/src/client/visualizations/office/constants/zone-definitions.ts +++ b/packages/factory/src/client/visualizations/office/constants/zone-definitions.ts @@ -11,8 +11,8 @@ export const PREP_ZONE: ZoneDefinition = { label: 'Prep area', bounds: { col: 1, row: 0, width: 11, height: 12 }, slots: [ - { id: 'prep-desk-0', type: 'workstation', tile: { col: 3, row: 5 } }, - { id: 'prep-desk-1', type: 'workstation', tile: { col: 8, row: 5 } }, + { id: 'prep-desk-0', type: 'workstation', tile: { col: 3, row: 5 }, facing: 'up' }, + { id: 'prep-desk-1', type: 'workstation', tile: { col: 8, row: 5 }, facing: 'up' }, ], doors: [{ tile: { col: 6, row: 12 }, direction: 'down' }], }; @@ -28,12 +28,12 @@ export const WORKSHOP_ZONE: ZoneDefinition = { label: 'The workshop', bounds: { col: 13, row: 0, width: 23, height: 12 }, slots: [ - { id: 'workshop-desk-0', type: 'workstation', tile: { col: 15, row: 5 } }, - { id: 'workshop-desk-1', type: 'workstation', tile: { col: 21, row: 7 } }, - { id: 'workshop-desk-2', type: 'workstation', tile: { col: 25, row: 7 } }, - { id: 'workshop-desk-3', type: 'workstation', tile: { col: 28, row: 7 } }, - { id: 'workshop-desk-4', type: 'workstation', tile: { col: 31, row: 7 } }, - { id: 'workshop-desk-5', type: 'workstation', tile: { col: 34, row: 7 } }, + { id: 'workshop-desk-0', type: 'workstation', tile: { col: 15, row: 5 }, facing: 'up' }, + { id: 'workshop-desk-1', type: 'workstation', tile: { col: 21, row: 7 }, facing: 'down' }, + { id: 'workshop-desk-2', type: 'workstation', tile: { col: 25, row: 7 }, facing: 'down' }, + { id: 'workshop-desk-3', type: 'workstation', tile: { col: 28, row: 7 }, facing: 'down' }, + { id: 'workshop-desk-4', type: 'workstation', tile: { col: 31, row: 7 }, facing: 'down' }, + { id: 'workshop-desk-5', type: 'workstation', tile: { col: 34, row: 7 }, facing: 'down' }, ], doors: [{ tile: { col: 25, row: 12 }, direction: 'down' }], }; @@ -49,7 +49,7 @@ export const GOVERNOR_ZONE: ZoneDefinition = { label: "Governor's office", bounds: { col: 19, row: 13, width: 17, height: 9 }, slots: [ - { id: 'governor-desk-0', type: 'workstation', tile: { col: 23, row: 18 } }, + { id: 'governor-desk-0', type: 'workstation', tile: { col: 23, row: 18 }, facing: 'down' }, { id: 'governor-storage-0', type: 'storage', tile: { col: 28, row: 16 } }, { id: 'governor-storage-1', type: 'storage', tile: { col: 31, row: 16 } }, { id: 'governor-storage-2', type: 'storage', tile: { col: 34, row: 16 } }, diff --git a/packages/factory/src/client/visualizations/office/layout/office-layout.ts b/packages/factory/src/client/visualizations/office/layout/office-layout.ts index 9ccec6dd..94eac6a3 100644 --- a/packages/factory/src/client/visualizations/office/layout/office-layout.ts +++ b/packages/factory/src/client/visualizations/office/layout/office-layout.ts @@ -67,6 +67,14 @@ export function createOfficeLayout(): FacilityLayout { zoneCenters.set(zone.id, computeZoneCenter(zone)); } + // Build slot definition lookup + const slotById = new Map(); + for (const zone of zones) { + for (const slot of zone.slots) { + slotById.set(slot.id, slot); + } + } + // Build zone lookup const zoneById = new Map(); for (const zone of zones) { @@ -119,11 +127,16 @@ export function createOfficeLayout(): FacilityLayout { return [...path]; } + function slotDefinition(slotId: string): SlotDefinition | undefined { + return slotById.get(slotId); + } + return { slotPosition, zoneCenter, slotsInZone, corridorPath, + slotDefinition, zones, }; } diff --git a/packages/factory/src/client/visualizations/office/scene/OfficeScene.ts b/packages/factory/src/client/visualizations/office/scene/OfficeScene.ts index 85e95f1f..369a0e46 100644 --- a/packages/factory/src/client/visualizations/office/scene/OfficeScene.ts +++ b/packages/factory/src/client/visualizations/office/scene/OfficeScene.ts @@ -19,7 +19,10 @@ import { } from '../sprites/office-sprite-loader.js'; import { DIR_DOWN, DIR_UP, FURNITURE_MANIFEST, resolveCharacterName } from '../sprites/sprite-definitions.js'; import { diffOfficeConfigs } from '../state/office-differ.js'; -import type { FacilityLayout, OfficeSceneConfig, Position, ResolvedPositions } from '../types.js'; +import type { AnimationHandle } from '../transitions/transition-executor.js'; +import { executeTransitions } from '../transitions/transition-executor.js'; +import { planTransitions } from '../transitions/transition-planner.js'; +import type { EntityKind, FacilityLayout, OfficeSceneConfig, Position, ResolvedPositions } from '../types.js'; // --------------------------------------------------------------------------- // Visual constants @@ -53,6 +56,8 @@ export class OfficeScene extends Scene { private readonly layout: FacilityLayout; private status: CanonicalRunStatus; private prevConfig: OfficeSceneConfig | undefined; + private prevPositions: ResolvedPositions | undefined; + private activeAnimation: AnimationHandle | undefined; // Actor registries for incremental updates private agentActors = new Map(); @@ -67,6 +72,9 @@ export class OfficeScene extends Scene { } override onInitialize(): void { + // If sprite loading fails the scene will render with fallback/empty graphics. + // This is intentional silent degradation — the office remains interactive but visually broken. + // TODO(#333): consider aborting initialization or retrying on sprite-load failure. loadOfficeSprites().catch((error: unknown) => { console.error('[OfficeScene] Failed to load sprites:', error); }); @@ -89,16 +97,20 @@ export class OfficeScene extends Scene { const nextConfig = mapLogicalToOffice(logical); const nextPositions = resolvePositions(nextConfig, this.layout); - if (this.prevConfig === undefined) { + if (this.prevConfig === undefined || this.prevPositions === undefined) { this.applyFullState(nextConfig, nextPositions); } else { const diff = diffOfficeConfigs(this.prevConfig, nextConfig); if (diff.hasChanges) { - this.applyFullState(nextConfig, nextPositions); + this.activeAnimation?.cancel(); + const fromPositions = this.snapshotCurrentPositions(this.prevPositions); + const plan = planTransitions(diff, fromPositions, nextPositions, this.layout); + this.activeAnimation = executeTransitions(plan, this.buildTransitionContext(nextConfig, nextPositions)); } } this.prevConfig = nextConfig; + this.prevPositions = nextPositions; } catch (error) { // Intentional silent degradation: the scene freezes at its last good state. // prevConfig is NOT updated on failure, so the next call will re-attempt a full render. @@ -148,6 +160,39 @@ export class OfficeScene extends Scene { this.artifactActors.clear(); } + /** + * Snapshot current actor pixel positions, falling back to stable positions + * for entities that don't have a live actor (e.g., removed entities). + */ + private snapshotCurrentPositions(stablePositions: ResolvedPositions): ResolvedPositions { + const agents = new Map(); + for (const [id, stablePos] of stablePositions.agents) { + const actor = this.agentActors.get(id); + if (actor === undefined) { + agents.set(id, stablePos); + } else { + agents.set(id, { x: actor.pos.x, y: actor.pos.y }); + } + } + + const artifacts = new Map(); + for (const [id, stablePos] of stablePositions.artifacts) { + const actor = this.artifactActors.get(id); + if (actor === undefined) { + artifacts.set(id, stablePos); + } else { + artifacts.set(id, { x: actor.pos.x, y: actor.pos.y }); + } + } + + const orchestrator = + this.orchestratorActor === undefined + ? stablePositions.orchestrator + : { x: this.orchestratorActor.pos.x, y: this.orchestratorActor.pos.y }; + + return { agents, artifacts, orchestrator }; + } + /** Draw the tiled background onto a single Canvas graphic actor. */ private drawBackground(): void { const floorImage = getFloorImageSource(); @@ -201,26 +246,28 @@ export class OfficeScene extends Scene { } /** Place a character sprite for an agent at the given position. */ - private placeAgent(agentId: string, phase: string, pos: Position): void { + private placeAgent(agentId: string, phase: string, pos: Position): Actor { const characterName = resolveCharacterName(phase, agentId); const sprite = getCharacterSprite(characterName, DIR_UP); const actor = new Actor({ pos: vec(pos.x, pos.y), anchor: vec(0.5, 1), z: 2 }); actor.graphics.use(sprite); this.add(actor); this.agentActors.set(agentId, actor); + return actor; } /** Place the orchestrator (Adam) sprite facing the camera. */ - private placeOrchestrator(pos: Position): void { + private placeOrchestrator(pos: Position): Actor { const sprite = getCharacterSprite('Adam', DIR_DOWN); const actor = new Actor({ pos: vec(pos.x, pos.y), anchor: vec(0.5, 1), z: 2 }); actor.graphics.use(sprite); this.add(actor); this.orchestratorActor = actor; + return actor; } /** Place a colored rectangle for an artifact at the given position. */ - private placeArtifact(artifactId: string, color: string, pos: Position): void { + private placeArtifact(artifactId: string, color: string, pos: Position): Actor { const actor = new Actor({ pos: vec(pos.x, pos.y), z: 3 }); actor.graphics.use( new Rectangle({ @@ -231,6 +278,85 @@ export class OfficeScene extends Scene { ); this.add(actor); this.artifactActors.set(artifactId, actor); + return actor; + } + + /** Look up an actor by entity ID and kind from the registries. */ + private findActor(entityId: string, entityKind: EntityKind): Actor | undefined { + switch (entityKind) { + case 'orchestrator': + return this.orchestratorActor; + case 'agent': + return this.agentActors.get(entityId); + case 'artifact': + return this.artifactActors.get(entityId); + } + } + + /** Remove an entity's actor from the scene and registries. */ + private removeEntityActor(entityId: string, entityKind: EntityKind): void { + switch (entityKind) { + case 'orchestrator': + if (this.orchestratorActor !== undefined) { + this.remove(this.orchestratorActor); + this.orchestratorActor = undefined; + } + break; + case 'agent': { + const actor = this.agentActors.get(entityId); + if (actor !== undefined) { + this.remove(actor); + this.agentActors.delete(entityId); + } + break; + } + case 'artifact': { + const actor = this.artifactActors.get(entityId); + if (actor !== undefined) { + this.remove(actor); + this.artifactActors.delete(entityId); + } + break; + } + } + } + + /** Update the sprite direction for an entity's actor using the given config snapshot. */ + private updateEntitySprite( + actor: Actor, + entityId: string, + entityKind: EntityKind, + direction: number, + config: OfficeSceneConfig, + ): void { + if (entityKind === 'orchestrator') { + actor.graphics.use(getCharacterSprite('Adam', direction)); + } else if (entityKind === 'agent') { + const agentState = config.agents.find((a) => a.id === entityId); + const phase = agentState?.phase ?? 'implementation'; + const characterName = resolveCharacterName(phase, entityId); + actor.graphics.use(getCharacterSprite(characterName, direction)); + } + // Artifacts have no directional sprites + } + + /** Build a TransitionContext that bridges the executor to scene internals. */ + private buildTransitionContext( + nextConfig: OfficeSceneConfig, + nextPositions: ResolvedPositions, + ): import('../transitions/transition-executor.js').TransitionContext { + return { + findActor: (entityId, entityKind) => this.findActor(entityId, entityKind), + createAgent: (agentId, pos, phase) => this.placeAgent(agentId, phase, pos), + createArtifact: (artifactId, pos, color) => this.placeArtifact(artifactId, color, pos), + createOrchestrator: (pos) => this.placeOrchestrator(pos), + removeActor: (entityId, entityKind) => this.removeEntityActor(entityId, entityKind), + updateSprite: (actor, entityId, entityKind, direction) => + this.updateEntitySprite(actor, entityId, entityKind, direction, nextConfig), + config: nextConfig, + nextPositions, + layout: this.layout, + }; } /** Center the camera on the facility. */ diff --git a/packages/factory/src/client/visualizations/office/transitions/transition-executor.ts b/packages/factory/src/client/visualizations/office/transitions/transition-executor.ts new file mode 100644 index 00000000..22af928b --- /dev/null +++ b/packages/factory/src/client/visualizations/office/transitions/transition-executor.ts @@ -0,0 +1,287 @@ +import type { Actor } from 'excalibur'; +import { vec } from 'excalibur'; + +import { + ARTIFACT_APPEAR_SCALE_SPEED, + ARTIFACT_DELIVER_SPEED_PX_PER_SEC, + ARTIFACT_OVERSHOOT_SCALE, + FADE_DURATION_MS, + STATE_CHANGE_PULSE_HALF_MS, + STATE_CHANGE_PULSE_OPACITY, + WALK_SPEED_PX_PER_SEC, +} from '../constants/animation.js'; +import { DIR_DOWN, DIR_LEFT, DIR_RIGHT, DIR_UP } from '../sprites/sprite-definitions.js'; +import type { + Direction, + EntityKind, + FacilityLayout, + OfficeSceneConfig, + Position, + ResolvedPositions, + Transition, + TransitionPlan, +} from '../types.js'; + +// --------------------------------------------------------------------------- +// TransitionContext — the scene-provided callbacks the executor depends on +// --------------------------------------------------------------------------- + +/** Callback interface that decouples the executor from scene internals. */ +export interface TransitionContext { + findActor(entityId: string, entityKind: EntityKind): Actor | undefined; + createAgent(agentId: string, pos: Position, phase: string): Actor; + createArtifact(artifactId: string, pos: Position, color: string): Actor; + createOrchestrator(pos: Position): Actor; + removeActor(entityId: string, entityKind: EntityKind): void; + updateSprite(actor: Actor, entityId: string, entityKind: EntityKind, direction: number): void; + config: OfficeSceneConfig; + nextPositions: ResolvedPositions; + layout: FacilityLayout; +} + +// --------------------------------------------------------------------------- +// AnimationHandle — returned to the caller for interrupt support +// --------------------------------------------------------------------------- + +/** Handle for cancelling all pending and in-progress transition animations. */ +export interface AnimationHandle { + cancel(): void; +} + +// --------------------------------------------------------------------------- +// Direction helpers +// --------------------------------------------------------------------------- + +/** Map a Direction string to a sprite-sheet column index. */ +function directionToSpriteCol(direction: Direction): number { + switch (direction) { + case 'down': + return DIR_DOWN; + case 'left': + return DIR_LEFT; + case 'right': + return DIR_RIGHT; + case 'up': + return DIR_UP; + } +} + +/** Compute the cardinal direction from one position to another. Dominant axis wins; ties favor vertical. */ +export function computeDirection(from: Position, to: Position): number { + const dx = to.x - from.x; + const dy = to.y - from.y; + + if (dx === 0 && dy === 0) { + return DIR_DOWN; + } + + if (Math.abs(dy) >= Math.abs(dx)) { + return dy >= 0 ? DIR_DOWN : DIR_UP; + } + return dx > 0 ? DIR_RIGHT : DIR_LEFT; +} + +/** Resolve the resting direction for an agent at a workstation slot. */ +function resolveAgentRestingDirection(entityId: string, config: OfficeSceneConfig, layout: FacilityLayout): number { + const agent = config.agents.find((a) => a.id === entityId); + if (agent === undefined) { + return DIR_UP; + } + + const slot = layout.slotDefinition(agent.slotId); + if (slot?.facing === undefined) { + return DIR_UP; + } + + return directionToSpriteCol(slot.facing); +} + +// --------------------------------------------------------------------------- +// Transition handlers +// --------------------------------------------------------------------------- + +/** Schedule walk waypoints as chained moveTo actions with direction updates between segments. */ +function handleWalk(transition: Transition, context: TransitionContext): void { + const actor = context.findActor(transition.entityId, transition.entityKind); + if (actor === undefined || transition.waypoints === undefined || transition.waypoints.length < 2) { + return; + } + + const waypoints = transition.waypoints; + + for (let i = 1; i < waypoints.length; i++) { + const from = waypoints[i - 1]; + const to = waypoints[i]; + if (from === undefined || to === undefined) continue; + + const direction = computeDirection(from, to); + const entityId = transition.entityId; + const entityKind = transition.entityKind; + + // Update sprite direction before each segment via callMethod + actor.actions.callMethod(() => { + context.updateSprite(actor, entityId, entityKind, direction); + }); + + actor.actions.moveTo(vec(to.x, to.y), WALK_SPEED_PX_PER_SEC); + } + + // Set resting direction after walk completes + const entityId = transition.entityId; + const entityKind = transition.entityKind; + + actor.actions.callMethod(() => { + const restingDirection = + entityKind === 'orchestrator' ? DIR_DOWN : resolveAgentRestingDirection(entityId, context.config, context.layout); + context.updateSprite(actor, entityId, entityKind, restingDirection); + }); +} + +/** + * Fade in: create actor at target position with opacity 0, then fade to 1. + * Handles `agent` and `orchestrator` entity kinds only. Artifacts use `artifact_appear` instead. + */ +function handleFadeIn(transition: Transition, context: TransitionContext): void { + const targetPos = transition.waypoints?.[0]; + if (targetPos === undefined) return; + + let actor: Actor | undefined; + + if (transition.entityKind === 'agent') { + const agentState = context.config.agents.find((a) => a.id === transition.entityId); + if (agentState === undefined) return; + actor = context.createAgent(transition.entityId, targetPos, agentState.phase); + } else if (transition.entityKind === 'orchestrator') { + actor = context.createOrchestrator(targetPos); + } + + if (actor === undefined) return; + + actor.graphics.opacity = 0; + actor.actions.fade(1, FADE_DURATION_MS); +} + +/** Fade out: fade to 0 then remove actor. */ +function handleFadeOut(transition: Transition, context: TransitionContext): void { + const actor = context.findActor(transition.entityId, transition.entityKind); + if (actor === undefined) return; + + const entityId = transition.entityId; + const entityKind = transition.entityKind; + + actor.actions.fade(0, FADE_DURATION_MS); + actor.actions.callMethod(() => { + context.removeActor(entityId, entityKind); + }); +} + +/** State change: quick dim-and-restore pulse. */ +function handleStateChange(transition: Transition, context: TransitionContext): void { + const actor = context.findActor(transition.entityId, transition.entityKind); + if (actor === undefined) return; + + actor.actions.fade(STATE_CHANGE_PULSE_OPACITY, STATE_CHANGE_PULSE_HALF_MS); + actor.actions.fade(1, STATE_CHANGE_PULSE_HALF_MS); +} + +/** Artifact appear: create at target position with scale 0, overshoot then settle. */ +function handleArtifactAppear(transition: Transition, context: TransitionContext): void { + const targetPos = transition.waypoints?.[0]; + if (targetPos === undefined) return; + + const artifactState = context.config.artifacts.find((a) => a.id === transition.entityId); + if (artifactState === undefined) return; + + const actor = context.createArtifact(transition.entityId, targetPos, artifactState.color); + actor.scale = vec(0, 0); + + const speed = ARTIFACT_APPEAR_SCALE_SPEED; + actor.actions.scaleTo(vec(ARTIFACT_OVERSHOOT_SCALE, ARTIFACT_OVERSHOOT_SCALE), vec(speed, speed)); + actor.actions.scaleTo(vec(1, 1), vec(speed, speed)); +} + +/** Artifact deliver: slide to the delivery position from nextPositions. */ +function handleArtifactDeliver(transition: Transition, context: TransitionContext): void { + const actor = context.findActor(transition.entityId, transition.entityKind); + if (actor === undefined) return; + + const targetPos = context.nextPositions.artifacts.get(transition.entityId); + if (targetPos === undefined) return; + + actor.actions.moveTo(vec(targetPos.x, targetPos.y), ARTIFACT_DELIVER_SPEED_PX_PER_SEC); +} + +// --------------------------------------------------------------------------- +// Dispatcher +// --------------------------------------------------------------------------- + +/** Dispatch a transition to the appropriate handler. */ +function dispatchTransition(transition: Transition, context: TransitionContext): void { + switch (transition.type) { + case 'walk': + handleWalk(transition, context); + break; + case 'fade_in': + handleFadeIn(transition, context); + break; + case 'fade_out': + handleFadeOut(transition, context); + break; + case 'state_change': + handleStateChange(transition, context); + break; + case 'artifact_appear': + handleArtifactAppear(transition, context); + break; + case 'artifact_deliver': + handleArtifactDeliver(transition, context); + break; + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Execute a transition plan by scheduling each transition with its delay + * and dispatching to the appropriate handler. Returns a handle for cancellation. + */ +export function executeTransitions(plan: TransitionPlan, context: TransitionContext): AnimationHandle { + const timers: ReturnType[] = []; + const touchedActors = new Set(); + + /** Clear all pending timers and in-progress actor actions. */ + function cancelAll(): void { + for (const timer of timers) { + clearTimeout(timer); + } + for (const actor of touchedActors) { + actor.actions.clearActions(); + } + timers.length = 0; + touchedActors.clear(); + } + + for (const transition of plan.transitions) { + const timer = setTimeout(() => { + try { + dispatchTransition(transition, context); + } catch (error) { + console.error('[transition-executor] dispatchTransition failed:', error); + cancelAll(); + return; + } + + // Track actors that received actions for cancel support + const actor = context.findActor(transition.entityId, transition.entityKind); + if (actor !== undefined) { + touchedActors.add(actor); + } + }, transition.delayMs); + + timers.push(timer); + } + + return { cancel: cancelAll }; +} diff --git a/packages/factory/src/client/visualizations/office/types.ts b/packages/factory/src/client/visualizations/office/types.ts index b988150e..9bffc48f 100644 --- a/packages/factory/src/client/visualizations/office/types.ts +++ b/packages/factory/src/client/visualizations/office/types.ts @@ -31,6 +31,7 @@ export interface SlotDefinition { id: string; type: SlotType; tile: TileCoord; + facing?: Direction; } /** A doorway connecting a zone to the corridor system. */ @@ -72,6 +73,8 @@ export interface FacilityLayout { slotsInZone(zoneId: string, type?: SlotType): SlotDefinition[]; /** Return corridor waypoints between two zones (directional). */ corridorPath(fromZoneId: string, toZoneId: string): Position[]; + /** Return the slot definition for a given slot ID, or undefined if not found. */ + slotDefinition(slotId: string): SlotDefinition | undefined; /** All zone definitions. */ zones: readonly ZoneDefinition[]; } @@ -205,6 +208,9 @@ export interface Transition { to?: unknown; } +/** Entity kind discriminator extracted from Transition. */ +export type EntityKind = Transition['entityKind']; + /** Ordered list of transitions to apply. */ export interface TransitionPlan { transitions: Transition[];