feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能

新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 09:10:26 +08:00
co-authored by Claude Sonnet 5
parent 127a59dafd
commit 1bea06e3cd
195 changed files with 17219 additions and 738 deletions
+174
View File
@@ -0,0 +1,174 @@
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { createPrismaClient } from "@kokorone/db";
import { afterEach, describe, expect, it } from "vitest";
import type { PrismaService } from "../prisma/prisma.service.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 { 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, USER_SPEAKER_ID } from "./room.service.js";
import { CharacterRelationshipService } from "./character-relationship.service.js";
import { RoomGenerationService } from "./room-generation.service.js";
import { RoomConsolidationService } from "./room-consolidation.service.js";
import { ControllableClock } from "../clock/controllable-clock.js";
import { SLIP_INTIMACY_PENALTY } from "./constants.js";
// T40 驗收:K-3 隱私邊界不再是沒人寫入的空殼——一般原型在群聊中不會洩漏私密(1:1)記憶;
// 口風不緊原型(天然呆)偶爾會洩漏,一旦洩漏視為真實事件,對使用者的親密度給予懲罰。
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 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 consolidation = new RoomConsolidationService(prisma, roomService, new RuleBasedEmotionTagger(), characterRelationship);
const userId = "test-privacy-user";
const CHAR_DISCREET = "test-privacy-char-discreet"; // 元氣:非口風不緊
const CHAR_LOOSE = "test-privacy-char-loose"; // 天然呆:口風不緊
const CHAR_OTHER = "test-privacy-char-other"; // 房內第三人,純粹製造「有旁人在場」的群聊情境
afterEach(async () => {
await prisma.client.episodicMemory.deleteMany({
where: { characterId: { in: [CHAR_DISCREET, CHAR_LOOSE, CHAR_OTHER] } },
});
await prisma.client.characterRelationship.deleteMany({
where: { characterId: { in: [CHAR_DISCREET, CHAR_LOOSE, CHAR_OTHER] } },
});
await prisma.client.relationship.deleteMany({ where: { userId } });
await prisma.client.room.deleteMany({ where: { userId } });
await prisma.client.character.deleteMany({ where: { id: { in: [CHAR_DISCREET, CHAR_LOOSE, CHAR_OTHER] } } });
await prisma.client.user.deleteMany({ where: { id: userId } });
});
async function createCharacter(id: string, archetype: string, formalName: string) {
await prisma.client.character.create({
data: {
id,
source: "ORIGINAL",
buildStatus: "BUILT",
basicInfo: "測試",
backgroundStory: "測試",
personalityArchetype: archetype,
likesDislikes: "測試",
goalsObsessions: "測試",
speechStyle: "測試",
aliases: { create: { formalName } },
},
});
}
async function seedPrivateMemory(characterId: string) {
await prisma.client.episodicMemory.create({
data: {
characterId,
content: "秘密基地的事只有我們兩個知道",
occurredAt: clock.now(),
emotionTag: "JOY",
emotionIntensity: 0.9,
source: "INTERACTION",
weight: 1.9,
isPrivate: true,
},
});
}
describe("隱私邊界與口風不緊(T40)", () => {
it("一般原型(非口風不緊)在群聊生成中不會檢索到私密記憶,也不觸發懲罰", async () => {
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
await createCharacter(CHAR_DISCREET, "元氣", "小甲");
await createCharacter(CHAR_OTHER, "元氣", "小丙");
await seedPrivateMemory(CHAR_DISCREET);
await relationshipService.getOrCreate(CHAR_DISCREET, userId, clock.now());
await prisma.client.relationship.update({
where: { characterId_userId: { characterId: CHAR_DISCREET, userId } },
data: { intimacy: 50 },
});
const room = await roomService.create(userId, [CHAR_DISCREET, CHAR_OTHER], "GROUP");
const reply = await generation.generateReply(room, CHAR_DISCREET, USER_SPEAKER_ID, "使用者", "秘密基地的事", {
now: clock.now(),
});
expect(reply.text).toBeTruthy();
const { relationship } = await relationshipService.getState(CHAR_DISCREET, userId, clock.now());
expect(relationship.intimacy).toBe(50); // 未洩漏私密記憶,親密度不受影響
}, 20000);
it("口風不緊原型可能在群聊中洩漏私密記憶,洩漏時對使用者的親密度給予懲罰", async () => {
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
await createCharacter(CHAR_LOOSE, "天然呆", "小乙");
await createCharacter(CHAR_OTHER, "元氣", "小丙");
await seedPrivateMemory(CHAR_LOOSE);
await relationshipService.getOrCreate(CHAR_LOOSE, userId, clock.now());
await prisma.client.relationship.update({
where: { characterId_userId: { characterId: CHAR_LOOSE, userId } },
data: { intimacy: 50 },
});
const room = await roomService.create(userId, [CHAR_LOOSE, CHAR_OTHER], "GROUP");
const reply = await generation.generateReply(room, CHAR_LOOSE, USER_SPEAKER_ID, "使用者", "秘密基地的事", {
now: clock.now(),
});
expect(reply.text).toBeTruthy();
const { relationship } = await relationshipService.getState(CHAR_LOOSE, userId, clock.now());
expect(relationship.intimacy).toBe(50 + SLIP_INTIMACY_PENALTY); // 說溜嘴:親密度扣分
}, 20000);
it("K-5 各自視角固化:同一場群聊,不同角色各自萃取出的記憶內容與權重不同", async () => {
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
await createCharacter(CHAR_DISCREET, "元氣", "小甲");
await createCharacter(CHAR_LOOSE, "天然呆", "小乙");
const room = await roomService.create(userId, [CHAR_DISCREET, CHAR_LOOSE], "GROUP");
// 「好可愛」觸發高情緒(PRAISE,強度剛好達門檻 0.6):小甲自己說的話因情緒夠強而留存;
// 小乙被提到(aboutMe)則無論情緒強度一律留存,且權重比小甲自己主觀敘述更高。
await roomService.addTurn(room.id, CHAR_DISCREET, "小甲", "小乙你好可愛,喜歡你啦!!!", clock.now());
await consolidation.consolidate(room.id);
const memoriesDiscreet = await prisma.client.episodicMemory.findMany({ where: { characterId: CHAR_DISCREET } });
const memoriesLoose = await prisma.client.episodicMemory.findMany({ where: { characterId: CHAR_LOOSE } });
expect(memoriesDiscreet.length).toBeGreaterThan(0);
expect(memoriesLoose.length).toBeGreaterThan(0);
// 小乙被小甲提到(虧她可愛)——小乙記得這件事是「被說到自己」,權重比小甲自己主觀的敘述更高。
const looseAboutSelf = memoriesLoose.find((m) => m.content.includes("小乙你好可愛"));
const discreetOwnLine = memoriesDiscreet.find((m) => m.content.includes("小乙你好可愛"));
expect(looseAboutSelf).toBeTruthy();
expect(discreetOwnLine).toBeTruthy();
expect(looseAboutSelf!.weight).toBeGreaterThan(discreetOwnLine!.weight);
// 群聊固化的記憶並非來自 1:1,不應標記為私密。
expect(memoriesDiscreet.every((m) => m.isPrivate === false)).toBe(true);
expect(memoriesLoose.every((m) => m.isPrivate === false)).toBe(true);
}, 20000);
});