import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { randomUUID } from 'crypto'; import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'; import { dirname, resolve } from 'path'; import { WorldNpcAction, WorldNpcActionEvent, WorldNpcActivity, WorldNpcDailyPlan, WorldNpcConversationEvent, WorldNpcDefinition, WorldNpcDirection, WorldNpcInteractionRequest, WorldNpcInteractionResult, WorldNpcRuntime, WorldNpcSnapshot, WorldNpcSnapshotItem, WorldNpcTickResult, WorldNpcTownStatus, WorldNpcResidentSummary, WorldNpcResidentTurn, WorldNpcMemory, WorldNpcPlanningContext, WorldLocation, } from './world_npc.types'; import { fallbackNpcPlan, fallbackResearcherPlan, townDate, townMinute, WorldNpcPlanner } from './world_npc.planner'; import { findWorldRoute, getRouteKind, getWorldLocation } from './world_npc.world'; import { WorldNpcClock } from './world_npc.clock'; import { getWorldNpcDefinition, WORLD_NPC_DEFINITIONS } from './world_npc.registry'; const WALK_SPEED_PIXELS_PER_SECOND = 90; const TRANSITION_DURATION_MS = 500; const INTERACTION_DISTANCE = 150; const MAX_MEMORIES_PER_NPC = 100; const MAX_RESIDENT_SUMMARIES_PER_NPC = 5000; const MAX_SESSION_TURNS = 24; const SESSION_IDLE_TIMEOUT_MS = 10 * 60_000; const DEFAULT_REPLAN_COOLDOWN_MS = 5 * 60_000; const DEFAULT_SOCIAL_COOLDOWN_MS = 30_000; interface PersistedTownState { version: 2; runtimes: WorldNpcRuntime[]; } @Injectable() export class WorldNpcService implements OnModuleInit { private readonly logger = new Logger(WorldNpcService.name); private readonly statePath = resolve(process.env.WORLD_NPC_STATE_PATH || 'data/world-npc-state.json'); private runtimes = new Map(); private planning = new Map>(); private lastReplanRequestedAt = new Map(); private socialPlanning = new Map>(); private socializedEncounters = new Set(); private socialBusyNpcIds = new Set(); private lastSocializedAt = new Map(); private pendingConversations: WorldNpcConversationEvent[] = []; private residentSessions = new Map(); constructor( private readonly planner: WorldNpcPlanner, private readonly clock: WorldNpcClock = new WorldNpcClock(), ) { this.runtimes = this.loadRuntimes(this.clock.now()); for (const runtime of this.runtimes.values()) { runtime.memories.forEach((memory) => { if (memory.encounterId) { this.socializedEncounters.add(memory.encounterId); this.lastSocializedAt.set(runtime.npcId, Math.max( this.lastSocializedAt.get(runtime.npcId) || 0, memory.createdAt, )); } }); } } async onModuleInit(): Promise { await Promise.all([...this.runtimes.values()].map((runtime) => this.ensureDailyPlan(runtime, this.clock.now(), true))); } getMapSnapshot(mapId: string, now = this.clock.now()): WorldNpcSnapshot { const normalizedMapId = mapId.trim(); const npcs = [...this.runtimes.values()] .filter((runtime) => runtime.mapId === normalizedMapId) .map((runtime) => this.toSnapshotItem(runtime, now)); return { mapId: normalizedMapId, serverNow: now, version: npcs.reduce((version, npc) => Math.max(version, npc.version), 0), npcs, }; } async tick(now = this.clock.now()): Promise { const result: WorldNpcTickResult = { started: [], completed: [], changedMaps: [], conversations: this.pendingConversations.splice(0), }; for (const runtime of this.runtimes.values()) { await this.ensureDailyPlan(runtime, now); this.tickRuntime(runtime, now, result); } this.queueNpcEncounters(now); if (result.started.length || result.completed.length) this.persistRuntimes(); result.changedMaps = [...new Set(result.changedMaps)]; return result; } private queueNpcEncounters(now: number): void { if (process.env.WORLD_NPC_SOCIAL_ENABLED === 'off' || typeof this.planner.createNpcConversation !== 'function') return; const candidates = [...this.runtimes.values()].filter((runtime) => runtime.activeAction?.kind === 'perform'); const configuredCooldown = Number(process.env.WORLD_NPC_SOCIAL_COOLDOWN_MS || DEFAULT_SOCIAL_COOLDOWN_MS); const cooldown = Number.isFinite(configuredCooldown) && configuredCooldown >= 0 ? configuredCooldown : DEFAULT_SOCIAL_COOLDOWN_MS; for (let firstIndex = 0; firstIndex < candidates.length; firstIndex += 1) { for (let secondIndex = firstIndex + 1; secondIndex < candidates.length; secondIndex += 1) { const pair = [candidates[firstIndex], candidates[secondIndex]] .sort((first, second) => first.npcId.localeCompare(second.npcId)); const [first, second] = pair; if (this.socialBusyNpcIds.has(first.npcId) || this.socialBusyNpcIds.has(second.npcId)) continue; const firstElapsed = now - (this.lastSocializedAt.get(first.npcId) || 0); const secondElapsed = now - (this.lastSocializedAt.get(second.npcId) || 0); if ((firstElapsed >= 0 && firstElapsed < cooldown) || (secondElapsed >= 0 && secondElapsed < cooldown)) continue; if (first.mapId !== second.mapId || first.locationId !== second.locationId) continue; const firstActivity = first.plan.activities.find((activity) => activity.id === first.activityId); const secondActivity = second.plan.activities.find((activity) => activity.id === second.activityId); if (!firstActivity || !secondActivity || (firstActivity.activityKind !== 'socialize' && secondActivity.activityKind !== 'socialize')) continue; const encounterId = [ townDate(now), first.npcId, second.npcId, first.locationId, firstActivity.id, secondActivity.id, ].join(':'); if (this.socializedEncounters.has(encounterId) || this.socialPlanning.has(encounterId)) continue; this.socializedEncounters.add(encounterId); this.socialBusyNpcIds.add(first.npcId); this.socialBusyNpcIds.add(second.npcId); const planning = this.createNpcEncounter( encounterId, first, second, firstActivity, secondActivity, now, ).catch((error) => { this.socializedEncounters.delete(encounterId); this.logger.warn(`NPC 自主交流失败: ${error instanceof Error ? error.message : error}`); }).finally(() => { this.socialPlanning.delete(encounterId); this.socialBusyNpcIds.delete(first.npcId); this.socialBusyNpcIds.delete(second.npcId); }); this.socialPlanning.set(encounterId, planning); } } } private async createNpcEncounter( encounterId: string, first: WorldNpcRuntime, second: WorldNpcRuntime, firstActivity: WorldNpcActivity, secondActivity: WorldNpcActivity, now: number, ): Promise { const firstDefinition = getWorldNpcDefinition(first.npcId); const secondDefinition = getWorldNpcDefinition(second.npcId); const location = getWorldLocation(first.locationId); const lines = await this.planner.createNpcConversation({ first: firstDefinition, second: secondDefinition, firstActivity, secondActivity, firstMemories: first.memories.slice(-8), secondMemories: second.memories.slice(-8), locationName: location.name, }); if (first.mapId !== location.mapId || second.mapId !== location.mapId || first.locationId !== location.id || second.locationId !== location.id || first.activeAction?.kind !== 'perform' || second.activeAction?.kind !== 'perform' || first.activityId !== firstActivity.id || second.activityId !== secondActivity.id) { throw new Error('NPC encounter ended before the conversation was ready'); } const conversationId = randomUUID(); const addMemory = (owner: WorldNpcRuntime, peer: WorldNpcRuntime, activity: WorldNpcActivity): void => { const ownerLines = lines.filter((line) => line.speakerNpcId === owner.npcId).map((line) => line.text).join(' '); const peerLines = lines.filter((line) => line.speakerNpcId === peer.npcId).map((line) => line.text).join(' '); owner.memories.push({ memoryId: randomUUID(), userId: `npc:${peer.npcId}`, username: getWorldNpcDefinition(peer.npcId).name, message: peerLines, response: ownerLines, activityId: activity.id, locationId: owner.locationId, createdAt: now, kind: 'npc', peerNpcId: peer.npcId, encounterId, }); owner.memories = owner.memories.slice(-MAX_MEMORIES_PER_NPC); }; addMemory(first, second, firstActivity); addMemory(second, first, secondActivity); this.lastSocializedAt.set(first.npcId, now); this.lastSocializedAt.set(second.npcId, now); this.pendingConversations.push({ conversationId, encounterId, mapId: first.mapId, locationId: first.locationId, participantNpcIds: [first.npcId, second.npcId], lines, serverNow: now, }); this.persistRuntimes(); this.queueRemainingPlanRevision(first, now); this.queueRemainingPlanRevision(second, now); } private tickRuntime(runtime: WorldNpcRuntime, now: number, result: WorldNpcTickResult): void { const definition = getWorldNpcDefinition(runtime.npcId); if (definition.stationary) { this.tickStationaryRuntime(runtime, definition, now, result); return; } if (runtime.activeAction && now >= runtime.activeAction.completesAt) { const completed = runtime.activeAction; const oldMapId = runtime.mapId; this.finishAction(runtime, completed); result.completed.push(this.eventFor(runtime.npcId, completed, oldMapId, now)); result.changedMaps.push(oldMapId, runtime.mapId); } if (!runtime.activeAction) { const activity = this.currentActivity(runtime.plan, townMinute(now)); if (runtime.activityId !== activity.id || runtime.actionQueue.length === 0) { runtime.activityId = activity.id; runtime.actionQueue = this.buildActionQueue(runtime, activity); } const next = runtime.actionQueue.shift(); if (next) { this.startAction(runtime, next, now); result.started.push(this.eventFor(runtime.npcId, next, runtime.mapId, now)); result.changedMaps.push(runtime.mapId); } } } private tickStationaryRuntime( runtime: WorldNpcRuntime, definition: WorldNpcDefinition, now: number, result: WorldNpcTickResult, ): void { const location = getWorldLocation(definition.homeLocationId); const point = this.fixedPointFor(definition); const activity = this.currentActivity(runtime.plan, townMinute(now)); const currentAction = runtime.activeAction; runtime.mapId = location.mapId; runtime.locationId = location.id; runtime.x = point.x; runtime.y = point.y; runtime.actionQueue = []; if (currentAction && (currentAction.kind !== 'perform' || currentAction.activityId !== activity.id || now >= currentAction.completesAt)) { if (currentAction.kind === 'perform' && now >= currentAction.completesAt) { result.completed.push(this.eventFor(runtime.npcId, currentAction, location.mapId, now)); } runtime.activeAction = undefined; runtime.state = 'idle'; } runtime.activityId = activity.id; if (!runtime.activeAction) { const perform = this.makeAction('perform', location.id, location.id, activity, 1_000); perform.fromX = point.x; perform.fromY = point.y; perform.toX = point.x; perform.toY = point.y; this.startAction(runtime, perform, now); result.started.push(this.eventFor(runtime.npcId, perform, location.mapId, now)); result.changedMaps.push(location.mapId); } else { runtime.activeAction.fromX = point.x; runtime.activeAction.fromY = point.y; runtime.activeAction.toX = point.x; runtime.activeAction.toY = point.y; } } getRuntimeForTesting(npcId = WORLD_NPC_DEFINITIONS[0].npcId): WorldNpcRuntime { const runtime = this.requireRuntime(npcId); return JSON.parse(JSON.stringify(runtime)); } replacePlanForTesting(plan: WorldNpcDailyPlan, npcId = WORLD_NPC_DEFINITIONS[0].npcId): void { const runtime = this.requireRuntime(npcId); runtime.plan = this.constrainPlanToDefinition(plan, getWorldNpcDefinition(npcId)); runtime.activityId = ''; runtime.actionQueue = []; runtime.activeAction = undefined; } async interact(request: WorldNpcInteractionRequest): Promise { const now = request.now ?? this.clock.now(); const runtime = this.requireRuntime(request.npcId); const definition = getWorldNpcDefinition(request.npcId); if (runtime.mapId !== request.mapId) throw new Error('NPC不在当前地图'); if (runtime.activeAction?.kind === 'transition') throw new Error('NPC正在前往另一个区域'); const position = runtime.activeAction?.kind === 'walk' ? this.interpolate(runtime.activeAction, now) : { x: runtime.x, y: runtime.y }; if (Math.hypot(position.x - request.x, position.y - request.y) > INTERACTION_DISTANCE) { throw new Error('距离NPC太远'); } const message = String(request.message || '').trim(); if (message.length > 300) throw new Error('消息不能超过300个字符'); const activity = runtime.plan.activities.find((item) => item.id === runtime.activityId) || this.currentActivity(runtime.plan, townMinute(now)); const sessionKey = `${runtime.npcId}:${request.userId}`; const requestedSessionId = String(request.sessionId || '').trim(); let session = this.residentSessions.get(sessionKey); if (!session || session.sessionId !== requestedSessionId || now - session.lastActivityAt > SESSION_IDLE_TIMEOUT_MS) { if (session && session.turns.length) await this.finalizeResidentSession(runtime, request.userId, request.username, session, now); session = { sessionId: randomUUID(), turns: [], lastActivityAt: now }; this.residentSessions.set(sessionKey, session); } const summary = runtime.residentSummaries.find((item) => item.userId === request.userId); const response = await this.planner.createInteractionReply({ definition, activity, dailyGoal: runtime.plan.goal, memories: runtime.memories, residentSummary: summary?.summary || '', sessionTurns: session.turns, userId: String(request.userId), username: request.username, message, }); const memoryId = randomUUID(); session.turns.push({ role: 'user', content: message, createdAt: now }); session.turns.push({ role: 'assistant', content: response, createdAt: now }); session.turns = session.turns.slice(-MAX_SESSION_TURNS); session.lastActivityAt = now; this.persistRuntimes(); if (message) this.queueRemainingPlanRevision(runtime, now); return { npcId: runtime.npcId, npcName: definition.name, response, publicIntention: activity.intention, activity, memoryId, sessionId: session.sessionId, serverNow: now, }; } async endResidentSession(npcId: string, userId: string, username = '', sessionId = '', now = this.clock.now()): Promise { const runtime = this.requireRuntime(npcId); const key = `${npcId}:${userId}`; const session = this.residentSessions.get(key); if (session && (!sessionId || session.sessionId === sessionId) && session.turns.length) { await this.finalizeResidentSession(runtime, userId, username, session, now); this.residentSessions.delete(key); } } private async finalizeResidentSession(runtime: WorldNpcRuntime, userId: string, username: string, session: { sessionId: string; turns: WorldNpcResidentTurn[]; lastActivityAt: number }, now: number): Promise { const existing = runtime.residentSummaries.find((item) => item.userId === userId); const summary = await this.planner.summarizeResidentSession({ npc: getWorldNpcDefinition(runtime.npcId), userId, username, previousSummary: existing?.summary || '', turns: session.turns, }); const next: WorldNpcResidentSummary = { userId, username: username || existing?.username || '居民', summary, sessionCount: (existing?.sessionCount || 0) + 1, updatedAt: now, }; runtime.residentSummaries = [...runtime.residentSummaries.filter((item) => item.userId !== userId), next] .slice(-MAX_RESIDENT_SUMMARIES_PER_NPC); this.persistRuntimes(); } private queueRemainingPlanRevision(runtime: WorldNpcRuntime, now: number): void { if (typeof this.planner.reviseRemainingPlan !== 'function' || this.planning.has(runtime.npcId)) return; const configuredCooldown = Number(process.env.WORLD_NPC_REPLAN_COOLDOWN_MS || DEFAULT_REPLAN_COOLDOWN_MS); const cooldown = Number.isFinite(configuredCooldown) && configuredCooldown >= 0 ? configuredCooldown : DEFAULT_REPLAN_COOLDOWN_MS; const requestedAt = Date.now(); const previousRequest = this.lastReplanRequestedAt.get(runtime.npcId) || 0; if (requestedAt - previousRequest < cooldown) return; this.lastReplanRequestedAt.set(runtime.npcId, requestedAt); const planDate = runtime.plan.date; const planning = this.planner.reviseRemainingPlan( getWorldNpcDefinition(runtime.npcId), runtime.plan, this.planningContext(runtime, now), now, ).then((revised) => { if (runtime.plan.date !== planDate || revised === runtime.plan) return; runtime.plan = this.constrainPlanToDefinition(revised, getWorldNpcDefinition(runtime.npcId)); runtime.plannerFallbackReason = revised.source === 'fallback' ? 'AI planner is not configured or returned an invalid plan' : undefined; this.persistRuntimes(); }).catch((error) => { this.logger.warn(`NPC 剩余日程更新失败: ${error instanceof Error ? error.message : error}`); }).finally(() => { this.planning.delete(runtime.npcId); }); this.planning.set(runtime.npcId, planning); } getTownStatus(now = this.clock.now()): WorldNpcTownStatus { return { serverNow: now, townDate: townDate(now), townMinute: townMinute(now), clockScale: this.clock.getScale(), plannerConfigured: typeof this.planner.isPlannerConfigured === 'function' && this.planner.isPlannerConfigured(), dialogueConfigured: typeof this.planner.isDialogueConfigured === 'function' && this.planner.isDialogueConfigured(), socialEnabled: process.env.WORLD_NPC_SOCIAL_ENABLED !== 'off', pendingPlanCount: this.planning.size, pendingConversationCount: this.socialPlanning.size, npcs: [...this.runtimes.values()].map((runtime) => ({ definition: getWorldNpcDefinition(runtime.npcId), mapId: runtime.mapId, locationId: runtime.locationId, state: runtime.state, plan: runtime.plan, currentActivity: runtime.plan.activities.find((item) => item.id === runtime.activityId) || this.currentActivity(runtime.plan, townMinute(now)), activeAction: runtime.activeAction, queuedActions: runtime.actionQueue, memoryCount: runtime.memories.length + runtime.residentSummaries.length + [...this.residentSessions.entries()].filter(([key, session]) => key.startsWith(`${runtime.npcId}:`) && session.turns.length > 0).length, recentNpcEncounters: runtime.memories.filter((memory) => memory.kind === 'npc').slice(-5) .map((memory) => ({ peerNpcId: memory.peerNpcId, encounterId: memory.encounterId, activityId: memory.activityId, locationId: memory.locationId, createdAt: memory.createdAt, })), plannerFallbackReason: runtime.plannerFallbackReason, })), }; } async setTownTimeForTesting(now?: number): Promise { this.clock.setForTesting(now); await this.tick(this.clock.now()); return this.getTownStatus(); } private async ensureDailyPlan( runtime: WorldNpcRuntime, now: number, allowAgentRefresh = false, ): Promise { const date = townDate(now); if (runtime.plan.date === date && (!allowAgentRefresh || runtime.plan.source === 'agent')) return; const existing = this.planning.get(runtime.npcId); if (existing) return existing; const planning = (async () => { const definition = getWorldNpcDefinition(runtime.npcId); const generatedPlan = await this.planner.createDailyPlan(definition, this.planningContext(runtime, now), now); const plan = this.constrainPlanToDefinition(generatedPlan, definition); if (plan.date !== runtime.plan.date || (allowAgentRefresh && plan.source === 'agent')) { if (runtime.plan.date !== plan.date) runtime.previousDailyPlan = runtime.plan; runtime.plan = plan; runtime.activityId = ''; runtime.actionQueue = []; runtime.plannerFallbackReason = plan.source === 'fallback' ? 'AI planner is not configured or returned an invalid plan' : undefined; this.persistRuntimes(); } })().finally(() => { this.planning.delete(runtime.npcId); }); this.planning.set(runtime.npcId, planning); return planning; } private currentActivity(plan: WorldNpcDailyPlan, minute: number): WorldNpcActivity { return plan.activities.find((item) => minute >= item.startMinute && minute < item.endMinute) || plan.activities[plan.activities.length - 1]; } private buildActionQueue(runtime: WorldNpcRuntime, activity: WorldNpcActivity): WorldNpcAction[] { const route = findWorldRoute(runtime.locationId, activity.locationId); const actions: WorldNpcAction[] = []; const targetPoint = this.locationPointForNpc(runtime.npcId, activity.locationId); for (let index = 0; index < route.length - 1; index += 1) { const from = getWorldLocation(route[index]); const to = getWorldLocation(route[index + 1]); const kind = getRouteKind(from.id, to.id); const action = this.makeAction(kind, from.id, to.id, activity, 1_000); if (index === 0) { action.fromX = runtime.x; action.fromY = runtime.y; } if (index === route.length - 2 && kind === 'walk') { action.toX = targetPoint.x; action.toY = targetPoint.y; } const distance = Math.hypot(action.toX - action.fromX, action.toY - action.fromY); const duration = kind === 'transition' ? TRANSITION_DURATION_MS : Math.max(1_000, Math.round(distance / WALK_SPEED_PIXELS_PER_SECOND * 1_000)); action.completesAt = duration; actions.push(action); } const perform = this.makeAction('perform', activity.locationId, activity.locationId, activity, 1_000); perform.fromX = targetPoint.x; perform.fromY = targetPoint.y; perform.toX = targetPoint.x; perform.toY = targetPoint.y; actions.push(perform); return actions; } private locationPointForNpc(npcId: string, locationId: string): { x: number; y: number } { const location = getWorldLocation(locationId); const definition = getWorldNpcDefinition(npcId); if (definition.stationary) { if (location.id !== definition.homeLocationId) { throw new Error(`Stationary NPC ${npcId} cannot use world location ${locationId}`); } return this.fixedPointFor(definition); } if (!location.slots?.length) return { x: location.x, y: location.y }; const definitionIndex = WORLD_NPC_DEFINITIONS.findIndex((item) => item.npcId === npcId); if (definitionIndex < 0) throw new Error(`Unknown world NPC: ${npcId}`); const slot = location.slots[definitionIndex]; if (!slot) throw new Error(`World location ${locationId} has no slot for NPC ${npcId}`); return { x: slot.x, y: slot.y }; } private makeAction( kind: WorldNpcAction['kind'], fromLocationId: string, toLocationId: string, activity: WorldNpcActivity, duration: number, ): WorldNpcAction { const from = getWorldLocation(fromLocationId); const to = getWorldLocation(toLocationId); return { actionId: '', kind, fromX: from.x, fromY: from.y, toX: to.x, toY: to.y, fromMapId: from.mapId, toMapId: to.mapId, fromLocationId, toLocationId, activityId: activity.id, activityKind: activity.activityKind, startedAt: 0, completesAt: duration, version: 0, }; } private startAction(runtime: WorldNpcRuntime, action: WorldNpcAction, now: number): void { const duration = Math.max(1, action.completesAt - action.startedAt); action.startedAt = now; action.completesAt = action.kind === 'perform' ? Math.max(now + 1_000, this.activityEndAt(runtime, action.activityId, now)) : now + duration; action.version = ++runtime.version; action.actionId = `${runtime.npcId}_${action.activityId}_${action.version}`; runtime.activeAction = action; if (action.kind === 'walk') { runtime.state = 'walking'; runtime.direction = this.directionFor(action); } else if (action.kind === 'transition') { runtime.state = 'travelling'; } else { runtime.state = action.activityKind === 'socialize' ? 'talking' : 'working'; } } private activityEndAt(runtime: WorldNpcRuntime, activityId: string, now: number): number { const activity = runtime.plan.activities.find((item) => item.id === activityId) || this.currentActivity(runtime.plan, townMinute(now)); const dayStart = Date.parse(`${runtime.plan.date}T00:00:00+08:00`); return Number.isFinite(dayStart) ? dayStart + activity.endMinute * 60_000 : now + 1_000; } private finishAction(runtime: WorldNpcRuntime, action: WorldNpcAction): void { const destination = getWorldLocation(action.toLocationId); runtime.locationId = destination.id; runtime.mapId = destination.mapId; runtime.x = action.toX; runtime.y = action.toY; runtime.direction = action.kind === 'walk' ? this.directionFor(action) : runtime.direction; runtime.state = 'idle'; runtime.activeAction = undefined; } private toSnapshotItem(runtime: WorldNpcRuntime, now: number): WorldNpcSnapshotItem { const definition = getWorldNpcDefinition(runtime.npcId); const activity = runtime.plan.activities.find((item) => item.id === runtime.activityId) || this.currentActivity(runtime.plan, townMinute(now)); let x = runtime.x; let y = runtime.y; if (definition.stationary) ({ x, y } = this.fixedPointFor(definition)); else if (runtime.activeAction?.kind === 'walk') ({ x, y } = this.interpolate(runtime.activeAction, now)); return { npcId: runtime.npcId, mapId: runtime.mapId, name: definition.name, x, y, direction: runtime.direction, movementState: !definition.stationary && runtime.activeAction?.kind === 'walk' ? 'walk' : 'idle', state: runtime.state, version: runtime.version, publicIntention: activity.intention, dialogue: activity.dialogue, scene: definition.scene, currentActivity: activity, dailyGoal: runtime.plan.goal, planSource: runtime.plan.source, activeAction: runtime.activeAction, }; } private directionFor(action: WorldNpcAction): WorldNpcDirection { const dx = action.toX - action.fromX; const dy = action.toY - action.fromY; return Math.abs(dx) > Math.abs(dy) ? (dx >= 0 ? 'right' : 'left') : (dy >= 0 ? 'down' : 'up'); } private interpolate(action: WorldNpcAction, now: number): { x: number; y: number } { const duration = Math.max(1, action.completesAt - action.startedAt); const progress = Math.max(0, Math.min(1, (now - action.startedAt) / duration)); return { x: action.fromX + (action.toX - action.fromX) * progress, y: action.fromY + (action.toY - action.fromY) * progress, }; } private eventFor(npcId: string, action: WorldNpcAction, mapId: string, now: number): WorldNpcActionEvent { return { mapId, serverNow: now, npcId, action }; } private loadRuntimes(now: number): Map { const loaded = new Map(); if (process.env.WORLD_NPC_PERSISTENCE !== 'off' && existsSync(this.statePath)) { try { const parsed = JSON.parse(readFileSync(this.statePath, 'utf8')) as PersistedTownState | WorldNpcRuntime; const persisted = 'runtimes' in parsed && Array.isArray(parsed.runtimes) ? parsed.runtimes : [parsed as WorldNpcRuntime]; for (const runtime of persisted) { const definition = WORLD_NPC_DEFINITIONS.find((item) => item.npcId === runtime.npcId); if (definition) loaded.set(definition.npcId, this.normalizeRuntime(definition, runtime, now)); } } catch (error) { this.logger.warn(`NPC 状态恢复失败,将从注册表启动: ${error instanceof Error ? error.message : error}`); } } for (const definition of WORLD_NPC_DEFINITIONS) { if (!loaded.has(definition.npcId)) loaded.set(definition.npcId, this.createRuntime(definition, now)); } return loaded; } private normalizeRuntime( definition: WorldNpcDefinition, value: WorldNpcRuntime, now: number, ): WorldNpcRuntime { if (definition.stationary) { const location = getWorldLocation(definition.homeLocationId); const point = this.fixedPointFor(definition); const plan = this.constrainPlanToDefinition( value.plan?.date === townDate(now) ? value.plan : fallbackNpcPlan(definition, now), definition, ); return { ...value, npcId: definition.npcId, mapId: location.mapId, locationId: location.id, x: point.x, y: point.y, direction: value.direction || 'down', state: 'idle', plan, previousDailyPlan: value.plan?.date !== townDate(now) ? value.plan : value.previousDailyPlan, activityId: '', actionQueue: [], activeAction: undefined, memories: Array.isArray(value.memories) ? value.memories.filter((memory) => memory.kind !== 'player').slice(-MAX_MEMORIES_PER_NPC) : [], residentSummaries: Array.isArray(value.residentSummaries) ? value.residentSummaries : this.migrateResidentSummaries(value.memories, now), plannerFallbackReason: plan.source === 'fallback' ? value.plannerFallbackReason || 'AI planner is not configured or returned an invalid plan' : undefined, }; } let location = getWorldLocation(value.locationId || definition.homeLocationId); let point = this.locationPointForNpc(definition.npcId, location.id); const plan = value.plan?.date === townDate(now) ? value.plan : fallbackNpcPlan(definition, now); const restoredAction = this.restorePersistedAction(value.activeAction, plan, now); if (restoredAction?.kind === 'perform') { restoredAction.fromX = point.x; restoredAction.fromY = point.y; restoredAction.toX = point.x; restoredAction.toY = point.y; } if (value.activeAction && !restoredAction && value.activeAction.completesAt <= now) { try { location = getWorldLocation(value.activeAction.toLocationId); if (this.isPointNearLocation(value.activeAction.toX, value.activeAction.toY, location)) { point = { x: value.activeAction.toX, y: value.activeAction.toY }; } else { point = this.locationPointForNpc(definition.npcId, location.id); } } catch { // Invalid persisted destinations fall back to the last verified semantic location. } } return { ...value, npcId: definition.npcId, mapId: location.mapId, locationId: location.id, x: point.x, y: point.y, state: restoredAction ? this.stateForAction(restoredAction) : 'idle', plan, previousDailyPlan: value.plan?.date !== townDate(now) ? value.plan : value.previousDailyPlan, activityId: restoredAction?.activityId || '', actionQueue: [], activeAction: restoredAction, memories: Array.isArray(value.memories) ? value.memories.filter((memory) => memory.kind !== 'player').slice(-MAX_MEMORIES_PER_NPC) : [], residentSummaries: Array.isArray(value.residentSummaries) ? value.residentSummaries : this.migrateResidentSummaries(value.memories, now), plannerFallbackReason: plan.source === 'fallback' ? value.plannerFallbackReason || 'AI planner is not configured or returned an invalid plan' : undefined, }; } private restorePersistedAction( value: WorldNpcAction | undefined, plan: WorldNpcDailyPlan, now: number, ): WorldNpcAction | undefined { if (!value || value.startedAt > now || value.completesAt <= now) return undefined; if (!plan.activities.some((activity) => activity.id === value.activityId)) return undefined; try { const from = getWorldLocation(value.fromLocationId); const to = getWorldLocation(value.toLocationId); if (value.kind === 'perform') { if (from.id !== to.id) return undefined; } else if (getRouteKind(from.id, to.id) !== value.kind) { return undefined; } if (!this.isPointNearLocation(value.fromX, value.fromY, from) || !this.isPointNearLocation(value.toX, value.toY, to)) return undefined; return { ...value, fromMapId: from.mapId, toMapId: to.mapId, }; } catch { return undefined; } } private isPointNearLocation(x: number, y: number, location: WorldLocation): boolean { if (!Number.isFinite(x) || !Number.isFinite(y)) return false; return [{ x: location.x, y: location.y }, ...(location.slots || [])] .some((point) => Math.hypot(x - point.x, y - point.y) <= 30); } private stateForAction(action: WorldNpcAction): WorldNpcRuntime['state'] { if (action.kind === 'walk') return 'walking'; if (action.kind === 'transition') return 'travelling'; return action.activityKind === 'socialize' ? 'talking' : 'working'; } private createRuntime(definition: WorldNpcDefinition, now: number): WorldNpcRuntime { const location = getWorldLocation(definition.homeLocationId); const point = this.locationPointForNpc(definition.npcId, location.id); return { npcId: definition.npcId, mapId: location.mapId, locationId: location.id, x: point.x, y: point.y, direction: 'down', state: 'idle', version: 1, plan: fallbackNpcPlan(definition, now), activityId: '', actionQueue: [], memories: [], residentSummaries: [], plannerFallbackReason: 'AI planner is not configured or returned an invalid plan', }; } private fixedPointFor(definition: WorldNpcDefinition): { x: number; y: number } { if (!definition.fixedPosition) { throw new Error(`Stationary NPC ${definition.npcId} is missing fixedPosition`); } return { x: definition.fixedPosition.x, y: definition.fixedPosition.y }; } private constrainPlanToDefinition( plan: WorldNpcDailyPlan, definition: WorldNpcDefinition, ): WorldNpcDailyPlan { if (!definition.stationary) return plan; const activities = plan.activities.map((activity) => activity.locationId === definition.homeLocationId ? activity : { ...activity, locationId: definition.homeLocationId }); return activities.every((activity, index) => activity === plan.activities[index]) ? plan : { ...plan, activities }; } private migrateResidentSummaries(memories: WorldNpcMemory[] | undefined, now: number): WorldNpcResidentSummary[] { const grouped = new Map(); for (const memory of memories || []) { if (memory.kind !== 'player' || !memory.userId) continue; const list = grouped.get(memory.userId) || []; list.push(memory); grouped.set(memory.userId, list); } return [...grouped.entries()].map(([userId, items]) => ({ userId, username: items.at(-1)?.username || '居民', summary: items.map((item) => `居民:${item.message}\nNPC:${item.response}`).join('\n').slice(-2000), sessionCount: 1, updatedAt: now, })); } private planningContext(runtime: WorldNpcRuntime, now = this.clock.now()): WorldNpcPlanningContext { const activeResidentSignals = [...this.residentSessions.entries()] .filter(([key, session]) => key.startsWith(`${runtime.npcId}:`) && session.turns.length > 0) .map(([, session]) => session.turns .filter((turn) => turn.role === 'user') .map((turn) => turn.content) .join(' ') .slice(-600)) .filter(Boolean); return { previousDailyPlan: runtime.plan.date !== townDate(now) ? runtime.plan : runtime.previousDailyPlan, npcMemories: runtime.memories.filter((memory) => memory.kind === 'npc'), residentNeedSummaries: runtime.residentSummaries.map((summary) => summary.summary), activeResidentSignals, }; } private requireRuntime(npcId: string): WorldNpcRuntime { const runtime = this.runtimes.get(npcId); if (!runtime) throw new Error(`NPC不存在: ${npcId}`); return runtime; } private persistRuntimes(): void { if (process.env.WORLD_NPC_PERSISTENCE === 'off' || process.env.NODE_ENV === 'test') return; try { mkdirSync(dirname(this.statePath), { recursive: true }); const tempPath = `${this.statePath}.tmp`; const state: PersistedTownState = { version: 2, runtimes: [...this.runtimes.values()] }; writeFileSync(tempPath, JSON.stringify(state, null, 2), 'utf8'); renameSync(tempPath, this.statePath); } catch (error) { this.logger.error(`NPC 状态持久化失败: ${error instanceof Error ? error.message : error}`); } } }