feat: integrate invitation access, world NPCs, and deployment

This commit is contained in:
2026-09-08 22:22:38 +08:00
parent 2a3125075f
commit 513a3eba31
75 changed files with 9566 additions and 1070 deletions

View File

@@ -0,0 +1,858 @@
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}`);
}
}
}