新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
173 lines
9.8 KiB
TypeScript
173 lines
9.8 KiB
TypeScript
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);
|
||
});
|