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