import assert from 'node:assert/strict'; import { buildNpcInteractionMessages, fallbackNpcPlan, townDate, WorldNpcPlanner, } from '../src/business/world_npc/world_npc.planner'; import { WorldNpcService } from '../src/business/world_npc/world_npc.service'; import { WorldNpcConversationEvent, WorldNpcDailyPlan } from '../src/business/world_npc/world_npc.types'; import { findWorldRoute, getWorldLocation, isWorldNpcPublicMap, WORLD_LOCATIONS, WORLD_NPC_PUBLIC_MAP_IDS, WORLD_ROUTE_EDGES, } from '../src/business/world_npc/world_npc.world'; import { WorldNpcClock } from '../src/business/world_npc/world_npc.clock'; import { WORLD_NPC_DEFINITIONS } from '../src/business/world_npc/world_npc.registry'; async function main(): Promise { process.env.WORLD_NPC_PERSISTENCE = 'off'; const dialogueMessages = buildNpcInteractionMessages({ definition: { npcId: 'npc_whale_researcher', name: '鲸小研', role: '科研观察员', personality: '友善、好奇', dailyFocus: '收集科研兴趣', homeLocationId: 'square_dock_research', scene: 'classic_whale', }, activity: { id: 'square_interviews', title: '广场访谈', intention: '收集科研话题', locationId: 'square_forum', startMinute: 540, endMinute: 660, activityKind: 'socialize', dialogue: '你最近在研究什么?', }, dailyGoal: '整理居民的科研问题', username: '测试居民', residentSummary: '居民对海洋科学感兴趣', sessionTurns: [ { role: 'user', content: '你还记得我刚才说的方向吗?', createdAt: 1 }, { role: 'assistant', content: '记得,你想先看海流数据。', createdAt: 2 }, ], message: '那我们继续吧。', }); assert.deepEqual(dialogueMessages.map((message) => message.role), ['system', 'user', 'assistant', 'user'], 'short-term NPC conversation must use normal multi-turn chat roles'); assert.match(String(dialogueMessages[0].content), /当前每日目标/); assert.match(String(dialogueMessages[0].content), /海洋科学/); assert.match(String(dialogueMessages[0].content), /Agent 工具 query_npc_memory/); assert.equal(dialogueMessages.at(-1)?.content, '那我们继续吧。'); const realPlanner = new WorldNpcPlanner(); const planningDefinition = { npcId: 'npc_whale_researcher', name: '鲸小研', role: '科研观察员', personality: '友善、好奇', dailyFocus: '长期跟进小镇的科研需求', homeLocationId: 'square_dock_research', scene: 'classic_whale' as const, }; const planningSystemPrompt = (realPlanner as any).systemPrompt(planningDefinition, { previousDailyPlan: fallbackNpcPlan(planningDefinition, Date.parse('2026-08-27T12:00:00+08:00')), npcMemories: [], residentNeedSummaries: ['居民希望今天有一场海洋科学分享'], activeResidentSignals: [], }); assert.match(planningSystemPrompt, /角色长期记忆/); assert.match(planningSystemPrompt, /长期跟进小镇的科研需求/); assert.match(planningSystemPrompt, /海洋科学分享/); assert.match(planningSystemPrompt, /locationName/); assert.doesNotMatch(planningSystemPrompt, /locationId/); const modelPlan = (realPlanner as any).validatePlan({ goal: '在广场整理居民的科研问题', activities: [{ id: 'forum_research', title: '广场科研交流', intention: '收集科研问题', locationName: '广场交流区', startMinute: 0, endMinute: 1440, activityKind: 'socialize', dialogue: '今天想和大家聊聊最近关心的科研问题。', }], }, '2026-08-29'); assert.equal(modelPlan.activities[0].locationId, 'square_forum'); assert.throws(() => (realPlanner as any).validatePlan({ goal: '错误示例', activities: [{ id: 'internal_id', title: '错误地点', intention: '测试内部 ID', locationId: 'square_forum', startMinute: 0, endMinute: 1440, activityKind: 'socialize', dialogue: '这条计划不应该通过校验。', }], }, '2026-08-29'), /invalid enum/); let previousPlanDateSeenByPlanner = ''; const crossDayPlanner = { createDailyPlan: async (definition: any, context: any, planNow: number) => { if (definition.npcId === 'npc_whale_researcher') { previousPlanDateSeenByPlanner = String(context.previousDailyPlan?.date || ''); } return fallbackNpcPlan(definition, planNow); }, } as WorldNpcPlanner; const crossDayService = new WorldNpcService(crossDayPlanner); const previousDayNow = Date.parse('2026-08-28T12:00:00+08:00'); const nextDayNow = Date.parse('2026-08-29T00:01:00+08:00'); crossDayService.replacePlanForTesting(fallbackNpcPlan(planningDefinition, previousDayNow)); await crossDayService.tick(nextDayNow); assert.equal(previousPlanDateSeenByPlanner, '2026-08-28', 'the completed daily plan must become long-term planning memory after midnight'); const planner = { createDailyPlan: async (definition: any, _memories: unknown, planNow: number) => fallbackNpcPlan(definition, planNow), createInteractionReply: async () => '这个问题很有意思,我已经记进今天的研究笔记了。', } as WorldNpcPlanner; const service = new WorldNpcService(planner); const now = Date.now(); const plan: WorldNpcDailyPlan = { date: townDate(now), goal: '去咖啡馆收集研究问题', source: 'agent', activities: [{ id: 'cafe_visit', title: '咖啡馆访谈', intention: '前往咖啡馆访谈', locationId: 'cafe_research_table', startMinute: 0, endMinute: 1440, activityKind: 'socialize', dialogue: '你最近在研究什么?', }], }; service.replacePlanForTesting(plan); for (const edge of WORLD_ROUTE_EDGES) { const from = getWorldLocation(edge.from); const to = getWorldLocation(edge.to); assert(from.x !== to.x || from.y !== to.y || from.mapId !== to.mapId, `zero-length edge: ${edge.from}`); assert.equal(edge.kind === 'transition', from.mapId !== to.mapId, `edge/map mismatch: ${edge.from} -> ${edge.to}`); } for (const location of WORLD_LOCATIONS) { assert(isWorldNpcPublicMap(location.mapId), `private map exposed to NPC planner: ${location.mapId}`); assert(findWorldRoute(WORLD_LOCATIONS[0].id, location.id).length > 0, `disconnected location: ${location.id}`); if (!location.tags.includes('transit')) { const movableDefinitions = WORLD_NPC_DEFINITIONS.filter((definition) => !definition.stationary); assert((location.slots?.length || 0) >= movableDefinitions.length, `activity location must have one slot per movable NPC: ${location.id}`); const slotKeys = new Set(location.slots!.map((slot) => `${slot.x}:${slot.y}`)); assert.equal(slotKeys.size, location.slots!.length, `activity location contains duplicate NPC slots: ${location.id}`); const assignedPoints = movableDefinitions.map((definition) => (service as any).locationPointForNpc(definition.npcId, location.id) as { x: number; y: number }); assert.equal(new Set(assignedPoints.map((point) => `${point.x}:${point.y}`)).size, movableDefinitions.length, `NPC slot assignment reuses a position: ${location.id}`); for (let first = 0; first < location.slots!.length; first += 1) { for (let second = first + 1; second < location.slots!.length; second += 1) { assert(Math.hypot( location.slots![first].x - location.slots![second].x, location.slots![first].y - location.slots![second].y, ) >= 80, `activity location slots are too close: ${location.id}`); } } } } assert(!WORLD_NPC_PUBLIC_MAP_IDS.includes('personal_space' as any), 'personal rooms must not be in the NPC world'); assert.equal(service.getMapSnapshot('personal_space', now).npcs.length, 0, 'personal rooms must never receive world NPC snapshots'); const cafeRoute = findWorldRoute('square_dock_research', 'cafe_research_table'); assert.equal(cafeRoute[0], 'square_dock_research'); assert.equal(cafeRoute[cafeRoute.length - 1], 'cafe_research_table'); assert(cafeRoute.includes('square_work_gate') && cafeRoute.includes('work_square_gate')); assert(cafeRoute.includes('work_cafe_gate') && cafeRoute.includes('cafe_entrance')); let clock = now; let previousLocation = 'square_dock_research'; let sawTransition = false; for (let step = 0; step < 24; step += 1) { await service.tick(clock); const active = service.getRuntimeForTesting().activeAction; assert(active, 'runtime must always have a route or activity action'); assert.equal(active.fromLocationId, previousLocation, 'each action must start at the previous destination'); sawTransition ||= active.kind === 'transition'; clock = active.completesAt + 1; await service.tick(clock); previousLocation = service.getRuntimeForTesting().locationId; if (previousLocation === 'cafe_research_table') break; } const runtime = service.getRuntimeForTesting(); assert.equal(runtime.locationId, 'cafe_research_table'); assert.equal(runtime.mapId, 'whale_cafe'); assert(sawTransition); assert(!service.getMapSnapshot('whale_port', clock).npcs.some((npc) => npc.npcId === 'npc_whale_researcher')); assert.equal(service.getMapSnapshot('whale_cafe', clock).npcs.find((npc) => npc.npcId === 'npc_whale_researcher')?.state, 'talking'); await assert.rejects(() => service.interact({ npcId: 'npc_whale_researcher', userId: 'far-user', username: '远处玩家', mapId: 'whale_cafe', x: 1_000, y: 1_000, message: '听得到吗?', now: clock, }), /距离NPC太远/); const interaction = await service.interact({ npcId: 'npc_whale_researcher', userId: 'near-user', username: '测试居民', mapId: 'whale_cafe', x: -200, y: 300, message: '今天研究什么?', now: clock, }); assert.equal(interaction.response, '这个问题很有意思,我已经记进今天的研究笔记了。'); assert.equal(service.getTownStatus(clock).npcs.find((npc) => npc.definition.npcId === 'npc_whale_researcher')?.memoryCount, 1); assert.equal(service.getTownStatus(clock).npcs.length, WORLD_NPC_DEFINITIONS.length); const replanNow = Date.parse('2026-08-28T10:00:00+08:00'); const initialReplanPlan = fallbackNpcPlan({ npcId: 'npc_whale_researcher', name: '鲸小研', role: '科研交流员', personality: '好奇、耐心', dailyFocus: '收集科研兴趣', homeLocationId: 'square_dock_research', scene: 'classic_whale', }, replanNow); let revisionMemoryCount = 0; const replanningPlanner = { createDailyPlan: async () => initialReplanPlan, createInteractionReply: async () => '我会把这个需求放进今天后续的安排。', reviseRemainingPlan: async (_definition: unknown, current: WorldNpcDailyPlan, context: any) => { revisionMemoryCount = context.npcMemories.length + context.residentNeedSummaries.length + context.activeResidentSignals.length; return { ...current, goal: '根据居民需求调整今天剩余的科研交流', source: 'agent' as const, revisionReason: 'interaction' as const, generatedAt: replanNow, activities: current.activities.map((activity) => activity.id === 'evening_share' ? { ...activity, locationId: 'square_forum', intention: '回应居民提出的新需求' } : activity), }; }, } as unknown as WorldNpcPlanner; const replanningService = new WorldNpcService(replanningPlanner); replanningService.replacePlanForTesting(initialReplanPlan); await replanningService.tick(replanNow); let routeAction = replanningService.getRuntimeForTesting().activeAction; assert(routeAction && routeAction.kind === 'walk'); let replanClock = routeAction.completesAt + 1; while (routeAction && routeAction.kind !== 'perform') { await replanningService.tick(replanClock); routeAction = replanningService.getRuntimeForTesting().activeAction; if (routeAction && routeAction.kind !== 'perform') replanClock = routeAction.completesAt + 1; } assert.equal(replanningService.getRuntimeForTesting().locationId, 'square_forum'); const activityBeforeRevision = replanningService.getRuntimeForTesting().activeAction; assert.equal(activityBeforeRevision?.kind, 'perform'); assert.equal(activityBeforeRevision?.completesAt, Date.parse('2026-08-28T11:00:00+08:00'), 'activity end must stay anchored to the daily schedule after travel'); process.env.WORLD_NPC_REPLAN_COOLDOWN_MS = '0'; const replanSnapshot = replanningService.getMapSnapshot('whale_port', replanClock).npcs.find((npc) => npc.npcId === 'npc_whale_researcher')!; await replanningService.interact({ npcId: 'npc_whale_researcher', userId: 'replan-user', username: '提出需求的居民', mapId: 'whale_port', x: replanSnapshot.x, y: replanSnapshot.y, message: '傍晚可以在广场回应这个问题吗?', now: replanClock, }); await new Promise((resolve) => setImmediate(resolve)); const revisedRuntime = replanningService.getRuntimeForTesting(); assert.equal(revisionMemoryCount, 1); assert.equal(revisedRuntime.plan.revisionReason, 'interaction'); assert.equal(revisedRuntime.plan.goal, '根据居民需求调整今天剩余的科研交流'); assert.equal(revisedRuntime.activeAction?.actionId, activityBeforeRevision?.actionId, 'replanning must not interrupt the current activity'); assert.equal(revisedRuntime.plan.activities.find((activity) => activity.id === 'evening_share')?.locationId, 'square_forum'); delete process.env.WORLD_NPC_REPLAN_COOLDOWN_MS; const persistedAction = routeAction!; const persistedRuntime = replanningService.getRuntimeForTesting(); persistedRuntime.locationId = persistedAction.fromLocationId; persistedRuntime.mapId = persistedAction.fromMapId; persistedRuntime.x = persistedAction.fromX; persistedRuntime.y = persistedAction.fromY; persistedRuntime.activityId = persistedAction.activityId; persistedRuntime.activeAction = persistedAction; const normalizeRuntime = (replanningService as any).normalizeRuntime.bind(replanningService); const restored = normalizeRuntime( replanningService.getTownStatus(replanNow).npcs.find((npc) => npc.definition.npcId === 'npc_whale_researcher')!.definition, persistedRuntime, persistedAction.startedAt + 1, ); assert.equal(restored.activeAction?.actionId, persistedAction.actionId, 'a running persisted route must survive a server restart'); const completedOnRestart = normalizeRuntime( replanningService.getTownStatus(replanNow).npcs.find((npc) => npc.definition.npcId === 'npc_whale_researcher')!.definition, persistedRuntime, persistedAction.completesAt + 1, ); assert.equal(completedOnRestart.locationId, persistedAction.toLocationId, 'an expired persisted route must recover at its destination'); const dayStart = Date.parse('2026-08-28T00:00:00+08:00'); for (const npcId of ['npc_whale_researcher']) { const dayService = new WorldNpcService(planner); const definition = dayService.getTownStatus(dayStart).npcs.find((npc) => npc.definition.npcId === npcId)!.definition; const dayPlan = fallbackNpcPlan(definition, dayStart); dayService.replacePlanForTesting(dayPlan, npcId); for (const scheduledActivity of dayPlan.activities) { let activityClock = dayStart + scheduledActivity.startMinute * 60_000; let reachedActivity = false; for (let actionStep = 0; actionStep < 20; actionStep += 1) { await dayService.tick(activityClock); const active = dayService.getRuntimeForTesting(npcId).activeAction; assert(active, `${npcId}/${scheduledActivity.id} must have an active action`); if (active.kind === 'perform' && active.activityId === scheduledActivity.id) { reachedActivity = true; break; } activityClock = active.completesAt + 1; } const arrived = dayService.getRuntimeForTesting(npcId); assert(reachedActivity, `${npcId}/${scheduledActivity.id} did not reach its activity`); assert.equal(arrived.locationId, scheduledActivity.locationId, `${npcId}/${scheduledActivity.id} arrived at the wrong semantic location`); assert.equal(arrived.mapId, getWorldLocation(scheduledActivity.locationId).mapId, `${npcId}/${scheduledActivity.id} arrived on the wrong map`); } } for (const [npcId, expected] of [ ['npc_town_mayor', { locationId: 'square_guild_reception', x: -199, y: -515 }], ['npc_dock_guide', { locationId: 'square_dock_guide', x: -825, y: 437 }], ] as const) { const stationaryService = new WorldNpcService(planner); const definition = stationaryService.getTownStatus(dayStart).npcs.find((npc) => npc.definition.npcId === npcId)!.definition; stationaryService.replacePlanForTesting(fallbackNpcPlan(definition, dayStart), npcId); for (const minute of [0, 600, 1200]) { await stationaryService.tick(dayStart + minute * 60_000); const runtime = stationaryService.getRuntimeForTesting(npcId); assert.equal(runtime.locationId, expected.locationId); assert.deepEqual({ x: runtime.x, y: runtime.y }, { x: expected.x, y: expected.y }); assert.equal(runtime.activeAction?.kind, 'perform'); assert.equal(runtime.activeAction?.fromLocationId, expected.locationId); assert.equal(runtime.activeAction?.toLocationId, expected.locationId); assert.equal(stationaryService.getMapSnapshot('whale_port', dayStart + minute * 60_000) .npcs.find((npc) => npc.npcId === npcId)?.movementState, 'idle'); } } let socialPlannerCalls = 0; const socialPlanner = { createDailyPlan: async (definition: any, _memories: unknown, planNow: number) => fallbackNpcPlan(definition, planNow), createInteractionReply: async () => '', createNpcConversation: async (input: any) => { socialPlannerCalls += 1; return [ { speakerNpcId: input.first.npcId, speakerName: input.first.name, text: '我收集到一个值得继续研究的问题。' }, { speakerNpcId: input.second.npcId, speakerName: input.second.name, text: '我会把它带到今天的居民交流里。' }, ]; }, } as WorldNpcPlanner; const socialService = new WorldNpcService(socialPlanner); const socialDate = townDate(replanNow); const atForum = (id: string): WorldNpcDailyPlan => ({ date: socialDate, goal: '在广场交换今天的信息', source: 'agent', activities: [{ id, title: '广场交流', intention: '与其他小镇成员交换信息', locationId: 'square_forum', startMinute: 0, endMinute: 1440, activityKind: 'socialize', dialogue: '今天有什么新消息?', }], }); const atReception = (id: string): WorldNpcDailyPlan => ({ date: socialDate, goal: '在公会接待处交换今天的信息', source: 'agent', activities: [{ id, title: '接待处交流', intention: '与到访居民交换信息', locationId: 'square_guild_reception', startMinute: 0, endMinute: 1440, activityKind: 'socialize', dialogue: '今天有什么新消息?', }], }); socialService.replacePlanForTesting(atReception('researcher_social'), 'npc_whale_researcher'); socialService.replacePlanForTesting(atReception('mayor_social'), 'npc_town_mayor'); const nonSocialPlan = (id: string, locationId: string): WorldNpcDailyPlan => ({ date: socialDate, goal: '独立完成今天的工作', source: 'agent', activities: [{ id, title: '独立工作', intention: '完成自己的日常工作', locationId, startMinute: 0, endMinute: 1440, activityKind: 'organize', dialogue: '我正在整理今天的工作。', }], }); socialService.replacePlanForTesting(nonSocialPlan('guide_work', 'square_dock_guide'), 'npc_dock_guide'); socialService.replacePlanForTesting(nonSocialPlan('niulai_work', 'square_dock_research'), 'npc_niulai'); let socialClock = replanNow; const socialConversations: WorldNpcConversationEvent[] = []; for (let step = 0; step < 12; step += 1) { const tickResult = await socialService.tick(socialClock); socialConversations.push(...tickResult.conversations); const participants = ['npc_whale_researcher', 'npc_town_mayor'] .map((npcId) => socialService.getRuntimeForTesting(npcId)); if (participants.every((runtime) => runtime.locationId === 'square_guild_reception' && runtime.activeAction?.kind === 'perform')) break; const completionTimes = participants .map((runtime) => runtime.activeAction?.completesAt) .filter((value): value is number => typeof value === 'number' && value > socialClock); assert(completionTimes.length > 0, 'social participants stopped before reaching the forum'); socialClock = Math.min(...completionTimes) + 1; } await new Promise((resolve) => setImmediate(resolve)); const socialTick = await socialService.tick(socialClock); socialConversations.push(...socialTick.conversations); assert.equal(socialPlannerCalls, 1, 'one co-located NPC pair should create one encounter'); assert.equal(socialConversations.length, 1); assert.deepEqual(socialConversations[0].participantNpcIds, ['npc_town_mayor', 'npc_whale_researcher']); assert.equal(socialService.getTownStatus(socialClock).npcs.find((npc) => npc.definition.npcId === 'npc_whale_researcher')?.recentNpcEncounters.length, 1); const socialPositions = ['npc_whale_researcher', 'npc_town_mayor'] .map((npcId) => socialService.getMapSnapshot('whale_port', socialClock).npcs.find((npc) => npc.npcId === npcId)!); assert.notDeepEqual( { x: socialPositions[0].x, y: socialPositions[0].y }, { x: socialPositions[1].x, y: socialPositions[1].y }, 'co-located NPCs must use distinct visual slots', ); assert(Math.hypot(socialPositions[0].x - socialPositions[1].x, socialPositions[0].y - socialPositions[1].y) <= 160, 'conversation slots must remain visually close'); await new Promise((resolve) => setImmediate(resolve)); const duplicateSocialTick = await socialService.tick(socialClock + 1); assert.equal(duplicateSocialTick.conversations.length, 0, 'the same encounter must not repeat'); assert.equal(socialPlannerCalls, 1); process.env.WORLD_NPC_TIME_SCALE = '60'; const worldClock = new WorldNpcClock(); const realAnchor = Date.now(); const acceleratedDelta = worldClock.now(realAnchor + 1_000) - worldClock.now(realAnchor); assert.equal(acceleratedDelta, 60_000); worldClock.setForTesting(Date.parse('2026-08-28T10:00:00+08:00')); assert.equal(worldClock.now(), Date.parse('2026-08-28T10:00:00+08:00')); console.log('WORLD_NPC_RUNTIME_OK'); } void main().catch((error) => { console.error(error); process.exitCode = 1; });