import { WorldNpcPlanner, fallbackResearcherPlan, townDate } from './world_npc.planner'; import { WorldNpcService } from './world_npc.service'; import { WorldNpcDailyPlan } from './world_npc.types'; import { findWorldRoute } from './world_npc.world'; describe('WorldNpcService', () => { const previousPersistence = process.env.WORLD_NPC_PERSISTENCE; let service: WorldNpcService; beforeEach(() => { process.env.WORLD_NPC_PERSISTENCE = 'off'; const planner = { createDailyPlan: async (_definition: unknown, _context: unknown, now: number) => fallbackResearcherPlan(now), } as unknown as WorldNpcPlanner; service = new WorldNpcService(planner); }); afterAll(() => { process.env.WORLD_NPC_PERSISTENCE = previousPersistence; }); it('returns the versioned NPC snapshot only on its current map', () => { const snapshot = service.getMapSnapshot('whale_port'); expect(snapshot.npcs[0]).toEqual(expect.objectContaining({ npcId: 'npc_whale_researcher', name: '鲸小研', dailyGoal: expect.any(String), planSource: 'fallback', })); expect(service.getMapSnapshot('work_zone').npcs).toEqual([]); }); it('builds a semantic cross-map route instead of raw coordinate patrol', () => { const route = findWorldRoute('square_dock_research', 'cafe_research_table'); expect(route[0]).toBe('square_dock_research'); expect(route[route.length - 1]).toBe('cafe_research_table'); expect(route).toEqual(expect.arrayContaining([ 'square_work_gate', 'work_square_gate', 'work_cafe_gate', 'cafe_entrance', ])); }); it('executes todays activity route continuously without teleporting to the initial point', async () => { 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); let clock = now; const observedLocations = ['square_dock_research']; let sawTransition = false; for (let index = 0; index < 24; index += 1) { const result = await service.tick(clock); const active = service.getRuntimeForTesting().activeAction; expect(active).toBeDefined(); if (active?.kind === 'transition') sawTransition = true; clock = active!.completesAt + 1; await service.tick(clock); observedLocations.push(service.getRuntimeForTesting().locationId); if (service.getRuntimeForTesting().locationId === 'cafe_research_table') break; } expect(sawTransition).toBe(true); expect(observedLocations).toContain('work_square_gate'); expect(observedLocations[observedLocations.length - 1]).toBe('cafe_research_table'); expect(observedLocations.slice(1)).not.toContain('square_dock_research'); expect(service.getMapSnapshot('whale_cafe', clock).npcs[0]).toEqual(expect.objectContaining({ state: 'talking', publicIntention: '前往咖啡馆访谈', })); }); it('uses deterministic schedules that cover the full town day', () => { const plan = fallbackResearcherPlan(Date.now()); expect(plan.activities[0].startMinute).toBe(0); expect(plan.activities[plan.activities.length - 1].endMinute).toBe(1440); plan.activities.slice(1).forEach((item, index) => { expect(plan.activities[index].endMinute).toBe(item.startMinute); }); }); });