Initial commit

This commit is contained in:
LZH-YS1998
2026-07-01 17:56:31 +08:00
commit d78931979d
731 changed files with 311088 additions and 0 deletions
@@ -0,0 +1,234 @@
import Phaser from 'phaser'
import type { OfficeScene } from './scenes/OfficeScene'
import type { VisualEvent, VisualSnapshot } from '../types/visual'
import { getOffices, type OfficeConfig } from './map/OfficeStore'
import { AgentState } from './types'
export class GameBridge extends Phaser.Events.EventEmitter {
private scene: OfficeScene | null = null
private eventQueue: VisualEvent[] = []
private snapshotQueue: VisualSnapshot[] = []
constructor() {
super()
}
setScene(scene: OfficeScene) {
this.scene = scene
for (const snap of this.snapshotQueue) {
this.applySnapshot(snap)
}
this.snapshotQueue = []
for (const evt of this.eventQueue) {
this.applyEvent(evt)
}
this.eventQueue = []
}
getScene(): OfficeScene | null {
return this.scene
}
// ── Called from React side ────────────────────────────
pushEvent(evt: VisualEvent) {
if (!this.scene) {
this.eventQueue.push(evt)
return
}
this.applyEvent(evt)
}
pushSnapshot(snapshot: VisualSnapshot) {
const agentCount = Object.keys(snapshot.agents ?? {}).length
if (!this.scene) {
console.log(`[GameBridge] pushSnapshot queued (scene not ready) — ${agentCount} agents`)
this.snapshotQueue.push(snapshot)
return
}
console.log(`[GameBridge] pushSnapshot applying now — ${agentCount} agents`)
this.applySnapshot(snapshot)
}
sendToSeat(agentId: string) {
if (!this.scene) return
this.scene.behavior.sendToSeat(
this.scene.ensureAgent(agentId),
)
}
setAgentActive(agentId: string, active: boolean) {
if (!this.scene) return
const agent = this.scene.getAgent(agentId)
if (agent) agent.isActive = active
}
setAgentBubble(agentId: string, text: string | null) {
if (!this.scene) return
const agent = this.scene.getAgent(agentId)
if (!agent) return
if (text) agent.showBubble(text)
else agent.clearBubble()
}
ensureAgent(agentId: string, displayName?: string, officeId?: string, palette?: number, deskId?: string) {
if (!this.scene) return
this.scene.ensureAgent(agentId, displayName, false, null, officeId, palette, deskId)
}
getCharacterCards() {
if (!this.scene) return []
return this.scene.getCharacterCards()
}
// ── Office management API ─────────────────────────────
getOffices(): OfficeConfig[] {
return getOffices()
}
renameOffice(officeId: string, newName: string) {
if (!this.scene) return
this.scene.renameOffice(officeId, newName)
this.emit('officeChanged')
}
assignAgentToOffice(agentId: string, officeId: string) {
if (!this.scene) return
this.scene.reassignAgent(agentId, officeId)
this.emit('officeChanged')
}
panToOffice(officeId: string) {
if (!this.scene) return
this.scene.panToOffice(officeId)
}
resetCamera() {
if (!this.scene) return
this.scene.resetCameraView()
}
/** Re-read `isLocalDaytime()` (URL + localStorage) and refresh skyline / grass if it changed. */
syncOutdoorLighting() {
if (!this.scene) return
this.scene.syncOutdoorLighting()
}
rebuildOfficeCollision(officeId: string, mapStr: string[], seats: [number, number][]) {
if (!this.scene) return
this.scene.rebuildOfficeCollision(officeId, mapStr, seats)
}
getSeatsForOffice(officeId: string): Array<{ id: string; assigned: boolean; assignedTo: string | null }> {
if (!this.scene) return []
return this.scene.seats
.filter(s => s.id.startsWith(`${officeId}-desk-`) || s.id.startsWith(`${officeId}-leader-`))
.map(s => ({ id: s.id, assigned: s.assigned, assignedTo: s.assignedTo }))
}
changeAgentSeat(agentId: string, seatId: string) {
if (!this.scene) return
this.scene.changeAgentSeat(agentId, seatId)
this.emit('officeChanged')
}
// ── Chat + Kanban game integration ──────────────────────
notifyChannelMessage(agentIds: string[], text: string) {
if (!this.scene) return
for (const id of agentIds) {
const agent = this.scene.getAgent(id)
if (agent) {
agent.showBubble(text.slice(0, 30))
setTimeout(() => agent.clearBubble(), 4000)
}
}
}
triggerCrossOfficeMeeting(agentIds: string[]) {
if (!this.scene) return
for (const id of agentIds) {
const agent = this.scene.getAgent(id)
if (agent) {
this.scene.behavior.moveToZone(agent, 'meetingRoom')
}
}
}
triggerCelebration(agentId: string) {
if (!this.scene) return
const agent = this.scene.getAgent(agentId)
if (agent) {
agent.showBubble('🎉 Done!')
setTimeout(() => agent.clearBubble(), 3000)
}
}
// ── Internal ──────────────────────────────────────────
private applyEvent(evt: VisualEvent) {
if (!this.scene) return
this.scene.behavior.applyEvent(evt)
this.emit('eventApplied', evt)
}
private applySnapshot(snapshot: VisualSnapshot) {
if (!this.scene) {
console.warn('[GameBridge] applySnapshot called but scene is null')
return
}
// Snapshot old agents to array first (avoid mutating Map during iteration)
const oldIds = Array.from(this.scene.agents.keys())
console.log('[GameBridge] applySnapshot — clearing', oldIds.length, 'old agents:', oldIds)
for (const id of oldIds) {
this.scene.removeAgent(id)
}
const timeline = snapshot.timeline ?? []
for (const evt of timeline) {
this.scene.behavior.applyEvent(evt)
}
const agentEntries = Object.entries(snapshot.agents ?? {})
console.log('[GameBridge] applySnapshot — adding', agentEntries.length, 'agents:', agentEntries.map(([id]) => id))
for (const [id, info] of agentEntries) {
const agentData = info as {
name?: string; role_name?: string; office_id?: string
status?: string; runtime_status?: string; current_tool?: string | null
appearance?: { palette?: number; hue_shift?: number; seat_zone?: string; desk_id?: string }
}
const name = agentData.name || agentData.role_name || id
const officeId = agentData.office_id
const palette = agentData.appearance?.palette
const deskId = agentData.appearance?.desk_id
try {
const agent = this.scene.ensureAgent(id, name, false, null, officeId, palette, deskId)
const runtimeStatus = agentData.runtime_status || agentData.status
if (runtimeStatus === 'tool_active') {
agent.currentTool = agentData.current_tool ?? null
agent.isActive = true
agent.setAgentState(AgentState.TYPE)
} else if (runtimeStatus === 'reflecting') {
agent.currentTool = agentData.current_tool ?? 'Reflect'
agent.isActive = false
agent.setAgentState(AgentState.REFLECT)
}
console.log(`[GameBridge] ✓ ensured ${id} in ${officeId} palette=${palette}`)
} catch (err) {
console.error(`[GameBridge] ✗ ensureAgent failed for ${id}:`, err)
}
}
if (timeline.length === 0 && agentEntries.length === 0) {
console.warn('[GameBridge] applySnapshot — empty snapshot, creating fallback agent')
this.scene.ensureAgent('openopc-main', 'OpenOPC')
}
console.log('[GameBridge] applySnapshot done — total agents:', this.scene.agents.size)
this.emit('snapshotApplied', snapshot)
}
}
@@ -0,0 +1,70 @@
import { useEffect, useRef } from 'react'
import Phaser from 'phaser'
import { createGameConfig } from './config'
import type { GameBridge } from './GameBridge'
import { BootScene } from './scenes/BootScene'
import { OfficeScene } from './scenes/OfficeScene'
interface Props {
bridge: GameBridge
}
export function PhaserGame({ bridge }: Props) {
const wrapperRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const gameRef = useRef<Phaser.Game | null>(null)
useEffect(() => {
if (!wrapperRef.current || !containerRef.current || gameRef.current) return
// Measure the wrapper (which has definite CSS dimensions from the grid layout).
// The inner container div is initially empty so has 0 dimensions.
const wrapper = wrapperRef.current
const container = containerRef.current
// Force container to fill wrapper so clientWidth/Height are non-zero
container.style.width = `${wrapper.clientWidth}px`
container.style.height = `${wrapper.clientHeight}px`
// Safety: never create a 0×0 game
const w = container.clientWidth || window.innerWidth - 400
const h = container.clientHeight || window.innerHeight - 48
if (w < 50 || h < 50) {
console.warn('[PhaserGame] Container too small:', w, h, '— using fallback size')
container.style.width = `${window.innerWidth - 400}px`
container.style.height = `${window.innerHeight - 48}px`
}
console.log('[PhaserGame] Creating Phaser game', container.clientWidth, '×', container.clientHeight)
const config = createGameConfig(container)
config.scene = [BootScene, OfficeScene]
const game = new Phaser.Game(config)
game.registry.set('bridge', bridge)
gameRef.current = game
// Keep canvas sized to wrapper on window resize
const onResize = () => {
if (!wrapper || !game) return
container.style.width = `${wrapper.clientWidth}px`
container.style.height = `${wrapper.clientHeight}px`
game.scale.resize(wrapper.clientWidth, wrapper.clientHeight)
}
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('resize', onResize)
game.destroy(true)
gameRef.current = null
}
}, [bridge]) // bridge is a stable ref, effect runs once
return (
// Wrapper fills the CSS grid cell
<div ref={wrapperRef} style={{ width: '100%', height: '100%' }}>
{/* Phaser mounts its canvas inside this div */}
<div ref={containerRef} />
</div>
)
}
@@ -0,0 +1,130 @@
import Phaser from 'phaser'
export const TILE_SIZE = 32
export const OFFICE_COLS = 20
export const OFFICE_ROWS = 25
export const GAP_COLS = 2
export const OFFICE_COUNT = 3
export const WORLD_COLS = OFFICE_COLS * OFFICE_COUNT + GAP_COLS * (OFFICE_COUNT - 1) // 64
export const WORLD_ROWS = OFFICE_ROWS // 25
export const MAP_COLS = WORLD_COLS
export const MAP_ROWS = WORLD_ROWS
export const OUTDOOR_MARGIN_X = TILE_SIZE * 8
export const OUTDOOR_MARGIN_TOP = TILE_SIZE * 4
export const OUTDOOR_MARGIN_BOTTOM = TILE_SIZE * 20
export const CHAR_SCALE = 1.8
export const CHAR_SCALE_X = CHAR_SCALE
export const CHAR_SCALE_Y = CHAR_SCALE
export const CHAR_SHADOW_WIDTH = 30
export const CHAR_SHADOW_HEIGHT = 8
export const CHAR_SHADOW_Y = -2
export const CHAR_SHADOW_ALPHA = 0.22
/** Daytime if local hour is in [DAYTIME_START_HOUR, DAYTIME_END_HOUR] inclusive. */
export const DAYTIME_START_HOUR = 5
/** 23 → day through 23:59; 0:004:59 is night when mode is Auto (no URL/storage override). */
export const DAYTIME_END_HOUR = 23
/** Parse `?day=1` / `#?day=1` / `#/path?day=1` for outdoor preview. */
function readOutdoorOverrideFromUrl(): 'day' | 'night' | null {
if (typeof window === 'undefined') return null
const parse = (raw: string): 'day' | 'night' | null => {
const q = new URLSearchParams(raw)
if (q.get('day') === '1' || q.get('daytime') === '1') return 'day'
if (q.get('night') === '1') return 'night'
return null
}
let o = parse(window.location.search || '')
if (o) return o
const hash = window.location.hash
if (!hash) return null
const qm = hash.indexOf('?')
if (qm >= 0) {
o = parse(hash.slice(qm + 1))
if (o) return o
}
const h = hash.replace(/^#/, '')
if (h.includes('=')) {
o = parse(h)
if (o) return o
}
return null
}
/**
* Day vs night for the outdoor skyline. Clock: local 5:0023:59 = day, 0:004:59 = night (Auto mode).
* Override (browser): URL `?day=1` / `?daytime=1` (also after `#…?`), or `localStorage opc_outdoor_override` = `day`|`night`.
* Legacy: `opc_outdoor_day` / `opc_outdoor_night` = `1`.
*/
export function isLocalDaytime(now = new Date()): boolean {
if (typeof window !== 'undefined') {
try {
const url = readOutdoorOverrideFromUrl()
if (url === 'day') return true
if (url === 'night') return false
const om = window.localStorage?.getItem('opc_outdoor_override')
if (om === 'day') return true
if (om === 'night') return false
if (window.localStorage?.getItem('opc_outdoor_day') === '1') return true
if (window.localStorage?.getItem('opc_outdoor_night') === '1') return false
} catch {
/* private mode / SSR */
}
}
const h = now.getHours()
return h >= DAYTIME_START_HOUR && h <= DAYTIME_END_HOUR
}
/** Phaser camera clear color to match sky / lawn edge. */
export const SCENE_CLEAR_DAY = 0xa8d4ec
export const SCENE_CLEAR_NIGHT = 0x31453a
export const WALK_SPEED_URGENT = 200
export const WALK_SPEED_NORMAL = 100
export const WALK_SPEED_RELAXED = 60
export const WANDER_PAUSE_MIN = 2.0
export const WANDER_PAUSE_MAX = 20.0
export const WANDER_MOVES_BEFORE_REST_MIN = 3
export const WANDER_MOVES_BEFORE_REST_MAX = 6
export const SEAT_REST_MIN = 120.0
export const SEAT_REST_MAX = 240.0
export const CELEBRATE_DURATION = 2.5
export const COFFEE_DURATION_MIN = 8.0
export const COFFEE_DURATION_MAX = 15.0
export const CHAT_DURATION_MIN = 5.0
export const CHAT_DURATION_MAX = 10.0
export const STATUS_BUBBLE_DURATION = 5.0
export const INACTIVE_SEAT_TIMER_MIN = 3.0
export const INACTIVE_SEAT_TIMER_RANGE = 2.0
export function createGameConfig(parent: HTMLElement): Phaser.Types.Core.GameConfig {
const w = parent.clientWidth || window.innerWidth - 380
const h = parent.clientHeight || window.innerHeight - 48
const skyHex = isLocalDaytime() ? '#a8d4ec' : '#31453a'
return {
type: Phaser.CANVAS,
parent,
width: w,
height: h,
pixelArt: true,
backgroundColor: skyHex,
physics: {
default: 'arcade',
arcade: {
gravity: { x: 0, y: 0 },
debug: false,
},
},
scale: {
mode: Phaser.Scale.RESIZE,
autoCenter: Phaser.Scale.NONE,
parent,
},
render: {
antialias: false,
pixelArt: true,
},
}
}
@@ -0,0 +1,360 @@
import Phaser from 'phaser'
import {
TILE_SIZE,
CHAR_SCALE_X, CHAR_SCALE_Y,
CHAR_SHADOW_ALPHA, CHAR_SHADOW_HEIGHT, CHAR_SHADOW_WIDTH, CHAR_SHADOW_Y,
WALK_SPEED_URGENT, WALK_SPEED_NORMAL, WALK_SPEED_RELAXED,
CELEBRATE_DURATION, STATUS_BUBBLE_DURATION,
WANDER_PAUSE_MIN, WANDER_PAUSE_MAX,
WANDER_MOVES_BEFORE_REST_MIN, WANDER_MOVES_BEFORE_REST_MAX,
} from '../config'
import { AgentState, Direction } from '../types'
import type { PathfindingManager } from '../systems/PathfindingManager'
function randomRange(min: number, max: number) {
return min + Math.random() * (max - min)
}
function randomInt(min: number, max: number) {
return Math.floor(randomRange(min, max + 1))
}
export class Agent extends Phaser.GameObjects.Container {
declare body: Phaser.Physics.Arcade.Body
agentId: string
displayName: string
officeId = 'office-0'
agentState: AgentState = AgentState.IDLE
dir: Direction = Direction.DOWN
palette: number
isActive = false
currentTool: string | null = null
seatId: string | null = null
urgency: 'urgent' | 'normal' | 'relaxed' = 'relaxed'
isSubagent = false
parentAgentId: string | null = null
taskSummary?: string
lastEventAt = 0
stateTimer = 0
seatTimer = 0
wanderTimer: number
wanderCount = 0
wanderLimit: number
hueShift = 0
bubbleText: string | null = null
bubbleTimer = 0
myceliumEffect: string | null = null
myceliumEffectTimer = 0
myceliumSession: string | null = null
private sprite: Phaser.GameObjects.Sprite
private shadow: Phaser.GameObjects.Ellipse
private bubbleObj: Phaser.GameObjects.Container | null = null
private currentPath: { x: number; y: number }[] = []
private pathIndex = 0
private pathfinder: PathfindingManager | null = null
private arrivalCallback: (() => void) | null = null
constructor(
scene: Phaser.Scene,
agentId: string,
displayName: string,
palette: number,
tileX: number,
tileY: number,
) {
const px = tileX * TILE_SIZE + TILE_SIZE / 2
const py = tileY * TILE_SIZE + TILE_SIZE / 2
super(scene, px, py)
this.agentId = agentId
this.displayName = displayName
this.palette = palette % 6
this.wanderTimer = randomRange(0.5, 2.5)
this.wanderLimit = randomInt(WANDER_MOVES_BEFORE_REST_MIN, WANDER_MOVES_BEFORE_REST_MAX)
const spriteKey = `char_${this.palette}`
this.shadow = scene.add.ellipse(
0,
CHAR_SHADOW_Y,
CHAR_SHADOW_WIDTH,
CHAR_SHADOW_HEIGHT,
0x151820,
CHAR_SHADOW_ALPHA,
)
this.shadow.setOrigin(0.5, 0.5)
this.add(this.shadow)
this.sprite = scene.add.sprite(0, 0, spriteKey)
this.sprite.setScale(CHAR_SCALE_X, CHAR_SCALE_Y)
this.sprite.setOrigin(0.5, 1)
this.add(this.sprite)
scene.add.existing(this)
scene.physics.world.enable(this)
const bodyW = Math.min(12, TILE_SIZE - 4)
const bodyH = Math.min(6, TILE_SIZE / 2)
this.body.setSize(bodyW, bodyH)
this.body.setOffset(-bodyW / 2, -bodyH)
this.body.setCollideWorldBounds(true)
this.setDepth(py)
this.playAnimForState()
}
setPathfinder(pf: PathfindingManager) {
this.pathfinder = pf
}
getState(): AgentState { return this.agentState }
getTilePos(): { x: number; y: number } {
return {
x: Math.floor(this.x / TILE_SIZE),
y: Math.floor(this.y / TILE_SIZE),
}
}
// ── State management ──────────────────────────────────────
setAgentState(newState: AgentState) {
if (this.agentState === newState) return
this.agentState = newState
this.playAnimForState()
}
setDirection(dir: Direction) {
if (this.dir === dir) return
this.dir = dir
this.playAnimForState()
}
private playAnimForState() {
if (!this.sprite?.anims) return
const key = `char_${this.palette}`
const dir = this.dir
const isLeft = dir === Direction.LEFT
this.sprite.setFlipX(isLeft)
const animDir = isLeft ? 'right' : dir
switch (this.agentState) {
case AgentState.WALK:
this.sprite.play(`${key}_walk_${animDir}`, true)
break
case AgentState.CELEBRATE:
this.sprite.play(`${key}_celebrate_${animDir}`, true)
break
case AgentState.TYPE:
case AgentState.PRESENT:
case AgentState.PRACTICE:
this.sprite.play(`${key}_type_${animDir}`, true)
break
case AgentState.THINK:
case AgentState.REFLECT:
this.sprite.play(`${key}_read_${animDir}`, true)
break
case AgentState.COFFEE:
case AgentState.CHAT:
this.sprite.play(`${key}_coffee_${animDir}`, true)
break
case AgentState.SLEEP:
case AgentState.IDLE:
default:
this.sprite.play(`${key}_idle_${animDir}`, true)
break
}
}
// ── Movement ──────────────────────────────────────────────
async walkTo(tileX: number, tileY: number, onArrival?: () => void): Promise<boolean> {
if (!this.pathfinder || !this.sprite?.anims) return false
const from = this.getTilePos()
const path = await this.pathfinder.findPath(from, { x: tileX, y: tileY })
if (path.length === 0) return false
this.currentPath = path
this.pathIndex = 0
this.arrivalCallback = onArrival ?? null
this.setAgentState(AgentState.WALK)
return true
}
stopMovement() {
this.currentPath = []
this.pathIndex = 0
this.arrivalCallback = null
this.body?.setVelocity(0, 0)
}
get isMoving(): boolean {
return this.agentState === AgentState.WALK && this.currentPath.length > 0
}
private getWalkSpeed(): number {
switch (this.urgency) {
case 'urgent': return WALK_SPEED_URGENT
case 'normal': return WALK_SPEED_NORMAL
default: return WALK_SPEED_RELAXED
}
}
// ── Bubble ────────────────────────────────────────────────
showBubble(text: string, duration = STATUS_BUBBLE_DURATION) {
this.bubbleText = text
this.bubbleTimer = duration
this.updateBubbleDisplay()
}
clearBubble() {
this.bubbleText = null
this.bubbleTimer = 0
if (this.bubbleObj) {
this.bubbleObj.destroy()
this.bubbleObj = null
}
}
private updateBubbleDisplay() {
if (this.bubbleObj) {
this.bubbleObj.destroy()
this.bubbleObj = null
}
if (!this.bubbleText) return
const container = this.scene.add.container(this.x, this.y - 70)
const textObj = this.scene.add.text(0, 0, this.bubbleText, {
fontSize: '10px',
fontFamily: 'monospace',
color: '#1a1a2e',
backgroundColor: '#ffffff',
padding: { x: 4, y: 2 },
resolution: 2,
})
textObj.setOrigin(0.5, 1)
const bg = this.scene.add.graphics()
const w = textObj.width + 8
const h = textObj.height + 4
bg.fillStyle(0xffffff, 0.95)
bg.fillRoundedRect(-w / 2, -h, w, h, 4)
bg.lineStyle(1, 0x666666, 0.5)
bg.strokeRoundedRect(-w / 2, -h, w, h, 4)
container.add([bg, textObj])
container.setDepth(100000)
this.bubbleObj = container
}
// ── Per-frame update ──────────────────────────────────────
update(dt: number) {
// Depth sorting
this.setDepth(this.y)
// Bubble position tracking + timer
if (this.bubbleObj) {
this.bubbleObj.setPosition(this.x, this.y - 70)
if (this.bubbleTimer > 0) {
this.bubbleTimer -= dt
if (this.bubbleTimer <= 0) {
this.clearBubble()
}
}
}
// State timer
if (this.stateTimer > 0) {
this.stateTimer -= dt
if (this.stateTimer <= 0) {
this.stateTimer = 0
if (this.agentState === AgentState.CELEBRATE) {
this.setAgentState(AgentState.IDLE)
}
if (this.agentState === AgentState.COFFEE) {
this.setAgentState(AgentState.IDLE)
this.wanderTimer = randomRange(WANDER_PAUSE_MIN, WANDER_PAUSE_MAX)
}
if (this.agentState === AgentState.CHAT) {
this.setAgentState(AgentState.IDLE)
}
}
}
// Path following
if (this.agentState === AgentState.WALK && this.currentPath.length > 0) {
this.followPath(dt)
}
}
private followPath(dt: number) {
this.body.setVelocity(0, 0)
if (this.pathIndex >= this.currentPath.length) {
this.arriveAtDestination()
return
}
const target = this.currentPath[this.pathIndex]
const targetPx = target.x * TILE_SIZE + TILE_SIZE / 2
const targetPy = target.y * TILE_SIZE + TILE_SIZE / 2
const dx = targetPx - this.x
const dy = targetPy - this.y
const dist = Math.sqrt(dx * dx + dy * dy)
const speed = this.getWalkSpeed()
const step = speed * dt
if (dist <= step + 0.5) {
this.x = targetPx
this.y = targetPy
this.pathIndex++
if (this.pathIndex >= this.currentPath.length) {
this.arriveAtDestination()
}
return
}
this.x += (dx / dist) * step
this.y += (dy / dist) * step
if (Math.abs(dx) > Math.abs(dy)) {
this.setDirection(dx > 0 ? Direction.RIGHT : Direction.LEFT)
} else {
this.setDirection(dy > 0 ? Direction.DOWN : Direction.UP)
}
}
private arriveAtDestination() {
this.currentPath = []
this.pathIndex = 0
this.body.setVelocity(0, 0)
const cb = this.arrivalCallback
this.arrivalCallback = null
if (cb) {
cb()
} else if (this.agentState === AgentState.WALK) {
this.setAgentState(AgentState.IDLE)
}
}
destroy(fromScene?: boolean) {
this.clearBubble()
super.destroy(fromScene)
}
}
@@ -0,0 +1,215 @@
import type { Direction, SeatDef, InteractableDef } from '../types'
import { OFFICE_COLS, OFFICE_ROWS } from '../config'
import { getOffices, parseOfficeMapStr, type OfficeConfig } from './OfficeStore'
export interface ZoneDef {
name: string
bounds: { x: number; y: number; w: number; h: number }
seats: SeatDef[]
interactables: InteractableDef[]
doorways: { id: string; tileX: number; tileY: number }[]
}
function seat(id: string, tileX: number, tileY: number, facing: Direction): SeatDef {
return { id, tileX, tileY, facing, assigned: false, assignedTo: null }
}
function interactable(id: string, tileX: number, tileY: number, type: string): InteractableDef {
return { id, tileX, tileY, type }
}
interface ZoneTemplate {
name: string
localBounds: { x: number; y: number; w: number; h: number }
interactables: { id: string; localX: number; localY: number; type: string }[]
doorways: { id: string; localX: number; localY: number }[]
}
const ZONE_TEMPLATES: Record<string, ZoneTemplate> = {
meetingRoom: {
name: 'Meeting Room',
localBounds: { x: 7, y: 3, w: 6, h: 5 },
interactables: [{ id: 'whiteboard', localX: 9, localY: 1, type: 'whiteboard' }],
doorways: [{ id: 'door-meeting', localX: 9, localY: 9 }],
},
workspace: {
name: 'Workspace',
localBounds: { x: 1, y: 10, w: 11, h: 7 },
interactables: [{ id: 'printer', localX: 10, localY: 10, type: 'printer' }],
doorways: [{ id: 'door-ws', localX: 9, localY: 10 }],
},
breakRoom: {
name: 'Break Room',
localBounds: { x: 13, y: 10, w: 6, h: 8 },
interactables: [
{ id: 'coffee-machine', localX: 18, localY: 10, type: 'coffee_machine' },
{ id: 'fridge', localX: 18, localY: 11, type: 'fridge' },
],
doorways: [{ id: 'door-break', localX: 13, localY: 10 }],
},
leaderOffice: {
name: 'Leader Office',
localBounds: { x: 13, y: 18, w: 6, h: 6 },
interactables: [],
doorways: [{ id: 'door-leader', localX: 16, localY: 17 }],
},
lobby: {
name: 'Lobby',
localBounds: { x: 1, y: 19, w: 12, h: 5 },
interactables: [],
doorways: [{ id: 'entrance', localX: 10, localY: 18 }],
},
}
function inferFacing(col: number, row: number, zoneName: string): Direction {
switch (zoneName) {
case 'workspace':
return 'up'
case 'meetingRoom': {
const tableCenterX = 9.5
return col < tableCenterX ? 'right' : 'left'
}
case 'breakRoom': {
const tableCenterX = 15.5
return col < tableCenterX ? 'right' : 'left'
}
case 'leaderOffice':
return 'up'
default:
return 'down'
}
}
function classifyLocalSeat(col: number, row: number): string {
for (const [name, z] of Object.entries(ZONE_TEMPLATES)) {
const { x, y, w, h } = z.localBounds
if (col >= x && col < x + w && row >= y && row < y + h) return name
}
return 'lobby'
}
export function buildZonesForOffice(office: OfficeConfig): Record<string, ZoneDef> {
const off = office.offsetCol
const zones: Record<string, ZoneDef> = {}
for (const [zoneKey, tmpl] of Object.entries(ZONE_TEMPLATES)) {
const globalKey = `${office.id}-${zoneKey}`
zones[globalKey] = {
name: `${tmpl.name} (${office.name})`,
bounds: {
x: tmpl.localBounds.x + off,
y: tmpl.localBounds.y,
w: tmpl.localBounds.w,
h: tmpl.localBounds.h,
},
seats: [],
interactables: tmpl.interactables.map(i =>
interactable(`${office.id}-${i.id}`, i.localX + off, i.localY, i.type),
),
doorways: tmpl.doorways.map(d => ({
id: `${office.id}-${d.id}`,
tileX: d.localX + off,
tileY: d.localY,
})),
}
}
const counters: Record<string, number> = {}
for (const [col, row] of office.seats) {
const localZone = classifyLocalSeat(col, row)
const globalKey = `${office.id}-${localZone}`
if (!zones[globalKey]) continue
counters[globalKey] = (counters[globalKey] ?? 0) + 1
const idx = counters[globalKey]
const prefix = localZone === 'workspace' ? 'desk' : localZone === 'meetingRoom' ? 'meeting' : localZone === 'breakRoom' ? 'break' : localZone === 'leaderOffice' ? 'leader' : 'lobby'
const facing = inferFacing(col, row, localZone)
zones[globalKey].seats.push(seat(`${office.id}-${prefix}-${idx}`, col + off, row, facing))
}
return zones
}
export function buildAllZones(offices?: OfficeConfig[]): Record<string, ZoneDef> {
const all = offices ?? getOffices()
const zones: Record<string, ZoneDef> = {}
for (const office of all) {
Object.assign(zones, buildZonesForOffice(office))
}
return zones
}
export let ZONES: Record<string, ZoneDef> = buildAllZones()
export function reloadZones(offices?: OfficeConfig[]) {
ZONES = buildAllZones(offices)
}
export function getOfficeZoneKey(officeId: string, zoneName: string): string {
return `${officeId}-${zoneName}`
}
export function getOfficeDeskSeats(officeId: string): SeatDef[] {
const desks = ZONES[`${officeId}-workspace`]?.seats ?? []
const leaders = ZONES[`${officeId}-leaderOffice`]?.seats ?? []
return [...desks, ...leaders]
}
export function getOfficeAllSeats(officeId: string): SeatDef[] {
return Object.entries(ZONES)
.filter(([k]) => k.startsWith(`${officeId}-`))
.flatMap(([, z]) => z.seats)
}
export function getAllDeskSeats(): SeatDef[] {
return Object.entries(ZONES)
.filter(([k]) => k.endsWith('-workspace'))
.flatMap(([, z]) => z.seats)
}
export function getMeetingSeats(officeId?: string): SeatDef[] {
if (officeId) return ZONES[`${officeId}-meetingRoom`]?.seats ?? []
return Object.entries(ZONES)
.filter(([k]) => k.endsWith('-meetingRoom'))
.flatMap(([, z]) => z.seats)
}
export function getAllSeats(): SeatDef[] {
return Object.values(ZONES).flatMap(z => z.seats)
}
export function randomTileInZone(zoneKey: string): { x: number; y: number } | null {
const zone = ZONES[zoneKey]
if (!zone) return null
const { x, y, w, h } = zone.bounds
const officeId = zoneKey.split('-').slice(0, 2).join('-')
const offices = getOffices()
const office = offices.find(o => o.id === officeId)
if (!office) return { x: x + 1, y: y + 1 }
const grid = parseOfficeMapStr(office.mapStr)
const off = office.offsetCol
const walkable: { x: number; y: number }[] = []
for (let row = y; row < y + h; row++) {
for (let col = x; col < x + w; col++) {
const localCol = col - off
if (localCol >= 0 && localCol < OFFICE_COLS && row < OFFICE_ROWS && grid[row]?.[localCol] === 0) {
walkable.push({ x: col, y: row })
}
}
}
if (walkable.length === 0) return null
return walkable[Math.floor(Math.random() * walkable.length)]
}
export function getDoorwayTargets(zoneKey: string): { x: number; y: number }[] {
const zone = ZONES[zoneKey]
if (!zone) return []
return zone.doorways.map(d => ({ x: d.tileX, y: d.tileY }))
}
export function getOfficeLobbyDoorways(officeId: string): { x: number; y: number }[] {
const zone = ZONES[`${officeId}-lobby`]
if (!zone) return []
return zone.doorways.map(d => ({ x: d.tileX, y: d.tileY }))
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,159 @@
import { OFFICE_COLS, OFFICE_ROWS, GAP_COLS } from '../config'
export const DEFAULT_MAP_STR: string[] = [
'####################',
'####################',
'####################',
'#######......#######',
'#######..##..#######',
'#######..##..#######',
'#######..##..#######',
'#######......#######',
'#########.##########',
'#########.##########',
'#.............#....#',
'#..................#',
'#.#########....##..#',
'#...........#......#',
'#...........#..##..#',
'#.#########.#......#',
'#...........#......#',
'#...........####.###',
'##########..####.###',
'##########..#......#',
'#...........#..#...#',
'#...####....#.###..#',
'#...........#......#',
'#.##....#####....###',
'####################',
]
export const DEFAULT_SEATS: [number, number][] = [
[8, 4], [8, 5], [8, 6], [11, 4], [11, 5], [11, 6],
[3, 13], [6, 13], [9, 13], [3, 16], [6, 16], [9, 16],
[14, 12], [17, 12], [14, 14], [17, 14],
[15, 22],
]
export interface OfficeConfig {
id: string
name: string
offsetCol: number
mapStr: string[]
seats: [number, number][]
assignedAgents: string[]
}
const STORAGE_KEY = 'office-multi-config'
function makeDefaultOffices(): OfficeConfig[] {
const step = OFFICE_COLS + GAP_COLS
return [
{ id: 'office-0', name: 'Office A', offsetCol: 0, mapStr: [...DEFAULT_MAP_STR], seats: [...DEFAULT_SEATS], assignedAgents: [] },
{ id: 'office-1', name: 'Office B', offsetCol: step, mapStr: [...DEFAULT_MAP_STR], seats: [...DEFAULT_SEATS], assignedAgents: [] },
{ id: 'office-2', name: 'Office C', offsetCol: step * 2, mapStr: [...DEFAULT_MAP_STR], seats: [...DEFAULT_SEATS], assignedAgents: [] },
]
}
export const DEFAULT_OFFICES = makeDefaultOffices()
export function getOffices(): OfficeConfig[] {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored) as OfficeConfig[]
if (Array.isArray(parsed) && parsed.length > 0) return parsed
}
} catch { /* ignore */ }
return makeDefaultOffices()
}
export function saveOffices(offices: OfficeConfig[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(offices))
}
export function getOfficeById(id: string): OfficeConfig | undefined {
return getOffices().find(o => o.id === id)
}
export function renameOffice(id: string, name: string) {
const offices = getOffices()
const office = offices.find(o => o.id === id)
if (office) {
office.name = name
saveOffices(offices)
}
}
export function assignAgent(officeId: string, agentId: string) {
const offices = getOffices()
for (const o of offices) {
o.assignedAgents = o.assignedAgents.filter(a => a !== agentId)
}
const target = offices.find(o => o.id === officeId)
if (target) target.assignedAgents.push(agentId)
saveOffices(offices)
}
export function unassignAgent(agentId: string) {
const offices = getOffices()
for (const o of offices) {
o.assignedAgents = o.assignedAgents.filter(a => a !== agentId)
}
saveOffices(offices)
}
export function getAgentOffice(agentId: string): OfficeConfig | undefined {
return getOffices().find(o => o.assignedAgents.includes(agentId))
}
export function updateOfficeMap(officeId: string, mapStr: string[], seats: [number, number][]) {
const offices = getOffices()
const office = offices.find(o => o.id === officeId)
if (office) {
office.mapStr = mapStr
office.seats = seats
saveOffices(offices)
}
}
export function parseOfficeMapStr(mapStr: string[]): number[][] {
const grid: number[][] = []
for (let r = 0; r < OFFICE_ROWS; r++) {
const row: number[] = []
const line = r < mapStr.length ? mapStr[r] : '#'.repeat(OFFICE_COLS)
for (let c = 0; c < OFFICE_COLS; c++) {
row.push(c < line.length && line[c] === '.' ? 0 : 1)
}
grid.push(row)
}
return grid
}
export function buildCompositeGrid(offices: OfficeConfig[]): number[][] {
const worldCols = offices.length > 0
? offices[offices.length - 1].offsetCol + OFFICE_COLS
: OFFICE_COLS
const grid: number[][] = []
for (let r = 0; r < OFFICE_ROWS; r++) {
grid.push(new Array(worldCols).fill(1))
}
for (const office of offices) {
const officeGrid = parseOfficeMapStr(office.mapStr)
for (let r = 0; r < OFFICE_ROWS; r++) {
for (let c = 0; c < OFFICE_COLS; c++) {
grid[r][office.offsetCol + c] = officeGrid[r][c]
}
}
}
return grid
}
export function getWorkspaceSeatCount(office: OfficeConfig): number {
let count = 0
for (const [col, row] of office.seats) {
if (row >= 10 && row <= 17 && col < 12) count++
else if (row >= 18 && row <= 23 && col >= 13 && col <= 18) count++
}
return count
}
@@ -0,0 +1,62 @@
import Phaser from 'phaser'
const CHAR_FRAME_W = 16
const CHAR_FRAME_H = 32
const CHAR_COUNT = 6
export class BootScene extends Phaser.Scene {
constructor() {
super('Boot')
}
preload() {
this.load.image('office-bg', 'assets/office-bg.png')
this.load.spritesheet('office-tileset-32', 'assets/office-tileset-32.png', {
frameWidth: 32,
frameHeight: 32,
})
for (let i = 0; i < CHAR_COUNT; i++) {
this.load.spritesheet(`char_${i}`, `assets/characters/char_${i}.png`, {
frameWidth: CHAR_FRAME_W,
frameHeight: CHAR_FRAME_H,
})
}
const bar = this.add.graphics()
this.load.on('progress', (v: number) => {
bar.clear()
bar.fillStyle(0x4a90d9, 1)
bar.fillRect(this.scale.width / 2 - 100, this.scale.height / 2 - 8, 200 * v, 16)
})
this.load.on('complete', () => bar.destroy())
}
create() {
this.createCharacterAnimations()
console.log('[BootScene] All assets loaded')
this.scene.start('Office')
}
private createCharacterAnimations() {
const COLS = 7
const dirs = [
{ name: 'down', row: 0 },
{ name: 'up', row: 1 },
{ name: 'right', row: 2 },
{ name: 'left', row: 2 },
]
for (let p = 0; p < CHAR_COUNT; p++) {
const key = `char_${p}`
for (const dir of dirs) {
const base = dir.row * COLS
this.anims.create({ key: `${key}_walk_${dir.name}`, frames: [{ key, frame: base }, { key, frame: base + 1 }, { key, frame: base + 2 }, { key, frame: base + 1 }], frameRate: 8, repeat: -1 })
this.anims.create({ key: `${key}_idle_${dir.name}`, frames: [{ key, frame: base + 1 }], frameRate: 1 })
this.anims.create({ key: `${key}_type_${dir.name}`, frames: [{ key, frame: base + 3 }, { key, frame: base + 4 }], frameRate: 3, repeat: -1 })
this.anims.create({ key: `${key}_read_${dir.name}`, frames: [{ key, frame: base + 5 }, { key, frame: base + 6 }], frameRate: 2, repeat: -1 })
this.anims.create({ key: `${key}_coffee_${dir.name}`, frames: [{ key, frame: base + 1 }, { key, frame: base }], frameRate: 2, repeat: -1 })
this.anims.create({ key: `${key}_celebrate_${dir.name}`, frames: [{ key, frame: base }, { key, frame: base + 1 }, { key, frame: base + 2 }, { key, frame: base + 1 }], frameRate: 6, repeat: -1 })
}
}
}
}
@@ -0,0 +1,590 @@
import Phaser from 'phaser'
import {
TILE_SIZE, MAP_COLS, MAP_ROWS, OFFICE_COLS, OUTDOOR_MARGIN_X, OUTDOOR_MARGIN_TOP, OUTDOOR_MARGIN_BOTTOM,
isLocalDaytime, SCENE_CLEAR_DAY, SCENE_CLEAR_NIGHT,
} from '../config'
import { OfficeMapBuilder, type MapData } from '../map/OfficeMapBuilder'
import { Agent } from '../entities/Agent'
import { PathfindingManager } from '../systems/PathfindingManager'
import { BehaviorController } from '../systems/BehaviorController'
import type { GameBridge } from '../GameBridge'
import { getAllSeats, getOfficeDeskSeats, reloadZones } from '../map/InteractionZones'
import {
getOffices, assignAgent as storeAssignAgent,
updateOfficeMap, buildCompositeGrid, renameOffice as storeRenameOffice,
getAgentOffice,
type OfficeConfig,
} from '../map/OfficeStore'
import { AgentState, type SeatDef } from '../types'
export class OfficeScene extends Phaser.Scene {
mapData!: MapData
pathfinder!: PathfindingManager
behavior!: BehaviorController
bridge!: GameBridge
mapBuilder!: OfficeMapBuilder
private yachtLoopStarted = false
private outdoorIsDay: boolean | null = null
agents: Map<string, Agent> = new Map()
seats: SeatDef[] = []
walkableTiles: { x: number; y: number }[] = []
private walkableTilesByOffice: Map<string, { x: number; y: number }[]> = new Map()
private dragState: {
pointerId: number | null
lastX: number
lastY: number
moved: boolean
} = {
pointerId: null,
lastX: 0,
lastY: 0,
moved: false,
}
constructor() {
super('Office')
}
create() {
this.bridge = this.registry.get('bridge') as GameBridge
const isDay = isLocalDaytime()
this.outdoorIsDay = isDay
this.cameras.main.setBackgroundColor(isDay ? SCENE_CLEAR_DAY : SCENE_CLEAR_NIGHT)
this.mapBuilder = new OfficeMapBuilder()
this.mapData = this.mapBuilder.buildMap(this, isDay)
this.pathfinder = new PathfindingManager(this.mapData.collisionGrid)
this.walkableTiles = this.pathfinder.getWalkableTiles()
this.buildWalkableTilesByOffice()
this.seats = getAllSeats().map(s => ({ ...s }))
this.behavior = new BehaviorController(this)
const mapW = MAP_COLS * TILE_SIZE
const mapH = MAP_ROWS * TILE_SIZE
const worldX = -OUTDOOR_MARGIN_X
const worldY = -OUTDOOR_MARGIN_TOP
const worldW = mapW + OUTDOOR_MARGIN_X * 2
const worldH = mapH + OUTDOOR_MARGIN_TOP + OUTDOOR_MARGIN_BOTTOM
this.physics.world.setBounds(worldX, worldY, worldW, worldH)
this.cameras.main.setBounds(worldX, worldY, worldW, worldH)
this.resetCameraView(false)
this.startWaterfrontLoop()
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
if (!pointer.leftButtonDown()) return
this.dragState.pointerId = pointer.id
this.dragState.lastX = pointer.x
this.dragState.lastY = pointer.y
this.dragState.moved = false
})
this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => {
if (!pointer.isDown || this.dragState.pointerId !== pointer.id) return
const dx = pointer.x - this.dragState.lastX
const dy = pointer.y - this.dragState.lastY
if (!this.dragState.moved && Math.abs(pointer.downX - pointer.x) + Math.abs(pointer.downY - pointer.y) < 6) {
return
}
this.dragState.moved = true
const cam = this.cameras.main
cam.stopFollow()
cam.scrollX -= dx / cam.zoom
cam.scrollY -= dy / cam.zoom
this.dragState.lastX = pointer.x
this.dragState.lastY = pointer.y
})
this.input.on('wheel', (_p: Phaser.Input.Pointer, _g: Phaser.GameObjects.GameObject[], _dx: number, dy: number) => {
const cam = this.cameras.main
const pointer = this.input.activePointer
const before = cam.getWorldPoint(pointer.x, pointer.y)
const minZ = this.getMinCameraZoom(cam)
const nextZoom = Phaser.Math.Clamp(cam.zoom - dy * 0.0015, minZ, 3)
cam.setZoom(nextZoom)
const after = cam.getWorldPoint(pointer.x, pointer.y)
cam.scrollX += before.x - after.x
cam.scrollY += before.y - after.y
cam.scrollX = cam.clampX(cam.scrollX)
cam.scrollY = cam.clampY(cam.scrollY)
})
this.scale.on('resize', () => {
const cam = this.cameras.main
const minZ = this.getMinCameraZoom(cam)
if (cam.zoom < minZ) cam.setZoom(minZ)
cam.scrollX = cam.clampX(cam.scrollX)
cam.scrollY = cam.clampY(cam.scrollY)
})
this.time.addEvent({
delay: 45000,
loop: true,
callback: this.checkOutdoorDayNight,
callbackScope: this,
})
this.input.on('pointerup', (pointer: Phaser.Input.Pointer) => {
if (this.dragState.pointerId !== pointer.id) return
const dragged = this.dragState.pointerId === pointer.id && this.dragState.moved
this.dragState.pointerId = null
this.dragState.moved = false
if (dragged) return
const wp = this.cameras.main.getWorldPoint(pointer.x, pointer.y)
let closest: Agent | null = null
let closestDist = Infinity
for (const agent of this.agents.values()) {
const d = Phaser.Math.Distance.Between(wp.x, wp.y, agent.x, agent.y)
if (d < 24 && d < closestDist) { closest = agent; closestDist = d }
}
if (closest) {
this.bridge.emit('agentSelected', closest.agentId)
this.cameras.main.startFollow(closest, true, 0.1, 0.1)
}
})
if (this.bridge) this.bridge.setScene(this)
console.log('[OfficeScene] Ready — walkable tiles:', this.walkableTiles.length)
}
update(_time: number, delta: number) {
const dt = delta / 1000
for (const agent of this.agents.values()) agent.update(dt)
this.behavior.updateIdle(dt)
}
private startWaterfrontLoop() {
if (this.yachtLoopStarted) return
this.yachtLoopStarted = true
const { dockCenterX, dockY, waterTopY, waterBottomY } = this.mapData.waterfront
const yachtScale = 3
const boatY = Phaser.Math.Clamp(dockY + TILE_SIZE * 5.0, waterTopY + TILE_SIZE * 3.8, waterBottomY - TILE_SIZE * 4.6)
const startX = dockCenterX - TILE_SIZE * 20
const dockX = dockCenterX + TILE_SIZE * 1.65
const exitX = dockCenterX + TILE_SIZE * 19
const shadow = this.add.ellipse(0, 13, 122, 24, 0x173443, 0.28)
const wakeA = this.add.ellipse(-78, 8, 24, 7, 0xeaf8ff, 0.42)
const wakeB = this.add.ellipse(-92, 9, 16, 5, 0xd7eef7, 0.3)
const wakeC = this.add.ellipse(-106, 10, 10, 4, 0xbfdceb, 0.18)
const hullMain = this.add.rectangle(-2, 6, 94, 16, 0xf6f7f8, 1)
const sternBlock = this.add.rectangle(-49, 5, 12, 14, 0xe5e8ec, 1)
const bowMid = this.add.rectangle(44, 4, 12, 12, 0xf6f7f8, 1)
const bowTip = this.add.triangle(56, 4, 0, -6, 13, 0, 0, 6, 0xf6f7f8, 1)
const waterline = this.add.rectangle(-1, 11, 66, 4, 0xb9c3cb, 0.95)
const hullStripe = this.add.rectangle(6, 3, 74, 3, 0x8dbfd8, 0.95)
const lowerCabin = this.add.rectangle(-6, -3, 52, 10, 0xffffff, 1)
const lowerCabinAft = this.add.rectangle(-28, -2, 18, 8, 0xecf0f2, 1)
const bridgeBase = this.add.rectangle(18, -5, 18, 8, 0xffffff, 1)
const upperDeck = this.add.rectangle(6, -13, 40, 8, 0xf8fafb, 1)
const upperDeckAft = this.add.rectangle(-18, -12, 18, 6, 0xf0f4f6, 1)
const bridgeGlass = this.add.rectangle(19, -5, 14, 4, 0x97d3f7, 0.92)
const deckGlassBand = this.add.rectangle(0, -3, 42, 3, 0xcfe9f7, 0.88)
const rail = this.add.rectangle(4, -16, 44, 2, 0xd9e1e6, 0.96)
const mast = this.add.rectangle(7, -25, 2, 9, 0xe9edf1, 0.95)
const radar = this.add.rectangle(11, -28, 9, 2, 0xe9edf1, 0.95)
const flag = this.add.triangle(17, -27, 0, -3, 7, 0, 0, 3, 0x9fc5dc, 0.95)
const yacht = this.add.container(startX, boatY, [
shadow,
wakeC,
wakeB,
wakeA,
sternBlock,
hullMain,
bowMid,
bowTip,
hullStripe,
waterline,
lowerCabinAft,
lowerCabin,
bridgeBase,
upperDeckAft,
upperDeck,
bridgeGlass,
deckGlassBand,
rail,
mast,
radar,
flag,
])
const portholes = [-26, -10, 8, 26].map(px => this.add.circle(px, 5, 2.1, 0xd9f4ff, 0.95))
yacht.add(portholes)
yacht.setDepth(-250)
yacht.setAlpha(0)
yacht.setRotation(-0.04)
yacht.setScale(yachtScale)
this.tweens.add({
targets: [wakeA, wakeB, wakeC],
scaleX: { from: 0.85, to: 1.2 },
alpha: { from: 0.5, to: 0.2 },
duration: 900,
yoyo: true,
repeat: -1,
})
const runCycle = () => {
yacht.setPosition(startX, boatY)
yacht.setAlpha(0)
yacht.setRotation(-0.04)
wakeA.setAlpha(0.55)
wakeB.setAlpha(0.42)
wakeC.setAlpha(0.24)
this.tweens.add({
targets: yacht,
x: dockX,
alpha: 1,
rotation: 0.015,
duration: 7200,
ease: 'Sine.InOut',
onComplete: () => {
wakeA.setAlpha(0.22)
wakeB.setAlpha(0.14)
wakeC.setAlpha(0.08)
this.tweens.add({
targets: yacht,
x: dockX + 4,
y: boatY + TILE_SIZE * 0.12,
duration: 1400,
yoyo: true,
repeat: 2,
ease: 'Sine.InOut',
})
this.time.delayedCall(3600, () => {
wakeA.setAlpha(0.48)
wakeB.setAlpha(0.32)
wakeC.setAlpha(0.18)
this.tweens.add({
targets: yacht,
x: exitX,
alpha: 0,
rotation: -0.055,
duration: 7600,
ease: 'Sine.InOut',
onComplete: () => {
this.time.delayedCall(2400, runCycle)
},
})
})
},
})
}
runCycle()
}
// ── Office helpers ──────────────────────────────────
private buildWalkableTilesByOffice() {
this.walkableTilesByOffice.clear()
const offices = getOffices()
for (const office of offices) {
const tiles: { x: number; y: number }[] = []
for (const t of this.walkableTiles) {
if (t.x >= office.offsetCol && t.x < office.offsetCol + OFFICE_COLS) {
tiles.push(t)
}
}
this.walkableTilesByOffice.set(office.id, tiles)
}
}
getWalkableTilesForOffice(officeId: string): { x: number; y: number }[] {
return this.walkableTilesByOffice.get(officeId) ?? []
}
resetCameraView(animate = true) {
const cam = this.cameras.main
const { worldW, worldH } = this.getWorldSize()
const minZ = this.getMinCameraZoom(cam)
const fitZoom = Math.min(this.scale.width / worldW, this.scale.height / worldH)
const maxReset = Math.max(1.22, minZ)
const targetZoom = Phaser.Math.Clamp(Math.max(fitZoom * 1.18, 0.62), minZ, maxReset)
const targetX = MAP_COLS * TILE_SIZE / 2
const targetY = (MAP_ROWS * TILE_SIZE - OUTDOOR_MARGIN_TOP + OUTDOOR_MARGIN_BOTTOM) / 2 + TILE_SIZE * 2.6
cam.stopFollow()
if (!animate) {
cam.setZoom(targetZoom)
cam.centerOn(targetX, targetY)
return
}
this.tweens.add({
targets: cam,
zoom: targetZoom,
duration: 260,
ease: 'Cubic.Out',
})
cam.pan(targetX, targetY, 260, 'Cubic.Out')
}
panToOffice(officeId: string) {
const offices = getOffices()
const office = offices.find(o => o.id === officeId)
if (!office) return
const cx = (office.offsetCol + OFFICE_COLS / 2) * TILE_SIZE
const cy = (MAP_ROWS / 2) * TILE_SIZE
const cam = this.cameras.main
const minZ = this.getMinCameraZoom(cam)
const targetZoom = Phaser.Math.Clamp(
Math.min(this.scale.width / ((OFFICE_COLS + 4) * TILE_SIZE), this.scale.height / ((MAP_ROWS - 4) * TILE_SIZE)),
minZ,
Math.max(1.5, minZ),
)
cam.stopFollow()
this.tweens.add({
targets: cam,
zoom: targetZoom,
duration: 380,
ease: 'Cubic.Out',
})
cam.pan(cx, cy, 380, 'Cubic.Out')
}
// ── Agent management ──────────────────────────────────
resolveOfficeForAgent(agentId: string): string {
const stored = getAgentOffice(agentId)
if (stored) return stored.id
const offices = getOffices()
let best: OfficeConfig | null = null
let bestFree = -1
for (const o of offices) {
const deskSeats = getOfficeDeskSeats(o.id)
const assignedCount = o.assignedAgents.length
const free = deskSeats.length - assignedCount
if (free > bestFree) { best = o; bestFree = free }
}
const officeId = best?.id ?? offices[0]?.id ?? 'office-0'
storeAssignAgent(officeId, agentId)
return officeId
}
addAgent(agentId: string, displayName?: string, isSubagent = false, parentAgentId: string | null = null, backendOfficeId?: string, backendPalette?: number, backendDeskId?: string): Agent {
if (this.agents.has(agentId)) return this.agents.get(agentId)!
const name = displayName ?? agentId
const palette = backendPalette ?? (this.agents.size % 6)
// Use backend office_id if provided, otherwise fall back to local resolution
const officeId = backendOfficeId
? (() => { storeAssignAgent(backendOfficeId, agentId); return backendOfficeId })()
: this.resolveOfficeForAgent(agentId)
// Use backend desk_id for precise seat, otherwise find free seat
const seat = backendDeskId
? (this.seats.find(s => s.id === backendDeskId && !s.assigned) ?? this.findFreeSeatInOffice(officeId))
: this.findFreeSeatInOffice(officeId)
const offices = getOffices()
const office = offices.find(o => o.id === officeId)
const fallbackX = (office?.offsetCol ?? 0) + 5
const fallbackY = 20
let startX = fallbackX
let startY = fallbackY
if (seat) {
startX = seat.tileX
startY = seat.tileY
seat.assigned = true
seat.assignedTo = agentId
}
const agent = new Agent(this, agentId, name, palette, startX, startY)
agent.officeId = officeId
agent.setPathfinder(this.pathfinder)
agent.seatId = seat?.id ?? null
agent.isSubagent = isSubagent
agent.parentAgentId = parentAgentId
if (seat) {
agent.setAgentState(AgentState.TYPE)
agent.setDirection(seat.facing)
agent.seatTimer = 10
}
this.agents.set(agentId, agent)
return agent
}
removeAgent(agentId: string) {
const agent = this.agents.get(agentId)
if (!agent) return
if (agent.seatId) {
const seat = this.seats.find(s => s.id === agent.seatId)
if (seat) { seat.assigned = false; seat.assignedTo = null }
}
agent.destroy()
this.agents.delete(agentId)
try {
const { unassignAgent } = require('../map/OfficeStore')
unassignAgent(agentId)
} catch { /* OfficeStore may not be available */ }
}
getAgent(agentId: string): Agent | undefined { return this.agents.get(agentId) }
ensureAgent(agentId: string, displayName?: string, isSubagent = false, parentAgentId: string | null = null, backendOfficeId?: string, backendPalette?: number, backendDeskId?: string): Agent {
const existing = this.agents.get(agentId)
if (existing) {
// If backend specifies a different office, reassign
if (backendOfficeId && existing.officeId !== backendOfficeId) {
this.reassignAgent(agentId, backendOfficeId)
}
// If backend specifies a specific desk, move to it
if (backendDeskId && existing.seatId !== backendDeskId) {
this.changeAgentSeat(agentId, backendDeskId)
}
return existing
}
return this.addAgent(agentId, displayName, isSubagent, parentAgentId, backendOfficeId, backendPalette, backendDeskId)
}
findFreeSeatInOffice(officeId: string): SeatDef | null {
const deskSeat = this.seats.filter(s => s.id.startsWith(`${officeId}-desk-`)).find(s => !s.assigned)
if (deskSeat) return deskSeat
return this.seats.filter(s => s.id.startsWith(`${officeId}-leader-`)).find(s => !s.assigned) ?? null
}
getSeatById(id: string): SeatDef | undefined { return this.seats.find(s => s.id === id) }
reassignAgent(agentId: string, newOfficeId: string) {
const agent = this.agents.get(agentId)
if (!agent) return
if (agent.seatId) {
const oldSeat = this.seats.find(s => s.id === agent.seatId)
if (oldSeat) { oldSeat.assigned = false; oldSeat.assignedTo = null }
}
agent.stopMovement()
storeAssignAgent(newOfficeId, agentId)
agent.officeId = newOfficeId
const newSeat = this.findFreeSeatInOffice(newOfficeId)
const offices = getOffices()
const office = offices.find(o => o.id === newOfficeId)
const fallbackX = (office?.offsetCol ?? 0) + 5
const fallbackY = 20
if (newSeat) {
newSeat.assigned = true
newSeat.assignedTo = agentId
agent.seatId = newSeat.id
agent.setPosition(newSeat.tileX * TILE_SIZE + TILE_SIZE / 2, newSeat.tileY * TILE_SIZE + TILE_SIZE / 2)
agent.setAgentState(AgentState.TYPE)
agent.setDirection(newSeat.facing)
agent.seatTimer = 10
} else {
agent.seatId = null
agent.setPosition(fallbackX * TILE_SIZE + TILE_SIZE / 2, fallbackY * TILE_SIZE + TILE_SIZE / 2)
}
}
getCharacterCards() {
const cards: Array<{
id: string; displayName: string; state: string; currentTool: string | null
isSubagent: boolean; parentAgentId: string | null; taskSummary?: string
lastEventAt: number; officeId: string; seatId: string | null
}> = []
for (const agent of this.agents.values()) {
cards.push({
id: agent.agentId, displayName: agent.displayName, state: agent.agentState,
currentTool: agent.currentTool, isSubagent: agent.isSubagent,
parentAgentId: agent.parentAgentId, taskSummary: agent.taskSummary,
lastEventAt: agent.lastEventAt, officeId: agent.officeId, seatId: agent.seatId,
})
}
return cards
}
changeAgentSeat(agentId: string, newSeatId: string) {
const agent = this.agents.get(agentId)
if (!agent) return
if (agent.seatId) {
const oldSeat = this.seats.find(s => s.id === agent.seatId)
if (oldSeat) { oldSeat.assigned = false; oldSeat.assignedTo = null }
}
const newSeat = this.seats.find(s => s.id === newSeatId)
if (!newSeat || (newSeat.assigned && newSeat.assignedTo !== agentId)) return
newSeat.assigned = true
newSeat.assignedTo = agentId
agent.seatId = newSeatId
agent.stopMovement()
agent.setPosition(newSeat.tileX * TILE_SIZE + TILE_SIZE / 2, newSeat.tileY * TILE_SIZE + TILE_SIZE / 2)
agent.setAgentState(AgentState.TYPE)
agent.setDirection(newSeat.facing)
agent.seatTimer = 10
}
rebuildOfficeCollision(officeId: string, mapStr: string[], seatCoords: [number, number][]) {
updateOfficeMap(officeId, mapStr, seatCoords)
if (this.mapData.wallBodies) {
this.mapData.wallBodies.clear(true, true)
}
const offices = getOffices()
const grid = buildCompositeGrid(offices)
const wallBodies = OfficeMapBuilder.buildWallBodies(this, grid)
this.mapData = { ...this.mapData, wallBodies, collisionGrid: grid }
this.pathfinder = new PathfindingManager(grid)
this.walkableTiles = this.pathfinder.getWalkableTiles()
this.buildWalkableTilesByOffice()
reloadZones(offices)
this.seats = getAllSeats().map(s => ({ ...s }))
for (const agent of this.agents.values()) {
agent.setPathfinder(this.pathfinder)
agent.stopMovement()
}
console.log('[OfficeScene] Office collision rebuilt —', officeId, 'walkable:', this.walkableTiles.length)
}
renameOffice(officeId: string, newName: string) {
storeRenameOffice(officeId, newName)
this.mapBuilder.updateLabel(officeId, newName)
}
/** Matches `setBounds` in create(); used for zoom floor so the viewport never extends past the world. */
private checkOutdoorDayNight() {
this.applyOutdoorLighting(isLocalDaytime())
}
/** Call after changing URL or `opc_outdoor_override` in localStorage (via GameBridge). */
syncOutdoorLighting() {
this.applyOutdoorLighting(isLocalDaytime())
}
private applyOutdoorLighting(next: boolean) {
if (next === this.outdoorIsDay) return
this.outdoorIsDay = next
this.cameras.main.setBackgroundColor(next ? SCENE_CLEAR_DAY : SCENE_CLEAR_NIGHT)
this.mapBuilder.refreshOutdoorDayNight(this, next)
}
private getWorldSize() {
const mapW = MAP_COLS * TILE_SIZE
const mapH = MAP_ROWS * TILE_SIZE
return {
worldW: mapW + OUTDOOR_MARGIN_X * 2,
worldH: mapH + OUTDOOR_MARGIN_TOP + OUTDOOR_MARGIN_BOTTOM,
}
}
/** Minimum zoom so `displayWidth/Height` never exceeds world bounds (avoids empty margin past the map). */
private getMinCameraZoom(cam: Phaser.Cameras.Scene2D.Camera) {
const { worldW, worldH } = this.getWorldSize()
return Math.max(cam.width / worldW, cam.height / worldH)
}
}
@@ -0,0 +1,548 @@
import { AgentState, Direction } from '../types'
import {
CELEBRATE_DURATION, COFFEE_DURATION_MIN, COFFEE_DURATION_MAX,
CHAT_DURATION_MIN, CHAT_DURATION_MAX,
WANDER_PAUSE_MIN, WANDER_PAUSE_MAX,
WANDER_MOVES_BEFORE_REST_MIN, WANDER_MOVES_BEFORE_REST_MAX,
SEAT_REST_MIN, SEAT_REST_MAX,
INACTIVE_SEAT_TIMER_MIN, INACTIVE_SEAT_TIMER_RANGE,
TILE_SIZE,
} from '../config'
import type { OfficeScene } from '../scenes/OfficeScene'
import type { Agent } from '../entities/Agent'
import { ZONES, randomTileInZone, getOfficeLobbyDoorways } from '../map/InteractionZones'
import type { VisualEvent } from '../../types/visual'
function randomRange(min: number, max: number) { return min + Math.random() * (max - min) }
function randomInt(min: number, max: number) { return Math.floor(randomRange(min, max + 1)) }
function trimPreview(s: string, maxLen: number) { return s.length > maxLen ? s.slice(0, maxLen) + '...' : s }
const READING_TOOLS = new Set([
'read', 'read_file', 'browse', 'list', 'list_dir',
'glob', 'grep', 'search', 'fetch', 'web_fetch',
'webfetch', 'web_search', 'websearch',
])
function mapToolToState(toolName: string | null): AgentState {
if (!toolName) return AgentState.TYPE
const t = toolName.toLowerCase().trim()
if (t === 'reflect') return AgentState.REFLECT
if (t === 'practice') return AgentState.PRACTICE
if (t === 'synthesize') return AgentState.PRESENT
if (READING_TOOLS.has(t)) return AgentState.THINK
return AgentState.TYPE
}
function mapToolDisplay(toolName: string | null): string {
if (!toolName) return 'Tool'
const t = toolName.toLowerCase().trim()
if (t === 'shell') return 'Shell'
if (t === 'write_file' || t === 'write') return 'Write'
if (t === 'edit_file' || t === 'edit') return 'Edit'
if (t === 'read_file' || t === 'read') return 'Read'
if (t === 'grep' || t === 'search') return 'Search'
if (t === 'web_search' || t === 'websearch') return 'WebSearch'
if (t === 'web_fetch' || t === 'webfetch') return 'Fetch'
return toolName.slice(0, 12)
}
export class BehaviorController {
private scene: OfficeScene
private pendingDespawn = new Set<string>()
constructor(scene: OfficeScene) {
this.scene = scene
}
// ── Event dispatch ────────────────────────────────────
applyEvent(evt: VisualEvent) {
const data = evt.data ?? {}
const agentId = evt.agent_id || 'openopc-main'
if (agentId === 'user') return
const isSub = agentId.startsWith('subagent-')
const parentId = isSub
? (typeof data.parent_agent_id === 'string' ? data.parent_agent_id : 'openopc-main')
: null
const displayName = isSub ? `Sub ${agentId.slice(-4)}` : undefined
const agent = this.scene.ensureAgent(agentId, displayName, isSub, parentId)
agent.lastEventAt = Date.now()
if (typeof data.task_preview === 'string') agent.taskSummary = data.task_preview
if (typeof data.result_preview === 'string') agent.taskSummary = data.result_preview
switch (evt.type) {
case 'tool_start': this.onToolStart(agent, data); break
case 'tool_done': this.onToolDone(agent, data); break
case 'agent_active': this.onAgentActive(agent); break
case 'waiting': this.onWaiting(agent); break
case 'reflect_start': this.onReflectStart(agent); break
case 'reflect_done': this.onReflectDone(agent); break
case 'skill_synthesized': this.onSkillSynthesized(agent, data); break
case 'subagent_spawn': this.onSubagentSpawn(agent, agentId, parentId); break
case 'subagent_done': this.onSubagentDone(agent, agentId); break
case 'message_in': this.onMessageIn(agent, data); break
case 'message_out': this.onMessageOut(agent, data); break
case 'practice_start': this.onPracticeStart(agent, data); break
case 'practice_done': this.onPracticeDone(agent); break
case 'task_routed': this.onTaskRouted(agent, data); break
case 'task_delegated': this.onTaskDelegated(agent, agentId, data); break
case 'delegation_done': this.onDelegationDone(agent, data); break
case 'agent_spawned': this.onAgentSpawned(agent, agentId, data); break
case 'agent_removed': this.onAgentRemoved(agent, agentId); break
case 'collab_started': this.onCollabStarted(agent); break
case 'collab_ended': this.onCollabEnded(agent); break
case 'skill_published': this.onSkillPublished(agent, data); break
case 'skill_adopted': this.onSkillAdopted(agent, data); break
case 'mycelium_transport': this.onMyceliumTransport(agent, agentId, data); break
case 'mycelium_crystallize': this.onMyceliumCrystallize(agent, agentId, data); break
case 'mycelium_spore': this.onMyceliumSpore(agent, agentId, data); break
case 'mycelium_decompose': this.onMyceliumDecompose(agent, agentId, data); break
case 'mycelium_unit_created': this.onMyceliumUnitCreated(agent, data); break
case 'mycelium_germinate': this.onMyceliumGerminate(agent, agentId, data); break
case 'hyphal_strengthen': this.onHyphalStrengthen(agentId, data); break
case 'hyphal_weaken': this.onHyphalWeaken(agentId, data); break
}
}
// ── Individual event handlers ─────────────────────────
private onToolStart(agent: Agent, data: Record<string, unknown>) {
const toolName = typeof data.tool_name === 'string' ? data.tool_name : 'tool'
const label = mapToolDisplay(toolName)
agent.urgency = 'urgent'
agent.currentTool = toolName
agent.isActive = true
agent.showBubble(`${label}...`)
this.sendToSeat(agent)
}
private onToolDone(agent: Agent, data: Record<string, unknown>) {
const toolName = typeof data.tool_name === 'string' ? data.tool_name : agent.currentTool
const label = mapToolDisplay(toolName)
agent.currentTool = null
agent.setAgentState(AgentState.CELEBRATE)
agent.stateTimer = CELEBRATE_DURATION
agent.isActive = false
agent.showBubble(`${label} done`)
}
private onAgentActive(agent: Agent) {
agent.urgency = 'urgent'
agent.isActive = true
if (!agent.currentTool) {
this.sendToSeat(agent)
}
}
private onWaiting(agent: Agent) {
agent.urgency = 'relaxed'
agent.isActive = false
agent.currentTool = null
agent.showBubble('Waiting')
const moved = this.moveToZone(agent, 'breakRoom', AgentState.COFFEE)
if (!moved) agent.setAgentState(AgentState.IDLE)
if (moved) agent.stateTimer = randomRange(COFFEE_DURATION_MIN, COFFEE_DURATION_MAX)
}
private onReflectStart(agent: Agent) {
agent.urgency = 'normal'
agent.isActive = false
agent.currentTool = 'Reflect'
const moved = this.moveToZone(agent, 'meetingRoom', AgentState.REFLECT)
if (!moved) agent.setAgentState(AgentState.REFLECT)
agent.stateTimer = 30.0
agent.showBubble('Reflecting...')
}
private onReflectDone(agent: Agent) {
agent.currentTool = null
agent.setAgentState(AgentState.CELEBRATE)
agent.stateTimer = CELEBRATE_DURATION
agent.showBubble('Insight!')
}
private onSkillSynthesized(agent: Agent, data: Record<string, unknown>) {
const name = trimPreview(String(data.skill_name ?? 'new'), 20)
agent.setAgentState(AgentState.PRESENT)
agent.showBubble(`New Skill: ${name}`)
}
private onSubagentSpawn(agent: Agent, agentId: string, parentId: string | null) {
this.placeAtDoorway(agent)
agent.urgency = 'urgent'
agent.isActive = true
agent.showBubble('Spawned')
this.sendToSeat(agent)
}
private onSubagentDone(agent: Agent, agentId: string) {
agent.urgency = 'relaxed'
agent.currentTool = null
agent.isActive = false
agent.showBubble('Finished')
const moved = this.moveToDoorway(agent)
if (moved) {
this.pendingDespawn.add(agentId)
} else {
this.scene.removeAgent(agentId)
}
}
private onMessageIn(agent: Agent, data: Record<string, unknown>) {
agent.urgency = 'urgent'
const content = typeof data.content_preview === 'string' ? data.content_preview : ''
if (content) agent.taskSummary = content
const preview = content ? trimPreview(content, 30) : 'New task'
agent.showBubble(preview)
agent.isActive = true
this.sendToSeat(agent)
}
private onMessageOut(agent: Agent, data: Record<string, unknown>) {
const content = typeof data.content_preview === 'string' ? data.content_preview : ''
if (content) agent.taskSummary = content
const preview = content ? trimPreview(content, 30) : 'Reply sent'
agent.showBubble(`Reply: ${preview}`)
}
private onPracticeStart(agent: Agent, data: Record<string, unknown>) {
agent.urgency = 'normal'
const domain = typeof data.target_domain === 'string' ? data.target_domain : 'Practice'
agent.showBubble(`Practicing: ${trimPreview(domain, 20)}`)
agent.isActive = false
agent.currentTool = 'Practice'
const moved = this.moveToZone(agent, 'meetingRoom', AgentState.PRACTICE)
if (!moved) agent.setAgentState(AgentState.PRACTICE)
}
private onPracticeDone(agent: Agent) {
agent.currentTool = null
agent.setAgentState(AgentState.CELEBRATE)
agent.stateTimer = CELEBRATE_DURATION
agent.showBubble('Practice done!')
}
private onTaskRouted(agent: Agent, data: Record<string, unknown>) {
agent.urgency = 'urgent'
const method = typeof data.method === 'string' ? data.method : 'auto'
agent.isActive = true
this.sendToSeat(agent)
agent.showBubble(`Assigned (${method})`)
}
private onTaskDelegated(agent: Agent, agentId: string, data: Record<string, unknown>) {
agent.urgency = 'normal'
agent.stateTimer = randomRange(CHAT_DURATION_MIN, CHAT_DURATION_MAX)
const target = typeof data.target === 'string' ? data.target : '?'
agent.setAgentState(AgentState.CHAT)
agent.showBubble(`Delegating to ${target}...`)
const targetAgent = this.scene.getAgent(target)
if (targetAgent) {
targetAgent.parentAgentId = agentId
targetAgent.showBubble('Receiving task...')
}
}
private onDelegationDone(agent: Agent, data: Record<string, unknown>) {
const target = typeof data.target === 'string' ? data.target : ''
agent.setAgentState(AgentState.IDLE)
agent.showBubble('Delegation complete')
if (target) {
const targetAgent = this.scene.getAgent(target)
if (targetAgent) targetAgent.parentAgentId = null
}
}
private onAgentSpawned(agent: Agent, agentId: string, data: Record<string, unknown>) {
const roleName = typeof data.role_name === 'string' ? data.role_name : agentId
agent.displayName = roleName
this.placeAtDoorway(agent)
this.sendToSeat(agent)
agent.showBubble(`${roleName} joined`)
}
private onAgentRemoved(agent: Agent, agentId: string) {
agent.showBubble('Leaving...')
const moved = this.moveToDoorway(agent)
if (!moved) {
this.scene.removeAgent(agentId)
} else {
this.pendingDespawn.add(agentId)
}
}
private onCollabStarted(agent: Agent) {
agent.urgency = 'normal'
agent.isActive = false
agent.stateTimer = randomRange(CHAT_DURATION_MIN, CHAT_DURATION_MAX)
this.moveToZone(agent, 'meetingRoom', AgentState.CHAT)
agent.showBubble('Collaborating...')
}
private onCollabEnded(agent: Agent) {
agent.stateTimer = CELEBRATE_DURATION
agent.setAgentState(AgentState.CELEBRATE)
agent.showBubble('Collaboration done!')
}
private onSkillPublished(agent: Agent, data: Record<string, unknown>) {
const name = trimPreview(String(data.skill_name ?? 'skill'), 20)
agent.setAgentState(AgentState.CELEBRATE)
agent.stateTimer = CELEBRATE_DURATION
agent.showBubble(`Published: ${name}`)
}
private onSkillAdopted(agent: Agent, data: Record<string, unknown>) {
const name = trimPreview(String(data.skill_name ?? 'skill'), 20)
agent.showBubble(`Adopted: ${name}`)
}
// ── Mycelium events ───────────────────────────────────
private onMyceliumTransport(agent: Agent, agentId: string, data: Record<string, unknown>) {
const source = typeof data.source === 'string' ? data.source : agentId
const target = typeof data.target === 'string' ? data.target : null
const domain = typeof data.domain === 'string' ? trimPreview(data.domain, 15) : '?'
const srcAgent = this.scene.ensureAgent(source)
srcAgent.myceliumEffect = 'transport_send'
srcAgent.myceliumEffectTimer = 3.0
srcAgent.showBubble(`-> [${domain}]`)
if (target) {
const tgtAgent = this.scene.ensureAgent(target)
tgtAgent.myceliumEffect = 'transport_recv'
tgtAgent.myceliumEffectTimer = 2.0
tgtAgent.showBubble('Receiving...')
const tgtPos = tgtAgent.getTilePos()
srcAgent.walkTo(tgtPos.x, tgtPos.y + 1)
}
}
private onMyceliumCrystallize(agent: Agent, agentId: string, data: Record<string, unknown>) {
const corrobAgents: string[] = Array.isArray(data.corroborating_agents)
? data.corroborating_agents : []
const contentPreview = trimPreview(String(data.content_preview ?? 'Knowledge'), 22)
const allParticipants = Array.from(new Set([agentId, ...corrobAgents]))
const meetingZoneKey = `${agent.officeId}-meetingRoom`
const meetingSeats = ZONES[meetingZoneKey]?.seats ?? []
for (let i = 0; i < allParticipants.length; i++) {
const pid = allParticipants[i]
const pAgent = this.scene.ensureAgent(pid)
pAgent.myceliumEffect = 'crystal'
pAgent.myceliumEffectTimer = 15.0
pAgent.isActive = false
const seatIdx = i % meetingSeats.length
const seat = meetingSeats[seatIdx]
pAgent.walkTo(seat.tileX, seat.tileY, () => {
pAgent.setDirection(seat.facing)
pAgent.setAgentState(AgentState.CHAT)
})
pAgent.showBubble(pid === agentId ? `Crystal: ${contentPreview}` : 'Crystal!')
}
}
private onMyceliumSpore(agent: Agent, agentId: string, data: Record<string, unknown>) {
const preview = trimPreview(String(data.content_preview ?? 'Breakthrough'), 20)
agent.myceliumEffect = 'spore_send'
agent.myceliumEffectTimer = 4.0
agent.setAgentState(AgentState.PRESENT)
agent.showBubble(`Breakthrough! ${preview}`)
// Move to break room for broadcast
this.moveToZone(agent, 'breakRoom')
}
private onMyceliumDecompose(agent: Agent, agentId: string, data: Record<string, unknown>) {
const preview = trimPreview(String(data.humus_preview ?? 'Lesson learned'), 22)
agent.myceliumEffect = 'decompose'
agent.myceliumEffectTimer = 4.0
agent.setAgentState(AgentState.REFLECT)
agent.showBubble(`Learning: ${preview}`)
this.moveToZone(agent, 'meetingRoom')
}
private onMyceliumUnitCreated(agent: Agent, data: Record<string, unknown>) {
const nutrientType = String(data.nutrient_type ?? 'insight').slice(0, 1).toUpperCase()
agent.showBubble(`[${nutrientType}]...`, 2.0)
}
private onMyceliumGerminate(agent: Agent, agentId: string, data: Record<string, unknown>) {
const targetAgent = typeof data.target_agent === 'string' ? data.target_agent : agentId
const tgt = this.scene.ensureAgent(targetAgent)
tgt.myceliumEffect = null
tgt.setAgentState(AgentState.CELEBRATE)
tgt.stateTimer = CELEBRATE_DURATION
tgt.showBubble('Insight took root!')
}
private onHyphalStrengthen(_agentId: string, _data: Record<string, unknown>) {
// Visual connection tracking could be added here
}
private onHyphalWeaken(_agentId: string, _data: Record<string, unknown>) {
// Visual connection tracking could be added here
}
// ── Idle behavior (called each frame) ─────────────────
updateIdle(dt: number) {
for (const agent of this.scene.agents.values()) {
// Check pending despawn
if (this.pendingDespawn.has(agent.agentId) && !agent.isMoving) {
this.pendingDespawn.delete(agent.agentId)
this.scene.removeAgent(agent.agentId)
continue
}
// Mycelium effect timer
if (agent.myceliumEffectTimer > 0) {
agent.myceliumEffectTimer -= dt
if (agent.myceliumEffectTimer <= 0) {
agent.myceliumEffect = null
agent.myceliumEffectTimer = 0
agent.myceliumSession = null
}
}
// Non-active agents sitting at desk: count down seatTimer then transition to IDLE
if (agent.agentState === AgentState.TYPE && !agent.isActive) {
if (agent.seatTimer > 0) {
agent.seatTimer -= dt
if (agent.seatTimer <= 0) {
agent.seatTimer = 0
agent.setAgentState(AgentState.IDLE)
agent.wanderCount = 0
agent.wanderLimit = randomInt(WANDER_MOVES_BEFORE_REST_MIN, WANDER_MOVES_BEFORE_REST_MAX)
agent.wanderTimer = randomRange(WANDER_PAUSE_MIN, WANDER_PAUSE_MAX)
}
} else {
agent.setAgentState(AgentState.IDLE)
}
continue
}
if (agent.agentState !== AgentState.IDLE) continue
// Active agents should go to seat
if (agent.isActive) {
agent.urgency = 'urgent'
if (!agent.seatId) {
agent.setAgentState(AgentState.TYPE)
continue
}
this.sendToSeat(agent)
continue
}
// Idle wander logic
agent.wanderTimer -= dt
if (agent.wanderTimer <= 0) {
// Wander limit reached => go back to seat and rest
if (agent.wanderCount >= agent.wanderLimit && agent.seatId) {
agent.urgency = 'relaxed'
this.sendToSeat(agent)
agent.wanderTimer = randomRange(WANDER_PAUSE_MIN, WANDER_PAUSE_MAX)
continue
}
// Random wander (confined to agent's office)
const tiles = this.scene.getWalkableTilesForOffice(agent.officeId)
if (tiles.length > 0) {
agent.urgency = 'relaxed'
const target = tiles[Math.floor(Math.random() * tiles.length)]
agent.walkTo(target.x, target.y)
agent.wanderCount++
}
agent.wanderTimer = randomRange(WANDER_PAUSE_MIN, WANDER_PAUSE_MAX)
}
}
}
// ── Movement helpers ──────────────────────────────────
sendToSeat(agent: Agent) {
if (!agent.seatId) return
const seat = this.scene.getSeatById(agent.seatId)
if (!seat) return
agent.walkTo(seat.tileX, seat.tileY, () => {
agent.setAgentState(AgentState.TYPE)
agent.setDirection(seat.facing)
if (!agent.isActive) {
agent.seatTimer = INACTIVE_SEAT_TIMER_MIN + Math.random() * INACTIVE_SEAT_TIMER_RANGE
}
}).then(moved => {
if (!moved) {
agent.setAgentState(AgentState.TYPE)
agent.setDirection(seat.facing)
if (!agent.isActive) {
agent.seatTimer = INACTIVE_SEAT_TIMER_MIN + Math.random() * INACTIVE_SEAT_TIMER_RANGE
}
}
})
}
moveToZone(agent: Agent, zoneName: string, arrivalState?: string): boolean {
const zoneKey = `${agent.officeId}-${zoneName}`
const zone = ZONES[zoneKey]
if (!zone) return false
if (zone.seats.length > 0) {
const occupiedTiles = new Set<string>()
for (const other of this.scene.agents.values()) {
if (other === agent) continue
if (other.isMoving) continue
const pos = other.getTilePos()
occupiedTiles.add(`${pos.x},${pos.y}`)
}
for (const other of this.scene.agents.values()) {
if (other === agent) continue
if (!other.isMoving) continue
const path = (other as any).currentPath as { x: number; y: number }[]
if (path?.length) {
const dest = path[path.length - 1]
occupiedTiles.add(`${dest.x},${dest.y}`)
}
}
const freeSeat = zone.seats.find(s => !occupiedTiles.has(`${s.tileX},${s.tileY}`))
if (freeSeat) {
agent.walkTo(freeSeat.tileX, freeSeat.tileY, () => {
agent.setDirection(freeSeat.facing)
if (arrivalState) agent.setAgentState(arrivalState as any)
else agent.setAgentState(AgentState.IDLE)
})
return true
}
}
const pos = randomTileInZone(zoneKey)
if (!pos) return false
agent.walkTo(pos.x, pos.y, () => {
if (arrivalState) agent.setAgentState(arrivalState as any)
else agent.setAgentState(AgentState.IDLE)
})
return true
}
moveToDoorway(agent: Agent): boolean {
const doorways = getOfficeLobbyDoorways(agent.officeId)
if (doorways.length === 0) return false
const target = doorways[Math.floor(Math.random() * doorways.length)]
agent.walkTo(target.x, target.y)
return true
}
placeAtDoorway(agent: Agent) {
const doorways = getOfficeLobbyDoorways(agent.officeId)
if (doorways.length > 0) {
const d = doorways[Math.floor(Math.random() * doorways.length)]
agent.setPosition(d.x * TILE_SIZE + TILE_SIZE / 2, d.y * TILE_SIZE + TILE_SIZE / 2)
}
}
}
@@ -0,0 +1,97 @@
import * as EasyStar from 'easystarjs'
export class PathfindingManager {
private easystar: EasyStar.js
private grid: number[][]
private gridCols: number
private gridRows: number
constructor(collisionGrid: number[][]) {
this.grid = collisionGrid.map(row => [...row])
this.gridRows = this.grid.length
this.gridCols = this.gridRows > 0 ? this.grid[0].length : 0
this.easystar = new EasyStar.js()
this.easystar.setGrid(this.grid)
this.easystar.setAcceptableTiles([0])
this.easystar.setIterationsPerCalculation(800)
}
findPath(
from: { x: number; y: number },
to: { x: number; y: number },
): Promise<{ x: number; y: number }[]> {
return new Promise((resolve) => {
if (
from.x < 0 || from.x >= this.gridCols ||
from.y < 0 || from.y >= this.gridRows ||
to.x < 0 || to.x >= this.gridCols ||
to.y < 0 || to.y >= this.gridRows
) {
resolve([])
return
}
if (this.grid[to.y]?.[to.x] === 1) {
const alt = this.findNearestWalkable(to.x, to.y)
if (!alt) { resolve([]); return }
to = alt
}
if (this.grid[from.y]?.[from.x] === 1) {
const alt = this.findNearestWalkable(from.x, from.y)
if (!alt) { resolve([]); return }
from = alt
}
this.easystar.findPath(from.x, from.y, to.x, to.y, (path) => {
resolve(path ?? [])
})
this.easystar.calculate()
})
}
blockTile(x: number, y: number) {
if (y >= 0 && y < this.grid.length && x >= 0 && x < this.grid[0].length) {
this.grid[y][x] = 1
this.easystar.setGrid(this.grid)
}
}
unblockTile(x: number, y: number) {
if (y >= 0 && y < this.grid.length && x >= 0 && x < this.grid[0].length) {
this.grid[y][x] = 0
this.easystar.setGrid(this.grid)
}
}
isWalkable(x: number, y: number): boolean {
if (y < 0 || y >= this.grid.length || x < 0 || x < 0 || x >= this.grid[0].length) return false
return this.grid[y][x] === 0
}
getWalkableTiles(): { x: number; y: number }[] {
const tiles: { x: number; y: number }[] = []
for (let r = 0; r < this.grid.length; r++) {
for (let c = 0; c < this.grid[r].length; c++) {
if (this.grid[r][c] === 0) {
tiles.push({ x: c, y: r })
}
}
}
return tiles
}
private findNearestWalkable(x: number, y: number): { x: number; y: number } | null {
for (let radius = 1; radius <= 5; radius++) {
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (Math.abs(dx) !== radius && Math.abs(dy) !== radius) continue
const nx = x + dx
const ny = y + dy
if (this.isWalkable(nx, ny)) return { x: nx, y: ny }
}
}
}
return null
}
}
@@ -0,0 +1,361 @@
/**
* Agent event test runner — multi-office edition.
* Attach to window so it can be called from browser console:
* window.__runEventTests()
*/
import type { GameBridge } from '../GameBridge'
import type { VisualEvent } from '../../types/visual'
import { ZONES } from '../map/InteractionZones'
import { getOffices } from '../map/OfficeStore'
let _eventId = 0
function makeEvent(type: string, agentId: string, data: Record<string, unknown> = {}): VisualEvent {
return {
event_id: `test-${++_eventId}`,
type,
agent_id: agentId,
data,
timestamp: Date.now() / 1000,
}
}
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms))
}
interface TestResult {
name: string
pass: boolean
detail: string
}
export async function runAllTests(bridge: GameBridge): Promise<TestResult[]> {
const results: TestResult[] = []
const scene = bridge.getScene()
if (!scene) {
results.push({ name: 'scene-ready', pass: false, detail: 'OfficeScene not initialized' })
return results
}
console.log('%c[EventTest] Starting full event test suite (multi-office)...', 'color: #6366f1; font-weight: bold')
for (const id of [...scene.agents.keys()]) scene.removeAgent(id)
await sleep(100)
const offices = getOffices()
// ── Test 1: Office data ──
results.push({
name: 'offices-loaded',
pass: offices.length >= 3,
detail: `${offices.length} offices loaded: ${offices.map(o => o.name).join(', ')}`,
})
// ── Test 2: Agents assigned to different offices ──
const agentIds = ['agent-A', 'agent-B', 'agent-C', 'agent-D', 'agent-E', 'agent-F']
for (const id of agentIds) {
bridge.pushEvent(makeEvent('agent_active', id))
}
await sleep(300)
const assignedSeats = new Set<string>()
let deskOverlap = false
for (const id of agentIds) {
const agent = scene.getAgent(id)
if (!agent) continue
if (agent.seatId) {
if (assignedSeats.has(agent.seatId)) deskOverlap = true
assignedSeats.add(agent.seatId)
}
}
results.push({
name: 'desk-assignment-unique',
pass: !deskOverlap && assignedSeats.size === agentIds.length,
detail: `Assigned ${assignedSeats.size} unique seats to ${agentIds.length} agents. IDs: [${[...assignedSeats].join(', ')}]`,
})
// ── Test 3: All agents have officeId set ──
const allHaveOffice = agentIds.every(id => {
const agent = scene.getAgent(id)
return agent?.officeId != null
})
results.push({
name: 'agents-have-officeId',
pass: allHaveOffice,
detail: agentIds.map(id => `${id}=${scene.getAgent(id)?.officeId}`).join(', '),
})
// ── Test 4: tool_start → active ──
bridge.pushEvent(makeEvent('tool_start', 'agent-A', { tool_name: 'shell' }))
await sleep(200)
const agA = scene.getAgent('agent-A')!
results.push({
name: 'tool_start-state',
pass: agA.isActive === true && agA.currentTool === 'shell',
detail: `isActive=${agA.isActive}, currentTool=${agA.currentTool}`,
})
// ── Test 5: tool_done → celebrate ──
bridge.pushEvent(makeEvent('tool_done', 'agent-A', { tool_name: 'shell' }))
await sleep(100)
results.push({
name: 'tool_done-celebrate',
pass: agA.agentState === 'celebrate',
detail: `state=${agA.agentState}`,
})
for (let i = 0; i < 20; i++) {
if (agA.agentState !== 'celebrate') break
await sleep(500)
}
results.push({
name: 'celebrate-to-idle',
pass: agA.agentState !== 'celebrate',
detail: `state=${agA.agentState}`,
})
// ── Test 6: waiting → break room ──
bridge.pushEvent(makeEvent('waiting', 'agent-B'))
await sleep(200)
const agB = scene.getAgent('agent-B')!
results.push({
name: 'waiting-state',
pass: agB.isActive === false && (agB.agentState === 'walk' || agB.agentState === 'idle' || agB.agentState === 'coffee'),
detail: `isActive=${agB.isActive}, state=${agB.agentState}`,
})
// ── Test 7: reflect_start → meeting room ──
bridge.pushEvent(makeEvent('reflect_start', 'agent-C'))
await sleep(200)
const agC = scene.getAgent('agent-C')!
results.push({
name: 'reflect_start-state',
pass: agC.currentTool === 'Reflect' && (agC.agentState === 'walk' || agC.agentState === 'reflect'),
detail: `currentTool=${agC.currentTool}, state=${agC.agentState}`,
})
bridge.pushEvent(makeEvent('reflect_done', 'agent-C'))
await sleep(100)
results.push({
name: 'reflect_done-celebrate',
pass: agC.agentState === 'celebrate',
detail: `state=${agC.agentState}`,
})
// ── Test 8: collab in meeting room (same office) ──
const collabAgents = ['agent-A', 'agent-B', 'agent-C', 'agent-D']
for (const id of collabAgents) {
bridge.pushEvent(makeEvent('collab_started', id))
}
await sleep(4000)
const meetingPositions = new Map<string, string>()
let meetingOverlap = false
for (const id of collabAgents) {
const agent = scene.getAgent(id)
if (!agent) continue
const pos = agent.getTilePos()
const key = `${pos.x},${pos.y}`
if (meetingPositions.has(key)) meetingOverlap = true
meetingPositions.set(key, id)
}
results.push({
name: 'collab-no-overlap',
pass: !meetingOverlap,
detail: `${collabAgents.length} agents, ${meetingPositions.size} unique positions`,
})
for (const id of collabAgents) bridge.pushEvent(makeEvent('collab_ended', id))
await sleep(100)
const allCelebrate = collabAgents.every(id => scene.getAgent(id)?.agentState === 'celebrate')
results.push({
name: 'collab_ended-celebrate',
pass: allCelebrate,
detail: collabAgents.map(id => `${id}=${scene.getAgent(id)?.agentState}`).join(', '),
})
await sleep(3000)
// ── Test 9: practice ──
bridge.pushEvent(makeEvent('practice_start', 'agent-E', { target_domain: 'TypeScript' }))
await sleep(200)
const agE = scene.getAgent('agent-E')!
results.push({
name: 'practice_start-state',
pass: agE.currentTool === 'Practice' && (agE.agentState === 'walk' || agE.agentState === 'practice'),
detail: `currentTool=${agE.currentTool}, state=${agE.agentState}`,
})
bridge.pushEvent(makeEvent('practice_done', 'agent-E'))
await sleep(100)
results.push({
name: 'practice_done-celebrate',
pass: agE.agentState === 'celebrate',
detail: `state=${agE.agentState}`,
})
await sleep(3000)
// ── Test 10: task_delegated ──
bridge.pushEvent(makeEvent('task_delegated', 'agent-A', { target: 'agent-B' }))
await sleep(100)
results.push({
name: 'task_delegated-chat',
pass: agA.agentState === 'chat',
detail: `state=${agA.agentState}`,
})
bridge.pushEvent(makeEvent('delegation_done', 'agent-A', { target: 'agent-B' }))
await sleep(300)
results.push({
name: 'delegation_done-return',
pass: ['idle', 'walk', 'type'].includes(agA.agentState),
detail: `state=${agA.agentState}`,
})
// ── Test 11: message ──
const agF = scene.getAgent('agent-F')!
bridge.pushEvent(makeEvent('message_in', 'agent-F', { content_preview: 'Hello test' }))
await sleep(200)
results.push({
name: 'message_in-active',
pass: agF.isActive === true,
detail: `isActive=${agF.isActive}, state=${agF.agentState}`,
})
bridge.pushEvent(makeEvent('message_out', 'agent-F', { content_preview: 'Reply test' }))
await sleep(100)
results.push({
name: 'message_out-bubble',
pass: agF.bubbleText?.includes('Reply') === true,
detail: `bubble="${agF.bubbleText}"`,
})
// ── Test 12: subagent ──
bridge.pushEvent(makeEvent('subagent_spawn', 'subagent-test-1', { parent_agent_id: 'agent-A' }))
await sleep(200)
const sub1 = scene.getAgent('subagent-test-1')
results.push({
name: 'subagent_spawn-created',
pass: sub1 != null && sub1.isSubagent === true,
detail: `created=${!!sub1}, isSubagent=${sub1?.isSubagent}`,
})
bridge.pushEvent(makeEvent('subagent_done', 'subagent-test-1'))
await sleep(5000)
results.push({
name: 'subagent_done-removed',
pass: scene.getAgent('subagent-test-1') == null,
detail: `still exists=${!!scene.getAgent('subagent-test-1')}`,
})
// ── Test 13: task_routed ──
bridge.pushEvent(makeEvent('task_routed', 'agent-E', { method: 'auto' }))
await sleep(200)
results.push({
name: 'task_routed-active',
pass: agE.isActive === true,
detail: `isActive=${agE.isActive}, state=${agE.agentState}`,
})
// ── Test 14: agent_removed ──
bridge.pushEvent(makeEvent('agent_removed', 'agent-F'))
await sleep(5000)
results.push({
name: 'agent_removed-destroyed',
pass: scene.getAgent('agent-F') == null,
detail: `still exists=${!!scene.getAgent('agent-F')}`,
})
// ── Test 15: crystallize ──
bridge.pushEvent(makeEvent('mycelium_crystallize', 'agent-A', {
corroborating_agents: ['agent-B', 'agent-C'],
content_preview: 'Shared knowledge',
}))
await sleep(4000)
const crystalPositions = new Map<string, string>()
let crystalOverlap = false
for (const id of ['agent-A', 'agent-B', 'agent-C']) {
const agent = scene.getAgent(id)
if (!agent) continue
const pos = agent.getTilePos()
const key = `${pos.x},${pos.y}`
if (crystalPositions.has(key)) crystalOverlap = true
crystalPositions.set(key, id)
}
results.push({
name: 'crystal-no-overlap',
pass: !crystalOverlap,
detail: `3 agents, ${crystalPositions.size} unique positions`,
})
// ── Test 16: desk seat uniqueness ──
const deskSeatMap = new Map<string, string>()
let deskConflict = false
for (const agent of scene.agents.values()) {
if (!agent.seatId) continue
if (deskSeatMap.has(agent.seatId)) deskConflict = true
deskSeatMap.set(agent.seatId, agent.agentId)
}
results.push({
name: 'desk-seat-no-conflict',
pass: !deskConflict,
detail: `${deskSeatMap.size} desk seats assigned, conflict=${deskConflict}`,
})
// ── Test 17: reassign agent across offices ──
const agD = scene.getAgent('agent-D')
if (agD) {
const oldOfficeId = agD.officeId
const targetOffice = offices.find(o => o.id !== oldOfficeId) ?? offices[1]
bridge.assignAgentToOffice('agent-D', targetOffice.id)
await sleep(300)
const newOffice = agD.officeId
results.push({
name: 'reassign-office',
pass: newOffice === targetOffice.id && newOffice !== oldOfficeId,
detail: `${oldOfficeId}${newOffice} (expected ${targetOffice.id})`,
})
} else {
results.push({ name: 'reassign-office', pass: false, detail: 'agent-D not found' })
}
// ── Test 18: cross-office seat uniqueness after reassign ──
const seatMap2 = new Map<string, string>()
let conflict2 = false
for (const agent of scene.agents.values()) {
if (!agent.seatId) continue
if (seatMap2.has(agent.seatId)) conflict2 = true
seatMap2.set(agent.seatId, agent.agentId)
}
results.push({
name: 'cross-office-seat-unique',
pass: !conflict2,
detail: `${seatMap2.size} seats, conflict=${conflict2}`,
})
// ── Summary ──
const passed = results.filter(r => r.pass).length
const failed = results.filter(r => !r.pass).length
console.log('')
console.log('%c[EventTest] ═══════════════════════════════════════', 'color: #6366f1; font-weight: bold')
console.log(`%c[EventTest] Results: ${passed} passed, ${failed} failed, ${results.length} total`, `color: ${failed > 0 ? '#f87171' : '#34d399'}; font-weight: bold`)
console.log('%c[EventTest] ═══════════════════════════════════════', 'color: #6366f1; font-weight: bold')
for (const r of results) {
const icon = r.pass ? '\u2705' : '\u274C'
const color = r.pass ? 'color: #34d399' : 'color: #f87171; font-weight: bold'
console.log(`%c ${icon} ${r.name}: ${r.detail}`, color)
}
return results
}
export function registerTestRunner(bridge: GameBridge) {
(window as any).__runEventTests = () => runAllTests(bridge)
;(window as any).__bridge = bridge
console.log(
'%c[EventTest] Test runner ready. Run: window.__runEventTests()',
'color: #fbbf24; font-weight: bold',
)
}
@@ -0,0 +1,71 @@
export const AgentState = {
IDLE: 'idle',
WALK: 'walk',
TYPE: 'type',
THINK: 'think',
CELEBRATE: 'celebrate',
COFFEE: 'coffee',
CHAT: 'chat',
PRACTICE: 'practice',
REFLECT: 'reflect',
SLEEP: 'sleep',
PRESENT: 'present',
} as const
export type AgentState = (typeof AgentState)[keyof typeof AgentState]
export const Direction = {
DOWN: 'down',
LEFT: 'left',
RIGHT: 'right',
UP: 'up',
} as const
export type Direction = (typeof Direction)[keyof typeof Direction]
export interface SeatDef {
id: string
tileX: number
tileY: number
facing: Direction
assigned: boolean
assignedTo: string | null
}
export interface InteractableDef {
id: string
tileX: number
tileY: number
type: string
}
export interface ZoneDef {
bounds: { x: number; y: number; w: number; h: number }
seats: SeatDef[]
interactables: InteractableDef[]
doorways: { id: string; tileX: number; tileY: number }[]
}
export interface AgentInfo {
id: string
displayName: string
state: AgentState
isActive: boolean
currentTool: string | null
seatId: string | null
urgency: 'urgent' | 'normal' | 'relaxed'
bubble: string | null
bubbleTimer: number
isSubagent: boolean
parentAgentId: string | null
palette: number
hueShift: number
taskSummary?: string
lastEventAt: number
wanderTimer: number
wanderCount: number
wanderLimit: number
seatTimer: number
stateTimer: number
myceliumEffect: 'crystal' | 'transport_send' | 'transport_recv' | 'spore_send' | 'decompose' | null
myceliumEffectTimer: number
myceliumSession: string | null
}