411 lines
22 KiB
TypeScript
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: 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<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;
|
|
});
|