Files
OpenClaw-bot-review/lib/pixel-office/engine/officeState.ts
T
2026-03-20 10:55:14 +08:00

2475 lines
93 KiB
TypeScript

import { TILE_SIZE, MATRIX_EFFECT_DURATION, CharacterState, Direction } from '../types'
import {
HUE_SHIFT_MIN_DEG,
HUE_SHIFT_RANGE_DEG,
WAITING_BUBBLE_DURATION_SEC,
DISMISS_BUBBLE_FAST_FADE_SEC,
INACTIVE_SEAT_TIMER_MIN_SEC,
INACTIVE_SEAT_TIMER_RANGE_SEC,
AUTO_ON_FACING_DEPTH,
AUTO_ON_SIDE_DEPTH,
CHARACTER_SITTING_OFFSET_PX,
CHARACTER_HIT_HALF_WIDTH,
CHARACTER_HIT_HEIGHT,
} from '../constants'
import type { Character, Seat, FurnitureInstance, TileType as TileTypeVal, OfficeLayout, PlacedFurniture } from '../types'
import { FurnitureType } from '../types'
import { createCharacter, updateCharacter } from './characters'
import { CHARACTER_PALETTES, getAvailableCharacterVariantCount } from '../sprites/spriteData'
import { matrixEffectSeeds } from './matrixEffect'
import { isWalkable, getWalkableTiles, findPath } from '../layout/tileMap'
import {
createDefaultLayout,
layoutToTileMap,
layoutToFurnitureInstances,
layoutToSeats,
getBlockedTiles,
getInteractionPoints,
getDoorwayTiles,
} from '../layout/layoutSerializer'
import type { InteractionPoint } from '../layout/layoutSerializer'
import { getCatalogEntry, getOnStateType } from '../layout/furnitureCatalog'
import { BugSystem } from '../bugs/bugSystem'
import { BUG_DEFAULT_COUNT } from '../bugs/config'
import type { BugEntity } from '../bugs/types'
const CODE_SNIPPET_LIFETIME = 5.5 // seconds
const CODE_SNIPPET_SPAWN_RATE = 0.6 // per second
const SRE_BLACKWORD_SPAWN_RATE = 3.2 // per second
const PHOTO_COMMENT_LIFETIME = 4.0 // seconds
const PHOTO_COMMENT_SPAWN_RATE = 2.5 // per second
const LOBSTER_RAGE_DURATION_SEC = 10
const LOBSTER_BUBBLE_LIFETIME_SEC = 0.8
const LOBSTER_BUBBLE_SPAWN_RATE = 12
const LOBSTER_HIT_RADIUS_PX = 6
// ── Locale-aware text pools ─────────────────────────────────────────────────
type OfficeLocale = 'zh-TW' | 'zh' | 'en'
const CODE_SNIPPETS_ZH_TW = [
// JS/TS
'if (...)', 'else {', 'for (...)', 'return', 'async', 'await', 'try {', 'catch', 'import',
'const x =', '=> {', '...args', '() => {}', '[...arr]', '{ ...obj }', '===', '?.', '??',
// Python
'def fn():', 'self.', 'yield', 'lambda x:', '@decorator', '**kwargs', 'except:', 'raise',
// LLM / AI
'prompt:', 'tokens++', 'model.chat()', 'agent.run()', 'stream()', 'tool_use', 'thinking...',
'chat.completions', 'role: "user"', 'max_tokens=', 'temperature=0.7', 'messages.append()',
'stop_reason', 'tool_calls[]', 'system_prompt', 'embedding()', 'rag.search()', 'context[]',
// Prompt snippets
'You are a...', 'Step by step', 'Let me think', 'In summary:', 'For example:',
'Please fix...', 'Refactor this', 'Add tests for', 'Explain why', 'Review this PR',
'Write a func', 'Debug this', 'Optimize the', 'How to impl', 'Best practice',
'Chain of thought', 'Few-shot:', 'Zero-shot:', 'As an expert', 'Given context:',
// Dev slang
'LGTM', 'RTFM', 'WIP', 'FIXME:', 'TODO:', 'HACK:', 'nit:', '// why?!',
'git push -f', 'npm i npm i', 'works on my💻', 'ship it!',
'¯\\_(ツ)_/¯', 'seg fault', 'null ptr', '404', 'stack overflow',
'it compiles!', 'no repro', 'wontfix', 'by design', 'tech debt++',
'rebase hell', 'merge conflict', '// magic num', 'sudo !!', 'chmod 777',
'rm /tmp/*',
// 台灣工程師行話
'// 別刪這行', '// 能動就好', '// 不知道為啥能動', '// 下次再說', '// 祖傳程式碼',
'在嗎?', '已讀不回', '先這樣吧', '回滾!', '又掛了',
'誰動了我的分支', '這不是bug', '需求又改了', '上線吧', '別碰那個檔案',
'跑不起來啊', '靠腰,重啟試試', '這是誰寫的垃圾!!!', '誰刪我程式碼了去你的', '又500了', '刪庫跑路再說!!!',
'這什麼三小需求', '怎麼又改規格', '去你的,為什麼又改', '靠腰,測試又炸了', '噢不,production掛了',
// OpenClaw CLI
'openclaw status', 'openclaw gateway start', 'openclaw gateway restart',
'openclaw logs', 'openclaw doctor', 'openclaw config get',
'openclaw message send', 'openclaw skills', 'openclaw models', 'openclaw update',
]
const SRE_BLACKWORDS_ZH_TW = [
'先調整!',
'先看監控',
'先看P99',
'先查閘道日誌',
'先查日誌紀錄',
'先重現',
'限流先開',
'檢查gateway',
'降載執行',
'先做降級',
'先擴容',
'先擴容頂住',
'找找文提',
'先重啟試試',
'先重啟閘道',
'先保SLA',
'先止血',
'核心鏈路優先',
'非核心先降級',
'觀察流量',
'開灰度觀察',
'先斷開故障節點',
'先摘流壞實例',
'熔斷值再收緊',
'重試風暴了',
'依賴超時傳染了',
'執行緒池爆了',
'連線池見底了',
'快取擊穿了',
'快取雪崩了',
'DB抖了',
'MQ堆積了',
'閘道扛不住了',
'上游在抖',
'下游撐不住了',
'觀察錯誤率',
'觀察超時率',
'抓出回應慢的',
'RT飆了',
'error budget快打穿了',
'先叫值班同學',
'先通知業務端',
'先發故障通告',
'記錄時間線,後面盤點',
'警報風暴來了',
'開戰情室',
'恢復中...',
'SLA 要頂住',
'MTTR 壓下來',
'這波別炸',
]
const PHOTO_COMMENTS_ZH_TW = [
'這張讚!', '構圖超棒', '光線好美', '色調很舒服', '學習了',
'出片了!', '桌布等級', '大片既視感', '這質感絕了', '散景好美',
'焦段選得好', '拍出了情緒', '太有電影感了', '超有氛圍',
'這光太絕了!!!', '這光太絕了!!!', '這光太絕了!!!',
'這光太絕了!!!', '這光太絕了!!!',
'不愧是台灣之光', '這是決定性瞬間!!!', '這個構圖絕了!',
]
const CODE_SNIPPETS_ZH = [
// JS/TS
'if (...)', 'else {', 'for (...)', 'return', 'async', 'await', 'try {', 'catch', 'import',
'const x =', '=> {', '...args', '() => {}', '[...arr]', '{ ...obj }', '===', '?.', '??',
// Python
'def fn():', 'self.', 'yield', 'lambda x:', '@decorator', '**kwargs', 'except:', 'raise',
// LLM / AI
'prompt:', 'tokens++', 'model.chat()', 'agent.run()', 'stream()', 'tool_use', 'thinking...',
'chat.completions', 'role: "user"', 'max_tokens=', 'temperature=0.7', 'messages.append()',
'stop_reason', 'tool_calls[]', 'system_prompt', 'embedding()', 'rag.search()', 'context[]',
// Prompt snippets
'You are a...', 'Step by step', 'Let me think', 'In summary:', 'For example:',
'Please fix...', 'Refactor this', 'Add tests for', 'Explain why', 'Review this PR',
'Write a func', 'Debug this', 'Optimize the', 'How to impl', 'Best practice',
'Chain of thought', 'Few-shot:', 'Zero-shot:', 'As an expert', 'Given context:',
// Dev slang
'LGTM', 'RTFM', 'WIP', 'FIXME:', 'TODO:', 'HACK:', 'nit:', '// why?!',
'git push -f', 'rm -rf node_', 'npm i npm i', 'works on my💻', 'ship it!',
'¯\\_(ツ)_/¯', 'seg fault', 'null ptr', '404', 'stack overflow',
'it compiles!', 'no repro', 'wontfix', 'by design', 'tech debt++',
'rebase hell', 'merge conflict', '// magic num', 'sudo !!', 'chmod 777',
'rm /tmp/*',
// 中文程序员黑话
'// 别删这行', '// 能跑就行', '// 不知道为啥能跑', '// 下次再改', '// 祖传代码',
'在吗?', '已读不回', '先这样吧', '回滚!', '又挂了',
'谁动了我的分支', '这不是bug', '需求又变了', '上线吧', '别碰那个文件',
'跑不起来啊', '重启试试', '这是谁写的屎山!!!', '谁删我代码了???tmd', '又500了', '删库跑路再说!!!',
// OpenClaw CLI
'openclaw status', 'openclaw gateway start', 'openclaw gateway restart',
'openclaw logs', 'openclaw doctor', 'openclaw config get',
'openclaw message send', 'openclaw skills', 'openclaw models', 'openclaw update',
]
const SRE_BLACKWORDS_ZH = [
'先止血!',
'先看监控',
'先看P99',
'先查网关日志',
'先查日志',
'先复现',
'限流先开',
'熔断先上',
'先熔断',
'降级保命',
'做降级保核心链路',
'先扩容',
'先扩容顶住',
'先回滚',
'先重启试试',
'先重启网关',
'先保SLA',
'先兜底',
'核心链路优先',
'非核心先降级',
'流量削峰',
'开灰度观察',
'先旁路故障节点',
'先摘流坏实例',
'熔断阈值再收紧',
'重试风暴了',
'依赖超时传染了',
'线程池爆了',
'连接池见底了',
'缓存击穿了',
'缓存雪崩了',
'DB抖了',
'MQ堆积了',
'网关扛不住了',
'上游在抖',
'下游背压了',
'观察错误率',
'观察超时率',
'抓慢请求',
'RT飙了',
'error budget快打穿了',
'先拉值班同学',
'先同步业务方预期',
'先发故障通告',
'记录时间线,后面复盘',
'告警风暴来了',
'开战情室',
'恢复中...',
'SLA 要顶住',
'MTTR 压下来',
'这波别炸',
]
const PHOTO_COMMENTS_ZH = [
'毒!', '毒德大学', '德味!', '刀锐奶化', '空气切割感',
'氛围感拉满', '这光太绝了', '构图教科书', '色彩太舒服了', '学习了',
'出片了!', '壁纸级', '大片既视感', '这质感绝了', '奶油焦外',
'焦段选得好', '拍出了情绪', '太有电影感了',
'德味、毒、大师、学习了!!!', '德味、毒、大师、学习了!!!', '德味、毒、大师、学习了!!!',
'德味、毒、大师、学习了!!!', '德味、毒、大师、学习了!!!',
'不愧是中国布列松', '这是决定性瞬间!!!', '这个复杂构图绝了!',
]
const CODE_SNIPPETS_EN = [
// JS/TS
'if (...)', 'else {', 'for (...)', 'return', 'async', 'await', 'try {', 'catch', 'import',
'const x =', '=> {', '...args', '() => {}', '[...arr]', '{ ...obj }', '===', '?.', '??',
// Python
'def fn():', 'self.', 'yield', 'lambda x:', '@decorator', '**kwargs', 'except:', 'raise',
// LLM / AI
'prompt:', 'tokens++', 'model.chat()', 'agent.run()', 'stream()', 'tool_use', 'thinking...',
'chat.completions', 'role: "user"', 'max_tokens=', 'temperature=0.7', 'messages.append()',
'stop_reason', 'tool_calls[]', 'system_prompt', 'embedding()', 'rag.search()', 'context[]',
// Prompt snippets
'You are a...', 'Step by step', 'Let me think', 'In summary:', 'For example:',
'Please fix...', 'Refactor this', 'Add tests for', 'Explain why', 'Review this PR',
'Write a func', 'Debug this', 'Optimize the', 'How to impl', 'Best practice',
'Chain of thought', 'Few-shot:', 'Zero-shot:', 'As an expert', 'Given context:',
// Dev slang (EN flavor)
'LGTM', 'RTFM', 'WIP', 'FIXME:', 'TODO:', 'HACK:', 'nit:', '// why?!',
'git push -f', 'rm -rf node_', 'npm i npm i', 'works on my💻', 'ship it!',
'¯\\_(ツ)_/¯', 'seg fault', 'null ptr', '404', 'stack overflow',
'it compiles!', 'no repro', 'wontfix', 'by design', 'tech debt++',
'rebase hell', 'merge conflict', '// magic num', 'sudo !!', 'chmod 777',
'have you tried turning it off?', 'undefined is not a function',
'it was like this when I got here', 'not my code, not my problem',
'just needs a quick fix', 'we can refactor later', 'deadline is tomorrow',
'the tests pass locally', 'works in staging!', 'blame git log',
'rm /tmp/*',
// OpenClaw CLI
'openclaw status', 'openclaw gateway start', 'openclaw gateway restart',
'openclaw logs', 'openclaw doctor', 'openclaw config get',
'openclaw message send', 'openclaw skills', 'openclaw models', 'openclaw update',
]
const SRE_BLACKWORDS_EN = [
'Stop the bleeding!',
'Check the dashboards',
'Check P99 latency',
'Check gateway logs',
'Check the logs',
'Reproduce it first',
'Enable rate limiting',
'Circuit break it',
'Initiate circuit break',
'Degrade gracefully',
'Protect the critical path',
'Scale up!',
'Scale up to hold the load',
'Roll back now',
'Restart and see',
'Restart the gateway',
'Protect the SLA',
'Fallback first',
'Critical path first',
'Degrade non-critical',
'Shed the load',
'Canary deploy',
'Bypass the bad node',
'Drain the bad instance',
'Tighten circuit threshold',
'Retry storm detected',
'Timeout is cascading',
'Thread pool exhausted',
'Connection pool empty',
'Cache penetration!',
'Cache avalanche!',
'DB is flapping',
'MQ backlog growing',
'Gateway overloaded',
'Upstream is flapping',
'Downstream back-pressure',
'Watch error rate',
'Watch timeout rate',
'Catch slow requests',
'RT is spiking',
'Error budget almost gone',
'Page the on-call',
'Sync with stakeholders',
'Send incident notice',
'Log the timeline',
'Alert storm incoming',
'Open the war room',
'Recovering...',
'Hold the SLA',
'Drive MTTR down',
"Don\'t let it blow up",
]
const PHOTO_COMMENTS_EN = [
'Stunning shot!', 'Love the bokeh', 'Perfect lighting', 'Great composition', 'Amazing colors',
'Wallpaper worthy!', 'Cinematic feel', 'Such atmosphere', 'Beautiful tones', 'Shot of the day!',
'Incredible depth', 'Mood is perfect', 'Frame it!', 'So filmic', 'Golden hour magic',
'Rule of thirds nailed', 'Negative space done right', 'The decisive moment!',
'This is art!!!', 'This is art!!!', 'This is art!!!', 'This is art!!!', 'This is art!!!',
'Not a photo, a painting!', 'The decisive moment!!!', 'Complex comp, nailed it!',
]
function getCodeSnippets(locale: OfficeLocale): string[] {
if (locale === 'zh-TW') return CODE_SNIPPETS_ZH_TW
if (locale === 'en') return CODE_SNIPPETS_EN
return CODE_SNIPPETS_ZH
}
function getSreBlackwords(locale: OfficeLocale): string[] {
if (locale === 'zh-TW') return SRE_BLACKWORDS_ZH_TW
if (locale === 'en') return SRE_BLACKWORDS_EN
return SRE_BLACKWORDS_ZH
}
function getPhotoComments(locale: OfficeLocale): string[] {
if (locale === 'zh-TW') return PHOTO_COMMENTS_ZH_TW
if (locale === 'en') return PHOTO_COMMENTS_EN
return PHOTO_COMMENTS_ZH
}
function getTempWorkerLabel(locale: OfficeLocale): string {
if (locale === 'zh-TW') return '臨時工'
if (locale === 'en') return 'Temp'
return '临时工'
}
function getGatewaySreLabel(locale: OfficeLocale): string {
if (locale === 'zh-TW') return '值班工程師'
if (locale === 'en') return 'On-Call SRE'
return '值班SRE'
}
const SUBAGENT_PRIORITY_SEAT_IDS = [
'stool-r1', 'stool-r2', 'stool-r3', 'stool-r4',
'stool-r5', 'stool-r6', 'stool-r7', 'stool-r8',
] as const
const SUBAGENT_SPAWN_CENTER_COL = 10
const SUBAGENT_SPAWN_CENTER_ROW = 14
const SUBAGENT_RUN_SPEED_MULTIPLIER = 2.8
const GATEWAY_SRE_LABEL = '值班SRE'
const GATEWAY_SRE_STANDBY_COL = 2
const GATEWAY_SRE_STANDBY_ROW = 14
const GATEWAY_SRE_RESCUE_CANDIDATES = [
// Rescue point: right wall of lounge area.
{ col: 18, row: 14 },
{ col: 18, row: 13 },
{ col: 17, row: 14 },
{ col: 17, row: 13 },
] as const
export type GatewaySreState = 'unknown' | 'healthy' | 'degraded' | 'down'
export interface GatewaySreInfo {
id: number
status: GatewaySreState
error: string | null
responseMs: number | null
checkedAt: number | null
}
interface GreetingSequence {
childId: number
parentId: number
childTarget: { col: number; row: number } | null
/** Where the parent walks to when they are NOT at their seat (midpoint meeting) */
parentTarget: { col: number; row: number } | null
/** Tile to wait at when MainAgent is busy with another greeter */
waitTarget: { col: number; row: number } | null
phase: 'waiting' | 'walk' | 'pause' | 'parent_smile' | 'child_smile' | 'final_pause' | 'complete'
timer: number
isExit: boolean
/** Tile to walk back to after farewell, before despawning */
exitReturnPos: { col: number; row: number } | null
/** True when child arrived at greeting tile but MainAgent wasn't physically present */
parentAbsent?: boolean
}
export class OfficeState {
layout: OfficeLayout
tileMap: TileTypeVal[][]
seats: Map<string, Seat>
blockedTiles: Set<string>
furniture: FurnitureInstance[]
walkableTiles: Array<{ col: number; row: number }>
interactionPoints: InteractionPoint[]
doorwayTiles: Array<{ col: number; row: number }>
characters: Map<number, Character> = new Map()
selectedAgentId: number | null = null
cameraFollowId: number | null = null
hoveredAgentId: number | null = null
hoveredTile: { col: number; row: number } | null = null
/** Maps "parentId:toolId" → sub-agent character ID (negative) */
subagentIdMap: Map<string, number> = new Map()
/** Reverse lookup: sub-agent character ID → parent info */
subagentMeta: Map<number, { parentAgentId: number; parentToolId: string }> = new Map()
private nextSubagentId = -1
private static CAT_ID = -9999
private static LOBSTER_ID = -9998
private static HUNTER_LOBSTER_ID = -9997
private static GATEWAY_SRE_ID = -9996
private bugSystem: BugSystem
private bugWorldWidth: number
private bugWorldHeight: number
private gatewaySreStatus: GatewaySreState = 'unknown'
private gatewaySreError: string | null = null
private gatewaySreResponseMs: number | null = null
private gatewaySreCheckedAt: number | null = null
private locale: OfficeLocale = 'zh'
private activeGreetings: Map<number, GreetingSequence> = new Map()
/** The first regular agent added — all other agents greet this one on entry */
private mainAgentId: number | null = null
/** FIFO queue of agent IDs waiting to greet MainAgent. Only queue head walks over; others stay at seat. */
private greetQueue: number[] = []
/** Subagents lingering at seat before farewell. key=charId, value=remaining seconds */
private lingerSubagents: Map<number, number> = new Map()
/** Subagent IDs queued for farewell — when processGreetQueue starts their greeting, mark isExit=true */
private exitOnGreetComplete: Set<number> = new Set()
/** Agent IDs that were explicitly set to idle (下班) — only these should greet on next activation */
private explicitlyIdledAgents: Set<number> = new Set()
/** Stashed exit return positions for subagents waiting in greetQueue. Cleared when greeting starts. */
private exitReturnStash: Map<number, { col: number; row: number }> = new Map()
getTempWorkerLabel(): string {
return getTempWorkerLabel(this.locale)
}
setLocale(locale: OfficeLocale) {
this.locale = locale
// Re-label the SRE character if it exists
const sre = this.characters.get(OfficeState.GATEWAY_SRE_ID)
if (sre) sre.label = getGatewaySreLabel(locale)
// Re-label any temp workers
for (const [, ch] of this.characters) {
if (ch.label === '临时工' || ch.label === '臨時工' || ch.label === 'Temp') {
ch.label = getTempWorkerLabel(locale)
}
}
}
private buildRuntimeBlockedTiles(furniture: PlacedFurniture[]): Set<string> {
// Keep right-office stool seats walkable so adding subagent stools
// does not shrink idle wandering range.
const nonBlockingSeatTiles = new Set<string>()
for (const [seatId, seat] of this.seats.entries()) {
if (!seatId.startsWith('stool-r')) continue
nonBlockingSeatTiles.add(`${seat.seatCol},${seat.seatRow}`)
}
return getBlockedTiles(furniture, nonBlockingSeatTiles)
}
constructor(layout?: OfficeLayout, locale: OfficeLocale = 'zh') {
this.locale = locale
this.layout = layout || createDefaultLayout()
this.tileMap = layoutToTileMap(this.layout)
this.seats = layoutToSeats(this.layout.furniture)
this.blockedTiles = this.buildRuntimeBlockedTiles(this.layout.furniture)
this.furniture = layoutToFurnitureInstances(this.layout.furniture)
this.walkableTiles = getWalkableTiles(this.tileMap, this.blockedTiles)
this.interactionPoints = getInteractionPoints(this.layout.furniture, this.tileMap, this.blockedTiles)
this.doorwayTiles = getDoorwayTiles(this.layout)
this.spawnCat()
this.spawnLobster()
this.spawnHunterLobster()
this.ensureGatewaySre()
this.bugWorldWidth = this.layout.cols * TILE_SIZE
this.bugWorldHeight = this.layout.rows * TILE_SIZE
this.bugSystem = new BugSystem(this.bugWorldWidth, this.bugWorldHeight, BUG_DEFAULT_COUNT)
}
/** Rebuild all derived state from a new layout. Reassigns existing characters.
* @param shift Optional pixel shift to apply when grid expands left/up */
rebuildFromLayout(layout: OfficeLayout, shift?: { col: number; row: number }): void {
this.layout = layout
this.tileMap = layoutToTileMap(layout)
this.seats = layoutToSeats(layout.furniture)
this.blockedTiles = this.buildRuntimeBlockedTiles(layout.furniture)
this.rebuildFurnitureInstances()
this.walkableTiles = getWalkableTiles(this.tileMap, this.blockedTiles)
this.interactionPoints = getInteractionPoints(layout.furniture, this.tileMap, this.blockedTiles)
this.doorwayTiles = getDoorwayTiles(layout)
// Shift character positions when grid expands left/up
if (shift && (shift.col !== 0 || shift.row !== 0)) {
for (const ch of this.characters.values()) {
ch.tileCol += shift.col
ch.tileRow += shift.row
ch.x += shift.col * TILE_SIZE
ch.y += shift.row * TILE_SIZE
// Clear path since tile coords changed
ch.path = []
ch.moveProgress = 0
}
}
// Reassign characters to new seats, preserving existing assignments when possible
for (const seat of this.seats.values()) {
seat.assigned = false
}
// First pass: try to keep characters at their existing seats
for (const ch of this.characters.values()) {
if (ch.isCat || ch.isLobster || ch.isSystemRole) continue
if (ch.seatId && this.seats.has(ch.seatId)) {
const seat = this.seats.get(ch.seatId)!
if (!seat.assigned) {
seat.assigned = true
// Snap character to seat position (tileCol/Row rounded for pathfinding, x/y fractional for visual)
ch.tileCol = Math.round(seat.seatCol)
ch.tileRow = Math.round(seat.seatRow)
const cx = seat.seatCol * TILE_SIZE + TILE_SIZE / 2
const cy = seat.seatRow * TILE_SIZE + TILE_SIZE / 2
ch.x = cx
ch.y = cy
ch.dir = seat.facingDir
continue
}
}
ch.seatId = null // will be reassigned below
}
// Second pass: assign remaining characters to free seats
for (const ch of this.characters.values()) {
if (ch.isCat || ch.isLobster || ch.isSystemRole) continue
if (ch.seatId) continue
const seatId = this.findFreeSeat()
if (seatId) {
this.seats.get(seatId)!.assigned = true
ch.seatId = seatId
const seat = this.seats.get(seatId)!
ch.tileCol = Math.round(seat.seatCol)
ch.tileRow = Math.round(seat.seatRow)
ch.x = seat.seatCol * TILE_SIZE + TILE_SIZE / 2
ch.y = seat.seatRow * TILE_SIZE + TILE_SIZE / 2
ch.dir = seat.facingDir
}
}
// Relocate any characters that ended up outside bounds or on non-walkable tiles
for (const ch of this.characters.values()) {
if (ch.isSystemRole) continue
if (ch.seatId) continue // seated characters are fine
if (ch.tileCol < 0 || ch.tileCol >= layout.cols || ch.tileRow < 0 || ch.tileRow >= layout.rows) {
this.relocateCharacterToWalkable(ch)
}
}
}
/** Move a character to a random walkable tile */
private relocateCharacterToWalkable(ch: Character): void {
if (this.walkableTiles.length === 0) return
const spawn = this.walkableTiles[Math.floor(Math.random() * this.walkableTiles.length)]
ch.tileCol = spawn.col
ch.tileRow = spawn.row
ch.x = spawn.col * TILE_SIZE + TILE_SIZE / 2
ch.y = spawn.row * TILE_SIZE + TILE_SIZE / 2
ch.path = []
ch.moveProgress = 0
}
getLayout(): OfficeLayout {
return this.layout
}
/** Get the blocked-tile key for a character's own seat, or null */
private ownSeatKey(ch: Character): string | null {
if (!ch.seatId) return null
const seat = this.seats.get(ch.seatId)
if (!seat) return null
return `${Math.round(seat.seatCol)},${Math.round(seat.seatRow)}`
}
/** Temporarily unblock a character's own seat, run fn, then re-block */
private withOwnSeatUnblocked<T>(ch: Character, fn: () => T): T {
const key = this.ownSeatKey(ch)
if (key) this.blockedTiles.delete(key)
const result = fn()
if (key) this.blockedTiles.add(key)
return result
}
private findFreeSeat(): string | null {
for (const [uid, seat] of this.seats) {
if (!seat.assigned) return uid
}
return null
}
private getSubagentSpawnCandidates(): Array<{ col: number; row: number }> {
if (this.walkableTiles.length === 0) return [{ col: 1, row: 1 }]
const preferred = this.walkableTiles.filter((t) => t.row >= 11)
const candidates = preferred.length > 0 ? preferred : this.walkableTiles
const occupied = new Set<string>()
for (const ch of this.characters.values()) {
occupied.add(`${ch.tileCol},${ch.tileRow}`)
}
const free = candidates.filter((t) => !occupied.has(`${t.col},${t.row}`))
const source = free.length > 0 ? free : candidates
return source
.slice()
.sort((a, b) => {
const da = Math.abs(a.col - SUBAGENT_SPAWN_CENTER_COL) + Math.abs(a.row - SUBAGENT_SPAWN_CENTER_ROW)
const db = Math.abs(b.col - SUBAGENT_SPAWN_CENTER_COL) + Math.abs(b.row - SUBAGENT_SPAWN_CENTER_ROW)
return da - db
})
}
/**
* Pick a diverse palette for a new agent based on currently active agents.
* First round uses each available character variant once (random order).
* Beyond that, variants
* repeat in balanced rounds with a random hue shift (≥45°).
*/
private pickDiversePalette(): { palette: number; hueShift: number } {
const paletteCount = getAvailableCharacterVariantCount()
const counts = new Array(paletteCount).fill(0) as number[]
for (const ch of this.characters.values()) {
if (ch.isSubagent) continue
counts[ch.palette % paletteCount]++
}
const minCount = Math.min(...counts)
// Available = variants at the minimum count (least used)
const available: number[] = []
for (let i = 0; i < paletteCount; i++) {
if (counts[i] === minCount) available.push(i)
}
const extraVariants = available.filter((index) => index >= CHARACTER_PALETTES.length)
const preferred = minCount === 0 && extraVariants.length > 0 ? extraVariants : available
const palette = preferred[Math.floor(Math.random() * preferred.length)]
// First round (minCount === 0): no hue shift. Subsequent rounds: random ≥45°.
let hueShift = 0
if (minCount > 0) {
hueShift = HUE_SHIFT_MIN_DEG + Math.floor(Math.random() * HUE_SHIFT_RANGE_DEG)
}
return { palette, hueShift }
}
addAgent(id: number, preferredPalette?: number, preferredHueShift?: number, preferredSeatId?: string, skipSpawnEffect?: boolean, spawnAtDoor?: boolean): void {
if (this.characters.has(id)) return
let palette: number
let hueShift: number
if (preferredPalette !== undefined) {
palette = preferredPalette
hueShift = preferredHueShift ?? 0
} else {
const pick = this.pickDiversePalette()
palette = pick.palette
hueShift = pick.hueShift
}
// Try preferred seat first, then any free seat
let seatId: string | null = null
if (preferredSeatId && this.seats.has(preferredSeatId)) {
const seat = this.seats.get(preferredSeatId)!
if (!seat.assigned) {
seatId = preferredSeatId
}
}
if (!seatId) {
seatId = this.findFreeSeat()
}
let ch: Character
// Spawn at doorway and walk to seat
if (spawnAtDoor && this.doorwayTiles.length > 0 && seatId) {
const door = this.doorwayTiles[Math.floor(Math.random() * this.doorwayTiles.length)]
const seat = this.seats.get(seatId)!
seat.assigned = true
ch = createCharacter(id, palette, seatId, null, hueShift)
ch.tileCol = door.col
ch.tileRow = door.row
ch.x = door.col * TILE_SIZE + TILE_SIZE / 2
ch.y = door.row * TILE_SIZE + TILE_SIZE / 2
ch.dir = Direction.DOWN
// Pathfind from door to seat
const path = findPath(door.col, door.row, Math.round(seat.seatCol), Math.round(seat.seatRow), this.tileMap, this.blockedTiles)
if (path.length > 0) {
ch.path = path
ch.state = CharacterState.WALK
ch.frame = 0
ch.frameTimer = 0
} else {
// No path — just sit at seat directly
ch.tileCol = Math.round(seat.seatCol)
ch.tileRow = Math.round(seat.seatRow)
ch.x = seat.seatCol * TILE_SIZE + TILE_SIZE / 2
ch.y = seat.seatRow * TILE_SIZE + TILE_SIZE / 2
ch.dir = seat.facingDir
ch.state = CharacterState.TYPE
}
} else if (seatId) {
const seat = this.seats.get(seatId)!
seat.assigned = true
ch = createCharacter(id, palette, seatId, seat, hueShift)
} else {
// No seats — spawn at random walkable tile
const spawn = this.walkableTiles.length > 0
? this.walkableTiles[Math.floor(Math.random() * this.walkableTiles.length)]
: { col: 1, row: 1 }
ch = createCharacter(id, palette, null, null, hueShift)
ch.x = spawn.col * TILE_SIZE + TILE_SIZE / 2
ch.y = spawn.row * TILE_SIZE + TILE_SIZE / 2
ch.tileCol = spawn.col
ch.tileRow = spawn.row
}
if (!skipSpawnEffect) {
ch.matrixEffect = 'spawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
}
this.characters.set(id, ch)
// Track first agent as MainAgent; others greet on entry
if (this.mainAgentId === null) {
this.mainAgentId = id
} else if (spawnAtDoor) {
this.tryStartGreeting(ch)
}
}
/** Spawn the office cat at a random walkable tile */
spawnCat(): void {
const id = OfficeState.CAT_ID
if (this.characters.has(id)) return
const spawn = this.walkableTiles.length > 0
? this.walkableTiles[Math.floor(Math.random() * this.walkableTiles.length)]
: { col: 1, row: 1 }
const ch = createCharacter(id, 0, null, null, 0)
ch.isCat = true
ch.tileCol = spawn.col
ch.tileRow = spawn.row
ch.x = spawn.col * TILE_SIZE + TILE_SIZE / 2
ch.y = spawn.row * TILE_SIZE + TILE_SIZE / 2
ch.state = CharacterState.IDLE
ch.wanderTimer = 1 + Math.random() * 3
this.characters.set(id, ch)
}
/** Spawn the office lobster at a random walkable tile */
spawnLobster(): void {
const id = OfficeState.LOBSTER_ID
if (this.characters.has(id)) return
const spawn = this.walkableTiles.length > 0
? this.walkableTiles[Math.floor(Math.random() * this.walkableTiles.length)]
: { col: 1, row: 1 }
const ch = createCharacter(id, 0, null, null, 0)
ch.isLobster = true
ch.tileCol = spawn.col
ch.tileRow = spawn.row
ch.x = spawn.col * TILE_SIZE + TILE_SIZE / 2
ch.y = spawn.row * TILE_SIZE + TILE_SIZE / 2
ch.state = CharacterState.IDLE
ch.wanderTimer = 1 + Math.random() * 3
this.characters.set(id, ch)
}
toggleFirstLobsterRage(): boolean {
const lobster = this.characters.get(OfficeState.LOBSTER_ID)
if (!lobster || !lobster.isLobster) return false
if (lobster.lobsterRageTimer > 0) {
lobster.lobsterRageTimer = 0
lobster.lobsterBubbles = []
return false
}
lobster.lobsterRageTimer = LOBSTER_RAGE_DURATION_SEC
return true
}
getFirstLobsterAt(worldX: number, worldY: number): number | null {
const lobster = this.characters.get(OfficeState.LOBSTER_ID)
if (!lobster || lobster.matrixEffect === 'despawn') return null
const cx = lobster.x
const cy = lobster.y + 2
const dx = worldX - cx
const dy = worldY - cy
const hitR = LOBSTER_HIT_RADIUS_PX
if (dx * dx + dy * dy <= hitR * hitR) return lobster.id
return null
}
/** Spawn the hunter lobster at a random walkable tile */
spawnHunterLobster(): void {
const id = OfficeState.HUNTER_LOBSTER_ID
if (this.characters.has(id)) return
const spawn = this.walkableTiles.length > 0
? this.walkableTiles[Math.floor(Math.random() * this.walkableTiles.length)]
: { col: 1, row: 1 }
const ch = createCharacter(id, 0, null, null, 0)
ch.isLobster = true
ch.tileCol = spawn.col
ch.tileRow = spawn.row
ch.x = spawn.col * TILE_SIZE + TILE_SIZE / 2
ch.y = spawn.row * TILE_SIZE + TILE_SIZE / 2
ch.state = CharacterState.IDLE
ch.wanderTimer = 1 + Math.random() * 3
this.characters.set(id, ch)
}
ensureGatewaySre(): void {
const id = OfficeState.GATEWAY_SRE_ID
if (this.characters.has(id)) return
const spawn = this.findClosestWalkable(GATEWAY_SRE_STANDBY_COL, GATEWAY_SRE_STANDBY_ROW)
const ch = createCharacter(id, 2, null, null, 0)
ch.isSystemRole = true
ch.systemRoleType = 'gateway_sre'
ch.systemStatus = this.gatewaySreStatus
ch.label = getGatewaySreLabel(this.locale)
ch.isActive = false
ch.state = CharacterState.IDLE
ch.seatId = null
ch.tileCol = spawn.col
ch.tileRow = spawn.row
ch.x = spawn.col * TILE_SIZE + TILE_SIZE / 2
ch.y = spawn.row * TILE_SIZE + TILE_SIZE / 2
ch.wanderTimer = 60
ch.matrixEffect = 'spawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
this.characters.set(id, ch)
}
updateGatewaySreState(info: {
status: GatewaySreState
error?: string | null
responseMs?: number | null
checkedAt?: number | null
}): void {
const prevStatus = this.gatewaySreStatus
this.gatewaySreStatus = info.status
this.gatewaySreError = info.error ?? null
this.gatewaySreResponseMs = info.responseMs ?? null
this.gatewaySreCheckedAt = info.checkedAt ?? null
const ch = this.characters.get(OfficeState.GATEWAY_SRE_ID)
if (!ch) return
ch.systemStatus = this.gatewaySreStatus
if (prevStatus !== info.status) {
// React immediately when gateway status changes.
ch.wanderTimer = 0
if (info.status === 'down') {
ch.path = []
ch.moveProgress = 0
}
}
}
getGatewaySreInfo(): GatewaySreInfo | null {
const ch = this.characters.get(OfficeState.GATEWAY_SRE_ID)
if (!ch) return null
return {
id: ch.id,
status: this.gatewaySreStatus,
error: this.gatewaySreError,
responseMs: this.gatewaySreResponseMs,
checkedAt: this.gatewaySreCheckedAt,
}
}
/** Find a walkable tile adjacent to a character */
private findAdjacentWalkable(ch: Character): { col: number; row: number } | null {
const adjacents = [
{ col: ch.tileCol - 1, row: ch.tileRow },
{ col: ch.tileCol + 1, row: ch.tileRow },
{ col: ch.tileCol, row: ch.tileRow - 1 },
{ col: ch.tileCol, row: ch.tileRow + 1 },
]
for (const t of adjacents) {
if (isWalkable(t.col, t.row, this.tileMap, this.blockedTiles)) return t
}
return null
}
/**
* Find a midpoint meeting location when MainAgent is not at their seat.
* Returns childTarget (where child walks) and parentTarget (where parent walks),
* adjacent to each other near the midpoint between the two characters.
*/
private findMeetingTiles(
child: Character,
parent: Character,
): { childTarget: { col: number; row: number }; parentTarget: { col: number; row: number } } | null {
const midCol = Math.round((child.tileCol + parent.tileCol) / 2)
const midRow = Math.round((child.tileRow + parent.tileRow) / 2)
// Find walkable tile closest to midpoint for the parent to walk to
const parentTile = this.findClosestWalkable(midCol, midRow)
if (!parentTile) return null
// Verify parent can reach that tile
const parentPath = findPath(parent.tileCol, parent.tileRow, parentTile.col, parentTile.row, this.tileMap, this.blockedTiles)
if (parentPath.length === 0 && !(parent.tileCol === parentTile.col && parent.tileRow === parentTile.row)) return null
// Child walks to a tile adjacent to parent's meeting tile
const adjacents = [
{ col: parentTile.col - 1, row: parentTile.row },
{ col: parentTile.col + 1, row: parentTile.row },
{ col: parentTile.col, row: parentTile.row - 1 },
{ col: parentTile.col, row: parentTile.row + 1 },
]
for (const adj of adjacents) {
if (!isWalkable(adj.col, adj.row, this.tileMap, this.blockedTiles)) continue
const childPath = findPath(child.tileCol, child.tileRow, adj.col, adj.row, this.tileMap, this.blockedTiles)
if (childPath.length > 0) return { childTarget: adj, parentTarget: parentTile }
}
return null
}
/** Returns true if MainAgent is currently in an active greeting sequence */
private isMainAgentBusy(): boolean {
if (this.mainAgentId === null) return false
for (const g of this.activeGreetings.values()) {
if (g.parentId === this.mainAgentId) return true
}
return false
}
/**
* Queue `ch` to greet MainAgent. If MainAgent is free and queue is empty,
* starts the greeting immediately. Otherwise, adds to FIFO queue and the agent
* stays at their seat until it's their turn.
* No-op if: ch IS MainAgent, already in queue/greeting, MainAgent not found.
*/
private tryStartGreeting(ch: Character): void {
if (this.mainAgentId === null || ch.id === this.mainAgentId || ch.isSystemRole) return
if (this.activeGreetings.has(ch.id)) return
if (this.greetQueue.includes(ch.id)) return
const mainCh = this.characters.get(this.mainAgentId)
if (!mainCh || mainCh.matrixEffect === 'despawn') return
this.greetQueue.push(ch.id)
this.processGreetQueue()
}
/** Dequeue the next agent and start their greeting walk if MainAgent is free. */
private processGreetQueue(): void {
if (this.isMainAgentBusy()) return
// Find first queued agent that still exists and isn't already greeting
while (this.greetQueue.length > 0) {
const nextId = this.greetQueue[0]
const ch = this.characters.get(nextId)
if (!ch || this.activeGreetings.has(nextId)) {
this.greetQueue.shift()
continue
}
const mainCh = this.characters.get(this.mainAgentId!)
if (!mainCh || mainCh.matrixEffect === 'despawn') {
this.greetQueue.length = 0
return
}
// Determine meeting point based on whether MainAgent is at their seat
let greetTile: { col: number; row: number } | null
let parentTarget: { col: number; row: number } | null = null
if (mainCh.state === CharacterState.TYPE) {
// MainAgent is at seat — child walks to adjacent tile, parent stays
greetTile = this.findAdjacentWalkable(mainCh)
} else {
// MainAgent is not at seat — meet halfway
const meeting = this.findMeetingTiles(ch, mainCh)
if (meeting) {
greetTile = meeting.childTarget
parentTarget = meeting.parentTarget
} else {
// Fallback: walk to wherever MainAgent currently is
greetTile = this.findAdjacentWalkable(mainCh)
}
}
if (!greetTile) { this.greetQueue.shift(); continue }
const greetPath = findPath(ch.tileCol, ch.tileRow, greetTile.col, greetTile.row, this.tileMap, this.blockedTiles)
if (greetPath.length === 0) { this.greetQueue.shift(); continue }
this.greetQueue.shift()
ch.path = greetPath
ch.state = CharacterState.WALK
ch.moveProgress = 0
ch.greetLocked = true
// If parent needs to walk to meeting point, lock and start them moving now
if (parentTarget) {
const parentPath = findPath(mainCh.tileCol, mainCh.tileRow, parentTarget.col, parentTarget.row, this.tileMap, this.blockedTiles)
if (parentPath.length > 0) {
mainCh.greetLocked = true
mainCh.path = parentPath
mainCh.state = CharacterState.WALK
mainCh.moveProgress = 0
} else {
parentTarget = null // parent already there or unreachable
}
}
const isExitGreeting = this.exitOnGreetComplete.has(ch.id)
if (isExitGreeting) this.exitOnGreetComplete.delete(ch.id)
// Recover the stashed return position for this exit greeting
const exitReturnPos = isExitGreeting ? (this.exitReturnStash.get(ch.id) ?? null) : null
if (isExitGreeting) this.exitReturnStash.delete(ch.id)
this.activeGreetings.set(ch.id, {
childId: ch.id,
parentId: this.mainAgentId!,
childTarget: greetTile,
parentTarget,
waitTarget: null,
phase: 'walk',
timer: 0,
isExit: isExitGreeting,
exitReturnPos,
})
return
}
}
/** Approximate facing direction from one tile toward another */
private directionToward(fromCol: number, fromRow: number, toCol: number, toRow: number): Direction {
const dc = toCol - fromCol
const dr = toRow - fromRow
if (Math.abs(dc) >= Math.abs(dr)) return dc >= 0 ? Direction.RIGHT : Direction.LEFT
return dr >= 0 ? Direction.DOWN : Direction.UP
}
private updateGreetings(dt: number): void {
const completed: number[] = []
for (const [childId, seq] of this.activeGreetings) {
const child = this.characters.get(childId)
const parent = this.characters.get(seq.parentId)
if (!child || child.matrixEffect === 'despawn') {
completed.push(childId)
if (parent) parent.greetLocked = false
continue
}
switch (seq.phase) {
case 'walk': {
const target = seq.childTarget
if (!target) { seq.phase = 'complete'; break }
// If parent has a meeting target, keep them walking toward it
if (seq.parentTarget && parent) {
const pt = seq.parentTarget
const parentArrived = parent.tileCol === pt.col && parent.tileRow === pt.row
if (!parentArrived && parent.path.length === 0 && parent.state !== CharacterState.WALK) {
const repath = findPath(parent.tileCol, parent.tileRow, pt.col, pt.row, this.tileMap, this.blockedTiles)
if (repath.length > 0) {
parent.path = repath
parent.state = CharacterState.WALK
parent.moveProgress = 0
parent.greetLocked = true
}
}
}
const childArrived = child.tileCol === target.col && child.tileRow === target.row
const parentArrived = !seq.parentTarget || !parent ||
(parent.tileCol === seq.parentTarget.col && parent.tileRow === seq.parentTarget.row)
// Both have arrived at their respective meeting tiles
if (childArrived && parentArrived) {
child.path = []
child.state = CharacterState.IDLE
child.greetLocked = true
if (parent) {
parent.greetLocked = true
parent.path = []
parent.state = CharacterState.IDLE
child.dir = this.directionToward(child.tileCol, child.tileRow, parent.tileCol, parent.tileRow)
parent.dir = this.directionToward(parent.tileCol, parent.tileRow, child.tileCol, child.tileRow)
// Detect if parent is not physically nearby (wandered away from greeting tile)
const distToParent = Math.abs(parent.tileCol - child.tileCol) + Math.abs(parent.tileRow - child.tileRow)
if (seq.isExit && distToParent > 3) {
seq.parentAbsent = true
}
} else {
// Parent character doesn't exist
if (seq.isExit) seq.parentAbsent = true
}
seq.phase = 'pause'
seq.timer = 1.0
} else if (child.path.length === 0 && child.state !== CharacterState.WALK) {
// Child lost path mid-walk — try to re-path to target
const path = findPath(child.tileCol, child.tileRow, target.col, target.row, this.tileMap, this.blockedTiles)
if (path.length > 0) {
child.path = path
child.state = CharacterState.WALK
child.moveProgress = 0
} else {
seq.phase = 'complete'
}
}
break
}
case 'pause': {
child.path = []
child.state = CharacterState.IDLE
if (parent) {
parent.path = []
parent.state = CharacterState.IDLE
child.dir = this.directionToward(child.tileCol, child.tileRow, parent.tileCol, parent.tileRow)
parent.dir = this.directionToward(parent.tileCol, parent.tileRow, child.tileCol, child.tileRow)
}
seq.timer -= dt
if (seq.timer <= 0) {
const emoji = seq.isExit ? '❤️' : '😊'
if (parent) this.pushCodeSnippet(parent.id, emoji)
seq.phase = 'parent_smile'
seq.timer = 1.0
}
break
}
case 'parent_smile': {
child.path = []
child.state = CharacterState.IDLE
if (parent) { parent.path = []; parent.state = CharacterState.IDLE }
seq.timer -= dt
if (seq.timer <= 0) {
this.pushCodeSnippet(child.id, seq.isExit ? '❤️' : '😊')
seq.phase = 'child_smile'
seq.timer = 1.0
}
break
}
case 'child_smile': {
child.path = []
child.state = CharacterState.IDLE
if (parent) { parent.path = []; parent.state = CharacterState.IDLE }
seq.timer -= dt
if (seq.timer <= 0) {
seq.phase = 'final_pause'
seq.timer = 1.0
}
break
}
case 'final_pause': {
child.path = []
child.state = CharacterState.IDLE
if (parent) { parent.path = []; parent.state = CharacterState.IDLE }
seq.timer -= dt
if (seq.timer <= 0) {
seq.phase = 'complete'
}
break
}
case 'complete': {
child.greetLocked = false
if (parent) parent.greetLocked = false
if (seq.isExit) {
child.bubbleType = null
// After farewell (whether MainAgent was present or not), walk to bottom-right corner
if (!this.walkToBottomRightThenDespawn(child)) {
child.matrixEffect = 'despawn'
child.matrixEffectTimer = 0
child.matrixEffectSeeds = matrixEffectSeeds()
}
} else {
// Walk to assigned seat
if (child.seatId) {
const seat = this.seats.get(child.seatId)
if (seat) {
const path = this.withOwnSeatUnblocked(child, () =>
findPath(child.tileCol, child.tileRow, Math.round(seat.seatCol), Math.round(seat.seatRow), this.tileMap, this.blockedTiles)
)
if (path.length > 0) {
child.path = path
child.state = CharacterState.WALK
child.moveProgress = 0
}
}
}
}
completed.push(childId)
break
}
}
}
for (const id of completed) this.activeGreetings.delete(id)
// After any greeting completes, let the next queued agent proceed
if (completed.length > 0) this.processGreetQueue()
}
private findClosestWalkable(targetCol: number, targetRow: number): { col: number; row: number } {
if (this.walkableTiles.length === 0) return { col: 1, row: 1 }
let best = this.walkableTiles[0]
let bestDist = Math.abs(best.col - targetCol) + Math.abs(best.row - targetRow)
for (let i = 1; i < this.walkableTiles.length; i++) {
const t = this.walkableTiles[i]
const d = Math.abs(t.col - targetCol) + Math.abs(t.row - targetRow)
if (d < bestDist) {
best = t
bestDist = d
}
}
return best
}
/** Find a walkable tile near the sofa/lounge area, for temp workers with no assigned seat */
private findSofaAreaTile(): { col: number; row: number } | null {
const sofa = this.layout.furniture.find(
(f) => f.type === FurnitureType.SOFA || f.type === FurnitureType.BENCH,
)
if (!sofa) return null
return this.findClosestWalkable(sofa.col, sofa.row)
}
/** Find the walkable tile closest to the bottom-right corner of the map */
private findBottomRightCornerTile(): { col: number; row: number } | null {
if (this.walkableTiles.length === 0) return null
// Bottom-right in tile space = max col + max row
const maxCol = Math.max(...this.walkableTiles.map((t) => t.col))
const maxRow = Math.max(...this.walkableTiles.map((t) => t.row))
return this.findClosestWalkable(maxCol, maxRow)
}
/**
* Walk character to the bottom-right corner then despawn.
* Unlike findExitWalkPath, accepts any path length >= 1 so nearby agents still move.
*/
private walkToBottomRightThenDespawn(ch: Character): boolean {
const corner = this.findBottomRightCornerTile()
if (!corner) return false
if (corner.col === ch.tileCol && corner.row === ch.tileRow) {
// Already there — despawn directly
ch.matrixEffect = 'despawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
return true
}
const path = findPath(ch.tileCol, ch.tileRow, corner.col, corner.row, this.tileMap, this.blockedTiles)
if (path.length === 0) return false
ch.path = path
ch.state = CharacterState.WALK
ch.moveProgress = 0
ch.pendingDespawn = corner
return true
}
/**
* Find a walk-back path for a departing temp worker.
* Ensures the path is long enough that the worker visibly walks away from MainAgent.
* Falls back to the sofa area or any distant tile if the primary target is unreachable.
*/
private findExitWalkPath(
child: Character,
preferredTarget: { col: number; row: number } | null,
): { path: Array<{ col: number; row: number }>; target: { col: number; row: number } } | null {
const MIN_WALK_TILES = 5 // must walk at least this many steps
const tryTarget = (target: { col: number; row: number }) => {
if (target.col === child.tileCol && target.row === child.tileRow) return null
const path = findPath(child.tileCol, child.tileRow, target.col, target.row, this.tileMap, this.blockedTiles)
if (path.length >= MIN_WALK_TILES) return { path, target }
return null
}
// 1. Try preferred target (seat area)
if (preferredTarget) {
const result = tryTarget(preferredTarget)
if (result) return result
}
// 2. Try sofa area
const sofaTile = this.findSofaAreaTile()
if (sofaTile) {
const result = tryTarget(sofaTile)
if (result) return result
}
// 3. Find any walkable tile sufficiently far from current position
const { tileCol: cx, tileRow: cy } = child
const farTile = this.walkableTiles
.filter((t) => Math.abs(t.col - child.tileCol) + Math.abs(t.row - child.tileRow) >= MIN_WALK_TILES)
.sort((a, b) => {
// Prefer tiles far from child but in direction away from center of map
const da = Math.abs(a.col - cx) + Math.abs(a.row - cy)
const db = Math.abs(b.col - cx) + Math.abs(b.row - cy)
return db - da
})
.find((t) => {
const path = findPath(child.tileCol, child.tileRow, t.col, t.row, this.tileMap, this.blockedTiles)
return path.length >= MIN_WALK_TILES
})
if (farTile) {
const path = findPath(child.tileCol, child.tileRow, farTile.col, farTile.row, this.tileMap, this.blockedTiles)
return { path, target: farTile }
}
return null
}
private getGatewaySrePatrolTiles(): Array<{ col: number; row: number }> {
// Mostly patrol in lounge (break area), occasionally stroll in office areas.
const loungeTiles = this.walkableTiles.filter((t) => t.row >= 9)
const officeTiles = this.walkableTiles.filter((t) =>
t.row <= 8 && t.col >= 1 && t.col <= 19
)
const lounge = loungeTiles.length > 0 ? loungeTiles : this.walkableTiles
if (officeTiles.length === 0) return lounge
// Weighted sampling pool: ~86% lounge, ~14% office.
return [...lounge, ...lounge, ...lounge, ...lounge, ...lounge, ...lounge, ...officeTiles]
}
private getGatewaySreRescuePoint(patrolTiles: Array<{ col: number; row: number }>): { col: number; row: number } {
for (const candidate of GATEWAY_SRE_RESCUE_CANDIDATES) {
if (patrolTiles.some((t) => t.col === candidate.col && t.row === candidate.row)) {
return { col: candidate.col, row: candidate.row }
}
}
return this.findClosestWalkable(18, 14)
}
private getGatewaySreDegradedTiles(
patrolTiles: Array<{ col: number; row: number }>,
rescuePoint: { col: number; row: number },
): Array<{ col: number; row: number }> {
const nearby = patrolTiles.filter((t) =>
Math.abs(t.col - rescuePoint.col) + Math.abs(t.row - rescuePoint.row) <= 4
)
return nearby.length > 0 ? nearby : patrolTiles
}
private forceWalkTo(ch: Character, col: number, row: number): void {
const path = findPath(ch.tileCol, ch.tileRow, col, row, this.tileMap, this.blockedTiles)
if (path.length === 0) return
ch.path = path
ch.moveProgress = 0
ch.state = CharacterState.WALK
ch.frame = 0
ch.frameTimer = 0
}
private updateGatewaySreCharacter(ch: Character, dt: number): void {
ch.isActive = false
ch.seatId = null
ch.systemRoleType = 'gateway_sre'
ch.systemStatus = this.gatewaySreStatus
const patrolTiles = this.getGatewaySrePatrolTiles()
const rescuePoint = this.getGatewaySreRescuePoint(patrolTiles)
const degradedTiles = this.getGatewaySreDegradedTiles(patrolTiles, rescuePoint)
if (this.gatewaySreStatus === 'unknown') {
ch.moveSpeedMultiplier = 1
ch.path = []
ch.moveProgress = 0
ch.state = CharacterState.IDLE
ch.frame = 0
ch.frameTimer = 0
ch.wanderTimer = 5
return
}
if (this.gatewaySreStatus === 'down') {
ch.moveSpeedMultiplier = 2.2
const atRescue = ch.tileCol === rescuePoint.col && ch.tileRow === rescuePoint.row
const last = ch.path[ch.path.length - 1]
if (!atRescue && (!last || last.col !== rescuePoint.col || last.row !== rescuePoint.row)) {
this.withOwnSeatUnblocked(ch, () => this.forceWalkTo(ch, rescuePoint.col, rescuePoint.row))
}
this.withOwnSeatUnblocked(ch, () =>
updateCharacter(ch, dt, [rescuePoint], this.seats, this.tileMap, this.blockedTiles, [])
)
// Hold at server front and face the server.
if (ch.tileCol === rescuePoint.col && ch.tileRow === rescuePoint.row) {
ch.path = []
ch.moveProgress = 0
ch.state = CharacterState.IDLE
ch.frame = 0
ch.frameTimer = 0
ch.wanderTimer = 2
ch.dir = Direction.UP
}
return
}
ch.moveSpeedMultiplier = this.gatewaySreStatus === 'degraded' ? 1.4 : 0.9
const scope = this.gatewaySreStatus === 'degraded' ? degradedTiles : patrolTiles
this.withOwnSeatUnblocked(ch, () =>
updateCharacter(ch, dt, scope, this.seats, this.tileMap, this.blockedTiles, [])
)
}
private getFirstIdleHumanoid(): Character | null {
const list = Array.from(this.characters.values())
.filter(ch => !ch.isCat && !ch.isLobster && !ch.isSystemRole && ch.state === CharacterState.IDLE && ch.matrixEffect !== 'despawn')
.sort((a, b) => a.id - b.id)
return list[0] || null
}
private faceToward(ch: Character, target: Character): void {
const dx = target.tileCol - ch.tileCol
const dy = target.tileRow - ch.tileRow
if (Math.abs(dx) >= Math.abs(dy)) {
ch.dir = dx >= 0 ? Direction.RIGHT : Direction.LEFT
} else {
ch.dir = dy >= 0 ? Direction.DOWN : Direction.UP
}
}
private spawnLobsterBubble(ch: Character): void {
let tailX = 0
let tailY = 0
if (ch.dir === Direction.RIGHT) tailX = -6
else if (ch.dir === Direction.LEFT) tailX = 6
else if (ch.dir === Direction.UP) tailY = 6
else tailY = -6
ch.lobsterBubbles.push({
age: 0,
x: tailX + (Math.random() - 0.5) * 2.5,
y: tailY + (Math.random() - 0.5) * 2.5,
})
}
/**
* Hunter lobster behavior:
* - No idle humanoid -> default wandering (same as normal lobster)
* - Has idle humanoid -> follow while staying around half a tile away
*/
private updateHunterLobster(ch: Character, dt: number, idleTarget: Character | null): void {
if (!idleTarget) {
this.withOwnSeatUnblocked(ch, () =>
updateCharacter(ch, dt, this.walkableTiles, this.seats, this.tileMap, this.blockedTiles, this.interactionPoints)
)
return
}
const targetDistancePx = TILE_SIZE * 0.5
const stopDistancePx = TILE_SIZE * 0.45
const dx = idleTarget.x - ch.x
const dy = idleTarget.y - ch.y
const distPx = Math.hypot(dx, dy)
// Close enough: hold position and face the target.
if (distPx <= stopDistancePx) {
ch.path = []
ch.moveProgress = 0
ch.state = CharacterState.IDLE
ch.frame = 0
ch.frameTimer = 0
this.faceToward(ch, idleTarget)
ch.wanderTimer = 60
this.withOwnSeatUnblocked(ch, () =>
updateCharacter(ch, dt, this.walkableTiles, this.seats, this.tileMap, this.blockedTiles, this.interactionPoints)
)
return
}
// If we are already near half-tile distance, hold and face.
if (Math.abs(distPx - targetDistancePx) <= TILE_SIZE * 0.2) {
ch.path = []
ch.moveProgress = 0
ch.state = CharacterState.IDLE
ch.frame = 0
ch.frameTimer = 0
this.faceToward(ch, idleTarget)
} else {
// Repath to target's tile so motion keeps following; we'll stop early at half-tile distance.
const destination = { col: idleTarget.tileCol, row: idleTarget.tileRow }
const last = ch.path[ch.path.length - 1]
const keepsCurrentPath = last && last.col === destination.col && last.row === destination.row
if (!keepsCurrentPath) {
const newPath = findPath(ch.tileCol, ch.tileRow, destination.col, destination.row, this.tileMap, this.blockedTiles)
if (newPath.length > 0) {
ch.path = newPath
ch.moveProgress = 0
ch.state = CharacterState.WALK
ch.frame = 0
ch.frameTimer = 0
}
}
}
// Keep timer high so cat/lobster FSM won't pick a random wander target while tracking.
ch.wanderTimer = 60
this.withOwnSeatUnblocked(ch, () =>
updateCharacter(ch, dt, this.walkableTiles, this.seats, this.tileMap, this.blockedTiles, this.interactionPoints)
)
// Clamp after movement so it never catches the idle humanoid.
const dxAfter = idleTarget.x - ch.x
const dyAfter = idleTarget.y - ch.y
const distAfterPx = Math.hypot(dxAfter, dyAfter)
if (distAfterPx <= stopDistancePx) {
ch.path = []
ch.moveProgress = 0
ch.state = CharacterState.IDLE
ch.frame = 0
ch.frameTimer = 0
this.faceToward(ch, idleTarget)
}
}
removeAgent(id: number): void {
const ch = this.characters.get(id)
if (!ch) return
if (ch.matrixEffect === 'despawn') return // already despawning
// Free seat and clear selection immediately
if (ch.seatId) {
const seat = this.seats.get(ch.seatId)
if (seat) seat.assigned = false
}
if (this.selectedAgentId === id) this.selectedAgentId = null
if (this.cameraFollowId === id) this.cameraFollowId = null
// Start despawn animation instead of immediate delete
ch.matrixEffect = 'despawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
ch.bubbleType = null
}
removeAgentImmediately(id: number): void {
const ch = this.characters.get(id)
if (!ch) return
if (ch.seatId) {
const seat = this.seats.get(ch.seatId)
if (seat) seat.assigned = false
}
if (this.selectedAgentId === id) this.selectedAgentId = null
if (this.cameraFollowId === id) this.cameraFollowId = null
this.characters.delete(id)
}
/** Find seat uid at a given tile position, or null */
getSeatAtTile(col: number, row: number): string | null {
for (const [uid, seat] of this.seats) {
if (Math.round(seat.seatCol) === col && Math.round(seat.seatRow) === row) return uid
}
return null
}
/** Reassign an agent from their current seat to a new seat */
reassignSeat(agentId: number, seatId: string): void {
const ch = this.characters.get(agentId)
if (!ch) return
// Unassign old seat
if (ch.seatId) {
const old = this.seats.get(ch.seatId)
if (old) old.assigned = false
}
// Assign new seat
const seat = this.seats.get(seatId)
if (!seat || seat.assigned) return
seat.assigned = true
ch.seatId = seatId
// Pathfind to new seat (unblock own seat tile for this query)
const path = this.withOwnSeatUnblocked(ch, () =>
findPath(ch.tileCol, ch.tileRow, Math.round(seat.seatCol), Math.round(seat.seatRow), this.tileMap, this.blockedTiles)
)
if (path.length > 0) {
ch.path = path
ch.moveProgress = 0
ch.state = CharacterState.WALK
ch.frame = 0
ch.frameTimer = 0
} else {
// Already at seat or no path — sit down
ch.state = CharacterState.TYPE
ch.dir = seat.facingDir
ch.frame = 0
ch.frameTimer = 0
if (!ch.isActive) {
ch.seatTimer = INACTIVE_SEAT_TIMER_MIN_SEC + Math.random() * INACTIVE_SEAT_TIMER_RANGE_SEC
}
}
}
/** Send an agent back to their currently assigned seat */
sendToSeat(agentId: number): void {
const ch = this.characters.get(agentId)
if (!ch || !ch.seatId) return
const seat = this.seats.get(ch.seatId)
if (!seat) return
const path = this.withOwnSeatUnblocked(ch, () =>
findPath(ch.tileCol, ch.tileRow, Math.round(seat.seatCol), Math.round(seat.seatRow), this.tileMap, this.blockedTiles)
)
if (path.length > 0) {
ch.path = path
ch.moveProgress = 0
ch.state = CharacterState.WALK
ch.frame = 0
ch.frameTimer = 0
} else {
// Already at seat — sit down
ch.state = CharacterState.TYPE
ch.dir = seat.facingDir
ch.frame = 0
ch.frameTimer = 0
if (!ch.isActive) {
ch.seatTimer = INACTIVE_SEAT_TIMER_MIN_SEC + Math.random() * INACTIVE_SEAT_TIMER_RANGE_SEC
}
}
}
/** Walk an agent to an arbitrary walkable tile (right-click command) */
walkToTile(agentId: number, col: number, row: number): boolean {
const ch = this.characters.get(agentId)
if (!ch || ch.isSubagent) return false
if (!isWalkable(col, row, this.tileMap, this.blockedTiles)) {
// Also allow walking to own seat tile (blocked for others but not self)
const key = this.ownSeatKey(ch)
if (!key || key !== `${col},${row}`) return false
}
const path = this.withOwnSeatUnblocked(ch, () =>
findPath(ch.tileCol, ch.tileRow, col, row, this.tileMap, this.blockedTiles)
)
if (path.length === 0) return false
ch.path = path
ch.moveProgress = 0
ch.state = CharacterState.WALK
ch.frame = 0
ch.frameTimer = 0
return true
}
/** Create a sub-agent character with the parent's palette. Returns the sub-agent ID. */
addSubagent(parentAgentId: number, parentToolId: string): number {
const key = `${parentAgentId}:${parentToolId}`
if (this.subagentIdMap.has(key)) return this.subagentIdMap.get(key)!
const id = this.nextSubagentId--
const parentCh = this.characters.get(parentAgentId)
const palette = parentCh ? parentCh.palette : 0
const hueShift = parentCh ? parentCh.hueShift : 0
// Find the free seat closest to the parent agent
const parentCol = parentCh ? parentCh.tileCol : 0
const parentRow = parentCh ? parentCh.tileRow : 0
const dist = (c: number, r: number) =>
Math.abs(c - parentCol) + Math.abs(r - parentRow)
let bestSeatId: string | null = null
const availablePrioritySeats = SUBAGENT_PRIORITY_SEAT_IDS.filter((seatId) => {
const seat = this.seats.get(seatId)
return !!seat && !seat.assigned
})
if (availablePrioritySeats.length > 0) {
const randomIdx = Math.floor(Math.random() * availablePrioritySeats.length)
bestSeatId = availablePrioritySeats[randomIdx]
}
if (!bestSeatId) {
let bestDist = Infinity
for (const [uid, seat] of this.seats) {
if (!seat.assigned) {
const d = dist(seat.seatCol, seat.seatRow)
if (d < bestDist) {
bestDist = d
bestSeatId = uid
}
}
}
}
let ch: Character
if (bestSeatId) {
const seat = this.seats.get(bestSeatId)!
seat.assigned = true
ch = createCharacter(id, palette, bestSeatId, null, hueShift)
ch.moveSpeedMultiplier = SUBAGENT_RUN_SPEED_MULTIPLIER
const targetCol = Math.round(seat.seatCol)
const targetRow = Math.round(seat.seatRow)
const spawnCandidates = this.getSubagentSpawnCandidates()
let selectedSpawn: { col: number; row: number } | null = null
let selectedPath: Array<{ col: number; row: number }> = []
for (const candidate of spawnCandidates) {
const path = this.withOwnSeatUnblocked(ch, () =>
findPath(candidate.col, candidate.row, targetCol, targetRow, this.tileMap, this.blockedTiles)
)
if (path.length > 0) {
selectedSpawn = candidate
selectedPath = path
break
}
}
if (selectedSpawn && selectedPath.length > 0) {
ch.tileCol = selectedSpawn.col
ch.tileRow = selectedSpawn.row
ch.x = selectedSpawn.col * TILE_SIZE + TILE_SIZE / 2
ch.y = selectedSpawn.row * TILE_SIZE + TILE_SIZE / 2
ch.path = selectedPath
ch.state = CharacterState.WALK
ch.moveProgress = 0
} else {
// Rare fallback: if no candidate has a path, keep old behavior and place at seat.
ch = createCharacter(id, palette, bestSeatId, seat, hueShift)
}
} else {
// No seats — spawn at closest walkable tile to parent
let spawn = { col: 1, row: 1 }
if (this.walkableTiles.length > 0) {
let closest = this.walkableTiles[0]
let closestDist = dist(closest.col, closest.row)
for (let i = 1; i < this.walkableTiles.length; i++) {
const d = dist(this.walkableTiles[i].col, this.walkableTiles[i].row)
if (d < closestDist) {
closest = this.walkableTiles[i]
closestDist = d
}
}
spawn = closest
}
ch = createCharacter(id, palette, null, null, hueShift)
ch.x = spawn.col * TILE_SIZE + TILE_SIZE / 2
ch.y = spawn.row * TILE_SIZE + TILE_SIZE / 2
ch.tileCol = spawn.col
ch.tileRow = spawn.row
ch.moveSpeedMultiplier = SUBAGENT_RUN_SPEED_MULTIPLIER
}
ch.isSubagent = true
ch.parentAgentId = parentAgentId
ch.label = getTempWorkerLabel(this.locale)
ch.matrixEffect = 'spawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
this.characters.set(id, ch)
// Join MainAgent greeting queue (waits in place if MainAgent is busy)
this.tryStartGreeting(ch)
this.subagentIdMap.set(key, id)
this.subagentMeta.set(id, { parentAgentId, parentToolId })
return id
}
/** Remove a specific sub-agent character and free its seat */
removeSubagent(parentAgentId: number, parentToolId: string): void {
const key = `${parentAgentId}:${parentToolId}`
const id = this.subagentIdMap.get(key)
if (id === undefined) return
const ch = this.characters.get(id)
if (ch) {
if (ch.matrixEffect === 'despawn') {
this.subagentIdMap.delete(key)
this.subagentMeta.delete(id)
this.lingerSubagents.delete(id)
return
}
// If already lingering, do nothing — let the timer run
if (this.lingerSubagents.has(id)) return
// Start linger: stay at seat for 60s, then do farewell
this.lingerSubagents.set(id, 60)
this.subagentIdMap.delete(key)
this.subagentMeta.delete(id)
if (this.selectedAgentId === id) this.selectedAgentId = null
if (this.cameraFollowId === id) this.cameraFollowId = null
return
}
this.subagentIdMap.delete(key)
this.subagentMeta.delete(id)
}
/** Internal: execute the actual farewell sequence for a subagent (called after linger) */
private startSubagentFarewell(id: number): void {
const ch = this.characters.get(id)
if (!ch || ch.matrixEffect === 'despawn') {
this.characters.delete(id)
return
}
// Save a walkable return position before freeing seat.
// Seat tile itself is often blocked by chair furniture, so use nearest walkable floor tile.
let exitReturnPos: { col: number; row: number } | null = null
if (ch.seatId) {
const seat = this.seats.get(ch.seatId)
if (seat) {
exitReturnPos = this.findClosestWalkable(Math.round(seat.seatCol), Math.round(seat.seatRow))
seat.assigned = false
}
ch.seatId = null
}
// No seat — fall back to sofa/lounge area
if (!exitReturnPos) exitReturnPos = this.findSofaAreaTile()
ch.bubbleType = null
// Queue farewell greeting with MainAgent (isExit=true marks it as a departure)
this.tryStartGreeting(ch)
if (this.activeGreetings.has(id)) {
// Already started greeting immediately — mark as exit
const seq = this.activeGreetings.get(id)!
seq.isExit = true
seq.exitReturnPos = exitReturnPos
} else if (this.greetQueue.includes(id)) {
// In queue — when processed, processGreetQueue will set isExit=true
this.exitOnGreetComplete.add(id)
// Stash the return position until the greeting actually starts
if (exitReturnPos) this.exitReturnStash.set(id, exitReturnPos)
} else {
// No MainAgent reachable — walk to bottom-right corner then despawn
if (!this.walkToBottomRightThenDespawn(ch)) {
ch.matrixEffect = 'despawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
}
}
}
/** Remove all sub-agents belonging to a parent agent */
removeAllSubagents(parentAgentId: number): void {
const toRemove: string[] = []
for (const [key, id] of this.subagentIdMap) {
const meta = this.subagentMeta.get(id)
if (meta && meta.parentAgentId === parentAgentId) {
const ch = this.characters.get(id)
if (ch) {
if (ch.matrixEffect === 'despawn') {
this.subagentMeta.delete(id)
toRemove.push(key)
continue
}
if (this.activeGreetings.has(id)) {
const seq = this.activeGreetings.get(id)!
seq.isExit = true
if (ch.seatId) {
const seat = this.seats.get(ch.seatId)
if (seat) seat.assigned = false
ch.seatId = null
}
this.subagentMeta.delete(id)
if (this.selectedAgentId === id) this.selectedAgentId = null
if (this.cameraFollowId === id) this.cameraFollowId = null
toRemove.push(key)
continue
}
if (ch.seatId) {
const seat = this.seats.get(ch.seatId)
if (seat) seat.assigned = false
ch.seatId = null
}
ch.bubbleType = null
const parentCh = this.characters.get(parentAgentId)
const greetTile = parentCh ? this.findAdjacentWalkable(parentCh) : null
if (greetTile && parentCh) {
const path = findPath(ch.tileCol, ch.tileRow, greetTile.col, greetTile.row, this.tileMap, this.blockedTiles)
if (path.length > 0) {
ch.path = path
ch.state = CharacterState.WALK
ch.moveProgress = 0
this.activeGreetings.set(id, {
childId: id,
parentId: parentAgentId,
childTarget: greetTile,
parentTarget: null,
waitTarget: null,
phase: 'walk',
timer: 0,
isExit: true,
exitReturnPos: ch.pendingDespawn && ch.pendingDespawn !== true ? ch.pendingDespawn : null,
})
this.subagentMeta.delete(id)
if (this.selectedAgentId === id) this.selectedAgentId = null
if (this.cameraFollowId === id) this.cameraFollowId = null
toRemove.push(key)
continue
}
}
// No greeting path — walk to bottom-right corner then despawn
if (!this.walkToBottomRightThenDespawn(ch)) {
ch.matrixEffect = 'despawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
}
}
this.subagentMeta.delete(id)
if (this.selectedAgentId === id) this.selectedAgentId = null
if (this.cameraFollowId === id) this.cameraFollowId = null
toRemove.push(key)
}
}
for (const key of toRemove) {
this.subagentIdMap.delete(key)
}
}
removeAllSubagentsImmediately(parentAgentId: number): void {
const toRemove: string[] = []
const toDeleteIds: number[] = []
for (const [key, id] of this.subagentIdMap) {
const meta = this.subagentMeta.get(id)
if (!meta || meta.parentAgentId !== parentAgentId) continue
const ch = this.characters.get(id)
if (ch?.seatId) {
const seat = this.seats.get(ch.seatId)
if (seat) seat.assigned = false
}
if (this.selectedAgentId === id) this.selectedAgentId = null
if (this.cameraFollowId === id) this.cameraFollowId = null
this.subagentMeta.delete(id)
toRemove.push(key)
toDeleteIds.push(id)
}
for (const key of toRemove) {
this.subagentIdMap.delete(key)
}
for (const id of toDeleteIds) {
this.characters.delete(id)
}
}
/** Look up the sub-agent character ID for a given parent+toolId, or null */
getSubagentId(parentAgentId: number, parentToolId: string): number | null {
return this.subagentIdMap.get(`${parentAgentId}:${parentToolId}`) ?? null
}
setAgentActive(id: number, active: boolean): void {
const ch = this.characters.get(id)
if (ch) {
if (ch.isActive === active) return
ch.isActive = active
if (!active) {
// Sentinel -1: signals turn just ended, skip next seat rest timer.
// Prevents the WALK handler from setting a 2-4 min rest on arrival.
ch.seatTimer = -1
ch.path = []
ch.moveProgress = 0
}
// Greet MainAgent on start-work and stop-work transitions
if (!ch.isSubagent && !ch.isSystemRole) {
this.tryStartGreeting(ch)
}
this.rebuildFurnitureInstances()
}
}
/** Rebuild furniture instances with auto-state applied (active agents turn electronics ON) */
private rebuildFurnitureInstances(): void {
// Collect tiles where active agents face desks
const autoOnTiles = new Set<string>()
for (const ch of this.characters.values()) {
if (!ch.isActive || !ch.seatId) continue
const seat = this.seats.get(ch.seatId)
if (!seat) continue
// Find the desk tile(s) the agent faces from their seat
const dCol = seat.facingDir === Direction.RIGHT ? 1 : seat.facingDir === Direction.LEFT ? -1 : 0
const dRow = seat.facingDir === Direction.DOWN ? 1 : seat.facingDir === Direction.UP ? -1 : 0
// Check tiles in the facing direction (desk could be 1-3 tiles deep)
for (let d = 1; d <= AUTO_ON_FACING_DEPTH; d++) {
const tileCol = seat.seatCol + dCol * d
const tileRow = seat.seatRow + dRow * d
autoOnTiles.add(`${tileCol},${tileRow}`)
}
// Also check tiles to the sides of the facing direction (desks can be wide)
for (let d = 1; d <= AUTO_ON_SIDE_DEPTH; d++) {
const baseCol = seat.seatCol + dCol * d
const baseRow = seat.seatRow + dRow * d
if (dCol !== 0) {
// Facing left/right: check tiles above and below
autoOnTiles.add(`${baseCol},${baseRow - 1}`)
autoOnTiles.add(`${baseCol},${baseRow + 1}`)
} else {
// Facing up/down: check tiles left and right
autoOnTiles.add(`${baseCol - 1},${baseRow}`)
autoOnTiles.add(`${baseCol + 1},${baseRow}`)
}
}
}
if (autoOnTiles.size === 0) {
this.furniture = layoutToFurnitureInstances(this.layout.furniture)
return
}
// Build modified furniture list with auto-state applied
const modifiedFurniture: PlacedFurniture[] = this.layout.furniture.map((item) => {
const entry = getCatalogEntry(item.type)
if (!entry) return item
// Check if any tile of this furniture overlaps an auto-on tile
for (let dr = 0; dr < entry.footprintH; dr++) {
for (let dc = 0; dc < entry.footprintW; dc++) {
if (autoOnTiles.has(`${item.col + dc},${item.row + dr}`)) {
const onType = getOnStateType(item.type)
if (onType !== item.type) {
return { ...item, type: onType }
}
return item
}
}
}
return item
})
this.furniture = layoutToFurnitureInstances(modifiedFurniture)
}
setAgentTool(id: number, tool: string | null): void {
const ch = this.characters.get(id)
if (ch) {
ch.currentTool = tool
}
}
setAgentTaskText(id: number, text: string | undefined): void {
const ch = this.characters.get(id)
if (ch) {
ch.taskText = text
}
}
showPermissionBubble(id: number): void {
const ch = this.characters.get(id)
if (ch) {
ch.bubbleType = 'permission'
ch.bubbleTimer = 0
}
}
clearPermissionBubble(id: number): void {
const ch = this.characters.get(id)
if (ch && ch.bubbleType === 'permission') {
ch.bubbleType = null
ch.bubbleTimer = 0
}
}
showWaitingBubble(id: number): void {
const ch = this.characters.get(id)
if (ch) {
ch.bubbleType = 'waiting'
ch.bubbleTimer = WAITING_BUBBLE_DURATION_SEC
}
}
/** Push an explicit snippet bubble (e.g. subagent session updates). */
pushCodeSnippet(id: number, text: string): void {
const ch = this.characters.get(id)
if (!ch || ch.isCat || ch.isLobster) return
const compact = text.replace(/\s+/g, ' ').trim()
if (!compact) return
ch.codeSnippets.push({
text: compact.length > 90 ? `${compact.slice(0, 89)}…` : compact,
age: 0,
x: (Math.random() - 0.5) * 20,
y: 0,
})
if (ch.codeSnippets.length > 4) {
ch.codeSnippets = ch.codeSnippets.slice(-4)
}
}
/** Dismiss bubble on click — permission: instant, waiting: quick fade */
dismissBubble(id: number): void {
const ch = this.characters.get(id)
if (!ch || !ch.bubbleType) return
if (ch.bubbleType === 'permission') {
ch.bubbleType = null
ch.bubbleTimer = 0
} else if (ch.bubbleType === 'waiting') {
// Trigger immediate fade (0.3s remaining)
ch.bubbleTimer = Math.min(ch.bubbleTimer, DISMISS_BUBBLE_FAST_FADE_SEC)
}
}
/**
* Collision avoidance: when two walking characters contest the same next tile,
* the one farther from its destination steps aside (sideways first, then backwards)
* and waits for the closer one to pass before resuming.
*/
/** Find a free adjacent tile for a character to dodge to, or null if none found */
private findDodgeTile(
ch: Character,
dc: number, dr: number,
walkableSet: Set<string>,
occupiedKeys: Set<string>,
claimedNext: Map<string, number>,
): { col: number; row: number } | null {
const candidates: Array<{ col: number; row: number }> = dc !== 0 || dr !== 0
? [
{ col: ch.tileCol + dr, row: ch.tileRow + dc }, // side A (perpendicular)
{ col: ch.tileCol - dr, row: ch.tileRow - dc }, // side B (perpendicular)
{ col: ch.tileCol - dc, row: ch.tileRow - dr }, // backwards
]
: [
// No direction info — try all 4 neighbours
{ col: ch.tileCol + 1, row: ch.tileRow },
{ col: ch.tileCol - 1, row: ch.tileRow },
{ col: ch.tileCol, row: ch.tileRow + 1 },
{ col: ch.tileCol, row: ch.tileRow - 1 },
]
for (const cand of candidates) {
const key = `${cand.col},${cand.row}`
if (!walkableSet.has(key)) continue
if (this.blockedTiles.has(key)) continue
if (occupiedKeys.has(key)) continue
if (claimedNext.has(key)) continue
return cand
}
return null
}
private resolveWalkConflicts(): void {
const allHumanoids: Character[] = []
for (const ch of this.characters.values()) {
if (ch.matrixEffect || ch.isCat || ch.isLobster || ch.greetLocked) continue
if (ch.yieldTimer > 0) continue
allHumanoids.push(ch)
}
if (allHumanoids.length < 2) return
// Current tile positions of every character (for dodge-target exclusion)
const occupiedKeys = new Set<string>()
for (const ch of this.characters.values()) {
occupiedKeys.add(`${ch.tileCol},${ch.tileRow}`)
}
const walkableSet = new Set<string>(this.walkableTiles.map(t => `${t.col},${t.row}`))
// claimedNext: tiles that are "taken" — no other character may step into them.
// Pre-populate with standing characters' current tiles only.
// Walking characters claim their next tile dynamically in Pass 1 (sorted by priority).
const claimedNext = new Map<string, number>()
for (const ch of allHumanoids) {
if (ch.state !== CharacterState.WALK) {
claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id)
}
}
// ── Pass 1: next-tile conflicts ───────────────────────────────────────────
// All walkers (including mid-step), sorted by remaining path length.
// Shorter path = closer to destination = higher priority = claims the tile.
// Loser is snapped back to current tile and must dodge.
const walkers = allHumanoids.filter(
ch => ch.state === CharacterState.WALK && !ch.yieldDestination && ch.path.length > 0,
)
walkers.sort((a, b) => a.path.length - b.path.length)
for (const ch of walkers) {
const next = ch.path[0]
const key = `${next.col},${next.row}`
if (!claimedNext.has(key)) {
// Also claim current tile so no one walks into us from behind
claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id)
claimedNext.set(key, ch.id)
continue
}
// Tile is taken — snap back to current tile and dodge
if (ch.moveProgress > 0) {
// Abort mid-step: snap back to the tile we came from
ch.x = ch.tileCol * TILE_SIZE + TILE_SIZE / 2
ch.y = ch.tileRow * TILE_SIZE + TILE_SIZE / 2
ch.moveProgress = 0
}
const dest = ch.path[ch.path.length - 1]
const dc = next.col - ch.tileCol
const dr = next.row - ch.tileRow
const dodgeTile = this.findDodgeTile(ch, dc, dr, walkableSet, occupiedKeys, claimedNext)
if (dodgeTile) {
ch.path = [dodgeTile]
ch.moveProgress = 0
// Face the dodge direction immediately so the character doesn't appear to
// move backward while still facing forward.
ch.dir = this.directionToward(ch.tileCol, ch.tileRow, dodgeTile.col, dodgeTile.row)
ch.yieldDestination = { col: dest.col, row: dest.row }
claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id)
claimedNext.set(`${dodgeTile.col},${dodgeTile.row}`, ch.id)
} else {
ch.path = []
ch.yieldTimer = 0.5 + Math.random() * 0.4
ch.yieldDestination = { col: dest.col, row: dest.row }
claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id)
}
}
// ── Pass 2: hard overlap — same tileCol/tileRow right now ────────────────
const tileGroups = new Map<string, Character[]>()
for (const ch of allHumanoids) {
const key = `${ch.tileCol},${ch.tileRow}`
const g = tileGroups.get(key)
if (g) g.push(ch)
else tileGroups.set(key, [ch])
}
for (const group of tileGroups.values()) {
if (group.length < 2) continue
// Highest priority (index 0) stays; others dodge
group.sort((a, b) => {
const aScore = a.state === CharacterState.WALK ? a.path.length : 9999
const bScore = b.state === CharacterState.WALK ? b.path.length : 9999
return aScore - bScore
})
for (let i = 1; i < group.length; i++) {
const dodger = group[i]
if (dodger.yieldTimer > 0 || dodger.yieldDestination) continue
const dc = dodger.dir === Direction.RIGHT ? 1 : dodger.dir === Direction.LEFT ? -1 : 0
const dr = dodger.dir === Direction.DOWN ? 1 : dodger.dir === Direction.UP ? -1 : 0
const dodgeTile = this.findDodgeTile(dodger, dc, dr, walkableSet, occupiedKeys, claimedNext)
if (dodgeTile) {
if (dodger.state === CharacterState.WALK && dodger.path.length > 0) {
const orig = dodger.path[dodger.path.length - 1]
dodger.yieldDestination = { col: orig.col, row: orig.row }
}
dodger.path = [dodgeTile]
dodger.moveProgress = 0
dodger.state = CharacterState.WALK
dodger.frame = 0
dodger.frameTimer = 0
// Face the dodge direction immediately
dodger.dir = this.directionToward(dodger.tileCol, dodger.tileRow, dodgeTile.col, dodgeTile.row)
claimedNext.set(`${dodgeTile.col},${dodgeTile.row}`, dodger.id)
occupiedKeys.delete(`${dodger.tileCol},${dodger.tileRow}`)
occupiedKeys.add(`${dodgeTile.col},${dodgeTile.row}`)
} else {
dodger.yieldTimer = 0.3 + Math.random() * 0.3
}
}
}
}
update(dt: number): void {
this.ensureGatewaySre()
this.bugSystem.update(dt, this.bugWorldWidth, this.bugWorldHeight)
this.resolveWalkConflicts()
const toDelete: number[] = []
const firstIdleHumanoid = this.getFirstIdleHumanoid()
for (const ch of this.characters.values()) {
// Handle matrix effect animation
if (ch.matrixEffect) {
ch.matrixEffectTimer += dt
if (ch.matrixEffectTimer >= MATRIX_EFFECT_DURATION) {
if (ch.matrixEffect === 'spawn') {
// Spawn complete — clear effect, resume normal FSM
ch.matrixEffect = null
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = []
} else {
// Despawn complete — mark for deletion
toDelete.push(ch.id)
}
}
continue // skip normal FSM while effect is active
}
if (ch.id === OfficeState.HUNTER_LOBSTER_ID) {
this.updateHunterLobster(ch, dt, firstIdleHumanoid)
continue
}
// Yield timer: character is waiting at dodge tile before resuming
if (ch.yieldTimer > 0) {
ch.yieldTimer = Math.max(0, ch.yieldTimer - dt)
if (ch.yieldTimer === 0 && ch.yieldDestination) {
const dest = ch.yieldDestination
ch.yieldDestination = null
const resumePath = findPath(ch.tileCol, ch.tileRow, dest.col, dest.row, this.tileMap, this.blockedTiles)
if (resumePath.length > 0) {
ch.path = resumePath
ch.moveProgress = 0
ch.state = CharacterState.WALK
ch.frame = 0
ch.frameTimer = 0
}
}
continue // frozen while waiting
}
if (ch.systemRoleType === 'gateway_sre' && !ch.greetLocked) {
this.updateGatewaySreCharacter(ch, dt)
} else {
// Temporarily unblock own seat so character can pathfind to it
// (greetLocked guards inside updateCharacter prevent repath-to-seat during greeting)
this.withOwnSeatUnblocked(ch, () =>
updateCharacter(ch, dt, this.walkableTiles, this.seats, this.tileMap, this.blockedTiles, this.interactionPoints)
)
}
// If character just finished walking to dodge tile, start wait timer
if (ch.yieldDestination && ch.state !== CharacterState.WALK && ch.path.length === 0) {
ch.yieldTimer = 0.6 + Math.random() * 0.5
ch.state = CharacterState.IDLE
ch.frame = 0
ch.frameTimer = 0
}
if (ch.isLobster) {
if (ch.lobsterRageTimer > 0) {
ch.lobsterRageTimer = Math.max(0, ch.lobsterRageTimer - dt)
if (Math.random() < dt * LOBSTER_BUBBLE_SPAWN_RATE) {
this.spawnLobsterBubble(ch)
}
}
for (const b of ch.lobsterBubbles) b.age += dt
ch.lobsterBubbles = ch.lobsterBubbles.filter(b => b.age < LOBSTER_BUBBLE_LIFETIME_SEC)
}
// Tick bubble timer for waiting bubbles
if (ch.bubbleType === 'waiting') {
ch.bubbleTimer -= dt
if (ch.bubbleTimer <= 0) {
ch.bubbleType = null
ch.bubbleTimer = 0
}
}
// Photo comment particles for characters viewing the photograph
if (ch.isViewingPhoto && ch.state === CharacterState.IDLE && !ch.isCat && !ch.isLobster) {
for (const pc of ch.photoComments) pc.age += dt
ch.photoComments = ch.photoComments.filter(pc => pc.age < PHOTO_COMMENT_LIFETIME)
if (ch.photoComments.length < 2 && Math.random() < dt * PHOTO_COMMENT_SPAWN_RATE) {
ch.photoComments.push({
text: getPhotoComments(this.locale)[Math.floor(Math.random() * getPhotoComments(this.locale).length)],
age: 0,
x: (Math.random() - 0.5) * 16,
})
}
// Spawn first one immediately
if (ch.photoComments.length === 0) {
ch.photoComments.push({
text: getPhotoComments(this.locale)[Math.floor(Math.random() * getPhotoComments(this.locale).length)],
age: 0,
x: (Math.random() - 0.5) * 16,
})
}
} else {
ch.isViewingPhoto = false
ch.photoComments = []
}
const isSreFirefighting =
ch.systemRoleType === 'gateway_sre' &&
ch.systemStatus === 'down' &&
ch.state === CharacterState.IDLE
// Code snippet particles:
// - Working agents: regular coding snippets
// - Gateway SRE in down state: ops slang ("运维黑话")
// - Subagent/session-driven snippets can be injected externally via pushCodeSnippet().
if (!ch.isCat && !ch.isLobster) {
for (const s of ch.codeSnippets) s.age += dt
ch.codeSnippets = ch.codeSnippets.filter(s => s.age < CODE_SNIPPET_LIFETIME)
}
if (isSreFirefighting && !ch.isCat && !ch.isLobster) {
if (ch.codeSnippets.length < 3 && Math.random() < dt * SRE_BLACKWORD_SPAWN_RATE) {
ch.codeSnippets.push({
text: getSreBlackwords(this.locale)[Math.floor(Math.random() * getSreBlackwords(this.locale).length)],
age: 0,
x: (Math.random() - 0.5) * 14,
y: 0,
})
}
} else if (ch.isActive && ch.state === CharacterState.TYPE && !ch.isCat && !ch.isLobster && !ch.isSubagent) {
// Spawn new snippet randomly
if (ch.codeSnippets.length < 2 && Math.random() < dt * CODE_SNIPPET_SPAWN_RATE) {
ch.codeSnippets.push({
text: getCodeSnippets(this.locale)[Math.floor(Math.random() * getCodeSnippets(this.locale).length)],
age: 0,
x: (Math.random() - 0.5) * 20,
y: 0,
})
}
}
}
// Tick linger timers — only count down while character is actually sitting at seat
if (this.lingerSubagents.size > 0) {
const lingerExpired: number[] = []
for (const [lingerId, timer] of this.lingerSubagents) {
const ch = this.characters.get(lingerId)
if (!ch) { lingerExpired.push(lingerId); continue }
// Only count down while character is idle/typing at their seat (not walking or greeting)
const isRestingAtSeat =
ch.seatId !== null &&
ch.state !== CharacterState.WALK &&
!this.activeGreetings.has(lingerId) &&
!this.greetQueue.includes(lingerId)
if (!isRestingAtSeat) continue
const remaining = timer - dt
if (remaining <= 0) {
lingerExpired.push(lingerId)
} else {
this.lingerSubagents.set(lingerId, remaining)
}
}
for (const lingerId of lingerExpired) {
this.lingerSubagents.delete(lingerId)
this.startSubagentFarewell(lingerId)
}
}
this.updateGreetings(dt)
// Remove characters that finished despawn
for (const id of toDelete) {
this.characters.delete(id)
}
}
getCharacters(): Character[] {
return Array.from(this.characters.values())
}
getBugs(): BugEntity[] {
return this.bugSystem.getBugs()
}
isBugEnabled(): boolean {
return this.bugSystem.isEnabled()
}
setBugEnabled(enabled: boolean): void {
this.bugSystem.setEnabled(enabled)
}
getBugTargetCount(): number {
return this.bugSystem.getTargetCount()
}
setBugTargetCount(count: number): void {
this.bugSystem.setTargetCount(count, this.bugWorldWidth, this.bugWorldHeight)
}
setBugWorldSize(width: number, height: number): void {
this.bugWorldWidth = Math.max(1, width)
this.bugWorldHeight = Math.max(1, height)
}
setBugCursor(x: number, y: number, active: boolean): void {
this.bugSystem.setCursor(x, y, active)
}
/** Get character at pixel position (for hit testing). Returns id or null. */
getCharacterAt(worldX: number, worldY: number): number | null {
const chars = this.getCharacters().sort((a, b) => b.y - a.y)
for (const ch of chars) {
// Skip characters that are despawning or pets
if (ch.matrixEffect === 'despawn') continue
if (ch.isCat || ch.isLobster) continue
// Character sprite is 16x24, anchored bottom-center
// Apply sitting offset to match visual position
const sittingOffset = ch.state === CharacterState.TYPE ? CHARACTER_SITTING_OFFSET_PX : 0
const anchorY = ch.y + sittingOffset
const left = ch.x - CHARACTER_HIT_HALF_WIDTH
const right = ch.x + CHARACTER_HIT_HALF_WIDTH
const top = anchorY - CHARACTER_HIT_HEIGHT
const bottom = anchorY
if (worldX >= left && worldX <= right && worldY >= top && worldY <= bottom) {
return ch.id
}
}
return null
}
}