import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { EventEmitter2 } from "@nestjs/event-emitter"; import { afterEach, describe, expect, it } from "vitest"; import { createPrismaClient } from "@kokorone/db"; import type { PrismaService } from "../prisma/prisma.service.js"; import { ControllableClock } from "../clock/controllable-clock.js"; import { EmotionService } from "../emotion/emotion.service.js"; import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js"; import { RelationshipService } from "../relationship/relationship.service.js"; import { RomanceService } from "../romance/romance.service.js"; import { RomanceEligibilityService } from "../romance/romance-eligibility.service.js"; import { AgeDeterminationService } from "../romance/age-determination.service.js"; import { ScheduleService } from "../schedule/schedule.service.js"; import { SleepDebtService } from "../schedule/sleep-debt.service.js"; import { PersonalityDriftService } from "../epilogue/personality-drift.service.js"; import { PersonalityResolutionService } from "../personality/personality-resolution.service.js"; import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js"; import { MockProvider } from "../llm/mock-provider.service.js"; import { GlobalPauseService } from "../llm/global-pause.service.js"; import { LobbyService } from "./lobby.service.js"; import { RoomEventsService } from "./room-events.service.js"; import { RoomService } from "./room.service.js"; import { CharacterRelationshipService } from "./character-relationship.service.js"; import { SpeakingRightService } from "./speaking-right.service.js"; import { RoomGenerationService } from "./room-generation.service.js"; import { BehaviorSelectionService } from "./behavior-selection.service.js"; import { SelfChatService } from "./self-chat.service.js"; import { TaskService } from "../task/task.service.js"; import { TaskSchedulerService } from "../task/task-scheduler.service.js"; import { TaskTriggerService } from "../task/task-trigger.service.js"; import { SentimentLedgerService } from "../relationship/sentiment-ledger.service.js"; import { PushTokenService } from "../push/push-token.service.js"; import { PushNotificationService } from "../push/push-notification.service.js"; import { MockPushProvider } from "../push/mock-push-provider.service.js"; import { InProcessJobQueue } from "../memory/in-process-job-queue.service.js"; import { RoomNotificationService } from "./room-notification.service.js"; import { PossessionService } from "./possession.service.js"; import { LifeEventService } from "../epilogue/life-event.service.js"; import { EpilogueProgressService } from "../epilogue/epilogue-progress.service.js"; import { ProjectionService } from "../canon/projection.service.js"; import { BehaviorReinforcementService } from "../llm/behavior-reinforcement.service.js"; // T6 驗收:多人房實測四種行為都出現過、每次離開都能對應到三項動因之一,且房間可連續推進超過 12 輪。 const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url)); process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`; const prisma = { client: createPrismaClient() } as PrismaService; const clock = new ControllableClock("2026-08-19T05:00:00.000Z"); const roomEvents = new RoomEventsService(new EventEmitter2()); const lobbyService = new LobbyService(prisma, clock, roomEvents); const roomService = new RoomService(prisma, clock, lobbyService, roomEvents); const emotionService = new EmotionService(prisma, new RuleBasedEmotionTagger()); const relationshipService = new RelationshipService(prisma, new EventEmitter2()); const retriever = new KeywordMemoryRetriever(prisma); const characterRelationship = new CharacterRelationshipService(prisma); const speakingRight = new SpeakingRightService(prisma, emotionService, relationshipService, characterRelationship); const romanceService = new RomanceService(prisma, relationshipService, new RomanceEligibilityService(prisma, new AgeDeterminationService(prisma))); const mockProvider = new MockProvider(); const generation = new RoomGenerationService(prisma, emotionService, relationshipService, retriever, characterRelationship, romanceService, mockProvider, new GlobalPauseService()); const scheduleService = new ScheduleService(prisma); const sleepDebtService = new SleepDebtService(prisma, scheduleService); const behaviorSelection = new BehaviorSelectionService(prisma, emotionService, scheduleService, sleepDebtService, new PersonalityResolutionService(prisma, new PersonalityDriftService(prisma))); const pushToken = new PushTokenService(prisma); const mockPushProvider = new MockPushProvider(); const pushNotification = new PushNotificationService(prisma, pushToken, mockPushProvider); const taskTrigger = new TaskTriggerService(prisma, scheduleService, retriever, pushNotification); const jobQueue = new InProcessJobQueue(); const taskScheduler = new TaskSchedulerService(prisma, jobQueue, taskTrigger); const sentimentLedger = new SentimentLedgerService(prisma, relationshipService); const taskService = new TaskService(prisma, sentimentLedger, taskScheduler); const roomNotification = new RoomNotificationService(pushNotification); const projection = new ProjectionService(prisma, new RuleBasedEmotionTagger(), new BehaviorReinforcementService(prisma)); const lifeEvent = new LifeEventService(prisma, projection); const epilogueProgress = new EpilogueProgressService(prisma, lifeEvent); const possession = new PossessionService(prisma, roomService, speakingRight, generation, lobbyService); const selfChat = new SelfChatService(prisma, roomService, speakingRight, generation, lobbyService, behaviorSelection, taskService, roomNotification, epilogueProgress, possession); const userId = "test-selfchat-user"; const CHAR_A = "test-selfchat-char-a"; const CHAR_B = "test-selfchat-char-b"; const WORK_ID = "test-selfchat-t34-work"; const CHAR_EPILOGUE = "test-selfchat-t34-char"; afterEach(async () => { await prisma.client.room.deleteMany({ where: { userId } }); await prisma.client.character.deleteMany({ where: { id: { in: [CHAR_A, CHAR_B] } } }); await prisma.client.user.deleteMany({ where: { id: userId } }); await prisma.client.character.deleteMany({ where: { id: CHAR_EPILOGUE } }); await prisma.client.work.deleteMany({ where: { id: WORK_ID } }); }); async function createCharacter(id: string, formalName: string) { await prisma.client.character.create({ data: { id, source: "ORIGINAL", buildStatus: "BUILT", basicInfo: "測試", backgroundStory: "測試", personalityArchetype: "元氣", likesDislikes: "測試", goalsObsessions: "測試", speechStyle: "測試", aliases: { create: { formalName } }, }, }); } describe("SelfChatService(T6)", () => { it("可連續推進超過 12 輪,不再受舊版硬上限強制結束", async () => { await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } }); await createCharacter(CHAR_A, "小甲"); await createCharacter(CHAR_B, "小乙"); const room = await selfChat.start(userId, [CHAR_A, CHAR_B], "測試話題", clock.now()); let turns = 0; let ended = false; for (let i = 0; i < 20 && !ended; i++) { const result = await selfChat.advance(room.id, new Date(clock.now().getTime() + i * 1000)); if (result.ended) { ended = true; } else { turns += 1; } } expect(turns).toBeGreaterThan(12); }, 60000); it("角色 LEAVE 後會離開房間在場名單、回到大廳,且 RoomTurn.kind 記為 LEAVE", async () => { await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } }); await createCharacter(CHAR_A, "小甲"); await prisma.client.scheduleSlot.create({ data: { characterId: CHAR_A, dayType: "WEEKDAY", startMinute: 720, endMinute: 900, status: "BUSY" }, }); const room = await selfChat.start(userId, [CHAR_A], "測試話題", clock.now()); let leftTurn: { kind: string; content: string } | undefined; for (let i = 0; i < 40 && !leftTurn; i++) { const before = await roomService.get(room.id); await selfChat.advance(room.id, new Date(clock.now().getTime() + i * 1000)); const after = await roomService.get(room.id); if (before.participants.has(CHAR_A) && !after.participants.has(CHAR_A)) { const lastTurn = after.turns.at(-1)!; leftTurn = { kind: lastTurn.kind, content: lastTurn.content }; } } expect(leftTurn).toBeDefined(); expect(leftTurn!.kind).toBe("LEAVE"); const lobby = await lobbyService.getOrCreateLobby(userId); const lobbyMember = await prisma.client.roomMember.findFirst({ where: { roomId: lobby.id, characterId: CHAR_A, leftAt: null } }); expect(lobbyMember).not.toBeNull(); }, 60000); it("T34:房內自走會順手推進發言角色所屬(後日談)作品的推演進度時鐘", async () => { await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } }); await prisma.client.work.create({ data: { id: WORK_ID, title: "T34 自走推進測試作品", inEpilogue: true, timeFlowMode: "FAST" } }); await createCharacter(CHAR_EPILOGUE, "小丁"); await prisma.client.character.update({ where: { id: CHAR_EPILOGUE }, data: { workId: WORK_ID } }); const room = await selfChat.start(userId, [CHAR_EPILOGUE], "測試話題", clock.now()); const t0 = clock.now(); await selfChat.advance(room.id, t0); // 第一次 tick:只建立時鐘起點,尚不推進 let work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } }); expect(work.anchorStoryOrder).toBe(0); await selfChat.advance(room.id, new Date(t0.getTime() + 24 * 60 * 60 * 1000)); // 一個現實日後再 tick 一次 work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } }); expect(work.anchorStoryOrder).toBe(3); // FAST:3 storyOrder/現實日 }, 60000); });