Files
whale-town-end-v2/src/business/world_npc/world_npc.service.ts

859 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, WorldNpcRuntime>();
private planning = new Map<string, Promise<void>>();
private lastReplanRequestedAt = new Map<string, number>();
private socialPlanning = new Map<string, Promise<void>>();
private socializedEncounters = new Set<string>();
private socialBusyNpcIds = new Set<string>();
private lastSocializedAt = new Map<string, number>();
private pendingConversations: WorldNpcConversationEvent[] = [];
private residentSessions = new Map<string, { sessionId: string; turns: WorldNpcResidentTurn[]; lastActivityAt: number }>();
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<void> {
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<WorldNpcTickResult> {
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<void> {
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<WorldNpcInteractionResult> {
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<void> {
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<void> {
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<WorldNpcTownStatus> {
this.clock.setForTesting(now);
await this.tick(this.clock.now());
return this.getTownStatus();
}
private async ensureDailyPlan(
runtime: WorldNpcRuntime,
now: number,
allowAgentRefresh = false,
): Promise<void> {
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<string, WorldNpcRuntime> {
const loaded = new Map<string, WorldNpcRuntime>();
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<string, WorldNpcMemory[]>();
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}`);
}
}
}