Files
Kokorone/apps/api/src/room/self-chat.service.test.ts
T
jiantw83andClaude Sonnet 5 1bea06e3cd feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:10:26 +08:00

173 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});