import { useOfficeStore } from "@/store/office-store"; import { ENTRANCE, MAX_MAIN_AGENTS, MAX_SUB_AGENTS } from "@/lib/constants"; import { DESK_SLOTS, HOT_DESK_SLOTS, LOUNGE_ANCHORS } from "@/lib/positions"; import { AGENT_MODELS, AGENT_NAMES, DONE_PHRASES, MEETING_PHRASES, SUB_AGENT_KINDS, SUB_DONE_PHRASES, SUB_TOOLS, TASK_NAMES, TOOLS, pick, rand, } from "@/lib/phrases"; type Phase = | "arriving" | "idle" | "thinking" | "tooling" | "waiting_subs" | "speaking" | "error" | "lounge_go" | "lounge_stay" | "lounge_back" | "meeting" | "sub_arriving" | "sub_working" | "sub_reporting" | "sub_leaving"; interface Brain { phase: Phase; timer: number; toolsLeft: number; loungeAnchor: number; taskName: string | null; } interface MeetingSession { id: string; agentIds: string[]; timer: number; speakTimer: number; speakerIdx: number; gathered: boolean; } let subSeq = 0; let meetingSeq = 0; /** * The simulation director: a per-agent finite-state machine plus a global * meeting scheduler. Everything mutates the world through store actions only, * so a real Claude Code event feed could replace this file wholesale. */ export class Director { private brains = new Map(); private meeting: MeetingSession | null = null; private meetingCooldown = 20; private arrivalQueue: string[] = []; private arrivalTimer = 0.5; constructor() { this.arrivalQueue = AGENT_NAMES.slice(0, 4); } private get store() { return useOfficeStore.getState(); } update(dt: number) { this.handleArrivals(dt); this.updateMeetingScheduler(dt); const S = this.store; for (const [id, brain] of this.brains) { const agent = S.agents.get(id); if (!agent) { this.brains.delete(id); continue; } brain.timer -= dt; this.stepAgent(id, brain, dt); } } /* ── public controls ── */ /** Manually assign a task from the UI panel. */ assignTask(id: string): boolean { const brain = this.brains.get(id); const agent = this.store.agents.get(id); if (!brain || !agent || brain.phase !== "idle" || agent.movement) return false; this.beginTask(id, brain); return true; } /** Spawn one more main agent (header button). */ hireAgent(): boolean { const S = this.store; const mains = [...S.agents.values()].filter((a) => a.role !== "subagent"); if (mains.length + this.arrivalQueue.length >= MAX_MAIN_AGENTS) return false; const used = new Set(mains.map((a) => a.name)); const name = AGENT_NAMES.find((n) => !used.has(n) && !this.arrivalQueue.includes(n)); if (!name) return false; this.arrivalQueue.push(name); return true; } /* ── arrivals ── */ private handleArrivals(dt: number) { if (this.arrivalQueue.length === 0) return; this.arrivalTimer -= dt; if (this.arrivalTimer > 0) return; this.arrivalTimer = rand(0.9, 1.6); const S = this.store; const name = this.arrivalQueue.shift()!; const occupied = new Set( [...S.agents.values()].filter((a) => a.homeZone === "desk").map((a) => a.homeSlot), ); const slot = DESK_SLOTS.findIndex((_, i) => !occupied.has(i)); if (slot === -1) return; const id = `main-${name.toLowerCase()}`; const role = S.agents.size === 0 ? "lead" : "agent"; S.spawnAgent({ id, name, role, model: AGENT_MODELS[name] ?? "claude-sonnet-5", homeSlot: slot }); S.addEvent("🚪", `${name} 進入辦公室`); S.startWalk(id, DESK_SLOTS[slot], "desk", { arriveStatus: "idle" }); this.brains.set(id, { phase: "arriving", timer: 0, toolsLeft: 0, loungeAnchor: -1, taskName: null, }); } /* ── main agent FSM ── */ private beginTask(id: string, brain: Brain) { const S = this.store; const task = pick(TASK_NAMES); brain.taskName = task; brain.phase = "thinking"; brain.timer = rand(2, 4); S.setStatus(id, "thinking"); const agent = S.agents.get(id); if (agent) S.addEvent("📋", `${agent.name} 接下任務「${task}」`); } private stepAgent(id: string, brain: Brain, _dt: number) { const S = this.store; const agent = S.agents.get(id)!; switch (brain.phase) { case "arriving": { if (!agent.movement && agent.zone === agent.homeZone) { brain.phase = "idle"; brain.timer = rand(1.5, 5); } break; } case "idle": { if (this.meeting?.agentIds.includes(id)) break; if (brain.timer > 0 || agent.movement) break; const roll = Math.random(); if (roll < 0.62) { this.beginTask(id, brain); } else if (roll < 0.78 && agent.zone === "desk") { // coffee break const usedAnchors = new Set( [...this.brains.values()].map((b) => b.loungeAnchor).filter((i) => i >= 0), ); const anchor = LOUNGE_ANCHORS.findIndex((_, i) => !usedAnchors.has(i)); if (anchor >= 0) { brain.loungeAnchor = anchor; brain.phase = "lounge_go"; S.startWalk(id, LOUNGE_ANCHORS[anchor], "lounge", { arriveStatus: "idle" }); S.addEvent("☕", `${agent.name} 去休息區倒咖啡`); } else { brain.timer = rand(2, 5); } } else if (roll < 0.83) { brain.phase = "error"; brain.timer = rand(2.5, 3.5); S.setStatus(id, "error"); S.addEvent("⚠️", `${agent.name} 遇到錯誤,重試中`); } else { brain.timer = rand(2, 6); } break; } case "thinking": { if (brain.timer > 0) break; brain.phase = "tooling"; brain.toolsLeft = 2 + Math.floor(Math.random() * 3); brain.timer = 0; S.setStatus(id, "tool_calling"); break; } case "tooling": { if (brain.timer > 0) break; if (brain.toolsLeft > 0) { brain.toolsLeft -= 1; brain.timer = rand(1.4, 2.6); S.setTool(id, { name: pick(TOOLS), startedAt: S.clock }); break; } S.setTool(id, null); // decide whether to delegate to subagents const subCount = [...S.agents.values()].filter((a) => a.role === "subagent").length; const wantSubs = Math.random() < 0.55 ? 1 + Math.floor(Math.random() * 2) : 0; const canSpawn = Math.min(wantSubs, MAX_SUB_AGENTS - subCount); if (canSpawn > 0) { for (let i = 0; i < canSpawn; i++) this.spawnSubagent(id); brain.phase = "waiting_subs"; S.setStatus(id, "thinking"); } else { this.finishTask(id, brain); } break; } case "waiting_subs": { if (agent.childIds.length === 0) { this.finishTask(id, brain); } break; } case "speaking": { if (brain.timer > 0) break; S.setSpeech(id, null); S.setStatus(id, "idle"); brain.phase = "idle"; brain.timer = rand(3, 8); break; } case "error": { if (brain.timer > 0) break; S.setStatus(id, "idle"); brain.phase = "idle"; brain.timer = rand(2, 5); break; } case "lounge_go": { if (!agent.movement && agent.zone === "lounge") { brain.phase = "lounge_stay"; brain.timer = rand(5, 10); } break; } case "lounge_stay": { if (brain.timer > 0) break; brain.phase = "lounge_back"; brain.loungeAnchor = -1; S.walkHome(id); break; } case "lounge_back": { if (!agent.movement && agent.zone === agent.homeZone) { brain.phase = "idle"; brain.timer = rand(2, 6); } break; } case "meeting": // handled by the meeting scheduler break; /* ── subagent FSM ── */ case "sub_arriving": { if (!agent.movement && agent.zone === "hotDesk") { brain.phase = "sub_working"; brain.toolsLeft = 3 + Math.floor(Math.random() * 4); brain.timer = 0; S.setStatus(id, "tool_calling"); } break; } case "sub_working": { if (brain.timer > 0) break; if (brain.toolsLeft > 0) { brain.toolsLeft -= 1; brain.timer = rand(1.2, 2.2); S.setTool(id, { name: pick(SUB_TOOLS), startedAt: S.clock }); break; } S.setTool(id, null); brain.phase = "sub_reporting"; brain.timer = rand(1.8, 2.6); S.setStatus(id, "speaking"); S.setSpeech(id, pick(SUB_DONE_PHRASES)); break; } case "sub_reporting": { if (brain.timer > 0) break; S.setSpeech(id, null); S.addEvent("↩️", `${agent.name} 回報完畢,離開辦公室`); if (agent.parentId) S.removeLink(agent.parentId, id); brain.phase = "sub_leaving"; S.startWalk(id, { ...ENTRANCE }, "corridor", { despawnOnArrive: true }); break; } case "sub_leaving": break; } } private finishTask(id: string, brain: Brain) { const S = this.store; const agent = S.agents.get(id); if (!agent) return; brain.phase = "speaking"; brain.timer = rand(2.5, 4); S.setStatus(id, "speaking"); S.setSpeech(id, pick(DONE_PHRASES)); S.addEvent("✅", `${agent.name} 完成「${brain.taskName ?? "任務"}」`); brain.taskName = null; } private spawnSubagent(parentId: string) { const S = this.store; const parent = S.agents.get(parentId); if (!parent) return; const occupied = new Set( [...S.agents.values()].filter((a) => a.homeZone === "hotDesk").map((a) => a.homeSlot), ); const slot = HOT_DESK_SLOTS.findIndex((_, i) => !occupied.has(i)); if (slot === -1) return; const kind = pick(SUB_AGENT_KINDS); const id = `sub-${kind.toLowerCase()}-${subSeq++}`; const name = `${kind}-${subSeq}`; S.spawnAgent({ id, name, role: "subagent", model: "claude-haiku-4-5", homeSlot: slot, parentId }); S.addEvent("✨", `${parent.name} 派出 subagent ${name}`); S.startWalk(id, HOT_DESK_SLOTS[slot], "hotDesk", { arriveStatus: "tool_calling" }); S.addLink({ sourceId: parentId, targetId: id, strength: 0.6, kind: "spawn" }); this.brains.set(id, { phase: "sub_arriving", timer: 0, toolsLeft: 0, loungeAnchor: -1, taskName: null, }); } /* ── meetings ── */ private updateMeetingScheduler(dt: number) { const S = this.store; if (this.meeting) { const m = this.meeting; m.timer -= dt; if (!m.gathered) { const allSeated = m.agentIds.every((id) => { const a = S.agents.get(id); return a && !a.movement && a.zone === "meeting"; }); if (allSeated) { m.gathered = true; m.speakTimer = 0; } } else { m.speakTimer -= dt; if (m.speakTimer <= 0) { // rotate speaker const prev = m.agentIds[m.speakerIdx % m.agentIds.length]; S.setSpeech(prev, null); S.setStatus(prev, "idle"); m.speakerIdx += 1; const next = m.agentIds[m.speakerIdx % m.agentIds.length]; S.setStatus(next, "speaking"); S.setSpeech(next, pick(MEETING_PHRASES)); m.speakTimer = rand(2, 3.4); } } if (m.timer <= 0 && m.gathered) { for (const id of m.agentIds) { S.setSpeech(id, null); const brain = this.brains.get(id); if (brain) { brain.phase = "arriving"; // reuse: wait until seated home, then idle brain.timer = 0; } } S.addEvent("🏁", "會議結束,各自回工位"); S.endMeeting(m.id); this.meeting = null; this.meetingCooldown = rand(30, 55); } return; } this.meetingCooldown -= dt; if (this.meetingCooldown > 0) return; // candidates: settled main agents that are idle at their desk const candidates = [...S.agents.values()].filter((a) => { const brain = this.brains.get(a.id); return ( a.role !== "subagent" && brain?.phase === "idle" && !a.movement && a.zone === "desk" ); }); if (candidates.length < 2) { this.meetingCooldown = 6; return; } const count = Math.min(candidates.length, 2 + Math.floor(Math.random() * 2)); const chosen = candidates.slice(0, count).map((a) => a.id); const id = `meeting-${meetingSeq++}`; for (const agentId of chosen) { const brain = this.brains.get(agentId)!; brain.phase = "meeting"; } S.addEvent("🤝", `${chosen.length} 位 agent 前往會議區討論`); S.startMeeting(id, chosen); this.meeting = { id, agentIds: chosen, timer: rand(12, 18), speakTimer: 0, speakerIdx: -1, gathered: false }; } }