forked from xiangwang25/whale-town-end-v2
859 lines
37 KiB
TypeScript
859 lines
37 KiB
TypeScript
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}`);
|
||
}
|
||
}
|
||
}
|