Files
Kokorone/apps/api/src/room/privacy-boundary.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

175 lines
8.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 { 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);
});