From dbb1e36a5d8619af2d49ac1d4cba14b8627330e1 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sun, 15 Mar 2026 02:16:19 -0700 Subject: [PATCH 1/4] Add 3-zone office adapter for facility visualization Implement the complete office visualization pipeline that maps LogicalSceneState to the 3-zone facility layout (prep area, workshop, governor's office). Includes types, zone definitions, layout with corridor paths, adapter mapping agents/artifacts to zones, differ, position resolver, transition planner, and OfficeScene with geometric placeholders. --- .../__tests__/agent-zone-assignments.test.ts | 251 ++++++++++++++++++ .../__tests__/logical-to-office.test.ts | 173 ++++++++++++ .../office/__tests__/office-differ.test.ts | 217 +++++++++++++++ .../office/__tests__/office-layout.test.ts | 114 ++++++++ .../office/__tests__/office-scene.test.ts | 159 +++++++++++ .../__tests__/position-resolver.test.ts | 128 +++++++++ .../__tests__/transition-planner.test.ts | 224 ++++++++++++++++ .../office/__tests__/zone-definitions.test.ts | 58 ++++ .../office/constants/dimensions.ts | 17 ++ .../office/constants/zone-definitions.ts | 55 ++++ .../office/layout/office-layout.ts | 126 +++++++++ .../office/layout/position-resolver.ts | 21 ++ .../office/mappers/agent-zone-assignments.ts | 185 +++++++++++++ .../office/mappers/logical-to-office.ts | 55 ++++ .../office/scene/OfficeScene.ts | 214 +++++++++++++++ .../office/state/office-differ.ts | 151 +++++++++++ .../office/transitions/transition-planner.ts | 189 +++++++++++++ .../src/client/visualizations/office/types.ts | 213 +++++++++++++++ 18 files changed, 2550 insertions(+) create mode 100644 packages/factory/src/client/visualizations/office/__tests__/agent-zone-assignments.test.ts create mode 100644 packages/factory/src/client/visualizations/office/__tests__/logical-to-office.test.ts create mode 100644 packages/factory/src/client/visualizations/office/__tests__/office-differ.test.ts create mode 100644 packages/factory/src/client/visualizations/office/__tests__/office-layout.test.ts create mode 100644 packages/factory/src/client/visualizations/office/__tests__/office-scene.test.ts create mode 100644 packages/factory/src/client/visualizations/office/__tests__/position-resolver.test.ts create mode 100644 packages/factory/src/client/visualizations/office/__tests__/transition-planner.test.ts create mode 100644 packages/factory/src/client/visualizations/office/__tests__/zone-definitions.test.ts create mode 100644 packages/factory/src/client/visualizations/office/constants/dimensions.ts create mode 100644 packages/factory/src/client/visualizations/office/constants/zone-definitions.ts create mode 100644 packages/factory/src/client/visualizations/office/layout/office-layout.ts create mode 100644 packages/factory/src/client/visualizations/office/layout/position-resolver.ts create mode 100644 packages/factory/src/client/visualizations/office/mappers/agent-zone-assignments.ts create mode 100644 packages/factory/src/client/visualizations/office/mappers/logical-to-office.ts create mode 100644 packages/factory/src/client/visualizations/office/scene/OfficeScene.ts create mode 100644 packages/factory/src/client/visualizations/office/state/office-differ.ts create mode 100644 packages/factory/src/client/visualizations/office/transitions/transition-planner.ts create mode 100644 packages/factory/src/client/visualizations/office/types.ts diff --git a/packages/factory/src/client/visualizations/office/__tests__/agent-zone-assignments.test.ts b/packages/factory/src/client/visualizations/office/__tests__/agent-zone-assignments.test.ts new file mode 100644 index 00000000..3836abd3 --- /dev/null +++ b/packages/factory/src/client/visualizations/office/__tests__/agent-zone-assignments.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from 'vitest'; + +import type { LogicalAgentState, LogicalArtifactState, LogicalOrchestratorState } from '../../shared/types.js'; +import { ZONE_DEFINITIONS } from '../constants/zone-definitions.js'; +import { + assignAgentToZone, + assignArtifactToZone, + buildArtifactStates, + computeReviewerIndices, + deriveOrchestratorZone, + deriveZoneStates, +} from '../mappers/agent-zone-assignments.js'; +import type { OfficeAgentState } from '../types.js'; + +/** Minimal agent factory. */ +function agent(overrides: Partial & { id: string }): LogicalAgentState { + return { + role: 'test-agent', + roleType: 'author', + phase: 'implementation', + status: 'idle', + ...overrides, + }; +} + +/** Minimal orchestrator factory. */ +function orchestrator(overrides: Partial = {}): LogicalOrchestratorState { + return { + status: 'idle', + carriedArtifacts: [], + codeBadge: null, + waiting: false, + ...overrides, + }; +} + +/** Minimal artifact factory. */ +function artifact(overrides: Partial & { id: string }): LogicalArtifactState { + return { + label: 'test-artifact', + color: '#ff0000', + status: 'created', + producerPhase: 'implementation', + ...overrides, + }; +} + +describe(assignAgentToZone, () => { + it('assigns architect to prep/prep-ws-0', () => { + const result = assignAgentToZone(agent({ id: 'arch', phase: 'architecture', roleType: 'analyst' }), 0); + expect(result).toEqual({ zoneId: 'prep', slotId: 'prep-ws-0' }); + }); + + it('assigns planner to prep/prep-ws-1', () => { + const result = assignAgentToZone(agent({ id: 'plan', phase: 'planning', roleType: 'planner' }), 0); + expect(result).toEqual({ zoneId: 'prep', slotId: 'prep-ws-1' }); + }); + + it('assigns coder to workshop/workshop-ws-0', () => { + const result = assignAgentToZone(agent({ id: 'code', phase: 'implementation', roleType: 'author' }), 0); + expect(result).toEqual({ zoneId: 'workshop', slotId: 'workshop-ws-0' }); + }); + + it('assigns reviewers to workshop/workshop-ws-{index}', () => { + const result = assignAgentToZone(agent({ id: 'rev1', phase: 'review', roleType: 'reviewer' }), 1); + expect(result).toEqual({ zoneId: 'workshop', slotId: 'workshop-ws-1' }); + }); + + it('caps reviewer slot index at 5', () => { + const result = assignAgentToZone(agent({ id: 'rev6', phase: 'review', roleType: 'reviewer' }), 10); + expect(result).toEqual({ zoneId: 'workshop', slotId: 'workshop-ws-5' }); + }); + + it('assigns simplifier to workshop', () => { + const result = assignAgentToZone(agent({ id: 'simp', phase: 'simplifier', roleType: 'reviewer' }), 2); + expect(result).toEqual({ zoneId: 'workshop', slotId: 'workshop-ws-2' }); + }); + + it('assigns holistic reviewer to workshop', () => { + const result = assignAgentToZone(agent({ id: 'hol', phase: 'holistic', roleType: 'reviewer' }), 3); + expect(result).toEqual({ zoneId: 'workshop', slotId: 'workshop-ws-3' }); + }); +}); + +describe(deriveOrchestratorZone, () => { + it('returns governor when idle', () => { + expect(deriveOrchestratorZone(orchestrator({ status: 'idle' }), undefined)).toBe('governor'); + }); + + it('returns governor when done', () => { + expect(deriveOrchestratorZone(orchestrator({ status: 'done' }), undefined)).toBe('governor'); + }); + + it('returns governor when delivering', () => { + expect(deriveOrchestratorZone(orchestrator({ status: 'delivering' }), undefined)).toBe('governor'); + }); + + it('returns prep when dispatching to architecture phase', () => { + expect(deriveOrchestratorZone(orchestrator({ status: 'dispatching' }), 'architecture')).toBe('prep'); + }); + + it('returns prep when dispatching to planning phase', () => { + expect(deriveOrchestratorZone(orchestrator({ status: 'dispatching' }), 'planning')).toBe('prep'); + }); + + it('returns workshop when monitoring implementation phase', () => { + expect(deriveOrchestratorZone(orchestrator({ status: 'monitoring' }), 'implementation')).toBe('workshop'); + }); + + it('returns workshop when dispatching to review phase', () => { + expect(deriveOrchestratorZone(orchestrator({ status: 'dispatching' }), 'review')).toBe('workshop'); + }); + + it('returns governor when dispatching with no current phase', () => { + expect(deriveOrchestratorZone(orchestrator({ status: 'dispatching' }), undefined)).toBe('governor'); + }); +}); + +describe(assignArtifactToZone, () => { + it('assigns delivered artifacts to governor storage slots', () => { + const result = assignArtifactToZone(artifact({ id: 'a1', status: 'delivered' }), 0); + expect(result).toEqual({ zoneId: 'governor', slotId: 'governor-storage-0' }); + }); + + it('cycles through storage slots for multiple delivered artifacts', () => { + const r0 = assignArtifactToZone(artifact({ id: 'a1', status: 'delivered' }), 0); + const r1 = assignArtifactToZone(artifact({ id: 'a2', status: 'delivered' }), 1); + const r2 = assignArtifactToZone(artifact({ id: 'a3', status: 'delivered' }), 2); + const r3 = assignArtifactToZone(artifact({ id: 'a4', status: 'delivered' }), 3); + + expect(r0.slotId).toBe('governor-storage-0'); + expect(r1.slotId).toBe('governor-storage-1'); + expect(r2.slotId).toBe('governor-storage-2'); + expect(r3.slotId).toBe('governor-storage-0'); + }); + + it('assigns created artifacts to their producer zone', () => { + const result = assignArtifactToZone(artifact({ id: 'a1', status: 'created', producerPhase: 'architecture' }), 0); + expect(result).toEqual({ zoneId: 'prep', slotId: 'prep-ws-0' }); + }); + + it('assigns in_transit artifacts to their producer zone (same as created)', () => { + const result = assignArtifactToZone( + artifact({ id: 'a1', status: 'in_transit', producerPhase: 'implementation' }), + 0, + ); + expect(result).toEqual({ zoneId: 'workshop', slotId: 'workshop-ws-0' }); + }); +}); + +describe(deriveZoneStates, () => { + /** Minimal office agent factory. */ + function officeAgent(overrides: Partial & { id: string; zoneId: string }): OfficeAgentState { + return { + role: 'test', + roleType: 'author', + phase: 'implementation', + status: 'idle', + slotId: 'test-slot', + ...overrides, + }; + } + + it('marks a zone as active when any agent is working', () => { + const agents: OfficeAgentState[] = [ + officeAgent({ id: 'a1', zoneId: 'workshop', status: 'working' }), + officeAgent({ id: 'a2', zoneId: 'workshop', status: 'idle' }), + ]; + const states = deriveZoneStates(agents, ZONE_DEFINITIONS); + const workshop = states.find((z) => z.id === 'workshop'); + expect(workshop).toEqual({ id: 'workshop', active: true, completed: false }); + }); + + it('marks a zone as completed when all agents are done', () => { + const agents: OfficeAgentState[] = [ + officeAgent({ id: 'a1', zoneId: 'prep', status: 'done' }), + officeAgent({ id: 'a2', zoneId: 'prep', status: 'done' }), + ]; + const states = deriveZoneStates(agents, ZONE_DEFINITIONS); + const prep = states.find((z) => z.id === 'prep'); + expect(prep).toEqual({ id: 'prep', active: false, completed: true }); + }); + + it('marks a zone with no agents as neither active nor completed', () => { + const states = deriveZoneStates([], ZONE_DEFINITIONS); + for (const zone of states) { + expect(zone.active).toBe(false); + expect(zone.completed).toBe(false); + } + }); + + it('a zone is not completed if some agents are still working', () => { + const agents: OfficeAgentState[] = [ + officeAgent({ id: 'a1', zoneId: 'workshop', status: 'done' }), + officeAgent({ id: 'a2', zoneId: 'workshop', status: 'working' }), + ]; + const states = deriveZoneStates(agents, ZONE_DEFINITIONS); + const workshop = states.find((z) => z.id === 'workshop'); + expect(workshop).toEqual({ id: 'workshop', active: true, completed: false }); + }); +}); + +describe(computeReviewerIndices, () => { + it('assigns stable indices sorted by agent ID', () => { + const agents: LogicalAgentState[] = [ + agent({ id: 'c-reviewer', phase: 'review', roleType: 'reviewer' }), + agent({ id: 'a-reviewer', phase: 'review', roleType: 'reviewer' }), + agent({ id: 'b-reviewer', phase: 'review', roleType: 'reviewer' }), + ]; + const indices = computeReviewerIndices(agents); + + expect(indices.get('a-reviewer')).toBe(1); + expect(indices.get('b-reviewer')).toBe(2); + expect(indices.get('c-reviewer')).toBe(3); + }); + + it('includes simplifier and holistic agents', () => { + const agents: LogicalAgentState[] = [ + agent({ id: 'rev1', phase: 'review', roleType: 'reviewer' }), + agent({ id: 'simp', phase: 'simplifier', roleType: 'reviewer' }), + agent({ id: 'hol', phase: 'holistic', roleType: 'reviewer' }), + ]; + const indices = computeReviewerIndices(agents); + expect(indices.size).toBe(3); + }); + + it('caps at slot index 5', () => { + const agents: LogicalAgentState[] = Array.from({ length: 8 }, (_, i) => + agent({ id: `rev-${String(i).padStart(2, '0')}`, phase: 'review', roleType: 'reviewer' }), + ); + const indices = computeReviewerIndices(agents); + + const maxIndex = Math.max(...indices.values()); + expect(maxIndex).toBe(5); + }); +}); + +describe(buildArtifactStates, () => { + it('increments storage counter only for delivered artifacts', () => { + const artifacts: LogicalArtifactState[] = [ + artifact({ id: 'a1', status: 'delivered', producerPhase: 'architecture' }), + artifact({ id: 'a2', status: 'created', producerPhase: 'planning' }), + artifact({ id: 'a3', status: 'delivered', producerPhase: 'implementation' }), + ]; + const result = buildArtifactStates(artifacts); + + expect(result[0]?.slotId).toBe('governor-storage-0'); + expect(result[1]?.slotId).toBe('prep-ws-1'); + expect(result[2]?.slotId).toBe('governor-storage-1'); + }); +}); diff --git a/packages/factory/src/client/visualizations/office/__tests__/logical-to-office.test.ts b/packages/factory/src/client/visualizations/office/__tests__/logical-to-office.test.ts new file mode 100644 index 00000000..73f53fab --- /dev/null +++ b/packages/factory/src/client/visualizations/office/__tests__/logical-to-office.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; + +import type { LogicalAgentState, LogicalArtifactState, LogicalSceneState } from '../../shared/types.js'; +import { mapLogicalToOffice } from '../mappers/logical-to-office.js'; + +/** Build a minimal LogicalSceneState. */ +function logicalScene(overrides: Partial = {}): LogicalSceneState { + return { + runStatus: 'in_progress', + currentPhase: undefined, + agents: [], + orchestrator: { + status: 'idle', + carriedArtifacts: [], + codeBadge: null, + waiting: false, + }, + artifacts: [], + ...overrides, + }; +} + +function agent(overrides: Partial & { id: string }): LogicalAgentState { + return { + role: 'test', + roleType: 'author', + phase: 'implementation', + status: 'idle', + ...overrides, + }; +} + +function artifact(overrides: Partial & { id: string }): LogicalArtifactState { + return { + label: 'test', + color: '#ff0000', + status: 'created', + producerPhase: 'implementation', + ...overrides, + }; +} + +describe(mapLogicalToOffice, () => { + it('handles empty state gracefully', () => { + const result = mapLogicalToOffice(logicalScene()); + + expect(result.agents).toHaveLength(0); + expect(result.artifacts).toHaveLength(0); + expect(result.zones).toHaveLength(3); + expect(result.orchestrator.zoneId).toBe('governor'); + }); + + it('assigns architect and planner to prep zone', () => { + const scene = logicalScene({ + agents: [ + agent({ id: 'architect', phase: 'architecture', roleType: 'analyst' }), + agent({ id: 'planner', phase: 'planning', roleType: 'planner' }), + ], + }); + const result = mapLogicalToOffice(scene); + + const arch = result.agents.find((a) => a.id === 'architect'); + const plan = result.agents.find((a) => a.id === 'planner'); + + expect(arch).toBeDefined(); + expect(arch?.zoneId).toBe('prep'); + expect(arch?.slotId).toBe('prep-ws-0'); + + expect(plan).toBeDefined(); + expect(plan?.zoneId).toBe('prep'); + expect(plan?.slotId).toBe('prep-ws-1'); + }); + + it('assigns coder to workshop/workshop-ws-0', () => { + const scene = logicalScene({ + agents: [agent({ id: 'coder', phase: 'implementation', roleType: 'author' })], + }); + const result = mapLogicalToOffice(scene); + const coder = result.agents.find((a) => a.id === 'coder'); + + expect(coder?.zoneId).toBe('workshop'); + expect(coder?.slotId).toBe('workshop-ws-0'); + }); + + it('assigns reviewers to workshop/workshop-ws-1 through ws-5', () => { + const scene = logicalScene({ + agents: [ + agent({ id: 'rev-a', phase: 'review', roleType: 'reviewer' }), + agent({ id: 'rev-b', phase: 'review', roleType: 'reviewer' }), + agent({ id: 'rev-c', phase: 'review', roleType: 'reviewer' }), + ], + }); + const result = mapLogicalToOffice(scene); + const reviewers = result.agents.filter((a) => a.phase === 'review'); + + expect(reviewers).toHaveLength(3); + const slotIds = new Set(reviewers.map((r) => r.slotId)); + // All should be workshop-ws-1 through ws-3 + expect(slotIds).toContain('workshop-ws-1'); + expect(slotIds).toContain('workshop-ws-2'); + expect(slotIds).toContain('workshop-ws-3'); + }); + + it('places orchestrator at prep when dispatching to architecture', () => { + const scene = logicalScene({ + currentPhase: 'architecture', + orchestrator: { status: 'dispatching', carriedArtifacts: [], codeBadge: null, waiting: false }, + }); + const result = mapLogicalToOffice(scene); + expect(result.orchestrator.zoneId).toBe('prep'); + }); + + it('places orchestrator at workshop when monitoring implementation', () => { + const scene = logicalScene({ + currentPhase: 'implementation', + orchestrator: { status: 'monitoring', carriedArtifacts: [], codeBadge: null, waiting: false }, + }); + const result = mapLogicalToOffice(scene); + expect(result.orchestrator.zoneId).toBe('workshop'); + }); + + it('places orchestrator at governor when idle', () => { + const scene = logicalScene({ + orchestrator: { status: 'idle', carriedArtifacts: [], codeBadge: null, waiting: false }, + }); + const result = mapLogicalToOffice(scene); + expect(result.orchestrator.zoneId).toBe('governor'); + }); + + it('places delivered artifacts at governor storage', () => { + const scene = logicalScene({ + artifacts: [ + artifact({ id: 'a1', status: 'delivered', producerPhase: 'architecture' }), + artifact({ id: 'a2', status: 'delivered', producerPhase: 'planning' }), + ], + }); + const result = mapLogicalToOffice(scene); + + expect(result.artifacts[0]?.zoneId).toBe('governor'); + expect(result.artifacts[0]?.slotId).toBe('governor-storage-0'); + expect(result.artifacts[1]?.zoneId).toBe('governor'); + expect(result.artifacts[1]?.slotId).toBe('governor-storage-1'); + }); + + it('places in_transit artifacts at their producer zone', () => { + const scene = logicalScene({ + artifacts: [artifact({ id: 'a1', status: 'in_transit', producerPhase: 'implementation' })], + }); + const result = mapLogicalToOffice(scene); + + expect(result.artifacts[0]?.zoneId).toBe('workshop'); + expect(result.artifacts[0]?.slotId).toBe('workshop-ws-0'); + }); + + it('derives correct zone states from agent statuses', () => { + const scene = logicalScene({ + agents: [ + agent({ id: 'a1', phase: 'implementation', status: 'working' }), + agent({ id: 'a2', phase: 'implementation', status: 'idle' }), + ], + }); + const result = mapLogicalToOffice(scene); + + const workshop = result.zones.find((z) => z.id === 'workshop'); + expect(workshop?.active).toBe(true); + expect(workshop?.completed).toBe(false); + + // Prep has no agents: neither active nor completed + const prep = result.zones.find((z) => z.id === 'prep'); + expect(prep?.active).toBe(false); + expect(prep?.completed).toBe(false); + }); +}); diff --git a/packages/factory/src/client/visualizations/office/__tests__/office-differ.test.ts b/packages/factory/src/client/visualizations/office/__tests__/office-differ.test.ts new file mode 100644 index 00000000..065f01d1 --- /dev/null +++ b/packages/factory/src/client/visualizations/office/__tests__/office-differ.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'vitest'; + +import { diffOfficeConfigs } from '../state/office-differ.js'; +import type { OfficeAgentState, OfficeArtifactState, OfficeSceneConfig, OfficeZoneState } from '../types.js'; + +/** Build a minimal OfficeSceneConfig. */ +function config(overrides: Partial = {}): OfficeSceneConfig { + return { + orchestrator: { + status: 'idle', + carriedArtifacts: [], + codeBadge: null, + waiting: false, + zoneId: 'governor', + }, + agents: [], + artifacts: [], + zones: [ + { id: 'prep', active: false, completed: false }, + { id: 'workshop', active: false, completed: false }, + { id: 'governor', active: false, completed: false }, + ], + ...overrides, + }; +} + +function agent(overrides: Partial & { id: string }): OfficeAgentState { + return { + role: 'test', + roleType: 'author', + phase: 'implementation', + status: 'idle', + zoneId: 'workshop', + slotId: 'workshop-ws-0', + ...overrides, + }; +} + +function artifact(overrides: Partial & { id: string }): OfficeArtifactState { + return { + label: 'test', + color: '#ff0000', + status: 'created', + producerPhase: 'implementation', + zoneId: 'workshop', + slotId: 'workshop-ws-0', + ...overrides, + }; +} + +describe(diffOfficeConfigs, () => { + it('returns hasChanges false for identical configs', () => { + const c = config(); + const diff = diffOfficeConfigs(c, c); + expect(diff.hasChanges).toBe(false); + }); + + describe('orchestrator', () => { + it('detects orchestrator zone movement', () => { + const prev = config({ orchestrator: { ...config().orchestrator, zoneId: 'governor' } }); + const next = config({ orchestrator: { ...config().orchestrator, zoneId: 'workshop' } }); + const diff = diffOfficeConfigs(prev, next); + + expect(diff.orchestrator.moved).toEqual({ from: 'governor', to: 'workshop' }); + expect(diff.hasChanges).toBe(true); + }); + + it('detects orchestrator status change', () => { + const prev = config({ orchestrator: { ...config().orchestrator, status: 'idle' } }); + const next = config({ orchestrator: { ...config().orchestrator, status: 'dispatching' } }); + const diff = diffOfficeConfigs(prev, next); + + expect(diff.orchestrator.statusChanged).toEqual({ from: 'idle', to: 'dispatching' }); + }); + + it('detects carried artifact changes', () => { + const prev = config({ orchestrator: { ...config().orchestrator, carriedArtifacts: [] } }); + const next = config({ + orchestrator: { + ...config().orchestrator, + carriedArtifacts: [{ label: 'plan', color: '#00ff00' }], + }, + }); + const diff = diffOfficeConfigs(prev, next); + expect(diff.orchestrator.carriedChanged).not.toBeNull(); + }); + + it('detects waiting state change', () => { + const prev = config({ orchestrator: { ...config().orchestrator, waiting: false } }); + const next = config({ orchestrator: { ...config().orchestrator, waiting: true } }); + const diff = diffOfficeConfigs(prev, next); + + expect(diff.orchestrator.waitingChanged).toEqual({ from: false, to: true }); + }); + }); + + describe('agents', () => { + it('detects agent status changes', () => { + const a = agent({ id: 'a1', status: 'idle' }); + const prev = config({ agents: [a] }); + const next = config({ agents: [{ ...a, status: 'working' }] }); + const diff = diffOfficeConfigs(prev, next); + + expect(diff.agents).toHaveLength(1); + expect(diff.agents[0]?.statusChanged).toEqual({ from: 'idle', to: 'working' }); + }); + + it('detects agent slot reassignment', () => { + const a = agent({ id: 'a1', zoneId: 'workshop', slotId: 'workshop-ws-0' }); + const prev = config({ agents: [a] }); + const next = config({ agents: [{ ...a, zoneId: 'workshop', slotId: 'workshop-ws-1' }] }); + const diff = diffOfficeConfigs(prev, next); + + expect(diff.agents).toHaveLength(1); + expect(diff.agents[0]?.moved).toEqual({ + fromZone: 'workshop', + fromSlot: 'workshop-ws-0', + toZone: 'workshop', + toSlot: 'workshop-ws-1', + }); + }); + + it('detects newly added agents', () => { + const prev = config({ agents: [] }); + const next = config({ agents: [agent({ id: 'new-agent' })] }); + const diff = diffOfficeConfigs(prev, next); + + expect(diff.agents).toHaveLength(1); + expect(diff.agents[0]?.moved?.fromZone).toBe(''); + }); + + it('detects removed agents', () => { + const prev = config({ agents: [agent({ id: 'old-agent' })] }); + const next = config({ agents: [] }); + const diff = diffOfficeConfigs(prev, next); + + expect(diff.agents).toHaveLength(1); + expect(diff.agents[0]?.moved?.toZone).toBe(''); + }); + + it('reports no changes for unchanged agents', () => { + const a = agent({ id: 'a1' }); + const diff = diffOfficeConfigs(config({ agents: [a] }), config({ agents: [a] })); + expect(diff.agents).toHaveLength(0); + }); + }); + + describe('artifacts', () => { + it('detects newly added artifacts', () => { + const prev = config({ artifacts: [] }); + const next = config({ artifacts: [artifact({ id: 'art1' })] }); + const diff = diffOfficeConfigs(prev, next); + + expect(diff.artifacts.added).toHaveLength(1); + expect(diff.artifacts.added[0]?.id).toBe('art1'); + }); + + it('detects artifact status transitions', () => { + const a = artifact({ id: 'art1', status: 'created' }); + const prev = config({ artifacts: [a] }); + const next = config({ artifacts: [{ ...a, status: 'delivered' }] }); + const diff = diffOfficeConfigs(prev, next); + + expect(diff.artifacts.statusChanged).toHaveLength(1); + expect(diff.artifacts.statusChanged[0]).toEqual({ artifactId: 'art1', from: 'created', to: 'delivered' }); + }); + + it('detects removed artifacts', () => { + const prev = config({ artifacts: [artifact({ id: 'art1' })] }); + const next = config({ artifacts: [] }); + const diff = diffOfficeConfigs(prev, next); + + expect(diff.artifacts.removed).toHaveLength(1); + }); + }); + + describe('zones', () => { + it('detects zone active state change', () => { + const prevZones: OfficeZoneState[] = [ + { id: 'prep', active: false, completed: false }, + { id: 'workshop', active: false, completed: false }, + { id: 'governor', active: false, completed: false }, + ]; + const nextZones: OfficeZoneState[] = [ + { id: 'prep', active: true, completed: false }, + { id: 'workshop', active: false, completed: false }, + { id: 'governor', active: false, completed: false }, + ]; + const diff = diffOfficeConfigs(config({ zones: prevZones }), config({ zones: nextZones })); + + expect(diff.zones).toContainEqual({ zoneId: 'prep', field: 'active', from: false, to: true }); + }); + + it('detects zone completed state change', () => { + const prevZones: OfficeZoneState[] = [ + { id: 'prep', active: true, completed: false }, + { id: 'workshop', active: false, completed: false }, + { id: 'governor', active: false, completed: false }, + ]; + const nextZones: OfficeZoneState[] = [ + { id: 'prep', active: false, completed: true }, + { id: 'workshop', active: false, completed: false }, + { id: 'governor', active: false, completed: false }, + ]; + const diff = diffOfficeConfigs(config({ zones: prevZones }), config({ zones: nextZones })); + + expect(diff.zones).toContainEqual({ zoneId: 'prep', field: 'active', from: true, to: false }); + expect(diff.zones).toContainEqual({ zoneId: 'prep', field: 'completed', from: false, to: true }); + }); + + it('reports no zone changes when zones are identical', () => { + const c = config(); + const diff = diffOfficeConfigs(c, c); + expect(diff.zones).toHaveLength(0); + }); + }); +}); 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 new file mode 100644 index 00000000..762e7844 --- /dev/null +++ b/packages/factory/src/client/visualizations/office/__tests__/office-layout.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; + +import { TILE_SIZE } from '../constants/dimensions.js'; +import { ZONE_DEFINITIONS } from '../constants/zone-definitions.js'; +import { createOfficeLayout } from '../layout/office-layout.js'; + +describe(createOfficeLayout, () => { + const layout = createOfficeLayout(); + + describe('slotPosition', () => { + it('returns pixel coordinates for every slot in every zone', () => { + for (const zone of ZONE_DEFINITIONS) { + for (const slot of zone.slots) { + const pos = layout.slotPosition(slot.id); + expect(pos.x).toBe(slot.tile.col * TILE_SIZE + TILE_SIZE / 2); + expect(pos.y).toBe(slot.tile.row * TILE_SIZE + TILE_SIZE / 2); + } + } + }); + + it('throws for an unknown slot ID', () => { + expect(() => layout.slotPosition('nonexistent')).toThrow('Unknown slot ID'); + }); + }); + + describe('zoneCenter', () => { + it('returns pixel center for all 3 zones', () => { + for (const zone of ZONE_DEFINITIONS) { + const center = layout.zoneCenter(zone.id); + const expectedX = (zone.bounds.col + zone.bounds.width / 2) * TILE_SIZE; + const expectedY = (zone.bounds.row + zone.bounds.height / 2) * TILE_SIZE; + expect(center).toEqual({ x: expectedX, y: expectedY }); + } + }); + + it('throws for an unknown zone ID', () => { + expect(() => layout.zoneCenter('nonexistent')).toThrow('Unknown zone ID'); + }); + }); + + describe('slotsInZone', () => { + it('returns all slots for a zone', () => { + const prepSlots = layout.slotsInZone('prep'); + expect(prepSlots).toHaveLength(2); + expect(prepSlots.map((s) => s.id)).toEqual(['prep-ws-0', 'prep-ws-1']); + }); + + it('filters by slot type', () => { + const storageSlots = layout.slotsInZone('governor', 'storage'); + expect(storageSlots).toHaveLength(3); + expect(storageSlots.every((s) => s.type === 'storage')).toBe(true); + }); + + it('returns workstations when filtered', () => { + const workstations = layout.slotsInZone('workshop', 'workstation'); + expect(workstations).toHaveLength(6); + }); + + it('throws for an unknown zone ID', () => { + expect(() => layout.slotsInZone('nonexistent')).toThrow('Unknown zone ID'); + }); + }); + + describe('corridorPath', () => { + it.each([ + ['prep', 'workshop'], + ['workshop', 'prep'], + ['prep', 'governor'], + ['governor', 'prep'], + ['workshop', 'governor'], + ['governor', 'workshop'], + ])('returns a non-empty waypoint array for %s -> %s', (from, to) => { + const path = layout.corridorPath(from, to); + expect(path.length).toBeGreaterThan(0); + for (const point of path) { + expect(typeof point.x).toBe('number'); + expect(typeof point.y).toBe('number'); + } + }); + + it('returns a reversed path for the opposite direction', () => { + const forward = layout.corridorPath('prep', 'workshop'); + const backward = layout.corridorPath('workshop', 'prep'); + + // The paths should pass through the same points in opposite order + expect(forward).toHaveLength(backward.length); + }); + + it('passes through doorway positions', () => { + const prepZone = ZONE_DEFINITIONS.find((z) => z.id === 'prep'); + expect(prepZone).toBeDefined(); + const prepDoor = prepZone?.doors[0]; + expect(prepDoor).toBeDefined(); + if (prepDoor === undefined) return; + const expectedDoorX = prepDoor.tile.col * TILE_SIZE + TILE_SIZE / 2; + const expectedDoorY = prepDoor.tile.row * TILE_SIZE + TILE_SIZE / 2; + + const path = layout.corridorPath('prep', 'governor'); + const matchesDoor = path.some((p) => p.x === expectedDoorX && p.y === expectedDoorY); + expect(matchesDoor).toBe(true); + }); + + it('throws for an invalid zone pair', () => { + expect(() => layout.corridorPath('prep', 'nonexistent')).toThrow('No corridor path'); + }); + }); + + describe('zones', () => { + it('exposes all zone definitions', () => { + expect(layout.zones).toHaveLength(3); + expect(layout.zones.map((z) => z.id)).toEqual(['prep', 'workshop', 'governor']); + }); + }); +}); 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 new file mode 100644 index 00000000..d1beac11 --- /dev/null +++ b/packages/factory/src/client/visualizations/office/__tests__/office-scene.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { LogicalSceneState } from '../../shared/types.js'; + +// Track actors added/removed through the mock +let actorCount = 0; + +// Mock Excalibur to avoid canvas/WebGL dependencies in tests +vi.mock('excalibur', () => { + class MockGraphic { + opacity = 1; + use() {} + } + class MockActor { + pos = { x: 0, y: 0 }; + graphics = new MockGraphic(); + constructor(opts?: { pos?: { x: number; y: number } }) { + if (opts?.pos) this.pos = opts.pos; + } + } + class MockScene { + backgroundColor = { r: 0, g: 0, b: 0 }; + camera = { pos: { x: 0, y: 0 }, zoom: 1 }; + add(_actor: MockActor) { + actorCount++; + } + remove(_actor: MockActor) { + actorCount--; + } + } + + return { + Actor: MockActor, + Circle: class {}, + Color: { + fromHex: (hex: string) => ({ hex }), + Transparent: { hex: 'transparent' }, + }, + Font: class {}, + Label: MockActor, + Rectangle: class {}, + Scene: MockScene, + TextAlign: { Center: 'center' }, + vec: (x: number, y: number) => ({ x, y }), + }; +}); + +// Import after mocking +const { OfficeScene } = await import('../scene/OfficeScene.js'); + +/** Build a minimal LogicalSceneState. */ +function logicalScene(overrides: Partial = {}): LogicalSceneState { + return { + runStatus: 'in_progress', + currentPhase: undefined, + agents: [], + orchestrator: { + status: 'idle', + carriedArtifacts: [], + codeBadge: null, + waiting: false, + }, + artifacts: [], + ...overrides, + }; +} + +describe('OfficeScene', () => { + it('can be constructed', () => { + const scene = new OfficeScene(); + expect(scene).toBeDefined(); + }); + + it('draws zone rectangles on initialize', () => { + actorCount = 0; + const scene = new OfficeScene(); + scene.onInitialize(); + + // 3 zones x 3 actors each (fill, border, label) = 9 + expect(actorCount).toBe(9); + }); + + it('places entities when updateState is called', () => { + actorCount = 0; + const scene = new OfficeScene(); + scene.onInitialize(); + const initialCount = actorCount; + + scene.updateState( + logicalScene({ + agents: [{ id: 'a1', role: 'coder', roleType: 'author', phase: 'implementation', status: 'working' }], + }), + ); + + // Should have added orchestrator + 1 agent + expect(actorCount).toBe(initialCount + 2); + }); + + it('handles empty state gracefully', () => { + actorCount = 0; + const scene = new OfficeScene(); + scene.onInitialize(); + + // Should not throw + scene.updateState(logicalScene()); + + // 9 zone actors + 1 orchestrator + expect(actorCount).toBe(10); + }); + + it('clears and replaces entities on subsequent updateState calls', () => { + actorCount = 0; + const scene = new OfficeScene(); + scene.onInitialize(); + + scene.updateState( + logicalScene({ + agents: [ + { id: 'a1', role: 'coder', roleType: 'author', phase: 'implementation', status: 'working' }, + { id: 'a2', role: 'architect', roleType: 'analyst', phase: 'architecture', status: 'idle' }, + ], + }), + ); + + const firstCount = actorCount; + + scene.updateState( + logicalScene({ + agents: [{ id: 'a1', role: 'coder', roleType: 'author', phase: 'implementation', status: 'done' }], + }), + ); + + // Went from 2 agents to 1 agent, so should be one fewer + expect(actorCount).toBe(firstCount - 1); + }); + + it('places artifacts at assigned positions', () => { + actorCount = 0; + const scene = new OfficeScene(); + scene.onInitialize(); + + scene.updateState( + logicalScene({ + artifacts: [ + { + id: 'art1', + label: 'plan', + color: '#00ff00', + status: 'delivered', + producerPhase: 'planning', + }, + ], + }), + ); + + // 9 zone actors + 1 orchestrator + 1 artifact + expect(actorCount).toBe(11); + }); +}); diff --git a/packages/factory/src/client/visualizations/office/__tests__/position-resolver.test.ts b/packages/factory/src/client/visualizations/office/__tests__/position-resolver.test.ts new file mode 100644 index 00000000..b16fd617 --- /dev/null +++ b/packages/factory/src/client/visualizations/office/__tests__/position-resolver.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; + +import { createOfficeLayout } from '../layout/office-layout.js'; +import { resolvePositions } from '../layout/position-resolver.js'; +import type { OfficeSceneConfig } from '../types.js'; + +describe(resolvePositions, () => { + const layout = createOfficeLayout(); + + /** Build a minimal OfficeSceneConfig. */ + function config(overrides: Partial = {}): OfficeSceneConfig { + return { + orchestrator: { + status: 'idle', + carriedArtifacts: [], + codeBadge: null, + waiting: false, + zoneId: 'governor', + }, + agents: [], + artifacts: [], + zones: [ + { id: 'prep', active: false, completed: false }, + { id: 'workshop', active: false, completed: false }, + { id: 'governor', active: false, completed: false }, + ], + ...overrides, + }; + } + + it('resolves orchestrator position to zone center', () => { + const c = config(); + const positions = resolvePositions(c, layout); + + const expected = layout.zoneCenter('governor'); + expect(positions.orchestrator).toEqual(expected); + }); + + it('resolves agent positions to slot positions', () => { + const c = config({ + agents: [ + { + id: 'a1', + role: 'architect', + roleType: 'analyst', + phase: 'architecture', + status: 'working', + zoneId: 'prep', + slotId: 'prep-ws-0', + }, + ], + }); + const positions = resolvePositions(c, layout); + + const expected = layout.slotPosition('prep-ws-0'); + expect(positions.agents.get('a1')).toEqual(expected); + }); + + it('resolves artifact positions to slot positions', () => { + const c = config({ + artifacts: [ + { + id: 'art1', + label: 'plan', + color: '#00ff00', + status: 'delivered', + producerPhase: 'planning', + zoneId: 'governor', + slotId: 'governor-storage-0', + }, + ], + }); + const positions = resolvePositions(c, layout); + + const expected = layout.slotPosition('governor-storage-0'); + expect(positions.artifacts.get('art1')).toEqual(expected); + }); + + it('resolves multiple agents and artifacts', () => { + const c = config({ + agents: [ + { + id: 'a1', + role: 'architect', + roleType: 'analyst', + phase: 'architecture', + status: 'working', + zoneId: 'prep', + slotId: 'prep-ws-0', + }, + { + id: 'a2', + role: 'coder', + roleType: 'author', + phase: 'implementation', + status: 'idle', + zoneId: 'workshop', + slotId: 'workshop-ws-0', + }, + ], + artifacts: [ + { + id: 'art1', + label: 'doc', + color: '#0000ff', + status: 'created', + producerPhase: 'architecture', + zoneId: 'prep', + slotId: 'prep-ws-0', + }, + ], + }); + const positions = resolvePositions(c, layout); + + expect(positions.agents.size).toBe(2); + expect(positions.artifacts.size).toBe(1); + expect(positions.agents.get('a1')).toEqual(layout.slotPosition('prep-ws-0')); + expect(positions.agents.get('a2')).toEqual(layout.slotPosition('workshop-ws-0')); + }); + + it('returns empty maps for empty config', () => { + const c = config(); + const positions = resolvePositions(c, layout); + + expect(positions.agents.size).toBe(0); + expect(positions.artifacts.size).toBe(0); + }); +}); diff --git a/packages/factory/src/client/visualizations/office/__tests__/transition-planner.test.ts b/packages/factory/src/client/visualizations/office/__tests__/transition-planner.test.ts new file mode 100644 index 00000000..89658a04 --- /dev/null +++ b/packages/factory/src/client/visualizations/office/__tests__/transition-planner.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest'; + +import { TRANSITION_STAGGER_MS } from '../constants/dimensions.js'; +import { createOfficeLayout } from '../layout/office-layout.js'; +import { planTransitions } from '../transitions/transition-planner.js'; +import type { OfficeDiff, Position, ResolvedPositions } from '../types.js'; + +const layout = createOfficeLayout(); + +/** Build a minimal OfficeDiff with no changes. */ +function emptyDiff(): OfficeDiff { + return { + orchestrator: { + moved: null, + statusChanged: null, + waitingChanged: null, + carriedChanged: null, + codeBadgeChanged: null, + }, + agents: [], + artifacts: { added: [], removed: [], statusChanged: [] }, + zones: [], + hasChanges: false, + }; +} + +/** Build minimal ResolvedPositions. */ +function positions(overrides: Partial = {}): ResolvedPositions { + return { + agents: new Map(), + artifacts: new Map(), + orchestrator: { x: 0, y: 0 }, + ...overrides, + }; +} + +describe(planTransitions, () => { + it('produces no transitions for an empty diff', () => { + const result = planTransitions(emptyDiff(), positions(), positions(), layout); + expect(result.transitions).toHaveLength(0); + }); + + it('produces a walk transition for orchestrator zone change', () => { + const diff: OfficeDiff = { + ...emptyDiff(), + orchestrator: { + ...emptyDiff().orchestrator, + moved: { from: 'governor', to: 'workshop' }, + }, + hasChanges: true, + }; + + const prevPos = positions({ orchestrator: layout.zoneCenter('governor') }); + const nextPos = positions({ orchestrator: layout.zoneCenter('workshop') }); + + const result = planTransitions(diff, prevPos, nextPos, layout); + const walk = result.transitions.find((t) => t.type === 'walk' && t.entityId === 'orchestrator'); + + expect(walk).toBeDefined(); + expect(walk?.entityKind).toBe('orchestrator'); + expect(walk?.waypoints?.length).toBeGreaterThan(2); // corridor waypoints included + }); + + it('produces a state_change transition for orchestrator status change', () => { + const diff: OfficeDiff = { + ...emptyDiff(), + orchestrator: { + ...emptyDiff().orchestrator, + statusChanged: { from: 'idle', to: 'dispatching' }, + }, + hasChanges: true, + }; + + const result = planTransitions(diff, positions(), positions(), layout); + const stateChange = result.transitions.find((t) => t.type === 'state_change' && t.entityId === 'orchestrator'); + + expect(stateChange).toBeDefined(); + expect(stateChange?.from).toBe('idle'); + expect(stateChange?.to).toBe('dispatching'); + }); + + it('produces walk transitions with corridor waypoints for agent cross-zone movement', () => { + const fromPos: Position = layout.slotPosition('prep-ws-0'); + const toPos: Position = layout.slotPosition('workshop-ws-0'); + + const diff: OfficeDiff = { + ...emptyDiff(), + agents: [ + { + agentId: 'a1', + statusChanged: null, + moved: { fromZone: 'prep', fromSlot: 'prep-ws-0', toZone: 'workshop', toSlot: 'workshop-ws-0' }, + }, + ], + hasChanges: true, + }; + + const prevPos = positions({ agents: new Map([['a1', fromPos]]) }); + const nextPos = positions({ agents: new Map([['a1', toPos]]) }); + + const result = planTransitions(diff, prevPos, nextPos, layout); + const walk = result.transitions.find((t) => t.type === 'walk' && t.entityId === 'a1'); + + expect(walk).toBeDefined(); + // Cross-zone: start + corridor waypoints + end > 2 points + expect(walk?.waypoints?.length).toBeGreaterThan(2); + }); + + it('produces state_change transitions for agent status changes', () => { + const diff: OfficeDiff = { + ...emptyDiff(), + agents: [ + { + agentId: 'a1', + statusChanged: { from: 'idle', to: 'working' }, + moved: null, + }, + ], + hasChanges: true, + }; + + const result = planTransitions(diff, positions(), positions(), layout); + const stateChange = result.transitions.find((t) => t.type === 'state_change' && t.entityId === 'a1'); + + expect(stateChange).toBeDefined(); + expect(stateChange?.from).toBe('idle'); + expect(stateChange?.to).toBe('working'); + }); + + it('staggers transitions by the configured delay', () => { + const diff: OfficeDiff = { + ...emptyDiff(), + orchestrator: { + ...emptyDiff().orchestrator, + moved: { from: 'governor', to: 'prep' }, + statusChanged: { from: 'idle', to: 'dispatching' }, + }, + hasChanges: true, + }; + + const prevPos = positions({ orchestrator: layout.zoneCenter('governor') }); + const nextPos = positions({ orchestrator: layout.zoneCenter('prep') }); + + const result = planTransitions(diff, prevPos, nextPos, layout); + + expect(result.transitions.length).toBeGreaterThanOrEqual(2); + const first = result.transitions[0]; + const second = result.transitions[1]; + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(first?.delayMs).toBe(0); + expect(second?.delayMs).toBe(TRANSITION_STAGGER_MS); + }); + + it('produces fade_in for newly added agents', () => { + const toPos: Position = layout.slotPosition('workshop-ws-0'); + + const diff: OfficeDiff = { + ...emptyDiff(), + agents: [ + { + agentId: 'new-agent', + statusChanged: null, + moved: { fromZone: '', fromSlot: '', toZone: 'workshop', toSlot: 'workshop-ws-0' }, + }, + ], + hasChanges: true, + }; + + const nextPos = positions({ agents: new Map([['new-agent', toPos]]) }); + const result = planTransitions(diff, positions(), nextPos, layout); + + const fadeIn = result.transitions.find((t) => t.type === 'fade_in' && t.entityId === 'new-agent'); + expect(fadeIn).toBeDefined(); + }); + + it('produces artifact_appear for newly added artifacts', () => { + const artPos: Position = layout.slotPosition('governor-storage-0'); + + const diff: OfficeDiff = { + ...emptyDiff(), + artifacts: { + added: [ + { + id: 'art1', + label: 'test', + color: '#ff0000', + status: 'created', + producerPhase: 'implementation', + zoneId: 'governor', + slotId: 'governor-storage-0', + }, + ], + removed: [], + statusChanged: [], + }, + hasChanges: true, + }; + + const nextPos = positions({ artifacts: new Map([['art1', artPos]]) }); + const result = planTransitions(diff, positions(), nextPos, layout); + + const appear = result.transitions.find((t) => t.type === 'artifact_appear' && t.entityId === 'art1'); + expect(appear).toBeDefined(); + }); + + it('produces artifact_deliver for artifacts transitioning to delivered', () => { + const diff: OfficeDiff = { + ...emptyDiff(), + artifacts: { + added: [], + removed: [], + statusChanged: [{ artifactId: 'art1', from: 'created', to: 'delivered' }], + }, + hasChanges: true, + }; + + const result = planTransitions(diff, positions(), positions(), layout); + const deliver = result.transitions.find((t) => t.type === 'artifact_deliver' && t.entityId === 'art1'); + expect(deliver).toBeDefined(); + expect(deliver?.from).toBe('created'); + expect(deliver?.to).toBe('delivered'); + }); +}); diff --git a/packages/factory/src/client/visualizations/office/__tests__/zone-definitions.test.ts b/packages/factory/src/client/visualizations/office/__tests__/zone-definitions.test.ts new file mode 100644 index 00000000..8e71af5a --- /dev/null +++ b/packages/factory/src/client/visualizations/office/__tests__/zone-definitions.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { GOVERNOR_ZONE, PREP_ZONE, WORKSHOP_ZONE, ZONE_DEFINITIONS } from '../constants/zone-definitions.js'; +import type { TileRect } from '../types.js'; + +describe('ZONE_DEFINITIONS', () => { + it('contains exactly 3 zones', () => { + expect(ZONE_DEFINITIONS).toHaveLength(3); + }); + + it('includes prep, workshop, and governor zones', () => { + const ids = ZONE_DEFINITIONS.map((z) => z.id); + expect(ids).toEqual(['prep', 'workshop', 'governor']); + }); + + it('has the expected number of slots per zone', () => { + expect(PREP_ZONE.slots).toHaveLength(2); + expect(WORKSHOP_ZONE.slots).toHaveLength(6); + expect(GOVERNOR_ZONE.slots).toHaveLength(4); + }); + + it('has globally unique slot IDs across all zones', () => { + const allSlotIds = ZONE_DEFINITIONS.flatMap((z) => z.slots.map((s) => s.id)); + const uniqueIds = new Set(allSlotIds); + expect(uniqueIds.size).toBe(allSlotIds.length); + }); + + it('has at least one door per zone', () => { + for (const zone of ZONE_DEFINITIONS) { + expect(zone.doors.length).toBeGreaterThanOrEqual(1); + } + }); + + it('has non-overlapping zone bounds', () => { + const zones = [...ZONE_DEFINITIONS]; + + for (let i = 0; i < zones.length; i++) { + for (let j = i + 1; j < zones.length; j++) { + const a = zones[i]; + const b = zones[j]; + if (a === undefined || b === undefined) { + throw new Error(`Zone at index ${i} or ${j} is undefined`); + } + expect(boundsOverlap(a.bounds, b.bounds)).toBe(false); + } + } + }); +}); + +/** Check whether two tile-space rectangles overlap. */ +function boundsOverlap(a: TileRect, b: TileRect): boolean { + const aRight = a.col + a.width; + const aBottom = a.row + a.height; + const bRight = b.col + b.width; + const bBottom = b.row + b.height; + + return a.col < bRight && aRight > b.col && a.row < bBottom && aBottom > b.row; +} diff --git a/packages/factory/src/client/visualizations/office/constants/dimensions.ts b/packages/factory/src/client/visualizations/office/constants/dimensions.ts new file mode 100644 index 00000000..a7f9c2eb --- /dev/null +++ b/packages/factory/src/client/visualizations/office/constants/dimensions.ts @@ -0,0 +1,17 @@ +/** Tile size in pixels, matching the prototype's 32px LimeZu tilesets. */ +export const TILE_SIZE = 32; + +/** Canvas width in tiles. */ +export const CANVAS_COLS = 40; + +/** Canvas height in tiles. */ +export const CANVAS_ROWS = 30; + +/** Canvas width in pixels. */ +export const CANVAS_WIDTH_PX = CANVAS_COLS * TILE_SIZE; + +/** Canvas height in pixels. */ +export const CANVAS_HEIGHT_PX = CANVAS_ROWS * TILE_SIZE; + +/** Stagger delay between transitions in milliseconds. */ +export const TRANSITION_STAGGER_MS = 150; diff --git a/packages/factory/src/client/visualizations/office/constants/zone-definitions.ts b/packages/factory/src/client/visualizations/office/constants/zone-definitions.ts new file mode 100644 index 00000000..ed1c5cf3 --- /dev/null +++ b/packages/factory/src/client/visualizations/office/constants/zone-definitions.ts @@ -0,0 +1,55 @@ +import type { ZoneDefinition } from '../types.js'; + +/** + * Prep area zone: architecture and planning agents work here. + * Matches prototype tiles: { x: 1, y: 1, w: 11, h: 12 }. + */ +export const PREP_ZONE: ZoneDefinition = { + id: 'prep', + label: 'Prep area', + bounds: { col: 1, row: 1, width: 11, height: 12 }, + slots: [ + { id: 'prep-ws-0', type: 'workstation', tile: { col: 3, row: 5 } }, + { id: 'prep-ws-1', type: 'workstation', tile: { col: 8, row: 5 } }, + ], + doors: [{ tile: { col: 6, row: 13 }, direction: 'down' }], +}; + +/** + * Workshop zone: coder and reviewers work here. + * Matches prototype tiles: { x: 13, y: 1, w: 26, h: 12 }. + */ +export const WORKSHOP_ZONE: ZoneDefinition = { + id: 'workshop', + label: 'The workshop', + bounds: { col: 13, row: 1, width: 26, height: 12 }, + slots: [ + { id: 'workshop-ws-0', type: 'workstation', tile: { col: 16, row: 5 } }, + { id: 'workshop-ws-1', type: 'workstation', tile: { col: 22, row: 10 } }, + { id: 'workshop-ws-2', type: 'workstation', tile: { col: 25, row: 10 } }, + { id: 'workshop-ws-3', type: 'workstation', tile: { col: 28, row: 10 } }, + { id: 'workshop-ws-4', type: 'workstation', tile: { col: 31, row: 10 } }, + { id: 'workshop-ws-5', type: 'workstation', tile: { col: 34, row: 10 } }, + ], + doors: [{ tile: { col: 13, row: 7 }, direction: 'left' }], +}; + +/** + * Governor's office zone: orchestrator home base with artifact storage. + * Matches prototype tiles: { x: 1, y: 16, w: 38, h: 13 }. + */ +export const GOVERNOR_ZONE: ZoneDefinition = { + id: 'governor', + label: "Governor's office", + bounds: { col: 1, row: 16, width: 38, height: 13 }, + slots: [ + { id: 'governor-desk-0', type: 'workstation', tile: { col: 5, row: 20 } }, + { id: 'governor-storage-0', type: 'storage', tile: { col: 12, row: 18 } }, + { id: 'governor-storage-1', type: 'storage', tile: { col: 16, row: 18 } }, + { id: 'governor-storage-2', type: 'storage', tile: { col: 20, row: 18 } }, + ], + doors: [{ tile: { col: 6, row: 16 }, direction: 'up' }], +}; + +/** All zone definitions for the 3-zone office layout. */ +export const ZONE_DEFINITIONS: readonly ZoneDefinition[] = [PREP_ZONE, WORKSHOP_ZONE, GOVERNOR_ZONE]; diff --git a/packages/factory/src/client/visualizations/office/layout/office-layout.ts b/packages/factory/src/client/visualizations/office/layout/office-layout.ts new file mode 100644 index 00000000..be186ab0 --- /dev/null +++ b/packages/factory/src/client/visualizations/office/layout/office-layout.ts @@ -0,0 +1,126 @@ +import { TILE_SIZE } from '../constants/dimensions.js'; +import { ZONE_DEFINITIONS } from '../constants/zone-definitions.js'; +import type { FacilityLayout, Position, SlotDefinition, SlotType, TileCoord, ZoneDefinition } from '../types.js'; + +/** Convert a tile coordinate to pixel-space center position. */ +function tileToPixel(tile: TileCoord): Position { + return { + x: tile.col * TILE_SIZE + TILE_SIZE / 2, + y: tile.row * TILE_SIZE + TILE_SIZE / 2, + }; +} + +/** Compute the pixel-space center of a zone's bounding rectangle. */ +function computeZoneCenter(zone: ZoneDefinition): Position { + return { + x: (zone.bounds.col + zone.bounds.width / 2) * TILE_SIZE, + y: (zone.bounds.row + zone.bounds.height / 2) * TILE_SIZE, + }; +} + +/** + * Build a corridor waypoint path between two zones by connecting their doorways + * through the shared corridor junction point. + */ +function buildCorridorPath(from: ZoneDefinition, to: ZoneDefinition): Position[] { + const fromDoor = from.doors[0]; + const toDoor = to.doors[0]; + if (fromDoor === undefined || toDoor === undefined) { + return []; + } + + const fromDoorPixel = tileToPixel(fromDoor.tile); + const toDoorPixel = tileToPixel(toDoor.tile); + + // Build a path through the corridor: exit from door, traverse junction, enter destination door + // The junction is the midpoint between the two doors in the gap between zones + const junction: Position = { + x: (fromDoorPixel.x + toDoorPixel.x) / 2, + y: (fromDoorPixel.y + toDoorPixel.y) / 2, + }; + + return [fromDoorPixel, junction, toDoorPixel]; +} + +/** + * Create the office facility layout query object. + * Converts tile-space zone and slot definitions into pixel-space positions, + * defines corridor paths between zones, and provides lookup methods. + */ +export function createOfficeLayout(): FacilityLayout { + const zones = ZONE_DEFINITIONS; + + // Build slot position lookup + const slotPositions = new Map(); + for (const zone of zones) { + for (const slot of zone.slots) { + slotPositions.set(slot.id, tileToPixel(slot.tile)); + } + } + + // Build zone center lookup + const zoneCenters = new Map(); + for (const zone of zones) { + zoneCenters.set(zone.id, computeZoneCenter(zone)); + } + + // Build zone lookup + const zoneById = new Map(); + for (const zone of zones) { + zoneById.set(zone.id, zone); + } + + // Pre-compute corridor paths for all 6 directional pairs + const corridorPaths = new Map(); + for (const from of zones) { + for (const to of zones) { + if (from.id === to.id) continue; + const key = `${from.id}->${to.id}`; + corridorPaths.set(key, buildCorridorPath(from, to)); + } + } + + function slotPosition(slotId: string): Position { + const pos = slotPositions.get(slotId); + if (pos === undefined) { + throw new Error(`Unknown slot ID: "${slotId}"`); + } + return pos; + } + + function zoneCenter(zoneId: string): Position { + const center = zoneCenters.get(zoneId); + if (center === undefined) { + throw new Error(`Unknown zone ID: "${zoneId}"`); + } + return center; + } + + function slotsInZone(zoneId: string, type?: SlotType): SlotDefinition[] { + const zone = zoneById.get(zoneId); + if (zone === undefined) { + throw new Error(`Unknown zone ID: "${zoneId}"`); + } + if (type === undefined) { + return [...zone.slots]; + } + return zone.slots.filter((s) => s.type === type); + } + + function corridorPath(fromZoneId: string, toZoneId: string): Position[] { + const key = `${fromZoneId}->${toZoneId}`; + const path = corridorPaths.get(key); + if (path === undefined) { + throw new Error(`No corridor path from "${fromZoneId}" to "${toZoneId}"`); + } + return [...path]; + } + + return { + slotPosition, + zoneCenter, + slotsInZone, + corridorPath, + zones, + }; +} diff --git a/packages/factory/src/client/visualizations/office/layout/position-resolver.ts b/packages/factory/src/client/visualizations/office/layout/position-resolver.ts new file mode 100644 index 00000000..2f707650 --- /dev/null +++ b/packages/factory/src/client/visualizations/office/layout/position-resolver.ts @@ -0,0 +1,21 @@ +import type { FacilityLayout, OfficeSceneConfig, Position, ResolvedPositions } from '../types.js'; + +/** + * Resolve pixel-space positions for all entities in an OfficeSceneConfig + * using the facility layout's spatial query methods. + */ +export function resolvePositions(config: OfficeSceneConfig, layout: FacilityLayout): ResolvedPositions { + const agents = new Map(); + for (const agent of config.agents) { + agents.set(agent.id, layout.slotPosition(agent.slotId)); + } + + const artifacts = new Map(); + for (const artifact of config.artifacts) { + artifacts.set(artifact.id, layout.slotPosition(artifact.slotId)); + } + + const orchestrator = layout.zoneCenter(config.orchestrator.zoneId); + + return { agents, artifacts, orchestrator }; +} diff --git a/packages/factory/src/client/visualizations/office/mappers/agent-zone-assignments.ts b/packages/factory/src/client/visualizations/office/mappers/agent-zone-assignments.ts new file mode 100644 index 00000000..33745d5f --- /dev/null +++ b/packages/factory/src/client/visualizations/office/mappers/agent-zone-assignments.ts @@ -0,0 +1,185 @@ +import type { PhaseName } from '../../../../shared/constants/role-types.js'; +import type { + LogicalAgentState, + LogicalArtifactState, + LogicalOrchestratorState, + OrchestratorStatus, +} from '../../shared/types.js'; +import type { OfficeAgentState, OfficeArtifactState, OfficeZoneState, ZoneDefinition } from '../types.js'; + +// --------------------------------------------------------------------------- +// Phase-to-zone mapping +// --------------------------------------------------------------------------- + +/** Map a phase to its office zone. */ +function phaseToZoneId(phase: PhaseName): string { + switch (phase) { + case 'architecture': + case 'planning': + return 'prep'; + case 'implementation': + case 'review': + case 'simplifier': + case 'holistic': + return 'workshop'; + case 'summary': + return 'governor'; + } +} + +// --------------------------------------------------------------------------- +// Agent assignment +// --------------------------------------------------------------------------- + +/** Assign an agent to a zone and slot based on phase and role type. */ +export function assignAgentToZone(agent: LogicalAgentState, agentIndex: number): { zoneId: string; slotId: string } { + const zoneId = phaseToZoneId(agent.phase); + + if (zoneId === 'prep') { + // Architect gets ws-0, planner gets ws-1 + const slotIndex = agent.phase === 'architecture' ? 0 : 1; + return { zoneId, slotId: `prep-ws-${slotIndex}` }; + } + + if (zoneId === 'workshop') { + // Coder gets ws-0, reviewers get ws-1 through ws-5 + if (agent.phase === 'implementation') { + return { zoneId, slotId: 'workshop-ws-0' }; + } + // Reviewers (review, simplifier, holistic) get ws-1 through ws-5 + // Cap at 5 reviewer slots + const reviewerSlot = Math.min(agentIndex, 5); + return { zoneId, slotId: `workshop-ws-${reviewerSlot}` }; + } + + // Governor zone fallback (summary phase agents) + return { zoneId, slotId: 'governor-desk-0' }; +} + +// --------------------------------------------------------------------------- +// Orchestrator zone +// --------------------------------------------------------------------------- + +/** Derive the orchestrator's zone from its status and the current phase. */ +export function deriveOrchestratorZone( + orchestrator: LogicalOrchestratorState, + currentPhase: PhaseName | undefined, +): string { + const homeZone = 'governor'; + + if (isOrchestratorAtHome(orchestrator.status)) { + return homeZone; + } + + // When dispatching or monitoring, infer zone from current phase + if (currentPhase !== undefined) { + return phaseToZoneId(currentPhase); + } + + return homeZone; +} + +/** Check whether the orchestrator status indicates it should be at its home zone. */ +function isOrchestratorAtHome(status: OrchestratorStatus): boolean { + return status === 'idle' || status === 'done' || status === 'delivering'; +} + +// --------------------------------------------------------------------------- +// Artifact assignment +// --------------------------------------------------------------------------- + +/** Assign an artifact to a zone and slot based on its status and producer phase. */ +export function assignArtifactToZone( + artifact: LogicalArtifactState, + storageCounter: number, +): { zoneId: string; slotId: string } { + if (artifact.status === 'delivered') { + // Delivered artifacts go to governor storage slots (cycle through available slots) + const storageSlotIndex = storageCounter % 3; + return { zoneId: 'governor', slotId: `governor-storage-${storageSlotIndex}` }; + } + + // Created and in_transit artifacts stay at their producer's zone + const zoneId = phaseToZoneId(artifact.producerPhase); + const producerSlotId = resolveProducerSlot(artifact.producerPhase); + return { zoneId, slotId: producerSlotId }; +} + +/** Resolve the slot ID for the primary producer of a phase. */ +function resolveProducerSlot(phase: PhaseName): string { + switch (phase) { + case 'architecture': + return 'prep-ws-0'; + case 'planning': + return 'prep-ws-1'; + case 'implementation': + return 'workshop-ws-0'; + case 'review': + case 'simplifier': + case 'holistic': + return 'workshop-ws-1'; + case 'summary': + return 'governor-desk-0'; + } +} + +// --------------------------------------------------------------------------- +// Zone state derivation +// --------------------------------------------------------------------------- + +/** Derive aggregate zone states from the agents present in each zone. */ +export function deriveZoneStates(agents: OfficeAgentState[], zones: readonly ZoneDefinition[]): OfficeZoneState[] { + return zones.map((zone) => { + const agentsInZone = agents.filter((a) => a.zoneId === zone.id); + + // No agents: neither active nor completed + if (agentsInZone.length === 0) { + return { id: zone.id, active: false, completed: false }; + } + + const active = agentsInZone.some((a) => a.status === 'working'); + const completed = agentsInZone.every((a) => a.status === 'done'); + + return { id: zone.id, active, completed }; + }); +} + +// --------------------------------------------------------------------------- +// Reviewer index computation +// --------------------------------------------------------------------------- + +/** Compute stable reviewer slot indices by sorting review-phase agents by ID. */ +export function computeReviewerIndices(agents: LogicalAgentState[]): Map { + const reviewAgents = agents + .filter((a) => a.phase === 'review' || a.phase === 'simplifier' || a.phase === 'holistic') + .toSorted((a, b) => a.id.localeCompare(b.id)); + + const indices = new Map(); + for (const [i, agent] of reviewAgents.entries()) { + // Reviewer slots start at ws-1, cap at ws-5 + indices.set(agent.id, Math.min(i + 1, 5)); + } + return indices; +} + +/** Build a complete artifact assignment producing OfficeArtifactState values. */ +export function buildArtifactStates(artifacts: LogicalArtifactState[]): OfficeArtifactState[] { + let storageCounter = 0; + + return artifacts.map((artifact) => { + const assignment = assignArtifactToZone(artifact, storageCounter); + if (artifact.status === 'delivered') { + storageCounter++; + } + + return { + id: artifact.id, + label: artifact.label, + color: artifact.color, + status: artifact.status, + producerPhase: artifact.producerPhase, + zoneId: assignment.zoneId, + slotId: assignment.slotId, + }; + }); +} diff --git a/packages/factory/src/client/visualizations/office/mappers/logical-to-office.ts b/packages/factory/src/client/visualizations/office/mappers/logical-to-office.ts new file mode 100644 index 00000000..b5432095 --- /dev/null +++ b/packages/factory/src/client/visualizations/office/mappers/logical-to-office.ts @@ -0,0 +1,55 @@ +import type { LogicalSceneState } from '../../shared/types.js'; +import { ZONE_DEFINITIONS } from '../constants/zone-definitions.js'; +import type { OfficeAgentState, OfficeSceneConfig } from '../types.js'; +import { + assignAgentToZone, + buildArtifactStates, + computeReviewerIndices, + deriveOrchestratorZone, + deriveZoneStates, +} from './agent-zone-assignments.js'; + +/** + * Transform a visualization-agnostic LogicalSceneState into an office-specific + * OfficeSceneConfig with spatial zone and slot assignments. + */ +export function mapLogicalToOffice(logical: LogicalSceneState): OfficeSceneConfig { + // Build reviewer index lookup for stable slot assignment + const reviewerIndices = computeReviewerIndices(logical.agents); + + // Assign agents to zones and slots + const agents: OfficeAgentState[] = logical.agents.map((agent) => { + const isReviewer = reviewerIndices.has(agent.id); + const slotIndex = isReviewer ? (reviewerIndices.get(agent.id) ?? 1) : 0; + const assignment = assignAgentToZone(agent, slotIndex); + + return { + id: agent.id, + role: agent.role, + roleType: agent.roleType, + phase: agent.phase, + status: agent.status, + zoneId: assignment.zoneId, + slotId: assignment.slotId, + }; + }); + + // Derive orchestrator zone + const orchestratorZoneId = deriveOrchestratorZone(logical.orchestrator, logical.currentPhase); + + const orchestrator = { + status: logical.orchestrator.status, + carriedArtifacts: logical.orchestrator.carriedArtifacts, + codeBadge: logical.orchestrator.codeBadge, + waiting: logical.orchestrator.waiting, + zoneId: orchestratorZoneId, + }; + + // Assign artifacts to zones + const artifacts = buildArtifactStates(logical.artifacts); + + // Derive zone states from assigned agents + const zones = deriveZoneStates(agents, ZONE_DEFINITIONS); + + return { orchestrator, agents, artifacts, zones }; +} diff --git a/packages/factory/src/client/visualizations/office/scene/OfficeScene.ts b/packages/factory/src/client/visualizations/office/scene/OfficeScene.ts new file mode 100644 index 00000000..3342b56b --- /dev/null +++ b/packages/factory/src/client/visualizations/office/scene/OfficeScene.ts @@ -0,0 +1,214 @@ +import { Actor, Circle, Color, Font, Label, Rectangle, Scene, TextAlign, vec } from 'excalibur'; + +import { getRoleTypeColor } from '../../../../shared/constants/role-types.js'; +import type { LogicalSceneState } from '../../shared/types.js'; +import { CANVAS_HEIGHT_PX, CANVAS_WIDTH_PX, TILE_SIZE } from '../constants/dimensions.js'; +import { createOfficeLayout } from '../layout/office-layout.js'; +import { resolvePositions } from '../layout/position-resolver.js'; +import { mapLogicalToOffice } from '../mappers/logical-to-office.js'; +import { diffOfficeConfigs } from '../state/office-differ.js'; +import type { FacilityLayout, OfficeSceneConfig, Position, ResolvedPositions } from '../types.js'; + +// --------------------------------------------------------------------------- +// Visual constants +// --------------------------------------------------------------------------- + +const AGENT_RADIUS = 12; +const ORCHESTRATOR_RADIUS = 16; +const ARTIFACT_WIDTH = 24; +const ARTIFACT_HEIGHT = 12; + +const ZONE_COLORS: Record = { + prep: '#4A90D9', + workshop: '#D97B4A', + governor: '#6B8E23', +}; + +const ZONE_BORDER_OPACITY = 0.3; +const ZONE_FILL_OPACITY = 0.08; + +// --------------------------------------------------------------------------- +// OfficeScene +// --------------------------------------------------------------------------- + +/** + * Excalibur scene that renders the office visualization pipeline: + * LogicalSceneState -> adapter -> differ -> position resolver -> transition planner -> render. + * Uses geometric placeholders for all entities. + */ +export class OfficeScene extends Scene { + private readonly layout: FacilityLayout; + private prevConfig: OfficeSceneConfig | undefined; + + // Actor registries for incremental updates + private agentActors = new Map(); + private artifactActors = new Map(); + private orchestratorActor: Actor | undefined; + + constructor() { + super(); + this.layout = createOfficeLayout(); + this.backgroundColor = Color.fromHex('#e8e4e0'); + } + + override onInitialize(): void { + this.drawZones(); + this.positionCamera(); + } + + /** Apply a new logical scene state, running the full pipeline. */ + updateState(logical: LogicalSceneState): void { + const nextConfig = mapLogicalToOffice(logical); + const nextPositions = resolvePositions(nextConfig, this.layout); + + if (this.prevConfig === undefined) { + this.applyFullState(nextConfig, nextPositions); + } else { + const diff = diffOfficeConfigs(this.prevConfig, nextConfig); + if (diff.hasChanges) { + // Teleport entities to new positions (no animation in this ticket) + this.applyFullState(nextConfig, nextPositions); + } + } + + this.prevConfig = nextConfig; + } + + /** Clear all entity actors and rebuild from scratch. */ + private applyFullState(config: OfficeSceneConfig, positions: ResolvedPositions): void { + this.clearEntities(); + + // Place orchestrator + this.placeOrchestrator(config, positions.orchestrator); + + // Place agents + for (const agent of config.agents) { + const pos = positions.agents.get(agent.id); + if (pos !== undefined) { + this.placeAgent(agent.id, agent.roleType, pos); + } + } + + // Place artifacts + for (const artifact of config.artifacts) { + const pos = positions.artifacts.get(artifact.id); + if (pos !== undefined) { + this.placeArtifact(artifact.id, artifact.color, pos); + } + } + } + + /** Remove all entity actors from the scene. */ + private clearEntities(): void { + if (this.orchestratorActor !== undefined) { + this.remove(this.orchestratorActor); + this.orchestratorActor = undefined; + } + + for (const actor of this.agentActors.values()) { + this.remove(actor); + } + this.agentActors.clear(); + + for (const actor of this.artifactActors.values()) { + this.remove(actor); + } + this.artifactActors.clear(); + } + + /** Draw zone rectangles as labeled geometric areas. */ + private drawZones(): void { + for (const zone of this.layout.zones) { + const x = zone.bounds.col * TILE_SIZE; + const y = zone.bounds.row * TILE_SIZE; + const width = zone.bounds.width * TILE_SIZE; + const height = zone.bounds.height * TILE_SIZE; + const color = ZONE_COLORS[zone.id] ?? '#888888'; + + // Zone fill + const fill = new Actor({ pos: vec(x + width / 2, y + height / 2) }); + fill.graphics.use( + new Rectangle({ + width, + height, + color: Color.fromHex(color), + }), + ); + fill.graphics.opacity = ZONE_FILL_OPACITY; + this.add(fill); + + // Zone border + const border = new Actor({ pos: vec(x + width / 2, y + height / 2) }); + border.graphics.use( + new Rectangle({ + width, + height, + color: Color.Transparent, + strokeColor: Color.fromHex(color), + lineWidth: 2, + }), + ); + border.graphics.opacity = ZONE_BORDER_OPACITY; + this.add(border); + + // Zone label + const label = new Label({ + text: zone.label, + pos: vec(x + width / 2, y + 12), + font: new Font({ + family: 'monospace', + size: 10, + color: Color.fromHex(color), + textAlign: TextAlign.Center, + }), + }); + this.add(label); + } + } + + /** Place a colored circle for an agent at the given position. */ + private placeAgent(agentId: string, roleType: string, pos: Position): void { + const color = getRoleTypeColor(roleType); + const actor = new Actor({ pos: vec(pos.x, pos.y) }); + actor.graphics.use( + new Circle({ + radius: AGENT_RADIUS, + color: Color.fromHex(color), + }), + ); + this.add(actor); + this.agentActors.set(agentId, actor); + } + + /** Place a larger circle for the orchestrator at the given position. */ + private placeOrchestrator(_config: OfficeSceneConfig, pos: Position): void { + const actor = new Actor({ pos: vec(pos.x, pos.y) }); + actor.graphics.use( + new Circle({ + radius: ORCHESTRATOR_RADIUS, + color: Color.fromHex('#FF55FF'), + }), + ); + this.add(actor); + this.orchestratorActor = actor; + } + + /** Place a colored rectangle for an artifact at the given position. */ + private placeArtifact(artifactId: string, color: string, pos: Position): void { + const actor = new Actor({ pos: vec(pos.x, pos.y) }); + actor.graphics.use( + new Rectangle({ + width: ARTIFACT_WIDTH, + height: ARTIFACT_HEIGHT, + color: Color.fromHex(color), + }), + ); + this.add(actor); + this.artifactActors.set(artifactId, actor); + } + + /** Center the camera on the facility. */ + private positionCamera(): void { + this.camera.pos = vec(CANVAS_WIDTH_PX / 2, CANVAS_HEIGHT_PX / 2); + } +} diff --git a/packages/factory/src/client/visualizations/office/state/office-differ.ts b/packages/factory/src/client/visualizations/office/state/office-differ.ts new file mode 100644 index 00000000..16886b87 --- /dev/null +++ b/packages/factory/src/client/visualizations/office/state/office-differ.ts @@ -0,0 +1,151 @@ +import type { + OfficeAgentDiff, + OfficeAgentState, + OfficeArtifactDiffs, + OfficeArtifactState, + OfficeDiff, + OfficeOrchestratorDiff, + OfficeOrchestratorState, + OfficeSceneConfig, + OfficeZoneState, + ZoneDiffEntry, +} from '../types.js'; + +/** Compare two orchestrator states and return zone/status/waiting/carried/badge changes. */ +function diffOrchestrator(prev: OfficeOrchestratorState, next: OfficeOrchestratorState): OfficeOrchestratorDiff { + const moved = prev.zoneId === next.zoneId ? null : { from: prev.zoneId, to: next.zoneId }; + const statusChanged = prev.status === next.status ? null : { from: prev.status, to: next.status }; + const waitingChanged = prev.waiting === next.waiting ? null : { from: prev.waiting, to: next.waiting }; + + const prevCarried = JSON.stringify(prev.carriedArtifacts); + const nextCarried = JSON.stringify(next.carriedArtifacts); + const carriedChanged = + prevCarried === nextCarried ? null : { from: prev.carriedArtifacts, to: next.carriedArtifacts }; + + const prevBadge = prev.codeBadge === null ? null : `${prev.codeBadge.label}:${prev.codeBadge.color}`; + const nextBadge = next.codeBadge === null ? null : `${next.codeBadge.label}:${next.codeBadge.color}`; + const codeBadgeChanged = prevBadge === nextBadge ? null : { from: prev.codeBadge, to: next.codeBadge }; + + return { moved, statusChanged, waitingChanged, carriedChanged, codeBadgeChanged }; +} + +/** Compare two agent arrays by ID, detecting status and zone/slot changes. */ +function diffAgents(prev: readonly OfficeAgentState[], next: readonly OfficeAgentState[]): OfficeAgentDiff[] { + const prevById = new Map(prev.map((a) => [a.id, a])); + const nextById = new Map(next.map((a) => [a.id, a])); + const diffs: OfficeAgentDiff[] = []; + + for (const [id, nextAgent] of nextById) { + const prevAgent = prevById.get(id); + if (prevAgent === undefined) { + // New agent: report as moved from nowhere + diffs.push({ + agentId: id, + statusChanged: null, + moved: { fromZone: '', fromSlot: '', toZone: nextAgent.zoneId, toSlot: nextAgent.slotId }, + }); + continue; + } + + const statusChanged = + prevAgent.status === nextAgent.status ? null : { from: prevAgent.status, to: nextAgent.status }; + const moved = + prevAgent.zoneId === nextAgent.zoneId && prevAgent.slotId === nextAgent.slotId + ? null + : { + fromZone: prevAgent.zoneId, + fromSlot: prevAgent.slotId, + toZone: nextAgent.zoneId, + toSlot: nextAgent.slotId, + }; + + if (statusChanged !== null || moved !== null) { + diffs.push({ agentId: id, statusChanged, moved }); + } + } + + // Removed agents + for (const [id, prevAgent] of prevById) { + if (!nextById.has(id)) { + diffs.push({ + agentId: id, + statusChanged: null, + moved: { fromZone: prevAgent.zoneId, fromSlot: prevAgent.slotId, toZone: '', toSlot: '' }, + }); + } + } + + return diffs; +} + +/** Compare two artifact arrays, detecting additions, removals, and status changes. */ +function diffArtifacts( + prev: readonly OfficeArtifactState[], + next: readonly OfficeArtifactState[], +): OfficeArtifactDiffs { + const prevById = new Map(prev.map((a) => [a.id, a])); + const nextById = new Map(next.map((a) => [a.id, a])); + + const added: OfficeArtifactState[] = []; + const removed: OfficeArtifactState[] = []; + const statusChanged: Array<{ artifactId: string; from: string; to: string }> = []; + + for (const [id, nextArtifact] of nextById) { + const prevArtifact = prevById.get(id); + if (prevArtifact === undefined) { + added.push(nextArtifact); + } else if (prevArtifact.status !== nextArtifact.status) { + statusChanged.push({ artifactId: id, from: prevArtifact.status, to: nextArtifact.status }); + } + } + + for (const [id, prevArtifact] of prevById) { + if (!nextById.has(id)) { + removed.push(prevArtifact); + } + } + + return { added, removed, statusChanged }; +} + +/** Compare two zone state arrays, tracking changes to `active` and `completed` fields. */ +function diffZones(prev: readonly OfficeZoneState[], next: readonly OfficeZoneState[]): ZoneDiffEntry[] { + const prevById = new Map(prev.map((z) => [z.id, z])); + const entries: ZoneDiffEntry[] = []; + + for (const nextZone of next) { + const prevZone = prevById.get(nextZone.id); + if (prevZone === undefined) continue; + + if (prevZone.active !== nextZone.active) { + entries.push({ zoneId: nextZone.id, field: 'active', from: prevZone.active, to: nextZone.active }); + } + if (prevZone.completed !== nextZone.completed) { + entries.push({ zoneId: nextZone.id, field: 'completed', from: prevZone.completed, to: nextZone.completed }); + } + } + + return entries; +} + +/** Compute the structural diff between two OfficeSceneConfig snapshots. */ +export function diffOfficeConfigs(prev: OfficeSceneConfig, next: OfficeSceneConfig): OfficeDiff { + const orchestrator = diffOrchestrator(prev.orchestrator, next.orchestrator); + const agents = diffAgents(prev.agents, next.agents); + const artifacts = diffArtifacts(prev.artifacts, next.artifacts); + const zones = diffZones(prev.zones, next.zones); + + const hasChanges = + orchestrator.moved !== null || + orchestrator.statusChanged !== null || + orchestrator.waitingChanged !== null || + orchestrator.carriedChanged !== null || + orchestrator.codeBadgeChanged !== null || + agents.length > 0 || + artifacts.added.length > 0 || + artifacts.removed.length > 0 || + artifacts.statusChanged.length > 0 || + zones.length > 0; + + return { orchestrator, agents, artifacts, zones, hasChanges }; +} diff --git a/packages/factory/src/client/visualizations/office/transitions/transition-planner.ts b/packages/factory/src/client/visualizations/office/transitions/transition-planner.ts new file mode 100644 index 00000000..e818b519 --- /dev/null +++ b/packages/factory/src/client/visualizations/office/transitions/transition-planner.ts @@ -0,0 +1,189 @@ +import { TRANSITION_STAGGER_MS } from '../constants/dimensions.js'; +import type { FacilityLayout, OfficeDiff, Position, ResolvedPositions, Transition, TransitionPlan } from '../types.js'; + +/** + * Convert an OfficeDiff into an ordered list of transition instructions + * with staggered timing. Walk transitions include corridor waypoints + * when an entity moves between zones. + */ +export function planTransitions( + diff: OfficeDiff, + prevPositions: ResolvedPositions, + nextPositions: ResolvedPositions, + layout: FacilityLayout, +): TransitionPlan { + const transitions: Transition[] = []; + let staggerIndex = 0; + + // Orchestrator transitions + if (diff.orchestrator.moved !== null) { + const fromZone = diff.orchestrator.moved.from; + const toZone = diff.orchestrator.moved.to; + const waypoints = buildWalkWaypoints( + prevPositions.orchestrator, + nextPositions.orchestrator, + fromZone, + toZone, + layout, + ); + + transitions.push({ + type: 'walk', + entityId: 'orchestrator', + entityKind: 'orchestrator', + delayMs: staggerIndex * TRANSITION_STAGGER_MS, + waypoints, + }); + staggerIndex++; + } + + if (diff.orchestrator.statusChanged !== null) { + transitions.push({ + type: 'state_change', + entityId: 'orchestrator', + entityKind: 'orchestrator', + delayMs: staggerIndex * TRANSITION_STAGGER_MS, + from: diff.orchestrator.statusChanged.from, + to: diff.orchestrator.statusChanged.to, + }); + staggerIndex++; + } + + // Agent transitions + for (const agentDiff of diff.agents) { + if (agentDiff.moved !== null) { + const fromPos = prevPositions.agents.get(agentDiff.agentId); + const toPos = nextPositions.agents.get(agentDiff.agentId); + + // New agent appearing (fromZone is empty) + if (agentDiff.moved.fromZone === '') { + if (toPos !== undefined) { + transitions.push({ + type: 'fade_in', + entityId: agentDiff.agentId, + entityKind: 'agent', + delayMs: staggerIndex * TRANSITION_STAGGER_MS, + waypoints: [toPos], + }); + staggerIndex++; + } + continue; + } + + // Agent removed (toZone is empty) + if (agentDiff.moved.toZone === '') { + if (fromPos !== undefined) { + transitions.push({ + type: 'fade_out', + entityId: agentDiff.agentId, + entityKind: 'agent', + delayMs: staggerIndex * TRANSITION_STAGGER_MS, + waypoints: [fromPos], + }); + staggerIndex++; + } + continue; + } + + // Agent moved between zones or slots + if (fromPos !== undefined && toPos !== undefined) { + const waypoints = buildWalkWaypoints(fromPos, toPos, agentDiff.moved.fromZone, agentDiff.moved.toZone, layout); + transitions.push({ + type: 'walk', + entityId: agentDiff.agentId, + entityKind: 'agent', + delayMs: staggerIndex * TRANSITION_STAGGER_MS, + waypoints, + }); + staggerIndex++; + } + } + + if (agentDiff.statusChanged !== null) { + transitions.push({ + type: 'state_change', + entityId: agentDiff.agentId, + entityKind: 'agent', + delayMs: staggerIndex * TRANSITION_STAGGER_MS, + from: agentDiff.statusChanged.from, + to: agentDiff.statusChanged.to, + }); + staggerIndex++; + } + } + + // Artifact transitions + for (const added of diff.artifacts.added) { + const pos = nextPositions.artifacts.get(added.id); + if (pos !== undefined) { + transitions.push({ + type: 'artifact_appear', + entityId: added.id, + entityKind: 'artifact', + delayMs: staggerIndex * TRANSITION_STAGGER_MS, + waypoints: [pos], + }); + staggerIndex++; + } + } + + for (const statusChange of diff.artifacts.statusChanged) { + if (statusChange.to === 'delivered') { + transitions.push({ + type: 'artifact_deliver', + entityId: statusChange.artifactId, + entityKind: 'artifact', + delayMs: staggerIndex * TRANSITION_STAGGER_MS, + from: statusChange.from, + to: statusChange.to, + }); + staggerIndex++; + } else { + transitions.push({ + type: 'state_change', + entityId: statusChange.artifactId, + entityKind: 'artifact', + delayMs: staggerIndex * TRANSITION_STAGGER_MS, + from: statusChange.from, + to: statusChange.to, + }); + staggerIndex++; + } + } + + for (const removed of diff.artifacts.removed) { + const pos = prevPositions.artifacts.get(removed.id); + if (pos !== undefined) { + transitions.push({ + type: 'fade_out', + entityId: removed.id, + entityKind: 'artifact', + delayMs: staggerIndex * TRANSITION_STAGGER_MS, + waypoints: [pos], + }); + staggerIndex++; + } + } + + return { transitions }; +} + +/** + * Build walk waypoints including corridor path when moving between different zones, + * or a direct path when moving within the same zone. + */ +function buildWalkWaypoints( + from: Position, + to: Position, + fromZone: string, + toZone: string, + layout: FacilityLayout, +): Position[] { + if (fromZone === toZone) { + return [from, to]; + } + + // Cross-zone: go through corridor + const corridor = layout.corridorPath(fromZone, toZone); + return [from, ...corridor, to]; +} diff --git a/packages/factory/src/client/visualizations/office/types.ts b/packages/factory/src/client/visualizations/office/types.ts new file mode 100644 index 00000000..c8704b2c --- /dev/null +++ b/packages/factory/src/client/visualizations/office/types.ts @@ -0,0 +1,213 @@ +import type { PhaseName, RoleType } from '../../../shared/constants/role-types.js'; + +// Re-export visualization-agnostic status types from shared +export type { CarriedArtifact, CodeBadge } from '../shared/orchestrator-utils.js'; +export type { AgentStatus, ArtifactStatus, OrchestratorStatus } from '../shared/types.js'; + +// --------------------------------------------------------------------------- +// Spatial primitives +// --------------------------------------------------------------------------- + +/** Tile-space coordinate (column, row). */ +export interface TileCoord { + col: number; + row: number; +} + +/** Tile-space axis-aligned rectangle. */ +export interface TileRect { + col: number; + row: number; + width: number; + height: number; +} + +/** Cardinal direction for pathfinding and facing. */ +export type Direction = 'up' | 'down' | 'left' | 'right'; + +/** Semantic role of a slot within a zone. */ +export type SlotType = 'workstation' | 'storage'; + +/** A named position within a zone where an entity can be placed. */ +export interface SlotDefinition { + id: string; + type: SlotType; + tile: TileCoord; +} + +/** A doorway connecting a zone to the corridor system. */ +export interface DoorDefinition { + tile: TileCoord; + direction: Direction; +} + +/** Spatial definition of a named zone with bounds, slots, and doorways. */ +export interface ZoneDefinition { + id: string; + label: string; + bounds: TileRect; + slots: SlotDefinition[]; + doors: DoorDefinition[]; +} + +// --------------------------------------------------------------------------- +// Pixel-space position +// --------------------------------------------------------------------------- + +/** Pixel-space position. */ +export interface Position { + x: number; + y: number; +} + +// --------------------------------------------------------------------------- +// Facility layout query API +// --------------------------------------------------------------------------- + +/** Query interface for the spatial layout consumed by downstream layers. */ +export interface FacilityLayout { + /** Return pixel position for a given slot ID. */ + slotPosition(slotId: string): Position; + /** Return pixel center of a zone. */ + zoneCenter(zoneId: string): Position; + /** Return all slots in a zone, optionally filtered by slot type. */ + slotsInZone(zoneId: string, type?: SlotType): SlotDefinition[]; + /** Return corridor waypoints between two zones (directional). */ + corridorPath(fromZoneId: string, toZoneId: string): Position[]; + /** All zone definitions. */ + zones: readonly ZoneDefinition[]; +} + +// --------------------------------------------------------------------------- +// Office scene config (spatial layer output) +// --------------------------------------------------------------------------- + +/** Spatial configuration for the office visualization. */ +export interface OfficeSceneConfig { + orchestrator: OfficeOrchestratorState; + agents: OfficeAgentState[]; + artifacts: OfficeArtifactState[]; + zones: OfficeZoneState[]; +} + +/** Agent state with spatial assignment. */ +export interface OfficeAgentState { + id: string; + role: string; + roleType: RoleType; + phase: PhaseName; + status: import('../shared/types.js').AgentStatus; + zoneId: string; + slotId: string; +} + +/** Orchestrator state with spatial assignment. */ +export interface OfficeOrchestratorState { + status: import('../shared/types.js').OrchestratorStatus; + carriedArtifacts: import('../shared/orchestrator-utils.js').CarriedArtifact[]; + codeBadge: import('../shared/orchestrator-utils.js').CodeBadge | null; + waiting: boolean; + zoneId: string; +} + +/** Artifact state with spatial assignment. */ +export interface OfficeArtifactState { + id: string; + label: string; + color: string; + status: import('../shared/types.js').ArtifactStatus; + producerPhase: PhaseName; + zoneId: string; + slotId: string; +} + +/** Aggregate state of a zone derived from the agents within it. */ +export interface OfficeZoneState { + id: string; + active: boolean; + completed: boolean; +} + +// --------------------------------------------------------------------------- +// Diff types +// --------------------------------------------------------------------------- + +/** Describes a single field change on a zone. */ +export interface ZoneDiffEntry { + zoneId: string; + field: string; + from: unknown; + to: unknown; +} + +/** Orchestrator-level diff fields. */ +export interface OfficeOrchestratorDiff { + moved: { from: string; to: string } | null; + statusChanged: { from: string; to: string } | null; + waitingChanged: { from: boolean; to: boolean } | null; + carriedChanged: { + from: import('../shared/orchestrator-utils.js').CarriedArtifact[]; + to: import('../shared/orchestrator-utils.js').CarriedArtifact[]; + } | null; + codeBadgeChanged: { + from: import('../shared/orchestrator-utils.js').CodeBadge | null; + to: import('../shared/orchestrator-utils.js').CodeBadge | null; + } | null; +} + +/** Agent-level diff fields. */ +export interface OfficeAgentDiff { + agentId: string; + statusChanged: { from: string; to: string } | null; + moved: { fromZone: string; fromSlot: string; toZone: string; toSlot: string } | null; +} + +/** Artifact-level diff fields. */ +export interface OfficeArtifactDiffs { + added: OfficeArtifactState[]; + removed: OfficeArtifactState[]; + statusChanged: Array<{ artifactId: string; from: string; to: string }>; +} + +/** Structural diff between two OfficeSceneConfig snapshots. */ +export interface OfficeDiff { + orchestrator: OfficeOrchestratorDiff; + agents: OfficeAgentDiff[]; + artifacts: OfficeArtifactDiffs; + zones: ZoneDiffEntry[]; + hasChanges: boolean; +} + +// --------------------------------------------------------------------------- +// Resolved positions (pixel-space output) +// --------------------------------------------------------------------------- + +/** Pixel-space positions for all entities in a scene snapshot. */ +export interface ResolvedPositions { + agents: Map; + artifacts: Map; + orchestrator: Position; +} + +// --------------------------------------------------------------------------- +// Transition plan types +// --------------------------------------------------------------------------- + +/** Transition action type. */ +export type TransitionType = 'walk' | 'state_change' | 'fade_in' | 'fade_out' | 'artifact_appear' | 'artifact_deliver'; + +/** A single transition instruction for the rendering layer. */ +export interface Transition { + type: TransitionType; + entityId: string; + entityKind: 'agent' | 'orchestrator' | 'artifact'; + delayMs: number; + waypoints?: Position[]; + from?: unknown; + to?: unknown; +} + +/** Ordered list of transitions to apply. */ +export interface TransitionPlan { + transitions: Transition[]; +} From b61743397d55bdf7fb16d6e2d3451b4b7d4c63e2 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sun, 15 Mar 2026 02:27:33 -0700 Subject: [PATCH 2/4] factory|fix: Harden office adapter error handling and tests Replace inline `import()` type expressions in `types.ts` with references to already-imported names. Throw on doorless zones in `buildCorridorPath` instead of silently returning `[]`. Return defensive copies from `slotPosition` and `zoneCenter` to prevent cache mutation. Add `console.warn` when reviewer count exceeds the 5 available workshop slots. Wrap `OfficeScene.updateState` in try/catch to prevent pipeline errors from crashing the game loop. Remove unused `_config` parameter from `placeOrchestrator`. Add tests for summary-phase agent slot assignment, idle+done zone state, `in_transit` artifacts in `buildArtifactStates`, storage slot count invariant, reviewer overflow warning, `done`/`delivering` orchestrator routing, and corridor path reversal content. Add comments noting deferred display slots in zone definitions and tests. --- .../__tests__/agent-zone-assignments.test.ts | 60 ++++++++++++++++++- .../__tests__/logical-to-office.test.ts | 16 +++++ .../office/__tests__/office-layout.test.ts | 1 + .../office/__tests__/zone-definitions.test.ts | 2 + .../office/constants/zone-definitions.ts | 3 + .../office/layout/office-layout.ts | 11 ++-- .../office/mappers/agent-zone-assignments.ts | 11 +++- .../office/scene/OfficeScene.ts | 30 ++++++---- .../src/client/visualizations/office/types.ts | 28 +++++---- 9 files changed, 129 insertions(+), 33 deletions(-) diff --git a/packages/factory/src/client/visualizations/office/__tests__/agent-zone-assignments.test.ts b/packages/factory/src/client/visualizations/office/__tests__/agent-zone-assignments.test.ts index 3836abd3..2a07516c 100644 --- a/packages/factory/src/client/visualizations/office/__tests__/agent-zone-assignments.test.ts +++ b/packages/factory/src/client/visualizations/office/__tests__/agent-zone-assignments.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { LogicalAgentState, LogicalArtifactState, LogicalOrchestratorState } from '../../shared/types.js'; -import { ZONE_DEFINITIONS } from '../constants/zone-definitions.js'; +import { GOVERNOR_ZONE, ZONE_DEFINITIONS } from '../constants/zone-definitions.js'; import { assignAgentToZone, assignArtifactToZone, @@ -80,6 +80,11 @@ describe(assignAgentToZone, () => { const result = assignAgentToZone(agent({ id: 'hol', phase: 'holistic', roleType: 'reviewer' }), 3); expect(result).toEqual({ zoneId: 'workshop', slotId: 'workshop-ws-3' }); }); + + it('assigns summary-phase agent to governor/governor-desk-0', () => { + const result = assignAgentToZone(agent({ id: 'sum', phase: 'summary', roleType: 'author' }), 0); + expect(result).toEqual({ zoneId: 'governor', slotId: 'governor-desk-0' }); + }); }); describe(deriveOrchestratorZone, () => { @@ -198,6 +203,16 @@ describe(deriveZoneStates, () => { const workshop = states.find((z) => z.id === 'workshop'); expect(workshop).toEqual({ id: 'workshop', active: true, completed: false }); }); + + it('a zone with idle and done agents is neither active nor completed', () => { + const agents: OfficeAgentState[] = [ + officeAgent({ id: 'a1', zoneId: 'workshop', status: 'idle' }), + officeAgent({ id: 'a2', zoneId: 'workshop', status: 'done' }), + ]; + const states = deriveZoneStates(agents, ZONE_DEFINITIONS); + const workshop = states.find((z) => z.id === 'workshop'); + expect(workshop).toEqual({ id: 'workshop', active: false, completed: false }); + }); }); describe(computeReviewerIndices, () => { @@ -233,6 +248,29 @@ describe(computeReviewerIndices, () => { const maxIndex = Math.max(...indices.values()); expect(maxIndex).toBe(5); }); + + it('logs a warning when reviewer count exceeds available slots', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const agents: LogicalAgentState[] = Array.from({ length: 7 }, (_, i) => + agent({ id: `rev-${String(i).padStart(2, '0')}`, phase: 'review', roleType: 'reviewer' }), + ); + computeReviewerIndices(agents); + + expect(warnSpy).toHaveBeenCalledOnce(); + expect(warnSpy.mock.calls[0]?.[0]).toContain('2 reviewer(s) exceed available slots'); + warnSpy.mockRestore(); + }); + + it('does not warn when reviewer count fits available slots', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const agents: LogicalAgentState[] = Array.from({ length: 5 }, (_, i) => + agent({ id: `rev-${String(i).padStart(2, '0')}`, phase: 'review', roleType: 'reviewer' }), + ); + computeReviewerIndices(agents); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); }); describe(buildArtifactStates, () => { @@ -248,4 +286,22 @@ describe(buildArtifactStates, () => { expect(result[1]?.slotId).toBe('prep-ws-1'); expect(result[2]?.slotId).toBe('governor-storage-1'); }); + + it('does not increment storage counter for in_transit artifacts', () => { + const artifacts: LogicalArtifactState[] = [ + artifact({ id: 'a1', status: 'in_transit', producerPhase: 'architecture' }), + artifact({ id: 'a2', status: 'delivered', producerPhase: 'planning' }), + ]; + const result = buildArtifactStates(artifacts); + + expect(result[0]?.slotId).toBe('prep-ws-0'); + // in_transit did not consume a storage slot, so first delivered gets storage-0 + expect(result[1]?.slotId).toBe('governor-storage-0'); + }); + + it('cycles storage slots using a modulus matching the governor zone storage count', () => { + const governorStorageCount = GOVERNOR_ZONE.slots.filter((s) => s.type === 'storage').length; + // Storage cycling modulus must equal the number of governor storage slots + expect(governorStorageCount).toBe(3); + }); }); diff --git a/packages/factory/src/client/visualizations/office/__tests__/logical-to-office.test.ts b/packages/factory/src/client/visualizations/office/__tests__/logical-to-office.test.ts index 73f53fab..67cac37c 100644 --- a/packages/factory/src/client/visualizations/office/__tests__/logical-to-office.test.ts +++ b/packages/factory/src/client/visualizations/office/__tests__/logical-to-office.test.ts @@ -127,6 +127,22 @@ describe(mapLogicalToOffice, () => { expect(result.orchestrator.zoneId).toBe('governor'); }); + it('places orchestrator at governor when done', () => { + const scene = logicalScene({ + orchestrator: { status: 'done', carriedArtifacts: [], codeBadge: null, waiting: false }, + }); + const result = mapLogicalToOffice(scene); + expect(result.orchestrator.zoneId).toBe('governor'); + }); + + it('places orchestrator at governor when delivering', () => { + const scene = logicalScene({ + orchestrator: { status: 'delivering', carriedArtifacts: [], codeBadge: null, waiting: false }, + }); + const result = mapLogicalToOffice(scene); + expect(result.orchestrator.zoneId).toBe('governor'); + }); + it('places delivered artifacts at governor storage', () => { const scene = logicalScene({ artifacts: [ 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 762e7844..e425e85a 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 @@ -84,6 +84,7 @@ describe(createOfficeLayout, () => { // The paths should pass through the same points in opposite order expect(forward).toHaveLength(backward.length); + expect(forward).toEqual(backward.toReversed()); }); it('passes through doorway positions', () => { diff --git a/packages/factory/src/client/visualizations/office/__tests__/zone-definitions.test.ts b/packages/factory/src/client/visualizations/office/__tests__/zone-definitions.test.ts index 8e71af5a..8c2f4364 100644 --- a/packages/factory/src/client/visualizations/office/__tests__/zone-definitions.test.ts +++ b/packages/factory/src/client/visualizations/office/__tests__/zone-definitions.test.ts @@ -13,6 +13,8 @@ describe('ZONE_DEFINITIONS', () => { expect(ids).toEqual(['prep', 'workshop', 'governor']); }); + // Display slots (prep-display-0, workshop-display-0, governor-display-0) are deferred. + // Counts reflect workstation and storage slots only. it('has the expected number of slots per zone', () => { expect(PREP_ZONE.slots).toHaveLength(2); expect(WORKSHOP_ZONE.slots).toHaveLength(6); 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 ed1c5cf3..07784a54 100644 --- a/packages/factory/src/client/visualizations/office/constants/zone-definitions.ts +++ b/packages/factory/src/client/visualizations/office/constants/zone-definitions.ts @@ -3,6 +3,7 @@ import type { ZoneDefinition } from '../types.js'; /** * Prep area zone: architecture and planning agents work here. * Matches prototype tiles: { x: 1, y: 1, w: 11, h: 12 }. + * Display slots (prep-display-0) deferred to a future ticket. */ export const PREP_ZONE: ZoneDefinition = { id: 'prep', @@ -18,6 +19,7 @@ export const PREP_ZONE: ZoneDefinition = { /** * Workshop zone: coder and reviewers work here. * Matches prototype tiles: { x: 13, y: 1, w: 26, h: 12 }. + * Display slots (workshop-display-0) deferred to a future ticket. */ export const WORKSHOP_ZONE: ZoneDefinition = { id: 'workshop', @@ -37,6 +39,7 @@ export const WORKSHOP_ZONE: ZoneDefinition = { /** * Governor's office zone: orchestrator home base with artifact storage. * Matches prototype tiles: { x: 1, y: 16, w: 38, h: 13 }. + * Display slots (governor-display-0) deferred to a future ticket. */ export const GOVERNOR_ZONE: ZoneDefinition = { id: 'governor', 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 be186ab0..9ccec6dd 100644 --- a/packages/factory/src/client/visualizations/office/layout/office-layout.ts +++ b/packages/factory/src/client/visualizations/office/layout/office-layout.ts @@ -25,8 +25,11 @@ function computeZoneCenter(zone: ZoneDefinition): Position { function buildCorridorPath(from: ZoneDefinition, to: ZoneDefinition): Position[] { const fromDoor = from.doors[0]; const toDoor = to.doors[0]; - if (fromDoor === undefined || toDoor === undefined) { - return []; + if (fromDoor === undefined) { + throw new Error(`Zone "${from.id}" has no doors; cannot build corridor path`); + } + if (toDoor === undefined) { + throw new Error(`Zone "${to.id}" has no doors; cannot build corridor path`); } const fromDoorPixel = tileToPixel(fromDoor.tile); @@ -85,7 +88,7 @@ export function createOfficeLayout(): FacilityLayout { if (pos === undefined) { throw new Error(`Unknown slot ID: "${slotId}"`); } - return pos; + return { ...pos }; } function zoneCenter(zoneId: string): Position { @@ -93,7 +96,7 @@ export function createOfficeLayout(): FacilityLayout { if (center === undefined) { throw new Error(`Unknown zone ID: "${zoneId}"`); } - return center; + return { ...center }; } function slotsInZone(zoneId: string, type?: SlotType): SlotDefinition[] { diff --git a/packages/factory/src/client/visualizations/office/mappers/agent-zone-assignments.ts b/packages/factory/src/client/visualizations/office/mappers/agent-zone-assignments.ts index 33745d5f..7fe6c843 100644 --- a/packages/factory/src/client/visualizations/office/mappers/agent-zone-assignments.ts +++ b/packages/factory/src/client/visualizations/office/mappers/agent-zone-assignments.ts @@ -154,11 +154,20 @@ export function computeReviewerIndices(agents: LogicalAgentState[]): Map a.phase === 'review' || a.phase === 'simplifier' || a.phase === 'holistic') .toSorted((a, b) => a.id.localeCompare(b.id)); + const maxSlot = 5; const indices = new Map(); for (const [i, agent] of reviewAgents.entries()) { // Reviewer slots start at ws-1, cap at ws-5 - indices.set(agent.id, Math.min(i + 1, 5)); + indices.set(agent.id, Math.min(i + 1, maxSlot)); } + + const overflowCount = reviewAgents.length - maxSlot; + if (overflowCount > 0) { + console.warn( + `[office] ${String(overflowCount)} reviewer(s) exceed available slots; they will share workshop-ws-${String(maxSlot)}`, + ); + } + return indices; } diff --git a/packages/factory/src/client/visualizations/office/scene/OfficeScene.ts b/packages/factory/src/client/visualizations/office/scene/OfficeScene.ts index 3342b56b..eba8d742 100644 --- a/packages/factory/src/client/visualizations/office/scene/OfficeScene.ts +++ b/packages/factory/src/client/visualizations/office/scene/OfficeScene.ts @@ -58,20 +58,24 @@ export class OfficeScene extends Scene { /** Apply a new logical scene state, running the full pipeline. */ updateState(logical: LogicalSceneState): void { - const nextConfig = mapLogicalToOffice(logical); - const nextPositions = resolvePositions(nextConfig, this.layout); - - if (this.prevConfig === undefined) { - this.applyFullState(nextConfig, nextPositions); - } else { - const diff = diffOfficeConfigs(this.prevConfig, nextConfig); - if (diff.hasChanges) { - // Teleport entities to new positions (no animation in this ticket) + try { + const nextConfig = mapLogicalToOffice(logical); + const nextPositions = resolvePositions(nextConfig, this.layout); + + if (this.prevConfig === undefined) { this.applyFullState(nextConfig, nextPositions); + } else { + const diff = diffOfficeConfigs(this.prevConfig, nextConfig); + if (diff.hasChanges) { + // Teleport entities to new positions (no animation in this ticket) + this.applyFullState(nextConfig, nextPositions); + } } - } - this.prevConfig = nextConfig; + this.prevConfig = nextConfig; + } catch (error) { + console.error('[OfficeScene] updateState failed:', error); + } } /** Clear all entity actors and rebuild from scratch. */ @@ -79,7 +83,7 @@ export class OfficeScene extends Scene { this.clearEntities(); // Place orchestrator - this.placeOrchestrator(config, positions.orchestrator); + this.placeOrchestrator(positions.orchestrator); // Place agents for (const agent of config.agents) { @@ -181,7 +185,7 @@ export class OfficeScene extends Scene { } /** Place a larger circle for the orchestrator at the given position. */ - private placeOrchestrator(_config: OfficeSceneConfig, pos: Position): void { + private placeOrchestrator(pos: Position): void { const actor = new Actor({ pos: vec(pos.x, pos.y) }); actor.graphics.use( new Circle({ diff --git a/packages/factory/src/client/visualizations/office/types.ts b/packages/factory/src/client/visualizations/office/types.ts index c8704b2c..b988150e 100644 --- a/packages/factory/src/client/visualizations/office/types.ts +++ b/packages/factory/src/client/visualizations/office/types.ts @@ -1,8 +1,6 @@ import type { PhaseName, RoleType } from '../../../shared/constants/role-types.js'; - -// Re-export visualization-agnostic status types from shared -export type { CarriedArtifact, CodeBadge } from '../shared/orchestrator-utils.js'; -export type { AgentStatus, ArtifactStatus, OrchestratorStatus } from '../shared/types.js'; +import type { CarriedArtifact, CodeBadge } from '../shared/orchestrator-utils.js'; +import type { AgentStatus, ArtifactStatus, OrchestratorStatus } from '../shared/types.js'; // --------------------------------------------------------------------------- // Spatial primitives @@ -96,16 +94,16 @@ export interface OfficeAgentState { role: string; roleType: RoleType; phase: PhaseName; - status: import('../shared/types.js').AgentStatus; + status: AgentStatus; zoneId: string; slotId: string; } /** Orchestrator state with spatial assignment. */ export interface OfficeOrchestratorState { - status: import('../shared/types.js').OrchestratorStatus; - carriedArtifacts: import('../shared/orchestrator-utils.js').CarriedArtifact[]; - codeBadge: import('../shared/orchestrator-utils.js').CodeBadge | null; + status: OrchestratorStatus; + carriedArtifacts: CarriedArtifact[]; + codeBadge: CodeBadge | null; waiting: boolean; zoneId: string; } @@ -115,7 +113,7 @@ export interface OfficeArtifactState { id: string; label: string; color: string; - status: import('../shared/types.js').ArtifactStatus; + status: ArtifactStatus; producerPhase: PhaseName; zoneId: string; slotId: string; @@ -146,12 +144,12 @@ export interface OfficeOrchestratorDiff { statusChanged: { from: string; to: string } | null; waitingChanged: { from: boolean; to: boolean } | null; carriedChanged: { - from: import('../shared/orchestrator-utils.js').CarriedArtifact[]; - to: import('../shared/orchestrator-utils.js').CarriedArtifact[]; + from: CarriedArtifact[]; + to: CarriedArtifact[]; } | null; codeBadgeChanged: { - from: import('../shared/orchestrator-utils.js').CodeBadge | null; - to: import('../shared/orchestrator-utils.js').CodeBadge | null; + from: CodeBadge | null; + to: CodeBadge | null; } | null; } @@ -211,3 +209,7 @@ export interface Transition { export interface TransitionPlan { transitions: Transition[]; } + +// Re-export visualization-agnostic status types from shared +export { type CarriedArtifact, type CodeBadge } from '../shared/orchestrator-utils.js'; +export { type AgentStatus, type ArtifactStatus, type OrchestratorStatus } from '../shared/types.js'; From 2b42243ade25133455ff28db32fd2149a7cb07b2 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sun, 15 Mar 2026 02:35:39 -0700 Subject: [PATCH 3/4] factory|refactor: Simplify zone assignment logic Replace the magic number `3` in `assignArtifactToZone` with a `GOVERNOR_STORAGE_SLOT_COUNT` constant derived from `GOVERNOR_ZONE.slots`. Remove the early-return guard in `deriveZoneStates` by inlining `agentsInZone.length > 0` into the `completed` expression. Eliminate the redundant `isReviewer` variable and `?? 1` fallback in `mapLogicalToOffice` by using the map lookup directly with `?? 0`. --- .../office/mappers/agent-zone-assignments.ts | 14 ++++++-------- .../office/mappers/logical-to-office.ts | 3 +-- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/factory/src/client/visualizations/office/mappers/agent-zone-assignments.ts b/packages/factory/src/client/visualizations/office/mappers/agent-zone-assignments.ts index 7fe6c843..b71d5d9c 100644 --- a/packages/factory/src/client/visualizations/office/mappers/agent-zone-assignments.ts +++ b/packages/factory/src/client/visualizations/office/mappers/agent-zone-assignments.ts @@ -5,8 +5,12 @@ import type { LogicalOrchestratorState, OrchestratorStatus, } from '../../shared/types.js'; +import { GOVERNOR_ZONE } from '../constants/zone-definitions.js'; import type { OfficeAgentState, OfficeArtifactState, OfficeZoneState, ZoneDefinition } from '../types.js'; +/** Number of storage slots in the governor zone, used for cycling delivered artifacts. */ +const GOVERNOR_STORAGE_SLOT_COUNT = GOVERNOR_ZONE.slots.filter((s) => s.type === 'storage').length; + // --------------------------------------------------------------------------- // Phase-to-zone mapping // --------------------------------------------------------------------------- @@ -95,7 +99,7 @@ export function assignArtifactToZone( ): { zoneId: string; slotId: string } { if (artifact.status === 'delivered') { // Delivered artifacts go to governor storage slots (cycle through available slots) - const storageSlotIndex = storageCounter % 3; + const storageSlotIndex = storageCounter % GOVERNOR_STORAGE_SLOT_COUNT; return { zoneId: 'governor', slotId: `governor-storage-${storageSlotIndex}` }; } @@ -131,14 +135,8 @@ function resolveProducerSlot(phase: PhaseName): string { export function deriveZoneStates(agents: OfficeAgentState[], zones: readonly ZoneDefinition[]): OfficeZoneState[] { return zones.map((zone) => { const agentsInZone = agents.filter((a) => a.zoneId === zone.id); - - // No agents: neither active nor completed - if (agentsInZone.length === 0) { - return { id: zone.id, active: false, completed: false }; - } - const active = agentsInZone.some((a) => a.status === 'working'); - const completed = agentsInZone.every((a) => a.status === 'done'); + const completed = agentsInZone.length > 0 && agentsInZone.every((a) => a.status === 'done'); return { id: zone.id, active, completed }; }); diff --git a/packages/factory/src/client/visualizations/office/mappers/logical-to-office.ts b/packages/factory/src/client/visualizations/office/mappers/logical-to-office.ts index b5432095..35cae239 100644 --- a/packages/factory/src/client/visualizations/office/mappers/logical-to-office.ts +++ b/packages/factory/src/client/visualizations/office/mappers/logical-to-office.ts @@ -19,8 +19,7 @@ export function mapLogicalToOffice(logical: LogicalSceneState): OfficeSceneConfi // Assign agents to zones and slots const agents: OfficeAgentState[] = logical.agents.map((agent) => { - const isReviewer = reviewerIndices.has(agent.id); - const slotIndex = isReviewer ? (reviewerIndices.get(agent.id) ?? 1) : 0; + const slotIndex = reviewerIndices.get(agent.id) ?? 0; const assignment = assignAgentToZone(agent, slotIndex); return { From 5eb1397fac3c417016281d6b9fc5f188e8ed7f52 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sun, 15 Mar 2026 02:58:26 -0700 Subject: [PATCH 4/4] Rename slot infix from -ws- to -desk- for clarity The -ws- abbreviation was ambiguous (workstation? workshop?). Using -desk- aligns with the office metaphor and matches the existing governor-desk-0 naming. --- .../__tests__/agent-zone-assignments.test.ts | 30 +++++++++---------- .../__tests__/logical-to-office.test.ts | 20 ++++++------- .../office/__tests__/office-differ.test.ts | 12 ++++---- .../office/__tests__/office-layout.test.ts | 2 +- .../__tests__/position-resolver.test.ts | 14 ++++----- .../__tests__/transition-planner.test.ts | 10 +++---- .../office/constants/zone-definitions.ts | 16 +++++----- .../office/mappers/agent-zone-assignments.ts | 16 +++++----- 8 files changed, 60 insertions(+), 60 deletions(-) diff --git a/packages/factory/src/client/visualizations/office/__tests__/agent-zone-assignments.test.ts b/packages/factory/src/client/visualizations/office/__tests__/agent-zone-assignments.test.ts index 2a07516c..01e87f8f 100644 --- a/packages/factory/src/client/visualizations/office/__tests__/agent-zone-assignments.test.ts +++ b/packages/factory/src/client/visualizations/office/__tests__/agent-zone-assignments.test.ts @@ -46,39 +46,39 @@ function artifact(overrides: Partial & { id: string }): Lo } describe(assignAgentToZone, () => { - it('assigns architect to prep/prep-ws-0', () => { + it('assigns architect to prep/prep-desk-0', () => { const result = assignAgentToZone(agent({ id: 'arch', phase: 'architecture', roleType: 'analyst' }), 0); - expect(result).toEqual({ zoneId: 'prep', slotId: 'prep-ws-0' }); + expect(result).toEqual({ zoneId: 'prep', slotId: 'prep-desk-0' }); }); - it('assigns planner to prep/prep-ws-1', () => { + it('assigns planner to prep/prep-desk-1', () => { const result = assignAgentToZone(agent({ id: 'plan', phase: 'planning', roleType: 'planner' }), 0); - expect(result).toEqual({ zoneId: 'prep', slotId: 'prep-ws-1' }); + expect(result).toEqual({ zoneId: 'prep', slotId: 'prep-desk-1' }); }); - it('assigns coder to workshop/workshop-ws-0', () => { + it('assigns coder to workshop/workshop-desk-0', () => { const result = assignAgentToZone(agent({ id: 'code', phase: 'implementation', roleType: 'author' }), 0); - expect(result).toEqual({ zoneId: 'workshop', slotId: 'workshop-ws-0' }); + expect(result).toEqual({ zoneId: 'workshop', slotId: 'workshop-desk-0' }); }); - it('assigns reviewers to workshop/workshop-ws-{index}', () => { + it('assigns reviewers to workshop/workshop-desk-{index}', () => { const result = assignAgentToZone(agent({ id: 'rev1', phase: 'review', roleType: 'reviewer' }), 1); - expect(result).toEqual({ zoneId: 'workshop', slotId: 'workshop-ws-1' }); + expect(result).toEqual({ zoneId: 'workshop', slotId: 'workshop-desk-1' }); }); it('caps reviewer slot index at 5', () => { const result = assignAgentToZone(agent({ id: 'rev6', phase: 'review', roleType: 'reviewer' }), 10); - expect(result).toEqual({ zoneId: 'workshop', slotId: 'workshop-ws-5' }); + expect(result).toEqual({ zoneId: 'workshop', slotId: 'workshop-desk-5' }); }); it('assigns simplifier to workshop', () => { const result = assignAgentToZone(agent({ id: 'simp', phase: 'simplifier', roleType: 'reviewer' }), 2); - expect(result).toEqual({ zoneId: 'workshop', slotId: 'workshop-ws-2' }); + expect(result).toEqual({ zoneId: 'workshop', slotId: 'workshop-desk-2' }); }); it('assigns holistic reviewer to workshop', () => { const result = assignAgentToZone(agent({ id: 'hol', phase: 'holistic', roleType: 'reviewer' }), 3); - expect(result).toEqual({ zoneId: 'workshop', slotId: 'workshop-ws-3' }); + expect(result).toEqual({ zoneId: 'workshop', slotId: 'workshop-desk-3' }); }); it('assigns summary-phase agent to governor/governor-desk-0', () => { @@ -141,7 +141,7 @@ describe(assignArtifactToZone, () => { it('assigns created artifacts to their producer zone', () => { const result = assignArtifactToZone(artifact({ id: 'a1', status: 'created', producerPhase: 'architecture' }), 0); - expect(result).toEqual({ zoneId: 'prep', slotId: 'prep-ws-0' }); + expect(result).toEqual({ zoneId: 'prep', slotId: 'prep-desk-0' }); }); it('assigns in_transit artifacts to their producer zone (same as created)', () => { @@ -149,7 +149,7 @@ describe(assignArtifactToZone, () => { artifact({ id: 'a1', status: 'in_transit', producerPhase: 'implementation' }), 0, ); - expect(result).toEqual({ zoneId: 'workshop', slotId: 'workshop-ws-0' }); + expect(result).toEqual({ zoneId: 'workshop', slotId: 'workshop-desk-0' }); }); }); @@ -283,7 +283,7 @@ describe(buildArtifactStates, () => { const result = buildArtifactStates(artifacts); expect(result[0]?.slotId).toBe('governor-storage-0'); - expect(result[1]?.slotId).toBe('prep-ws-1'); + expect(result[1]?.slotId).toBe('prep-desk-1'); expect(result[2]?.slotId).toBe('governor-storage-1'); }); @@ -294,7 +294,7 @@ describe(buildArtifactStates, () => { ]; const result = buildArtifactStates(artifacts); - expect(result[0]?.slotId).toBe('prep-ws-0'); + expect(result[0]?.slotId).toBe('prep-desk-0'); // in_transit did not consume a storage slot, so first delivered gets storage-0 expect(result[1]?.slotId).toBe('governor-storage-0'); }); diff --git a/packages/factory/src/client/visualizations/office/__tests__/logical-to-office.test.ts b/packages/factory/src/client/visualizations/office/__tests__/logical-to-office.test.ts index 67cac37c..f3e43728 100644 --- a/packages/factory/src/client/visualizations/office/__tests__/logical-to-office.test.ts +++ b/packages/factory/src/client/visualizations/office/__tests__/logical-to-office.test.ts @@ -64,14 +64,14 @@ describe(mapLogicalToOffice, () => { expect(arch).toBeDefined(); expect(arch?.zoneId).toBe('prep'); - expect(arch?.slotId).toBe('prep-ws-0'); + expect(arch?.slotId).toBe('prep-desk-0'); expect(plan).toBeDefined(); expect(plan?.zoneId).toBe('prep'); - expect(plan?.slotId).toBe('prep-ws-1'); + expect(plan?.slotId).toBe('prep-desk-1'); }); - it('assigns coder to workshop/workshop-ws-0', () => { + it('assigns coder to workshop/workshop-desk-0', () => { const scene = logicalScene({ agents: [agent({ id: 'coder', phase: 'implementation', roleType: 'author' })], }); @@ -79,10 +79,10 @@ describe(mapLogicalToOffice, () => { const coder = result.agents.find((a) => a.id === 'coder'); expect(coder?.zoneId).toBe('workshop'); - expect(coder?.slotId).toBe('workshop-ws-0'); + expect(coder?.slotId).toBe('workshop-desk-0'); }); - it('assigns reviewers to workshop/workshop-ws-1 through ws-5', () => { + it('assigns reviewers to workshop/workshop-desk-1 through ws-5', () => { const scene = logicalScene({ agents: [ agent({ id: 'rev-a', phase: 'review', roleType: 'reviewer' }), @@ -95,10 +95,10 @@ describe(mapLogicalToOffice, () => { expect(reviewers).toHaveLength(3); const slotIds = new Set(reviewers.map((r) => r.slotId)); - // All should be workshop-ws-1 through ws-3 - expect(slotIds).toContain('workshop-ws-1'); - expect(slotIds).toContain('workshop-ws-2'); - expect(slotIds).toContain('workshop-ws-3'); + // All should be workshop-desk-1 through ws-3 + expect(slotIds).toContain('workshop-desk-1'); + expect(slotIds).toContain('workshop-desk-2'); + expect(slotIds).toContain('workshop-desk-3'); }); it('places orchestrator at prep when dispatching to architecture', () => { @@ -165,7 +165,7 @@ describe(mapLogicalToOffice, () => { const result = mapLogicalToOffice(scene); expect(result.artifacts[0]?.zoneId).toBe('workshop'); - expect(result.artifacts[0]?.slotId).toBe('workshop-ws-0'); + expect(result.artifacts[0]?.slotId).toBe('workshop-desk-0'); }); it('derives correct zone states from agent statuses', () => { diff --git a/packages/factory/src/client/visualizations/office/__tests__/office-differ.test.ts b/packages/factory/src/client/visualizations/office/__tests__/office-differ.test.ts index 065f01d1..ddda4a06 100644 --- a/packages/factory/src/client/visualizations/office/__tests__/office-differ.test.ts +++ b/packages/factory/src/client/visualizations/office/__tests__/office-differ.test.ts @@ -31,7 +31,7 @@ function agent(overrides: Partial & { id: string }): OfficeAge phase: 'implementation', status: 'idle', zoneId: 'workshop', - slotId: 'workshop-ws-0', + slotId: 'workshop-desk-0', ...overrides, }; } @@ -43,7 +43,7 @@ function artifact(overrides: Partial & { id: string }): Off status: 'created', producerPhase: 'implementation', zoneId: 'workshop', - slotId: 'workshop-ws-0', + slotId: 'workshop-desk-0', ...overrides, }; } @@ -106,17 +106,17 @@ describe(diffOfficeConfigs, () => { }); it('detects agent slot reassignment', () => { - const a = agent({ id: 'a1', zoneId: 'workshop', slotId: 'workshop-ws-0' }); + const a = agent({ id: 'a1', zoneId: 'workshop', slotId: 'workshop-desk-0' }); const prev = config({ agents: [a] }); - const next = config({ agents: [{ ...a, zoneId: 'workshop', slotId: 'workshop-ws-1' }] }); + const next = config({ agents: [{ ...a, zoneId: 'workshop', slotId: 'workshop-desk-1' }] }); const diff = diffOfficeConfigs(prev, next); expect(diff.agents).toHaveLength(1); expect(diff.agents[0]?.moved).toEqual({ fromZone: 'workshop', - fromSlot: 'workshop-ws-0', + fromSlot: 'workshop-desk-0', toZone: 'workshop', - toSlot: 'workshop-ws-1', + toSlot: 'workshop-desk-1', }); }); 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 e425e85a..e31c1001 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 @@ -42,7 +42,7 @@ describe(createOfficeLayout, () => { it('returns all slots for a zone', () => { const prepSlots = layout.slotsInZone('prep'); expect(prepSlots).toHaveLength(2); - expect(prepSlots.map((s) => s.id)).toEqual(['prep-ws-0', 'prep-ws-1']); + expect(prepSlots.map((s) => s.id)).toEqual(['prep-desk-0', 'prep-desk-1']); }); it('filters by slot type', () => { diff --git a/packages/factory/src/client/visualizations/office/__tests__/position-resolver.test.ts b/packages/factory/src/client/visualizations/office/__tests__/position-resolver.test.ts index b16fd617..bf7af53a 100644 --- a/packages/factory/src/client/visualizations/office/__tests__/position-resolver.test.ts +++ b/packages/factory/src/client/visualizations/office/__tests__/position-resolver.test.ts @@ -46,13 +46,13 @@ describe(resolvePositions, () => { phase: 'architecture', status: 'working', zoneId: 'prep', - slotId: 'prep-ws-0', + slotId: 'prep-desk-0', }, ], }); const positions = resolvePositions(c, layout); - const expected = layout.slotPosition('prep-ws-0'); + const expected = layout.slotPosition('prep-desk-0'); expect(positions.agents.get('a1')).toEqual(expected); }); @@ -86,7 +86,7 @@ describe(resolvePositions, () => { phase: 'architecture', status: 'working', zoneId: 'prep', - slotId: 'prep-ws-0', + slotId: 'prep-desk-0', }, { id: 'a2', @@ -95,7 +95,7 @@ describe(resolvePositions, () => { phase: 'implementation', status: 'idle', zoneId: 'workshop', - slotId: 'workshop-ws-0', + slotId: 'workshop-desk-0', }, ], artifacts: [ @@ -106,7 +106,7 @@ describe(resolvePositions, () => { status: 'created', producerPhase: 'architecture', zoneId: 'prep', - slotId: 'prep-ws-0', + slotId: 'prep-desk-0', }, ], }); @@ -114,8 +114,8 @@ describe(resolvePositions, () => { expect(positions.agents.size).toBe(2); expect(positions.artifacts.size).toBe(1); - expect(positions.agents.get('a1')).toEqual(layout.slotPosition('prep-ws-0')); - expect(positions.agents.get('a2')).toEqual(layout.slotPosition('workshop-ws-0')); + expect(positions.agents.get('a1')).toEqual(layout.slotPosition('prep-desk-0')); + expect(positions.agents.get('a2')).toEqual(layout.slotPosition('workshop-desk-0')); }); it('returns empty maps for empty config', () => { diff --git a/packages/factory/src/client/visualizations/office/__tests__/transition-planner.test.ts b/packages/factory/src/client/visualizations/office/__tests__/transition-planner.test.ts index 89658a04..15608e8a 100644 --- a/packages/factory/src/client/visualizations/office/__tests__/transition-planner.test.ts +++ b/packages/factory/src/client/visualizations/office/__tests__/transition-planner.test.ts @@ -80,8 +80,8 @@ describe(planTransitions, () => { }); it('produces walk transitions with corridor waypoints for agent cross-zone movement', () => { - const fromPos: Position = layout.slotPosition('prep-ws-0'); - const toPos: Position = layout.slotPosition('workshop-ws-0'); + const fromPos: Position = layout.slotPosition('prep-desk-0'); + const toPos: Position = layout.slotPosition('workshop-desk-0'); const diff: OfficeDiff = { ...emptyDiff(), @@ -89,7 +89,7 @@ describe(planTransitions, () => { { agentId: 'a1', statusChanged: null, - moved: { fromZone: 'prep', fromSlot: 'prep-ws-0', toZone: 'workshop', toSlot: 'workshop-ws-0' }, + moved: { fromZone: 'prep', fromSlot: 'prep-desk-0', toZone: 'workshop', toSlot: 'workshop-desk-0' }, }, ], hasChanges: true, @@ -153,7 +153,7 @@ describe(planTransitions, () => { }); it('produces fade_in for newly added agents', () => { - const toPos: Position = layout.slotPosition('workshop-ws-0'); + const toPos: Position = layout.slotPosition('workshop-desk-0'); const diff: OfficeDiff = { ...emptyDiff(), @@ -161,7 +161,7 @@ describe(planTransitions, () => { { agentId: 'new-agent', statusChanged: null, - moved: { fromZone: '', fromSlot: '', toZone: 'workshop', toSlot: 'workshop-ws-0' }, + moved: { fromZone: '', fromSlot: '', toZone: 'workshop', toSlot: 'workshop-desk-0' }, }, ], hasChanges: true, 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 07784a54..1402bdaf 100644 --- a/packages/factory/src/client/visualizations/office/constants/zone-definitions.ts +++ b/packages/factory/src/client/visualizations/office/constants/zone-definitions.ts @@ -10,8 +10,8 @@ export const PREP_ZONE: ZoneDefinition = { label: 'Prep area', bounds: { col: 1, row: 1, width: 11, height: 12 }, slots: [ - { id: 'prep-ws-0', type: 'workstation', tile: { col: 3, row: 5 } }, - { id: 'prep-ws-1', type: 'workstation', tile: { col: 8, row: 5 } }, + { id: 'prep-desk-0', type: 'workstation', tile: { col: 3, row: 5 } }, + { id: 'prep-desk-1', type: 'workstation', tile: { col: 8, row: 5 } }, ], doors: [{ tile: { col: 6, row: 13 }, direction: 'down' }], }; @@ -26,12 +26,12 @@ export const WORKSHOP_ZONE: ZoneDefinition = { label: 'The workshop', bounds: { col: 13, row: 1, width: 26, height: 12 }, slots: [ - { id: 'workshop-ws-0', type: 'workstation', tile: { col: 16, row: 5 } }, - { id: 'workshop-ws-1', type: 'workstation', tile: { col: 22, row: 10 } }, - { id: 'workshop-ws-2', type: 'workstation', tile: { col: 25, row: 10 } }, - { id: 'workshop-ws-3', type: 'workstation', tile: { col: 28, row: 10 } }, - { id: 'workshop-ws-4', type: 'workstation', tile: { col: 31, row: 10 } }, - { id: 'workshop-ws-5', type: 'workstation', tile: { col: 34, row: 10 } }, + { id: 'workshop-desk-0', type: 'workstation', tile: { col: 16, row: 5 } }, + { id: 'workshop-desk-1', type: 'workstation', tile: { col: 22, row: 10 } }, + { id: 'workshop-desk-2', type: 'workstation', tile: { col: 25, row: 10 } }, + { id: 'workshop-desk-3', type: 'workstation', tile: { col: 28, row: 10 } }, + { id: 'workshop-desk-4', type: 'workstation', tile: { col: 31, row: 10 } }, + { id: 'workshop-desk-5', type: 'workstation', tile: { col: 34, row: 10 } }, ], doors: [{ tile: { col: 13, row: 7 }, direction: 'left' }], }; diff --git a/packages/factory/src/client/visualizations/office/mappers/agent-zone-assignments.ts b/packages/factory/src/client/visualizations/office/mappers/agent-zone-assignments.ts index b71d5d9c..cb25b118 100644 --- a/packages/factory/src/client/visualizations/office/mappers/agent-zone-assignments.ts +++ b/packages/factory/src/client/visualizations/office/mappers/agent-zone-assignments.ts @@ -42,18 +42,18 @@ export function assignAgentToZone(agent: LogicalAgentState, agentIndex: number): if (zoneId === 'prep') { // Architect gets ws-0, planner gets ws-1 const slotIndex = agent.phase === 'architecture' ? 0 : 1; - return { zoneId, slotId: `prep-ws-${slotIndex}` }; + return { zoneId, slotId: `prep-desk-${slotIndex}` }; } if (zoneId === 'workshop') { // Coder gets ws-0, reviewers get ws-1 through ws-5 if (agent.phase === 'implementation') { - return { zoneId, slotId: 'workshop-ws-0' }; + return { zoneId, slotId: 'workshop-desk-0' }; } // Reviewers (review, simplifier, holistic) get ws-1 through ws-5 // Cap at 5 reviewer slots const reviewerSlot = Math.min(agentIndex, 5); - return { zoneId, slotId: `workshop-ws-${reviewerSlot}` }; + return { zoneId, slotId: `workshop-desk-${reviewerSlot}` }; } // Governor zone fallback (summary phase agents) @@ -113,15 +113,15 @@ export function assignArtifactToZone( function resolveProducerSlot(phase: PhaseName): string { switch (phase) { case 'architecture': - return 'prep-ws-0'; + return 'prep-desk-0'; case 'planning': - return 'prep-ws-1'; + return 'prep-desk-1'; case 'implementation': - return 'workshop-ws-0'; + return 'workshop-desk-0'; case 'review': case 'simplifier': case 'holistic': - return 'workshop-ws-1'; + return 'workshop-desk-1'; case 'summary': return 'governor-desk-0'; } @@ -162,7 +162,7 @@ export function computeReviewerIndices(agents: LogicalAgentState[]): Map 0) { console.warn( - `[office] ${String(overflowCount)} reviewer(s) exceed available slots; they will share workshop-ws-${String(maxSlot)}`, + `[office] ${String(overflowCount)} reviewer(s) exceed available slots; they will share workshop-desk-${String(maxSlot)}`, ); }