forked from xiangwang25/whale-town-end-v2
feat: integrate invitation access, world NPCs, and deployment
This commit is contained in:
858
src/business/world_npc/world_npc.service.ts
Normal file
858
src/business/world_npc/world_npc.service.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user