feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,8 @@
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"start": "node dist/main.js"
|
||||
"start": "node dist/main.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kokorone/db": "workspace:*",
|
||||
@@ -23,6 +24,7 @@
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node-cron": "^3.0.11"
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,30 +7,32 @@ import { EmotionModule } from "./emotion/emotion.module.js";
|
||||
import { RelationshipModule } from "./relationship/relationship.module.js";
|
||||
import { LlmModule } from "./llm/llm.module.js";
|
||||
import { PersonalityModule } from "./personality/personality.module.js";
|
||||
import { ChatModule } from "./chat/chat.module.js";
|
||||
import { ScheduleModule } from "./schedule/schedule.module.js";
|
||||
import { TaskModule } from "./task/task.module.js";
|
||||
import { RoomModule } from "./room/room.module.js";
|
||||
import { LobbyModule } from "./lobby/lobby.module.js";
|
||||
import { RomanceModule } from "./romance/romance.module.js";
|
||||
import { CanonModule } from "./canon/canon.module.js";
|
||||
import { EpilogueModule } from "./epilogue/epilogue.module.js";
|
||||
import { TachieModule } from "./tachie/tachie.module.js";
|
||||
import { VoiceModule } from "./voice/voice.module.js";
|
||||
import { PushModule } from "./push/push.module.js";
|
||||
import { ClockModule } from "./clock/clock.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
EventEmitterModule.forRoot(),
|
||||
ClockModule,
|
||||
CharactersModule,
|
||||
MemoryModule,
|
||||
EmotionModule,
|
||||
RelationshipModule,
|
||||
LlmModule,
|
||||
PersonalityModule,
|
||||
ChatModule,
|
||||
ScheduleModule,
|
||||
TaskModule,
|
||||
RoomModule,
|
||||
LobbyModule,
|
||||
RomanceModule,
|
||||
CanonModule,
|
||||
EpilogueModule,
|
||||
|
||||
@@ -29,14 +29,14 @@ export class AnchorService {
|
||||
}
|
||||
|
||||
// 錨定點後退:她「不再知道」超出新錨定點的後段劇情——移除這些場景先前投影出的所有角色資料。
|
||||
// T17/T25:清除清單與 ProjectionService.clearProjectedData() 共用同一份,避免這裡各自維護一份
|
||||
// 清單、日後新增的投影資料類型忘了同步加在兩處(先前 CharacterEmotionBaseline/ProceduralRule
|
||||
// 上線時就漏了這裡,直到本次才發現並補上)。
|
||||
const toUnproject = await this.prisma.client.scene.findMany({
|
||||
where: { workId, storyOrder: { gt: newStoryOrder }, projected: true },
|
||||
});
|
||||
for (const scene of toUnproject) {
|
||||
await this.prisma.client.episodicMemory.deleteMany({ where: { sourceSceneId: scene.id } });
|
||||
await this.prisma.client.semanticMemory.deleteMany({ where: { sourceSceneId: scene.id } });
|
||||
await this.prisma.client.personalityTraitSnapshot.deleteMany({ where: { sourceSceneId: scene.id } });
|
||||
await this.prisma.client.characterRelationshipEventLog.deleteMany({ where: { sceneId: scene.id } });
|
||||
await this.projection.clearProjectedData(scene.id);
|
||||
await this.prisma.client.scene.update({ where: { id: scene.id }, data: { projected: false } });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { MockProvider } from "../llm/mock-provider.service.js";
|
||||
import { BehaviorReinforcementService } from "../llm/behavior-reinforcement.service.js";
|
||||
import { FingerprintService } from "./fingerprint.service.js";
|
||||
import { AttributionService } from "./attribution.service.js";
|
||||
import { ProjectionService } from "./projection.service.js";
|
||||
import { WorkRosterService } from "./work-roster.service.js";
|
||||
import { TimelineService } from "./timeline.service.js";
|
||||
import { EndingDetectionService } from "./ending-detection.service.js";
|
||||
import { SceneImportService } from "./scene-import.service.js";
|
||||
|
||||
// T25 驗收:匯入一章後可看到新增與被強化的規則各自的來源章節,且重複匯入相同行為不會產生同義重複規則。
|
||||
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 fingerprint = new FingerprintService(prisma);
|
||||
const attribution = new AttributionService(prisma, fingerprint);
|
||||
const behaviorReinforcement = new BehaviorReinforcementService(prisma);
|
||||
const projection = new ProjectionService(prisma, new RuleBasedEmotionTagger(), behaviorReinforcement);
|
||||
const roster = new WorkRosterService(prisma);
|
||||
const timeline = new TimelineService(prisma);
|
||||
const endingDetection = new EndingDetectionService(prisma, new MockProvider());
|
||||
const sceneImport = new SceneImportService(prisma, attribution, projection, roster, timeline, endingDetection);
|
||||
|
||||
const WORK_ID = "test-t25-work";
|
||||
const CHAR_A = "test-t25-char-a";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.proceduralRule.deleteMany({ where: { characterId: CHAR_A } });
|
||||
await prisma.client.scene.deleteMany({ where: { workId: WORK_ID } });
|
||||
await prisma.client.character.deleteMany({ where: { id: CHAR_A } });
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
});
|
||||
|
||||
async function createWorkAndCharacter(anchorStoryOrder = 100) {
|
||||
await prisma.client.work.create({ data: { id: WORK_ID, title: "T25 測試作品", anchorStoryOrder } });
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_A,
|
||||
workId: WORK_ID,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "CANDIDATE",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: "小甲" } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("ACTION 描寫投影為 ProceduralRule(T25)", () => {
|
||||
it("匯入含 ACTION 描寫的章節後,新增規則記錄來源章節", async () => {
|
||||
await createWorkAndCharacter();
|
||||
|
||||
const scene = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一章",
|
||||
storyOrder: 1,
|
||||
summary: "小甲被老師點名回答問題。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "ACTION", rawText: "小甲害羞地低下頭。", hasExplicitMarker: true, markerCharacterId: CHAR_A }],
|
||||
});
|
||||
expect(scene.projected).toBe(true);
|
||||
|
||||
const rule = await prisma.client.proceduralRule.findFirstOrThrow({ where: { characterId: CHAR_A } });
|
||||
expect(rule.situation).toBe("小甲被老師點名回答問題。");
|
||||
expect(rule.responsePattern).toBe("小甲害羞地低下頭。");
|
||||
expect(rule.weight).toBe(1);
|
||||
expect(rule.sourceSceneId).toBe(scene.id);
|
||||
}, 20000);
|
||||
|
||||
it("再匯入相同情境與回應模式的另一章時,強化既有規則的權重並更新來源章節,不產生重複規則", async () => {
|
||||
await createWorkAndCharacter();
|
||||
|
||||
const sceneA = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一章",
|
||||
storyOrder: 1,
|
||||
summary: "小甲被老師點名回答問題。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "ACTION", rawText: "小甲害羞地低下頭。", hasExplicitMarker: true, markerCharacterId: CHAR_A }],
|
||||
});
|
||||
|
||||
const sceneB = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第二章",
|
||||
storyOrder: 2,
|
||||
summary: "小甲被老師點名回答問題。", // 同樣的情境敘述
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "ACTION", rawText: "小甲害羞地低下頭。", hasExplicitMarker: true, markerCharacterId: CHAR_A }],
|
||||
});
|
||||
|
||||
const rules = await prisma.client.proceduralRule.findMany({ where: { characterId: CHAR_A } });
|
||||
expect(rules).toHaveLength(1); // 不會產生同義重複規則
|
||||
expect(rules[0].weight).toBe(2); // 首次 1,強化一次後 2
|
||||
expect(rules[0].sourceSceneId).toBe(sceneB.id); // 記錄最近一次觸發的來源章節
|
||||
expect(sceneA.id).not.toBe(sceneB.id);
|
||||
}, 20000);
|
||||
|
||||
it("不同情境或不同回應模式視為不同規則,各自累計", async () => {
|
||||
await createWorkAndCharacter();
|
||||
|
||||
await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一章",
|
||||
storyOrder: 1,
|
||||
summary: "小甲被老師點名回答問題。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "ACTION", rawText: "小甲害羞地低下頭。", hasExplicitMarker: true, markerCharacterId: CHAR_A }],
|
||||
});
|
||||
await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第二章",
|
||||
storyOrder: 2,
|
||||
summary: "小甲在派對上收到禮物。", // 不同情境
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "ACTION", rawText: "小甲開心地跳了起來。", hasExplicitMarker: true, markerCharacterId: CHAR_A }],
|
||||
});
|
||||
|
||||
const rules = await prisma.client.proceduralRule.findMany({ where: { characterId: CHAR_A } });
|
||||
expect(rules).toHaveLength(2);
|
||||
expect(rules.every((r) => r.weight === 1)).toBe(true);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { WorkRosterService } from "./work-roster.service.js";
|
||||
import { IllustrationService } from "./illustration.service.js";
|
||||
import { AnchorService } from "./anchor.service.js";
|
||||
import { TimelineService } from "./timeline.service.js";
|
||||
import { EndingDetectionService } from "./ending-detection.service.js";
|
||||
|
||||
@Controller("canon")
|
||||
export class CanonController {
|
||||
@@ -20,8 +21,15 @@ export class CanonController {
|
||||
private readonly illustration: IllustrationService,
|
||||
private readonly anchor: AnchorService,
|
||||
private readonly timeline: TimelineService,
|
||||
private readonly endingDetection: EndingDetectionService,
|
||||
) {}
|
||||
|
||||
// M-11:待裁決畫面需要先選一部作品才能往下查場景/台詞,既有 API 沒有列出作品的端點,補上這個最小讀取端點。
|
||||
@Get("works")
|
||||
async listWorks() {
|
||||
return this.prisma.client.work.findMany({ orderBy: { createdAt: "desc" } });
|
||||
}
|
||||
|
||||
@Post("scenes")
|
||||
async importScene(@Body() body: ImportSceneInput) {
|
||||
return this.sceneImport.importScene(body);
|
||||
@@ -40,22 +48,48 @@ export class CanonController {
|
||||
return this.prisma.client.scene.findMany({ where: { workId }, orderBy: { storyOrder: "asc" } });
|
||||
}
|
||||
|
||||
// T39:裁決完(不再有隔離台詞或待確認矛盾)後才執行階段二投影與角色建置門檻檢查,
|
||||
// 使用者不需要另外手動觸發——這三個既有端點裁決完成後都會自動接續嘗試。
|
||||
@Post("scene-lines/:lineId/adjudicate")
|
||||
async adjudicate(@Param("lineId") lineId: string, @Body() body: { speakerCharacterId: string }) {
|
||||
await this.attribution.adjudicate(lineId, body.speakerCharacterId);
|
||||
return this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
|
||||
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
|
||||
await this.sceneImport.tryProjectScene(line.sceneId);
|
||||
return line;
|
||||
}
|
||||
|
||||
@Post("scenes/:sceneId/reattempt-quarantined")
|
||||
async reattemptQuarantined(@Param("sceneId") sceneId: string) {
|
||||
const released = await this.attribution.reattemptQuarantined(sceneId);
|
||||
await this.sceneImport.tryProjectScene(sceneId);
|
||||
return { released };
|
||||
}
|
||||
|
||||
@Post("scene-lines/:lineId/correct")
|
||||
async correctAttribution(@Param("lineId") lineId: string, @Body() body: { newSpeakerCharacterId: string; now?: string }) {
|
||||
await this.correction.correctLineAttribution(lineId, body.newSpeakerCharacterId, body.now ? new Date(body.now) : undefined);
|
||||
return this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
|
||||
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
|
||||
await this.sceneImport.tryProjectScene(line.sceneId);
|
||||
return line;
|
||||
}
|
||||
|
||||
// T39:待裁決清單——場景自身有隔離中的台詞、或所屬作品有待人工確認的矛盾時,都算「有待裁決項目」;
|
||||
// 供大廳作品卡片提示(T19-P4)與 smoke 驗證共用。
|
||||
@Get("works/:workId/pending-adjudication")
|
||||
async listPendingAdjudication(@Param("workId") workId: string) {
|
||||
const scenes = await this.prisma.client.scene.findMany({ where: { workId, projected: false } });
|
||||
const results = [];
|
||||
for (const scene of scenes) {
|
||||
const detail = await this.sceneImport.getSceneDetail(scene.id);
|
||||
if (detail.pendingAdjudication) results.push(detail);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// T13:作品頁可查詢待確認的結局偵測結果(含判定理由);確認/拒絕操作見 T14(epilogue 模組)。
|
||||
@Get("works/:workId/ending-detections")
|
||||
async listPendingEndingDetections(@Param("workId") workId: string) {
|
||||
return this.endingDetection.listPendingForWork(workId);
|
||||
}
|
||||
|
||||
@Get("characters/:characterId/roster-progress")
|
||||
@@ -103,6 +137,8 @@ export class CanonController {
|
||||
@Post("works/:workId/detect-contradictions")
|
||||
async detectContradictions(@Param("workId") workId: string) {
|
||||
const created = await this.timeline.detectContradictions(workId);
|
||||
// T39:矛盾是作品層級的,解決後可能同時解除多個場景的封鎖,逐一嘗試整個作品尚未投影的場景。
|
||||
await this.sceneImport.tryProjectPendingScenesForWork(workId);
|
||||
return { created };
|
||||
}
|
||||
|
||||
@@ -110,4 +146,14 @@ export class CanonController {
|
||||
async listContradictions(@Param("workId") workId: string) {
|
||||
return this.timeline.listContradictions(workId);
|
||||
}
|
||||
|
||||
// T39:人工裁決矛盾——原本沒有任何端點能把 PENDING_HUMAN_REVIEW 的矛盾標記解決,含這類矛盾的作品
|
||||
// 會永久卡住無法投影;解決後嘗試接續投影整個作品裡尚未投影的場景。
|
||||
@Post("contradictions/:contradictionId/resolve")
|
||||
async resolveContradiction(@Param("contradictionId") contradictionId: string, @Body() body: { resolution: string }) {
|
||||
const contradiction = await this.prisma.client.sceneContradiction.findUniqueOrThrow({ where: { id: contradictionId } });
|
||||
await this.timeline.resolveContradiction(contradictionId, body.resolution);
|
||||
await this.sceneImport.tryProjectPendingScenesForWork(contradiction.workId);
|
||||
return this.prisma.client.sceneContradiction.findUniqueOrThrow({ where: { id: contradictionId } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||
import { EmotionModule } from "../emotion/emotion.module.js";
|
||||
import { LlmModule } from "../llm/llm.module.js";
|
||||
import { FingerprintService } from "./fingerprint.service.js";
|
||||
import { WorkRosterService } from "./work-roster.service.js";
|
||||
import { IllustrationService } from "./illustration.service.js";
|
||||
@@ -10,10 +11,11 @@ import { SceneImportService } from "./scene-import.service.js";
|
||||
import { AnchorService } from "./anchor.service.js";
|
||||
import { TimelineService } from "./timeline.service.js";
|
||||
import { CorrectionService } from "./correction.service.js";
|
||||
import { EndingDetectionService } from "./ending-detection.service.js";
|
||||
import { CanonController } from "./canon.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, EmotionModule],
|
||||
imports: [PrismaModule, EmotionModule, LlmModule],
|
||||
controllers: [CanonController],
|
||||
providers: [
|
||||
FingerprintService,
|
||||
@@ -25,6 +27,7 @@ import { CanonController } from "./canon.controller.js";
|
||||
AnchorService,
|
||||
TimelineService,
|
||||
CorrectionService,
|
||||
EndingDetectionService,
|
||||
],
|
||||
exports: [
|
||||
FingerprintService,
|
||||
@@ -36,6 +39,7 @@ import { CanonController } from "./canon.controller.js";
|
||||
AnchorService,
|
||||
TimelineService,
|
||||
CorrectionService,
|
||||
EndingDetectionService,
|
||||
],
|
||||
})
|
||||
export class CanonModule {}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { MockProvider } from "../llm/mock-provider.service.js";
|
||||
import { BehaviorReinforcementService } from "../llm/behavior-reinforcement.service.js";
|
||||
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { SleepDebtService } from "../schedule/sleep-debt.service.js";
|
||||
import { ScheduleEmotionBaselineService } from "../schedule/schedule-emotion-baseline.service.js";
|
||||
import { FingerprintService } from "./fingerprint.service.js";
|
||||
import { AttributionService } from "./attribution.service.js";
|
||||
import { ProjectionService } from "./projection.service.js";
|
||||
import { WorkRosterService } from "./work-roster.service.js";
|
||||
import { TimelineService } from "./timeline.service.js";
|
||||
import { EndingDetectionService } from "./ending-detection.service.js";
|
||||
import { SceneImportService } from "./scene-import.service.js";
|
||||
|
||||
// T17 驗收:匯入悲劇章節後該角色整體基調偏低落(透過 ScheduleEmotionBaselineService.computeBaseline()
|
||||
// 這個既有的合成點觀察,不直接檢查 EmotionState);reprojectScene() 重投影時一致清除舊的基線紀錄;
|
||||
// 平淡場景不記錄基線;本項全程不寫 EmotionState 六維、不重設其 updatedAt。
|
||||
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
|
||||
const WEEKDAY_NOW = new Date("2026-08-19T05:00:00.000Z"); // 台北週三 13:00,避開週末的假期基線
|
||||
|
||||
const prisma = { client: createPrismaClient() } as PrismaService;
|
||||
const fingerprint = new FingerprintService(prisma);
|
||||
const attribution = new AttributionService(prisma, fingerprint);
|
||||
const behaviorReinforcement = new BehaviorReinforcementService(prisma);
|
||||
const projection = new ProjectionService(prisma, new RuleBasedEmotionTagger(), behaviorReinforcement);
|
||||
const roster = new WorkRosterService(prisma);
|
||||
const timeline = new TimelineService(prisma);
|
||||
const endingDetection = new EndingDetectionService(prisma, new MockProvider());
|
||||
const sceneImport = new SceneImportService(prisma, attribution, projection, roster, timeline, endingDetection);
|
||||
const scheduleService = new ScheduleService(prisma);
|
||||
const sleepDebtService = new SleepDebtService(prisma, scheduleService);
|
||||
const scheduleBaseline = new ScheduleEmotionBaselineService(prisma, scheduleService, sleepDebtService);
|
||||
|
||||
const WORK_ID = "test-t17-work";
|
||||
const CHAR_A = "test-t17-char-a";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.characterEmotionBaseline.deleteMany({ where: { characterId: CHAR_A } });
|
||||
await prisma.client.emotionState.deleteMany({ where: { characterId: CHAR_A } });
|
||||
await prisma.client.scene.deleteMany({ where: { workId: WORK_ID } });
|
||||
await prisma.client.character.deleteMany({ where: { id: CHAR_A } });
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
});
|
||||
|
||||
async function createWorkAndCharacter(anchorStoryOrder = 100) {
|
||||
await prisma.client.work.create({ data: { id: WORK_ID, title: "T17 測試作品", anchorStoryOrder } });
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_A,
|
||||
workId: WORK_ID,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "CANDIDATE",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: "小甲" } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("章節匯入的情緒基線連動(T17)", () => {
|
||||
it("匯入悲劇章節後,作息基線的合成結果偏向低落;沒有寫入 EmotionState", async () => {
|
||||
await createWorkAndCharacter();
|
||||
|
||||
const scene = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一章",
|
||||
storyOrder: 1,
|
||||
summary: "小甲最深愛的家人離開了,她感到非常難過,好傷心。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "小甲哭了。" }],
|
||||
});
|
||||
expect(scene.projected).toBe(true);
|
||||
|
||||
const baselineRow = await prisma.client.characterEmotionBaseline.findFirstOrThrow({
|
||||
where: { characterId: CHAR_A },
|
||||
});
|
||||
expect(baselineRow.emotionTag).toBe("SAD");
|
||||
expect(baselineRow.sourceSceneId).toBe(scene.id);
|
||||
|
||||
const synthesized = await scheduleBaseline.computeBaseline(CHAR_A, WEEKDAY_NOW);
|
||||
expect(synthesized?.tag).toBe("SAD");
|
||||
|
||||
// T17 硬性約束:全程不得直接寫 EmotionState 六維或重設其 updatedAt。
|
||||
const emotionState = await prisma.client.emotionState.findUnique({ where: { characterId: CHAR_A } });
|
||||
expect(emotionState).toBeNull();
|
||||
}, 20000);
|
||||
|
||||
it("平淡場景不記錄基線;重投影時一致清除舊的基線紀錄", async () => {
|
||||
await createWorkAndCharacter();
|
||||
|
||||
const neutralScene = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一章",
|
||||
storyOrder: 1,
|
||||
summary: "小甲去便利商店買了飲料。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "今天天氣普通。" }],
|
||||
});
|
||||
const rowsAfterNeutral = await prisma.client.characterEmotionBaseline.findMany({ where: { characterId: CHAR_A } });
|
||||
expect(rowsAfterNeutral).toHaveLength(0);
|
||||
|
||||
const sadScene = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第二章",
|
||||
storyOrder: 2,
|
||||
summary: "小甲失去了摯友,非常難過委屈。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "小甲難過地離開了。" }],
|
||||
});
|
||||
expect(await prisma.client.characterEmotionBaseline.count({ where: { characterId: CHAR_A } })).toBe(1);
|
||||
|
||||
await projection.reprojectScene(sadScene.id);
|
||||
const rowsAfterReproject = await prisma.client.characterEmotionBaseline.findMany({
|
||||
where: { characterId: CHAR_A },
|
||||
});
|
||||
expect(rowsAfterReproject).toHaveLength(1); // 清掉重建後仍是同一筆(同一場景),不會累積或消失
|
||||
expect(rowsAfterReproject[0].sourceSceneId).toBe(sadScene.id);
|
||||
void neutralScene;
|
||||
}, 20000);
|
||||
|
||||
it("作息本身有更急迫的訊號時(睡眠債),優先於章節匯入的長期基調", async () => {
|
||||
await createWorkAndCharacter();
|
||||
await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一章",
|
||||
storyOrder: 1,
|
||||
summary: "小甲失去了摯友,非常難過委屈。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "小甲難過地離開了。" }],
|
||||
});
|
||||
|
||||
await prisma.client.sleepDebt.create({ data: { characterId: CHAR_A, accumulatedMinutes: 90 } });
|
||||
const synthesized = await scheduleBaseline.computeBaseline(CHAR_A, WEEKDAY_NOW);
|
||||
expect(synthesized?.tag).toBe("SAD"); // 恰好同為 SAD,改用強度驗證優先序真的走了睡眠債分支
|
||||
expect(synthesized?.intensity).toBe(0.4); // SLEEP_DEBT_TIRED_INTENSITY,而非章節基線的強度
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { MockProvider } from "../llm/mock-provider.service.js";
|
||||
import { BehaviorReinforcementService } from "../llm/behavior-reinforcement.service.js";
|
||||
import { FingerprintService } from "./fingerprint.service.js";
|
||||
import { AttributionService } from "./attribution.service.js";
|
||||
import { ProjectionService } from "./projection.service.js";
|
||||
import { WorkRosterService } from "./work-roster.service.js";
|
||||
import { TimelineService } from "./timeline.service.js";
|
||||
import { EndingDetectionService } from "./ending-detection.service.js";
|
||||
import { SceneImportService } from "./scene-import.service.js";
|
||||
|
||||
// T13 驗收:匯入結局章節後作品頁出現提示且附判定理由,Work.inEpilogue 在未確認前保持不變,
|
||||
// 非結局章節不會誤提示;判定不看章節標題(chapterLabel 故意取無結局意味的名稱)。
|
||||
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 fingerprint = new FingerprintService(prisma);
|
||||
const attribution = new AttributionService(prisma, fingerprint);
|
||||
const behaviorReinforcement = new BehaviorReinforcementService(prisma);
|
||||
const projection = new ProjectionService(prisma, new RuleBasedEmotionTagger(), behaviorReinforcement);
|
||||
const roster = new WorkRosterService(prisma);
|
||||
const timeline = new TimelineService(prisma);
|
||||
const mockProvider = new MockProvider();
|
||||
const endingDetection = new EndingDetectionService(prisma, mockProvider);
|
||||
const sceneImport = new SceneImportService(prisma, attribution, projection, roster, timeline, endingDetection);
|
||||
|
||||
const WORK_ID = "test-t13-work";
|
||||
const CHAR_A = "test-t13-char-a";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.endingDetectionLog.deleteMany({ where: { scene: { workId: WORK_ID } } });
|
||||
await prisma.client.scene.deleteMany({ where: { workId: WORK_ID } });
|
||||
await prisma.client.character.deleteMany({ where: { id: CHAR_A } });
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
});
|
||||
|
||||
async function createWork() {
|
||||
await prisma.client.work.create({ data: { id: WORK_ID, title: "T13 測試作品", anchorStoryOrder: 100 } });
|
||||
}
|
||||
|
||||
async function createCharacter() {
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_A,
|
||||
workId: WORK_ID,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "CANDIDATE",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: "小甲" } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("EndingDetectionService(T13)", () => {
|
||||
it("匯入結局章節後,作品頁可查到待確認的偵測結果且附判定理由;Work.inEpilogue 保持不變", async () => {
|
||||
await createWork();
|
||||
await createCharacter();
|
||||
|
||||
// chapterLabel 故意取一個完全不帶結局意味的名稱,證明判定依據是場景內容本身而非章節標題。
|
||||
const scene = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 3,
|
||||
chapterLabel: "第五十話",
|
||||
storyOrder: 1,
|
||||
summary: "眾人在最後一戰後道別,故事到此結束,全劇終。",
|
||||
rawText: "(測試文本)眾人告別,全劇終。",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "故事在此完結。" }],
|
||||
});
|
||||
expect(scene.projected).toBe(true);
|
||||
|
||||
const pending = await endingDetection.listPendingForWork(WORK_ID);
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].sceneId).toBe(scene.id);
|
||||
expect(pending[0].evidence.length).toBeGreaterThan(0);
|
||||
expect(pending[0].resolution).toBe("PENDING");
|
||||
|
||||
const work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.inEpilogue).toBe(false); // T13 本身不得直接修改 Work,確認交給 T14
|
||||
}, 20000);
|
||||
|
||||
it("非結局章節不會誤提示", async () => {
|
||||
await createWork();
|
||||
await createCharacter();
|
||||
|
||||
const scene = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一話:最終決戰!!", // 標題帶「最終」也不該誤判,判斷依據只看場景內容
|
||||
storyOrder: 1,
|
||||
summary: "小甲今天去上學,路上買了早餐。",
|
||||
rawText: "(測試文本)平凡的一天。",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "今天天氣不錯。" }],
|
||||
});
|
||||
expect(scene.projected).toBe(true);
|
||||
|
||||
const pending = await endingDetection.listPendingForWork(WORK_ID);
|
||||
expect(pending).toHaveLength(0);
|
||||
}, 20000);
|
||||
|
||||
it("同一場景不會重複寫入偵測紀錄", async () => {
|
||||
await createWork();
|
||||
await createCharacter();
|
||||
const scene = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一話",
|
||||
storyOrder: 1,
|
||||
summary: "大結局,故事在此完結。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "全劇終。" }],
|
||||
});
|
||||
await endingDetection.detectForScene(scene.id); // 重複呼叫模擬未來可能的重複觸發路徑
|
||||
const logs = await prisma.client.endingDetectionLog.findMany({ where: { sceneId: scene.id } });
|
||||
expect(logs).toHaveLength(1);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { LLM_PROVIDER, type LLMProvider } from "../llm/llm-provider.js";
|
||||
|
||||
// T13 匯入時的結局偵測與提示:每個場景投影完成後都呼叫,不以章節標題關鍵字預篩(呼叫端只傳場景
|
||||
// 摘要/原文/作品標題,不傳章節標題)。判定成立只寫 EndingDetectionLog(resolution=PENDING),
|
||||
// 絕不直接修改 Work——是否真的切入日後談由使用者在 T14 的確認端點決定。
|
||||
@Injectable()
|
||||
export class EndingDetectionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(LLM_PROVIDER) private readonly provider: LLMProvider,
|
||||
) {}
|
||||
|
||||
async detectForScene(sceneId: string): Promise<void> {
|
||||
const scene = await this.prisma.client.scene.findUniqueOrThrow({
|
||||
where: { id: sceneId },
|
||||
include: { work: true },
|
||||
});
|
||||
|
||||
// 同一場景只投影一次(tryProjectScene 對已投影場景是 no-op),理論上不會重複偵測;
|
||||
// 仍加一道保險,避免任何未來呼叫路徑重複寫入同一場景的偵測紀錄。
|
||||
const existing = await this.prisma.client.endingDetectionLog.findFirst({ where: { sceneId } });
|
||||
if (existing) return;
|
||||
|
||||
const result = await this.provider.detectEnding(scene.summary, scene.rawText, scene.work.title);
|
||||
if (!result.isEnding) return;
|
||||
|
||||
await this.prisma.client.endingDetectionLog.create({
|
||||
data: { sceneId, evidence: result.evidence, resolution: "PENDING" },
|
||||
});
|
||||
}
|
||||
|
||||
async listPendingForWork(workId: string) {
|
||||
return this.prisma.client.endingDetectionLog.findMany({
|
||||
where: { scene: { workId }, resolution: "PENDING" },
|
||||
include: { scene: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { HIGH_EMOTION_THRESHOLD } from "../memory/constants.js";
|
||||
import { BehaviorReinforcementService } from "../llm/behavior-reinforcement.service.js";
|
||||
|
||||
const CANON_EPOCH_MS = new Date("2000-01-01T00:00:00.000Z").getTime();
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -19,6 +20,7 @@ export class ProjectionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly tagger: RuleBasedEmotionTagger,
|
||||
private readonly behaviorRule: BehaviorReinforcementService,
|
||||
) {}
|
||||
|
||||
async projectScene(sceneId: string): Promise<void> {
|
||||
@@ -90,16 +92,49 @@ export class ProjectionService {
|
||||
}
|
||||
}
|
||||
|
||||
// 6) 動作描寫 → 行為規則(T25):ACTION 描述的是「在這個情境下,這個角色做了什麼」,情境取場景摘要
|
||||
// (客觀記錄的「發生了什麼事」),回應模式取這句動作描寫本身;首見新增規則、已有相同情境+回應模式
|
||||
// 則強化既有權重(沿用 F-5 既有的 (situation, responsePattern) 比對,見 BehaviorReinforcementService)。
|
||||
const actionLines = scene.lines.filter(
|
||||
(line) => line.lineType === "ACTION" && line.status === "CONFIRMED" && line.speakerCharacterId,
|
||||
);
|
||||
for (const line of actionLines) {
|
||||
await this.behaviorRule.recordFromScene(line.speakerCharacterId!, scene.summary, line.rawText, scene.id);
|
||||
}
|
||||
|
||||
// 7) 場景整體情緒 → 情緒基線(T17):沿用步驟 1 已算出的場景情緒訊號(同一份客觀記錄,在場者共同
|
||||
// 經歷),累積進在場角色的長期基調;只有出現明確情緒訊號的場景才記一筆,平淡場景不灌水基線資料。
|
||||
// 這裡只寫 CharacterEmotionBaseline,絕不直接寫 EmotionState 六維、也不重設其 updatedAt——
|
||||
// 基線只在 ScheduleEmotionBaselineService.computeBaseline() 取不到更急迫的訊號時才會被拿去參考。
|
||||
if (signal.tag !== "CALM") {
|
||||
for (const characterId of presentCharacterIds) {
|
||||
await this.prisma.client.characterEmotionBaseline.create({
|
||||
data: { characterId, emotionTag: signal.tag, intensity: signal.intensity, sourceSceneId: scene.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.client.scene.update({ where: { id: scene.id }, data: { projected: true } });
|
||||
}
|
||||
|
||||
// M-7 重新投影:修正場景資料庫後,先移除這個場景先前投影出的所有資料,再重新投影一次。
|
||||
// 只清「由這個場景投影而來」的紀錄(sourceSceneId 指到它)——使用者互動記憶從不設這個欄位,永遠不受影響。
|
||||
async reprojectScene(sceneId: string): Promise<void> {
|
||||
await this.clearProjectedData(sceneId);
|
||||
await this.projectScene(sceneId);
|
||||
}
|
||||
|
||||
// T39:階段二(投影)失敗時,把這個場景已經寫入一部分的投影結果清乾淨,回到「已裁決但未投影」
|
||||
// 可安全重跑的狀態(不是留一半髒資料卡住),與 reprojectScene() 共用同一份清除邏輯。
|
||||
async clearProjectedData(sceneId: string): Promise<void> {
|
||||
await this.prisma.client.episodicMemory.deleteMany({ where: { sourceSceneId: sceneId } });
|
||||
await this.prisma.client.semanticMemory.deleteMany({ where: { sourceSceneId: sceneId } });
|
||||
await this.prisma.client.personalityTraitSnapshot.deleteMany({ where: { sourceSceneId: sceneId } });
|
||||
await this.prisma.client.characterRelationshipEventLog.deleteMany({ where: { sceneId } });
|
||||
await this.projectScene(sceneId);
|
||||
// T25:已知簡化——sourceSceneId 只記錄「最近一次」觸發,若這條規則後來又被別的場景強化過,
|
||||
// 這裡整條刪掉會連同其他場景的貢獻一起清掉,見 schema.prisma 該欄位的註解。
|
||||
await this.prisma.client.proceduralRule.deleteMany({ where: { sourceSceneId: sceneId } });
|
||||
// T17:情緒基線是每場景各自一筆(不像 ProceduralRule 是單列累加),刪除重建不會誤刪其他場景的貢獻。
|
||||
await this.prisma.client.characterEmotionBaseline.deleteMany({ where: { sourceSceneId: sceneId } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { FingerprintService } from "./fingerprint.service.js";
|
||||
import { AttributionService } from "./attribution.service.js";
|
||||
import { ProjectionService } from "./projection.service.js";
|
||||
import { WorkRosterService } from "./work-roster.service.js";
|
||||
import { TimelineService } from "./timeline.service.js";
|
||||
import { EndingDetectionService } from "./ending-detection.service.js";
|
||||
import { SceneImportService } from "./scene-import.service.js";
|
||||
import { MockProvider } from "../llm/mock-provider.service.js";
|
||||
import { BehaviorReinforcementService } from "../llm/behavior-reinforcement.service.js";
|
||||
|
||||
// T39 驗收:兩階段與裁決阻斷——含爭議歸屬(QUARANTINED)或待人工確認矛盾(PENDING_HUMAN_REVIEW)的
|
||||
// 匯入,暫不投影也不影響角色資料;裁決/解決矛盾完成後才接續投影;階段一失敗全回滾視為沒匯入過。
|
||||
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 fingerprint = new FingerprintService(prisma);
|
||||
const attribution = new AttributionService(prisma, fingerprint);
|
||||
const behaviorReinforcement = new BehaviorReinforcementService(prisma);
|
||||
const projection = new ProjectionService(prisma, new RuleBasedEmotionTagger(), behaviorReinforcement);
|
||||
const roster = new WorkRosterService(prisma);
|
||||
const timeline = new TimelineService(prisma);
|
||||
const endingDetection = new EndingDetectionService(prisma, new MockProvider());
|
||||
const sceneImport = new SceneImportService(prisma, attribution, projection, roster, timeline, endingDetection);
|
||||
|
||||
const WORK_ID = "test-t39-work";
|
||||
const CHAR_A = "test-t39-char-a";
|
||||
const CHAR_B = "test-t39-char-b";
|
||||
const CHAR_C = "test-t39-char-c";
|
||||
const ALL_CHAR_IDS = [CHAR_A, CHAR_B, CHAR_C];
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.sceneContradiction.deleteMany({ where: { workId: WORK_ID } });
|
||||
await prisma.client.scene.deleteMany({ where: { workId: WORK_ID } });
|
||||
await prisma.client.character.deleteMany({ where: { id: { in: ALL_CHAR_IDS } } });
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
});
|
||||
|
||||
async function createWork(anchorStoryOrder = 100) {
|
||||
await prisma.client.work.create({ data: { id: WORK_ID, title: "T39 測試作品", anchorStoryOrder } });
|
||||
}
|
||||
|
||||
async function createCharacter(id: string, formalName: string) {
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id,
|
||||
workId: WORK_ID,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "CANDIDATE",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("SceneImportService 兩階段與裁決阻斷(T39)", () => {
|
||||
it("沒有隔離台詞、沒有矛盾時,匯入後立即完成階段二投影", async () => {
|
||||
await createWork();
|
||||
await createCharacter(CHAR_A, "小甲");
|
||||
await createCharacter(CHAR_B, "小乙");
|
||||
|
||||
const result = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一章",
|
||||
storyOrder: 1,
|
||||
summary: "小甲與小乙在教室聊天。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A, CHAR_B],
|
||||
lines: [
|
||||
{ lineType: "DIALOGUE", rawText: "早安。", hasExplicitMarker: true, markerCharacterId: CHAR_A },
|
||||
{ lineType: "DIALOGUE", rawText: "早安呀。", hasExplicitMarker: true, markerCharacterId: CHAR_B },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.pendingAdjudication).toBe(false);
|
||||
expect(result.projected).toBe(true);
|
||||
const memories = await prisma.client.episodicMemory.findMany({ where: { characterId: { in: [CHAR_A, CHAR_B] } } });
|
||||
expect(memories.length).toBeGreaterThan(0);
|
||||
}, 20000);
|
||||
|
||||
it("有隔離中的台詞時,暫不投影;裁決完成後才接續投影", async () => {
|
||||
await createWork();
|
||||
await createCharacter(CHAR_A, "小甲");
|
||||
await createCharacter(CHAR_B, "小乙");
|
||||
await createCharacter(CHAR_C, "小丙");
|
||||
|
||||
// 三人在場、無明示標記、無任何指紋樣本可依附:turnTakingScore 恆為 0.5,score=0.5 < CONFIRM_THRESHOLD
|
||||
// (0.55),必定隔離。
|
||||
const result = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第二章",
|
||||
storyOrder: 2,
|
||||
summary: "三人在場的一段對話。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A, CHAR_B, CHAR_C],
|
||||
lines: [{ lineType: "DIALOGUE", rawText: "這句話是誰說的呢?" }],
|
||||
});
|
||||
|
||||
expect(result.pendingAdjudication).toBe(true);
|
||||
expect(result.projected).toBe(false);
|
||||
const memoriesBefore = await prisma.client.episodicMemory.findMany({
|
||||
where: { characterId: { in: ALL_CHAR_IDS } },
|
||||
});
|
||||
expect(memoriesBefore).toHaveLength(0);
|
||||
|
||||
const quarantinedLine = result.lines.find((l) => l.status === "QUARANTINED");
|
||||
expect(quarantinedLine).toBeTruthy();
|
||||
|
||||
// 使用者裁決:指定發言者,AttributionController 裁決完成後會呼叫 tryProjectScene()。
|
||||
await attribution.adjudicate(quarantinedLine!.id, CHAR_A);
|
||||
const projected = await sceneImport.tryProjectScene(result.id);
|
||||
expect(projected).toBe(true);
|
||||
|
||||
const sceneAfter = await prisma.client.scene.findUniqueOrThrow({ where: { id: result.id } });
|
||||
expect(sceneAfter.projected).toBe(true);
|
||||
const memoriesAfter = await prisma.client.episodicMemory.findMany({
|
||||
where: { characterId: { in: ALL_CHAR_IDS } },
|
||||
});
|
||||
expect(memoriesAfter.length).toBeGreaterThan(0);
|
||||
}, 20000);
|
||||
|
||||
it("有待人工確認的矛盾時,即使場景本身台詞皆已確認也暫不投影;解決矛盾後才接續投影", async () => {
|
||||
await createWork();
|
||||
await createCharacter(CHAR_A, "小甲");
|
||||
|
||||
const scene1 = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一章",
|
||||
storyOrder: 1,
|
||||
summary: "小甲的生日。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "小甲的生日是五月。" }],
|
||||
facts: [{ eventLabel: "小甲的生日", factKey: "月份", factValue: "五月" }],
|
||||
});
|
||||
expect(scene1.pendingAdjudication).toBe(false); // 第一筆事實尚無衝突對象
|
||||
|
||||
// 第二個場景寫入矛盾的事實值(同 sourceType=NOVEL,來源優先序相同,不可自動裁決)。
|
||||
const scene2 = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第二章",
|
||||
storyOrder: 3, // 落在錨定點內,若無矛盾應會投影
|
||||
summary: "小甲的生日(矛盾版本)。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "小甲的生日是十月。" }],
|
||||
facts: [{ eventLabel: "小甲的生日", factKey: "月份", factValue: "十月" }],
|
||||
});
|
||||
|
||||
expect(scene2.pendingAdjudication).toBe(true);
|
||||
expect(scene2.projected).toBe(false);
|
||||
// 矛盾是作品層級的,先前已投影的 scene1 不會被追溯撤銷。
|
||||
const scene1After = await prisma.client.scene.findUniqueOrThrow({ where: { id: scene1.id } });
|
||||
expect(scene1After.projected).toBe(true);
|
||||
|
||||
const contradiction = await prisma.client.sceneContradiction.findFirstOrThrow({
|
||||
where: { workId: WORK_ID, status: "PENDING_HUMAN_REVIEW" },
|
||||
});
|
||||
await timeline.resolveContradiction(contradiction.id, "採用十月版本(人工確認)");
|
||||
const projectedCount = await sceneImport.tryProjectPendingScenesForWork(WORK_ID);
|
||||
expect(projectedCount).toBe(1);
|
||||
|
||||
const scene2After = await prisma.client.scene.findUniqueOrThrow({ where: { id: scene2.id } });
|
||||
expect(scene2After.projected).toBe(true);
|
||||
}, 20000);
|
||||
|
||||
it("階段一失敗(不存在的角色)時全回滾,Scene 本身不殘留", async () => {
|
||||
await createWork();
|
||||
await createCharacter(CHAR_A, "小甲");
|
||||
|
||||
await expect(
|
||||
sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "失敗章",
|
||||
storyOrder: 5,
|
||||
summary: "會失敗的匯入。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A, "test-t39-char-not-exist"],
|
||||
lines: [{ lineType: "NARRATION", rawText: "這句不重要。" }],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
const scenesAfter = await prisma.client.scene.findMany({ where: { workId: WORK_ID, chapterLabel: "失敗章" } });
|
||||
expect(scenesAfter).toHaveLength(0);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { AttributionService } from "./attribution.service.js";
|
||||
import { ProjectionService } from "./projection.service.js";
|
||||
import { WorkRosterService } from "./work-roster.service.js";
|
||||
import { TimelineService } from "./timeline.service.js";
|
||||
import { EndingDetectionService } from "./ending-detection.service.js";
|
||||
|
||||
export interface ImportLineInput {
|
||||
lineType: SceneLineType;
|
||||
@@ -42,6 +44,16 @@ export interface ImportSceneInput {
|
||||
// M-3 章節匯入與場景切分:以場景為單位寫入共用場景資料庫(角色中立的客觀記錄)。
|
||||
// 場景本身的切分(章節文本 → 一個個場景)由呼叫端(人工或前置流程)完成後以結構化輸入送進來——
|
||||
// 這裡不做自由文本的自動場景邊界偵測,理由同 F-7 MockProvider:對應的真實 NLP 能力屬於之後才會補的範疇。
|
||||
//
|
||||
// T39 兩階段與裁決阻斷:
|
||||
// 階段一(本方法內)=建立 Scene/SceneLine 等客觀記錄+跑發言歸屬(AttributionService)+矛盾偵測
|
||||
// (TimelineService)。階段一若拋錯,視為沒匯入過——把剛建立的 Scene 整棵刪掉(級聯刪除 lines/
|
||||
// facts/interactions/presences),不留半套資料。
|
||||
// 階段二(tryProjectScene()/tryProjectPendingScenesForWork())=投影+角色建置門檻檢查;只有這個
|
||||
// 場景「自己沒有隔離中的台詞」且「所屬作品沒有待人工確認的矛盾」才會執行,否則維持
|
||||
// `Scene.projected=false` 原地等待,不阻擋其他場景的匯入或投影。使用者透過既有端點
|
||||
// (adjudicate/correct/reattempt-quarantined/detect-contradictions)處理完裁決後,
|
||||
// controller 會呼叫這兩個方法把符合條件的場景接續投影,呼叫端不需要另外手動觸發。
|
||||
@Injectable()
|
||||
export class SceneImportService {
|
||||
constructor(
|
||||
@@ -49,6 +61,8 @@ export class SceneImportService {
|
||||
private readonly attribution: AttributionService,
|
||||
private readonly projection: ProjectionService,
|
||||
private readonly roster: WorkRosterService,
|
||||
private readonly timeline: TimelineService,
|
||||
private readonly endingDetection: EndingDetectionService,
|
||||
) {}
|
||||
|
||||
async importScene(input: ImportSceneInput) {
|
||||
@@ -64,54 +78,135 @@ export class SceneImportService {
|
||||
},
|
||||
});
|
||||
|
||||
for (const characterId of input.participantCharacterIds) {
|
||||
await this.prisma.client.scenePresence.create({ data: { sceneId: scene.id, characterId } });
|
||||
try {
|
||||
for (const characterId of input.participantCharacterIds) {
|
||||
await this.prisma.client.scenePresence.create({ data: { sceneId: scene.id, characterId } });
|
||||
}
|
||||
|
||||
for (const [lineIndex, line] of input.lines.entries()) {
|
||||
await this.prisma.client.sceneLine.create({
|
||||
data: {
|
||||
sceneId: scene.id,
|
||||
lineIndex,
|
||||
lineType: line.lineType,
|
||||
rawText: line.rawText,
|
||||
hasExplicitMarker: line.hasExplicitMarker ?? false,
|
||||
markerCharacterId: line.markerCharacterId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const interaction of input.interactions ?? []) {
|
||||
await this.prisma.client.sceneInteraction.create({
|
||||
data: {
|
||||
sceneId: scene.id,
|
||||
characterId: interaction.characterId,
|
||||
otherCharacterId: interaction.otherCharacterId,
|
||||
delta: interaction.delta,
|
||||
description: interaction.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const fact of input.facts ?? []) {
|
||||
await this.prisma.client.sceneFact.create({
|
||||
data: { sceneId: scene.id, eventLabel: fact.eventLabel, factKey: fact.factKey, factValue: fact.factValue },
|
||||
});
|
||||
}
|
||||
|
||||
await this.attribution.attributeScene(scene.id);
|
||||
await this.timeline.detectContradictions(input.workId);
|
||||
} catch (err) {
|
||||
// 階段一失敗全回滾,視為沒匯入過——級聯刪除已建立的 Scene 及其所有子紀錄。
|
||||
await this.prisma.client.scene.delete({ where: { id: scene.id } });
|
||||
throw err;
|
||||
}
|
||||
|
||||
for (const [lineIndex, line] of input.lines.entries()) {
|
||||
await this.prisma.client.sceneLine.create({
|
||||
data: {
|
||||
sceneId: scene.id,
|
||||
lineIndex,
|
||||
lineType: line.lineType,
|
||||
rawText: line.rawText,
|
||||
hasExplicitMarker: line.hasExplicitMarker ?? false,
|
||||
markerCharacterId: line.markerCharacterId,
|
||||
},
|
||||
const pendingAdjudication = await this.isPendingAdjudication(scene.id, input.workId);
|
||||
if (!pendingAdjudication) {
|
||||
await this.tryProjectScene(scene.id);
|
||||
}
|
||||
|
||||
return this.getSceneDetail(scene.id);
|
||||
}
|
||||
|
||||
// 場景自身沒有隔離中的台詞、且所屬作品沒有待人工確認的矛盾,才算「裁決完成」可以往下走階段二。
|
||||
private async isPendingAdjudication(sceneId: string, workId: string): Promise<boolean> {
|
||||
const quarantinedCount = await this.prisma.client.sceneLine.count({ where: { sceneId, status: "QUARANTINED" } });
|
||||
if (quarantinedCount > 0) return true;
|
||||
const pendingContradictions = await this.prisma.client.sceneContradiction.count({
|
||||
where: { workId, status: "PENDING_HUMAN_REVIEW" },
|
||||
});
|
||||
return pendingContradictions > 0;
|
||||
}
|
||||
|
||||
// 階段二:已裁決完成(不再有隔離台詞或待確認矛盾)才投影+檢查角色建置門檻;已投影過的場景直接
|
||||
// no-op(避免重複觸發)。回傳是否真的執行了投影,供呼叫端(例如結局偵測)判斷要不要接著跑。
|
||||
async tryProjectScene(sceneId: string): Promise<boolean> {
|
||||
const scene = await this.prisma.client.scene.findUniqueOrThrow({
|
||||
where: { id: sceneId },
|
||||
include: { participants: true },
|
||||
});
|
||||
if (scene.projected) return false;
|
||||
if (await this.isPendingAdjudication(sceneId, scene.workId)) return false;
|
||||
|
||||
// M-9 知識邊界:只有在錨定點之內的場景才投影;超前的場景先留在場景資料庫,等錨定點推進時再載入。
|
||||
let work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: scene.workId } });
|
||||
|
||||
// T15:後日談期間的「日後談續匯入」(真實正史內容)不受知識邊界卡住——匯入代表使用者已經讀到
|
||||
// 這裡,直接把錨定點推進到覆蓋這批內容再投影;純粹只影響「這篇要不要投影」的判斷,不刪除任何
|
||||
// 既有 EPILOGUE 場景、也不寫入敘事化的記憶修正(與 canon-reclamation.service.ts 的 reclaim() 不同,
|
||||
// reclaim() 是使用者主動選擇的覆寫手段,會找出並移除重疊的推演場景;這裡是預設路徑,兩者並存)。
|
||||
if (work.inEpilogue && scene.sourceType !== "EPILOGUE" && scene.storyOrder > work.anchorStoryOrder) {
|
||||
work = await this.prisma.client.work.update({
|
||||
where: { id: work.id },
|
||||
data: { anchorStoryOrder: scene.storyOrder },
|
||||
});
|
||||
}
|
||||
|
||||
for (const interaction of input.interactions ?? []) {
|
||||
await this.prisma.client.sceneInteraction.create({
|
||||
data: {
|
||||
sceneId: scene.id,
|
||||
characterId: interaction.characterId,
|
||||
otherCharacterId: interaction.otherCharacterId,
|
||||
delta: interaction.delta,
|
||||
description: interaction.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const fact of input.facts ?? []) {
|
||||
await this.prisma.client.sceneFact.create({
|
||||
data: { sceneId: scene.id, eventLabel: fact.eventLabel, factKey: fact.factKey, factValue: fact.factValue },
|
||||
});
|
||||
}
|
||||
|
||||
await this.attribution.attributeScene(scene.id);
|
||||
|
||||
// M-9 知識邊界:只有在錨定點之內的場景才立刻投影;超前的場景先留在場景資料庫,等錨定點推進時再載入。
|
||||
// 建置門檻的「實質互動」次數只在投影後才算數(互動要先套用到關係帳本才算數),因此門檻檢查必須排在投影之後。
|
||||
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: input.workId } });
|
||||
let didProject = false;
|
||||
if (scene.storyOrder <= work.anchorStoryOrder) {
|
||||
await this.projection.projectScene(scene.id);
|
||||
try {
|
||||
await this.projection.projectScene(sceneId);
|
||||
didProject = true;
|
||||
} catch (err) {
|
||||
// 階段二失敗回滾到「已裁決但未投影」可重跑:清掉這次可能已寫入一部分的投影結果,
|
||||
// 不設 projected=true,下次呼叫 tryProjectScene() 會重新完整跑一次。
|
||||
await this.projection.clearProjectedData(sceneId);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
for (const characterId of input.participantCharacterIds) {
|
||||
await this.roster.checkAndPromote(characterId);
|
||||
// 建置門檻的「實質互動」次數只在投影後才算數,因此門檻檢查排在投影之後;即使場景超前錨定點
|
||||
// 未投影,仍沿用既有行為讓在場角色照常檢查建置門檻(維持匯入時既有語意,不因本次改動而收緊)。
|
||||
for (const participant of scene.participants) {
|
||||
await this.roster.checkAndPromote(participant.characterId);
|
||||
}
|
||||
|
||||
return this.prisma.client.scene.findUniqueOrThrow({ where: { id: scene.id }, include: { participants: true, lines: true } });
|
||||
// T13:於階段二投影完成後才加入結局偵測——場景超前錨定點而沒有真的投影時不偵測,因為錨定點本身
|
||||
// 就代表「還沒推進到這裡」,不該提前提示結局。
|
||||
if (didProject) {
|
||||
await this.endingDetection.detectForScene(sceneId);
|
||||
}
|
||||
return didProject;
|
||||
}
|
||||
|
||||
// 矛盾裁決是整個作品層級的(SceneContradiction 沒有 sceneId),解決後可能同時解除多個場景的封鎖,
|
||||
// 因此需要逐一嘗試整個作品裡尚未投影的場景,而不只是觸發裁決當下那一個。
|
||||
async tryProjectPendingScenesForWork(workId: string): Promise<number> {
|
||||
const pendingScenes = await this.prisma.client.scene.findMany({ where: { workId, projected: false } });
|
||||
let projected = 0;
|
||||
for (const scene of pendingScenes) {
|
||||
if (await this.tryProjectScene(scene.id)) projected += 1;
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
async getSceneDetail(sceneId: string) {
|
||||
const scene = await this.prisma.client.scene.findUniqueOrThrow({
|
||||
where: { id: sceneId },
|
||||
include: { participants: true, lines: true },
|
||||
});
|
||||
return { ...scene, pendingAdjudication: await this.isPendingAdjudication(sceneId, scene.workId) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,4 +64,14 @@ export class TimelineService {
|
||||
async listContradictions(workId: string) {
|
||||
return this.prisma.client.sceneContradiction.findMany({ where: { workId } });
|
||||
}
|
||||
|
||||
// T39:detectContradictions() 只能自動裁決「來源優先序不同」的矛盾,剩下標記 PENDING_HUMAN_REVIEW
|
||||
// 的矛盾原本沒有任何端點可以真的解決掉——這是本次補上的缺口,否則含這類矛盾的作品會永久卡住,
|
||||
// 沒有任何路徑能讓後續場景繼續投影。
|
||||
async resolveContradiction(contradictionId: string, resolution: string): Promise<void> {
|
||||
await this.prisma.client.sceneContradiction.update({
|
||||
where: { id: contradictionId },
|
||||
data: { status: "RESOLVED", resolution },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Body, Controller, Param, Post } from "@nestjs/common";
|
||||
import { DialogueService } from "../llm/dialogue.service.js";
|
||||
import { MemoryConsolidationService } from "../memory/memory-consolidation.service.js";
|
||||
|
||||
interface ChatMessageBody {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
text: string;
|
||||
seed?: number;
|
||||
now?: string; // 測試用:模擬「現在」時間,供 I 群組作息相關行為驗證
|
||||
}
|
||||
|
||||
interface EndSessionBody {
|
||||
characterId: string;
|
||||
}
|
||||
|
||||
// H-1 對話 API:POST /chat/:characterId 走完整管線,POST /session/:sessionId/end 觸發睡眠固化。
|
||||
// 這是本清單第一個對外的正式使用者端點,先前 F 群組的 /dialogue/* 僅供工程內部驗證。
|
||||
@Controller()
|
||||
export class ChatController {
|
||||
constructor(
|
||||
private readonly dialogue: DialogueService,
|
||||
private readonly consolidation: MemoryConsolidationService,
|
||||
) {}
|
||||
|
||||
@Post("chat/:characterId")
|
||||
async chat(@Param("characterId") characterId: string, @Body() body: ChatMessageBody) {
|
||||
return this.dialogue.handleMessage(characterId, body.userId, body.sessionId, body.text, {
|
||||
seed: body.seed,
|
||||
now: body.now ? new Date(body.now) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Post("session/:sessionId/end")
|
||||
async endSession(@Param("sessionId") sessionId: string, @Body() body: EndSessionBody) {
|
||||
await this.consolidation.consolidate(body.characterId, sessionId);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { LlmModule } from "../llm/llm.module.js";
|
||||
import { MemoryModule } from "../memory/memory.module.js";
|
||||
import { ChatController } from "./chat.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [LlmModule, MemoryModule],
|
||||
controllers: [ChatController],
|
||||
})
|
||||
export class ChatModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CLOCK } from "./clock.js";
|
||||
import { SystemClockService } from "./system-clock.service.js";
|
||||
|
||||
@Module({
|
||||
providers: [SystemClockService, { provide: CLOCK, useExisting: SystemClockService }],
|
||||
exports: [CLOCK, SystemClockService],
|
||||
})
|
||||
export class ClockModule {}
|
||||
@@ -0,0 +1,7 @@
|
||||
// T36:可注入時鐘介面。本計畫所有新程式一律從時鐘取時間、不直接 `new Date()`,
|
||||
// 以便測試以 ControllableClock(見 controllable-clock.ts)快轉時間而不需真的等待。
|
||||
export interface Clock {
|
||||
now(): Date;
|
||||
}
|
||||
|
||||
export const CLOCK = Symbol("CLOCK");
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ControllableClock } from "./controllable-clock.js";
|
||||
|
||||
describe("ControllableClock", () => {
|
||||
it("回報設定時刻,不隨真實時間流動", () => {
|
||||
const clock = new ControllableClock("2026-08-18T00:00:00.000Z");
|
||||
expect(clock.now().toISOString()).toBe("2026-08-18T00:00:00.000Z");
|
||||
expect(clock.now().toISOString()).toBe("2026-08-18T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("可快轉跨夜情境,秒級內完成", () => {
|
||||
const clock = new ControllableClock("2026-08-18T23:50:00.000Z");
|
||||
clock.advance(20 * 60 * 1000); // 快轉 20 分鐘跨過午夜
|
||||
expect(clock.now().toISOString()).toBe("2026-08-19T00:10:00.000Z");
|
||||
});
|
||||
|
||||
it("set() 可直接指定新時刻", () => {
|
||||
const clock = new ControllableClock();
|
||||
clock.set("2026-01-01T00:00:00.000Z");
|
||||
expect(clock.now().toISOString()).toBe("2026-01-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Clock } from "./clock.js";
|
||||
|
||||
// T36 測試專用時鐘:可任意設定與快轉當前時間,讓「跨夜→角色去睡→房間關閉」這類情境
|
||||
// 在秒級內於測試中重現,不需真的等待。正式環境一律使用 SystemClockService。
|
||||
export class ControllableClock implements Clock {
|
||||
private current: Date;
|
||||
|
||||
constructor(initial: Date | string | number = new Date(0)) {
|
||||
this.current = new Date(initial);
|
||||
}
|
||||
|
||||
now(): Date {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
set(date: Date | string | number): void {
|
||||
this.current = new Date(date);
|
||||
}
|
||||
|
||||
advance(ms: number): void {
|
||||
this.current = new Date(this.current.getTime() + ms);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Clock } from "./clock.js";
|
||||
|
||||
@Injectable()
|
||||
export class SystemClockService implements Clock {
|
||||
now(): Date {
|
||||
return new Date();
|
||||
}
|
||||
}
|
||||
@@ -39,12 +39,10 @@ export class CanonReclamationService {
|
||||
for (const presence of scene.participants) {
|
||||
affectedCharacterIds.add(presence.characterId);
|
||||
}
|
||||
// 清掉這場推演事件投影出的所有角色資料——同 M-7 reprojectScene 的刪除邏輯,一樣只篩
|
||||
// sourceSceneId/sceneId,使用者互動記憶(source=INTERACTION)從不帶這些欄位,不會被動到。
|
||||
await this.prisma.client.episodicMemory.deleteMany({ where: { sourceSceneId: scene.id } });
|
||||
await this.prisma.client.semanticMemory.deleteMany({ where: { sourceSceneId: scene.id } });
|
||||
await this.prisma.client.personalityTraitSnapshot.deleteMany({ where: { sourceSceneId: scene.id } });
|
||||
await this.prisma.client.characterRelationshipEventLog.deleteMany({ where: { sceneId: scene.id } });
|
||||
// 清掉這場推演事件投影出的所有角色資料——與 ProjectionService.clearProjectedData() 共用同一份
|
||||
// 清除清單(T17/T25 上線時就漏了這裡沒同步更新,本次隨 T15 一併發現並修正),使用者互動記憶
|
||||
// (source=INTERACTION)從不帶這些欄位,不會被動到。
|
||||
await this.projection.clearProjectedData(scene.id);
|
||||
await this.prisma.client.sceneLine.deleteMany({ where: { sceneId: scene.id } });
|
||||
await this.prisma.client.scenePresence.deleteMany({ where: { sceneId: scene.id } });
|
||||
await this.prisma.client.scene.delete({ where: { id: scene.id } });
|
||||
|
||||
@@ -11,3 +11,25 @@ export const MILESTONE_MIN_STORY_GAP = 5;
|
||||
export const MAX_EXPRESSIVENESS_DRIFT = 0.15;
|
||||
export const MAX_HALF_LIFE_REDUCTION = 0.3; // 情緒衰減最多加快 30%
|
||||
export const HALF_LIFE_REDUCTION_PER_STORY_UNIT = 0.01;
|
||||
|
||||
// T34:`timeFlowMode` 唯一真正的消費者——大廳/房間對話、行程判定、睡眠機制完全不讀這個欄位,
|
||||
// 一律走真實時間;這裡只換算「後日談推演進度」(anchorStoryOrder 這個既有時鐘,在日後談期間
|
||||
// 繼續往前走的速度),推演進度本身又是 N-2 性格漂移幅度與 N-4 里程碑低頻節流唯二依賴的量尺。
|
||||
// FROZEN 恆為 0(見 epilogue-progress.service.ts);其餘數值為每個現實日推進的 storyOrder 單位數,
|
||||
// 僅為示意性量尺(storyOrder 本身就是不帶物理時間意義的整數序),非官方文件規定的精確值。
|
||||
//
|
||||
// 刻意用「分子/幾個現實日」的整數比表示速率,而不是先算好一個 storyOrder-per-ms 的浮點常數:
|
||||
// 後者在 epilogue-progress.service.ts 逐次相乘除時,會因為二進位浮點無法精確表示像 1/28800000
|
||||
// 這種分數而在多次 tick 後累積出偏差(實測會少算掉整數單位);用整數比讓每次換算都以「先乘、
|
||||
// 最後才除」的單一步驟完成,被除數與除數都是可精確表示的整數,不會有這種捨入誤差。
|
||||
export const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
export interface EpilogueProgressRate {
|
||||
numerator: number; // 每 `perRealDays` 個現實日推進的 storyOrder 單位數
|
||||
perRealDays: number;
|
||||
}
|
||||
export const EPILOGUE_STORY_ORDER_RATE: Record<TimeFlowMode, EpilogueProgressRate> = {
|
||||
FROZEN: { numerator: 0, perRealDays: 1 },
|
||||
SLOW: { numerator: 1, perRealDays: 3 },
|
||||
REALTIME: { numerator: 1, perRealDays: 1 },
|
||||
FAST: { numerator: 3, perRealDays: 1 },
|
||||
};
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { MockProvider } from "../llm/mock-provider.service.js";
|
||||
import { BehaviorReinforcementService } from "../llm/behavior-reinforcement.service.js";
|
||||
import { FingerprintService } from "../canon/fingerprint.service.js";
|
||||
import { AttributionService } from "../canon/attribution.service.js";
|
||||
import { ProjectionService } from "../canon/projection.service.js";
|
||||
import { WorkRosterService } from "../canon/work-roster.service.js";
|
||||
import { TimelineService } from "../canon/timeline.service.js";
|
||||
import { EndingDetectionService } from "../canon/ending-detection.service.js";
|
||||
import { SceneImportService } from "../canon/scene-import.service.js";
|
||||
import { LifeEventService } from "./life-event.service.js";
|
||||
|
||||
// T15 驗收:後日談期間續匯入正史內容(L 路徑)——不受 M-9 知識邊界卡住,直接投影並推進錨定點;
|
||||
// 不刪除既有 EPILOGUE 場景、不寫入敘事化的記憶修正(那是 canon-reclamation.service.ts 的 reclaim()
|
||||
// 才做的事,屬使用者主動選擇的覆寫路徑,不是這裡的預設行為)。
|
||||
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 fingerprint = new FingerprintService(prisma);
|
||||
const attribution = new AttributionService(prisma, fingerprint);
|
||||
const behaviorReinforcement = new BehaviorReinforcementService(prisma);
|
||||
const projection = new ProjectionService(prisma, new RuleBasedEmotionTagger(), behaviorReinforcement);
|
||||
const roster = new WorkRosterService(prisma);
|
||||
const timeline = new TimelineService(prisma);
|
||||
const endingDetection = new EndingDetectionService(prisma, new MockProvider());
|
||||
const sceneImport = new SceneImportService(prisma, attribution, projection, roster, timeline, endingDetection);
|
||||
const lifeEvent = new LifeEventService(prisma, projection);
|
||||
|
||||
const WORK_ID = "test-t15-work";
|
||||
const CHAR_A = "test-t15-char-a";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.episodicMemory.deleteMany({ where: { characterId: CHAR_A } });
|
||||
await prisma.client.epilogueMilestoneLog.deleteMany({ where: { characterId: CHAR_A } });
|
||||
await prisma.client.scene.deleteMany({ where: { workId: WORK_ID } });
|
||||
await prisma.client.character.deleteMany({ where: { id: CHAR_A } });
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
});
|
||||
|
||||
async function createWorkInEpilogue() {
|
||||
await prisma.client.work.create({
|
||||
data: { id: WORK_ID, title: "T15 測試作品", anchorStoryOrder: 5, inEpilogue: true, epilogueStartStoryOrder: 5 },
|
||||
});
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_A,
|
||||
workId: WORK_ID,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "CANDIDATE",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: "小甲" } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("後日談期間續匯入正史內容(T15)", () => {
|
||||
it("匯入超前錨定點的正史章節後直接投影,並把錨定點推進到覆蓋這批內容", async () => {
|
||||
await createWorkInEpilogue();
|
||||
|
||||
const scene = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 2,
|
||||
chapterLabel: "第十章",
|
||||
storyOrder: 10,
|
||||
summary: "小甲在正史裡的新章節:她升職了。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "小甲升職了,很開心。" }],
|
||||
});
|
||||
|
||||
expect(scene.projected).toBe(true);
|
||||
const work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.anchorStoryOrder).toBe(10);
|
||||
expect(work.inEpilogue).toBe(true); // 續匯入本身不改變是否處於後日談,只推進知識邊界
|
||||
|
||||
const memories = await prisma.client.episodicMemory.findMany({
|
||||
where: { characterId: CHAR_A, sourceSceneId: scene.id },
|
||||
});
|
||||
expect(memories.length).toBeGreaterThan(0);
|
||||
expect(memories.every((m) => m.source !== "CORRECTION")).toBe(true); // 不是敘事化記憶修正
|
||||
}, 20000);
|
||||
|
||||
it("既有已投影的 EPILOGUE 場景與其記憶完全不受續匯入影響", async () => {
|
||||
await createWorkInEpilogue();
|
||||
|
||||
const epilogueScene = await lifeEvent.generateSharedEvent(
|
||||
WORK_ID,
|
||||
CHAR_A,
|
||||
6,
|
||||
"小甲和朋友去了海邊玩。",
|
||||
[],
|
||||
new Date("2026-08-19T00:00:00.000Z"),
|
||||
);
|
||||
const epilogueMemoriesBefore = await prisma.client.episodicMemory.findMany({
|
||||
where: { characterId: CHAR_A, sourceSceneId: epilogueScene.id },
|
||||
});
|
||||
expect(epilogueMemoriesBefore.length).toBeGreaterThan(0);
|
||||
|
||||
await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 2,
|
||||
chapterLabel: "第十章",
|
||||
storyOrder: 10,
|
||||
summary: "小甲在正史裡的新章節:她升職了。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "小甲升職了,很開心。" }],
|
||||
});
|
||||
|
||||
const epilogueSceneAfter = await prisma.client.scene.findUniqueOrThrow({ where: { id: epilogueScene.id } });
|
||||
expect(epilogueSceneAfter.projected).toBe(true); // 沒有被刪除、也沒有被 unproject
|
||||
const epilogueMemoriesAfter = await prisma.client.episodicMemory.findMany({
|
||||
where: { characterId: CHAR_A, sourceSceneId: epilogueScene.id },
|
||||
});
|
||||
expect(epilogueMemoriesAfter).toHaveLength(epilogueMemoriesBefore.length); // 完全不受影響
|
||||
}, 20000);
|
||||
|
||||
it("尚未進入後日談的作品,超前錨定點的正史場景仍維持既有行為(暫不投影,等錨定點推進)", async () => {
|
||||
await prisma.client.work.create({ data: { id: WORK_ID, title: "T15 對照組", anchorStoryOrder: 5, inEpilogue: false } });
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_A,
|
||||
workId: WORK_ID,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "CANDIDATE",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: "小甲" } },
|
||||
},
|
||||
});
|
||||
|
||||
const scene = await sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 2,
|
||||
chapterLabel: "第十章",
|
||||
storyOrder: 10,
|
||||
summary: "尚未出版到這裡。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "這句還不該被角色知道。" }],
|
||||
});
|
||||
|
||||
expect(scene.projected).toBe(false);
|
||||
const work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.anchorStoryOrder).toBe(5); // 未進入後日談時不受 T15 影響,維持既有 M-9 知識邊界
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { BehaviorReinforcementService } from "../llm/behavior-reinforcement.service.js";
|
||||
import { ProjectionService } from "../canon/projection.service.js";
|
||||
import { LifeEventService } from "./life-event.service.js";
|
||||
import { EpilogueProgressService } from "./epilogue-progress.service.js";
|
||||
|
||||
// T34 驗收:`timeFlowMode` 唯一真正的作用是換算後日談「推演進度」(anchorStoryOrder 這個既有時鐘)
|
||||
// 該推進多快——FAST 較快、SLOW 較慢、FROZEN 完全不推進;推演進度推進時順手嘗試自主成立里程碑
|
||||
// (不需使用者確認)。大廳/房間對話、行程、睡眠等真實時間流程完全不讀這個欄位,另見
|
||||
// `schedule/timeflow-realtime-boundary.test.ts`。
|
||||
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 behaviorReinforcement = new BehaviorReinforcementService(prisma);
|
||||
const projection = new ProjectionService(prisma, new RuleBasedEmotionTagger(), behaviorReinforcement);
|
||||
const lifeEvent = new LifeEventService(prisma, projection);
|
||||
const epilogueProgress = new EpilogueProgressService(prisma, lifeEvent);
|
||||
|
||||
const WORK_ID = "test-t34-work";
|
||||
const CHAR_BAKER = "test-t34-char-baker"; // goalsObsessions 含「麵包店」,可對到既有里程碑模板
|
||||
const CHAR_PLAIN = "test-t34-char-plain"; // 沒有可對應的正史伏筆關鍵字
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.epilogueMilestoneLog.deleteMany({ where: { characterId: { in: [CHAR_BAKER, CHAR_PLAIN] } } });
|
||||
await prisma.client.character.deleteMany({ where: { id: { in: [CHAR_BAKER, CHAR_PLAIN] } } });
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
});
|
||||
|
||||
async function createWork(timeFlowMode: string, inEpilogue = true) {
|
||||
await prisma.client.work.create({ data: { id: WORK_ID, title: "T34 測試作品", inEpilogue, timeFlowMode, anchorStoryOrder: 0 } });
|
||||
}
|
||||
|
||||
async function createCharacter(id: string, goalsObsessions: string) {
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id,
|
||||
workId: WORK_ID,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "CANDIDATE",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions,
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: id } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("後日談推演進度時鐘(T34)", () => {
|
||||
it("尚未進入後日談的作品完全不推進,也不寫入時鐘 checkpoint", async () => {
|
||||
await createWork("FAST", false);
|
||||
await createCharacter(CHAR_PLAIN, "測試");
|
||||
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, new Date("2026-08-19T00:00:00.000Z"));
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, new Date("2026-09-19T00:00:00.000Z"));
|
||||
|
||||
const work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.anchorStoryOrder).toBe(0);
|
||||
expect(work.epilogueProgressCheckpointAt).toBeNull();
|
||||
});
|
||||
|
||||
it("FAST 模式下,跨多次不足一天的 tick 也不會遺失推演進度(餘量會保留累積)", async () => {
|
||||
await createWork("FAST"); // 3 storyOrder/現實日
|
||||
await createCharacter(CHAR_PLAIN, "測試");
|
||||
const t0 = new Date("2026-08-19T00:00:00.000Z");
|
||||
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, t0); // 初始化 checkpoint,不推進
|
||||
let work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.anchorStoryOrder).toBe(0);
|
||||
|
||||
for (let hours = 4; hours <= 20; hours += 4) {
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, new Date(t0.getTime() + hours * 60 * 60 * 1000));
|
||||
}
|
||||
work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.anchorStoryOrder).toBe(2); // 每 8 小時累積跨過一個整數單位(3/day → 1 單位/8h)
|
||||
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, new Date(t0.getTime() + 24 * 60 * 60 * 1000));
|
||||
work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.anchorStoryOrder).toBe(3); // 整整一個現實日過後,總量剛好是 3(沒有因為拆成多次 tick 而漏算)
|
||||
}, 20000);
|
||||
|
||||
it("SLOW 比 REALTIME 推進得慢、FAST 比 REALTIME 推進得快(同一段現實時間下比較)", async () => {
|
||||
const oneWeekLater = new Date(new Date("2026-08-19T00:00:00.000Z").getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
await createWork("SLOW");
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, new Date("2026-08-19T00:00:00.000Z"));
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, oneWeekLater);
|
||||
const slowAnchor = (await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } })).anchorStoryOrder;
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
|
||||
await createWork("REALTIME");
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, new Date("2026-08-19T00:00:00.000Z"));
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, oneWeekLater);
|
||||
const realtimeAnchor = (await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } })).anchorStoryOrder;
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
|
||||
await createWork("FAST");
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, new Date("2026-08-19T00:00:00.000Z"));
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, oneWeekLater);
|
||||
const fastAnchor = (await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } })).anchorStoryOrder;
|
||||
|
||||
expect(slowAnchor).toBeLessThan(realtimeAnchor);
|
||||
expect(realtimeAnchor).toBeLessThan(fastAnchor);
|
||||
});
|
||||
|
||||
it("FROZEN 模式永遠不推進;解凍後只從解凍當下開始累積,不會把停滯期間全部補回來", async () => {
|
||||
await createWork("FROZEN");
|
||||
await createCharacter(CHAR_PLAIN, "測試");
|
||||
const t0 = new Date("2026-08-19T00:00:00.000Z");
|
||||
const t0Plus50Days = new Date(t0.getTime() + 50 * 24 * 60 * 60 * 1000);
|
||||
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, t0);
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, t0Plus50Days); // 凍結期間即使經過 50 天仍不推進
|
||||
let work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.anchorStoryOrder).toBe(0);
|
||||
|
||||
await prisma.client.work.update({ where: { id: WORK_ID }, data: { timeFlowMode: "REALTIME" } });
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, new Date(t0Plus50Days.getTime() + 24 * 60 * 60 * 1000));
|
||||
work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.anchorStoryOrder).toBe(1); // 只計入解凍後的 1 天,不是凍結前後合計的 51 天
|
||||
});
|
||||
|
||||
it("推進時嘗試自主成立里程碑:有對應正史伏筆的角色會成立、沒有的角色不會、且不需使用者確認", async () => {
|
||||
await createWork("FAST");
|
||||
await createCharacter(CHAR_BAKER, "她一直想開一間屬於自己的麵包店。");
|
||||
await createCharacter(CHAR_PLAIN, "測試");
|
||||
|
||||
const t0 = new Date("2026-08-19T00:00:00.000Z");
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, t0);
|
||||
await epilogueProgress.advanceIfInEpilogue(WORK_ID, new Date(t0.getTime() + 24 * 60 * 60 * 1000)); // +3 storyOrder
|
||||
|
||||
const bakerMilestones = await prisma.client.epilogueMilestoneLog.findMany({ where: { characterId: CHAR_BAKER } });
|
||||
expect(bakerMilestones).toHaveLength(1);
|
||||
expect(bakerMilestones[0].label).toBe("籌備麵包店");
|
||||
|
||||
const plainMilestones = await prisma.client.epilogueMilestoneLog.findMany({ where: { characterId: CHAR_PLAIN } });
|
||||
expect(plainMilestones).toHaveLength(0);
|
||||
|
||||
// 讀取端點沿用的方法:作品層級可以查到剛剛自主成立的里程碑。
|
||||
const forWork = await lifeEvent.listMilestonesForWork(WORK_ID);
|
||||
expect(forWork.map((m) => m.characterId)).toContain(CHAR_BAKER);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { LifeEventService } from "./life-event.service.js";
|
||||
import { EPILOGUE_STORY_ORDER_RATE, MS_PER_DAY, type TimeFlowMode } from "./constants.js";
|
||||
|
||||
// T34:後日談期間的推演進度時鐘——只在 `work.inEpilogue===true` 時才推進,把「現實流逝了多久」換算成
|
||||
// `anchorStoryOrder` 該往前走多少個單位(FROZEN 恆為 0)。刻意只做「推進時鐘+提議里程碑」,不呼叫
|
||||
// AnchorService.setAnchor()/ProjectionService:那條路徑會無條件重新投影任何 `projected=false` 的場景,
|
||||
// 若剛好有場景還卡在 T39 裁決中,會被這裡的自動推進意外強行投影,破壞裁決阻斷;真正的場景投影
|
||||
// 一律只透過既有的 SceneImportService/裁決端點觸發,這裡只是純粹的時間換算。
|
||||
@Injectable()
|
||||
export class EpilogueProgressService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly lifeEvent: LifeEventService,
|
||||
) {}
|
||||
|
||||
async advanceIfInEpilogue(workId: string, now: Date): Promise<void> {
|
||||
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: workId } });
|
||||
if (!work.inEpilogue) return;
|
||||
|
||||
// 第一次呼叫(時鐘還沒有起點):只建立起點,不倒推之前「沒被 tick 到」的時間,避免一啟用
|
||||
// 就把角色創建到現在為止的整段空窗期全部當成已經推演過。
|
||||
if (work.epilogueProgressCheckpointAt === null) {
|
||||
await this.prisma.client.work.update({ where: { id: workId }, data: { epilogueProgressCheckpointAt: now } });
|
||||
return;
|
||||
}
|
||||
const checkpoint = work.epilogueProgressCheckpointAt;
|
||||
|
||||
const mode = work.timeFlowMode as TimeFlowMode;
|
||||
const rate = EPILOGUE_STORY_ORDER_RATE[mode] ?? EPILOGUE_STORY_ORDER_RATE.FROZEN;
|
||||
|
||||
if (rate.numerator <= 0) {
|
||||
// FROZEN(或未知模式,同視為凍結):不推進,但把時鐘追到現在,避免解凍後把停滯期間全部補回來。
|
||||
await this.prisma.client.work.update({ where: { id: workId }, data: { epilogueProgressCheckpointAt: now } });
|
||||
return;
|
||||
}
|
||||
|
||||
// 先乘、最後才除(見 constants.ts 的說明):分子分母都是整數,被除數在合理的現實時間跨度內
|
||||
// 仍落在 Number 可精確表示的整數範圍,避免預先算好的浮點速率常數在多次 tick 後累積捨入誤差。
|
||||
const elapsedMs = now.getTime() - checkpoint.getTime();
|
||||
const denominator = MS_PER_DAY * rate.perRealDays;
|
||||
const delta = Math.floor((elapsedMs * rate.numerator) / denominator);
|
||||
if (delta <= 0) return; // 累積量還不到一個 storyOrder 單位,時鐘先不動,留給下次繼續累積。
|
||||
|
||||
const consumedMs = (delta * denominator) / rate.numerator;
|
||||
const newAnchorStoryOrder = work.anchorStoryOrder + delta;
|
||||
await this.prisma.client.work.update({
|
||||
where: { id: workId },
|
||||
data: { anchorStoryOrder: newAnchorStoryOrder, epilogueProgressCheckpointAt: new Date(checkpoint.getTime() + consumedMs) },
|
||||
});
|
||||
|
||||
// N-4 里程碑:低頻節流與正史伏筆比對交給既有的 proposeMilestone() 自己判斷,這裡對在場的每位
|
||||
// 角色都嘗試提議一次即可(沒有模板可對應或還沒到節流間隔時,proposeMilestone() 本身回傳 null)。
|
||||
const characters = await this.prisma.client.character.findMany({ where: { workId } });
|
||||
for (const character of characters) {
|
||||
await this.lifeEvent.proposeMilestone(character.id, newAnchorStoryOrder, [], now);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,17 @@ export class EpilogueController {
|
||||
return this.timeFlow.checkEpilogueTransition(workId);
|
||||
}
|
||||
|
||||
// T14:使用者確認/拒絕 T13 寫入的結局偵測結果——尚未進入日後談的作品,這是唯一合法的切換入口。
|
||||
@Post("ending-detections/:logId/confirm")
|
||||
async confirmEndingDetection(@Param("logId") logId: string, @Body() body: { now?: string }) {
|
||||
return this.timeFlow.confirmEndingDetection(logId, body.now ? new Date(body.now) : undefined);
|
||||
}
|
||||
|
||||
@Post("ending-detections/:logId/reject")
|
||||
async rejectEndingDetection(@Param("logId") logId: string, @Body() body: { now?: string }) {
|
||||
return this.timeFlow.rejectEndingDetection(logId, body.now ? new Date(body.now) : undefined);
|
||||
}
|
||||
|
||||
@Post("works/:workId/time-flow")
|
||||
async setTimeFlow(@Param("workId") workId: string, @Body() body: { mode: TimeFlowMode }) {
|
||||
return this.timeFlow.setTimeFlow(workId, body.mode);
|
||||
@@ -43,6 +54,11 @@ export class EpilogueController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("works/:workId/milestones")
|
||||
async listMilestones(@Param("workId") workId: string) {
|
||||
return this.lifeEvent.listMilestonesForWork(workId);
|
||||
}
|
||||
|
||||
@Post("works/:workId/reclaim")
|
||||
async reclaim(@Param("workId") workId: string, @Body() body: { newCanonScenes: ImportSceneInput[] }) {
|
||||
return this.reclamation.reclaim(workId, body.newCanonScenes);
|
||||
|
||||
@@ -6,12 +6,13 @@ import { TimeFlowService } from "./time-flow.service.js";
|
||||
import { PersonalityDriftService } from "./personality-drift.service.js";
|
||||
import { LifeEventService } from "./life-event.service.js";
|
||||
import { CanonReclamationService } from "./canon-reclamation.service.js";
|
||||
import { EpilogueProgressService } from "./epilogue-progress.service.js";
|
||||
import { EpilogueController } from "./epilogue.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, RomanceModule, CanonModule],
|
||||
controllers: [EpilogueController],
|
||||
providers: [TimeFlowService, PersonalityDriftService, LifeEventService, CanonReclamationService],
|
||||
exports: [TimeFlowService, PersonalityDriftService, LifeEventService, CanonReclamationService],
|
||||
providers: [TimeFlowService, PersonalityDriftService, LifeEventService, CanonReclamationService, EpilogueProgressService],
|
||||
exports: [TimeFlowService, PersonalityDriftService, LifeEventService, CanonReclamationService, EpilogueProgressService],
|
||||
})
|
||||
export class EpilogueModule {}
|
||||
|
||||
@@ -110,4 +110,13 @@ export class LifeEventService {
|
||||
await this.projection.projectScene(scene.id);
|
||||
return this.prisma.client.scene.findUniqueOrThrow({ where: { id: scene.id } });
|
||||
}
|
||||
|
||||
// T34:作品頁/大廳呈現這個作品旗下角色自主成立過的所有里程碑,依 storyOrder 排序方便對照時序。
|
||||
async listMilestonesForWork(workId: string) {
|
||||
const characters = await this.prisma.client.character.findMany({ where: { workId }, select: { id: true } });
|
||||
return this.prisma.client.epilogueMilestoneLog.findMany({
|
||||
where: { characterId: { in: characters.map((c) => c.id) } },
|
||||
orderBy: { storyOrder: "asc" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { MockProvider } from "../llm/mock-provider.service.js";
|
||||
import { BehaviorReinforcementService } from "../llm/behavior-reinforcement.service.js";
|
||||
import { AgeDeterminationService } from "../romance/age-determination.service.js";
|
||||
import { RomanceEligibilityService } from "../romance/romance-eligibility.service.js";
|
||||
import { FingerprintService } from "../canon/fingerprint.service.js";
|
||||
import { AttributionService } from "../canon/attribution.service.js";
|
||||
import { ProjectionService } from "../canon/projection.service.js";
|
||||
import { WorkRosterService } from "../canon/work-roster.service.js";
|
||||
import { TimelineService } from "../canon/timeline.service.js";
|
||||
import { EndingDetectionService } from "../canon/ending-detection.service.js";
|
||||
import { SceneImportService } from "../canon/scene-import.service.js";
|
||||
import { TimeFlowService } from "./time-flow.service.js";
|
||||
|
||||
// T14 驗收:確認後 Work.inEpilogue 為 true、epilogueStartStoryOrder 正確;拒絕後作品維持原狀,
|
||||
// 同一場景不再重複提示;已處理過的偵測結果不可重複確認或拒絕。
|
||||
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 fingerprint = new FingerprintService(prisma);
|
||||
const attribution = new AttributionService(prisma, fingerprint);
|
||||
const behaviorReinforcement = new BehaviorReinforcementService(prisma);
|
||||
const projection = new ProjectionService(prisma, new RuleBasedEmotionTagger(), behaviorReinforcement);
|
||||
const roster = new WorkRosterService(prisma);
|
||||
const timeline = new TimelineService(prisma);
|
||||
const endingDetection = new EndingDetectionService(prisma, new MockProvider());
|
||||
const sceneImport = new SceneImportService(prisma, attribution, projection, roster, timeline, endingDetection);
|
||||
const eligibility = new RomanceEligibilityService(prisma, new AgeDeterminationService(prisma));
|
||||
const timeFlow = new TimeFlowService(prisma, eligibility);
|
||||
|
||||
const WORK_ID = "test-t14-work";
|
||||
const CHAR_A = "test-t14-char-a";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.endingDetectionLog.deleteMany({ where: { scene: { workId: WORK_ID } } });
|
||||
await prisma.client.scene.deleteMany({ where: { workId: WORK_ID } });
|
||||
await prisma.client.character.deleteMany({ where: { id: CHAR_A } });
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
});
|
||||
|
||||
async function importEndingScene(anchorStoryOrder: number, storyOrder: number) {
|
||||
await prisma.client.work.create({ data: { id: WORK_ID, title: "T14 測試作品", anchorStoryOrder } });
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_A,
|
||||
workId: WORK_ID,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "CANDIDATE",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: "小甲" } },
|
||||
},
|
||||
});
|
||||
return sceneImport.importScene({
|
||||
workId: WORK_ID,
|
||||
volume: 1,
|
||||
chapterLabel: "第一話",
|
||||
storyOrder,
|
||||
summary: "故事在此完結,全劇終。",
|
||||
rawText: "(測試文本)",
|
||||
participantCharacterIds: [CHAR_A],
|
||||
lines: [{ lineType: "NARRATION", rawText: "全劇終。" }],
|
||||
});
|
||||
}
|
||||
|
||||
describe("TimeFlowService 結局確認與拒絕(T14)", () => {
|
||||
it("確認後 Work.inEpilogue 為 true、epilogueStartStoryOrder 正確,且該筆紀錄改為 CONFIRMED", async () => {
|
||||
await importEndingScene(50, 1);
|
||||
const log = await prisma.client.endingDetectionLog.findFirstOrThrow({ where: { scene: { workId: WORK_ID } } });
|
||||
|
||||
const work = await timeFlow.confirmEndingDetection(log.id);
|
||||
expect(work.inEpilogue).toBe(true);
|
||||
expect(work.epilogueStartStoryOrder).toBe(50);
|
||||
|
||||
const logAfter = await prisma.client.endingDetectionLog.findUniqueOrThrow({ where: { id: log.id } });
|
||||
expect(logAfter.resolution).toBe("CONFIRMED");
|
||||
expect(logAfter.respondedAt).toBeTruthy();
|
||||
}, 20000);
|
||||
|
||||
it("拒絕後作品維持原狀,且同一場景不再重複提示", async () => {
|
||||
const scene = await importEndingScene(50, 1);
|
||||
const log = await prisma.client.endingDetectionLog.findFirstOrThrow({ where: { scene: { workId: WORK_ID } } });
|
||||
|
||||
await timeFlow.rejectEndingDetection(log.id);
|
||||
const work = await prisma.client.work.findUniqueOrThrow({ where: { id: WORK_ID } });
|
||||
expect(work.inEpilogue).toBe(false);
|
||||
|
||||
const logAfter = await prisma.client.endingDetectionLog.findUniqueOrThrow({ where: { id: log.id } });
|
||||
expect(logAfter.resolution).toBe("REJECTED");
|
||||
|
||||
// 對同一場景重新嘗試偵測(模擬未來可能的重複觸發路徑),不應再寫入新的一筆。
|
||||
await endingDetection.detectForScene(scene.id);
|
||||
const logs = await prisma.client.endingDetectionLog.findMany({ where: { sceneId: scene.id } });
|
||||
expect(logs).toHaveLength(1);
|
||||
}, 20000);
|
||||
|
||||
it("已處理過的偵測結果不可重複確認或拒絕", async () => {
|
||||
await importEndingScene(50, 1);
|
||||
const log = await prisma.client.endingDetectionLog.findFirstOrThrow({ where: { scene: { workId: WORK_ID } } });
|
||||
await timeFlow.confirmEndingDetection(log.id);
|
||||
|
||||
await expect(timeFlow.confirmEndingDetection(log.id)).rejects.toThrow();
|
||||
await expect(timeFlow.rejectEndingDetection(log.id)).rejects.toThrow();
|
||||
}, 20000);
|
||||
});
|
||||
@@ -13,6 +13,11 @@ export class TimeFlowService {
|
||||
) {}
|
||||
|
||||
// 錨定點推進後呼叫:若已達到(或超過)已出版內容的盡頭,切入後日談區間。
|
||||
// T14:本方法保留給 T15(日後談續匯入)既有呼叫端(canon-reclamation.service.ts)沿用——那裡處理
|
||||
// 的是「已經在日後談中的作品」續匯入正史內容,`work.inEpilogue` 早已為 true 時本方法直接 no-op 回傳,
|
||||
// 不構成新的未經確認自動切換路徑。**尚未進入日後談的作品,唯一合法的切換入口是下方
|
||||
// `confirmEndingDetection()`**(使用者確認結局偵測結果後才切);本方法不應再被當成「偵測到疑似結局
|
||||
// 就自動切換」的路徑使用,第 4 輪「偵測到結局先問使用者再切」的決定以 confirmEndingDetection() 落實。
|
||||
async checkEpilogueTransition(workId: string) {
|
||||
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: workId } });
|
||||
if (work.inEpilogue) {
|
||||
@@ -24,14 +29,49 @@ export class TimeFlowService {
|
||||
});
|
||||
const maxCanonStoryOrder = canonMax._max.storyOrder ?? 0;
|
||||
if (maxCanonStoryOrder > 0 && work.anchorStoryOrder >= maxCanonStoryOrder) {
|
||||
return this.prisma.client.work.update({
|
||||
where: { id: work.id },
|
||||
data: { inEpilogue: true, epilogueStartStoryOrder: work.anchorStoryOrder },
|
||||
});
|
||||
return this.switchToEpilogue(work.id, work.anchorStoryOrder);
|
||||
}
|
||||
return work;
|
||||
}
|
||||
|
||||
private async switchToEpilogue(workId: string, epilogueStartStoryOrder: number) {
|
||||
return this.prisma.client.work.update({
|
||||
where: { id: workId },
|
||||
data: { inEpilogue: true, epilogueStartStoryOrder },
|
||||
});
|
||||
}
|
||||
|
||||
// T14:使用者確認 EndingDetectionLog 的偵測結果——這是「尚未進入日後談的作品」唯一合法的切換入口。
|
||||
async confirmEndingDetection(logId: string, now: Date = new Date()) {
|
||||
const log = await this.prisma.client.endingDetectionLog.findUniqueOrThrow({
|
||||
where: { id: logId },
|
||||
include: { scene: true },
|
||||
});
|
||||
if (log.resolution !== "PENDING") {
|
||||
throw new BadRequestException(`此偵測結果已處理過(目前狀態:${log.resolution}),不可重複確認或拒絕`);
|
||||
}
|
||||
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: log.scene.workId } });
|
||||
await this.switchToEpilogue(work.id, work.anchorStoryOrder);
|
||||
await this.prisma.client.endingDetectionLog.update({
|
||||
where: { id: logId },
|
||||
data: { resolution: "CONFIRMED", respondedAt: now },
|
||||
});
|
||||
return this.prisma.client.work.findUniqueOrThrow({ where: { id: work.id } });
|
||||
}
|
||||
|
||||
// T14:拒絕——作品維持原狀;同一 sceneId 不再重複提示(EndingDetectionService.detectForScene()
|
||||
// 一律以「這個場景是否已有任意一筆紀錄」判斷要不要再寫新紀錄,不看 resolution,故拒絕後天然不重問)。
|
||||
async rejectEndingDetection(logId: string, now: Date = new Date()) {
|
||||
const log = await this.prisma.client.endingDetectionLog.findUniqueOrThrow({ where: { id: logId } });
|
||||
if (log.resolution !== "PENDING") {
|
||||
throw new BadRequestException(`此偵測結果已處理過(目前狀態:${log.resolution}),不可重複確認或拒絕`);
|
||||
}
|
||||
return this.prisma.client.endingDetectionLog.update({
|
||||
where: { id: logId },
|
||||
data: { resolution: "REJECTED", respondedAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
async setTimeFlow(workId: string, mode: TimeFlowMode) {
|
||||
if (mode === "FAST") {
|
||||
const characters = await this.prisma.client.character.findMany({ where: { workId } });
|
||||
|
||||
@@ -62,6 +62,27 @@ export class BehaviorReinforcementService {
|
||||
}
|
||||
}
|
||||
|
||||
// T25:章節匯入時從場景(ACTION 描寫)抽出的行為模式——首見新增規則、已有相同 (situation,
|
||||
// responsePattern) 則強化既有權重,一律記錄最近一次觸發的來源場景(供追溯與重投影清除,
|
||||
// 已知簡化見 schema.prisma 的 sourceSceneId 欄位註解:只記錄最近一次,不是完整貢獻清單)。
|
||||
async recordFromScene(characterId: string, situation: string, responsePattern: string, sceneId: string): Promise<void> {
|
||||
const existing = await this.prisma.client.proceduralRule.findFirst({
|
||||
where: { characterId, situation, responsePattern },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await this.prisma.client.proceduralRule.update({
|
||||
where: { id: existing.id },
|
||||
data: { weight: existing.weight + REINFORCE_INCREMENT, sourceSceneId: sceneId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.client.proceduralRule.create({
|
||||
data: { characterId, situation, responsePattern, weight: 1, sourceSceneId: sceneId },
|
||||
});
|
||||
}
|
||||
|
||||
async getTopRule(characterId: string, situation: string) {
|
||||
return this.prisma.client.proceduralRule.findFirst({
|
||||
where: { characterId, situation },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { LLMProvider } from "./llm-provider.js";
|
||||
import type { CharacterCallCandidate, EndingDetectionResult, LLMProvider } from "./llm-provider.js";
|
||||
import type { GenerationContext, GeneratedResponse } from "./types.js";
|
||||
import { buildMessages } from "./prompt-builder.js";
|
||||
import { extractActions } from "./action-markup.js";
|
||||
@@ -8,8 +8,13 @@ interface OpenAiCompatibleChoice {
|
||||
message?: { content?: string };
|
||||
delta?: { content?: string };
|
||||
}
|
||||
interface OpenAiCompatibleUsage {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
}
|
||||
interface OpenAiCompatibleResponse {
|
||||
choices?: OpenAiCompatibleChoice[];
|
||||
usage?: OpenAiCompatibleUsage;
|
||||
}
|
||||
|
||||
function baseUrl(): string {
|
||||
@@ -61,7 +66,12 @@ export class ClaudeProvider implements LLMProvider {
|
||||
if (!text) {
|
||||
throw new Error("ClaudeProvider 回應內容為空(choices[0].message.content 缺失)");
|
||||
}
|
||||
return { text, actions: extractActions(text) };
|
||||
// T9:真實 API 回應的 usage 未提供時(部分相容端點不回傳)以 0 記錄,不因此中斷生成。
|
||||
return {
|
||||
text,
|
||||
actions: extractActions(text),
|
||||
usage: { model: model(), promptTokens: json.usage?.prompt_tokens ?? 0, completionTokens: json.usage?.completion_tokens ?? 0 },
|
||||
};
|
||||
}
|
||||
|
||||
async *stream(context: GenerationContext): AsyncIterable<string> {
|
||||
@@ -98,4 +108,37 @@ export class ClaudeProvider implements LLMProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// T5:LLM 兼底判斷指稱哪個角色;提示詞要求只能回傳候選 id 或 NONE,避免自行編造不存在的角色。
|
||||
async identifyCharacter(utterance: string, candidates: CharacterCallCandidate[]): Promise<string | null> {
|
||||
const candidateList = candidates.map((c) => `- ${c.id}:${c.label}(${c.keywords})`).join("\n");
|
||||
const prompt = `使用者說了一句話,請判斷這句話是在呼喚下面候選名單中的哪一位。只能回傳候選名單裡的 id,不可以自行編造不存在的 id;如果判斷不出來或這句話沒有在呼喚任何人,就回傳 NONE。只回傳 id 或 NONE,不要有其他文字。\n\n候選名單:\n${candidateList}\n\n使用者的話:「${utterance}」`;
|
||||
const res = await requestChatCompletion({
|
||||
model: model(),
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
stream: false,
|
||||
});
|
||||
const json = (await res.json()) as OpenAiCompatibleResponse;
|
||||
const answer = json.choices?.[0]?.message?.content?.trim() ?? "";
|
||||
return candidates.some((c) => c.id === answer) ? answer : null;
|
||||
}
|
||||
|
||||
// T13:每章投影完成後都呼叫,判斷這個場景是否為整部作品的最終結局;提示詞刻意不帶章節標題,
|
||||
// 只給場景摘要與原文本身,避免標題關鍵字(例如「最終回」)預先左右判斷。
|
||||
async detectEnding(sceneSummary: string, sceneRawText: string, workTitle: string): Promise<EndingDetectionResult> {
|
||||
const prompt = `你是故事編輯,請只依下面的場景內容本身判斷:這段場景是否為作品「${workTitle}」整部作品的最終結局(整部作品在此完全收尾,不是單一篇章、單元劇或某個角色支線的結尾)。不要用章節標題或任何標題訊號預先假設,只看場景實際描寫的內容。\n\n場景摘要:${sceneSummary}\n\n場景原文:${sceneRawText}\n\n請用以下格式回答,不要有其他文字:\nANSWER: YES 或 NO\nEVIDENCE: 一到兩句話說明你的判斷依據`;
|
||||
const res = await requestChatCompletion({
|
||||
model: model(),
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
stream: false,
|
||||
});
|
||||
const json = (await res.json()) as OpenAiCompatibleResponse;
|
||||
const text = json.choices?.[0]?.message?.content?.trim() ?? "";
|
||||
const answerMatch = text.match(/ANSWER:\s*(YES|NO)/i);
|
||||
const evidenceMatch = text.match(/EVIDENCE:\s*([\s\S]+)/i);
|
||||
return {
|
||||
isEnding: (answerMatch?.[1] ?? "").toUpperCase() === "YES",
|
||||
evidence: evidenceMatch?.[1]?.trim() || text || "(LLM 未回傳可解析的判斷依據)",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import { BadRequestException, Body, Controller, Get, Inject, Param, Post, Query } from "@nestjs/common";
|
||||
import { DialogueService } from "./dialogue.service.js";
|
||||
import { BehaviorReinforcementService } from "./behavior-reinforcement.service.js";
|
||||
import { LLM_PROVIDER, type LLMProvider } from "./llm-provider.js";
|
||||
import { MockProvider } from "./mock-provider.service.js";
|
||||
import { GlobalPauseService } from "./global-pause.service.js";
|
||||
|
||||
interface SendMessageBody {
|
||||
userId: string;
|
||||
@@ -25,8 +28,39 @@ export class DialogueController {
|
||||
constructor(
|
||||
private readonly dialogue: DialogueService,
|
||||
private readonly reinforcement: BehaviorReinforcementService,
|
||||
@Inject(LLM_PROVIDER) private readonly provider: LLMProvider,
|
||||
private readonly globalPause: GlobalPauseService,
|
||||
) {}
|
||||
|
||||
// T21-P3 端到端驗證用途:讓 smoke test 能觸發真正的連續失敗達到 T30 的全域靜默暫停門檻,
|
||||
// 僅在 LLM_PROVIDER=mock 時可用(MockProvider 才有可佇列的失敗)。
|
||||
@Post("test/force-llm-failure")
|
||||
async forceLlmFailure(@Body() body: { count?: number }) {
|
||||
if (!(this.provider instanceof MockProvider)) {
|
||||
throw new BadRequestException("僅 LLM_PROVIDER=mock 時可用");
|
||||
}
|
||||
for (let i = 0; i < (body.count ?? 1); i++) {
|
||||
this.provider.enqueueFailure(new Error("smoke-test forced failure"));
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get("test/global-pause-state")
|
||||
getGlobalPauseState() {
|
||||
return { paused: this.globalPause.isPaused() };
|
||||
}
|
||||
|
||||
// T21-P3:驗證「恢復」需要一次保證成功的呼叫——先清空可能還沒被消耗完的佇列失敗,
|
||||
// 確保下一次真的呼叫 LLM 的行為會成功,而不是被前面過量佇列的失敗繼續蓋過去。
|
||||
@Post("test/reset-llm-queue")
|
||||
resetLlmQueue() {
|
||||
if (!(this.provider instanceof MockProvider)) {
|
||||
throw new BadRequestException("僅 LLM_PROVIDER=mock 時可用");
|
||||
}
|
||||
this.provider.resetQueue();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post(":characterId/sessions/:sessionId/messages")
|
||||
async sendMessage(
|
||||
@Param("characterId") characterId: string,
|
||||
|
||||
@@ -9,12 +9,14 @@ import { ScheduleEmotionBaselineService } from "../schedule/schedule-emotion-bas
|
||||
import { OfflineEventService } from "../schedule/offline-event.service.js";
|
||||
import { availabilityForStatus, type AvailabilityMode, type ExceptionType } from "../schedule/constants.js";
|
||||
import { LLM_PROVIDER, type LLMProvider } from "./llm-provider.js";
|
||||
import { GlobalPauseService } from "./global-pause.service.js";
|
||||
import { generateWithResilience } from "./generate-with-resilience.js";
|
||||
import { ContextAssemblerService } from "./context-assembler.service.js";
|
||||
import { OutputFilterService } from "./output-filter.service.js";
|
||||
import { FastChannelDetector, DEFAULT_FAST_REPLIES } from "./fast-channel.service.js";
|
||||
import { extractActions } from "./action-markup.js";
|
||||
import { getForbiddenWords, applySpeechQuirks } from "../personality/language-style.js";
|
||||
import type { GenerationContext } from "./types.js";
|
||||
import type { GenerationContext, GenerationUsage } from "./types.js";
|
||||
|
||||
export interface HandleMessageOptions {
|
||||
seed?: number;
|
||||
@@ -28,6 +30,7 @@ export interface HandleMessageResult {
|
||||
availability: AvailabilityMode;
|
||||
exceptionType: ExceptionType | null;
|
||||
context: GenerationContext | null;
|
||||
usage?: GenerationUsage; // T9:僅真的呼叫 Provider 生成時才有值(雙速通道/作息延遲不消耗 LLM)
|
||||
}
|
||||
|
||||
function fastChannelSituation(reason: string): string {
|
||||
@@ -53,6 +56,7 @@ export class DialogueService {
|
||||
private readonly scheduleBaseline: ScheduleEmotionBaselineService,
|
||||
private readonly offlineEvent: OfflineEventService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly globalPause: GlobalPauseService,
|
||||
) {}
|
||||
|
||||
async handleMessage(
|
||||
@@ -111,6 +115,7 @@ export class DialogueService {
|
||||
|
||||
let text: string;
|
||||
let actions: string[];
|
||||
let usage: GenerationUsage | undefined;
|
||||
|
||||
if (effectiveAvailability === "DELAYED" && !exceptionType) {
|
||||
text = DELAYED_ACKNOWLEDGEMENT;
|
||||
@@ -123,16 +128,27 @@ export class DialogueService {
|
||||
text = rule?.responsePattern ?? DEFAULT_FAST_REPLIES[reason];
|
||||
actions = extractActions(text);
|
||||
} else {
|
||||
const generated = await this.llmProvider.generate(context);
|
||||
text = generated.text;
|
||||
actions = generated.actions;
|
||||
// T30:全域靜默暫停或重試全部失敗時回傳 null——比照既有 NO_REPLY 的「這輪沒有真的回覆」語意,
|
||||
// 回傳空字串,呼叫端(SoloChatService.sendMessage)已經是「text 有內容才寫入 RoomTurn」,
|
||||
// 不需要另外判斷;使用者本人的發話在呼叫端已於此之前寫入,不受影響。
|
||||
const generated = await generateWithResilience(this.llmProvider, context, this.globalPause);
|
||||
text = generated?.text ?? "";
|
||||
actions = generated?.actions ?? [];
|
||||
usage = generated?.usage;
|
||||
}
|
||||
|
||||
// I-2 剛醒/睡前:以拖長語尾的簡單標記呈現迷糊演出(完整口吻交由模板庫日後擴充)。
|
||||
if (scheduleInfo.isWaking || scheduleInfo.isPreSleep) {
|
||||
// I-2 剛醒/睡前:以拖長語尾的簡單標記呈現迷糊演出(完整口吻交由模板庫日後擴充);暫停中沒有
|
||||
// 真的回覆(text 為空)時不套用,避免把「……」誤當成一則真的有內容的回覆。
|
||||
if (text && (scheduleInfo.isWaking || scheduleInfo.isPreSleep)) {
|
||||
text = `……${text}`;
|
||||
}
|
||||
|
||||
// T30:暫停中沒有真的回覆(text 為空)時,比照既有 NO_REPLY 語意略過輸出過濾與工作記憶寫入,
|
||||
// 不留下一則空白的角色訊息。
|
||||
if (!text) {
|
||||
return { text: "", actions, isFastChannel, availability: effectiveAvailability, exceptionType, context, usage };
|
||||
}
|
||||
|
||||
const forbiddenWords = getForbiddenWords(context.character.personalityArchetype);
|
||||
const filteredText = applySpeechQuirks(
|
||||
this.outputFilter.filter(text, forbiddenWords),
|
||||
@@ -140,6 +156,6 @@ export class DialogueService {
|
||||
);
|
||||
await this.workingMemory.append(sessionId, { role: "character", content: filteredText, timestamp: now });
|
||||
|
||||
return { text: filteredText, actions, isFastChannel, availability: effectiveAvailability, exceptionType, context };
|
||||
return { text: filteredText, actions, isFastChannel, availability: effectiveAvailability, exceptionType, context, usage };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MockProvider } from "./mock-provider.service.js";
|
||||
import { GlobalPauseService } from "./global-pause.service.js";
|
||||
import { generateWithResilience } from "./generate-with-resilience.js";
|
||||
import { GLOBAL_PAUSE_FAILURE_THRESHOLD } from "./resilience-constants.js";
|
||||
import type { GenerationContext } from "./types.js";
|
||||
|
||||
function fixtureContext(): GenerationContext {
|
||||
return {
|
||||
character: { id: "char-1", personalityArchetype: "元氣", speechStyle: "測試", likesDislikes: "測試", basicInfo: "測試" },
|
||||
emotion: { dominant: "CALM", style: { tone: "平穩自然", sentenceLength: "medium", initiative: "medium" } },
|
||||
relationship: { intimacy: 50, trust: 50, stage: "ACQUAINTANCE" },
|
||||
retrievedMemories: [],
|
||||
history: [],
|
||||
userInput: "測試",
|
||||
injectedMemoryIds: [],
|
||||
seed: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// T30 驗收:連續失敗達門檻觸發全域靜默暫停、恢復後自動解除並繼續;暫停期間不重試(只嘗試一次)。
|
||||
describe("generateWithResilience/GlobalPauseService(T30)", () => {
|
||||
it("單次失敗會重試,最終成功時回傳結果且不觸發暫停", async () => {
|
||||
const provider = new MockProvider();
|
||||
const pause = new GlobalPauseService();
|
||||
provider.enqueueFailure(new Error("暫時性錯誤"));
|
||||
const result = await generateWithResilience(provider, fixtureContext(), pause);
|
||||
expect(result).not.toBeNull();
|
||||
expect(pause.isPaused()).toBe(false);
|
||||
});
|
||||
|
||||
it("連續失敗達門檻時觸發全域靜默暫停,回傳 null", async () => {
|
||||
const provider = new MockProvider();
|
||||
const pause = new GlobalPauseService();
|
||||
|
||||
// 每次呼叫都會重試 RETRY_MAX_ATTEMPTS+1 次,讓每次呼叫都徹底失敗,模擬 LLM 全面故障。
|
||||
for (let call = 0; call < GLOBAL_PAUSE_FAILURE_THRESHOLD; call++) {
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
provider.enqueueFailure(new Error(`模擬全面故障 ${call}-${attempt}`));
|
||||
}
|
||||
const result = await generateWithResilience(provider, fixtureContext(), pause);
|
||||
expect(result).toBeNull();
|
||||
}
|
||||
|
||||
expect(pause.isPaused()).toBe(true);
|
||||
}, 20000);
|
||||
|
||||
it("暫停中每次只嘗試一次(不重試);一旦成功就立刻解除暫停並回傳結果", async () => {
|
||||
const provider = new MockProvider();
|
||||
const pause = new GlobalPauseService();
|
||||
|
||||
for (let call = 0; call < GLOBAL_PAUSE_FAILURE_THRESHOLD; call++) {
|
||||
for (let attempt = 0; attempt < 10; attempt++) provider.enqueueFailure(new Error("故障"));
|
||||
await generateWithResilience(provider, fixtureContext(), pause);
|
||||
}
|
||||
expect(pause.isPaused()).toBe(true);
|
||||
|
||||
// 暫停中再失敗一次:只嘗試一次(佇列裡還有很多待用的失敗,但只會消耗一個)。
|
||||
provider.enqueueFailure(new Error("仍在故障"));
|
||||
provider.enqueueFailure(new Error("這個不該被消耗到"));
|
||||
const stillFailing = await generateWithResilience(provider, fixtureContext(), pause);
|
||||
expect(stillFailing).toBeNull();
|
||||
expect(pause.isPaused()).toBe(true);
|
||||
provider.resetQueue(); // 清掉尚未消耗的「這個不該被消耗到」,確認上面真的只嘗試了一次
|
||||
|
||||
const recovered = await generateWithResilience(provider, fixtureContext(), pause);
|
||||
expect(recovered).not.toBeNull();
|
||||
expect(pause.isPaused()).toBe(false);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { LLMProvider } from "./llm-provider.js";
|
||||
import type { GenerationContext, GeneratedResponse } from "./types.js";
|
||||
import type { GlobalPauseService } from "./global-pause.service.js";
|
||||
import { RETRY_MAX_ATTEMPTS, RETRY_BASE_BACKOFF_MS } from "./resilience-constants.js";
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// T30:自走生成的唯一呼叫入口——指數退避重試+全域靜默暫停旗標的判斷與更新都收斂在這裡,
|
||||
// RoomGenerationService/DialogueService 兩個呼叫端都改叫這個函式,不直接呼叫 provider.generate()。
|
||||
// 回傳 null 表示這一輪沒有回覆(暫停中,或重試全部失敗),呼叫端應靜默略過(不寫 RoomTurn/不顯示錯誤),
|
||||
// 使用者本人的發話仍照常已由呼叫端在此之前寫入,不受影響。
|
||||
export async function generateWithResilience(
|
||||
provider: LLMProvider,
|
||||
context: GenerationContext,
|
||||
pause: GlobalPauseService,
|
||||
): Promise<GeneratedResponse | null> {
|
||||
if (pause.isPaused()) {
|
||||
// 暫停期間仍每次嘗試「一次」真的呼叫(不額外重試),用來偵測服務是否已恢復;
|
||||
// 一旦成功就立刻解除暫停,不需要額外的健康檢查機制。
|
||||
try {
|
||||
const result = await provider.generate(context);
|
||||
pause.recordSuccess();
|
||||
return result;
|
||||
} catch (err) {
|
||||
pause.recordFailure(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= RETRY_MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const result = await provider.generate(context);
|
||||
pause.recordSuccess();
|
||||
return result;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (attempt < RETRY_MAX_ATTEMPTS) {
|
||||
await sleep(RETRY_BASE_BACKOFF_MS * 2 ** attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
pause.recordFailure(lastError);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { log } from "@kokorone/shared";
|
||||
import { GLOBAL_PAUSE_FAILURE_THRESHOLD } from "./resilience-constants.js";
|
||||
|
||||
// T30:自走生成連續失敗達門檻時,設定全域靜默暫停旗標,停止所有房間與大廳的自走生成——不對使用者
|
||||
// 顯示任何提示、不寫 SYSTEM 訊息,只記後端 log。單一 api 實例(README 已於部署章節註明),故用
|
||||
// 行程內記憶體狀態即可,不需要跨行程同步。
|
||||
@Injectable()
|
||||
export class GlobalPauseService {
|
||||
private paused = false;
|
||||
private consecutiveFailures = 0;
|
||||
|
||||
isPaused(): boolean {
|
||||
return this.paused;
|
||||
}
|
||||
|
||||
recordSuccess(): void {
|
||||
this.consecutiveFailures = 0;
|
||||
if (this.paused) {
|
||||
this.paused = false;
|
||||
log("自走暫停", "INF", "LLM 恢復正常,自動解除全域靜默暫停");
|
||||
}
|
||||
}
|
||||
|
||||
recordFailure(err: unknown): void {
|
||||
this.consecutiveFailures += 1;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log("自走暫停", "ERR", `LLM 生成失敗(連續第 ${this.consecutiveFailures} 次):${message}`);
|
||||
if (this.consecutiveFailures >= GLOBAL_PAUSE_FAILURE_THRESHOLD && !this.paused) {
|
||||
this.paused = true;
|
||||
log(
|
||||
"自走暫停",
|
||||
"ERR",
|
||||
`連續失敗達 ${GLOBAL_PAUSE_FAILURE_THRESHOLD} 次,觸發全域靜默暫停:所有房間與大廳自走生成停止,不對使用者顯示任何提示`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// K-4/T5 共用:文字與關鍵字語料的重疊度,供「是否被提及/指稱哪個角色」類判斷使用。
|
||||
export function keywordOverlap(text: string, corpus: string): number {
|
||||
// 中文常見以單字成詞(貓/狗/書),門檻不能設 >=2,否則會漏掉大量真實關鍵字。
|
||||
const keywords = corpus.split(/[、,,;::\s]+/).filter((word) => word.length >= 1);
|
||||
if (keywords.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const hits = keywords.filter((keyword) => text.includes(keyword)).length;
|
||||
if (hits === 0) {
|
||||
return 0;
|
||||
}
|
||||
// 只要命中,就是明確訊號(哪怕只命中角色喜好清單裡的一個詞):至少給予下限分數,
|
||||
// 不讓「喜好清單條目多」稀釋掉單次命中的重要性。
|
||||
return Math.max(0.5, hits / keywords.length);
|
||||
}
|
||||
@@ -1,9 +1,26 @@
|
||||
import type { GenerationContext, GeneratedResponse } from "./types.js";
|
||||
|
||||
export interface CharacterCallCandidate {
|
||||
id: string;
|
||||
label: string;
|
||||
keywords: string; // 供關鍵字比對的語料(性格原型/喜好/目標等),格式同既有頓號分隔慣例
|
||||
}
|
||||
|
||||
export interface EndingDetectionResult {
|
||||
isEnding: boolean;
|
||||
evidence: string; // 判定理由,成立與不成立都要有依據可查
|
||||
}
|
||||
|
||||
// F-1 LLMProvider 介面:切換 Provider 不動引擎任何一行。
|
||||
export interface LLMProvider {
|
||||
generate(context: GenerationContext): Promise<GeneratedResponse>;
|
||||
stream(context: GenerationContext): AsyncIterable<string>;
|
||||
// T5:大廳對話式呼叫的規則比對失敗或指稱含糊時的 LLM 兼底——從候選清單判斷指稱的是哪個角色,
|
||||
// 必須回傳候選清單中的某個 id 或 null(無法判斷/沒有人被指稱),不可自行編造不存在的角色。
|
||||
identifyCharacter(utterance: string, candidates: CharacterCallCandidate[]): Promise<string | null>;
|
||||
// T13:每章投影完成後都呼叫,判斷這個場景是否為整部作品的最終結局;只依場景內容本身判斷,
|
||||
// 不以章節標題關鍵字預篩(呼叫端不會把標題資訊帶進來影響判斷)。
|
||||
detectEnding(sceneSummary: string, sceneRawText: string, workTitle: string): Promise<EndingDetectionResult>;
|
||||
}
|
||||
|
||||
export const LLM_PROVIDER = Symbol("LLM_PROVIDER");
|
||||
|
||||
@@ -14,6 +14,7 @@ import { BehaviorReinforcementService } from "./behavior-reinforcement.service.j
|
||||
import { ContextAssemblerService } from "./context-assembler.service.js";
|
||||
import { DialogueService } from "./dialogue.service.js";
|
||||
import { DialogueController } from "./dialogue.controller.js";
|
||||
import { GlobalPauseService } from "./global-pause.service.js";
|
||||
|
||||
// F-2 Provider 切換:LLM_PROVIDER=mock|claude 決定注入哪個實作,切換不動引擎任何一行。
|
||||
// R-3:ClaudeProvider 已接上真實 API,這裡不再需要特別的「尚未實作」警告。
|
||||
@@ -38,7 +39,8 @@ function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
|
||||
BehaviorReinforcementService,
|
||||
ContextAssemblerService,
|
||||
DialogueService,
|
||||
GlobalPauseService,
|
||||
],
|
||||
exports: [DialogueService, BehaviorReinforcementService, LLM_PROVIDER],
|
||||
exports: [DialogueService, BehaviorReinforcementService, LLM_PROVIDER, GlobalPauseService],
|
||||
})
|
||||
export class LlmModule {}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MockProvider } from "./mock-provider.service.js";
|
||||
import type { GenerationContext } from "./types.js";
|
||||
|
||||
function fixtureContext(overrides: Partial<GenerationContext> = {}): GenerationContext {
|
||||
return {
|
||||
character: {
|
||||
id: "char-1",
|
||||
personalityArchetype: "TSUNDERE",
|
||||
speechStyle: "口是心非",
|
||||
likesDislikes: "喜歡貓,不喜歡被稱讚",
|
||||
basicInfo: "測試用角色",
|
||||
},
|
||||
emotion: { dominant: "CALM", style: { tone: "平穩自然", sentenceLength: "medium", initiative: "medium" } },
|
||||
relationship: { intimacy: 10, trust: 10, stage: "STRANGER" },
|
||||
retrievedMemories: [],
|
||||
history: [],
|
||||
userInput: "早安",
|
||||
injectedMemoryIds: [],
|
||||
seed: 42,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MockProvider", () => {
|
||||
it("同 seed 產生相同輸出,全程不呼叫網路(LLM_PROVIDER=mock 的離線保證)", async () => {
|
||||
const provider = new MockProvider();
|
||||
const context = fixtureContext();
|
||||
const first = await provider.generate(context);
|
||||
const second = await provider.generate(context);
|
||||
expect(first.text).toBe(second.text);
|
||||
});
|
||||
|
||||
it("enqueueResponse 可指定接下來的回傳內容,用完才回退模板庫", async () => {
|
||||
const provider = new MockProvider();
|
||||
provider.enqueueResponse("測試專用固定回覆");
|
||||
const queued = await provider.generate(fixtureContext());
|
||||
expect(queued.text).toBe("測試專用固定回覆");
|
||||
|
||||
const fallback = await provider.generate(fixtureContext());
|
||||
expect(fallback.text).not.toBe("測試專用固定回覆");
|
||||
});
|
||||
|
||||
it("enqueueFailure 可指定連續失敗次數,用於驗證重試與全域暫停路徑", async () => {
|
||||
const provider = new MockProvider();
|
||||
provider.enqueueFailure(new Error("模擬 LLM 失敗 1"));
|
||||
provider.enqueueFailure(new Error("模擬 LLM 失敗 2"));
|
||||
|
||||
await expect(provider.generate(fixtureContext())).rejects.toThrow("模擬 LLM 失敗 1");
|
||||
await expect(provider.generate(fixtureContext())).rejects.toThrow("模擬 LLM 失敗 2");
|
||||
|
||||
const recovered = await provider.generate(fixtureContext());
|
||||
expect(recovered.text).toBeTruthy();
|
||||
});
|
||||
|
||||
it("resetQueue 可清空佇列,恢復模板庫行為", async () => {
|
||||
const provider = new MockProvider();
|
||||
provider.enqueueResponse("不該被用到");
|
||||
provider.resetQueue();
|
||||
const result = await provider.generate(fixtureContext());
|
||||
expect(result.text).not.toBe("不該被用到");
|
||||
});
|
||||
|
||||
it("T9:generate() 附上非零的 usage 估算值,且隨輸入內容成長而增加", async () => {
|
||||
const provider = new MockProvider();
|
||||
const short = await provider.generate(fixtureContext({ userInput: "早" }));
|
||||
const long = await provider.generate(
|
||||
fixtureContext({ userInput: "早".repeat(200), history: [{ role: "user", content: "嗨".repeat(200), timestamp: "" }] }),
|
||||
);
|
||||
expect(short.usage?.model).toBe("mock");
|
||||
expect(short.usage?.promptTokens).toBeGreaterThan(0);
|
||||
expect(short.usage?.completionTokens).toBeGreaterThan(0);
|
||||
expect(long.usage!.promptTokens).toBeGreaterThan(short.usage!.promptTokens);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,57 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { LLMProvider } from "./llm-provider.js";
|
||||
import type { GenerationContext, GeneratedResponse } from "./types.js";
|
||||
import type { CharacterCallCandidate, EndingDetectionResult, LLMProvider } from "./llm-provider.js";
|
||||
import type { GenerationContext, GeneratedResponse, GenerationUsage } from "./types.js";
|
||||
import { extractActions } from "./action-markup.js";
|
||||
import { createSeededRandom } from "./seeded-random.js";
|
||||
import { intimacyTier, pickTemplates } from "./template-library.js";
|
||||
import { keywordOverlap } from "./keyword-overlap.js";
|
||||
|
||||
const IDENTIFY_MATCH_THRESHOLD = 0.3;
|
||||
const ENDING_SIGNAL_PHRASES = ["全劇終", "大結局", "故事到此結束", "完結", "The End"];
|
||||
|
||||
// T9:MockProvider 沒有真的呼叫 LLM,用字數/4 的粗略估算給出非零、隨內容成長的用量數字,
|
||||
// 讓「用量統計」在測試/mock 環境下也能被觀察到成長趨勢,不追求與真實 tokenizer 一致。
|
||||
function estimateUsage(context: GenerationContext, outputText: string): GenerationUsage {
|
||||
const historyChars = context.history.reduce((sum, turn) => sum + turn.content.length, 0);
|
||||
const promptChars = context.userInput.length + historyChars;
|
||||
return {
|
||||
model: "mock",
|
||||
promptTokens: Math.max(1, Math.ceil(promptChars / 4)),
|
||||
completionTokens: Math.max(1, Math.ceil(outputText.length / 4)),
|
||||
};
|
||||
}
|
||||
|
||||
type QueuedResult =
|
||||
| { kind: "success"; text: string; actions?: string[] }
|
||||
| { kind: "failure"; error: Error };
|
||||
|
||||
// F-7 MockProvider:依「性格原型 × 情緒狀態 × 親密度」從模板庫選填回應,同 seed 產生可重現輸出。
|
||||
// T36:新增測試專用佇列——enqueueResponse/enqueueFailure 可指定接下來 N 次 generate() 的
|
||||
// 回傳內容或連續失敗,先進先出,用完才回退到原有模板庫邏輯;正式環境(佇列恆空)行為不變。
|
||||
@Injectable()
|
||||
export class MockProvider implements LLMProvider {
|
||||
private readonly queue: QueuedResult[] = [];
|
||||
|
||||
enqueueResponse(text: string, actions?: string[]): void {
|
||||
this.queue.push({ kind: "success", text, actions });
|
||||
}
|
||||
|
||||
enqueueFailure(error: Error): void {
|
||||
this.queue.push({ kind: "failure", error });
|
||||
}
|
||||
|
||||
resetQueue(): void {
|
||||
this.queue.length = 0;
|
||||
}
|
||||
|
||||
async generate(context: GenerationContext): Promise<GeneratedResponse> {
|
||||
const queued = this.queue.shift();
|
||||
if (queued) {
|
||||
if (queued.kind === "failure") throw queued.error;
|
||||
const text = queued.text;
|
||||
return { text, actions: queued.actions ?? extractActions(text), usage: estimateUsage(context, text) };
|
||||
}
|
||||
|
||||
const tier = intimacyTier(context.relationship.intimacy);
|
||||
const candidates = pickTemplates(context.character.personalityArchetype, context.emotion.dominant, tier);
|
||||
|
||||
@@ -16,7 +59,7 @@ export class MockProvider implements LLMProvider {
|
||||
const index = Math.floor(random() * candidates.length);
|
||||
const text = candidates[index];
|
||||
|
||||
return { text, actions: extractActions(text) };
|
||||
return { text, actions: extractActions(text), usage: estimateUsage(context, text) };
|
||||
}
|
||||
|
||||
async *stream(context: GenerationContext): AsyncIterable<string> {
|
||||
@@ -25,4 +68,28 @@ export class MockProvider implements LLMProvider {
|
||||
yield char;
|
||||
}
|
||||
}
|
||||
|
||||
// T5:無真實語言理解能力,以關鍵字重疊度模擬「LLM 兼底」——沿用既有 K-4 的重疊度公式,
|
||||
// 分數最高且達門檻者視為指稱對象;沒有任何候選達門檻則回傳 null(無法判斷,不可編造)。
|
||||
async identifyCharacter(utterance: string, candidates: CharacterCallCandidate[]): Promise<string | null> {
|
||||
let best: { id: string; score: number } | null = null;
|
||||
for (const candidate of candidates) {
|
||||
const score = keywordOverlap(utterance, candidate.keywords);
|
||||
if (score > 0 && (!best || score > best.score)) {
|
||||
best = { id: candidate.id, score };
|
||||
}
|
||||
}
|
||||
return best && best.score >= IDENTIFY_MATCH_THRESHOLD ? best.id : null;
|
||||
}
|
||||
|
||||
// T13:無真實語言理解能力,以場景內容是否出現明確的結局訊號詞模擬判定(例如「完結」「全劇終」);
|
||||
// 不使用呼叫端可能帶入的章節標題(本方法本身就沒有標題參數,天然滿足「不以標題關鍵字預篩」)。
|
||||
async detectEnding(sceneSummary: string, sceneRawText: string, _workTitle: string): Promise<EndingDetectionResult> {
|
||||
const combined = `${sceneSummary}\n${sceneRawText}`;
|
||||
const hit = ENDING_SIGNAL_PHRASES.find((phrase) => combined.includes(phrase));
|
||||
if (hit) {
|
||||
return { isEnding: true, evidence: `場景內容出現「${hit}」等結局訊號詞(mock 判定,非真實 LLM 推論)` };
|
||||
}
|
||||
return { isEnding: false, evidence: "場景內容未見明顯結局訊號詞(mock 判定,非真實 LLM 推論)" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const RELATIONSHIP_STAGE_LABEL: Record<string, string> = {
|
||||
};
|
||||
|
||||
function buildSystemPrompt(context: GenerationContext): string {
|
||||
const { character, emotion, relationship, retrievedMemories } = context;
|
||||
const { character, emotion, relationship, retrievedMemories, crossWorldNote } = context;
|
||||
const stageLabel = RELATIONSHIP_STAGE_LABEL[relationship.stage] ?? relationship.stage;
|
||||
|
||||
const memoryLines =
|
||||
@@ -44,6 +44,7 @@ function buildSystemPrompt(context: GenerationContext): string {
|
||||
`【相關記憶】`,
|
||||
memoryLines,
|
||||
``,
|
||||
...(crossWorldNote ? [`【跨作品提示】`, crossWorldNote, ``] : []),
|
||||
`【輸出格式】`,
|
||||
`回應為純文字。若有動作/表情描寫,用全形星號包住,例如 *臉紅撇過頭*;不要用任何其他標記語法,不要加角色名稱前綴。`,
|
||||
``,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// T30:連續失敗達此次數即觸發全域靜默暫停。
|
||||
export const GLOBAL_PAUSE_FAILURE_THRESHOLD = 5;
|
||||
// T30:指數退避重試——單次生成最多重試這麼多次(不含首次嘗試),每次間隔倍增。
|
||||
export const RETRY_MAX_ATTEMPTS = 3;
|
||||
export const RETRY_BASE_BACKOFF_MS = 200;
|
||||
@@ -15,6 +15,9 @@ export interface GenerationContext {
|
||||
likesDislikes: string;
|
||||
basicInfo: string;
|
||||
};
|
||||
// T28:跨作品混房時,對「這次要回應的對象」附上一句世界觀提示——這個人來自不同作品,不是自己
|
||||
// 原本世界就認識的存在,禁止把對方設定當成自身世界既有知識。同作品/對象是使用者本人時不設定。
|
||||
crossWorldNote?: string;
|
||||
emotion: {
|
||||
dominant: EmotionTag;
|
||||
style: ResponseStyle;
|
||||
@@ -33,7 +36,14 @@ export interface GenerationContext {
|
||||
seed: number;
|
||||
}
|
||||
|
||||
export interface GenerationUsage {
|
||||
model: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
}
|
||||
|
||||
export interface GeneratedResponse {
|
||||
text: string; // 含動作描寫標記,如 *臉紅撇過頭*
|
||||
actions: string[]; // 從 text 抽取出的動作描寫(不含*符號)
|
||||
usage?: GenerationUsage; // T9:每房 LLM 用量統計的來源,僅供觀察,不影響生成行為
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
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 { SentimentLedgerService } from "../relationship/sentiment-ledger.service.js";
|
||||
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { ScheduleExceptionService } from "../schedule/schedule-exception.service.js";
|
||||
import { MockProvider } from "../llm/mock-provider.service.js";
|
||||
import { LobbyService as RoomLobbyService } from "../room/lobby.service.js";
|
||||
import { RoomEventsService } from "../room/room-events.service.js";
|
||||
import { RoomService } from "../room/room.service.js";
|
||||
import { CharacterRelationshipService } from "../room/character-relationship.service.js";
|
||||
import { SpeakingRightService } from "../room/speaking-right.service.js";
|
||||
import { RoomGenerationService } from "../room/room-generation.service.js";
|
||||
import { PossessionService } from "../room/possession.service.js";
|
||||
import { KeywordMemoryRetriever } from "../memory/memory-retriever.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 { GlobalPauseService } from "../llm/global-pause.service.js";
|
||||
import { CallService } from "./call.service.js";
|
||||
|
||||
// T5 驗收:喊一位答應者進單人房、喊多位有兩位答應進多人房、喊多位僅一位答應會降級為單人房、
|
||||
// 全部不答應則留在大廳且畫面顯示的是角色台詞而非系統錯誤。
|
||||
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"); // 週三 13:00 台北,屬 FREE 時段
|
||||
const roomEvents = new RoomEventsService(new EventEmitter2());
|
||||
const roomLobby = new RoomLobbyService(prisma, clock, roomEvents);
|
||||
const roomService = new RoomService(prisma, clock, roomLobby, roomEvents);
|
||||
const emotionService = new EmotionService(prisma, new RuleBasedEmotionTagger());
|
||||
const relationshipService = new RelationshipService(prisma, new EventEmitter2());
|
||||
const sentimentLedger = new SentimentLedgerService(prisma, relationshipService);
|
||||
const scheduleService = new ScheduleService(prisma);
|
||||
const scheduleException = new ScheduleExceptionService(prisma, sentimentLedger);
|
||||
const mockProvider = new MockProvider();
|
||||
const characterRelationship = new CharacterRelationshipService(prisma);
|
||||
const speakingRight = new SpeakingRightService(prisma, emotionService, relationshipService, characterRelationship);
|
||||
const retriever = new KeywordMemoryRetriever(prisma);
|
||||
const romanceService = new RomanceService(prisma, relationshipService, new RomanceEligibilityService(prisma, new AgeDeterminationService(prisma)));
|
||||
const generation = new RoomGenerationService(prisma, emotionService, relationshipService, retriever, characterRelationship, romanceService, mockProvider, new GlobalPauseService());
|
||||
const possession = new PossessionService(prisma, roomService, speakingRight, generation, roomLobby);
|
||||
const callService = new CallService(
|
||||
prisma,
|
||||
clock,
|
||||
emotionService,
|
||||
relationshipService,
|
||||
scheduleService,
|
||||
scheduleException,
|
||||
mockProvider,
|
||||
roomService,
|
||||
roomLobby,
|
||||
possession,
|
||||
);
|
||||
|
||||
const userId = "test-call-user";
|
||||
const GENKI_LIKE = "運動、甜食"; // 元氣:conversationBaseline 高,中性關係下應答應
|
||||
const COOL_LIKE = "安靜、看書"; // 三無:conversationBaseline 低,中性關係下應婉拒
|
||||
|
||||
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: archetype === "元氣" ? GENKI_LIKE : COOL_LIKE,
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName } },
|
||||
},
|
||||
});
|
||||
await prisma.client.relationship.create({
|
||||
data: { characterId: id, userId, intimacy: 50, trust: 50 },
|
||||
});
|
||||
}
|
||||
|
||||
const ALL_TEST_CHARACTER_IDS = [
|
||||
"test-call-genki",
|
||||
"test-call-cool",
|
||||
"test-call-genki2",
|
||||
"test-call-sleeper",
|
||||
"test-call-delegate",
|
||||
"test-call-possessed",
|
||||
];
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.pendingLobbyCall.deleteMany({ where: { userId } });
|
||||
await prisma.client.room.deleteMany({ where: { userId } });
|
||||
await prisma.client.callAttemptLog.deleteMany({ where: { userId } });
|
||||
await prisma.client.characterRelationship.deleteMany({ where: { characterId: { in: ALL_TEST_CHARACTER_IDS } } });
|
||||
await prisma.client.scheduleSlot.deleteMany({ where: { characterId: { in: ALL_TEST_CHARACTER_IDS } } });
|
||||
await prisma.client.character.deleteMany({ where: { id: { in: ALL_TEST_CHARACTER_IDS } } });
|
||||
await prisma.client.user.deleteMany({ where: { id: userId } });
|
||||
});
|
||||
|
||||
describe("CallService(T5)", () => {
|
||||
it("喊一位答應者:降級開 SOLO 房", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試呼叫使用者" } });
|
||||
await createCharacter("test-call-genki", "元氣", "測試喵一號");
|
||||
|
||||
const result = await callService.say(userId, "測試喵一號,你在嗎?", clock.now());
|
||||
expect(result.accepted).toEqual(["test-call-genki"]);
|
||||
expect(result.mode).toBe("SOLO");
|
||||
expect(result.roomId).not.toBeNull();
|
||||
|
||||
const log = await prisma.client.callAttemptLog.findFirst({ where: { userId, characterId: "test-call-genki" } });
|
||||
expect(log?.accepted).toBe(true);
|
||||
}, 20000);
|
||||
|
||||
it("喊多位皆答應:開 GROUP 房", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試呼叫使用者" } });
|
||||
await createCharacter("test-call-genki", "元氣", "測試喵一號");
|
||||
await createCharacter("test-call-genki2", "元氣", "測試喵二號");
|
||||
|
||||
const result = await callService.say(userId, "測試喵一號、測試喵二號,你們在嗎?", clock.now());
|
||||
expect(result.accepted.sort()).toEqual(["test-call-genki", "test-call-genki2"].sort());
|
||||
expect(result.mode).toBe("GROUP");
|
||||
}, 20000);
|
||||
|
||||
it("喊多位僅一位答應:降級開 SOLO 房", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試呼叫使用者" } });
|
||||
await createCharacter("test-call-genki", "元氣", "測試喵一號");
|
||||
await createCharacter("test-call-cool", "三無", "測試喵三號");
|
||||
|
||||
const result = await callService.say(userId, "測試喵一號、測試喵三號,你們在嗎?", clock.now());
|
||||
expect(result.accepted).toEqual(["test-call-genki"]);
|
||||
expect(result.declined).toEqual(["test-call-cool"]);
|
||||
expect(result.mode).toBe("SOLO");
|
||||
}, 20000);
|
||||
|
||||
it("全部不答應:不開房,回傳角色台詞而非系統錯誤", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試呼叫使用者" } });
|
||||
await createCharacter("test-call-cool", "三無", "測試喵三號");
|
||||
|
||||
const result = await callService.say(userId, "測試喵三號,你在嗎?", clock.now());
|
||||
expect(result.accepted).toEqual([]);
|
||||
expect(result.roomId).toBeNull();
|
||||
expect(result.mode).toBeNull();
|
||||
expect(typeof result.message).toBe("string");
|
||||
expect(result.message!.length).toBeGreaterThan(0);
|
||||
expect(result.message).not.toMatch(/error|Error|錯誤/);
|
||||
}, 20000);
|
||||
});
|
||||
|
||||
describe("CallService 大廳代答(T23)", () => {
|
||||
it("喊睡著的角色:由關係最近且清醒未入房的角色代答,時間與行程資料一致", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試呼叫使用者" } });
|
||||
await createCharacter("test-call-sleeper", "三無", "測試喵睡神");
|
||||
await createCharacter("test-call-delegate", "元氣", "測試喵代答");
|
||||
// 週三(WEEKDAY)12:00~15:00 睡眠,clock 固定在 13:00,落在睡眠時段內;15:00 即為下次可找的時間。
|
||||
await prisma.client.scheduleSlot.create({
|
||||
data: { characterId: "test-call-sleeper", dayType: "WEEKDAY", startMinute: 720, endMinute: 900, status: "SLEEP" },
|
||||
});
|
||||
await prisma.client.characterRelationship.create({
|
||||
data: { characterId: "test-call-sleeper", otherCharacterId: "test-call-delegate", affinity: 90 },
|
||||
});
|
||||
|
||||
const result = await callService.say(userId, "測試喵睡神,你在嗎?", clock.now());
|
||||
expect(result.accepted).toEqual([]);
|
||||
expect(result.roomId).toBeNull();
|
||||
expect(result.delegatedBy).toBe("test-call-delegate");
|
||||
expect(result.message).toContain("15:00");
|
||||
expect(result.message).not.toMatch(/error|Error|錯誤/);
|
||||
|
||||
const log = await prisma.client.callAttemptLog.findFirst({
|
||||
where: { userId, characterId: "test-call-sleeper" },
|
||||
});
|
||||
expect(log?.delegatedByCharacterId).toBe("test-call-delegate");
|
||||
}, 20000);
|
||||
|
||||
it("喊睡著的角色但無人可代答:大廳沉默,不輸出系統錯誤訊息", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試呼叫使用者" } });
|
||||
await createCharacter("test-call-sleeper", "三無", "測試喵睡神");
|
||||
await prisma.client.scheduleSlot.create({
|
||||
data: { characterId: "test-call-sleeper", dayType: "WEEKDAY", startMinute: 720, endMinute: 900, status: "SLEEP" },
|
||||
});
|
||||
|
||||
const result = await callService.say(userId, "測試喵睡神,你在嗎?", clock.now());
|
||||
expect(result.accepted).toEqual([]);
|
||||
expect(result.roomId).toBeNull();
|
||||
expect(result.delegatedBy).toBeNull();
|
||||
expect(result.message).toBeNull();
|
||||
}, 20000);
|
||||
|
||||
it("T43:喊到目前套用中的角色——人格系統不代她判斷,記一筆待決給套用者;套用者接受後開房入場", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試呼叫使用者" } });
|
||||
await createCharacter("test-call-possessed", "元氣", "測試喵套用");
|
||||
await possession.enter(userId, "test-call-possessed");
|
||||
|
||||
const result = await callService.say(userId, "測試喵套用,你在嗎?", clock.now());
|
||||
expect(result.accepted).toEqual([]);
|
||||
expect(result.declined).toEqual([]);
|
||||
expect(result.roomId).toBeNull();
|
||||
expect(result.message).toBeNull(); // 沒有任何人格系統代答的台詞
|
||||
expect(result.pendingPossessedCharacterIds).toEqual(["test-call-possessed"]);
|
||||
|
||||
const log = await prisma.client.callAttemptLog.findFirst({ where: { userId, characterId: "test-call-possessed" } });
|
||||
expect(log).toBeNull(); // 沒有走判定流程,自然也不會留下判定紀錄
|
||||
|
||||
const pending = await possession.listPendingCalls(userId);
|
||||
expect(pending).toHaveLength(1);
|
||||
const decision = await possession.decidePendingCall(pending[0].id, true, clock.now());
|
||||
expect(decision.roomId).not.toBeNull();
|
||||
const roomAfter = await roomService.get(decision.roomId!);
|
||||
expect(roomAfter.participants.has("test-call-possessed")).toBe(true);
|
||||
expect(await possession.listPendingCalls(userId)).toHaveLength(0);
|
||||
|
||||
await possession.exit(userId);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { CLOCK, type Clock } from "../clock/clock.js";
|
||||
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
||||
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { ScheduleExceptionService } from "../schedule/schedule-exception.service.js";
|
||||
import { availabilityForStatus } from "../schedule/constants.js";
|
||||
import { getArchetypeParams } from "../personality/archetype-params.js";
|
||||
import { LLM_PROVIDER, type LLMProvider } from "../llm/llm-provider.js";
|
||||
import { hashToSeed } from "../llm/seeded-random.js";
|
||||
import { EMOTION_SPEAKING_MODIFIER } from "../room/constants.js";
|
||||
import { RoomService } from "../room/room.service.js";
|
||||
import { LobbyService as RoomLobbyService } from "../room/lobby.service.js";
|
||||
import { PossessionService } from "../room/possession.service.js";
|
||||
import { CALL_ACCEPTANCE_THRESHOLD } from "./constants.js";
|
||||
|
||||
export interface CallJudgment {
|
||||
characterId: string;
|
||||
accepted: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface CallResult {
|
||||
calledCharacterIds: string[];
|
||||
accepted: string[];
|
||||
declined: string[];
|
||||
roomId: string | null;
|
||||
mode: "SOLO" | "GROUP" | null;
|
||||
message: string | null; // 全部不答應時:本人婉拒台詞,或由代答者說出的理由與可再來的時間
|
||||
delegatedBy: string | null; // T23:代答者(若有)
|
||||
pendingPossessedCharacterIds: string[]; // T43:被喊到但目前套用中,改記待決、交由套用者決定
|
||||
}
|
||||
|
||||
const HARD_DECLINE_PREFIXES = ["無法回應(作息)", "已在其他房間"];
|
||||
|
||||
// T5:大廳對話式呼叫。角色辨識規則先行(角色名/CharacterAlias 直接比對),比不到才以 LLM 兼底
|
||||
// (見 MockProvider/ClaudeProvider.identifyCharacter);逐位判定是否答應後,依組房規則決定結果。
|
||||
@Injectable()
|
||||
export class CallService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(CLOCK) private readonly clock: Clock,
|
||||
private readonly emotion: EmotionService,
|
||||
private readonly relationship: RelationshipService,
|
||||
private readonly schedule: ScheduleService,
|
||||
private readonly scheduleException: ScheduleExceptionService,
|
||||
@Inject(LLM_PROVIDER) private readonly llmProvider: LLMProvider,
|
||||
private readonly room: RoomService,
|
||||
private readonly roomLobby: RoomLobbyService,
|
||||
private readonly possession: PossessionService,
|
||||
) {}
|
||||
|
||||
async say(userId: string, text: string, now?: Date): Promise<CallResult> {
|
||||
const effectiveNow = now ?? this.clock.now();
|
||||
const calledCharacterIds = await this.identifyCandidates(text);
|
||||
|
||||
if (calledCharacterIds.length === 0) {
|
||||
return {
|
||||
calledCharacterIds: [],
|
||||
accepted: [],
|
||||
declined: [],
|
||||
roomId: null,
|
||||
mode: null,
|
||||
message: null,
|
||||
delegatedBy: null,
|
||||
pendingPossessedCharacterIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
// T43:被喊到的角色若正被(任一使用者)套用中,人格系統不得代她判斷接受/婉拒——記一筆待決給
|
||||
// 套用者自己決定,不參與這一輪的組房/代答邏輯。
|
||||
const possessedIds = await this.possession.getPossessedCharacterIds();
|
||||
const possessedCalledIds = calledCharacterIds.filter((id) => possessedIds.has(id));
|
||||
for (const characterId of possessedCalledIds) {
|
||||
await this.possession.createPendingCall(characterId, text, effectiveNow);
|
||||
}
|
||||
const judgeableCharacterIds = calledCharacterIds.filter((id) => !possessedIds.has(id));
|
||||
if (judgeableCharacterIds.length === 0) {
|
||||
return {
|
||||
calledCharacterIds,
|
||||
accepted: [],
|
||||
declined: [],
|
||||
roomId: null,
|
||||
mode: null,
|
||||
message: null,
|
||||
delegatedBy: null,
|
||||
pendingPossessedCharacterIds: possessedCalledIds,
|
||||
};
|
||||
}
|
||||
|
||||
const judgments = await Promise.all(
|
||||
judgeableCharacterIds.map((characterId) => this.judgeAcceptance(characterId, userId, text, effectiveNow)),
|
||||
);
|
||||
const accepted = judgments.filter((j) => j.accepted).map((j) => j.characterId);
|
||||
const declined = judgments.filter((j) => !j.accepted).map((j) => j.characterId);
|
||||
|
||||
let roomId: string | null = null;
|
||||
let mode: "SOLO" | "GROUP" | null = null;
|
||||
let message: string | null = null;
|
||||
let delegatedBy: string | null = null;
|
||||
|
||||
if (accepted.length >= 2) {
|
||||
const room = await this.openRoom(userId, accepted, "GROUP", text, effectiveNow);
|
||||
roomId = room.id;
|
||||
mode = "GROUP";
|
||||
} else if (accepted.length === 1) {
|
||||
const room = await this.openRoom(userId, accepted, "SOLO", text, effectiveNow);
|
||||
roomId = room.id;
|
||||
mode = "SOLO";
|
||||
} else {
|
||||
// 全部不答應:軟性婉拒(人清醒、不在其他房間,單純不想理你)由本人親自回一句話;
|
||||
// 硬性無法回應(睡眠/忙碌/已在其他房間)改由關係最近且當下清醒未入房的角色代答(T23)。
|
||||
const primaryId = declined[0];
|
||||
const primaryJudgment = judgments.find((j) => j.characterId === primaryId)!;
|
||||
const isHardDecline = HARD_DECLINE_PREFIXES.some((prefix) => primaryJudgment.reason.startsWith(prefix));
|
||||
|
||||
if (!isHardDecline) {
|
||||
message = await this.declineMessage(primaryId, userId, text, effectiveNow);
|
||||
} else {
|
||||
const delegateId = await this.findDelegate(primaryId, effectiveNow);
|
||||
if (delegateId) {
|
||||
delegatedBy = delegateId;
|
||||
const nextAvailable = await this.describeNextAvailable(primaryId, primaryJudgment.reason, effectiveNow);
|
||||
message = await this.delegateMessage(delegateId, primaryId, userId, text, nextAvailable, effectiveNow);
|
||||
}
|
||||
// 找不到合適代答者:大廳沉默,message 維持 null,不輸出系統訊息。
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.client.callAttemptLog.createMany({
|
||||
data: judgments.map((j) => ({
|
||||
userId,
|
||||
characterId: j.characterId,
|
||||
accepted: j.accepted,
|
||||
reason: j.reason,
|
||||
delegatedByCharacterId: j.characterId === declined[0] ? delegatedBy : null,
|
||||
resultingRoomId: j.accepted ? roomId : null,
|
||||
createdAt: effectiveNow,
|
||||
})),
|
||||
});
|
||||
|
||||
return { calledCharacterIds, accepted, declined, roomId, mode, message, delegatedBy, pendingPossessedCharacterIds: possessedCalledIds };
|
||||
}
|
||||
|
||||
// 角色辨識:規則先行(角色名與 CharacterAlias 直接比對),比不到或指稱含糊才以 LLM 兼底。
|
||||
private async identifyCandidates(text: string): Promise<string[]> {
|
||||
const characters = await this.prisma.client.character.findMany({
|
||||
where: { buildStatus: "BUILT" },
|
||||
include: { aliases: true },
|
||||
});
|
||||
|
||||
const ruleMatches = characters.filter((character) =>
|
||||
character.aliases.some(
|
||||
(alias) =>
|
||||
(alias.formalName && text.includes(alias.formalName)) ||
|
||||
(alias.nickname && text.includes(alias.nickname)) ||
|
||||
(alias.calledByOthers && text.includes(alias.calledByOthers)),
|
||||
),
|
||||
);
|
||||
if (ruleMatches.length > 0) {
|
||||
return ruleMatches.map((c) => c.id);
|
||||
}
|
||||
|
||||
const candidateList = characters.map((character) => ({
|
||||
id: character.id,
|
||||
label: character.aliases[0]?.formalName ?? character.id,
|
||||
keywords: [character.personalityArchetype, character.likesDislikes, character.goalsObsessions].join("、"),
|
||||
}));
|
||||
const identified = await this.llmProvider.identifyCharacter(text, candidateList);
|
||||
return identified ? [identified] : [];
|
||||
}
|
||||
|
||||
private async judgeAcceptance(characterId: string, userId: string, text: string, now: Date): Promise<CallJudgment> {
|
||||
// R5:已在其他(非大廳)房間在場者無法接受新的呼叫。
|
||||
const activeMembership = await this.prisma.client.roomMember.findFirst({
|
||||
where: { characterId, activeCharacterId: characterId },
|
||||
include: { room: true },
|
||||
});
|
||||
if (activeMembership && activeMembership.room.mode !== "LOBBY") {
|
||||
return { characterId, accepted: false, reason: "已在其他房間" };
|
||||
}
|
||||
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||
const scheduleInfo = await this.schedule.getInfo(characterId, now);
|
||||
const availability = availabilityForStatus(scheduleInfo.slot?.status ?? null);
|
||||
const emotionState = await this.emotion.getState(characterId, now);
|
||||
const dominant = dominantState(emotionState);
|
||||
const { relationship } = await this.relationship.getState(characterId, userId, now);
|
||||
|
||||
let effectiveAvailability = availability;
|
||||
if (availability !== "NORMAL") {
|
||||
const exceptionType = await this.scheduleException.tryApply(
|
||||
characterId,
|
||||
userId,
|
||||
availability,
|
||||
{ intimacy: relationship.intimacy, dominantEmotion: dominant, userInput: text },
|
||||
now,
|
||||
);
|
||||
if (exceptionType) effectiveAvailability = "NORMAL";
|
||||
}
|
||||
if (effectiveAvailability === "NO_REPLY") {
|
||||
return { characterId, accepted: false, reason: "無法回應(作息)" };
|
||||
}
|
||||
|
||||
const archetypeParams = getArchetypeParams(character.personalityArchetype);
|
||||
const emotionModifier = EMOTION_SPEAKING_MODIFIER[dominant];
|
||||
const relationshipModifier = (relationship.intimacy - 50) / 100;
|
||||
const score = archetypeParams.conversationBaseline + emotionModifier + relationshipModifier;
|
||||
const accepted = score >= CALL_ACCEPTANCE_THRESHOLD;
|
||||
return { characterId, accepted, reason: accepted ? `答應(score=${score.toFixed(2)})` : `衝動值不足(score=${score.toFixed(2)})` };
|
||||
}
|
||||
|
||||
private async openRoom(userId: string, characterIds: string[], mode: "SOLO" | "GROUP", topicSeed: string, now: Date) {
|
||||
const room = await this.room.create(userId, [], mode, topicSeed);
|
||||
for (const characterId of characterIds) {
|
||||
await this.roomLobby.enterRoom(characterId, room.id, now);
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
private async declineMessage(characterId: string, userId: string, userInput: string, now: Date): Promise<string> {
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||
const emotionState = await this.emotion.getState(characterId, now);
|
||||
const dominant = dominantState(emotionState);
|
||||
const { relationship } = await this.relationship.getState(characterId, userId, now);
|
||||
const seed = hashToSeed(`lobby-decline:${characterId}:${userInput}`);
|
||||
|
||||
const generated = await this.llmProvider.generate({
|
||||
character: {
|
||||
id: character.id,
|
||||
personalityArchetype: character.personalityArchetype,
|
||||
speechStyle: character.speechStyle,
|
||||
likesDislikes: character.likesDislikes,
|
||||
basicInfo: character.basicInfo,
|
||||
},
|
||||
emotion: { dominant, style: { tone: "", sentenceLength: "short", initiative: "low" } },
|
||||
relationship: { intimacy: relationship.intimacy, trust: relationship.trust, stage: relationship.stage },
|
||||
retrievedMemories: [],
|
||||
history: [],
|
||||
userInput,
|
||||
injectedMemoryIds: [],
|
||||
seed,
|
||||
});
|
||||
return generated.text;
|
||||
}
|
||||
|
||||
// T23:找與被喊角色關係最近、當下清醒(非睡眠)且未入其他房間(含大廳都算「有空」)的代答者。
|
||||
private async findDelegate(characterId: string, now: Date): Promise<string | null> {
|
||||
const relationships = await this.prisma.client.characterRelationship.findMany({
|
||||
where: { characterId },
|
||||
orderBy: { affinity: "desc" },
|
||||
});
|
||||
for (const rel of relationships) {
|
||||
const candidateId = rel.otherCharacterId;
|
||||
const scheduleInfo = await this.schedule.getInfo(candidateId, now);
|
||||
if (availabilityForStatus(scheduleInfo.slot?.status ?? null) === "NO_REPLY") continue; // 睡著跳過
|
||||
const activeMembership = await this.prisma.client.roomMember.findFirst({
|
||||
where: { characterId: candidateId, activeCharacterId: candidateId },
|
||||
include: { room: true },
|
||||
});
|
||||
if (activeMembership && activeMembership.room.mode !== "LOBBY") continue; // 已入其他房間跳過
|
||||
return candidateId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// T23:「下次可找的時間」一律取自行程資料本身,不可由 LLM 自行編時間。
|
||||
private async describeNextAvailable(characterId: string, declineReason: string, now: Date): Promise<string> {
|
||||
if (declineReason.startsWith("已在其他房間")) {
|
||||
return "等她忙完手邊的事";
|
||||
}
|
||||
const scheduleInfo = await this.schedule.getInfo(characterId, now);
|
||||
if (!scheduleInfo.slot) {
|
||||
return "晚一點";
|
||||
}
|
||||
const hh = String(Math.floor(scheduleInfo.slot.endMinute / 60) % 24).padStart(2, "0");
|
||||
const mm = String(scheduleInfo.slot.endMinute % 60).padStart(2, "0");
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
private async delegateMessage(
|
||||
delegateId: string,
|
||||
aboutCharacterId: string,
|
||||
userId: string,
|
||||
userInput: string,
|
||||
nextAvailable: string,
|
||||
now: Date,
|
||||
): Promise<string> {
|
||||
const delegate = await this.prisma.client.character.findUniqueOrThrow({ where: { id: delegateId } });
|
||||
const aboutAlias = await this.prisma.client.characterAlias.findFirst({ where: { characterId: aboutCharacterId } });
|
||||
const aboutLabel = aboutAlias?.formalName ?? aboutCharacterId;
|
||||
const emotionState = await this.emotion.getState(delegateId, now);
|
||||
const dominant = dominantState(emotionState);
|
||||
const { relationship } = await this.relationship.getState(delegateId, userId, now);
|
||||
const seed = hashToSeed(`lobby-delegate:${delegateId}:${aboutCharacterId}:${userInput}`);
|
||||
|
||||
const generated = await this.llmProvider.generate({
|
||||
character: {
|
||||
id: delegate.id,
|
||||
personalityArchetype: delegate.personalityArchetype,
|
||||
speechStyle: delegate.speechStyle,
|
||||
likesDislikes: delegate.likesDislikes,
|
||||
basicInfo: delegate.basicInfo,
|
||||
},
|
||||
emotion: { dominant, style: { tone: "", sentenceLength: "short", initiative: "low" } },
|
||||
relationship: { intimacy: relationship.intimacy, trust: relationship.trust, stage: relationship.stage },
|
||||
retrievedMemories: [],
|
||||
history: [],
|
||||
userInput,
|
||||
injectedMemoryIds: [],
|
||||
seed,
|
||||
});
|
||||
// 「下次可找的時間」由程式碼直接拼接,不讓 LLM 生成的那段話決定時間內容。
|
||||
return `${generated.text}${aboutLabel}大概${nextAvailable}才有空,到時候再來找她吧。`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// T5:大廳對話式呼叫的接受判定門檻。score = conversationBaseline + 情緒修正 + 關係修正,
|
||||
// 中性關係(50)+平靜情緒時 score 即等於角色的 conversationBaseline;0.3 落在冷淡(0.15)/
|
||||
// 三無(0.05)之上、傲嬌(0.5)/天然呆(0.6)/大小姐(0.55)/元氣(0.85)之下,讓「外向角色
|
||||
// 預設答應、內向角色預設婉拒」符合直覺,同時仍可被情緒/關係修正推過門檻。
|
||||
export const CALL_ACCEPTANCE_THRESHOLD = 0.3;
|
||||
@@ -0,0 +1,184 @@
|
||||
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 { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||
import { CharacterRelationshipService } from "../room/character-relationship.service.js";
|
||||
import { LobbyService as RoomLobbyService } from "../room/lobby.service.js";
|
||||
import { RoomEventsService } from "../room/room-events.service.js";
|
||||
import { RoomService } from "../room/room.service.js";
|
||||
import { SpeakingRightService } from "../room/speaking-right.service.js";
|
||||
import { RoomGenerationService } from "../room/room-generation.service.js";
|
||||
import { PossessionService } from "../room/possession.service.js";
|
||||
import { KeywordMemoryRetriever } from "../memory/memory-retriever.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 { MockProvider } from "../llm/mock-provider.service.js";
|
||||
import { GlobalPauseService } from "../llm/global-pause.service.js";
|
||||
import { InvitationService } from "./invitation.service.js";
|
||||
|
||||
// T29 驗收:同一角色同時被兩處邀請時,兩處都收到該角色的回覆,且他只會出現在其中一間。
|
||||
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"); // 週三 13:00 台北,FREE 時段
|
||||
const roomEvents = new RoomEventsService(new EventEmitter2());
|
||||
const roomLobby = new RoomLobbyService(prisma, clock, roomEvents);
|
||||
const roomService = new RoomService(prisma, clock, roomLobby, roomEvents);
|
||||
const emotionService = new EmotionService(prisma, new RuleBasedEmotionTagger());
|
||||
const scheduleService = new ScheduleService(prisma);
|
||||
const characterRelationship = new CharacterRelationshipService(prisma);
|
||||
const relationshipService = new RelationshipService(prisma, new EventEmitter2());
|
||||
const speakingRight = new SpeakingRightService(prisma, emotionService, relationshipService, characterRelationship);
|
||||
const retriever = new KeywordMemoryRetriever(prisma);
|
||||
const romanceService = new RomanceService(prisma, relationshipService, new RomanceEligibilityService(prisma, new AgeDeterminationService(prisma)));
|
||||
const generation = new RoomGenerationService(
|
||||
prisma,
|
||||
emotionService,
|
||||
relationshipService,
|
||||
retriever,
|
||||
characterRelationship,
|
||||
romanceService,
|
||||
new MockProvider(),
|
||||
new GlobalPauseService(),
|
||||
);
|
||||
const possession = new PossessionService(prisma, roomService, speakingRight, generation, roomLobby);
|
||||
const invitationService = new InvitationService(
|
||||
prisma,
|
||||
clock,
|
||||
emotionService,
|
||||
scheduleService,
|
||||
characterRelationship,
|
||||
roomService,
|
||||
roomLobby,
|
||||
possession,
|
||||
);
|
||||
|
||||
const ownerUserId = "test-invite-user";
|
||||
const TARGET = "test-invite-target";
|
||||
const INVITER_A = "test-invite-inviter-a";
|
||||
const INVITER_B = "test-invite-inviter-b";
|
||||
|
||||
async function createCharacter(id: string, archetype = "元氣") {
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: archetype,
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: id } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.room.deleteMany({ where: { userId: ownerUserId } });
|
||||
await prisma.client.characterRelationship.deleteMany({
|
||||
where: { characterId: TARGET, otherCharacterId: { in: [INVITER_A, INVITER_B] } },
|
||||
});
|
||||
await prisma.client.character.deleteMany({ where: { id: { in: [TARGET, INVITER_A, INVITER_B] } } });
|
||||
await prisma.client.user.deleteMany({ where: { id: ownerUserId } });
|
||||
});
|
||||
|
||||
describe("InvitationService(T29)", () => {
|
||||
it("同一角色同時被兩間房邀請:兩處都收到回覆,且只會出現在其中一間", async () => {
|
||||
await prisma.client.user.create({ data: { id: ownerUserId, name: "測試邀請使用者" } });
|
||||
await createCharacter(TARGET);
|
||||
await createCharacter(INVITER_A);
|
||||
await createCharacter(INVITER_B);
|
||||
|
||||
const roomA = await roomService.create(ownerUserId, [INVITER_A], "GROUP");
|
||||
const roomB = await roomService.create(ownerUserId, [INVITER_B], "GROUP");
|
||||
await roomLobby.enterRoom(INVITER_A, roomA.id, clock.now());
|
||||
await roomLobby.enterRoom(INVITER_B, roomB.id, clock.now());
|
||||
|
||||
// TARGET 與 B 關係較好,應該選擇加入 B 的房間。
|
||||
await prisma.client.characterRelationship.create({
|
||||
data: { characterId: TARGET, otherCharacterId: INVITER_A, affinity: 50 },
|
||||
});
|
||||
await prisma.client.characterRelationship.create({
|
||||
data: { characterId: TARGET, otherCharacterId: INVITER_B, affinity: 95 },
|
||||
});
|
||||
|
||||
await invitationService.invite(roomA.id, TARGET, INVITER_A, clock.now());
|
||||
await invitationService.invite(roomB.id, TARGET, INVITER_B, clock.now());
|
||||
|
||||
// 裁決前:兩筆都還是 PENDING,TARGET 尚未入房。
|
||||
const beforeResolve = await prisma.client.roomInvitation.findMany({ where: { characterId: TARGET } });
|
||||
expect(beforeResolve.every((i) => i.status === "PENDING")).toBe(true);
|
||||
|
||||
await invitationService.resolvePendingFor(TARGET, clock.now());
|
||||
|
||||
const invitations = await prisma.client.roomInvitation.findMany({ where: { characterId: TARGET } });
|
||||
expect(invitations).toHaveLength(2);
|
||||
const accepted = invitations.filter((i) => i.status === "ACCEPTED");
|
||||
const declined = invitations.filter((i) => i.status === "DECLINED");
|
||||
expect(accepted).toHaveLength(1);
|
||||
expect(declined).toHaveLength(1);
|
||||
expect(accepted[0].roomId).toBe(roomB.id); // 關係較好的那間
|
||||
|
||||
// 兩處都收到回覆(各自房間都留了一則 TARGET 的發話)。
|
||||
const roomAAfter = await roomService.get(roomA.id);
|
||||
const roomBAfter = await roomService.get(roomB.id);
|
||||
expect(roomAAfter.turns.some((t) => t.speakerId === TARGET)).toBe(true);
|
||||
expect(roomBAfter.turns.some((t) => t.speakerId === TARGET)).toBe(true);
|
||||
|
||||
// 只會出現在其中一間:僅在 B 房實際在場。
|
||||
expect(roomBAfter.participants.has(TARGET)).toBe(true);
|
||||
expect(roomAAfter.participants.has(TARGET)).toBe(false);
|
||||
|
||||
const activeRows = await prisma.client.roomMember.findMany({ where: { characterId: TARGET, leftAt: null } });
|
||||
expect(activeRows).toHaveLength(1);
|
||||
expect(activeRows[0].roomId).toBe(roomB.id);
|
||||
}, 20000);
|
||||
|
||||
it("T43:被邀請的角色目前套用中時,人格系統不代她判斷——維持 PENDING,交由套用者明確決定", async () => {
|
||||
await prisma.client.user.create({ data: { id: ownerUserId, name: "測試邀請使用者" } });
|
||||
await createCharacter(TARGET);
|
||||
await createCharacter(INVITER_A);
|
||||
await createCharacter(INVITER_B);
|
||||
|
||||
const roomA = await roomService.create(ownerUserId, [INVITER_A], "GROUP");
|
||||
const roomB = await roomService.create(ownerUserId, [INVITER_B], "GROUP");
|
||||
await roomLobby.enterRoom(INVITER_A, roomA.id, clock.now());
|
||||
await roomLobby.enterRoom(INVITER_B, roomB.id, clock.now());
|
||||
await invitationService.invite(roomA.id, TARGET, INVITER_A, clock.now());
|
||||
await invitationService.invite(roomB.id, TARGET, INVITER_B, clock.now());
|
||||
|
||||
await possession.enter(ownerUserId, TARGET);
|
||||
await invitationService.resolvePendingFor(TARGET, clock.now());
|
||||
|
||||
// 人格系統的自動裁決完全沒有介入:兩筆都還是 PENDING,沒有任何一間被系統代答。
|
||||
const stillPending = await prisma.client.roomInvitation.findMany({ where: { characterId: TARGET } });
|
||||
expect(stillPending.every((i) => i.status === "PENDING")).toBe(true);
|
||||
const roomAWhilePending = await roomService.get(roomA.id);
|
||||
const roomBWhilePending = await roomService.get(roomB.id);
|
||||
expect(roomAWhilePending.turns).toHaveLength(0);
|
||||
expect(roomBWhilePending.turns).toHaveLength(0);
|
||||
|
||||
// 套用者親自決定:接受 B 房的邀請,A 房的邀請一併被婉拒(與人格系統「選一個、其餘逐一回覆」語意一致)。
|
||||
const invitationB = stillPending.find((i) => i.roomId === roomB.id)!;
|
||||
await invitationService.decideAsPossessed(invitationB.id, true, clock.now());
|
||||
|
||||
const afterDecision = await prisma.client.roomInvitation.findMany({ where: { characterId: TARGET } });
|
||||
expect(afterDecision.find((i) => i.roomId === roomB.id)?.status).toBe("ACCEPTED");
|
||||
expect(afterDecision.find((i) => i.roomId === roomA.id)?.status).toBe("DECLINED");
|
||||
const roomBAfterDecision = await roomService.get(roomB.id);
|
||||
expect(roomBAfterDecision.participants.has(TARGET)).toBe(true);
|
||||
|
||||
await possession.exit(ownerUserId);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { CLOCK, type Clock } from "../clock/clock.js";
|
||||
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
||||
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { availabilityForStatus } from "../schedule/constants.js";
|
||||
import { getArchetypeParams } from "../personality/archetype-params.js";
|
||||
import { EMOTION_SPEAKING_MODIFIER } from "../room/constants.js";
|
||||
import { RoomService } from "../room/room.service.js";
|
||||
import { LobbyService as RoomLobbyService } from "../room/lobby.service.js";
|
||||
import { CharacterRelationshipService } from "../room/character-relationship.service.js";
|
||||
import { PossessionService } from "../room/possession.service.js";
|
||||
import { CALL_ACCEPTANCE_THRESHOLD } from "./constants.js";
|
||||
|
||||
// T29:使用者呼叫(T5)與房內角色邀請走同一套 RoomInvitation。房內角色邀請不在場角色時,
|
||||
// 若該角色同時有多筆 PENDING 邀請(例如同時被兩個不同房間邀請),由人格系統依關係/情緒選出
|
||||
// 一筆 ACCEPTED,其餘標 DECLINED,並在每一個邀請來源各留一則發話後才真正入房。
|
||||
@Injectable()
|
||||
export class InvitationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(CLOCK) private readonly clock: Clock,
|
||||
private readonly emotion: EmotionService,
|
||||
private readonly schedule: ScheduleService,
|
||||
private readonly characterRelationship: CharacterRelationshipService,
|
||||
private readonly room: RoomService,
|
||||
private readonly roomLobby: RoomLobbyService,
|
||||
private readonly possession: PossessionService,
|
||||
) {}
|
||||
|
||||
// invitedByCharacterId 為 null 表使用者從大廳呼叫;非 null 表房內角色邀請。
|
||||
// 只登記邀請意向(PENDING 或依 R5 立即 DECLINED),不在此立刻裁決——同一角色可能「同時」被
|
||||
// 多處邀請(例如兩個不同房間各自邀請),必須等呼叫端明確觸發 resolvePendingFor() 才裁決,
|
||||
// 否則最先送達的邀請永遠會在還沒有對手可比較時就單獨勝出,變成先到先贏而非依人格系統選擇。
|
||||
async invite(roomId: string, characterId: string, invitedByCharacterId: string | null, now?: Date): Promise<void> {
|
||||
const effectiveNow = now ?? this.clock.now();
|
||||
|
||||
// R5:已在其他(非大廳)房間在場者只能婉拒,不進入待裁決佇列。
|
||||
const activeMembership = await this.prisma.client.roomMember.findFirst({
|
||||
where: { characterId, activeCharacterId: characterId },
|
||||
include: { room: true },
|
||||
});
|
||||
if (activeMembership && activeMembership.room.mode !== "LOBBY" && activeMembership.roomId !== roomId) {
|
||||
await this.prisma.client.roomInvitation.create({
|
||||
data: { roomId, characterId, invitedByCharacterId, status: "DECLINED", respondedAt: effectiveNow },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.client.roomInvitation.create({
|
||||
data: { roomId, characterId, invitedByCharacterId, status: "PENDING" },
|
||||
});
|
||||
}
|
||||
|
||||
// 依關係/情緒選出一筆 ACCEPTED,其餘 DECLINED;每個來源各留一則發話後才真正入房。
|
||||
async resolvePendingFor(characterId: string, now?: Date): Promise<void> {
|
||||
const effectiveNow = now ?? this.clock.now();
|
||||
const pending = await this.prisma.client.roomInvitation.findMany({
|
||||
where: { characterId, status: "PENDING" },
|
||||
include: { room: true },
|
||||
});
|
||||
if (pending.length === 0) return;
|
||||
|
||||
// T43:套用中的角色不能讓人格系統代她判斷接受/婉拒——維持 PENDING,交由套用者透過
|
||||
// decideAsPossessed() 明確決定。
|
||||
const possessedIds = await this.possession.getPossessedCharacterIds();
|
||||
if (possessedIds.has(characterId)) return;
|
||||
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||
const scheduleInfo = await this.schedule.getInfo(characterId, effectiveNow);
|
||||
const asleep = availabilityForStatus(scheduleInfo.slot?.status ?? null) === "NO_REPLY";
|
||||
|
||||
const emotionState = await this.emotion.getState(characterId, effectiveNow);
|
||||
const dominant = dominantState(emotionState);
|
||||
const archetypeParams = getArchetypeParams(character.personalityArchetype);
|
||||
const emotionModifier = EMOTION_SPEAKING_MODIFIER[dominant];
|
||||
|
||||
const scored = await Promise.all(
|
||||
pending.map(async (invitation) => {
|
||||
if (asleep) return { invitation, score: -Infinity };
|
||||
const affinity = invitation.invitedByCharacterId
|
||||
? await this.characterRelationship.getAffinity(characterId, invitation.invitedByCharacterId)
|
||||
: 50; // 使用者從大廳呼叫:以中性關係修正為基準(呼叫端多半已另行以 T5 的完整關係計分判斷過一次)
|
||||
const relationshipModifier = (affinity - 50) / 100;
|
||||
const score = archetypeParams.conversationBaseline + emotionModifier + relationshipModifier;
|
||||
return { invitation, score };
|
||||
}),
|
||||
);
|
||||
|
||||
const winner = scored.reduce((best, cur) => (cur.score > best.score ? cur : best), scored[0]);
|
||||
const accept = winner.score >= CALL_ACCEPTANCE_THRESHOLD;
|
||||
|
||||
for (const { invitation } of scored) {
|
||||
const isWinner = accept && invitation.id === winner.invitation.id;
|
||||
await this.prisma.client.roomInvitation.update({
|
||||
where: { id: invitation.id },
|
||||
data: { status: isWinner ? "ACCEPTED" : "DECLINED", respondedAt: effectiveNow },
|
||||
});
|
||||
if (!isWinner) {
|
||||
await this.leaveReply(invitation.roomId, characterId, effectiveNow, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (accept) {
|
||||
// T42:邀請對象答應加入原本是 SOLO 的房間時,房型立即改為 GROUP(不支援之後降回 SOLO,
|
||||
// 沒有任何程式路徑會把 GROUP 改回 SOLO——離開只會減少在場人數,不影響 mode)。
|
||||
const targetRoom = await this.prisma.client.room.findUniqueOrThrow({ where: { id: winner.invitation.roomId } });
|
||||
if (targetRoom.mode === "SOLO") {
|
||||
await this.room.switchToGroup(winner.invitation.roomId);
|
||||
}
|
||||
await this.roomLobby.enterRoom(characterId, winner.invitation.roomId, effectiveNow);
|
||||
await this.leaveReply(winner.invitation.roomId, characterId, effectiveNow, true);
|
||||
}
|
||||
}
|
||||
|
||||
// T43:套用中角色的待決邀請清單,供套用者查看並決定。
|
||||
async listPendingFor(characterId: string) {
|
||||
return this.prisma.client.roomInvitation.findMany({
|
||||
where: { characterId, status: "PENDING" },
|
||||
include: { room: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
// T43:套用者以角色本人的身分決定一筆待決邀請——不經過人格系統的關係/情緒評分。答應時,同角色
|
||||
// 其餘待決邀請一併婉拒(與 resolvePendingFor() 的「選一個、其餘逐一回覆」語意一致)。
|
||||
async decideAsPossessed(invitationId: string, accept: boolean, now?: Date): Promise<void> {
|
||||
const effectiveNow = now ?? this.clock.now();
|
||||
const invitation = await this.prisma.client.roomInvitation.findUniqueOrThrow({ where: { id: invitationId } });
|
||||
if (invitation.status !== "PENDING") return;
|
||||
|
||||
await this.prisma.client.roomInvitation.update({
|
||||
where: { id: invitationId },
|
||||
data: { status: accept ? "ACCEPTED" : "DECLINED", respondedAt: effectiveNow },
|
||||
});
|
||||
if (!accept) {
|
||||
await this.leaveReply(invitation.roomId, invitation.characterId, effectiveNow, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const otherPending = await this.prisma.client.roomInvitation.findMany({
|
||||
where: { characterId: invitation.characterId, status: "PENDING", id: { not: invitationId } },
|
||||
});
|
||||
for (const other of otherPending) {
|
||||
await this.prisma.client.roomInvitation.update({
|
||||
where: { id: other.id },
|
||||
data: { status: "DECLINED", respondedAt: effectiveNow },
|
||||
});
|
||||
await this.leaveReply(other.roomId, invitation.characterId, effectiveNow, false);
|
||||
}
|
||||
|
||||
const targetRoom = await this.prisma.client.room.findUniqueOrThrow({ where: { id: invitation.roomId } });
|
||||
if (targetRoom.mode === "SOLO") {
|
||||
await this.room.switchToGroup(invitation.roomId);
|
||||
}
|
||||
await this.roomLobby.enterRoom(invitation.characterId, invitation.roomId, effectiveNow);
|
||||
await this.leaveReply(invitation.roomId, invitation.characterId, effectiveNow, true);
|
||||
}
|
||||
|
||||
// 在邀請來源留下一則角色台詞(婉拒說明,或答應後的入房招呼),而不是靜默消失或系統訊息。
|
||||
private async leaveReply(roomId: string, characterId: string, now: Date, accepted: boolean): Promise<void> {
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||
const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId } });
|
||||
const label = alias?.formalName ?? character.id;
|
||||
const text = accepted ? "好,我來了!" : "抱歉,我這邊有點事,這次不能過去了。";
|
||||
await this.room.addTurn(roomId, characterId, label, text, now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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 { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { LobbyService as RoomLobbyService } from "../room/lobby.service.js";
|
||||
import { RoomEventsService } from "../room/room-events.service.js";
|
||||
import { RoomService, USER_SPEAKER_ID } from "../room/room.service.js";
|
||||
import { LobbyAggregationService } from "./lobby-aggregation.service.js";
|
||||
|
||||
// T4 驗收:單一請求即可畫出完整大廳;角色狀態與實際房間佔用一致;多房並存時各房未讀數皆正確。
|
||||
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-19T12:00:00.000Z");
|
||||
const roomEvents = new RoomEventsService(new EventEmitter2());
|
||||
const roomLobby = new RoomLobbyService(prisma, clock, roomEvents);
|
||||
const roomService = new RoomService(prisma, clock, roomLobby, roomEvents);
|
||||
const scheduleService = new ScheduleService(prisma);
|
||||
const aggregation = new LobbyAggregationService(prisma, scheduleService, roomLobby);
|
||||
|
||||
const userId = "test-lobby-agg-user";
|
||||
const charA = "test-lobby-agg-char-a";
|
||||
const charB = "test-lobby-agg-char-b";
|
||||
const WORK_ID = "test-lobby-agg-t34-work";
|
||||
const CHAR_C = "test-lobby-agg-t34-char-c";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.room.deleteMany({ where: { userId } });
|
||||
await prisma.client.character.deleteMany({ where: { id: { in: [charA, charB] } } });
|
||||
await prisma.client.user.deleteMany({ where: { id: userId } });
|
||||
await prisma.client.epilogueMilestoneLog.deleteMany({ where: { characterId: CHAR_C } });
|
||||
await prisma.client.character.deleteMany({ where: { id: CHAR_C } });
|
||||
await prisma.client.work.deleteMany({ where: { id: WORK_ID } });
|
||||
});
|
||||
|
||||
async function createCharacter(id: string) {
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "TSUNDERE",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: id } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("LobbyAggregationService(T4)", () => {
|
||||
it("角色狀態與實際房間佔用一致;多房並存時各房未讀數皆正確", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試大廳聚合使用者" } });
|
||||
await createCharacter(charA);
|
||||
await createCharacter(charB);
|
||||
|
||||
const before = await aggregation.getLobby(userId, clock.now());
|
||||
const beforeA = before.characters.find((c) => c.id === charA)!;
|
||||
expect(beforeA.presenceState).toBe("IDLE");
|
||||
expect(beforeA.roomId).toBeNull();
|
||||
|
||||
const room = await roomService.create(userId, [], "GROUP");
|
||||
await roomLobby.enterRoom(charA, room.id, clock.now());
|
||||
await roomService.addTurn(room.id, USER_SPEAKER_ID, "測試", "第一句", clock.now());
|
||||
await roomService.addTurn(room.id, USER_SPEAKER_ID, "測試", "第二句", clock.now());
|
||||
|
||||
const after = await aggregation.getLobby(userId, clock.now());
|
||||
const afterA = after.characters.find((c) => c.id === charA)!;
|
||||
expect(afterA.presenceState).toBe("IN_GROUP_ROOM");
|
||||
expect(afterA.roomId).toBe(room.id);
|
||||
const afterB = after.characters.find((c) => c.id === charB)!;
|
||||
expect(afterB.presenceState).toBe("IDLE"); // 未入房的角色狀態不受影響
|
||||
|
||||
const activeRoomView = after.activeRooms.find((r) => r.id === room.id)!;
|
||||
expect(activeRoomView.unreadCount).toBe(2); // 尚未回報已讀
|
||||
|
||||
await roomService.reportRead(room.id, userId, 0);
|
||||
const afterPartialRead = await aggregation.getLobby(userId, clock.now());
|
||||
expect(afterPartialRead.activeRooms.find((r) => r.id === room.id)!.unreadCount).toBe(1);
|
||||
}, 20000);
|
||||
|
||||
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 } });
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_C,
|
||||
workId: WORK_ID,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "CANDIDATE",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: "小丙" } },
|
||||
},
|
||||
});
|
||||
await prisma.client.epilogueMilestoneLog.create({
|
||||
data: { characterId: CHAR_C, storyOrder: 5, label: "考上目標學校" },
|
||||
});
|
||||
|
||||
const lobby = await aggregation.getLobby(userId, clock.now());
|
||||
const workView = lobby.works.find((w) => w.id === WORK_ID)!;
|
||||
expect(workView.milestones).toHaveLength(1);
|
||||
expect(workView.milestones[0]).toMatchObject({ characterId: CHAR_C, label: "考上目標學校", storyOrder: 5 });
|
||||
|
||||
// 沒有里程碑的其他作品不受影響(milestones 為空陣列,不是 undefined 或摻進別的作品的資料)。
|
||||
const otherWorks = lobby.works.filter((w) => w.id !== WORK_ID);
|
||||
expect(otherWorks.every((w) => Array.isArray(w.milestones) && w.milestones.length === 0)).toBe(true);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { deriveLobbyPresenceState, type LobbyPresenceState, type LobbyPresenceRoomMode } from "@kokorone/shared";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { LobbyService as RoomLobbyService } from "../room/lobby.service.js";
|
||||
|
||||
export interface LobbyMilestoneView {
|
||||
characterId: string;
|
||||
label: string;
|
||||
storyOrder: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface LobbyWorkView {
|
||||
id: string;
|
||||
title: string;
|
||||
inEpilogue: boolean;
|
||||
milestones: LobbyMilestoneView[]; // T34:自走一段時間後自主成立的人生變化,明顯呈現在大廳作品卡片
|
||||
}
|
||||
|
||||
export interface LobbyCharacterView {
|
||||
id: string;
|
||||
workId: string | null;
|
||||
formalName: string;
|
||||
buildStatus: string;
|
||||
presenceState: LobbyPresenceState;
|
||||
roomId: string | null; // 目前所在的非大廳房間(若有)
|
||||
}
|
||||
|
||||
export interface LobbyActiveRoomView {
|
||||
id: string;
|
||||
mode: string;
|
||||
topicSeed: string | null;
|
||||
memberCharacterIds: string[];
|
||||
unreadCount: number;
|
||||
totalTokens: number; // T9:該房累計 LLM 用量(promptTokens + completionTokens),僅供觀察
|
||||
}
|
||||
|
||||
// T4:大廳聚合端點。單一請求聚合所有作品、旗下角色與 T1 定義的狀態、以及該使用者所有 ACTIVE
|
||||
// 房間與各房未讀數,以批次查詢(findMany + groupBy)避免逐一角色/房間各自查詢造成 N+1;
|
||||
// 唯一的例外是 ScheduleService.getInfo(每位角色的作息判定邏輯本身就是逐角色計算,改以
|
||||
// Promise.all 併發查詢而非序列 N+1 阻塞)。
|
||||
@Injectable()
|
||||
export class LobbyAggregationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly schedule: ScheduleService,
|
||||
private readonly roomLobby: RoomLobbyService,
|
||||
) {}
|
||||
|
||||
async getLobby(
|
||||
userId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<{ works: LobbyWorkView[]; characters: LobbyCharacterView[]; activeRooms: LobbyActiveRoomView[] }> {
|
||||
// T24 常駐流轉的一致性前提:先確保所有角色都已正確登記在場(大廳或其他房間)。
|
||||
await this.roomLobby.syncMembership(userId);
|
||||
|
||||
const [works, characters, activeMemberships, readStates, activeRooms] = await Promise.all([
|
||||
this.prisma.client.work.findMany({ orderBy: { createdAt: "asc" } }),
|
||||
this.prisma.client.character.findMany({ include: { aliases: true }, orderBy: { createdAt: "asc" } }),
|
||||
this.prisma.client.roomMember.findMany({ where: { activeCharacterId: { not: null } }, include: { room: true } }),
|
||||
this.prisma.client.roomReadState.findMany({ where: { userId } }),
|
||||
this.prisma.client.room.findMany({
|
||||
where: { userId, status: "ACTIVE", mode: { not: "LOBBY" } },
|
||||
include: { members: { where: { leftAt: null } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const roomByCharacter = new Map(activeMemberships.map((m) => [m.characterId, m.room]));
|
||||
const lastReadSeqByRoom = new Map(readStates.map((r) => [r.roomId, r.lastReadSeq]));
|
||||
|
||||
const roomIds = activeRooms.map((r) => r.id);
|
||||
const latestTurns = roomIds.length
|
||||
? await this.prisma.client.roomTurn.groupBy({ by: ["roomId"], where: { roomId: { in: roomIds } }, _max: { seq: true } })
|
||||
: [];
|
||||
const turnCounterByRoom = new Map(latestTurns.map((t) => [t.roomId, (t._max.seq ?? -1) + 1]));
|
||||
|
||||
// T9:每房累計用量,批次查詢避免逐房各自聚合造成 N+1。
|
||||
const usageTotals = roomIds.length
|
||||
? await this.prisma.client.roomUsageLog.groupBy({
|
||||
by: ["roomId"],
|
||||
where: { roomId: { in: roomIds } },
|
||||
_sum: { promptTokens: true, completionTokens: true },
|
||||
})
|
||||
: [];
|
||||
const totalTokensByRoom = new Map(
|
||||
usageTotals.map((u) => [u.roomId, (u._sum.promptTokens ?? 0) + (u._sum.completionTokens ?? 0)]),
|
||||
);
|
||||
|
||||
// T34:批次查詢所有角色的里程碑紀錄,依角色所屬作品分組——同既有慣例,避免逐作品各自查詢造成 N+1。
|
||||
const milestoneLogs = characters.length
|
||||
? await this.prisma.client.epilogueMilestoneLog.findMany({
|
||||
where: { characterId: { in: characters.map((c) => c.id) } },
|
||||
orderBy: { storyOrder: "asc" },
|
||||
})
|
||||
: [];
|
||||
const workIdByCharacter = new Map(characters.map((c) => [c.id, c.workId]));
|
||||
const milestonesByWork = new Map<string, LobbyMilestoneView[]>();
|
||||
for (const log of milestoneLogs) {
|
||||
const workId = workIdByCharacter.get(log.characterId);
|
||||
if (!workId) continue;
|
||||
const list = milestonesByWork.get(workId) ?? [];
|
||||
list.push({ characterId: log.characterId, label: log.label, storyOrder: log.storyOrder, createdAt: log.createdAt });
|
||||
milestonesByWork.set(workId, list);
|
||||
}
|
||||
|
||||
const scheduleStatuses = await Promise.all(
|
||||
characters.map(async (c) => ({ id: c.id, status: (await this.schedule.getInfo(c.id, now)).slot?.status ?? null })),
|
||||
);
|
||||
const scheduleStatusByCharacter = new Map(scheduleStatuses.map((s) => [s.id, s.status]));
|
||||
|
||||
const characterViews: LobbyCharacterView[] = characters.map((character) => {
|
||||
const room = roomByCharacter.get(character.id);
|
||||
const roomMode: LobbyPresenceRoomMode = room && room.mode === "SOLO" ? "SOLO" : room && room.mode === "GROUP" ? "GROUP" : null;
|
||||
return {
|
||||
id: character.id,
|
||||
workId: character.workId,
|
||||
formalName: character.aliases[0]?.formalName ?? character.id,
|
||||
buildStatus: character.buildStatus,
|
||||
presenceState: deriveLobbyPresenceState({
|
||||
buildStatus: character.buildStatus,
|
||||
scheduleSlotStatus: scheduleStatusByCharacter.get(character.id) ?? "FREE",
|
||||
roomMode,
|
||||
}),
|
||||
roomId: roomMode ? (room!.id ?? null) : null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
works: works.map((work) => ({
|
||||
id: work.id,
|
||||
title: work.title,
|
||||
inEpilogue: work.inEpilogue,
|
||||
milestones: milestonesByWork.get(work.id) ?? [],
|
||||
})),
|
||||
characters: characterViews,
|
||||
activeRooms: activeRooms.map((room) => {
|
||||
const turnCounter = turnCounterByRoom.get(room.id) ?? 0;
|
||||
const lastReadSeq = lastReadSeqByRoom.get(room.id) ?? -1;
|
||||
return {
|
||||
id: room.id,
|
||||
mode: room.mode,
|
||||
topicSeed: room.topicSeed,
|
||||
memberCharacterIds: room.members.map((m) => m.characterId),
|
||||
unreadCount: Math.max(0, turnCounter - (lastReadSeq + 1)),
|
||||
totalTokens: totalTokensByRoom.get(room.id) ?? 0,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Body, Controller, Get, Post, Query } from "@nestjs/common";
|
||||
import { LobbyAggregationService } from "./lobby-aggregation.service.js";
|
||||
import { CallService } from "./call.service.js";
|
||||
import { PossessionService } from "../room/possession.service.js";
|
||||
|
||||
interface SayBody {
|
||||
userId: string;
|
||||
text: string;
|
||||
now?: string;
|
||||
}
|
||||
|
||||
// T4:GET /lobby——單一請求畫出完整大廳。既有 GET /room/lobby(T19-P1/T20-P1 最小可用)
|
||||
// 保留供舊呼叫端使用;本端點取代其作為正式聚合來源。
|
||||
// T5:POST /lobby/say——大廳對話式呼叫。
|
||||
@Controller("lobby")
|
||||
export class LobbyController {
|
||||
constructor(
|
||||
private readonly aggregation: LobbyAggregationService,
|
||||
private readonly call: CallService,
|
||||
private readonly possession: PossessionService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getLobby(@Query("userId") userId: string) {
|
||||
return this.aggregation.getLobby(userId);
|
||||
}
|
||||
|
||||
@Post("say")
|
||||
async say(@Body() body: SayBody) {
|
||||
// T43:套用角色期間不可以自己的身分發話(大廳呼叫本身就是「以使用者身分」喊人)。
|
||||
await this.possession.assertNotPossessing(body.userId);
|
||||
return this.call.say(body.userId, body.text, body.now ? new Date(body.now) : undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||
import { ScheduleModule } from "../schedule/schedule.module.js";
|
||||
import { RoomModule } from "../room/room.module.js";
|
||||
import { EmotionModule } from "../emotion/emotion.module.js";
|
||||
import { RelationshipModule } from "../relationship/relationship.module.js";
|
||||
import { LlmModule } from "../llm/llm.module.js";
|
||||
import { ClockModule } from "../clock/clock.module.js";
|
||||
import { LobbyAggregationService } from "./lobby-aggregation.service.js";
|
||||
import { CallService } from "./call.service.js";
|
||||
import { LobbyController } from "./lobby.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ScheduleModule, RoomModule, EmotionModule, RelationshipModule, LlmModule, ClockModule],
|
||||
controllers: [LobbyController],
|
||||
providers: [LobbyAggregationService, CallService],
|
||||
exports: [LobbyAggregationService, CallService],
|
||||
})
|
||||
export class LobbyModule {}
|
||||
@@ -8,7 +8,9 @@ function normalize(content: string): string {
|
||||
return content.trim().toLowerCase();
|
||||
}
|
||||
|
||||
// C-4 睡眠固化:對話中只累積在工作記憶,session 結束時才批次萃取寫入長期記憶。
|
||||
// C-4/T40 睡眠固化:對話中只累積在工作記憶,session 結束時才批次萃取寫入長期記憶。
|
||||
// T40:本服務固化的內容一律來自 1:1(SOLO)房間,因此一律標記 isPrivate=true——多人房生成
|
||||
// (RoomGenerationService)預設排除私密記憶,口風不緊原型除外(見 K-3/T40 隱私邊界)。
|
||||
@Injectable()
|
||||
export class MemoryConsolidationService {
|
||||
constructor(
|
||||
@@ -49,6 +51,7 @@ export class MemoryConsolidationService {
|
||||
emotionIntensity,
|
||||
source: "INTERACTION",
|
||||
weight: isHighEmotion ? 1 + emotionIntensity : 0.5,
|
||||
isPrivate: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { PersonalityDriftService } from "../epilogue/personality-drift.service.js";
|
||||
import { PersonalityResolutionService } from "./personality-resolution.service.js";
|
||||
import { getArchetypeParams } from "./archetype-params.js";
|
||||
|
||||
// T16 驗收:混房實測中日後談角色表現符合漂移後設定、本篇角色維持原設定,且彼此不互相污染。
|
||||
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 personalityDrift = new PersonalityDriftService(prisma);
|
||||
const resolution = new PersonalityResolutionService(prisma, personalityDrift);
|
||||
|
||||
const WORK_EPILOGUE = "test-t16-work-epilogue";
|
||||
const WORK_CANON = "test-t16-work-canon";
|
||||
const CHAR_EPILOGUE = "test-t16-char-epilogue";
|
||||
const CHAR_CANON = "test-t16-char-canon";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.personalityTraitSnapshot.deleteMany({ where: { characterId: CHAR_EPILOGUE } });
|
||||
await prisma.client.character.deleteMany({ where: { id: { in: [CHAR_EPILOGUE, CHAR_CANON] } } });
|
||||
await prisma.client.work.deleteMany({ where: { id: { in: [WORK_EPILOGUE, WORK_CANON] } } });
|
||||
});
|
||||
|
||||
describe("PersonalityResolutionService(T16)", () => {
|
||||
it("inEpilogue 角色取得漂移後 expressiveness;同房的本篇角色維持原始設定,互不污染", async () => {
|
||||
await prisma.client.work.create({
|
||||
data: { id: WORK_EPILOGUE, title: "測試作品(日後談)", inEpilogue: true, anchorStoryOrder: 100 },
|
||||
});
|
||||
await prisma.client.work.create({
|
||||
data: { id: WORK_CANON, title: "測試作品(本篇)", inEpilogue: false, anchorStoryOrder: 10 },
|
||||
});
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_EPILOGUE,
|
||||
workId: WORK_EPILOGUE,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "三無",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
},
|
||||
});
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_CANON,
|
||||
workId: WORK_CANON,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "三無",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
},
|
||||
});
|
||||
// 正史已顯現的成長軌跡:外顯度隨故事推進逐漸上升,日後談應外插延續這個趨勢。
|
||||
await prisma.client.personalityTraitSnapshot.create({
|
||||
data: { characterId: CHAR_EPILOGUE, atStoryOrder: 0, note: "測試", expressivenessOverride: 0.05 },
|
||||
});
|
||||
await prisma.client.personalityTraitSnapshot.create({
|
||||
data: { characterId: CHAR_EPILOGUE, atStoryOrder: 10, note: "測試", expressivenessOverride: 0.15 },
|
||||
});
|
||||
|
||||
const base = getArchetypeParams("三無");
|
||||
const epilogueResolved = await resolution.resolve(CHAR_EPILOGUE);
|
||||
const canonResolved = await resolution.resolve(CHAR_CANON);
|
||||
|
||||
// 日後談角色:expressiveness 應偏離原始值(漂移後設定),其餘參數(如 conversationBaseline)不變
|
||||
// ——性格核心本身鎖定,只有外顯度依正史成長軌跡外插小幅位移。
|
||||
expect(epilogueResolved.expressiveness).not.toBe(base.expressiveness);
|
||||
expect(epilogueResolved.expressiveness).toBeGreaterThan(base.expressiveness);
|
||||
expect(epilogueResolved.conversationBaseline).toBe(base.conversationBaseline);
|
||||
|
||||
// 本篇角色:完全維持原始設定,不受隔壁日後談角色的漂移結果影響(互不污染)。
|
||||
expect(canonResolved).toEqual(base);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { PersonalityDriftService } from "../epilogue/personality-drift.service.js";
|
||||
import { getArchetypeParams, type ArchetypeParams } from "./archetype-params.js";
|
||||
|
||||
// T16:混房的人格一致性——inEpilogue 作品的角色改取漂移後人格(目前只有 expressiveness 會被漂移,
|
||||
// 見 PersonalityDriftService 的說明:性格核心鎖定不變,只有外顯度依正史已顯現的成長軌跡小幅位移),
|
||||
// 其餘(非日後談)角色沿用本篇原始設定。每次呼叫都各自查該角色自己的 Work,不共用、不快取跨角色
|
||||
// 的結果,確保混房時彼此的設定不會互相污染。
|
||||
@Injectable()
|
||||
export class PersonalityResolutionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly personalityDrift: PersonalityDriftService,
|
||||
) {}
|
||||
|
||||
async resolve(characterId: string): Promise<ArchetypeParams> {
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({
|
||||
where: { id: characterId },
|
||||
include: { work: true },
|
||||
});
|
||||
const base = getArchetypeParams(character.personalityArchetype);
|
||||
if (!character.work?.inEpilogue) {
|
||||
return base;
|
||||
}
|
||||
const drift = await this.personalityDrift.computeDrift(characterId, character.work.anchorStoryOrder);
|
||||
return { ...base, expressiveness: drift.expressiveness };
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||
import { EmotionModule } from "../emotion/emotion.module.js";
|
||||
import { EpilogueModule } from "../epilogue/epilogue.module.js";
|
||||
import { ContrastBehaviorService } from "./contrast-behavior.service.js";
|
||||
import { CharacterCreationService } from "./character-creation.service.js";
|
||||
import { PersonalityResolutionService } from "./personality-resolution.service.js";
|
||||
import { PersonalityController } from "./personality.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, EmotionModule],
|
||||
imports: [PrismaModule, EmotionModule, EpilogueModule],
|
||||
controllers: [PersonalityController],
|
||||
providers: [ContrastBehaviorService, CharacterCreationService],
|
||||
exports: [ContrastBehaviorService, CharacterCreationService],
|
||||
providers: [ContrastBehaviorService, CharacterCreationService, PersonalityResolutionService],
|
||||
exports: [ContrastBehaviorService, CharacterCreationService, PersonalityResolutionService],
|
||||
})
|
||||
export class PersonalityModule {}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
||||
import { RomanceService } from "./romance.service.js";
|
||||
import { RomanceEligibilityService } from "./romance-eligibility.service.js";
|
||||
import { isOutsideSoloRoom } from "./room-context-guard.js";
|
||||
import {
|
||||
CONFESSION_METER_THRESHOLD,
|
||||
AWKWARD_PERIOD_DAYS,
|
||||
@@ -37,7 +38,12 @@ export class ConfessionService {
|
||||
private readonly emotion: EmotionService,
|
||||
) {}
|
||||
|
||||
async confess(characterId: string, userId: string, now: Date = new Date()): Promise<ConfessionResult> {
|
||||
async confess(characterId: string, userId: string, now: Date = new Date(), roomId?: string): Promise<ConfessionResult> {
|
||||
// T42:房型已轉為 GROUP/LOBBY/SELF_CHAT 時,告白一律不可用——不消耗嘗試次數、不記錄拒絕。
|
||||
if (await isOutsideSoloRoom(this.prisma, roomId)) {
|
||||
return { outcome: "INELIGIBLE", romanceMeter: 0, consecutiveRejections: 0 };
|
||||
}
|
||||
|
||||
const eligible = await this.eligibility.getEligibility(characterId);
|
||||
if (!eligible.romanceAxisEligible) {
|
||||
return { outcome: "INELIGIBLE", romanceMeter: 0, consecutiveRejections: 0 };
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from "@nestjs/common";
|
||||
import type { Archetype } from "../personality/archetype-params.js";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RomanceService } from "./romance.service.js";
|
||||
import { isOutsideSoloRoom } from "./room-context-guard.js";
|
||||
import { MILESTONE_MIN_TIER, PARTNER_STABLE_EVENT_COUNT, type MilestoneType } from "./constants.js";
|
||||
|
||||
export interface MilestoneResult {
|
||||
@@ -47,7 +48,13 @@ export class MilestoneService {
|
||||
userId: string,
|
||||
milestoneType: MilestoneType,
|
||||
now: Date = new Date(),
|
||||
roomId?: string,
|
||||
): Promise<MilestoneResult> {
|
||||
// T42:房型已轉為 GROUP/LOBBY/SELF_CHAT 時,親密表現里程碑一律禁止——視同尚未達到任何層級。
|
||||
if (await isOutsideSoloRoom(this.prisma, roomId)) {
|
||||
return { allowed: false, isFirstTime: false, reactionLine: "……這裡人多,不太方便。" };
|
||||
}
|
||||
|
||||
const relationship = await this.romance.getState(characterId, userId, now);
|
||||
const currentTier = tierFor(relationship.romanceStage, relationship.romanceEventsInStage);
|
||||
const requiredTier = MILESTONE_MIN_TIER[milestoneType];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { AgeDeterminationService } from "./age-determination.service.js";
|
||||
import { isOutsideSoloRoom } from "./room-context-guard.js";
|
||||
|
||||
export interface RomanceEligibility {
|
||||
romanceAxisEligible: boolean; // 戀愛軸是否可啟動(含後日談自然成年者)
|
||||
@@ -28,15 +29,20 @@ export class RomanceEligibilityService {
|
||||
return { romanceAxisEligible: currentAdult, adultModeEligible: canonAdult, isEpilogueAdult };
|
||||
}
|
||||
|
||||
async checkAdultContentAllowed(characterId: string, userId: string): Promise<AdultContentCheckResult> {
|
||||
const result = await this.evaluate(characterId, userId);
|
||||
async checkAdultContentAllowed(characterId: string, userId: string, roomId?: string): Promise<AdultContentCheckResult> {
|
||||
const result = await this.evaluate(characterId, userId, roomId);
|
||||
await this.prisma.client.adultContentAttemptLog.create({
|
||||
data: { characterId, userId, allowed: result.allowed, reason: result.reason },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private async evaluate(characterId: string, userId: string): Promise<AdultContentCheckResult> {
|
||||
private async evaluate(characterId: string, userId: string, roomId?: string): Promise<AdultContentCheckResult> {
|
||||
// T42:房型已轉為 GROUP/LOBBY/SELF_CHAT 時,成人內容一律禁止——房型本身是硬邊界,
|
||||
// 不受角色資格或使用者 opt-in 狀態影響,優先於下方其他判斷。
|
||||
if (await isOutsideSoloRoom(this.prisma, roomId)) {
|
||||
return { allowed: false, reason: "目前在多人房(GROUP/LOBBY/SELF_CHAT),成人內容僅限 1:1 房間" };
|
||||
}
|
||||
const eligibility = await this.getEligibility(characterId);
|
||||
if (!eligibility.adultModeEligible) {
|
||||
return {
|
||||
|
||||
@@ -25,12 +25,14 @@ interface RomanceEventBody {
|
||||
interface UserOnlyBody {
|
||||
userId: string;
|
||||
now?: string;
|
||||
roomId?: string; // T42:帶房間時依房型套用邊界(僅 SOLO 可用)
|
||||
}
|
||||
|
||||
interface MilestoneBody {
|
||||
userId: string;
|
||||
milestoneType: MilestoneType;
|
||||
now?: string;
|
||||
roomId?: string; // T42:帶房間時依房型套用邊界(僅 SOLO 可用)
|
||||
}
|
||||
|
||||
interface AdultConsentBody {
|
||||
@@ -77,7 +79,7 @@ export class RomanceController {
|
||||
// L-7 引擎層唯一入口:任何嘗試皆記錄,不論放行與否。
|
||||
@Post(":characterId/adult-content-check")
|
||||
async checkAdultContent(@Param("characterId") characterId: string, @Body() body: UserOnlyBody) {
|
||||
return this.eligibility.checkAdultContentAllowed(characterId, body.userId);
|
||||
return this.eligibility.checkAdultContentAllowed(characterId, body.userId, body.roomId);
|
||||
}
|
||||
|
||||
@Post("adult-mode/consent")
|
||||
@@ -108,7 +110,7 @@ export class RomanceController {
|
||||
|
||||
@Post(":characterId/confess")
|
||||
async confess(@Param("characterId") characterId: string, @Body() body: UserOnlyBody) {
|
||||
return this.confession.confess(characterId, body.userId, body.now ? new Date(body.now) : undefined);
|
||||
return this.confession.confess(characterId, body.userId, body.now ? new Date(body.now) : undefined, body.roomId);
|
||||
}
|
||||
|
||||
@Post(":characterId/milestone")
|
||||
@@ -118,6 +120,7 @@ export class RomanceController {
|
||||
body.userId,
|
||||
body.milestoneType,
|
||||
body.now ? new Date(body.now) : undefined,
|
||||
body.roomId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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 { RoomEventsService } from "../room/room-events.service.js";
|
||||
import { LobbyService } from "../room/lobby.service.js";
|
||||
import { RoomService } from "../room/room.service.js";
|
||||
import { RomanceService } from "./romance.service.js";
|
||||
import { RomanceEligibilityService } from "./romance-eligibility.service.js";
|
||||
import { AgeDeterminationService } from "./age-determination.service.js";
|
||||
import { ConfessionService } from "./confession.service.js";
|
||||
import { MilestoneService } from "./milestone.service.js";
|
||||
import { EmotionService } from "../emotion/emotion.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||
|
||||
// T42 驗收:房型轉為 GROUP 後,戀愛里程碑/告白/成人內容一律禁止,不看關係數值。
|
||||
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 roomLobby = new LobbyService(prisma, clock, roomEvents);
|
||||
const roomService = new RoomService(prisma, clock, roomLobby, roomEvents);
|
||||
const relationshipService = new RelationshipService(prisma, new EventEmitter2());
|
||||
const ageDetermination = new AgeDeterminationService(prisma);
|
||||
const eligibility = new RomanceEligibilityService(prisma, ageDetermination);
|
||||
const romanceService = new RomanceService(prisma, relationshipService, eligibility);
|
||||
const emotionService = new EmotionService(prisma, new RuleBasedEmotionTagger());
|
||||
const confessionService = new ConfessionService(prisma, romanceService, eligibility, emotionService);
|
||||
const milestoneService = new MilestoneService(prisma, romanceService);
|
||||
|
||||
const CHARACTER_ID = "test-t42-romance-char";
|
||||
const USER_ID = "test-t42-romance-user";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.room.deleteMany({ where: { userId: USER_ID } });
|
||||
await prisma.client.romanceEventLog.deleteMany({ where: { characterId: CHARACTER_ID } });
|
||||
await prisma.client.adultContentAttemptLog.deleteMany({ where: { characterId: CHARACTER_ID } });
|
||||
await prisma.client.characterAgeEvidence.deleteMany({ where: { characterId: CHARACTER_ID } });
|
||||
await prisma.client.relationship.deleteMany({ where: { characterId: CHARACTER_ID, userId: USER_ID } });
|
||||
await prisma.client.adultModeConsent.deleteMany({ where: { userId: USER_ID } });
|
||||
await prisma.client.character.deleteMany({ where: { id: CHARACTER_ID } });
|
||||
await prisma.client.user.deleteMany({ where: { id: USER_ID } });
|
||||
});
|
||||
|
||||
describe("戀愛系統房型邊界(T42)", () => {
|
||||
it("GROUP 房內告白/里程碑/成人內容一律被禁止,SOLO 房內不受影響", async () => {
|
||||
await prisma.client.user.create({ data: { id: USER_ID, name: "測試使用者" } });
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHARACTER_ID,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
},
|
||||
});
|
||||
await prisma.client.characterAgeEvidence.create({
|
||||
data: { characterId: CHARACTER_ID, evidenceType: "OFFICIAL_STATED", impliesAdult: true, isEpilogue: false, description: "測試" },
|
||||
});
|
||||
await prisma.client.relationship.create({
|
||||
data: {
|
||||
characterId: CHARACTER_ID,
|
||||
userId: USER_ID,
|
||||
intimacy: 80,
|
||||
trust: 80,
|
||||
stage: "BONDED",
|
||||
romanceStage: "AMBIGUOUS",
|
||||
romanceMeter: 90,
|
||||
},
|
||||
});
|
||||
await prisma.client.adultModeConsent.create({ data: { userId: USER_ID, verifiedAdult: true, optedIn: true } });
|
||||
|
||||
const soloRoom = await roomService.create(USER_ID, [], "SOLO");
|
||||
const groupRoom = await roomService.create(USER_ID, [], "GROUP");
|
||||
|
||||
// SOLO 房:正常運作(不受 T42 邊界影響)。
|
||||
const soloConfess = await confessionService.confess(CHARACTER_ID, USER_ID, clock.now(), soloRoom.id);
|
||||
expect(soloConfess.outcome).toBe("ACCEPTED");
|
||||
const soloMilestone = await milestoneService.attemptMilestone(CHARACTER_ID, USER_ID, "DISTANCE_SHORTENED", clock.now(), soloRoom.id);
|
||||
expect(soloMilestone.allowed).toBe(true);
|
||||
const soloAdult = await eligibility.checkAdultContentAllowed(CHARACTER_ID, USER_ID, soloRoom.id);
|
||||
expect(soloAdult.allowed).toBe(true);
|
||||
|
||||
// GROUP 房:一律禁止,即使關係數值完全達標。
|
||||
const groupConfess = await confessionService.confess(CHARACTER_ID, USER_ID, clock.now(), groupRoom.id);
|
||||
expect(groupConfess.outcome).toBe("INELIGIBLE");
|
||||
const groupMilestone = await milestoneService.attemptMilestone(CHARACTER_ID, USER_ID, "DISTANCE_SHORTENED", clock.now(), groupRoom.id);
|
||||
expect(groupMilestone.allowed).toBe(false);
|
||||
const groupAdult = await eligibility.checkAdultContentAllowed(CHARACTER_ID, USER_ID, groupRoom.id);
|
||||
expect(groupAdult.allowed).toBe(false);
|
||||
expect(groupAdult.reason).toContain("多人房");
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
|
||||
// T42:戀愛里程碑/告白/成人內容一律限 1:1(SOLO)房間;房型一旦轉為 GROUP/LOBBY/SELF_CHAT,
|
||||
// 這些互動全面禁止——不看關係階段、不看使用者是否已 opt-in,房型本身就是硬邊界。
|
||||
export async function isOutsideSoloRoom(prisma: PrismaService, roomId: string | undefined): Promise<boolean> {
|
||||
if (!roomId) return false; // 未帶 roomId 視為既有非房間情境(相容舊呼叫端),不套用房型邊界
|
||||
const room = await prisma.client.room.findUnique({ where: { id: roomId } });
|
||||
return room !== null && room.mode !== "SOLO";
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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 { 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 { LobbyService } from "./lobby.service.js";
|
||||
import { RoomEventsService } from "./room-events.service.js";
|
||||
import { RoomService, type Room } from "./room.service.js";
|
||||
import { BehaviorSelectionService, type BehaviorSelectionResult } from "./behavior-selection.service.js";
|
||||
|
||||
// T6 驗收:離開只在三項動因(行程/情緒耗盡/話題耗盡)之一成立時才進入候選;多輪抽樣下四種行為皆會出現。
|
||||
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"); // 週三 13:00 台北,FREE 時段
|
||||
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 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 userId = "test-behavior-user";
|
||||
const CHAR_A = "test-behavior-char-a";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.room.deleteMany({ where: { userId } });
|
||||
await prisma.client.emotionState.deleteMany({ where: { characterId: CHAR_A } });
|
||||
await prisma.client.scheduleSlot.deleteMany({ where: { characterId: CHAR_A } });
|
||||
await prisma.client.character.deleteMany({ where: { id: CHAR_A } });
|
||||
await prisma.client.user.deleteMany({ where: { id: userId } });
|
||||
});
|
||||
|
||||
// 依序(非併發)抽樣:better-sqlite3 是單一寫入者,併發大量 Promise.all 會在資料量大的共用 dev.db 上
|
||||
// 造成明顯的鎖等待,序列執行較穩定不易逾時。
|
||||
async function sampleMany(room: Room, characterId: string, baseTime: Date, count: number): Promise<BehaviorSelectionResult[]> {
|
||||
const results: BehaviorSelectionResult[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
results.push(await behaviorSelection.selectBehavior(room, characterId, new Date(baseTime.getTime() + i * 1000)));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function createCharacter(archetype: string) {
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_A,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: archetype,
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("BehaviorSelectionService(T6)", () => {
|
||||
it("沒有任何離開動因成立時,LEAVE 權重為 0、絕不會被抽中", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await createCharacter("元氣"); // FREE 時段、預設情緒 CALM、無行程
|
||||
const room = await roomService.create(userId, [CHAR_A], "SELF_CHAT", "測試話題");
|
||||
|
||||
const results = await sampleMany(room, CHAR_A, clock.now(), 15);
|
||||
expect(results.every((r) => r.kind !== "LEAVE")).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("行程忙碌(BUSY)時,LEAVE 有非零權重且 leaveReason 為 SCHEDULE", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await createCharacter("元氣");
|
||||
await prisma.client.scheduleSlot.create({
|
||||
data: { characterId: CHAR_A, dayType: "WEEKDAY", startMinute: 720, endMinute: 900, status: "BUSY" },
|
||||
});
|
||||
const room = await roomService.create(userId, [CHAR_A], "SELF_CHAT", "測試話題");
|
||||
|
||||
// 多次抽樣,統計是否確實可能抽到 LEAVE(機率性行為,抽夠多次應該至少中一次)。
|
||||
const results = await sampleMany(room, CHAR_A, clock.now(), 25);
|
||||
const leaveResults = results.filter((r) => r.kind === "LEAVE");
|
||||
expect(leaveResults.length).toBeGreaterThan(0);
|
||||
expect(leaveResults.every((r) => r.leaveReason === "SCHEDULE")).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("情緒耗盡(SAD 高強度)時,LEAVE 的 leaveReason 為 EMOTIONAL_EXHAUSTION", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await createCharacter("元氣");
|
||||
await prisma.client.emotionState.create({ data: { characterId: CHAR_A, sad: 90, calm: 10 } });
|
||||
const room = await roomService.create(userId, [CHAR_A], "SELF_CHAT", "測試話題");
|
||||
|
||||
const results = await sampleMany(room, CHAR_A, clock.now(), 25);
|
||||
const leaveResults = results.filter((r) => r.kind === "LEAVE");
|
||||
expect(leaveResults.length).toBeGreaterThan(0);
|
||||
expect(leaveResults.every((r) => r.leaveReason === "EMOTIONAL_EXHAUSTION")).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("多輪抽樣下 TALK/ACTION/DELEGATE 都可能出現(元氣:conversationBaseline 與 expressiveness 皆高)", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await createCharacter("元氣");
|
||||
const room = await roomService.create(userId, [CHAR_A], "SELF_CHAT", "測試話題");
|
||||
|
||||
const kinds = new Set((await sampleMany(room, CHAR_A, clock.now(), 35)).map((r) => r.kind));
|
||||
expect(kinds.has("TALK")).toBe(true);
|
||||
expect(kinds.has("ACTION")).toBe(true);
|
||||
expect(kinds.has("DELEGATE")).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("T32:睡眠債達升級門檻時,LEAVE 的 leaveReason 為 SLEEP_DEBT,優先於一般行程理由 SCHEDULE", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await createCharacter("元氣");
|
||||
// 同時符合一般行程忙碌動因,驗證睡眠債升級後會優先蓋過 SCHEDULE。
|
||||
await prisma.client.scheduleSlot.create({
|
||||
data: { characterId: CHAR_A, dayType: "WEEKDAY", startMinute: 720, endMinute: 900, status: "BUSY" },
|
||||
});
|
||||
await prisma.client.sleepDebt.create({ data: { characterId: CHAR_A, accumulatedMinutes: 200 } });
|
||||
const room = await roomService.create(userId, [CHAR_A], "SELF_CHAT", "測試話題");
|
||||
|
||||
const results = await sampleMany(room, CHAR_A, clock.now(), 25);
|
||||
const leaveResults = results.filter((r) => r.kind === "LEAVE");
|
||||
expect(leaveResults.length).toBeGreaterThan(0);
|
||||
expect(leaveResults.every((r) => r.leaveReason === "SLEEP_DEBT")).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("T32:睡眠債活躍(未達升級門檻)時,ACTION 權重提高,更容易抽到打瞌睡演出", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await createCharacter("三無"); // expressiveness 極低,平常幾乎不會抽到 ACTION
|
||||
await prisma.client.sleepDebt.create({ data: { characterId: CHAR_A, accumulatedMinutes: 90 } });
|
||||
const room = await roomService.create(userId, [CHAR_A], "SELF_CHAT", "測試話題");
|
||||
|
||||
const kinds = (await sampleMany(room, CHAR_A, clock.now(), 40)).map((r) => r.kind);
|
||||
expect(kinds).toContain("ACTION");
|
||||
}, 30000);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { RoomTurnKind } from "@kokorone/db";
|
||||
import type { EmotionState } from "@kokorone/shared";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
||||
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { SleepDebtService, SLEEP_DEBT_ACTIVE_THRESHOLD_MINUTES, SLEEP_DEBT_ESCALATION_THRESHOLD_MINUTES } from "../schedule/sleep-debt.service.js";
|
||||
import { PersonalityResolutionService } from "../personality/personality-resolution.service.js";
|
||||
import { createSeededRandom, hashToSeed } from "../llm/seeded-random.js";
|
||||
import { isStalling } from "./stall-detection.js";
|
||||
import { STALL_CHECK_WINDOW } from "./constants.js";
|
||||
import type { Room } from "./room.service.js";
|
||||
|
||||
export type LeaveReason = "SCHEDULE" | "EMOTIONAL_EXHAUSTION" | "TOPIC_EXHAUSTED" | "SLEEP_DEBT";
|
||||
|
||||
export interface BehaviorSelectionResult {
|
||||
kind: RoomTurnKind;
|
||||
leaveReason?: LeaveReason; // 只有 kind === "LEAVE" 時才有值,對應四項離開動因之一
|
||||
}
|
||||
|
||||
const DELEGATE_BASELINE_WEIGHT = 0.15;
|
||||
const ACTION_WEIGHT_SCALE = 0.4;
|
||||
const TALK_WEIGHT_FLOOR = 0.2; // 即使 conversationBaseline 極低(如三無),TALK 仍保留最低權重不至於完全消失
|
||||
const HIGH_EMOTION_INTENSITY_THRESHOLD = 70; // EmotionState 各維度 0~100,達此值視為「情緒耗盡」等級
|
||||
const LEAVE_WEIGHT_WHEN_ELIGIBLE = 0.5;
|
||||
const LEAVE_WEIGHT_WHEN_SLEEP_DEBT_ESCALATED = 0.75; // T32:撐不住想睡,比一般行程理由更強烈
|
||||
const SLEEP_DEBT_ACTION_WEIGHT_BONUS = 0.3; // T32:打瞌睡演出——睡眠債活躍時 ACTION 權重額外加成
|
||||
|
||||
function emotionFieldFor(tag: string): keyof Omit<EmotionState, "characterId" | "updatedAt"> {
|
||||
return tag === "CALM" ? "calm" : (tag.toLowerCase() as "joy" | "sad" | "alert" | "shy" | "grumpy");
|
||||
}
|
||||
|
||||
function weightedSample(weights: Partial<Record<RoomTurnKind, number>>, seed: number): RoomTurnKind {
|
||||
const entries = (Object.entries(weights) as [RoomTurnKind, number][]).filter(([, weight]) => weight > 0);
|
||||
const total = entries.reduce((sum, [, weight]) => sum + weight, 0);
|
||||
if (total <= 0) return "TALK"; // 保底:理論上不會發生(TALK 恆有 floor 權重),防禦性 fallback
|
||||
const roll = createSeededRandom(seed)() * total;
|
||||
let acc = 0;
|
||||
for (const [kind, weight] of entries) {
|
||||
acc += weight;
|
||||
if (roll < acc) return kind;
|
||||
}
|
||||
return entries[entries.length - 1][0];
|
||||
}
|
||||
|
||||
// T6/T32:由程式依 ARCHETYPE_PARAMS+情緒/行程/話題狀態/睡眠債算權重抽樣決定行為種類(TALK/
|
||||
// ACTION/DELEGATE/LEAVE),不交給 LLM 決定——LLM 只負責生成該行為的內容。LEAVE 只在動因成立時
|
||||
// 才會有非零權重、才可能被抽中:行程(忙碌/睡眠)、情緒(情緒耗盡)、話題耗盡(isStalling)、
|
||||
// 睡眠債(T32:熬夜累積的債務達門檻,升級為「撐不住想睡」,優先於一般行程理由)。
|
||||
@Injectable()
|
||||
export class BehaviorSelectionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly emotion: EmotionService,
|
||||
private readonly schedule: ScheduleService,
|
||||
private readonly sleepDebt: SleepDebtService,
|
||||
private readonly personalityResolution: PersonalityResolutionService,
|
||||
) {}
|
||||
|
||||
async selectBehavior(room: Room, characterId: string, now: Date = new Date()): Promise<BehaviorSelectionResult> {
|
||||
await this.sleepDebt.reconcileToday(characterId, now);
|
||||
|
||||
// T16:inEpilogue 角色改用漂移後的 expressiveness(僅影響 ACTION 權重),非日後談角色維持原始
|
||||
// 設定;每次都各自查該角色自己的 Work,混房時不會互相污染。
|
||||
const archetypeParams = await this.personalityResolution.resolve(characterId);
|
||||
|
||||
const emotionState = await this.emotion.getState(characterId, now);
|
||||
const dominant = dominantState(emotionState);
|
||||
const intensity = emotionState[emotionFieldFor(dominant)] as number;
|
||||
|
||||
const scheduleInfo = await this.schedule.getInfo(characterId, now);
|
||||
const scheduleEligible = scheduleInfo.slot?.status === "BUSY" || scheduleInfo.slot?.status === "SLEEP";
|
||||
const emotionalExhaustion = (dominant === "SAD" || dominant === "GRUMPY") && intensity >= HIGH_EMOTION_INTENSITY_THRESHOLD;
|
||||
const topicExhausted = isStalling(room.turns.slice(-STALL_CHECK_WINDOW).map((turn) => turn.content));
|
||||
const debtMinutes = await this.sleepDebt.getDebtMinutes(characterId);
|
||||
const sleepDebtEscalated = debtMinutes >= SLEEP_DEBT_ESCALATION_THRESHOLD_MINUTES;
|
||||
|
||||
// T32:睡眠債達升級門檻時,優先於一般行程理由——「撐不住想睡」比「行程到了」更強烈、更明說。
|
||||
let leaveReason: LeaveReason | undefined;
|
||||
if (sleepDebtEscalated) leaveReason = "SLEEP_DEBT";
|
||||
else if (scheduleEligible) leaveReason = "SCHEDULE";
|
||||
else if (emotionalExhaustion) leaveReason = "EMOTIONAL_EXHAUSTION";
|
||||
else if (topicExhausted) leaveReason = "TOPIC_EXHAUSTED";
|
||||
|
||||
const sleepDebtActive = debtMinutes >= SLEEP_DEBT_ACTIVE_THRESHOLD_MINUTES;
|
||||
const weights: Partial<Record<RoomTurnKind, number>> = {
|
||||
TALK: TALK_WEIGHT_FLOOR + archetypeParams.conversationBaseline,
|
||||
ACTION: archetypeParams.expressiveness * ACTION_WEIGHT_SCALE + (sleepDebtActive ? SLEEP_DEBT_ACTION_WEIGHT_BONUS : 0),
|
||||
DELEGATE: DELEGATE_BASELINE_WEIGHT,
|
||||
LEAVE: leaveReason ? (sleepDebtEscalated ? LEAVE_WEIGHT_WHEN_SLEEP_DEBT_ESCALATED : LEAVE_WEIGHT_WHEN_ELIGIBLE) : 0,
|
||||
};
|
||||
|
||||
const seed = hashToSeed(`${room.id}:${room.turnCounter}:${characterId}:${now.getTime()}:behavior`);
|
||||
const kind = weightedSample(weights, seed);
|
||||
|
||||
return { kind, leaveReason: kind === "LEAVE" ? leaveReason : undefined };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { CharacterRelationshipService } from "./character-relationship.service.js";
|
||||
|
||||
// T33 回歸測試:加上 Redis 讀寫快取後,get/set 的既有語意(未設定回傳中性值 50、set 後讀到新值、
|
||||
// 更新後不會讀到舊值)必須維持不變——本環境沒有可用的 Redis(同 EmotionService 既有的快取層一樣,
|
||||
// 未連線時一律安全退回直接讀寫 Prisma,見 character-relationship.service.ts 的 try/catch),
|
||||
// 因此這裡實際測到的是「Redis 不可用」的退回路徑,與快取命中路徑的正確性需在有 Redis 的環境另行確認。
|
||||
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 service = new CharacterRelationshipService(prisma);
|
||||
|
||||
const CHAR_A = "test-charrel-a";
|
||||
const CHAR_B = "test-charrel-b";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.characterRelationship.deleteMany({ where: { characterId: CHAR_A } });
|
||||
await prisma.client.character.deleteMany({ where: { id: { in: [CHAR_A, CHAR_B] } } });
|
||||
});
|
||||
|
||||
async function createCharacter(id: string) {
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("CharacterRelationshipService(T33 快取回歸)", () => {
|
||||
it("未設定時回傳中性值 50;set 後讀到新值;再次 set 更新後讀到最新值(不是舊快取)", async () => {
|
||||
await createCharacter(CHAR_A);
|
||||
await createCharacter(CHAR_B);
|
||||
|
||||
expect(await service.getAffinity(CHAR_A, CHAR_B)).toBe(50);
|
||||
|
||||
await service.set(CHAR_A, CHAR_B, 80, "喜歡");
|
||||
expect(await service.getAffinity(CHAR_A, CHAR_B)).toBe(80);
|
||||
|
||||
await service.set(CHAR_A, CHAR_B, 20, "吵架");
|
||||
expect(await service.getAffinity(CHAR_A, CHAR_B)).toBe(20);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -1,25 +1,78 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { getRedisClient } from "../cache/redis-client.js";
|
||||
|
||||
const REDIS_KEY_PREFIX = "kokorone:character-affinity:";
|
||||
// T28:同房互動的關係值小幅累積——初次同房從中性 50 開始,之後每次固化(房間結束)都朝更熟悉一點
|
||||
// 位移;夠小的固定值即可,跨作品/同作品一視同仁(跨作品混房也照常累積,不特別區分)。
|
||||
const INTERACTION_AFFINITY_DELTA = 2;
|
||||
|
||||
function cacheKey(characterId: string, otherCharacterId: string): string {
|
||||
return `${REDIS_KEY_PREFIX}${characterId}:${otherCharacterId}`;
|
||||
}
|
||||
|
||||
// K-2/K-4 角色間關係:同作品角色互損、拆台、護短、吃醋等化學反應的資料來源。
|
||||
// 有向邊:characterId 對 otherCharacterId 的觀感,兩邊可以各自設定、不必對稱。
|
||||
// T33:多人房每輪每位候選都要查一次 affinity,改走 Redis 快取當熱路徑——Prisma/SQLite 仍是唯一
|
||||
// 持久真實來源,Redis 不可用或未命中都直接退回原本讀 Prisma 的行為(同 EmotionService 既有慣例);
|
||||
// set() 寫入時一併使該筆快取失效,不會有「改了關係但快取還是舊值」的問題。
|
||||
@Injectable()
|
||||
export class CharacterRelationshipService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async set(characterId: string, otherCharacterId: string, affinity: number, dynamic?: string) {
|
||||
return this.prisma.client.characterRelationship.upsert({
|
||||
const result = await this.prisma.client.characterRelationship.upsert({
|
||||
where: { characterId_otherCharacterId: { characterId, otherCharacterId } },
|
||||
update: { affinity, dynamic },
|
||||
create: { characterId, otherCharacterId, affinity, dynamic },
|
||||
});
|
||||
try {
|
||||
await getRedisClient().del(cacheKey(characterId, otherCharacterId));
|
||||
} catch {
|
||||
// Redis 不可用:沒有快取可失效,下一次 getAffinity 本來就會直接讀 Prisma。
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 未設定關係時,回傳中性值(50)——同作品角色預設不特別親近也不特別有心結。
|
||||
async getAffinity(characterId: string, otherCharacterId: string): Promise<number> {
|
||||
const key = cacheKey(characterId, otherCharacterId);
|
||||
try {
|
||||
const cached = await getRedisClient().get(key);
|
||||
if (cached !== null) return Number(cached);
|
||||
} catch {
|
||||
// Redis 不可用:退回直接讀 Prisma。
|
||||
}
|
||||
|
||||
const row = await this.prisma.client.characterRelationship.findUnique({
|
||||
where: { characterId_otherCharacterId: { characterId, otherCharacterId } },
|
||||
});
|
||||
return row?.affinity ?? 50;
|
||||
const affinity = row?.affinity ?? 50;
|
||||
try {
|
||||
await getRedisClient().set(key, String(affinity));
|
||||
} catch {
|
||||
// Redis 不可用:純粹跳過快取寫入,不影響回傳值。
|
||||
}
|
||||
return affinity;
|
||||
}
|
||||
|
||||
// T28:同房互動後的關係值累積——初次同房(尚無既有關係列)視為中性 50 起算,之後每次固化都朝
|
||||
// 更熟悉一點小幅位移,夾在 0~100 之間。呼叫端(RoomConsolidationService)於房間關房固化時,
|
||||
// 對每一對「同一場都有真的發言」的角色雙向各呼叫一次。
|
||||
async recordInteraction(characterId: string, otherCharacterId: string): Promise<void> {
|
||||
const current = await this.getAffinity(characterId, otherCharacterId);
|
||||
const next = Math.max(0, Math.min(100, current + INTERACTION_AFFINITY_DELTA));
|
||||
await this.set(characterId, otherCharacterId, next);
|
||||
}
|
||||
|
||||
// T35 目擊者反應:旁觀角色對「使用者與另一位角色曖昧互動」的反應,同時調整關係值並留下事件紀錄
|
||||
// (CharacterRelationshipEventLog),供未來人格對話/關係史回顧引用這段「吃味/替對方開心」的事件。
|
||||
async recordWitnessReaction(witnessId: string, targetId: string, delta: number, description: string): Promise<void> {
|
||||
const current = await this.getAffinity(witnessId, targetId);
|
||||
const next = Math.max(0, Math.min(100, current + delta));
|
||||
await this.set(witnessId, targetId, next);
|
||||
await this.prisma.client.characterRelationshipEventLog.create({
|
||||
data: { characterId: witnessId, otherCharacterId: targetId, delta, description },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,11 +28,25 @@ export const GROUP_RESERVE_INTIMACY_PENALTY = 35;
|
||||
export const TSUNDERE_EXTRA_GROUP_PENALTY = 15;
|
||||
|
||||
// K-6 角色自聊
|
||||
// T6:不再是「累積到此輪數就整室結束」的硬上限——結束改由每位角色各自的 LEAVE 判定驅動(見
|
||||
// BehaviorSelectionService),房間可連續推進超過此值。保留常數供未來「單次推進批次上限」使用
|
||||
// (T8 排程器一次 tick 最多處理幾輪),目前尚無呼叫端消費。
|
||||
export const SELF_CHAT_MAX_TURNS = 12;
|
||||
export const STALL_CHECK_WINDOW = 3; // 檢查最近幾輪是否開始空轉
|
||||
export const TOPIC_TWIST_LINES = [
|
||||
"對了,說到這個,我突然想到另一件事……",
|
||||
"欸,先別說這個了,你們聽說了嗎——",
|
||||
"話說回來,今天發生了一件蠻好玩的事。",
|
||||
];
|
||||
// T6:LEAVE 動因之一(話題耗盡)離場時沿用的台詞。
|
||||
export const SELF_CHAT_WRAP_UP_LINES = ["好啦,先聊到這裡吧,晚點再聊!", "時間也差不多了,先這樣吧~"];
|
||||
|
||||
// T37:房間自走推進的「應有間隔」。P1 階段先用固定值佔位;T22 已實作依氣氛/情緒/記憶/行為動態
|
||||
// 計算的 RoomIntervalService,本常數留給尚未接上動態間隔的呼叫端(T8 會把排程器改為消費
|
||||
// RoomIntervalService 的計算結果,屆時本常數即可淘汰)。
|
||||
export const ROOM_ADVANCE_INTERVAL_MS = 30_000;
|
||||
// T37 排程 tick 週期(node-cron 表達式):遠小於 ROOM_ADVANCE_INTERVAL_MS,確保到期後很快被掃到。
|
||||
export const ROOM_SCHEDULER_TICK_CRON = "*/10 * * * * *";
|
||||
|
||||
// T22 動態推進間隔的上下限初值(第 5 輪決定):熱度最高時貼下限、完全空轉時貼上限。
|
||||
export const ROOM_ADVANCE_INTERVAL_LOWER_MS = 10_000; // 10 秒
|
||||
export const ROOM_ADVANCE_INTERVAL_UPPER_MS = 5 * 60_000; // 5 分鐘
|
||||
|
||||
// T40 隱私邊界:口風不緊原型在群聊中把私密(1:1)記憶說出來算「說溜嘴」,對使用者的信任/親密度
|
||||
// 給予懲罰(負值),代表這段本該只屬於兩人的私密內容被說給旁人聽,關係打了折扣。
|
||||
export const SLIP_INTIMACY_PENALTY = -3;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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 { 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 { buildCrossWorldNote, isCrossWork } from "./cross-world-note.js";
|
||||
|
||||
// T28 驗收:跨作品同房時生成上下文附上世界觀提示、關係值隨互動變化(初次同房中性起算)、
|
||||
// 記憶查得到該次相遇且標記來源為房間互動(非正典),矛盾偵測不會把它當成作品事實。
|
||||
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-crossword-user";
|
||||
const WORK_A = "test-crossword-work-a";
|
||||
const WORK_B = "test-crossword-work-b";
|
||||
const CHAR_A = "test-crossword-char-a";
|
||||
const CHAR_B = "test-crossword-char-b";
|
||||
const CHAR_A2 = "test-crossword-char-a2"; // 與 CHAR_A 同作品,作對照組
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.episodicMemory.deleteMany({ where: { characterId: { in: [CHAR_A, CHAR_B, CHAR_A2] } } });
|
||||
await prisma.client.characterRelationship.deleteMany({ where: { characterId: { in: [CHAR_A, CHAR_B, CHAR_A2] } } });
|
||||
await prisma.client.room.deleteMany({ where: { userId } });
|
||||
await prisma.client.character.deleteMany({ where: { id: { in: [CHAR_A, CHAR_B, CHAR_A2] } } });
|
||||
await prisma.client.work.deleteMany({ where: { id: { in: [WORK_A, WORK_B] } } });
|
||||
await prisma.client.user.deleteMany({ where: { id: userId } });
|
||||
});
|
||||
|
||||
async function createCharacter(id: string, workId: string, formalName: string) {
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id,
|
||||
workId,
|
||||
source: "EXISTING_WORK",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("跨作品混房(T28)", () => {
|
||||
it("isCrossWork/buildCrossWorldNote:純函式的判定邏輯", () => {
|
||||
expect(isCrossWork("work-a", "work-b")).toBe(true);
|
||||
expect(isCrossWork("work-a", "work-a")).toBe(false);
|
||||
expect(isCrossWork(null, null)).toBe(false); // 皆為原創(無掛作品),不算跨作品
|
||||
expect(isCrossWork("work-a", null)).toBe(true);
|
||||
|
||||
const note = buildCrossWorldNote("小乙", "測試作品乙");
|
||||
expect(note).toContain("小乙");
|
||||
expect(note).toContain("測試作品乙");
|
||||
expect(note).toContain("不可以把TA的背景設定當成妳自己世界本來就知道的事");
|
||||
});
|
||||
|
||||
it("對來自不同作品的對象生成回覆時,實際生成流程正常運作(不拋錯、有內容);同作品對象亦同", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await prisma.client.work.create({ data: { id: WORK_A, title: "測試作品甲" } });
|
||||
await prisma.client.work.create({ data: { id: WORK_B, title: "測試作品乙" } });
|
||||
await createCharacter(CHAR_A, WORK_A, "小甲");
|
||||
await createCharacter(CHAR_B, WORK_B, "小乙");
|
||||
await createCharacter(CHAR_A2, WORK_A, "小甲二");
|
||||
|
||||
const room = await roomService.create(userId, [CHAR_A, CHAR_B, CHAR_A2], "GROUP");
|
||||
|
||||
// 小甲對小乙(不同作品)發言:內部會組出 crossWorldNote 並帶進 GenerationContext(見
|
||||
// RoomGenerationService,實際內容由上一個測試對純函式直接驗證),這裡確認整條路徑不受影響。
|
||||
const replyToB = await generation.generateReply(room, CHAR_A, CHAR_B, "小乙", "你也在這裡?", { now: clock.now() });
|
||||
expect(replyToB.text).toBeTruthy();
|
||||
|
||||
// 小甲對小甲二(同作品)發言:不會有 crossWorldNote,一樣正常生成。
|
||||
const replyToA2 = await generation.generateReply(room, CHAR_A, CHAR_A2, "小甲二", "欸你也在?", { now: clock.now() });
|
||||
expect(replyToA2.text).toBeTruthy();
|
||||
}, 20000);
|
||||
|
||||
it("初次同房從中性關係起算;房間固化後關係值累積;記憶標記為房間互動(非正典)", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await prisma.client.work.create({ data: { id: WORK_A, title: "測試作品甲" } });
|
||||
await prisma.client.work.create({ data: { id: WORK_B, title: "測試作品乙" } });
|
||||
await createCharacter(CHAR_A, WORK_A, "小甲");
|
||||
await createCharacter(CHAR_B, WORK_B, "小乙");
|
||||
|
||||
// 初次同房前:中性 50。
|
||||
expect(await characterRelationship.getAffinity(CHAR_A, CHAR_B)).toBe(50);
|
||||
|
||||
const room = await roomService.create(userId, [CHAR_A, CHAR_B], "GROUP");
|
||||
// 用高情緒強度+提及對方別名的內容,確保觸發既有固化規則(RoomConsolidationService 只留存
|
||||
// 高情緒或與自己有關的發言,平淡閒聊不特別留存——這是既有規則,本測試只是配合它)。
|
||||
await roomService.addTurn(room.id, CHAR_A, "小甲", "小乙!第一次遇到你,好開心!!!", clock.now());
|
||||
await roomService.addTurn(room.id, CHAR_B, "小乙", "小甲你也太誇張了吧哈哈哈!!!", clock.now());
|
||||
|
||||
await consolidation.consolidate(room.id);
|
||||
|
||||
// 固化後:關係值應從中性起算後累積(變高),雙向皆有變化。
|
||||
const affinityAB = await characterRelationship.getAffinity(CHAR_A, CHAR_B);
|
||||
const affinityBA = await characterRelationship.getAffinity(CHAR_B, CHAR_A);
|
||||
expect(affinityAB).toBeGreaterThan(50);
|
||||
expect(affinityBA).toBeGreaterThan(50);
|
||||
|
||||
// 記憶查得到該次相遇,來源標記為房間互動(非正典)。
|
||||
const memoriesA = await prisma.client.episodicMemory.findMany({ where: { characterId: CHAR_A } });
|
||||
expect(memoriesA.length).toBeGreaterThan(0);
|
||||
expect(memoriesA.every((m) => m.source === "INTERACTION")).toBe(true);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
// T28:不同作品角色相遇時的世界觀提示——不是自己原本世界就認識的存在,禁止把對方設定當成自身
|
||||
// 世界既有知識,只能透過房內實際發生過的互動認識對方。純函式,方便直接單元測試。
|
||||
export function buildCrossWorldNote(speakerLabel: string, speakerWorkTitle: string | null): string {
|
||||
return `${speakerLabel}來自不同的作品世界(${speakerWorkTitle ?? "未知的世界"}),不是妳原本故事裡就認識的人。妳對TA的認識僅限於這個房間裡實際發生過的對話,不可以把TA的背景設定當成妳自己世界本來就知道的事,請以「第一次遇到不同世界的人」的態度應對。`;
|
||||
}
|
||||
|
||||
// 兩個角色的 workId 是否視為「不同作品」——皆為 null(都沒有掛作品)不算不同,只有明確不同的
|
||||
// 已知 workId 才算跨作品。
|
||||
export function isCrossWork(workIdA: string | null, workIdB: string | null): boolean {
|
||||
if (workIdA === null && workIdB === null) return false;
|
||||
return workIdA !== workIdB;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { TaskType } from "@kokorone/db";
|
||||
|
||||
export interface DelegateTemplate {
|
||||
line: string; // 寫入 RoomTurn 的台詞
|
||||
taskContent: string; // 對應建立的 Task.content
|
||||
taskType: TaskType;
|
||||
}
|
||||
|
||||
// T7:DELEGATE 行為的台詞與對應委託內容一對一固定配對(同 LEAVE 行為的 LEAVE_LINES 作法),
|
||||
// 不透過 LLM 自由生成再回頭解析——房內委託是「程式決定要不要委託、委託什麼」,LLM 自由生成的文字
|
||||
// 難以穩定解析出明確的 TaskType 與內容,固定配對能保證每次都可靠地转成 Task。
|
||||
export const DELEGATE_TEMPLATES: DelegateTemplate[] = [
|
||||
{ line: "欸,我來去幫大家查一下附近有什麼好吃的,等等回報!", taskContent: "查附近有什麼好吃的", taskType: "QUERY_ORGANIZE" },
|
||||
{ line: "我來把剛剛聊到的重點整理一下,晚點給你們看。", taskContent: "整理剛剛聊到的重點", taskType: "QUERY_ORGANIZE" },
|
||||
{ line: "這件事交給我,我去處理一下、之後跟你們說結果。", taskContent: "處理剛剛提到的待辦事項", taskType: "TODO_TRACKING" },
|
||||
{ line: "我來留意一下這件事的後續,有進展馬上講。", taskContent: "追蹤剛剛提到的事情後續", taskType: "TODO_TRACKING" },
|
||||
];
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterLobbyEligibleCandidates, LOBBY_ALLOWED_TURN_KINDS, LOBBY_SPEAKING_THRESHOLD } from "./lobby-behavior-params.js";
|
||||
import { SPEAKING_THRESHOLD } from "./constants.js";
|
||||
|
||||
describe("大廳專屬自走參數(T27)", () => {
|
||||
it("LOBBY_SPEAKING_THRESHOLD 高於一般房間門檻,LOBBY_ALLOWED_TURN_KINDS 不含 LEAVE", () => {
|
||||
expect(LOBBY_SPEAKING_THRESHOLD).toBeGreaterThan(SPEAKING_THRESHOLD);
|
||||
expect(LOBBY_ALLOWED_TURN_KINDS).not.toContain("LEAVE");
|
||||
expect(LOBBY_ALLOWED_TURN_KINDS).toEqual(expect.arrayContaining(["TALK", "ACTION", "DELEGATE"]));
|
||||
});
|
||||
|
||||
it("同作品的角色互相視為候選,即使關係普通、情緒平靜", () => {
|
||||
const candidates = [
|
||||
{ characterId: "a", workId: "work-1", emotionIntensity: 0.1 },
|
||||
{ characterId: "b", workId: "work-1", emotionIntensity: 0.1 },
|
||||
{ characterId: "c", workId: "work-2", emotionIntensity: 0.1 },
|
||||
];
|
||||
const eligible = filterLobbyEligibleCandidates(candidates, () => 50);
|
||||
expect(eligible.map((c) => c.characterId).sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("關係夠近(affinity 達門檻)即使不同作品也視為候選", () => {
|
||||
const candidates = [
|
||||
{ characterId: "a", workId: "work-1", emotionIntensity: 0.1 },
|
||||
{ characterId: "b", workId: "work-2", emotionIntensity: 0.1 },
|
||||
];
|
||||
const eligible = filterLobbyEligibleCandidates(candidates, () => 90);
|
||||
expect(eligible.map((c) => c.characterId).sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("情緒高漲者獨自也視為候選,不需要其他角色陪襯", () => {
|
||||
const candidates = [
|
||||
{ characterId: "a", workId: "work-1", emotionIntensity: 0.9 },
|
||||
{ characterId: "b", workId: "work-2", emotionIntensity: 0.1 },
|
||||
];
|
||||
const eligible = filterLobbyEligibleCandidates(candidates, () => 0);
|
||||
expect(eligible.map((c) => c.characterId)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("不同作品、關係普通、情緒平靜:都不列入候選", () => {
|
||||
const candidates = [
|
||||
{ characterId: "a", workId: "work-1", emotionIntensity: 0.1 },
|
||||
{ characterId: "b", workId: "work-2", emotionIntensity: 0.1 },
|
||||
];
|
||||
const eligible = filterLobbyEligibleCandidates(candidates, () => 50);
|
||||
expect(eligible).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { RoomTurnKind } from "@kokorone/db";
|
||||
import { SPEAKING_THRESHOLD } from "./constants.js";
|
||||
|
||||
// T27:大廳套用較高的發言門檻與更長的動態間隔上限,避免長時間觀察出現連續刷話;
|
||||
// 實際的自走行為引擎(T6 擴充行為種類、T8 不降頻排程、T22 動態間隔計算)尚未把 LOBBY 房納入
|
||||
// 推進範圍,這裡先備妥常數與純函式,供這幾項後續直接消費,不需要重新設計介面。
|
||||
export const LOBBY_SPEAKING_THRESHOLD = SPEAKING_THRESHOLD * 2; // 大廳話少一點,門檻拉高一倍
|
||||
export const LOBBY_ADVANCE_INTERVAL_MAX_MS = 10 * 60 * 1000; // 一般房間(T22)上限 5 分鐘,大廳拉到 10 分鐘
|
||||
|
||||
// 大廳只允許 TALK/ACTION/DELEGATE:行程轉忙或睡眠只改角色狀態(見 T1 deriveLobbyPresenceState),
|
||||
// 不會產生 LEAVE 把角色移出大廳在場名單。
|
||||
export const LOBBY_ALLOWED_TURN_KINDS: readonly RoomTurnKind[] = ["TALK", "ACTION", "DELEGATE"];
|
||||
|
||||
const HIGH_EMOTION_INTENSITY_THRESHOLD = 0.7;
|
||||
|
||||
export interface LobbyCandidate {
|
||||
characterId: string;
|
||||
workId: string | null;
|
||||
emotionIntensity: number; // 0~1,情緒引擎目前主導情緒的強度
|
||||
}
|
||||
|
||||
// T27:把大廳候選發言者限縮為「彼此關係近、同作品、或情緒高漲」的少數角色。
|
||||
// affinityLookup 由呼叫端提供(通常是 CharacterRelationshipService.getAffinity),本函式本身
|
||||
// 不查資料庫,方便單元測試以固定輸入驗證篩選邏輯。
|
||||
export function filterLobbyEligibleCandidates(
|
||||
candidates: LobbyCandidate[],
|
||||
affinityLookup: (characterId: string, otherCharacterId: string) => number,
|
||||
closeAffinityThreshold = 70,
|
||||
): LobbyCandidate[] {
|
||||
return candidates.filter((candidate) => {
|
||||
if (candidate.emotionIntensity >= HIGH_EMOTION_INTENSITY_THRESHOLD) return true;
|
||||
return candidates.some((other) => {
|
||||
if (other.characterId === candidate.characterId) return false;
|
||||
if (candidate.workId !== null && candidate.workId === other.workId) return true;
|
||||
return affinityLookup(candidate.characterId, other.characterId) >= closeAffinityThreshold;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { ControllableClock } from "../clock/controllable-clock.js";
|
||||
import { LobbyService } from "./lobby.service.js";
|
||||
import { RoomEventsService } from "./room-events.service.js";
|
||||
|
||||
// T24 驗收:任一時刻每個角色只有一筆未離場 RoomMember;進房後大廳在場名單少一人、離房(房間關閉)後自動回到大廳。
|
||||
// 沿用已套用過 migration 的開發用 dev.db,測試資料一律以 test- 前綴命名並在 afterEach 清理。
|
||||
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-18T12:00:00.000Z");
|
||||
const roomEvents = new RoomEventsService(new EventEmitter2());
|
||||
const lobby = new LobbyService(prisma, clock, roomEvents);
|
||||
|
||||
const userId = "test-lobby-user";
|
||||
const characterIdA = "test-lobby-char-a";
|
||||
const characterIdB = "test-lobby-char-b";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.roomMember.deleteMany({ where: { characterId: { in: [characterIdA, characterIdB] } } });
|
||||
await prisma.client.room.deleteMany({ where: { userId } });
|
||||
await prisma.client.character.deleteMany({ where: { id: { in: [characterIdA, characterIdB] } } });
|
||||
await prisma.client.user.deleteMany({ where: { id: userId } });
|
||||
});
|
||||
|
||||
describe("LobbyService(T24)", () => {
|
||||
it("任一時刻每個角色只有一筆未離場 RoomMember;進房後大廳名單少一人、房間關閉後自動回到大廳", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試大廳使用者" } });
|
||||
await prisma.client.character.createMany({
|
||||
data: [characterIdA, characterIdB].map((id) => ({
|
||||
id,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "TSUNDERE",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
})),
|
||||
});
|
||||
|
||||
// dev.db 為共用開發資料庫,可能已有其他角色(種子資料/其他冒煙測試殘留),syncMembership
|
||||
// 依規格會把「所有」未在場角色都掃進大廳,故底下一律用「是否包含」而非「精確相等」斷言,
|
||||
// 只驗證這兩個測試角色本身的在場狀態轉移,不假設大廳當下的完整名單只有這兩人。
|
||||
await lobby.syncMembership(userId);
|
||||
const lobbyRoom = await lobby.getOrCreateLobby(userId);
|
||||
const rosterIds = async () =>
|
||||
(await prisma.client.roomMember.findMany({ where: { roomId: lobbyRoom.id, leftAt: null } })).map(
|
||||
(m) => m.characterId,
|
||||
);
|
||||
|
||||
let lobbyRoster = await rosterIds();
|
||||
expect(lobbyRoster).toContain(characterIdA);
|
||||
expect(lobbyRoster).toContain(characterIdB);
|
||||
|
||||
const targetRoom = await prisma.client.room.create({ data: { userId, mode: "GROUP" } });
|
||||
await lobby.enterRoom(characterIdA, targetRoom.id);
|
||||
|
||||
lobbyRoster = await rosterIds();
|
||||
expect(lobbyRoster).not.toContain(characterIdA);
|
||||
expect(lobbyRoster).toContain(characterIdB);
|
||||
|
||||
const activeRowsForA = await prisma.client.roomMember.findMany({ where: { characterId: characterIdA, leftAt: null } });
|
||||
expect(activeRowsForA).toHaveLength(1);
|
||||
expect(activeRowsForA[0].roomId).toBe(targetRoom.id);
|
||||
|
||||
await lobby.closeRoomAndReturnMembers(targetRoom.id);
|
||||
lobbyRoster = await rosterIds();
|
||||
expect(lobbyRoster).toContain(characterIdA);
|
||||
expect(lobbyRoster).toContain(characterIdB);
|
||||
});
|
||||
|
||||
it("closeRoomAndReturnMembers 對大廳本身與未曾登記在場的既有房間安全地不做任何事", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試大廳使用者2" } });
|
||||
const lobbyRoom = await lobby.getOrCreateLobby(userId);
|
||||
await expect(lobby.closeRoomAndReturnMembers(lobbyRoom.id)).resolves.toBeUndefined();
|
||||
|
||||
const legacyRoom = await prisma.client.room.create({ data: { userId, mode: "GROUP" } });
|
||||
await expect(lobby.closeRoomAndReturnMembers(legacyRoom.id)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import type { Room as PrismaRoom } from "@kokorone/db";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { CLOCK, type Clock } from "../clock/clock.js";
|
||||
import { RoomEventsService } from "./room-events.service.js";
|
||||
|
||||
// T24:大廳常駐房與在場流轉。每位使用者恆有唯一一間 mode=LOBBY 的房間,status 永久 ACTIVE、
|
||||
// 不套用自動關房(T8 的自動關房掃描必須排除 LOBBY,見該項);所有未在其他房間在場的角色以
|
||||
// RoomMember 記在這間房。角色進房/離房只是把 activeCharacterId 從大廳那一筆搬到目標房那一筆
|
||||
// (或搬回來),R5「角色不可跨房」由 T2 的 RoomMember.activeCharacterId 唯一索引在 DB 層擋下。
|
||||
@Injectable()
|
||||
export class LobbyService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(CLOCK) private readonly clock: Clock,
|
||||
private readonly roomEvents: RoomEventsService,
|
||||
) {}
|
||||
|
||||
// T10:member 事件的序號沿用當下 turnCounter(不落地、盡力而為,見 RoomEventsService 註解)。
|
||||
private async currentTurnCounter(roomId: string): Promise<number> {
|
||||
const last = await this.prisma.client.roomTurn.findFirst({ where: { roomId }, orderBy: { seq: "desc" } });
|
||||
return (last?.seq ?? -1) + 1;
|
||||
}
|
||||
|
||||
async getOrCreateLobby(userId: string): Promise<PrismaRoom> {
|
||||
const existing = await this.prisma.client.room.findFirst({ where: { userId, mode: "LOBBY" } });
|
||||
if (existing) return existing;
|
||||
return this.prisma.client.room.create({
|
||||
data: { userId, mode: "LOBBY", status: "ACTIVE", autoAdvanceEnabled: false },
|
||||
});
|
||||
}
|
||||
|
||||
// 讓「目前沒有在任何房間在場」的角色補進大廳;已在其他房間在場(含已在大廳)的角色不重複處理。
|
||||
async syncMembership(userId: string): Promise<void> {
|
||||
const lobby = await this.getOrCreateLobby(userId);
|
||||
const [allCharacters, activeMemberships] = await Promise.all([
|
||||
this.prisma.client.character.findMany({ select: { id: true } }),
|
||||
this.prisma.client.roomMember.findMany({ where: { activeCharacterId: { not: null } }, select: { characterId: true } }),
|
||||
]);
|
||||
const alreadyActive = new Set(activeMemberships.map((m) => m.characterId));
|
||||
const missing = allCharacters.filter((c) => !alreadyActive.has(c.id));
|
||||
if (missing.length === 0) return;
|
||||
|
||||
await this.prisma.client.roomMember.createMany({
|
||||
data: missing.map((c) => ({ roomId: lobby.id, characterId: c.id, activeCharacterId: c.id })),
|
||||
});
|
||||
}
|
||||
|
||||
// 角色從目前所在房間(通常是大廳)搬進 targetRoomId;已經在 targetRoomId 則視為已完成(幂等)。
|
||||
async enterRoom(characterId: string, targetRoomId: string, now?: Date): Promise<void> {
|
||||
const effectiveNow = now ?? this.clock.now();
|
||||
const current = await this.prisma.client.roomMember.findFirst({ where: { activeCharacterId: characterId } });
|
||||
if (current?.roomId === targetRoomId) return;
|
||||
|
||||
await this.prisma.client.$transaction(async (tx) => {
|
||||
if (current) {
|
||||
await tx.roomMember.update({
|
||||
where: { id: current.id },
|
||||
data: { leftAt: effectiveNow, leaveReason: "ENTER_ROOM", activeCharacterId: null },
|
||||
});
|
||||
}
|
||||
await tx.roomMember.create({
|
||||
data: { roomId: targetRoomId, characterId, activeCharacterId: characterId },
|
||||
});
|
||||
});
|
||||
|
||||
if (current) {
|
||||
this.roomEvents.emit(current.roomId, {
|
||||
type: "member",
|
||||
seq: await this.currentTurnCounter(current.roomId),
|
||||
data: { characterId, action: "left" },
|
||||
});
|
||||
}
|
||||
this.roomEvents.emit(targetRoomId, {
|
||||
type: "member",
|
||||
seq: await this.currentTurnCounter(targetRoomId),
|
||||
data: { characterId, action: "joined" },
|
||||
});
|
||||
}
|
||||
|
||||
// 角色離開目前所在房間,補回大廳一筆在場紀錄;若目前所在房間本身就是大廳,視為已完成(幂等)。
|
||||
async returnToLobby(characterId: string, userId: string, now?: Date, leaveReason = "LEFT_ROOM"): Promise<void> {
|
||||
const effectiveNow = now ?? this.clock.now();
|
||||
const lobby = await this.getOrCreateLobby(userId);
|
||||
const current = await this.prisma.client.roomMember.findFirst({ where: { activeCharacterId: characterId } });
|
||||
if (current?.roomId === lobby.id) return;
|
||||
|
||||
await this.prisma.client.$transaction(async (tx) => {
|
||||
if (current) {
|
||||
await tx.roomMember.update({
|
||||
where: { id: current.id },
|
||||
data: { leftAt: effectiveNow, leaveReason, activeCharacterId: null },
|
||||
});
|
||||
}
|
||||
await tx.roomMember.create({
|
||||
data: { roomId: lobby.id, characterId, activeCharacterId: characterId },
|
||||
});
|
||||
});
|
||||
|
||||
if (current) {
|
||||
this.roomEvents.emit(current.roomId, {
|
||||
type: "member",
|
||||
seq: await this.currentTurnCounter(current.roomId),
|
||||
data: { characterId, action: "left" },
|
||||
});
|
||||
}
|
||||
this.roomEvents.emit(lobby.id, {
|
||||
type: "member",
|
||||
seq: await this.currentTurnCounter(lobby.id),
|
||||
data: { characterId, action: "joined" },
|
||||
});
|
||||
}
|
||||
|
||||
// T6:角色離開「指定的」房間並回大廳——與 returnToLobby() 不同之處在於明確帶入 roomId,不依賴
|
||||
// activeCharacterId 找出目前所在房間。既有 room.service.ts create() 對直接帶入 characterIds 建立
|
||||
// 的房間(K 群組沿用至今的舊式 GROUP/SELF_CHAT 測試房)刻意不設 activeCharacterId(見該處註解,
|
||||
// 讓同一角色可同時存在於多個測試房間),若 T6 的自走 LEAVE 沿用 returnToLobby() 的
|
||||
// activeCharacterId 查找方式會找不到「目前這個房間」的在場列,離開會失敗。改以 roomId+characterId
|
||||
// 直接鎖定要離開的那一列;回大廳的登記採「先查再寫」,避免與可能已存在(例如 syncMembership 誤判
|
||||
// 舊式房間角色為未在場而先建立的)大廳列衝突到 activeCharacterId 唯一索引。
|
||||
async leaveSpecificRoom(characterId: string, roomId: string, userId: string, now?: Date, leaveReason = "LEFT_ROOM"): Promise<void> {
|
||||
const effectiveNow = now ?? this.clock.now();
|
||||
const lobby = await this.getOrCreateLobby(userId);
|
||||
if (roomId === lobby.id) return;
|
||||
|
||||
const currentInRoom = await this.prisma.client.roomMember.findFirst({ where: { roomId, characterId, leftAt: null } });
|
||||
if (!currentInRoom) return; // 幂等:已經不在這個房間了
|
||||
|
||||
await this.prisma.client.$transaction(async (tx) => {
|
||||
await tx.roomMember.update({
|
||||
where: { id: currentInRoom.id },
|
||||
data: { leftAt: effectiveNow, leaveReason, activeCharacterId: null },
|
||||
});
|
||||
const alreadyActiveElsewhere = await tx.roomMember.findFirst({ where: { activeCharacterId: characterId } });
|
||||
if (!alreadyActiveElsewhere) {
|
||||
await tx.roomMember.create({ data: { roomId: lobby.id, characterId, activeCharacterId: characterId } });
|
||||
}
|
||||
});
|
||||
|
||||
this.roomEvents.emit(roomId, {
|
||||
type: "member",
|
||||
seq: await this.currentTurnCounter(roomId),
|
||||
data: { characterId, action: "left" },
|
||||
});
|
||||
this.roomEvents.emit(lobby.id, {
|
||||
type: "member",
|
||||
seq: await this.currentTurnCounter(lobby.id),
|
||||
data: { characterId, action: "joined" },
|
||||
});
|
||||
}
|
||||
|
||||
// 房間關閉時,把仍在場的每一位成員都送回(房主使用者的)大廳。對 LOBBY 房本身不生效
|
||||
// (大廳永不關閉);對從未有人以 activeCharacterId 在場登記過的房間(例如既有 K 群組
|
||||
// 自聊/群聊測試房)安全地什麼都不做。
|
||||
async closeRoomAndReturnMembers(roomId: string, now?: Date): Promise<void> {
|
||||
const room = await this.prisma.client.room.findUnique({
|
||||
where: { id: roomId },
|
||||
include: { members: { where: { activeCharacterId: { not: null } } } },
|
||||
});
|
||||
if (!room || room.mode === "LOBBY") return;
|
||||
|
||||
for (const member of room.members) {
|
||||
await this.returnToLobby(member.characterId, room.userId, now, "ROOM_CLOSED");
|
||||
}
|
||||
}
|
||||
|
||||
// T19-P1/T20-P1 最小可用大廳畫面所需的簡化快照:在場名單+進行中房間清單。
|
||||
// 正式的完整大廳聚合端點(作品分組、角色狀態、各房未讀數)是 T4(P2),屆時取代本方法的呼叫端。
|
||||
async getLobbySnapshot(userId: string): Promise<{
|
||||
lobbyRoomId: string;
|
||||
roster: string[];
|
||||
activeRooms: { id: string; mode: string; topicSeed: string | null; memberCharacterIds: string[] }[];
|
||||
}> {
|
||||
await this.syncMembership(userId);
|
||||
const lobby = await this.getOrCreateLobby(userId);
|
||||
const [lobbyMembers, activeRooms] = await Promise.all([
|
||||
this.prisma.client.roomMember.findMany({ where: { roomId: lobby.id, leftAt: null } }),
|
||||
this.prisma.client.room.findMany({
|
||||
where: { userId, status: "ACTIVE", mode: { not: "LOBBY" } },
|
||||
include: { members: { where: { leftAt: null } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
lobbyRoomId: lobby.id,
|
||||
roster: lobbyMembers.map((m) => m.characterId),
|
||||
activeRooms: activeRooms.map((room) => ({
|
||||
id: room.id,
|
||||
mode: room.mode,
|
||||
topicSeed: room.topicSeed,
|
||||
memberCharacterIds: room.members.map((m) => m.characterId),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import { PossessionService } from "./possession.service.js";
|
||||
import { InvitationService } from "../lobby/invitation.service.js";
|
||||
|
||||
interface EnterBody {
|
||||
userId: string;
|
||||
characterId: string;
|
||||
now?: string;
|
||||
}
|
||||
|
||||
interface ExitBody {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
interface SpeakBody {
|
||||
userId: string;
|
||||
text: string;
|
||||
now?: string;
|
||||
}
|
||||
|
||||
interface DecideBody {
|
||||
accept: boolean;
|
||||
now?: string;
|
||||
}
|
||||
|
||||
// R8/T43 人格套用:套用/退出、以角色身分在房內發話、待決邀請(房內邀請+大廳呼叫)的查詢與
|
||||
// 決定——集中在這一個 controller,因為這些都是「套用」這個橫切狀態衍生出的操作,不屬於任何單一
|
||||
// 既有 controller(room/canon/epilogue)的既有職責範圍。
|
||||
@Controller("possession")
|
||||
export class PossessionController {
|
||||
constructor(
|
||||
private readonly possession: PossessionService,
|
||||
private readonly invitation: InvitationService,
|
||||
) {}
|
||||
|
||||
@Get("status")
|
||||
async getStatus(@Query("userId") userId: string) {
|
||||
return this.possession.getStatus(userId);
|
||||
}
|
||||
|
||||
@Post("enter")
|
||||
async enter(@Body() body: EnterBody) {
|
||||
return this.possession.enter(body.userId, body.characterId, body.now ? new Date(body.now) : undefined);
|
||||
}
|
||||
|
||||
@Post("exit")
|
||||
async exit(@Body() body: ExitBody) {
|
||||
return this.possession.exit(body.userId);
|
||||
}
|
||||
|
||||
@Post(":roomId/message")
|
||||
async speak(@Param("roomId") roomId: string, @Body() body: SpeakBody) {
|
||||
return this.possession.speakAsPossessed(body.userId, roomId, body.text, body.now ? new Date(body.now) : undefined);
|
||||
}
|
||||
|
||||
@Get("pending-invitations")
|
||||
async listPendingInvitations(@Query("userId") userId: string) {
|
||||
const status = await this.possession.getStatus(userId);
|
||||
if (!status.possessingCharacterId) return [];
|
||||
return this.invitation.listPendingFor(status.possessingCharacterId);
|
||||
}
|
||||
|
||||
@Post("invitations/:invitationId/decide")
|
||||
async decideInvitation(@Param("invitationId") invitationId: string, @Body() body: DecideBody) {
|
||||
await this.invitation.decideAsPossessed(invitationId, body.accept, body.now ? new Date(body.now) : undefined);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get("pending-calls")
|
||||
async listPendingCalls(@Query("userId") userId: string) {
|
||||
return this.possession.listPendingCalls(userId);
|
||||
}
|
||||
|
||||
@Post("lobby-calls/:pendingCallId/decide")
|
||||
async decideLobbyCall(@Param("pendingCallId") pendingCallId: string, @Body() body: DecideBody) {
|
||||
return this.possession.decidePendingCall(pendingCallId, body.accept, body.now ? new Date(body.now) : undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
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 { 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 { RoomConsolidationService } from "./room-consolidation.service.js";
|
||||
import { BehaviorSelectionService } from "./behavior-selection.service.js";
|
||||
import { SelfChatService } from "./self-chat.service.js";
|
||||
import { PossessionService } from "./possession.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 { 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 { 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";
|
||||
|
||||
// T43 驗收:套用期間該角色不會自己發話(自走排程/回覆迴圈皆排除她)、其他角色的反應與她本人在場
|
||||
// 時一致、對話結束後她名下多了對應記憶、套用中被邀請由使用者決定且未出現系統代答、退出套用後恢復自走。
|
||||
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 consolidation = new RoomConsolidationService(prisma, roomService, new RuleBasedEmotionTagger(), characterRelationship);
|
||||
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-possession-user";
|
||||
const CHAR_A = "test-possession-char-a"; // 被套用的角色
|
||||
const CHAR_B = "test-possession-char-b";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.episodicMemory.deleteMany({ where: { characterId: { in: [CHAR_A, CHAR_B] } } });
|
||||
await prisma.client.characterRelationship.deleteMany({ where: { characterId: { in: [CHAR_A, CHAR_B] } } });
|
||||
await prisma.client.roomInvitation.deleteMany({ where: { characterId: CHAR_A } });
|
||||
await prisma.client.roomTurn.deleteMany({ where: { room: { userId } } });
|
||||
await prisma.client.roomMember.deleteMany({ where: { characterId: { in: [CHAR_A, CHAR_B] } } });
|
||||
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 } });
|
||||
});
|
||||
|
||||
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("人格套用(T43)", () => {
|
||||
it("套用/退出:狀態正確反映;套用範圍不設門檻(buildStatus 未完成的角色也可套用)", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: CHAR_A,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "CANDIDATE", // 未完成,仍可套用
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
},
|
||||
});
|
||||
|
||||
expect((await possession.getStatus(userId)).possessingCharacterId).toBeNull();
|
||||
await possession.enter(userId, CHAR_A);
|
||||
expect((await possession.getStatus(userId)).possessingCharacterId).toBe(CHAR_A);
|
||||
expect(await possession.getPossessedCharacterIds()).toEqual(new Set([CHAR_A]));
|
||||
|
||||
await possession.exit(userId);
|
||||
expect((await possession.getStatus(userId)).possessingCharacterId).toBeNull();
|
||||
expect(await possession.getPossessedCharacterIds()).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("套用期間該角色不會自己發話(自走排程排除她);退出套用後恢復自走", 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());
|
||||
|
||||
await possession.enter(userId, CHAR_A);
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const result = await selfChat.advance(room.id, new Date(clock.now().getTime() + i * 1000));
|
||||
if (result.turn) {
|
||||
expect(result.turn.characterId).not.toBe(CHAR_A); // 套用中絕不會被自走選為發言者
|
||||
}
|
||||
}
|
||||
|
||||
await possession.exit(userId);
|
||||
let sawCharA = false;
|
||||
for (let i = 15; i < 40 && !sawCharA; i++) {
|
||||
const result = await selfChat.advance(room.id, new Date(clock.now().getTime() + i * 1000));
|
||||
if (result.turn?.characterId === CHAR_A) sawCharA = true;
|
||||
}
|
||||
expect(sawCharA).toBe(true); // 退出套用後恢復自走,終究會輪到她自己開口
|
||||
}, 30000);
|
||||
|
||||
it("套用中若房內只剩她一人,自走安靜一輪而不會把房間收尾關閉", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await createCharacter(CHAR_A, "小甲");
|
||||
const room = await selfChat.start(userId, [CHAR_A], "測試話題", clock.now());
|
||||
await possession.enter(userId, CHAR_A);
|
||||
|
||||
const result = await selfChat.advance(room.id, clock.now());
|
||||
expect(result.ended).toBe(false);
|
||||
const roomAfter = await roomService.get(room.id);
|
||||
expect(roomAfter.status).toBe("ACTIVE");
|
||||
expect(roomAfter.participants.has(CHAR_A)).toBe(true);
|
||||
});
|
||||
|
||||
it("以套用角色的身分在房內發話:其他在場角色的反應與她本人在場時一致;對話結束後她名下多了記憶", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await createCharacter(CHAR_A, "小甲");
|
||||
await createCharacter(CHAR_B, "小乙");
|
||||
const room = await roomService.create(userId, [CHAR_A, CHAR_B], "GROUP");
|
||||
await possession.enter(userId, CHAR_A);
|
||||
|
||||
mockProvider.enqueueResponse("哇,聽起來好棒!");
|
||||
// 三個正向關鍵字疊加,確保情緒強度超過 HIGH_EMOTION_THRESHOLD(0.6),讓固化一定會記下這句話,
|
||||
// 不受測試巧合影響。
|
||||
const herLine = "太開心了!好棒!太好了!";
|
||||
const result = await possession.speakAsPossessed(userId, room.id, herLine, clock.now());
|
||||
// 小乙沒有明確理由不回應時,既有 K-4 發言權機制仍可能判定她這輪不開口;只要有回應,內容必須
|
||||
// 來自小乙且不是套用者自己編的話(走既有 LLM 生成路徑),佐證「其他角色不知情,一律當她本人反應」。
|
||||
for (const speaker of result.speakers) {
|
||||
expect(speaker.characterId).toBe(CHAR_B);
|
||||
}
|
||||
|
||||
const roomDetail = await roomService.get(room.id);
|
||||
const herTurn = roomDetail.turns.find((t) => t.speakerId === CHAR_A);
|
||||
expect(herTurn?.content).toBe(herLine);
|
||||
|
||||
await consolidation.consolidate(room.id);
|
||||
const herMemories = await prisma.client.episodicMemory.findMany({ where: { characterId: CHAR_A } });
|
||||
expect(herMemories.some((m) => m.content.includes(herLine))).toBe(true); // 記憶照常記在她名下
|
||||
}, 20000);
|
||||
|
||||
it("套用中不可以自己的身分發話;不可在 SOLO 房以套用角色身分發話;沒有套用時無法以角色身分發話", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await createCharacter(CHAR_A, "小甲");
|
||||
await createCharacter(CHAR_B, "小乙");
|
||||
const groupRoom = await roomService.create(userId, [CHAR_A, CHAR_B], "GROUP");
|
||||
|
||||
await expect(possession.speakAsPossessed(userId, groupRoom.id, "測試", clock.now())).rejects.toThrow();
|
||||
|
||||
await possession.enter(userId, CHAR_A);
|
||||
await expect(possession.assertNotPossessing(userId)).rejects.toThrow();
|
||||
|
||||
const soloRoom = await roomService.create(userId, [CHAR_A], "SOLO", "測試");
|
||||
await expect(possession.speakAsPossessed(userId, soloRoom.id, "測試", clock.now())).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("套用範圍不設親密度門檻,且套用中的角色仍佔用 RoomMember 在場名額(不可跨房規則照舊不受影響)", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await createCharacter(CHAR_A, "小甲");
|
||||
await createCharacter(CHAR_B, "小乙");
|
||||
const room = await roomService.create(userId, [CHAR_A, CHAR_B], "GROUP");
|
||||
await lobbyService.enterRoom(CHAR_A, room.id, clock.now()); // 既有「不可跨房」的在場登記機制,套用不改變它
|
||||
await possession.enter(userId, CHAR_A);
|
||||
|
||||
const member = await prisma.client.roomMember.findFirst({ where: { characterId: CHAR_A, activeCharacterId: CHAR_A } });
|
||||
expect(member?.roomId).toBe(room.id); // 套用不改變既有的在場判定機制
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RoomService } from "./room.service.js";
|
||||
import { SpeakingRightService } from "./speaking-right.service.js";
|
||||
import { RoomGenerationService } from "./room-generation.service.js";
|
||||
import { LobbyService } from "./lobby.service.js";
|
||||
|
||||
export interface PossessionStatus {
|
||||
possessingCharacterId: string | null;
|
||||
}
|
||||
|
||||
// R8/T43 人格套用:使用者套用某個角色的身分——她的自走暫停(見 SpeakingRightService/
|
||||
// SelfChatService 對 getPossessedCharacterIds() 的排除邏輯),改由使用者輸入產生她的
|
||||
// RoomTurn;其他角色不知情,一律當她本人在場反應(沿用既有 RoomGenerationService,不特殊處理);
|
||||
// 記憶/關係變化照常記在她名下(沿用既有的以 RoomTurn 為底的固化機制,未特殊處理)。
|
||||
@Injectable()
|
||||
export class PossessionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly room: RoomService,
|
||||
private readonly speakingRight: SpeakingRightService,
|
||||
private readonly generation: RoomGenerationService,
|
||||
private readonly roomLobby: LobbyService,
|
||||
) {}
|
||||
|
||||
async getStatus(userId: string): Promise<PossessionStatus> {
|
||||
const user = await this.prisma.client.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
return { possessingCharacterId: user.possessingCharacterId };
|
||||
}
|
||||
|
||||
// 所有「自走/自動回覆」的候選者篩選都要排除目前被(任何使用者)套用中的角色——她的發言只能
|
||||
// 來自套用者的輸入,不能被 LLM/自走機制搶著幫她講話。
|
||||
async getPossessedCharacterIds(): Promise<Set<string>> {
|
||||
const users = await this.prisma.client.user.findMany({
|
||||
where: { possessingCharacterId: { not: null } },
|
||||
select: { possessingCharacterId: true },
|
||||
});
|
||||
return new Set(users.map((u) => u.possessingCharacterId!));
|
||||
}
|
||||
|
||||
async assertNotPossessing(userId: string): Promise<void> {
|
||||
const status = await this.getStatus(userId);
|
||||
if (status.possessingCharacterId) {
|
||||
throw new BadRequestException("套用角色期間不可以自己的身分發話,請先退出套用");
|
||||
}
|
||||
}
|
||||
|
||||
// 可套用範圍為任何角色(含日後談、跨作品、buildStatus 未完成者),不設門檻;直接切換到新角色
|
||||
// 等同先退出舊的再套用新的(單一使用者一次只能套用一位)。
|
||||
async enter(userId: string, characterId: string, now: Date = new Date()): Promise<PossessionStatus> {
|
||||
await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||
const alreadyPossessedBy = await this.prisma.client.user.findFirst({
|
||||
where: { possessingCharacterId: characterId, id: { not: userId } },
|
||||
});
|
||||
if (alreadyPossessedBy) {
|
||||
throw new BadRequestException("這個角色目前已被其他使用者套用中");
|
||||
}
|
||||
await this.prisma.client.user.update({ where: { id: userId }, data: { possessingCharacterId: characterId } });
|
||||
void now; // 保留參數對齊其餘服務簽名慣例(時鐘可控),本方法本身不需要用到時間
|
||||
return { possessingCharacterId: characterId };
|
||||
}
|
||||
|
||||
async exit(userId: string): Promise<PossessionStatus> {
|
||||
await this.prisma.client.user.update({ where: { id: userId }, data: { possessingCharacterId: null } });
|
||||
return { possessingCharacterId: null };
|
||||
}
|
||||
|
||||
// 使用者以套用中的角色身分在某個房間發話——沿用既有 RoomController.message() 對「一輪發言後
|
||||
// 誰會回應」的處理方式(decideSpeakers 找出想回應的其他在場角色逐位生成回覆),差別只在觸發者
|
||||
// 是她(characterId)而不是 USER_SPEAKER_ID;不能用在 SOLO/LOBBY 房——那兩種房型的「對象」
|
||||
// 就是使用者本人,套用她之後沒有其他人可以對話。
|
||||
async speakAsPossessed(
|
||||
userId: string,
|
||||
roomId: string,
|
||||
text: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<{ speakers: { characterId: string; text: string; score: number }[] }> {
|
||||
const status = await this.getStatus(userId);
|
||||
const characterId = status.possessingCharacterId;
|
||||
if (!characterId) {
|
||||
throw new BadRequestException("目前沒有套用任何角色,無法以角色身分發話");
|
||||
}
|
||||
|
||||
let room = await this.room.get(roomId);
|
||||
if (room.mode === "SOLO") {
|
||||
throw new BadRequestException("SOLO 房沒有其他角色可以對話,不能在這裡以套用角色的身分發話");
|
||||
}
|
||||
if (!room.participants.has(characterId)) {
|
||||
throw new NotFoundException("套用中的角色目前不在這個房間");
|
||||
}
|
||||
|
||||
const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId } });
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||
const speakerLabel = alias?.nickname || alias?.formalName || character.id;
|
||||
|
||||
await this.room.addTurn(roomId, characterId, speakerLabel, text, now);
|
||||
room = await this.room.get(roomId);
|
||||
|
||||
const possessedIds = await this.getPossessedCharacterIds();
|
||||
const speakers = await this.speakingRight.decideSpeakers(room, characterId, text, now, possessedIds);
|
||||
const replies: { characterId: string; text: string; score: number }[] = [];
|
||||
for (const candidate of speakers) {
|
||||
const reactingCharacter = await this.prisma.client.character.findUniqueOrThrow({ where: { id: candidate.characterId } });
|
||||
const reactingAlias = await this.prisma.client.characterAlias.findFirst({ where: { characterId: candidate.characterId } });
|
||||
const reactingLabel = reactingAlias?.nickname || reactingAlias?.formalName || reactingCharacter.id;
|
||||
const reply = await this.generation.generateReply(room, candidate.characterId, characterId, speakerLabel, text, { now });
|
||||
if (reply.text) {
|
||||
const turn = await this.room.addTurn(roomId, candidate.characterId, reactingLabel, reply.text, now);
|
||||
if (reply.usage) {
|
||||
await this.room.recordUsage(roomId, { ...reply.usage, turnSeq: turn.turnIndex });
|
||||
}
|
||||
room = await this.room.get(roomId);
|
||||
replies.push({ characterId: candidate.characterId, text: reply.text, score: candidate.score });
|
||||
}
|
||||
}
|
||||
return { speakers: replies };
|
||||
}
|
||||
|
||||
// T5 大廳呼叫時識別到的角色目前被套用中:不能讓人格系統代她判斷接受/婉拒,記一筆待決給套用者。
|
||||
// 待決的歸戶對象是「目前正在套用她的那個使用者」,不是打這通呼叫的使用者——兩者不一定相同
|
||||
// (呼叫者甚至可能不知道對方正被套用),故這裡自己查出套用者,不接受呼叫端傳入的 userId。
|
||||
async createPendingCall(characterId: string, text: string, now: Date = new Date()): Promise<void> {
|
||||
const possessor = await this.prisma.client.user.findFirstOrThrow({ where: { possessingCharacterId: characterId } });
|
||||
await this.prisma.client.pendingLobbyCall.create({ data: { userId: possessor.id, characterId, text, createdAt: now } });
|
||||
}
|
||||
|
||||
async listPendingCalls(userId: string) {
|
||||
const status = await this.getStatus(userId);
|
||||
if (!status.possessingCharacterId) return [];
|
||||
return this.prisma.client.pendingLobbyCall.findMany({
|
||||
where: { userId, characterId: status.possessingCharacterId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
async decidePendingCall(pendingCallId: string, accept: boolean, now: Date = new Date()) {
|
||||
const pending = await this.prisma.client.pendingLobbyCall.findUniqueOrThrow({ where: { id: pendingCallId } });
|
||||
await this.prisma.client.pendingLobbyCall.delete({ where: { id: pendingCallId } });
|
||||
if (!accept) return { roomId: null };
|
||||
|
||||
// 與一般呼叫接受時的入房方式一致(見 CallService.openRoom()):開一間 SOLO 房,再透過既有的
|
||||
// LobbyService.enterRoom() 登記在場(會自動處理「原本在哪個房間」的離場轉移)。
|
||||
const room = await this.room.create(pending.userId, [], "SOLO", pending.text);
|
||||
await this.roomLobby.enterRoom(pending.characterId, room.id, now);
|
||||
return { roomId: room.id };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { HIGH_EMOTION_THRESHOLD } from "../memory/constants.js";
|
||||
import { RoomService, USER_SPEAKER_ID, type Room, type RoomTurnRecord } from "./room.service.js";
|
||||
import { CharacterRelationshipService } from "./character-relationship.service.js";
|
||||
|
||||
async function isAboutCharacter(
|
||||
prisma: PrismaService,
|
||||
@@ -26,15 +27,29 @@ export class RoomConsolidationService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly room: RoomService,
|
||||
private readonly tagger: RuleBasedEmotionTagger,
|
||||
private readonly characterRelationship: CharacterRelationshipService,
|
||||
) {}
|
||||
|
||||
async consolidate(roomId: string): Promise<void> {
|
||||
const room = this.room.get(roomId);
|
||||
const room = await this.room.get(roomId);
|
||||
for (const characterId of room.participants.keys()) {
|
||||
await this.consolidateForCharacter(room, characterId);
|
||||
}
|
||||
this.room.end(roomId);
|
||||
this.room.clear(roomId);
|
||||
await this.accumulateRelationships(room);
|
||||
await this.room.end(roomId);
|
||||
await this.room.clear(roomId);
|
||||
}
|
||||
|
||||
// T28:初次同房從中性關係開始、之後每次固化都朝更熟悉一點小幅累積——對每一對「這場都有真的
|
||||
// 發言過」的角色(不含使用者本人),雙向各累積一次;跨作品/同作品一視同仁。
|
||||
private async accumulateRelationships(room: Room): Promise<void> {
|
||||
const speakerIds = [...new Set(room.turns.map((turn) => turn.speakerId).filter((id) => id !== USER_SPEAKER_ID))];
|
||||
for (let i = 0; i < speakerIds.length; i++) {
|
||||
for (let j = i + 1; j < speakerIds.length; j++) {
|
||||
await this.characterRelationship.recordInteraction(speakerIds[i], speakerIds[j]);
|
||||
await this.characterRelationship.recordInteraction(speakerIds[j], speakerIds[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async consolidateForCharacter(room: Room, characterId: string): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RoomEventsService, type RoomStreamEvent } from "./room-events.service.js";
|
||||
|
||||
// T10 驗收(服務層):訂閱者能收到對應房間的即時事件、取消訂閱後不再收到、不同房間互不干擾。
|
||||
// SSE 端到端行為(Last-Event-ID/?afterSeq= 重連補齊)已以真實 curl 對運行中伺服器驗證,見該項備註。
|
||||
describe("RoomEventsService(T10)", () => {
|
||||
it("訂閱者收到對應房間發出的事件;取消訂閱後不再收到", () => {
|
||||
const service = new RoomEventsService(new EventEmitter2());
|
||||
const received: RoomStreamEvent[] = [];
|
||||
const unsubscribe = service.subscribe("room-a", (event) => received.push(event));
|
||||
|
||||
service.emit("room-a", { type: "turn", seq: 0, data: { content: "hi" } });
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toEqual({ type: "turn", seq: 0, data: { content: "hi" } });
|
||||
|
||||
unsubscribe();
|
||||
service.emit("room-a", { type: "turn", seq: 1, data: { content: "bye" } });
|
||||
expect(received).toHaveLength(1); // 取消訂閱後不再收到
|
||||
});
|
||||
|
||||
it("不同房間的事件互不干擾", () => {
|
||||
const service = new RoomEventsService(new EventEmitter2());
|
||||
const receivedA: RoomStreamEvent[] = [];
|
||||
const receivedB: RoomStreamEvent[] = [];
|
||||
service.subscribe("room-a", (event) => receivedA.push(event));
|
||||
service.subscribe("room-b", (event) => receivedB.push(event));
|
||||
|
||||
service.emit("room-a", { type: "member", seq: 0, data: { characterId: "x", action: "joined" } });
|
||||
|
||||
expect(receivedA).toHaveLength(1);
|
||||
expect(receivedB).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
|
||||
// T10:房間 SSE 推送的即時(live)事件匯流排。以 RoomTurn.seq 為事件序號空間——
|
||||
// turn 事件用真正的 RoomTurn.seq(可經 GET /room/:roomId 的分頁機制完整回補,見 getTurnsSince);
|
||||
// member/closed 事件用當下的 turnCounter(單調遞增、與 turn 事件同一號碼軸),但這兩類事件本身
|
||||
// 不落地成 RoomTurn,斷線期間錯過的 member/closed 屬於盡力而為通知,不保證重連後補送——
|
||||
// 重連後呼叫端可另外呼叫 GET /room/:roomId 取得當下最新的在場名單與房間狀態以校正。
|
||||
export type RoomStreamEventType = "turn" | "member" | "closed";
|
||||
|
||||
export interface RoomStreamEvent {
|
||||
type: RoomStreamEventType;
|
||||
seq: number;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RoomEventsService {
|
||||
constructor(private readonly emitter: EventEmitter2) {}
|
||||
|
||||
private channel(roomId: string): string {
|
||||
return `room-stream.${roomId}`;
|
||||
}
|
||||
|
||||
emit(roomId: string, event: RoomStreamEvent): void {
|
||||
this.emitter.emit(this.channel(roomId), event);
|
||||
}
|
||||
|
||||
subscribe(roomId: string, handler: (event: RoomStreamEvent) => void): () => void {
|
||||
const channel = this.channel(roomId);
|
||||
this.emitter.on(channel, handler);
|
||||
return () => this.emitter.off(channel, handler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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 { 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";
|
||||
|
||||
// T9 回歸測試:generateReply() 必須把 Provider 回傳的 usage 原樣往上傳,不可在組裝 RoomReply 時遺漏
|
||||
// (曾發生過:回傳字面量只挑了 text/actions/address,漏了 usage,導致 GROUP 房用量永遠是 0)。
|
||||
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-19T12: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 userId = "test-room-gen-usage-user";
|
||||
const characterId = "test-room-gen-usage-char";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.room.deleteMany({ where: { userId } });
|
||||
await prisma.client.relationship.deleteMany({ where: { characterId, userId } });
|
||||
await prisma.client.character.deleteMany({ where: { id: characterId } });
|
||||
await prisma.client.user.deleteMany({ where: { id: userId } });
|
||||
});
|
||||
|
||||
describe("RoomGenerationService.generateReply(T9 回歸)", () => {
|
||||
it("回傳的 RoomReply 帶有 Provider 提供的 usage,可直接交給 RoomService.recordUsage", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: characterId,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
},
|
||||
});
|
||||
const room = await roomService.create(userId, [characterId], "GROUP");
|
||||
|
||||
const reply = await generation.generateReply(room, characterId, USER_SPEAKER_ID, "測試使用者", "哈囉", { now: clock.now() });
|
||||
|
||||
expect(reply.usage).toBeDefined();
|
||||
expect(reply.usage!.model).toBe("mock");
|
||||
expect(reply.usage!.promptTokens).toBeGreaterThan(0);
|
||||
|
||||
const turn = await roomService.addTurn(room.id, characterId, "測試角色", reply.text, clock.now());
|
||||
await roomService.recordUsage(room.id, { ...reply.usage!, turnSeq: turn.turnIndex });
|
||||
const summary = await roomService.getUsageSummary(room.id);
|
||||
expect(summary.generationCount).toBe(1);
|
||||
expect(summary.totalPromptTokens).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -3,16 +3,21 @@ import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
||||
import { toResponseStyle } from "../emotion/response-style.js";
|
||||
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||
import { RomanceService } from "../romance/romance.service.js";
|
||||
import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js";
|
||||
import { LLM_PROVIDER, type LLMProvider } from "../llm/llm-provider.js";
|
||||
import { hashToSeed } from "../llm/seeded-random.js";
|
||||
import type { GenerationContext, GeneratedResponse } from "../llm/types.js";
|
||||
import { GlobalPauseService } from "../llm/global-pause.service.js";
|
||||
import { generateWithResilience } from "../llm/generate-with-resilience.js";
|
||||
import { getArchetypeParams } from "../personality/archetype-params.js";
|
||||
import { resolveAddress } from "../personality/language-style.js";
|
||||
import { stageForIntimacy } from "../relationship/constants.js";
|
||||
import { CharacterRelationshipService } from "./character-relationship.service.js";
|
||||
import { buildCrossWorldNote, isCrossWork } from "./cross-world-note.js";
|
||||
import { USER_SPEAKER_ID, type Room } from "./room.service.js";
|
||||
import { GROUP_RESERVE_INTIMACY_PENALTY, TSUNDERE_EXTRA_GROUP_PENALTY } from "./constants.js";
|
||||
import { GROUP_RESERVE_INTIMACY_PENALTY, SLIP_INTIMACY_PENALTY, TSUNDERE_EXTRA_GROUP_PENALTY } from "./constants.js";
|
||||
import { computeWitnessReaction, isRomanticMoment } from "./witness-reaction.js";
|
||||
|
||||
export interface RoomReplyOptions {
|
||||
seed?: number;
|
||||
@@ -32,7 +37,9 @@ export class RoomGenerationService {
|
||||
private readonly relationship: RelationshipService,
|
||||
private readonly retriever: KeywordMemoryRetriever,
|
||||
private readonly characterRelationship: CharacterRelationshipService,
|
||||
private readonly romance: RomanceService,
|
||||
@Inject(LLM_PROVIDER) private readonly provider: LLMProvider,
|
||||
private readonly globalPause: GlobalPauseService,
|
||||
) {}
|
||||
|
||||
async generateReply(
|
||||
@@ -55,15 +62,41 @@ export class RoomGenerationService {
|
||||
let baseIntimacy: number;
|
||||
let trust: number;
|
||||
let address: string;
|
||||
let crossWorldNote: string | undefined;
|
||||
if (speakerId === USER_SPEAKER_ID) {
|
||||
const { relationship } = await this.relationship.getState(characterId, room.userId, now);
|
||||
baseIntimacy = relationship.intimacy;
|
||||
trust = relationship.trust;
|
||||
address = resolveAddress(speakerLabel, relationship.intimacy);
|
||||
|
||||
// T35 目擊者反應:使用者跟這位角色已到曖昧以上,房內其他角色不是空氣——各自依「自己跟使用者
|
||||
// 是不是也有戀愛心思」反應,調整與這位角色的關係值並留下事件紀錄。
|
||||
if (isRomanticMoment(relationship.romanceStage)) {
|
||||
const witnessIds = [...room.participants.keys()].filter((id) => id !== characterId);
|
||||
if (witnessIds.length > 0) {
|
||||
const targetAlias = await this.prisma.client.characterAlias.findFirst({ where: { characterId } });
|
||||
const targetLabel = targetAlias?.nickname || targetAlias?.formalName || characterId;
|
||||
for (const witnessId of witnessIds) {
|
||||
const witnessRomance = await this.romance.getState(witnessId, room.userId, now);
|
||||
const reaction = computeWitnessReaction(witnessRomance.romanceStage, targetLabel);
|
||||
await this.characterRelationship.recordWitnessReaction(witnessId, characterId, reaction.delta, reaction.description);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
baseIntimacy = await this.characterRelationship.getAffinity(characterId, speakerId);
|
||||
trust = baseIntimacy;
|
||||
address = speakerLabel;
|
||||
|
||||
// T28:對象是另一位角色且來自不同作品時,附上世界觀提示——不是自己原本世界就認識的存在,
|
||||
// 禁止把對方設定當成自身世界既有知識,只能透過房內互動認識對方。
|
||||
const speakerCharacter = await this.prisma.client.character.findUnique({
|
||||
where: { id: speakerId },
|
||||
include: { work: true },
|
||||
});
|
||||
if (speakerCharacter && isCrossWork(speakerCharacter.workId, character.workId)) {
|
||||
crossWorldNote = buildCrossWorldNote(speakerLabel, speakerCharacter.work?.title ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
// K-2 群聊矜持:實際餵給生成的親密度下修,讓既有的模板分層(intimacyTier)自然選到更收斂的回應;
|
||||
@@ -81,6 +114,12 @@ export class RoomGenerationService {
|
||||
excludePrivate,
|
||||
});
|
||||
|
||||
// T40 說溜嘴:口風不緊原型才可能檢索到私密記憶(見上方 excludePrivate),一旦真的取到,代表
|
||||
// 這段本該只屬於 1:1 的內容要在有旁人的房間裡說出來了——對使用者的信任/親密度給予懲罰。
|
||||
if (!excludePrivate && retrievedMemories.some((memory) => memory.isPrivate)) {
|
||||
await this.relationship.recordInteraction(characterId, room.userId, now, SLIP_INTIMACY_PENALTY);
|
||||
}
|
||||
|
||||
const history = room.turns.slice(-10).map((turn) => ({
|
||||
role: (turn.speakerId === USER_SPEAKER_ID ? "user" : "character") as "user" | "character",
|
||||
content: `${turn.speakerLabel}:${turn.content}`,
|
||||
@@ -104,9 +143,15 @@ export class RoomGenerationService {
|
||||
userInput,
|
||||
injectedMemoryIds: retrievedMemories.map((memory) => memory.id),
|
||||
seed,
|
||||
crossWorldNote,
|
||||
};
|
||||
|
||||
const generated = await this.provider.generate(context);
|
||||
return { text: `${address},${generated.text}`, actions: generated.actions, address };
|
||||
// T30:全域靜默暫停或重試全部失敗時回傳 null——這裡回傳空字串,呼叫端(room.controller.ts/
|
||||
// self-chat.service.ts)一律以「reply.text 是否有內容」決定要不要寫入 RoomTurn,不需要另外判斷。
|
||||
const generated = await generateWithResilience(this.provider, context, this.globalPause);
|
||||
if (!generated) {
|
||||
return { text: "", actions: [], address };
|
||||
}
|
||||
return { text: `${address},${generated.text}`, actions: generated.actions, usage: generated.usage, address };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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 { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { SleepDebtService } from "../schedule/sleep-debt.service.js";
|
||||
import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js";
|
||||
import { LobbyService } from "./lobby.service.js";
|
||||
import { RoomEventsService } from "./room-events.service.js";
|
||||
import { RoomService } from "./room.service.js";
|
||||
import { RoomIntervalService } from "./room-interval.service.js";
|
||||
import { ROOM_ADVANCE_INTERVAL_LOWER_MS, ROOM_ADVANCE_INTERVAL_UPPER_MS } from "./constants.js";
|
||||
|
||||
// T22 驗收:熱絡話題下間隔明顯縮短、空轉時明顯拉長,且計算依據可從 log 回推(本測試驗證數值行為,
|
||||
// log 內容本身以 curl/人工檢視後端 log 驗證)。
|
||||
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"); // 週三 13:00 台北,FREE 時段
|
||||
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 scheduleService = new ScheduleService(prisma);
|
||||
const sleepDebtService = new SleepDebtService(prisma, scheduleService);
|
||||
const retriever = new KeywordMemoryRetriever(prisma);
|
||||
const intervalService = new RoomIntervalService(prisma, emotionService, scheduleService, retriever, sleepDebtService);
|
||||
|
||||
const userId = "test-interval-user";
|
||||
const CHAR_A = "test-interval-char-a";
|
||||
const CHAR_B = "test-interval-char-b";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.room.deleteMany({ where: { userId } });
|
||||
await prisma.client.emotionState.deleteMany({ where: { characterId: { in: [CHAR_A, CHAR_B] } } });
|
||||
await prisma.client.episodicMemory.deleteMany({ where: { characterId: { in: [CHAR_A, CHAR_B] } } });
|
||||
await prisma.client.character.deleteMany({ where: { id: { in: [CHAR_A, CHAR_B] } } });
|
||||
await prisma.client.user.deleteMany({ where: { id: userId } });
|
||||
});
|
||||
|
||||
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("RoomIntervalService(T22)", () => {
|
||||
it("靜默 tick 一律貼上限,不計入熱度計算", () => {
|
||||
const result = intervalService.silentInterval("some-room-id");
|
||||
expect(result.silent).toBe(true);
|
||||
expect(result.intervalMs).toBe(ROOM_ADVANCE_INTERVAL_UPPER_MS);
|
||||
expect(result.heatScore).toBe(0);
|
||||
});
|
||||
|
||||
it("熱絡話題(密集發言+互相點名+高昂情緒+命中記憶):間隔明顯縮短,貼近下限", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await createCharacter(CHAR_A, "小甲");
|
||||
await createCharacter(CHAR_B, "小乙");
|
||||
await prisma.client.emotionState.create({ data: { characterId: CHAR_A, joy: 80, calm: 20 } });
|
||||
await prisma.client.emotionState.create({ data: { characterId: CHAR_B, joy: 80, calm: 20 } });
|
||||
await prisma.client.episodicMemory.create({
|
||||
data: { characterId: CHAR_A, content: "小乙的生日派對", occurredAt: clock.now(), emotionTag: "JOY", emotionIntensity: 0.8, source: "INTERACTION", weight: 1 },
|
||||
});
|
||||
|
||||
const room = await roomService.create(userId, [CHAR_A, CHAR_B], "GROUP");
|
||||
const base = clock.now();
|
||||
const turns = [
|
||||
{ speaker: CHAR_A, label: "小甲", content: "小乙,今天生日派對超好玩的!", at: 0 },
|
||||
{ speaker: CHAR_B, label: "小乙", content: "小甲你也太誇張了吧哈哈哈", at: 5_000 },
|
||||
{ speaker: CHAR_A, label: "小甲", content: "小乙我們下次還要再辦一次!", at: 10_000 },
|
||||
];
|
||||
for (const t of turns) {
|
||||
await roomService.addTurn(room.id, t.speaker, t.label, t.content, new Date(base.getTime() + t.at));
|
||||
}
|
||||
const hydrated = await roomService.get(room.id);
|
||||
|
||||
const result = await intervalService.computeInterval(hydrated, new Date(base.getTime() + 12_000));
|
||||
expect(result.silent).toBe(false);
|
||||
expect(result.heatScore).toBeGreaterThan(0.5);
|
||||
// 熱絡話題應明顯低於上下限中點——具體門檻與冷場情境的相對差異在下一個測試直接比較。
|
||||
expect(result.intervalMs).toBeLessThan((ROOM_ADVANCE_INTERVAL_LOWER_MS + ROOM_ADVANCE_INTERVAL_UPPER_MS) / 2);
|
||||
});
|
||||
|
||||
it("空轉話題(內容重複觸發 isStalling、情緒平靜、無記憶命中):間隔明顯拉長,貼近上限", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await createCharacter(CHAR_A, "小甲");
|
||||
await prisma.client.emotionState.create({ data: { characterId: CHAR_A, calm: 100 } });
|
||||
|
||||
const room = await roomService.create(userId, [CHAR_A], "GROUP");
|
||||
const base = clock.now();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await roomService.addTurn(room.id, CHAR_A, "小甲", "嗯。", new Date(base.getTime() + i * 60_000));
|
||||
}
|
||||
const hydrated = await roomService.get(room.id);
|
||||
|
||||
const result = await intervalService.computeInterval(hydrated, new Date(base.getTime() + 180_000));
|
||||
expect(result.factors.atmosphere).toBe(0);
|
||||
expect(result.heatScore).toBeLessThan(0.3);
|
||||
expect(result.intervalMs).toBeGreaterThan((ROOM_ADVANCE_INTERVAL_LOWER_MS + ROOM_ADVANCE_INTERVAL_UPPER_MS) / 2);
|
||||
});
|
||||
|
||||
it("T32:睡眠債活躍時對該角色加懲罰項,同樣的對話內容下間隔比沒有睡眠債時更長(回覆變慢)", 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: 0, endMinute: 1440, status: "FREE" },
|
||||
});
|
||||
const room = await roomService.create(userId, [CHAR_A], "GROUP");
|
||||
const base = clock.now();
|
||||
await roomService.addTurn(room.id, CHAR_A, "小甲", "今天天氣真好!", base);
|
||||
const hydrated = await roomService.get(room.id);
|
||||
|
||||
const without = await intervalService.computeInterval(hydrated, new Date(base.getTime() + 1_000));
|
||||
|
||||
await prisma.client.sleepDebt.create({ data: { characterId: CHAR_A, accumulatedMinutes: 90 } });
|
||||
const withDebt = await intervalService.computeInterval(hydrated, new Date(base.getTime() + 1_000));
|
||||
|
||||
expect(withDebt.factors.behavior).toBeLessThan(without.factors.behavior);
|
||||
expect(withDebt.intervalMs).toBeGreaterThan(without.intervalMs);
|
||||
|
||||
await prisma.client.sleepDebt.deleteMany({ where: { characterId: CHAR_A } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { log } from "@kokorone/shared";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
||||
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js";
|
||||
import { SleepDebtService, SLEEP_DEBT_ACTIVE_THRESHOLD_MINUTES } from "../schedule/sleep-debt.service.js";
|
||||
import { EMOTION_SPEAKING_MODIFIER, ROOM_ADVANCE_INTERVAL_LOWER_MS, ROOM_ADVANCE_INTERVAL_UPPER_MS, STALL_CHECK_WINDOW } from "./constants.js";
|
||||
import { isStalling } from "./stall-detection.js";
|
||||
import type { Room } from "./room.service.js";
|
||||
|
||||
export interface IntervalFactors {
|
||||
atmosphere: number; // 0~1:近期發言密度、互相點名、空轉判定
|
||||
emotion: number; // 0~1:在場角色情緒對發言意願的影響
|
||||
memory: number; // 0~1:目前話題是否命中既有記憶
|
||||
behavior: number; // 0~1:行程狀態與既有行為規則的活躍程度
|
||||
}
|
||||
|
||||
export interface IntervalResult {
|
||||
intervalMs: number;
|
||||
heatScore: number; // 0~1,四項因子加權後的熱度
|
||||
factors: IntervalFactors;
|
||||
silent: boolean; // T22:靜默 tick(沒有實際產生 RoomTurn)一律貼上限,不計入熱度計算
|
||||
}
|
||||
|
||||
const FACTOR_WEIGHTS = { atmosphere: 0.35, emotion: 0.25, memory: 0.2, behavior: 0.2 };
|
||||
|
||||
// 發言密度的參考間隔:近期發言平均間隔 ≦ 此值視為「很熱絡」(density 分數趨近 1)。
|
||||
const DENSITY_REFERENCE_GAP_MS = 2 * 60_000;
|
||||
const RECENT_TURN_WINDOW = 5;
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function densityScore(timestamps: Date[]): number {
|
||||
if (timestamps.length < 2) return 0;
|
||||
const window = timestamps.slice(-RECENT_TURN_WINDOW);
|
||||
const gaps: number[] = [];
|
||||
for (let i = 1; i < window.length; i++) {
|
||||
gaps.push(window[i].getTime() - window[i - 1].getTime());
|
||||
}
|
||||
const avgGap = gaps.reduce((sum, gap) => sum + gap, 0) / gaps.length;
|
||||
return clamp01(1 - avgGap / DENSITY_REFERENCE_GAP_MS);
|
||||
}
|
||||
|
||||
function mutualNamingScore(recentTurns: { speakerLabel: string; content: string }[], presentLabels: string[]): number {
|
||||
if (recentTurns.length === 0 || presentLabels.length < 2) return 0;
|
||||
const hits = recentTurns.filter((turn) => presentLabels.some((label) => label !== turn.speakerLabel && turn.content.includes(label))).length;
|
||||
return clamp01(hits / recentTurns.length);
|
||||
}
|
||||
|
||||
// T22:把推進間隔改為每輪重算——房內氣氛(發言密度+互相點名,空轉判定會直接歸零)、在場角色情緒、
|
||||
// 話題是否命中既有記憶、行程狀態與既有行為規則四項因子算出熱度分數,再以單調遞減映射換成間隔並夾在
|
||||
// 上下限之間;計算全程不參考 SSE 連線數與用量(見各因子計算內容,皆與這兩者無關)。
|
||||
@Injectable()
|
||||
export class RoomIntervalService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly emotion: EmotionService,
|
||||
private readonly schedule: ScheduleService,
|
||||
private readonly retriever: KeywordMemoryRetriever,
|
||||
private readonly sleepDebt: SleepDebtService,
|
||||
) {}
|
||||
|
||||
// 靜默 tick(本輪沒有產生任何 RoomTurn,例如沒有角色被判定該發言):不計入熱度計算,間隔直接貼上限。
|
||||
silentInterval(roomId: string): IntervalResult {
|
||||
const result: IntervalResult = {
|
||||
intervalMs: ROOM_ADVANCE_INTERVAL_UPPER_MS,
|
||||
heatScore: 0,
|
||||
factors: { atmosphere: 0, emotion: 0, memory: 0, behavior: 0 },
|
||||
silent: true,
|
||||
};
|
||||
log("自走間隔計算", "INF", `房間 ${roomId} 本輪靜默(無新 RoomTurn),間隔貼上限 ${result.intervalMs}ms`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async computeInterval(room: Room, now: Date = new Date()): Promise<IntervalResult> {
|
||||
const presentCharacterIds = [...room.participants.keys()];
|
||||
const recentTurns = room.turns.slice(-RECENT_TURN_WINDOW);
|
||||
const recentContents = recentTurns.map((turn) => turn.content);
|
||||
|
||||
const atmosphere = isStalling(room.turns.slice(-STALL_CHECK_WINDOW).map((turn) => turn.content))
|
||||
? 0
|
||||
: clamp01(
|
||||
densityScore(recentTurns.map((turn) => turn.timestamp)) * 0.7 +
|
||||
mutualNamingScore(recentTurns, recentTurns.map((turn) => turn.speakerLabel)) * 0.3,
|
||||
);
|
||||
|
||||
const emotion = await this.computeEmotionFactor(presentCharacterIds, now);
|
||||
const memory = await this.computeMemoryFactor(presentCharacterIds, recentContents);
|
||||
const behavior = await this.computeBehaviorFactor(presentCharacterIds, now);
|
||||
|
||||
const factors: IntervalFactors = { atmosphere, emotion, memory, behavior };
|
||||
const heatScore = clamp01(
|
||||
factors.atmosphere * FACTOR_WEIGHTS.atmosphere +
|
||||
factors.emotion * FACTOR_WEIGHTS.emotion +
|
||||
factors.memory * FACTOR_WEIGHTS.memory +
|
||||
factors.behavior * FACTOR_WEIGHTS.behavior,
|
||||
);
|
||||
|
||||
// 單調遞減映射:熱度 1 → 下限,熱度 0 → 上限。
|
||||
const intervalMs = Math.round(
|
||||
ROOM_ADVANCE_INTERVAL_LOWER_MS + (ROOM_ADVANCE_INTERVAL_UPPER_MS - ROOM_ADVANCE_INTERVAL_LOWER_MS) * (1 - heatScore),
|
||||
);
|
||||
|
||||
log(
|
||||
"自走間隔計算",
|
||||
"INF",
|
||||
`房間 ${room.id} 熱度=${heatScore.toFixed(2)}(氣氛=${atmosphere.toFixed(2)}、情緒=${emotion.toFixed(2)}、記憶=${memory.toFixed(2)}、行為=${behavior.toFixed(2)})→ 間隔 ${intervalMs}ms`,
|
||||
);
|
||||
|
||||
return { intervalMs, heatScore, factors, silent: false };
|
||||
}
|
||||
|
||||
private async computeEmotionFactor(characterIds: string[], now: Date): Promise<number> {
|
||||
if (characterIds.length === 0) return 0;
|
||||
const modifiers = await Promise.all(
|
||||
characterIds.map(async (characterId) => {
|
||||
const state = await this.emotion.getState(characterId, now);
|
||||
return EMOTION_SPEAKING_MODIFIER[dominantState(state)];
|
||||
}),
|
||||
);
|
||||
const avg = modifiers.reduce((sum, modifier) => sum + modifier, 0) / modifiers.length;
|
||||
// EMOTION_SPEAKING_MODIFIER 範圍約 -0.3~0.3,正規化到 0~1。
|
||||
return clamp01((avg + 0.3) / 0.6);
|
||||
}
|
||||
|
||||
private async computeMemoryFactor(characterIds: string[], recentContents: string[]): Promise<number> {
|
||||
if (characterIds.length === 0 || recentContents.length === 0) return 0;
|
||||
const query = recentContents.join(" ");
|
||||
const hits = await Promise.all(
|
||||
characterIds.map(async (characterId) => {
|
||||
const memories = await this.retriever.retrieve(characterId, query, { limit: 1 });
|
||||
return memories.length > 0 ? 1 : 0;
|
||||
}),
|
||||
);
|
||||
return clamp01(hits.reduce<number>((sum, hit) => sum + hit, 0) / characterIds.length);
|
||||
}
|
||||
|
||||
private async computeBehaviorFactor(characterIds: string[], now: Date): Promise<number> {
|
||||
if (characterIds.length === 0) return 0;
|
||||
const perCharacter = await Promise.all(
|
||||
characterIds.map(async (characterId) => {
|
||||
const info = await this.schedule.getInfo(characterId, now);
|
||||
const scheduleComponent = info.slot?.status === "FREE" ? 1 : info.slot?.status === "BUSY" ? 0.3 : 0;
|
||||
const rules = await this.prisma.client.proceduralRule.findMany({ where: { characterId } });
|
||||
const ruleWeightSum = rules.reduce((sum, rule) => sum + rule.weight, 0);
|
||||
const ruleComponent = clamp01(ruleWeightSum / 5);
|
||||
// T32:睡眠債活躍時對該角色加懲罰項,拖低房間整體熱度(等同「回覆變慢」)。
|
||||
const debtMinutes = await this.sleepDebt.getDebtMinutes(characterId);
|
||||
const sleepDebtPenalty = debtMinutes >= SLEEP_DEBT_ACTIVE_THRESHOLD_MINUTES ? 0.4 : 0;
|
||||
return clamp01((scheduleComponent + ruleComponent) / 2 - sleepDebtPenalty);
|
||||
}),
|
||||
);
|
||||
return clamp01(perCharacter.reduce((sum, value) => sum + value, 0) / perCharacter.length);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PushNotificationService } from "../push/push-notification.service.js";
|
||||
import type { Room } from "./room.service.js";
|
||||
|
||||
// T31 靜音與重要事件推播:房間 `muted` 為真時完全不推播(但自走照常進行,不是暫停);
|
||||
// 閒聊本身一律不推播,只有下面四類事件才會走這裡。第四類(結局偵測待確認)的偵測邏輯本身屬於
|
||||
// P4「結局偵測」的範疇、尚未實作,這裡先備好可直接呼叫的推播入口——等 P4 該功能落地時直接呼叫
|
||||
// notifyEndingDetectionPending(),不需要再回頭改動推播模組本身。
|
||||
@Injectable()
|
||||
export class RoomNotificationService {
|
||||
constructor(private readonly push: PushNotificationService) {}
|
||||
|
||||
// 角色直接回覆使用者發言(被喊/被點名),非角色之間的自走閒聊。
|
||||
async notifyMentioned(room: Room, characterLabel: string, content: string): Promise<void> {
|
||||
await this.notify(room, `${characterLabel}回你了`, content);
|
||||
}
|
||||
|
||||
// 自走過程中所有角色各自離場後,房間自動收尾關閉(非使用者主動按下「結束房間」)。
|
||||
async notifyRoomClosed(room: Room, reason: string): Promise<void> {
|
||||
await this.notify(room, "房間已自動關閉", `原因:${reason}`);
|
||||
}
|
||||
|
||||
async notifyDelegateProposed(room: Room, characterLabel: string, taskContent: string): Promise<void> {
|
||||
await this.notify(room, `${characterLabel}提出了委託`, taskContent);
|
||||
}
|
||||
|
||||
async notifyEndingDetectionPending(userId: string, characterLabel: string): Promise<void> {
|
||||
await this.push.sendToUser(
|
||||
userId,
|
||||
"有一段故事可能走向結局",
|
||||
`${characterLabel}的故事似乎到了轉折點,要不要去看看、確認一下?`,
|
||||
);
|
||||
}
|
||||
|
||||
private async notify(room: Room, title: string, body: string): Promise<void> {
|
||||
if (room.muted) return;
|
||||
await this.push.sendToUser(room.userId, title, body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
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 { 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 { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js";
|
||||
import { MockProvider } from "../llm/mock-provider.service.js";
|
||||
import { GlobalPauseService } from "../llm/global-pause.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 { 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 { SpeakingRightService } from "./speaking-right.service.js";
|
||||
import { BehaviorSelectionService } from "./behavior-selection.service.js";
|
||||
import { SelfChatService } from "./self-chat.service.js";
|
||||
import { RoomNotificationService } from "./room-notification.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 { 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 { InProcessJobQueue } from "../memory/in-process-job-queue.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";
|
||||
|
||||
// T31 驗收:Room.muted 為真時完全不推播;被喊或被點名(自走內容提到使用者本名)、房間自動關閉
|
||||
// (自走收尾,非使用者主動結束)兩類事件會走 RoomNotificationService 送出推播。
|
||||
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 speakingRight = new SpeakingRightService(prisma, emotionService, relationshipService, characterRelationship);
|
||||
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 roomNotification = new RoomNotificationService(pushNotification);
|
||||
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 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-room-notify-user";
|
||||
const CHAR_A = "test-room-notify-char-a";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.pushToken.deleteMany({ where: { userId } });
|
||||
await prisma.client.pushNotificationLog.deleteMany({ where: { userId } });
|
||||
await prisma.client.room.deleteMany({ where: { userId } });
|
||||
await prisma.client.character.deleteMany({ where: { id: CHAR_A } });
|
||||
await prisma.client.user.deleteMany({ where: { id: userId } });
|
||||
});
|
||||
|
||||
async function createCharacter(id: string) {
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
aliases: { create: { formalName: id } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("靜音與重要事件推播(T31)", () => {
|
||||
it("Room.muted 為真時,四類事件的推播入口一律不送出", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await pushToken.register(userId, "test-room-notify-token-muted", "expo");
|
||||
const room = await roomService.create(userId, [], "GROUP");
|
||||
await roomService.setMuted(room.id, true);
|
||||
const mutedRoom = await roomService.get(room.id);
|
||||
|
||||
const before = mockPushProvider.sent.length;
|
||||
await roomNotification.notifyMentioned(mutedRoom, "小甲", "在嗎?");
|
||||
await roomNotification.notifyRoomClosed(mutedRoom, "ALL_LEFT");
|
||||
await roomNotification.notifyDelegateProposed(mutedRoom, "小甲", "幫你查了一下");
|
||||
expect(mockPushProvider.sent.length).toBe(before); // 靜音:一則都沒送出
|
||||
}, 20000);
|
||||
|
||||
it("Room.muted 為假時,四類事件都會送出推播", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await pushToken.register(userId, "test-room-notify-token-unmuted", "expo");
|
||||
const room = await roomService.create(userId, [], "GROUP");
|
||||
|
||||
await roomNotification.notifyMentioned(room, "小甲", "在嗎?");
|
||||
await roomNotification.notifyRoomClosed(room, "ALL_LEFT");
|
||||
await roomNotification.notifyDelegateProposed(room, "小甲", "幫你查了一下");
|
||||
await roomNotification.notifyEndingDetectionPending(userId, "小甲");
|
||||
|
||||
expect(mockPushProvider.sent.some((m) => m.body === "在嗎?")).toBe(true);
|
||||
expect(mockPushProvider.sent.some((m) => m.body === "原因:ALL_LEFT")).toBe(true);
|
||||
expect(mockPushProvider.sent.some((m) => m.body === "幫你查了一下")).toBe(true);
|
||||
expect(mockPushProvider.sent.some((m) => m.title === "有一段故事可能走向結局")).toBe(true);
|
||||
}, 20000);
|
||||
|
||||
it("自走內容提到使用者本名(被喊或被點名)時會推播,未提到(純閒聊)則不會", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await pushToken.register(userId, "test-room-notify-token-mention", "expo");
|
||||
await createCharacter(CHAR_A);
|
||||
const room = await selfChat.start(userId, [CHAR_A], "測試話題", clock.now());
|
||||
|
||||
mockProvider.enqueueResponse("欸,測試使用者,你在嗎?我有事想問你!");
|
||||
const beforeMention = mockPushProvider.sent.length;
|
||||
await selfChat.advance(room.id, clock.now());
|
||||
expect(mockPushProvider.sent.length).toBeGreaterThan(beforeMention);
|
||||
expect(mockPushProvider.sent.some((m) => m.body.includes("測試使用者,你在嗎"))).toBe(true);
|
||||
|
||||
mockProvider.enqueueResponse("今天天氣真好呢,心情也很不錯!");
|
||||
const beforeChitchat = mockPushProvider.sent.length;
|
||||
await selfChat.advance(room.id, new Date(clock.now().getTime() + 1000));
|
||||
expect(mockPushProvider.sent.length).toBe(beforeChitchat); // 純閒聊不推播
|
||||
}, 20000);
|
||||
|
||||
it("自走房間所有角色都離場、自動關閉時會推播", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await pushToken.register(userId, "test-room-notify-token-close", "expo");
|
||||
// 零角色的自走房:advance() 第一輪就因找不到候選發言者直接觸發 wrapUp()(見 self-chat.service.ts)。
|
||||
const room = await selfChat.start(userId, [], "測試話題", clock.now());
|
||||
|
||||
const before = mockPushProvider.sent.length;
|
||||
const result = await selfChat.advance(room.id, clock.now());
|
||||
expect(result.ended).toBe(true);
|
||||
expect(mockPushProvider.sent.length).toBeGreaterThan(before);
|
||||
expect(mockPushProvider.sent.some((m) => m.title === "房間已自動關閉")).toBe(true);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
|
||||
// T2 驗收:migration 可套用;RoomMember 的 activeCharacterId 唯一索引讓「重複讓同一角色入房」直接被 DB 擋下(R5)。
|
||||
// 沿用已套用過 migration 的開發用 dev.db,每個案例包在互動式交易內並強制丟出 sentinel 觸發回滾,
|
||||
// 確保測試資料不會殘留(`prisma migrate deploy` 對全新資料庫套用 16 筆 migration 實測約需 78 秒,
|
||||
// 不適合每次跑測試都重建資料庫)。
|
||||
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
const devDbUrl = `file:${join(repoRoot, "prisma", "dev.db")}`;
|
||||
|
||||
process.env.DATABASE_URL = devDbUrl;
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
const ROLLBACK = Symbol("rollback");
|
||||
|
||||
async function inRolledBackTransaction(fn: (tx: typeof prisma) => Promise<void>): Promise<unknown> {
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await fn(tx as typeof prisma);
|
||||
throw ROLLBACK;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error !== ROLLBACK) throw error;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("Room 持久化 schema(T2)", () => {
|
||||
it("角色不可同時在場於兩個房間:第二筆 activeCharacterId 會被唯一索引擋下", async () => {
|
||||
await inRolledBackTransaction(async (tx) => {
|
||||
const user = await tx.user.create({ data: { name: "測試使用者" } });
|
||||
const character = await tx.character.create({
|
||||
data: {
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試角色",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "TSUNDERE",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
},
|
||||
});
|
||||
const roomA = await tx.room.create({ data: { userId: user.id, mode: "SOLO" } });
|
||||
const roomB = await tx.room.create({ data: { userId: user.id, mode: "SOLO" } });
|
||||
|
||||
await tx.roomMember.create({
|
||||
data: { roomId: roomA.id, characterId: character.id, activeCharacterId: character.id },
|
||||
});
|
||||
|
||||
await expect(
|
||||
tx.roomMember.create({
|
||||
data: { roomId: roomB.id, characterId: character.id, activeCharacterId: character.id },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("角色離場(activeCharacterId 設為 null)後可在另一間房重新登記為在場", async () => {
|
||||
await inRolledBackTransaction(async (tx) => {
|
||||
const user = await tx.user.create({ data: { name: "測試使用者2" } });
|
||||
const character = await tx.character.create({
|
||||
data: {
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試角色2",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "TSUNDERE",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
},
|
||||
});
|
||||
const roomA = await tx.room.create({ data: { userId: user.id, mode: "SOLO" } });
|
||||
const roomB = await tx.room.create({ data: { userId: user.id, mode: "SOLO" } });
|
||||
|
||||
const membershipA = await tx.roomMember.create({
|
||||
data: { roomId: roomA.id, characterId: character.id, activeCharacterId: character.id },
|
||||
});
|
||||
await tx.roomMember.update({
|
||||
where: { id: membershipA.id },
|
||||
data: { leftAt: new Date(), leaveReason: "ENTER_ROOM", activeCharacterId: null },
|
||||
});
|
||||
|
||||
const membershipB = await tx.roomMember.create({
|
||||
data: { roomId: roomB.id, characterId: character.id, activeCharacterId: character.id },
|
||||
});
|
||||
expect(membershipB.activeCharacterId).toBe(character.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
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 type { JobQueue } from "../memory/job-queue.js";
|
||||
import { ControllableClock } from "../clock/controllable-clock.js";
|
||||
import { EmotionService } from "../emotion/emotion.service.js";
|
||||
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
|
||||
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { SleepDebtService } from "../schedule/sleep-debt.service.js";
|
||||
import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js";
|
||||
import { LobbyService } from "./lobby.service.js";
|
||||
import { RoomEventsService } from "./room-events.service.js";
|
||||
import { RoomService } from "./room.service.js";
|
||||
import { RoomIntervalService } from "./room-interval.service.js";
|
||||
import type { SelfChatService } from "./self-chat.service.js";
|
||||
import { RoomSchedulerService } from "./room-scheduler.service.js";
|
||||
import { ROOM_ADVANCE_INTERVAL_UPPER_MS } from "./constants.js";
|
||||
|
||||
// T37/T8 驗收:重啟 api 後房間自動繼續推進、不出現爆量補齊;到期判斷改依 T22 動態間隔(本測試用的
|
||||
// 空房間沒有任何在場角色/對話內容,熱度恆為 0,動態間隔會穩定落在上限值,藉此驗證排程判斷邏輯本身,
|
||||
// 實際自走內容生成已由 smoke K 群組覆蓋)。
|
||||
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 noopJobQueue = {} as JobQueue; // scanAndAdvance() 本身不用到 jobQueue,只有 onModuleInit 會用
|
||||
const roomEvents = new RoomEventsService(new EventEmitter2());
|
||||
const emotionService = new EmotionService(prisma, new RuleBasedEmotionTagger());
|
||||
const scheduleService = new ScheduleService(prisma);
|
||||
const sleepDebtService = new SleepDebtService(prisma, scheduleService);
|
||||
const retriever = new KeywordMemoryRetriever(prisma);
|
||||
|
||||
const userId = "test-scheduler-user";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.room.deleteMany({ where: { userId } });
|
||||
await prisma.client.user.deleteMany({ where: { id: userId } });
|
||||
});
|
||||
|
||||
function buildScheduler(clock: ControllableClock, selfChat: SelfChatService) {
|
||||
const lobbyService = new LobbyService(prisma, clock, roomEvents);
|
||||
const roomService = new RoomService(prisma, clock, lobbyService, roomEvents);
|
||||
const intervalService = new RoomIntervalService(prisma, emotionService, scheduleService, retriever, sleepDebtService);
|
||||
return new RoomSchedulerService(prisma, noopJobQueue, clock, roomService, selfChat, intervalService);
|
||||
}
|
||||
|
||||
describe("RoomSchedulerService(T37/T8)", () => {
|
||||
it("到期才推進一次;停機再久重新掃描也只補推進一次,不會依錯過的輪數重複補齊", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試排程使用者" } });
|
||||
const room = await prisma.client.room.create({ data: { userId, mode: "SELF_CHAT", status: "ACTIVE" } });
|
||||
|
||||
const clock = new ControllableClock("2026-08-18T12:00:00.000Z");
|
||||
const advanceCalls: string[] = [];
|
||||
const fakeSelfChat = {
|
||||
advance: async (roomId: string, now?: Date) => {
|
||||
advanceCalls.push(roomId);
|
||||
await prisma.client.room.update({ where: { id: roomId }, data: { lastAdvancedAt: now ?? clock.now() } });
|
||||
return { ended: false };
|
||||
},
|
||||
} as unknown as SelfChatService;
|
||||
const scheduler = buildScheduler(clock, fakeSelfChat);
|
||||
|
||||
// 剛建立、lastAdvancedAt 為 null:立刻視為到期,推進一次。
|
||||
await scheduler.scanAndAdvance();
|
||||
expect(advanceCalls).toEqual([room.id]);
|
||||
|
||||
// 間隔內再掃一次:還沒到期,不推進(空房間熱度恆為 0,動態間隔落在上限值)。
|
||||
advanceCalls.length = 0;
|
||||
await scheduler.scanAndAdvance();
|
||||
expect(advanceCalls).toEqual([]);
|
||||
|
||||
// 模擬停機遠超過間隔上限後才重新掃描:只推進「一次」讓它接上目前時間,不依錯過的輪數補齊。
|
||||
clock.advance(ROOM_ADVANCE_INTERVAL_UPPER_MS * 10);
|
||||
advanceCalls.length = 0;
|
||||
await scheduler.scanAndAdvance();
|
||||
expect(advanceCalls).toEqual([room.id]);
|
||||
expect(advanceCalls).toHaveLength(1);
|
||||
|
||||
// 補推進後立刻再掃一次:又還沒到期,不會連續補第二次。
|
||||
advanceCalls.length = 0;
|
||||
await scheduler.scanAndAdvance();
|
||||
expect(advanceCalls).toEqual([]);
|
||||
|
||||
// 間隔剛好過了:正常繼續推進,不因為前面的補推進而被跳過。
|
||||
clock.advance(ROOM_ADVANCE_INTERVAL_UPPER_MS + 1);
|
||||
advanceCalls.length = 0;
|
||||
await scheduler.scanAndAdvance();
|
||||
expect(advanceCalls).toEqual([room.id]);
|
||||
}, 20000);
|
||||
|
||||
it("同一房間的上一次 advance() 還沒跑完時,下一次 tick 不會再併發推進同一房間(T18 迴歸)", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試排程使用者3" } });
|
||||
const room = await prisma.client.room.create({ data: { userId, mode: "SELF_CHAT", status: "ACTIVE" } });
|
||||
|
||||
const clock = new ControllableClock("2026-08-18T12:00:00.000Z");
|
||||
let concurrentCalls = 0;
|
||||
let maxConcurrentCalls = 0;
|
||||
const fakeSelfChat = {
|
||||
advance: async (roomId: string) => {
|
||||
concurrentCalls += 1;
|
||||
maxConcurrentCalls = Math.max(maxConcurrentCalls, concurrentCalls);
|
||||
// 故意拖長:模擬 T18 實測中大房間單輪耗時可能超過 tick 週期(10 秒)的情況。
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
concurrentCalls -= 1;
|
||||
return { ended: false };
|
||||
},
|
||||
} as unknown as SelfChatService;
|
||||
const scheduler = buildScheduler(clock, fakeSelfChat);
|
||||
|
||||
// 第一次 scanAndAdvance() 還在跑(advance() 尚未完成)時,立刻疊上第二次 tick。
|
||||
const first = scheduler.scanAndAdvance();
|
||||
const second = scheduler.scanAndAdvance();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(maxConcurrentCalls).toBe(1); // 任何時刻最多只有一個 advance() 在跑同一房間
|
||||
void room; // 僅供上方 create 呼叫,本測試不需要另外讀取房間內容
|
||||
}, 20000);
|
||||
|
||||
it("不掃描 LOBBY 房與 autoAdvanceEnabled=false 的房間", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試排程使用者2" } });
|
||||
await prisma.client.room.create({ data: { userId, mode: "LOBBY", status: "ACTIVE", autoAdvanceEnabled: false } });
|
||||
await prisma.client.room.create({
|
||||
data: { userId, mode: "SELF_CHAT", status: "ACTIVE", autoAdvanceEnabled: false },
|
||||
});
|
||||
|
||||
const clock = new ControllableClock("2026-08-18T12:00:00.000Z");
|
||||
const advanceCalls: string[] = [];
|
||||
const fakeSelfChat = {
|
||||
advance: async (roomId: string) => {
|
||||
advanceCalls.push(roomId);
|
||||
return { ended: false };
|
||||
},
|
||||
} as unknown as SelfChatService;
|
||||
const scheduler = buildScheduler(clock, fakeSelfChat);
|
||||
|
||||
await scheduler.scanAndAdvance();
|
||||
expect(advanceCalls).toEqual([]);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Inject, Injectable, OnModuleInit } from "@nestjs/common";
|
||||
import { log } from "@kokorone/shared";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { JOB_QUEUE, type JobQueue } from "../memory/job-queue.js";
|
||||
import { CLOCK, type Clock } from "../clock/clock.js";
|
||||
import { RoomService } from "./room.service.js";
|
||||
import { SelfChatService } from "./self-chat.service.js";
|
||||
import { RoomIntervalService } from "./room-interval.service.js";
|
||||
import { ROOM_SCHEDULER_TICK_CRON } from "./constants.js";
|
||||
|
||||
// T37/T8:推進行程的部署與重啟恢復,且不降頻——排程與 api 同行程(沿用 InProcessJobQueue/node-cron
|
||||
// 慣例,不等主計畫 R-6 的 BullMQ 化)。啟動時立刻掃一次所有 ACTIVE 房間並依 lastAdvancedAt 判斷是否
|
||||
// 已到期,到期就推進「一次」讓它接上目前時間——停機期間錯過的推進不補齊(不因為停機 1 小時就
|
||||
// 補打 120 次),之後交給週期 tick 持續運作。
|
||||
//
|
||||
// 前提:單一 api 實例(README 已於部署章節註明),故不需要分散式鎖;本排程器目前查詢量小,
|
||||
// 沒有導入 Redis 加速的需要,因此也沒有任何硬性 Redis 依賴——「Redis 不可用時退回查庫」對本
|
||||
// 排程器而言是「本來就只查庫」,不是額外的容錯路徑。
|
||||
//
|
||||
// T8:到期判斷改為每房各自呼叫 T22 的 RoomIntervalService 動態算出應有間隔(不再是固定值),
|
||||
// 完全不參考 SSE 連線數/用量——關掉所有前端連線、沒有任何人在看,房間仍會依這裡算出的節奏持續推進;
|
||||
// 全員離開的自動關房已由 SelfChatService 內部的 LEAVE/wrapUp(見該檔)處理,本排程器只需照常
|
||||
// 掃描 status=ACTIVE 的房間,房間關閉後自然不再被掃到。
|
||||
@Injectable()
|
||||
export class RoomSchedulerService implements OnModuleInit {
|
||||
// T18 實測發現:多人房單輪耗時可能超過 tick 週期(12 人房 p95 實測達數十秒,遠超 10 秒 tick),
|
||||
// 若不擋下,下一次 tick 會在同一房間的 advance() 還沒跑完時又觸發一次,造成同一房間被併發推進兩次
|
||||
// (turnCounter/發言權計算都假設單一 in-flight advance,併發會產生競態)。用一個記憶體內的
|
||||
// in-flight 名單擋下:單一 api 實例前提下(見檔頭註解)足夠,房間推進完(成功或失敗)都會移除。
|
||||
private readonly advancing = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(JOB_QUEUE) private readonly jobQueue: JobQueue,
|
||||
@Inject(CLOCK) private readonly clock: Clock,
|
||||
private readonly room: RoomService,
|
||||
private readonly selfChat: SelfChatService,
|
||||
private readonly interval: RoomIntervalService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.jobQueue.now("room-scheduler:startup-scan", () => this.scanAndAdvance());
|
||||
this.jobQueue.every(ROOM_SCHEDULER_TICK_CRON, "room-scheduler:tick", () => this.scanAndAdvance());
|
||||
}
|
||||
|
||||
async scanAndAdvance(): Promise<void> {
|
||||
const now = this.clock.now();
|
||||
const candidates = await this.prisma.client.room.findMany({
|
||||
where: { status: "ACTIVE", mode: "SELF_CHAT", autoAdvanceEnabled: true },
|
||||
select: { id: true, lastAdvancedAt: true },
|
||||
});
|
||||
|
||||
// 支援多房並行:每間房各自獨立判斷、獨立推進,一間失敗不影響其他房間。
|
||||
await Promise.all(
|
||||
candidates.map(async ({ id: roomId, lastAdvancedAt }) => {
|
||||
// 檢查與標記必須同步、中間不能有 await,否則兩個幾乎同時的 tick 會在彼此標記完成前
|
||||
// 都通過檢查、一起把同一房間送進 advance()——這正是本段要擋下的併發。
|
||||
if (this.advancing.has(roomId)) return; // 上一次 advance() 還沒跑完,這次 tick 先跳過。
|
||||
this.advancing.add(roomId);
|
||||
try {
|
||||
if (!(await this.isDue(roomId, lastAdvancedAt, now))) return;
|
||||
await this.selfChat.advance(roomId, now);
|
||||
} catch (err) {
|
||||
log("排程", "ERR", `房間 ${roomId} 自走推進失敗:${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
this.advancing.delete(roomId);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async isDue(roomId: string, lastAdvancedAt: Date | null, now: Date): Promise<boolean> {
|
||||
if (!lastAdvancedAt) return true; // 從未推進過,立刻接上。
|
||||
const room = await this.room.get(roomId);
|
||||
const { intervalMs } = await this.interval.computeInterval(room, now);
|
||||
return now.getTime() - lastAdvancedAt.getTime() >= intervalMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { ControllableClock } from "../clock/controllable-clock.js";
|
||||
import { LobbyService } from "./lobby.service.js";
|
||||
import { RoomEventsService } from "./room-events.service.js";
|
||||
import { RoomService, USER_SPEAKER_ID } from "./room.service.js";
|
||||
|
||||
// T12 驗收:GET /room/:roomId 分頁支援從指定游標往前後取,重新進入未關閉的房間可看到先前對話。
|
||||
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-18T12: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 userId = "test-turns-page-user";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.room.deleteMany({ where: { userId } });
|
||||
await prisma.client.user.deleteMany({ where: { id: userId } });
|
||||
});
|
||||
|
||||
describe("RoomService.getTurnsPage(T12)", () => {
|
||||
it("未帶游標時回傳最新一頁;beforeSeq 可往前翻到較舊的一頁;afterSeq 可往後翻到較新的一頁", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試翻頁使用者" } });
|
||||
const room = await roomService.create(userId, [], "GROUP");
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await roomService.addTurn(room.id, USER_SPEAKER_ID, "測試使用者", `第 ${i} 句`, clock.now());
|
||||
clock.advance(1000);
|
||||
}
|
||||
|
||||
const latest = await roomService.getTurnsPage(room.id, { limit: 4 });
|
||||
expect(latest.turns.map((t) => t.content)).toEqual(["第 6 句", "第 7 句", "第 8 句", "第 9 句"]);
|
||||
expect(latest.hasMore).toBe(true);
|
||||
expect(latest.nextCursor).toBe(6);
|
||||
|
||||
const older = await roomService.getTurnsPage(room.id, { beforeSeq: latest.nextCursor!, limit: 4 });
|
||||
expect(older.turns.map((t) => t.content)).toEqual(["第 2 句", "第 3 句", "第 4 句", "第 5 句"]);
|
||||
expect(older.hasMore).toBe(true);
|
||||
expect(older.nextCursor).toBe(2);
|
||||
|
||||
const oldest = await roomService.getTurnsPage(room.id, { beforeSeq: older.nextCursor!, limit: 4 });
|
||||
expect(oldest.turns.map((t) => t.content)).toEqual(["第 0 句", "第 1 句"]);
|
||||
expect(oldest.hasMore).toBe(false);
|
||||
expect(oldest.nextCursor).toBeNull();
|
||||
|
||||
const newerAgain = await roomService.getTurnsPage(room.id, { afterSeq: 5, limit: 3 });
|
||||
expect(newerAgain.turns.map((t) => t.content)).toEqual(["第 6 句", "第 7 句", "第 8 句"]);
|
||||
expect(newerAgain.hasMore).toBe(true);
|
||||
expect(newerAgain.nextCursor).toBe(8);
|
||||
}, 20000); // 多次循序 DB 往返(better-sqlite3 + 逐漸增長的 dev.db),預設 5s 逾時偶爾不夠。
|
||||
|
||||
it("使用者離開後重進,對話歷程完整且角色仍在場", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試重進使用者" } });
|
||||
const characterId = "test-turns-page-char";
|
||||
await prisma.client.character.upsert({
|
||||
where: { id: characterId },
|
||||
update: {},
|
||||
create: {
|
||||
id: characterId,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "TSUNDERE",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
},
|
||||
});
|
||||
try {
|
||||
const room = await roomService.create(userId, [characterId], "GROUP");
|
||||
await roomService.addTurn(room.id, USER_SPEAKER_ID, "測試使用者", "你好", clock.now());
|
||||
await roomService.addTurn(room.id, characterId, "小測", "你好呀", clock.now());
|
||||
|
||||
// 模擬「離開後重進」:不做任何額外動作,單純重新讀取即可,因為對話已持久化不是記憶體物件。
|
||||
const reentered = await roomService.get(room.id);
|
||||
expect(reentered.turns.map((t) => t.content)).toEqual(["你好", "你好呀"]);
|
||||
expect([...reentered.participants.keys()]).toContain(characterId);
|
||||
} finally {
|
||||
await prisma.client.character.deleteMany({ where: { id: characterId } });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createPrismaClient } from "@kokorone/db";
|
||||
import type { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { ControllableClock } from "../clock/controllable-clock.js";
|
||||
import { LobbyService } from "./lobby.service.js";
|
||||
import { RoomEventsService } from "./room-events.service.js";
|
||||
import { RoomService, USER_SPEAKER_ID } from "./room.service.js";
|
||||
|
||||
// T11 驗收:離開房間讓它累積訊息後重進,畫面自動落在第一則未讀且未讀數歸零。
|
||||
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-19T12: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 userId = "test-unread-user";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.room.deleteMany({ where: { userId } });
|
||||
await prisma.client.user.deleteMany({ where: { id: userId } });
|
||||
});
|
||||
|
||||
describe("RoomService 未讀追蹤(T11)", () => {
|
||||
it("未讀數與第一則未讀游標正確;已讀游標單調遞增不可回退;讀到底部未讀數歸零", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試未讀使用者" } });
|
||||
const room = await roomService.create(userId, [], "GROUP");
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await roomService.addTurn(room.id, USER_SPEAKER_ID, "測試", `第 ${i} 句`, clock.now());
|
||||
}
|
||||
|
||||
const beforeAnyRead = await roomService.getUnreadInfo(room.id, userId);
|
||||
expect(beforeAnyRead.lastReadSeq).toBe(-1);
|
||||
expect(beforeAnyRead.firstUnreadSeq).toBe(0);
|
||||
expect(beforeAnyRead.unreadCount).toBe(5);
|
||||
|
||||
await roomService.reportRead(room.id, userId, 2);
|
||||
const afterPartialRead = await roomService.getUnreadInfo(room.id, userId);
|
||||
expect(afterPartialRead.firstUnreadSeq).toBe(3);
|
||||
expect(afterPartialRead.unreadCount).toBe(2);
|
||||
|
||||
// 不可回退:回報比目前紀錄舊的 seq 不應讓已讀游標倒退。
|
||||
await roomService.reportRead(room.id, userId, 1);
|
||||
const afterRegress = await roomService.getUnreadInfo(room.id, userId);
|
||||
expect(afterRegress.lastReadSeq).toBe(2);
|
||||
|
||||
// 讀到底部才回報:未讀數應歸零。
|
||||
await roomService.reportRead(room.id, userId, 4);
|
||||
const afterFullRead = await roomService.getUnreadInfo(room.id, userId);
|
||||
expect(afterFullRead.firstUnreadSeq).toBeNull();
|
||||
expect(afterFullRead.unreadCount).toBe(0);
|
||||
}, 20000); // 多次循序 DB 往返(better-sqlite3 + 逐漸增長的 dev.db),預設 5s 逾時偶爾不夠。
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
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 { LobbyService } from "./lobby.service.js";
|
||||
import { RoomEventsService } from "./room-events.service.js";
|
||||
import { RoomService, USER_SPEAKER_ID } from "./room.service.js";
|
||||
|
||||
// T9 驗收:每次生成後寫入用量紀錄、可查詢累計用量,且不因用量而限制發言(本測試只驗證記錄本身)。
|
||||
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-19T12: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 userId = "test-room-usage-user";
|
||||
const characterId = "test-room-usage-char";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.room.deleteMany({ where: { userId } });
|
||||
await prisma.client.character.deleteMany({ where: { id: characterId } });
|
||||
await prisma.client.user.deleteMany({ where: { id: userId } });
|
||||
});
|
||||
|
||||
describe("RoomService.recordUsage/getUsageSummary(T9)", () => {
|
||||
it("累計多筆用量紀錄後,getUsageSummary 回傳正確加總;刻意衝高用量不影響是否能繼續發言", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試用量使用者" } });
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id: characterId,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
},
|
||||
});
|
||||
const room = await roomService.create(userId, [], "GROUP");
|
||||
|
||||
const emptyBefore = await roomService.getUsageSummary(room.id);
|
||||
expect(emptyBefore).toEqual({ totalPromptTokens: 0, totalCompletionTokens: 0, generationCount: 0 });
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const turn = await roomService.addTurn(room.id, characterId, "測試角色", `第 ${i} 句`, clock.now());
|
||||
await roomService.recordUsage(room.id, { model: "mock", promptTokens: 100, completionTokens: 50, turnSeq: turn.turnIndex });
|
||||
clock.advance(1000);
|
||||
}
|
||||
|
||||
const summary = await roomService.getUsageSummary(room.id);
|
||||
expect(summary).toEqual({ totalPromptTokens: 500, totalCompletionTokens: 250, generationCount: 5 });
|
||||
|
||||
// 用量已經很高,房間仍可正常繼續新增發言——不存在任何用量門檻擋下寫入。
|
||||
const stillWorks = await roomService.addTurn(room.id, characterId, "測試角色", "第 5 句", clock.now());
|
||||
expect(stillWorks.content).toBe("第 5 句");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Body, Controller, Get, Param, Post } from "@nestjs/common";
|
||||
import { Body, Controller, Get, Headers, Param, Post, Query, Sse } from "@nestjs/common";
|
||||
import type { MessageEvent } from "@nestjs/common";
|
||||
import { Observable } from "rxjs";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { RoomService, USER_SPEAKER_ID } from "./room.service.js";
|
||||
import { SpeakingRightService } from "./speaking-right.service.js";
|
||||
@@ -6,6 +8,11 @@ import { RoomGenerationService } from "./room-generation.service.js";
|
||||
import { RoomConsolidationService } from "./room-consolidation.service.js";
|
||||
import { SelfChatService } from "./self-chat.service.js";
|
||||
import { CharacterRelationshipService } from "./character-relationship.service.js";
|
||||
import { LobbyService } from "./lobby.service.js";
|
||||
import { SoloChatService } from "./solo-chat.service.js";
|
||||
import { RoomEventsService, type RoomStreamEvent } from "./room-events.service.js";
|
||||
import { InvitationService } from "../lobby/invitation.service.js";
|
||||
import { PossessionService } from "./possession.service.js";
|
||||
|
||||
interface CreateRoomBody {
|
||||
userId: string;
|
||||
@@ -14,10 +21,16 @@ interface CreateRoomBody {
|
||||
topicSeed?: string;
|
||||
}
|
||||
|
||||
interface CreateSoloRoomBody {
|
||||
userId: string;
|
||||
characterId: string;
|
||||
}
|
||||
|
||||
interface MessageBody {
|
||||
userId: string;
|
||||
userLabel?: string;
|
||||
text: string;
|
||||
seed?: number;
|
||||
now?: string;
|
||||
}
|
||||
|
||||
@@ -27,6 +40,21 @@ interface RelationshipBody {
|
||||
dynamic?: string;
|
||||
}
|
||||
|
||||
interface ReadReceiptBody {
|
||||
userId: string;
|
||||
lastReadSeq: number;
|
||||
}
|
||||
|
||||
interface InviteBody {
|
||||
characterId: string;
|
||||
invitedByCharacterId: string; // 房內角色邀請:邀請人必須是 :roomId 目前在場的角色
|
||||
now?: string;
|
||||
}
|
||||
|
||||
interface MuteBody {
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
@Controller("room")
|
||||
export class RoomController {
|
||||
constructor(
|
||||
@@ -37,39 +65,190 @@ export class RoomController {
|
||||
private readonly consolidation: RoomConsolidationService,
|
||||
private readonly selfChat: SelfChatService,
|
||||
private readonly characterRelationship: CharacterRelationshipService,
|
||||
private readonly lobby: LobbyService,
|
||||
private readonly soloChat: SoloChatService,
|
||||
private readonly roomEvents: RoomEventsService,
|
||||
private readonly invitation: InvitationService,
|
||||
private readonly possession: PossessionService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() body: CreateRoomBody) {
|
||||
const room = this.room.create(body.userId, body.characterIds, body.mode ?? "GROUP", body.topicSeed);
|
||||
async create(@Body() body: CreateRoomBody) {
|
||||
const room = await this.room.create(body.userId, body.characterIds, body.mode ?? "GROUP", body.topicSeed);
|
||||
return { id: room.id, mode: room.mode, status: room.status, topicSeed: room.topicSeed };
|
||||
}
|
||||
|
||||
// T31:靜音只影響推播與自動語音播放,自走排程與生成照常進行——沒有「暫停房間」這個概念。
|
||||
@Post(":roomId/mute")
|
||||
async setMuted(@Param("roomId") roomId: string, @Body() body: MuteBody) {
|
||||
const room = await this.room.setMuted(roomId, body.muted);
|
||||
return { id: room.id, muted: room.muted };
|
||||
}
|
||||
|
||||
// T29:房內角色邀請不在場角色。與使用者從大廳呼叫(T5)走同一套 RoomInvitation;若被邀請角色
|
||||
// 同時有其他 PENDING 邀請,由人格系統依關係/情緒選一筆 ACCEPTED,其餘 DECLINED。
|
||||
@Post(":roomId/invite")
|
||||
async invite(@Param("roomId") roomId: string, @Body() body: InviteBody) {
|
||||
await this.invitation.invite(roomId, body.characterId, body.invitedByCharacterId, body.now ? new Date(body.now) : undefined);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// T19-P3:房間頁輪詢用量顯示,不需要整份 GET /:roomId 一起重查。
|
||||
@Get(":roomId/usage")
|
||||
async getUsage(@Param("roomId") roomId: string) {
|
||||
return this.room.getUsageSummary(roomId);
|
||||
}
|
||||
|
||||
// T29:明確觸發裁決——收集完某角色目前所有 PENDING 邀請後才呼叫,避免先到先贏。
|
||||
@Post("invitations/:characterId/resolve")
|
||||
async resolveInvitations(@Param("characterId") characterId: string, @Body() body: { now?: string }) {
|
||||
await this.invitation.resolvePendingFor(characterId, body.now ? new Date(body.now) : undefined);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// T19-P1/T20-P1:大廳頁最小可用所需的在場名單與進行中房間清單(正式聚合端點見 T4,P2)。
|
||||
@Get("lobby")
|
||||
async getLobby(@Query("userId") userId: string) {
|
||||
return this.lobby.getLobbySnapshot(userId);
|
||||
}
|
||||
|
||||
// T38:取得或建立與某角色的 1:1 房間,取代舊版由呼叫端自行產生 sessionId 的做法。
|
||||
@Post("solo")
|
||||
async createSolo(@Body() body: CreateSoloRoomBody) {
|
||||
const room = await this.soloChat.getOrCreateRoom(body.userId, body.characterId);
|
||||
return { id: room.id, mode: room.mode, status: room.status };
|
||||
}
|
||||
|
||||
// K-1 驗收:角色(或呼叫端)可取得完整在場名單。
|
||||
// T12 驗收:支援從指定游標(afterSeq/beforeSeq)分頁取歷程;未帶游標時預設回傳最新一頁,
|
||||
// 讓重新進入未關閉的房間可以看到先前對話(含 hasMore/nextCursor 供前端接續翻頁)。
|
||||
// T11 驗收:附上 unread(lastReadSeq/firstUnreadSeq/unreadCount),供前端定位第一則未讀分隔線;
|
||||
// 不帶 userId 時預設查房主本人(本系統目前為單一使用者情境,房主即唯一讀者)。
|
||||
@Get(":roomId")
|
||||
get(@Param("roomId") roomId: string) {
|
||||
const room = this.room.get(roomId);
|
||||
async get(
|
||||
@Param("roomId") roomId: string,
|
||||
@Query("afterSeq") afterSeq?: string,
|
||||
@Query("beforeSeq") beforeSeq?: string,
|
||||
@Query("limit") limit?: string,
|
||||
@Query("userId") userId?: string,
|
||||
) {
|
||||
const room = await this.room.get(roomId);
|
||||
const page = await this.room.getTurnsPage(roomId, {
|
||||
afterSeq: afterSeq !== undefined ? Number(afterSeq) : undefined,
|
||||
beforeSeq: beforeSeq !== undefined ? Number(beforeSeq) : undefined,
|
||||
limit: limit !== undefined ? Number(limit) : undefined,
|
||||
});
|
||||
const unread = await this.room.getUnreadInfo(roomId, userId ?? room.userId);
|
||||
// T9:房間頁可看到該房累計用量,僅供觀察。
|
||||
const usage = await this.room.getUsageSummary(roomId);
|
||||
return {
|
||||
id: room.id,
|
||||
mode: room.mode,
|
||||
status: room.status,
|
||||
topicSeed: room.topicSeed,
|
||||
participants: this.room.roster(roomId),
|
||||
muted: room.muted,
|
||||
participants: await this.room.roster(roomId),
|
||||
turnCount: room.turnCounter,
|
||||
turns: room.turns.map((turn) => ({ speakerId: turn.speakerId, speakerLabel: turn.speakerLabel, content: turn.content })),
|
||||
turns: page.turns.map((turn) => ({
|
||||
seq: turn.turnIndex,
|
||||
speakerId: turn.speakerId,
|
||||
speakerLabel: turn.speakerLabel,
|
||||
content: turn.content,
|
||||
kind: turn.kind,
|
||||
})),
|
||||
hasMore: page.hasMore,
|
||||
nextCursor: page.nextCursor,
|
||||
unread,
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
// T10:SSE 推送。turn/member/closed 三類事件,id 為 RoomTurn.seq(同一號碼軸,見
|
||||
// RoomEventsService 註解);支援 Last-Event-ID(瀏覽器重連自動帶)與 ?afterSeq= 手動指定,
|
||||
// 兩者擇一(Last-Event-ID 優先),斷線期間錯過的 turn 一律從 DB 補齊、不遺漏。
|
||||
@Sse(":roomId/stream")
|
||||
stream(
|
||||
@Param("roomId") roomId: string,
|
||||
@Query("afterSeq") afterSeqQuery?: string,
|
||||
@Headers("last-event-id") lastEventId?: string,
|
||||
): Observable<MessageEvent> {
|
||||
const startAfterSeq = lastEventId !== undefined ? Number(lastEventId) : afterSeqQuery !== undefined ? Number(afterSeqQuery) : -1;
|
||||
|
||||
return new Observable<MessageEvent>((subscriber) => {
|
||||
let lastSentSeq = startAfterSeq;
|
||||
let replaying = true;
|
||||
const buffered: RoomStreamEvent[] = [];
|
||||
|
||||
const forward = (event: RoomStreamEvent) => {
|
||||
if (event.seq <= lastSentSeq && event.type === "turn") return; // turn 事件有真正的 seq,避免補播時重複
|
||||
lastSentSeq = Math.max(lastSentSeq, event.seq);
|
||||
subscriber.next({ id: String(event.seq), type: event.type, data: event.data });
|
||||
};
|
||||
|
||||
// 先訂閱即時事件(暫存到 replay 查完再送),避免「查 DB 期間」發生的新事件被漏掉。
|
||||
const unsubscribe = this.roomEvents.subscribe(roomId, (event) => {
|
||||
if (replaying) {
|
||||
buffered.push(event);
|
||||
return;
|
||||
}
|
||||
forward(event);
|
||||
});
|
||||
|
||||
this.room
|
||||
.getTurnsSince(roomId, startAfterSeq)
|
||||
.then((missed) => {
|
||||
for (const turn of missed) {
|
||||
forward({
|
||||
type: "turn",
|
||||
seq: turn.turnIndex,
|
||||
data: { speakerId: turn.speakerId, speakerLabel: turn.speakerLabel, content: turn.content, kind: turn.kind },
|
||||
});
|
||||
}
|
||||
replaying = false;
|
||||
for (const event of buffered) forward(event);
|
||||
})
|
||||
.catch((err) => subscriber.error(err));
|
||||
|
||||
const heartbeat = setInterval(() => subscriber.next({ type: "ping", data: {} }), 15000);
|
||||
|
||||
return () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// T11:回報已讀游標(單調遞增、不可回退)。
|
||||
@Post(":roomId/read")
|
||||
async reportRead(@Param("roomId") roomId: string, @Body() body: ReadReceiptBody) {
|
||||
await this.room.reportRead(roomId, body.userId, body.lastReadSeq);
|
||||
return this.room.getUnreadInfo(roomId, body.userId);
|
||||
}
|
||||
|
||||
// K-2/K-4 群聊訊息:使用者發言後,依發言權分配決定哪些角色本輪回應,依衝動值高到低依序生成。
|
||||
// T38:SOLO 房走既有 1:1 對話管線(DialogueService,含快速通道/作息可用度/破例規則),
|
||||
// 回傳形狀與舊版 POST /chat/:characterId 一致,不是 GROUP/SELF_CHAT 的 {speakers:[...]}。
|
||||
@Post(":roomId/message")
|
||||
async message(@Param("roomId") roomId: string, @Body() body: MessageBody) {
|
||||
// T43:套用角色期間不可以自己的身分發話,一律先擋在這裡(SOLO/GROUP 兩種房型共用同一個入口)。
|
||||
await this.possession.assertNotPossessing(body.userId);
|
||||
const now = body.now ? new Date(body.now) : new Date();
|
||||
const room = this.room.get(roomId);
|
||||
const userLabel = body.userLabel ?? body.userId;
|
||||
|
||||
this.room.addTurn(roomId, USER_SPEAKER_ID, userLabel, body.text, now);
|
||||
const soloRoomCheck = await this.room.get(roomId);
|
||||
if (soloRoomCheck.mode === "SOLO") {
|
||||
return this.soloChat.sendMessage(roomId, body.userId, body.text, { seed: body.seed, now });
|
||||
}
|
||||
|
||||
const speakers = await this.speakingRight.decideSpeakers(room, USER_SPEAKER_ID, body.text, now);
|
||||
// 舊版 room 是共享的可變物件,addTurn 會就地 push/遞增,因此決定發言者與逐位生成時看到的
|
||||
// 都是「加上這一輪最新發言後」的狀態;DB 版改為每次快照,須在每次 addTurn 後重新 get() 一次,
|
||||
// 才能維持「候選者的沉默計數已計入使用者剛發的這句、後面候選者能看到前面候選者剛講的話」語意。
|
||||
await this.room.addTurn(roomId, USER_SPEAKER_ID, userLabel, body.text, now);
|
||||
let room = await this.room.get(roomId);
|
||||
|
||||
// T43:套用中的角色不能被這個回覆迴圈搶著代為發言。
|
||||
const possessedIds = await this.possession.getPossessedCharacterIds();
|
||||
const speakers = await this.speakingRight.decideSpeakers(room, USER_SPEAKER_ID, body.text, now, possessedIds);
|
||||
const replies: { characterId: string; text: string; score: number }[] = [];
|
||||
for (const candidate of speakers) {
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({
|
||||
@@ -82,8 +261,16 @@ export class RoomController {
|
||||
const reply = await this.generation.generateReply(room, candidate.characterId, USER_SPEAKER_ID, userLabel, body.text, {
|
||||
now,
|
||||
});
|
||||
this.room.addTurn(roomId, candidate.characterId, speakerLabel, reply.text, now);
|
||||
replies.push({ characterId: candidate.characterId, text: reply.text, score: candidate.score });
|
||||
// T30:全域靜默暫停或重試全部失敗時 reply.text 為空——靜默略過,不寫 RoomTurn、不對使用者
|
||||
// 顯示任何提示;使用者本人的發話已於上方寫入,不受影響,恢復後的下一次發言會正常繼續。
|
||||
if (reply.text) {
|
||||
const turn = await this.room.addTurn(roomId, candidate.characterId, speakerLabel, reply.text, now);
|
||||
if (reply.usage) {
|
||||
await this.room.recordUsage(roomId, { ...reply.usage, turnSeq: turn.turnIndex });
|
||||
}
|
||||
room = await this.room.get(roomId);
|
||||
replies.push({ characterId: candidate.characterId, text: reply.text, score: candidate.score });
|
||||
}
|
||||
}
|
||||
|
||||
return { speakers: replies };
|
||||
@@ -100,8 +287,15 @@ export class RoomController {
|
||||
return this.message(roomId, body);
|
||||
}
|
||||
|
||||
// T38:SOLO 房關房走既有睡眠固化(MemoryConsolidationService,取代舊版 POST /session/:id/end);
|
||||
// GROUP/SELF_CHAT 房沿用既有多人群聊記憶投影(RoomConsolidationService)。
|
||||
@Post(":roomId/end")
|
||||
async end(@Param("roomId") roomId: string) {
|
||||
const room = await this.room.get(roomId);
|
||||
if (room.mode === "SOLO") {
|
||||
await this.soloChat.endRoom(roomId);
|
||||
return { ok: true };
|
||||
}
|
||||
await this.consolidation.consolidate(roomId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -4,17 +4,34 @@ import { EmotionModule } from "../emotion/emotion.module.js";
|
||||
import { RelationshipModule } from "../relationship/relationship.module.js";
|
||||
import { MemoryModule } from "../memory/memory.module.js";
|
||||
import { LlmModule } from "../llm/llm.module.js";
|
||||
import { ClockModule } from "../clock/clock.module.js";
|
||||
import { ScheduleModule } from "../schedule/schedule.module.js";
|
||||
import { TaskModule } from "../task/task.module.js";
|
||||
import { PersonalityModule } from "../personality/personality.module.js";
|
||||
import { RomanceModule } from "../romance/romance.module.js";
|
||||
import { PushModule } from "../push/push.module.js";
|
||||
import { EpilogueModule } from "../epilogue/epilogue.module.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 { RoomConsolidationService } from "./room-consolidation.service.js";
|
||||
import { SelfChatService } from "./self-chat.service.js";
|
||||
import { LobbyService } from "./lobby.service.js";
|
||||
import { RoomSchedulerService } from "./room-scheduler.service.js";
|
||||
import { SoloChatService } from "./solo-chat.service.js";
|
||||
import { RoomEventsService } from "./room-events.service.js";
|
||||
import { RoomIntervalService } from "./room-interval.service.js";
|
||||
import { BehaviorSelectionService } from "./behavior-selection.service.js";
|
||||
import { InvitationService } from "../lobby/invitation.service.js";
|
||||
import { RoomController } from "./room.controller.js";
|
||||
import { RoomNotificationService } from "./room-notification.service.js";
|
||||
import { PossessionService } from "./possession.service.js";
|
||||
import { PossessionController } from "./possession.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, EmotionModule, RelationshipModule, MemoryModule, LlmModule],
|
||||
controllers: [RoomController],
|
||||
imports: [PrismaModule, EmotionModule, RelationshipModule, MemoryModule, LlmModule, ClockModule, ScheduleModule, TaskModule, PersonalityModule, RomanceModule, PushModule, EpilogueModule],
|
||||
controllers: [RoomController, PossessionController],
|
||||
providers: [
|
||||
RoomService,
|
||||
CharacterRelationshipService,
|
||||
@@ -22,7 +39,32 @@ import { RoomController } from "./room.controller.js";
|
||||
RoomGenerationService,
|
||||
RoomConsolidationService,
|
||||
SelfChatService,
|
||||
LobbyService,
|
||||
RoomSchedulerService,
|
||||
SoloChatService,
|
||||
RoomEventsService,
|
||||
RoomIntervalService,
|
||||
BehaviorSelectionService,
|
||||
InvitationService,
|
||||
RoomNotificationService,
|
||||
PossessionService,
|
||||
],
|
||||
exports: [
|
||||
RoomService,
|
||||
CharacterRelationshipService,
|
||||
SpeakingRightService,
|
||||
RoomGenerationService,
|
||||
RoomConsolidationService,
|
||||
SelfChatService,
|
||||
LobbyService,
|
||||
RoomSchedulerService,
|
||||
SoloChatService,
|
||||
RoomEventsService,
|
||||
RoomIntervalService,
|
||||
BehaviorSelectionService,
|
||||
InvitationService,
|
||||
RoomNotificationService,
|
||||
PossessionService,
|
||||
],
|
||||
exports: [RoomService, CharacterRelationshipService, SpeakingRightService, RoomGenerationService, RoomConsolidationService, SelfChatService],
|
||||
})
|
||||
export class RoomModule {}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import type { EmotionTag } from "@kokorone/shared";
|
||||
import type { RoomTurnKind } from "@kokorone/db";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { CLOCK, type Clock } from "../clock/clock.js";
|
||||
import { LobbyService } from "./lobby.service.js";
|
||||
import { RoomEventsService } from "./room-events.service.js";
|
||||
|
||||
export type RoomMode = "GROUP" | "SELF_CHAT";
|
||||
export type RoomMode = "GROUP" | "SELF_CHAT" | "SOLO"; // T38:SOLO 為既有 1:1 聊天併入房間後的房型
|
||||
export type RoomStatus = "ACTIVE" | "ENDED";
|
||||
export const USER_SPEAKER_ID = "USER";
|
||||
|
||||
@@ -16,6 +21,7 @@ export interface RoomTurnRecord {
|
||||
speakerId: string; // characterId,或使用者固定用 USER_SPEAKER_ID
|
||||
speakerLabel: string;
|
||||
content: string;
|
||||
kind: RoomTurnKind; // T6:TALK/ACTION/DELEGATE/LEAVE/SYSTEM,API 對外契約尚未擴充(見 T19-P3)
|
||||
emotionTag?: EmotionTag;
|
||||
emotionIntensity?: number;
|
||||
timestamp: Date;
|
||||
@@ -27,103 +33,320 @@ export interface Room {
|
||||
mode: RoomMode;
|
||||
status: RoomStatus;
|
||||
topicSeed?: string;
|
||||
muted: boolean; // T31:靜音——不推播、不自動播放語音,但自走照常進行(不是暫停)
|
||||
participants: Map<string, RoomParticipantState>;
|
||||
turns: RoomTurnRecord[];
|
||||
turnCounter: number;
|
||||
}
|
||||
|
||||
let roomSeq = 0;
|
||||
function nextRoomId(): string {
|
||||
roomSeq += 1;
|
||||
return `room-${roomSeq}-${roomSeq * 31 + 7}`;
|
||||
}
|
||||
|
||||
// K-1 在場者模型:群聊/自聊 session 的參與者名冊與共用場景記錄。
|
||||
// 刻意採用純記憶體儲存(同 C-1 WorkingMemoryService 的設計),因為這是 session 期間的暫存場景,
|
||||
// session 結束後會由 K-5 的固化流程萃取成各角色的長期記憶,原始記錄本身不需要落地保存。
|
||||
// T3:K-1 在場者模型與 K-2~K-6 群聊/自聊改為 Prisma 持久化(破壞性替換,不留記憶體相容層)。
|
||||
// 對外方法語意維持原樣(create/get/roster/addTurn/switchToGroup/end),供既有呼叫端
|
||||
// (room.controller/self-chat/speaking-right/room-consolidation)改為 await 後直接沿用。
|
||||
//
|
||||
// silentStreak/lastSpokeAtTurn 不落地成獨立欄位,改由 RoomTurn 歷史在 hydrate() 時重新算出:
|
||||
// 對任一角色 c,lastSpokeAtTurn(c) = 最後一則由 c 發言的 turn.seq(無則 -1);
|
||||
// silentStreak(c) = (turnCounter - 1) - lastSpokeAtTurn(c)(從未發言則等於 turnCounter)。
|
||||
// 這與舊版「每輪逐一遞增/發言者歸零」的逐步更新結果完全等價(純函數重算,無需額外欄位)。
|
||||
//
|
||||
// speakerLabel 舊版是呼叫端逐輪傳入的即時稱呼;DB 版改為 hydrate 時依當下的角色別名/使用者名稱
|
||||
// 重新解析(USER_SPEAKER_ID 一律取房主 User.name),準確度等同於「以目前名稱回溯」,不逐輪落地。
|
||||
@Injectable()
|
||||
export class RoomService {
|
||||
private readonly rooms = new Map<string, Room>();
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(CLOCK) private readonly clock: Clock,
|
||||
private readonly lobby: LobbyService,
|
||||
private readonly roomEvents: RoomEventsService,
|
||||
) {}
|
||||
|
||||
create(userId: string, characterIds: string[], mode: RoomMode, topicSeed?: string): Room {
|
||||
const room: Room = {
|
||||
id: nextRoomId(),
|
||||
userId,
|
||||
mode,
|
||||
status: "ACTIVE",
|
||||
topicSeed,
|
||||
participants: new Map(
|
||||
characterIds.map((characterId) => [characterId, { characterId, silentStreak: 0, lastSpokeAtTurn: -1 }]),
|
||||
),
|
||||
turns: [],
|
||||
turnCounter: 0,
|
||||
};
|
||||
this.rooms.set(room.id, room);
|
||||
return room;
|
||||
async create(userId: string, characterIds: string[], mode: RoomMode = "GROUP", topicSeed?: string): Promise<Room> {
|
||||
const dbRoom = await this.prisma.client.room.create({
|
||||
data: {
|
||||
userId,
|
||||
mode,
|
||||
status: "ACTIVE",
|
||||
topicSeed,
|
||||
// 舊版 K-1~K-6 的房間是無「不可跨房」約束的暫存場景(同一角色可同時存在於多個測試房間),
|
||||
// 這裡不設 activeCharacterId(維持 null),R5 的跨房唯一約束由 T24 起的大廳常駐流轉負責啟用。
|
||||
members: { create: characterIds.map((characterId) => ({ characterId })) },
|
||||
},
|
||||
});
|
||||
return this.hydrate(dbRoom.id);
|
||||
}
|
||||
|
||||
get(roomId: string): Room {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) {
|
||||
throw new NotFoundException(`聊天室不存在:${roomId}`);
|
||||
}
|
||||
return room;
|
||||
async get(roomId: string): Promise<Room> {
|
||||
return this.hydrate(roomId);
|
||||
}
|
||||
|
||||
// K-1 驗收:角色(或呼叫端)可取得完整在場名單。
|
||||
roster(roomId: string): string[] {
|
||||
return [...this.get(roomId).participants.keys()];
|
||||
async roster(roomId: string): Promise<string[]> {
|
||||
const room = await this.hydrate(roomId);
|
||||
return [...room.participants.keys()];
|
||||
}
|
||||
|
||||
addTurn(
|
||||
async addTurn(
|
||||
roomId: string,
|
||||
speakerId: string,
|
||||
speakerLabel: string,
|
||||
content: string,
|
||||
now: Date = new Date(),
|
||||
now?: Date,
|
||||
emotion?: { tag: EmotionTag; intensity: number },
|
||||
): RoomTurnRecord {
|
||||
const room = this.get(roomId);
|
||||
const turnIndex = room.turnCounter;
|
||||
room.turnCounter += 1;
|
||||
kind: RoomTurnKind = "TALK",
|
||||
): Promise<RoomTurnRecord> {
|
||||
const effectiveNow = now ?? this.clock.now();
|
||||
const speakerCharacterId = speakerId === USER_SPEAKER_ID ? null : speakerId;
|
||||
|
||||
const turn: RoomTurnRecord = {
|
||||
turnIndex,
|
||||
const turn = await this.prisma.client.$transaction(async (tx) => {
|
||||
const last = await tx.roomTurn.findFirst({ where: { roomId }, orderBy: { seq: "desc" } });
|
||||
const seq = (last?.seq ?? -1) + 1;
|
||||
const created = await tx.roomTurn.create({
|
||||
data: { roomId, seq, kind, speakerCharacterId, content, createdAt: effectiveNow },
|
||||
});
|
||||
await tx.room.update({ where: { id: roomId }, data: { lastAdvancedAt: effectiveNow } });
|
||||
return created;
|
||||
});
|
||||
|
||||
const record: RoomTurnRecord = {
|
||||
turnIndex: turn.seq,
|
||||
speakerId,
|
||||
speakerLabel,
|
||||
content,
|
||||
kind,
|
||||
emotionTag: emotion?.tag,
|
||||
emotionIntensity: emotion?.intensity,
|
||||
timestamp: now,
|
||||
timestamp: turn.createdAt,
|
||||
};
|
||||
room.turns.push(turn);
|
||||
// T10:新 RoomTurn 即時推送給所有已連上 SSE 的訂閱者。
|
||||
// T19-P3:kind 隨 SSE 一併推送,前端才能依 TALK/ACTION/DELEGATE/LEAVE/SYSTEM 分別呈現。
|
||||
this.roomEvents.emit(roomId, { type: "turn", seq: turn.seq, data: { speakerId, speakerLabel, content, kind } });
|
||||
return record;
|
||||
}
|
||||
|
||||
// 發言冷卻/被冷落累積:發言者本輪清空沉默計數,其他在場角色沉默計數 +1。
|
||||
for (const participant of room.participants.values()) {
|
||||
if (participant.characterId === speakerId) {
|
||||
participant.lastSpokeAtTurn = turnIndex;
|
||||
participant.silentStreak = 0;
|
||||
} else {
|
||||
participant.silentStreak += 1;
|
||||
}
|
||||
async switchToGroup(roomId: string): Promise<Room> {
|
||||
await this.prisma.client.room.update({ where: { id: roomId }, data: { mode: "GROUP" } });
|
||||
return this.hydrate(roomId);
|
||||
}
|
||||
|
||||
async end(roomId: string, closeReason: string = "SESSION_ENDED"): Promise<Room> {
|
||||
const now = this.clock.now();
|
||||
await this.prisma.client.room.update({
|
||||
where: { id: roomId },
|
||||
data: { status: "CLOSED", closedAt: now, closeReason },
|
||||
});
|
||||
// T24:房間關閉時把仍在場的成員送回大廳;對從未登記 activeCharacterId 的既有 K 群組
|
||||
// 自聊/群聊測試房是安全的 no-op(見 LobbyService.closeRoomAndReturnMembers 註解)。
|
||||
await this.lobby.closeRoomAndReturnMembers(roomId, now);
|
||||
const room = await this.hydrate(roomId);
|
||||
// T10:房間關閉即時通知;seq 沿用目前 turnCounter(與 turn 事件同一號碼軸,見 RoomEventsService 註解)。
|
||||
this.roomEvents.emit(roomId, { type: "closed", seq: room.turnCounter, data: { reason: closeReason } });
|
||||
return room;
|
||||
}
|
||||
|
||||
// 舊版記憶體實作於 session 結束後整筆丟棄;DB 版改為保留歷史紀錄供 T12 重新進入與稽核,故改為 no-op,
|
||||
// 不做實體刪除(呼叫端沿用既有「end() 後接著 clear()」的呼叫順序,維持語意相容不需改呼叫端)。
|
||||
async clear(_roomId: string): Promise<void> {}
|
||||
|
||||
// T12:分頁取得房間歷程,支援從指定游標往前或往後取,讓未關閉的房間可重新進入並看到先前對話。
|
||||
// afterSeq:取 seq > afterSeq 的較新那一段(往後翻);beforeSeq:取 seq < beforeSeq 的較舊那一段(往前翻)。
|
||||
// 兩者都未提供時,回傳最新一頁(依 limit)。回傳一律依 seq 由舊到新排序,並附 hasMore/nextCursor。
|
||||
async getTurnsPage(
|
||||
roomId: string,
|
||||
options: { afterSeq?: number; beforeSeq?: number; limit?: number } = {},
|
||||
): Promise<{ turns: RoomTurnRecord[]; hasMore: boolean; nextCursor: number | null }> {
|
||||
const room = await this.prisma.client.room.findUnique({ where: { id: roomId }, include: { user: true } });
|
||||
if (!room) {
|
||||
throw new NotFoundException(`聊天室不存在:${roomId}`);
|
||||
}
|
||||
const limit = options.limit ?? 50;
|
||||
// 沒給 afterSeq 時(不論有沒有給 beforeSeq)都以「離 beforeSeq(或離最新)最近的一頁」為準,
|
||||
// 從新到舊查再反轉——重新進入房間時預設看到的是最新對話,而不是從頭。只有明確給 afterSeq
|
||||
// (往後翻、要更新的那一段)才改成從舊到新查。
|
||||
const goingBackward = options.afterSeq === undefined;
|
||||
|
||||
const where: { roomId: string; seq?: { gt?: number; lt?: number } } = { roomId };
|
||||
if (options.afterSeq !== undefined) where.seq = { ...where.seq, gt: options.afterSeq };
|
||||
if (options.beforeSeq !== undefined) where.seq = { ...where.seq, lt: options.beforeSeq };
|
||||
|
||||
const rows = await this.prisma.client.roomTurn.findMany({
|
||||
where,
|
||||
orderBy: { seq: goingBackward ? "desc" : "asc" },
|
||||
take: limit + 1,
|
||||
});
|
||||
const hasMore = rows.length > limit;
|
||||
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||
const ordered = goingBackward ? [...page].reverse() : page;
|
||||
|
||||
const resolveLabel = await this.buildLabelResolver(ordered.flatMap((t) => (t.speakerCharacterId ? [t.speakerCharacterId] : [])));
|
||||
const turns: RoomTurnRecord[] = ordered.map((turn) => ({
|
||||
turnIndex: turn.seq,
|
||||
speakerId: turn.speakerCharacterId ?? USER_SPEAKER_ID,
|
||||
speakerLabel: turn.speakerCharacterId ? resolveLabel(turn.speakerCharacterId) : room.user.name,
|
||||
content: turn.content,
|
||||
kind: turn.kind,
|
||||
timestamp: turn.createdAt,
|
||||
}));
|
||||
|
||||
const nextCursor = hasMore ? (goingBackward ? turns[0]?.turnIndex ?? null : turns.at(-1)?.turnIndex ?? null) : null;
|
||||
return { turns, hasMore, nextCursor };
|
||||
}
|
||||
|
||||
// T10:SSE 重新連線時的完整回補(不設上限,`?afterSeq=` 或 `Last-Event-ID` 斷線期間錯過的訊息
|
||||
// 一律補齊),依 seq 由舊到新排序。
|
||||
async getTurnsSince(roomId: string, afterSeq: number): Promise<RoomTurnRecord[]> {
|
||||
const room = await this.prisma.client.room.findUnique({ where: { id: roomId }, include: { user: true } });
|
||||
if (!room) {
|
||||
throw new NotFoundException(`聊天室不存在:${roomId}`);
|
||||
}
|
||||
const rows = await this.prisma.client.roomTurn.findMany({
|
||||
where: { roomId, seq: { gt: afterSeq } },
|
||||
orderBy: { seq: "asc" },
|
||||
});
|
||||
const resolveLabel = await this.buildLabelResolver(rows.flatMap((t) => (t.speakerCharacterId ? [t.speakerCharacterId] : [])));
|
||||
return rows.map((turn) => ({
|
||||
turnIndex: turn.seq,
|
||||
speakerId: turn.speakerCharacterId ?? USER_SPEAKER_ID,
|
||||
speakerLabel: turn.speakerCharacterId ? resolveLabel(turn.speakerCharacterId) : room.user.name,
|
||||
content: turn.content,
|
||||
kind: turn.kind,
|
||||
timestamp: turn.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
// T11:回報已讀游標,單調遞增、不可回退——即使呼叫端傳來比目前紀錄舊的 seq 也只取兩者較大值。
|
||||
async reportRead(roomId: string, userId: string, lastReadSeq: number): Promise<void> {
|
||||
const existing = await this.prisma.client.roomReadState.findUnique({
|
||||
where: { roomId_userId: { roomId, userId } },
|
||||
});
|
||||
const nextValue = Math.max(existing?.lastReadSeq ?? -1, lastReadSeq);
|
||||
await this.prisma.client.roomReadState.upsert({
|
||||
where: { roomId_userId: { roomId, userId } },
|
||||
update: { lastReadSeq: nextValue },
|
||||
create: { roomId, userId, lastReadSeq: nextValue },
|
||||
});
|
||||
}
|
||||
|
||||
// T11:進房時依 lastReadSeq 定位到第一則未讀;未讀一律全部保留,不做摘要也不折疊。
|
||||
async getUnreadInfo(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
): Promise<{ lastReadSeq: number; firstUnreadSeq: number | null; unreadCount: number }> {
|
||||
const [readState, room] = await Promise.all([
|
||||
this.prisma.client.roomReadState.findUnique({ where: { roomId_userId: { roomId, userId } } }),
|
||||
this.prisma.client.room.findUnique({ where: { id: roomId } }),
|
||||
]);
|
||||
if (!room) {
|
||||
throw new NotFoundException(`聊天室不存在:${roomId}`);
|
||||
}
|
||||
const lastReadSeq = readState?.lastReadSeq ?? -1;
|
||||
const latestTurn = await this.prisma.client.roomTurn.findFirst({ where: { roomId }, orderBy: { seq: "desc" } });
|
||||
const turnCounter = latestTurn ? latestTurn.seq + 1 : 0;
|
||||
const unreadCount = Math.max(0, turnCounter - (lastReadSeq + 1));
|
||||
return { lastReadSeq, firstUnreadSeq: unreadCount > 0 ? lastReadSeq + 1 : null, unreadCount };
|
||||
}
|
||||
|
||||
// T9:每次角色發言生成後寫入一筆用量紀錄,僅供觀察,不因用量而暫停或降頻——呼叫端(RoomGenerationService/
|
||||
// DialogueService 的呼叫方)在拿到 Provider 回傳的 usage 後才呼叫本方法,沒有 usage 就不記錄。
|
||||
async recordUsage(roomId: string, usage: { model: string; promptTokens: number; completionTokens: number; turnSeq: number }): Promise<void> {
|
||||
await this.prisma.client.roomUsageLog.create({
|
||||
data: {
|
||||
roomId,
|
||||
model: usage.model,
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
turnSeq: usage.turnSeq,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// T9:房間頁與大廳呈現用的累計用量——一律以 roomId 聚合(T33 亦沿用同一個聚合方式,避免逐筆查詢)。
|
||||
async getUsageSummary(roomId: string): Promise<{ totalPromptTokens: number; totalCompletionTokens: number; generationCount: number }> {
|
||||
const aggregate = await this.prisma.client.roomUsageLog.aggregate({
|
||||
where: { roomId },
|
||||
_sum: { promptTokens: true, completionTokens: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
return {
|
||||
totalPromptTokens: aggregate._sum.promptTokens ?? 0,
|
||||
totalCompletionTokens: aggregate._sum.completionTokens ?? 0,
|
||||
generationCount: aggregate._count._all,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildLabelResolver(characterIds: string[]): Promise<(characterId: string) => string> {
|
||||
const ids = [...new Set(characterIds)];
|
||||
const [characters, aliases] = await Promise.all([
|
||||
this.prisma.client.character.findMany({ where: { id: { in: ids } } }),
|
||||
this.prisma.client.characterAlias.findMany({ where: { characterId: { in: ids } } }),
|
||||
]);
|
||||
return (characterId: string) => {
|
||||
const alias = aliases.find((a) => a.characterId === characterId);
|
||||
if (alias?.nickname) return alias.nickname;
|
||||
if (alias?.formalName) return alias.formalName;
|
||||
return characters.find((c) => c.id === characterId)?.id ?? characterId;
|
||||
};
|
||||
}
|
||||
|
||||
private async hydrate(roomId: string): Promise<Room> {
|
||||
const dbRoom = await this.prisma.client.room.findUnique({
|
||||
where: { id: roomId },
|
||||
include: {
|
||||
user: true,
|
||||
members: { where: { leftAt: null } },
|
||||
turns: { orderBy: { seq: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!dbRoom) {
|
||||
throw new NotFoundException(`聊天室不存在:${roomId}`);
|
||||
}
|
||||
|
||||
return turn;
|
||||
const characterIds = [
|
||||
...dbRoom.members.map((member) => member.characterId),
|
||||
...dbRoom.turns.flatMap((turn) => (turn.speakerCharacterId ? [turn.speakerCharacterId] : [])),
|
||||
];
|
||||
const labelFor = await this.buildLabelResolver(characterIds);
|
||||
|
||||
const turnCounter = dbRoom.turns.length === 0 ? 0 : dbRoom.turns[dbRoom.turns.length - 1].seq + 1;
|
||||
|
||||
const lastSpokeAtTurn = new Map<string, number>();
|
||||
for (const turn of dbRoom.turns) {
|
||||
if (turn.speakerCharacterId) lastSpokeAtTurn.set(turn.speakerCharacterId, turn.seq);
|
||||
}
|
||||
|
||||
const participants = new Map<string, RoomParticipantState>();
|
||||
for (const member of dbRoom.members) {
|
||||
const last = lastSpokeAtTurn.get(member.characterId) ?? -1;
|
||||
participants.set(member.characterId, {
|
||||
characterId: member.characterId,
|
||||
lastSpokeAtTurn: last,
|
||||
silentStreak: last === -1 ? turnCounter : turnCounter - 1 - last,
|
||||
});
|
||||
}
|
||||
|
||||
const turns: RoomTurnRecord[] = dbRoom.turns.map((turn) => ({
|
||||
turnIndex: turn.seq,
|
||||
speakerId: turn.speakerCharacterId ?? USER_SPEAKER_ID,
|
||||
speakerLabel: turn.speakerCharacterId ? labelFor(turn.speakerCharacterId) : dbRoom.user.name,
|
||||
content: turn.content,
|
||||
kind: turn.kind,
|
||||
timestamp: turn.createdAt,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: dbRoom.id,
|
||||
userId: dbRoom.userId,
|
||||
mode: dbRoom.mode as RoomMode,
|
||||
status: dbRoom.status === "ACTIVE" ? "ACTIVE" : "ENDED",
|
||||
topicSeed: dbRoom.topicSeed ?? undefined,
|
||||
muted: dbRoom.muted,
|
||||
participants,
|
||||
turns,
|
||||
turnCounter,
|
||||
};
|
||||
}
|
||||
|
||||
switchToGroup(roomId: string): Room {
|
||||
const room = this.get(roomId);
|
||||
room.mode = "GROUP";
|
||||
return room;
|
||||
}
|
||||
|
||||
end(roomId: string): Room {
|
||||
const room = this.get(roomId);
|
||||
room.status = "ENDED";
|
||||
return room;
|
||||
}
|
||||
|
||||
clear(roomId: string): void {
|
||||
this.rooms.delete(roomId);
|
||||
// T31:靜音開關——不提供「暫停」,自走排程與生成不受影響,只影響是否推播/自動播語音(由呼叫端判斷)。
|
||||
async setMuted(roomId: string, muted: boolean): Promise<Room> {
|
||||
await this.prisma.client.room.update({ where: { id: roomId }, data: { muted } });
|
||||
return this.hydrate(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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 { DELEGATE_TEMPLATES } from "./delegate-templates.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";
|
||||
|
||||
// T7 驗收:房內出現委託行為(DELEGATE)後,委託頁可看到對應 Task,且能追溯到來源房間與該則發言。
|
||||
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-delegate-user";
|
||||
const CHAR_A = "test-selfchat-delegate-char-a";
|
||||
const CHAR_B = "test-selfchat-delegate-char-b";
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.client.task.deleteMany({ where: { userId } });
|
||||
await prisma.client.pushToken.deleteMany({ where: { userId } });
|
||||
await prisma.client.pushNotificationLog.deleteMany({ where: { userId } });
|
||||
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 } });
|
||||
});
|
||||
|
||||
async function createCharacter(id: string) {
|
||||
await prisma.client.character.create({
|
||||
data: {
|
||||
id,
|
||||
source: "ORIGINAL",
|
||||
buildStatus: "BUILT",
|
||||
basicInfo: "測試",
|
||||
backgroundStory: "測試",
|
||||
personalityArchetype: "元氣",
|
||||
likesDislikes: "測試",
|
||||
goalsObsessions: "測試",
|
||||
speechStyle: "測試",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("SelfChatService DELEGATE → Task(T7)", () => {
|
||||
it("多輪推進直到出現 DELEGATE 行為:對應 Task 已建立,且可追溯到來源房間與該則發言", async () => {
|
||||
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
|
||||
await pushToken.register(userId, "test-selfchat-delegate-token", "expo");
|
||||
await createCharacter(CHAR_A);
|
||||
await createCharacter(CHAR_B);
|
||||
// 兩位角色輪流發言,即使其中一位途中因話題耗盡 LEAVE,房間仍會由另一位繼續,
|
||||
// 避免單一角色因內容重複觸發 LEAVE 而過早結束、導致跑不到 DELEGATE。
|
||||
const room = await selfChat.start(userId, [CHAR_A, CHAR_B], "測試話題", clock.now());
|
||||
|
||||
let delegateTurnContent: string | null = null;
|
||||
let delegateCharacterId: string | null = null;
|
||||
const kinds: string[] = [];
|
||||
for (let i = 0; i < 150 && !delegateTurnContent; i++) {
|
||||
const result = await selfChat.advance(room.id, new Date(clock.now().getTime() + i * 1000));
|
||||
if (result.turn) kinds.push(result.turn.content.slice(0, 6));
|
||||
if (result.turn && DELEGATE_TEMPLATES.some((t) => t.line === result.turn!.content)) {
|
||||
delegateTurnContent = result.turn.content;
|
||||
delegateCharacterId = result.turn.characterId;
|
||||
}
|
||||
}
|
||||
expect(delegateTurnContent, `跑了 ${kinds.length} 輪都沒出現 DELEGATE`).not.toBeNull();
|
||||
|
||||
const tasks = await prisma.client.task.findMany({ where: { userId, characterId: delegateCharacterId! } });
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].sourceRoomId).toBe(room.id);
|
||||
expect(tasks[0].rawText).toBe(delegateTurnContent);
|
||||
|
||||
const matchedTemplate = DELEGATE_TEMPLATES.find((t) => t.line === delegateTurnContent)!;
|
||||
expect(tasks[0].content).toBe(matchedTemplate.taskContent);
|
||||
expect(tasks[0].type).toBe(matchedTemplate.taskType);
|
||||
|
||||
// 委託頁(listForUser)可看到這筆委託。
|
||||
const listed = await taskService.listForUser(delegateCharacterId!, userId);
|
||||
expect(listed.some((t) => t.id === tasks[0].id)).toBe(true);
|
||||
|
||||
// T31:角色提出委託也算重要事件之一,應該推播(房間未靜音)。
|
||||
expect(mockPushProvider.sent.some((m) => m.body === matchedTemplate.taskContent)).toBe(true);
|
||||
}, 90000);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
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);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user