Files
whale-town-end-v2/scripts/test_world_npc_runtime.ts

411 lines
22 KiB
TypeScript

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<void> {
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<void>((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: 475 }],
] 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<void>((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<void>((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;
});