diff --git a/README.md b/README.md index a2270e2..b167156 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,11 @@ pnpm run smoke A # 執行 A 群組冒煙測試;all 表示依序執行 A~R 4. **程序常駐與重啟**:用 systemd unit(或 pm2)分別管理 api/web 兩個 Node 行程,設定 `Restart=on-failure`;健康檢查沿用 `GET /health` (api)與首頁 200(web),與 `scripts/dev-restart.mjs` 開發環境用的 - 檢查邏輯一致。 + 檢查邏輯一致。**房間自走(T37 起)需要 api 常駐**——排程與 api 同行程 + (node-cron,見 `apps/api/src/room/room-scheduler.service.ts`),api 停機 + 期間房間不會推進,重啟後只會補推進一次接上目前時間,不會補齊停機期間 + 錯過的輪數;因排程未加分散式鎖,**api 只能起一個實例**,多實例會讓同一 + 房間被重複推進。 5. **反向代理/TLS**:nginx 或雲端負載平衡器終止 TLS 後轉發到 api/web 對應埠號,對外只曝露 443。 diff --git a/apps/api/package.json b/apps/api/package.json index 99e2504..89e9e60 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" } } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 28ad00b..547ef11 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/canon/anchor.service.ts b/apps/api/src/canon/anchor.service.ts index b8b8d0b..7809464 100644 --- a/apps/api/src/canon/anchor.service.ts +++ b/apps/api/src/canon/anchor.service.ts @@ -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 } }); } diff --git a/apps/api/src/canon/behavior-rule-projection.test.ts b/apps/api/src/canon/behavior-rule-projection.test.ts new file mode 100644 index 0000000..1ad0b33 --- /dev/null +++ b/apps/api/src/canon/behavior-rule-projection.test.ts @@ -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); +}); diff --git a/apps/api/src/canon/canon.controller.ts b/apps/api/src/canon/canon.controller.ts index 7ed7eb1..7d0d2f0 100644 --- a/apps/api/src/canon/canon.controller.ts +++ b/apps/api/src/canon/canon.controller.ts @@ -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 } }); + } } diff --git a/apps/api/src/canon/canon.module.ts b/apps/api/src/canon/canon.module.ts index 3c50867..7e48a95 100644 --- a/apps/api/src/canon/canon.module.ts +++ b/apps/api/src/canon/canon.module.ts @@ -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 {} diff --git a/apps/api/src/canon/emotion-baseline-projection.test.ts b/apps/api/src/canon/emotion-baseline-projection.test.ts new file mode 100644 index 0000000..505af0b --- /dev/null +++ b/apps/api/src/canon/emotion-baseline-projection.test.ts @@ -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); +}); diff --git a/apps/api/src/canon/ending-detection.service.test.ts b/apps/api/src/canon/ending-detection.service.test.ts new file mode 100644 index 0000000..8c2e1c0 --- /dev/null +++ b/apps/api/src/canon/ending-detection.service.test.ts @@ -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); +}); diff --git a/apps/api/src/canon/ending-detection.service.ts b/apps/api/src/canon/ending-detection.service.ts new file mode 100644 index 0000000..5867b20 --- /dev/null +++ b/apps/api/src/canon/ending-detection.service.ts @@ -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 { + 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 }, + }); + } +} diff --git a/apps/api/src/canon/projection.service.ts b/apps/api/src/canon/projection.service.ts index 2af0635..23cf45f 100644 --- a/apps/api/src/canon/projection.service.ts +++ b/apps/api/src/canon/projection.service.ts @@ -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 { @@ -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 { + await this.clearProjectedData(sceneId); + await this.projectScene(sceneId); + } + + // T39:階段二(投影)失敗時,把這個場景已經寫入一部分的投影結果清乾淨,回到「已裁決但未投影」 + // 可安全重跑的狀態(不是留一半髒資料卡住),與 reprojectScene() 共用同一份清除邏輯。 + async clearProjectedData(sceneId: string): Promise { 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 } }); } } diff --git a/apps/api/src/canon/scene-import.service.test.ts b/apps/api/src/canon/scene-import.service.test.ts new file mode 100644 index 0000000..ca708e5 --- /dev/null +++ b/apps/api/src/canon/scene-import.service.test.ts @@ -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); +}); diff --git a/apps/api/src/canon/scene-import.service.ts b/apps/api/src/canon/scene-import.service.ts index d7498cf..336a029 100644 --- a/apps/api/src/canon/scene-import.service.ts +++ b/apps/api/src/canon/scene-import.service.ts @@ -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 { + 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 { + 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 { + 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) }; } } diff --git a/apps/api/src/canon/timeline.service.ts b/apps/api/src/canon/timeline.service.ts index 8f9bef9..7cb4c61 100644 --- a/apps/api/src/canon/timeline.service.ts +++ b/apps/api/src/canon/timeline.service.ts @@ -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 { + await this.prisma.client.sceneContradiction.update({ + where: { id: contradictionId }, + data: { status: "RESOLVED", resolution }, + }); + } } diff --git a/apps/api/src/chat/chat.controller.ts b/apps/api/src/chat/chat.controller.ts deleted file mode 100644 index b97a2b4..0000000 --- a/apps/api/src/chat/chat.controller.ts +++ /dev/null @@ -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 }; - } -} diff --git a/apps/api/src/chat/chat.module.ts b/apps/api/src/chat/chat.module.ts deleted file mode 100644 index 67e9798..0000000 --- a/apps/api/src/chat/chat.module.ts +++ /dev/null @@ -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 {} diff --git a/apps/api/src/clock/clock.module.ts b/apps/api/src/clock/clock.module.ts new file mode 100644 index 0000000..7f58d4d --- /dev/null +++ b/apps/api/src/clock/clock.module.ts @@ -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 {} diff --git a/apps/api/src/clock/clock.ts b/apps/api/src/clock/clock.ts new file mode 100644 index 0000000..b7189e8 --- /dev/null +++ b/apps/api/src/clock/clock.ts @@ -0,0 +1,7 @@ +// T36:可注入時鐘介面。本計畫所有新程式一律從時鐘取時間、不直接 `new Date()`, +// 以便測試以 ControllableClock(見 controllable-clock.ts)快轉時間而不需真的等待。 +export interface Clock { + now(): Date; +} + +export const CLOCK = Symbol("CLOCK"); diff --git a/apps/api/src/clock/controllable-clock.test.ts b/apps/api/src/clock/controllable-clock.test.ts new file mode 100644 index 0000000..68ecaec --- /dev/null +++ b/apps/api/src/clock/controllable-clock.test.ts @@ -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"); + }); +}); diff --git a/apps/api/src/clock/controllable-clock.ts b/apps/api/src/clock/controllable-clock.ts new file mode 100644 index 0000000..c5ab543 --- /dev/null +++ b/apps/api/src/clock/controllable-clock.ts @@ -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); + } +} diff --git a/apps/api/src/clock/system-clock.service.ts b/apps/api/src/clock/system-clock.service.ts new file mode 100644 index 0000000..98a70d5 --- /dev/null +++ b/apps/api/src/clock/system-clock.service.ts @@ -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(); + } +} diff --git a/apps/api/src/epilogue/canon-reclamation.service.ts b/apps/api/src/epilogue/canon-reclamation.service.ts index 23f669e..2c1be6a 100644 --- a/apps/api/src/epilogue/canon-reclamation.service.ts +++ b/apps/api/src/epilogue/canon-reclamation.service.ts @@ -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 } }); diff --git a/apps/api/src/epilogue/constants.ts b/apps/api/src/epilogue/constants.ts index 7b61451..5c70e40 100644 --- a/apps/api/src/epilogue/constants.ts +++ b/apps/api/src/epilogue/constants.ts @@ -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 = { + FROZEN: { numerator: 0, perRealDays: 1 }, + SLOW: { numerator: 1, perRealDays: 3 }, + REALTIME: { numerator: 1, perRealDays: 1 }, + FAST: { numerator: 3, perRealDays: 1 }, +}; diff --git a/apps/api/src/epilogue/continued-canon-import.test.ts b/apps/api/src/epilogue/continued-canon-import.test.ts new file mode 100644 index 0000000..8f4073e --- /dev/null +++ b/apps/api/src/epilogue/continued-canon-import.test.ts @@ -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); +}); diff --git a/apps/api/src/epilogue/epilogue-progress.service.test.ts b/apps/api/src/epilogue/epilogue-progress.service.test.ts new file mode 100644 index 0000000..4a8f564 --- /dev/null +++ b/apps/api/src/epilogue/epilogue-progress.service.test.ts @@ -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); +}); diff --git a/apps/api/src/epilogue/epilogue-progress.service.ts b/apps/api/src/epilogue/epilogue-progress.service.ts new file mode 100644 index 0000000..42847c0 --- /dev/null +++ b/apps/api/src/epilogue/epilogue-progress.service.ts @@ -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 { + 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); + } + } +} diff --git a/apps/api/src/epilogue/epilogue.controller.ts b/apps/api/src/epilogue/epilogue.controller.ts index bddc799..10d28a6 100644 --- a/apps/api/src/epilogue/epilogue.controller.ts +++ b/apps/api/src/epilogue/epilogue.controller.ts @@ -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); diff --git a/apps/api/src/epilogue/epilogue.module.ts b/apps/api/src/epilogue/epilogue.module.ts index 11ece4c..b00fbad 100644 --- a/apps/api/src/epilogue/epilogue.module.ts +++ b/apps/api/src/epilogue/epilogue.module.ts @@ -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 {} diff --git a/apps/api/src/epilogue/life-event.service.ts b/apps/api/src/epilogue/life-event.service.ts index 663fa67..149f791 100644 --- a/apps/api/src/epilogue/life-event.service.ts +++ b/apps/api/src/epilogue/life-event.service.ts @@ -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" }, + }); + } } diff --git a/apps/api/src/epilogue/time-flow.service.test.ts b/apps/api/src/epilogue/time-flow.service.test.ts new file mode 100644 index 0000000..0c56ad0 --- /dev/null +++ b/apps/api/src/epilogue/time-flow.service.test.ts @@ -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); +}); diff --git a/apps/api/src/epilogue/time-flow.service.ts b/apps/api/src/epilogue/time-flow.service.ts index 731bef0..21d43d2 100644 --- a/apps/api/src/epilogue/time-flow.service.ts +++ b/apps/api/src/epilogue/time-flow.service.ts @@ -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 } }); diff --git a/apps/api/src/llm/behavior-reinforcement.service.ts b/apps/api/src/llm/behavior-reinforcement.service.ts index 271a61c..f190f26 100644 --- a/apps/api/src/llm/behavior-reinforcement.service.ts +++ b/apps/api/src/llm/behavior-reinforcement.service.ts @@ -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 { + 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 }, diff --git a/apps/api/src/llm/claude-provider.service.ts b/apps/api/src/llm/claude-provider.service.ts index 26f0515..8a06b49 100644 --- a/apps/api/src/llm/claude-provider.service.ts +++ b/apps/api/src/llm/claude-provider.service.ts @@ -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 { @@ -98,4 +108,37 @@ export class ClaudeProvider implements LLMProvider { } } } + + // T5:LLM 兼底判斷指稱哪個角色;提示詞要求只能回傳候選 id 或 NONE,避免自行編造不存在的角色。 + async identifyCharacter(utterance: string, candidates: CharacterCallCandidate[]): Promise { + 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 { + 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 未回傳可解析的判斷依據)", + }; + } } diff --git a/apps/api/src/llm/dialogue.controller.ts b/apps/api/src/llm/dialogue.controller.ts index 6cc4708..f705806 100644 --- a/apps/api/src/llm/dialogue.controller.ts +++ b/apps/api/src/llm/dialogue.controller.ts @@ -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, diff --git a/apps/api/src/llm/dialogue.service.ts b/apps/api/src/llm/dialogue.service.ts index 418160f..8544858 100644 --- a/apps/api/src/llm/dialogue.service.ts +++ b/apps/api/src/llm/dialogue.service.ts @@ -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 }; } } diff --git a/apps/api/src/llm/generate-with-resilience.test.ts b/apps/api/src/llm/generate-with-resilience.test.ts new file mode 100644 index 0000000..c89426e --- /dev/null +++ b/apps/api/src/llm/generate-with-resilience.test.ts @@ -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); +}); diff --git a/apps/api/src/llm/generate-with-resilience.ts b/apps/api/src/llm/generate-with-resilience.ts new file mode 100644 index 0000000..f322461 --- /dev/null +++ b/apps/api/src/llm/generate-with-resilience.ts @@ -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 { + 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 { + 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; +} diff --git a/apps/api/src/llm/global-pause.service.ts b/apps/api/src/llm/global-pause.service.ts new file mode 100644 index 0000000..f8d7a41 --- /dev/null +++ b/apps/api/src/llm/global-pause.service.ts @@ -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} 次,觸發全域靜默暫停:所有房間與大廳自走生成停止,不對使用者顯示任何提示`, + ); + } + } +} diff --git a/apps/api/src/llm/keyword-overlap.ts b/apps/api/src/llm/keyword-overlap.ts new file mode 100644 index 0000000..84e368b --- /dev/null +++ b/apps/api/src/llm/keyword-overlap.ts @@ -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); +} diff --git a/apps/api/src/llm/llm-provider.ts b/apps/api/src/llm/llm-provider.ts index a9317b7..c29e132 100644 --- a/apps/api/src/llm/llm-provider.ts +++ b/apps/api/src/llm/llm-provider.ts @@ -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; stream(context: GenerationContext): AsyncIterable; + // T5:大廳對話式呼叫的規則比對失敗或指稱含糊時的 LLM 兼底——從候選清單判斷指稱的是哪個角色, + // 必須回傳候選清單中的某個 id 或 null(無法判斷/沒有人被指稱),不可自行編造不存在的角色。 + identifyCharacter(utterance: string, candidates: CharacterCallCandidate[]): Promise; + // T13:每章投影完成後都呼叫,判斷這個場景是否為整部作品的最終結局;只依場景內容本身判斷, + // 不以章節標題關鍵字預篩(呼叫端不會把標題資訊帶進來影響判斷)。 + detectEnding(sceneSummary: string, sceneRawText: string, workTitle: string): Promise; } export const LLM_PROVIDER = Symbol("LLM_PROVIDER"); diff --git a/apps/api/src/llm/llm.module.ts b/apps/api/src/llm/llm.module.ts index 29be8d5..a1c809e 100644 --- a/apps/api/src/llm/llm.module.ts +++ b/apps/api/src/llm/llm.module.ts @@ -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 {} diff --git a/apps/api/src/llm/mock-provider.service.test.ts b/apps/api/src/llm/mock-provider.service.test.ts new file mode 100644 index 0000000..90571a1 --- /dev/null +++ b/apps/api/src/llm/mock-provider.service.test.ts @@ -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 { + 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); + }); +}); diff --git a/apps/api/src/llm/mock-provider.service.ts b/apps/api/src/llm/mock-provider.service.ts index 1565bc9..2b515ea 100644 --- a/apps/api/src/llm/mock-provider.service.ts +++ b/apps/api/src/llm/mock-provider.service.ts @@ -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 { + 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 { @@ -25,4 +68,28 @@ export class MockProvider implements LLMProvider { yield char; } } + + // T5:無真實語言理解能力,以關鍵字重疊度模擬「LLM 兼底」——沿用既有 K-4 的重疊度公式, + // 分數最高且達門檻者視為指稱對象;沒有任何候選達門檻則回傳 null(無法判斷,不可編造)。 + async identifyCharacter(utterance: string, candidates: CharacterCallCandidate[]): Promise { + 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 { + 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 推論)" }; + } } diff --git a/apps/api/src/llm/prompt-builder.ts b/apps/api/src/llm/prompt-builder.ts index 24da42d..773d418 100644 --- a/apps/api/src/llm/prompt-builder.ts +++ b/apps/api/src/llm/prompt-builder.ts @@ -15,7 +15,7 @@ const RELATIONSHIP_STAGE_LABEL: Record = { }; 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, ``] : []), `【輸出格式】`, `回應為純文字。若有動作/表情描寫,用全形星號包住,例如 *臉紅撇過頭*;不要用任何其他標記語法,不要加角色名稱前綴。`, ``, diff --git a/apps/api/src/llm/resilience-constants.ts b/apps/api/src/llm/resilience-constants.ts new file mode 100644 index 0000000..43919ea --- /dev/null +++ b/apps/api/src/llm/resilience-constants.ts @@ -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; diff --git a/apps/api/src/llm/types.ts b/apps/api/src/llm/types.ts index 46c4f19..4b40097 100644 --- a/apps/api/src/llm/types.ts +++ b/apps/api/src/llm/types.ts @@ -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 用量統計的來源,僅供觀察,不影響生成行為 } diff --git a/apps/api/src/lobby/call.service.test.ts b/apps/api/src/lobby/call.service.test.ts new file mode 100644 index 0000000..e9c93ec --- /dev/null +++ b/apps/api/src/lobby/call.service.test.ts @@ -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); +}); diff --git a/apps/api/src/lobby/call.service.ts b/apps/api/src/lobby/call.service.ts new file mode 100644 index 0000000..bc39d23 --- /dev/null +++ b/apps/api/src/lobby/call.service.ts @@ -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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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}才有空,到時候再來找她吧。`; + } +} diff --git a/apps/api/src/lobby/constants.ts b/apps/api/src/lobby/constants.ts new file mode 100644 index 0000000..10c3298 --- /dev/null +++ b/apps/api/src/lobby/constants.ts @@ -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; diff --git a/apps/api/src/lobby/invitation.service.test.ts b/apps/api/src/lobby/invitation.service.test.ts new file mode 100644 index 0000000..991e3e7 --- /dev/null +++ b/apps/api/src/lobby/invitation.service.test.ts @@ -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); +}); diff --git a/apps/api/src/lobby/invitation.service.ts b/apps/api/src/lobby/invitation.service.ts new file mode 100644 index 0000000..ee4da03 --- /dev/null +++ b/apps/api/src/lobby/invitation.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/apps/api/src/lobby/lobby-aggregation.service.test.ts b/apps/api/src/lobby/lobby-aggregation.service.test.ts new file mode 100644 index 0000000..9304a83 --- /dev/null +++ b/apps/api/src/lobby/lobby-aggregation.service.test.ts @@ -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); +}); diff --git a/apps/api/src/lobby/lobby-aggregation.service.ts b/apps/api/src/lobby/lobby-aggregation.service.ts new file mode 100644 index 0000000..6a82556 --- /dev/null +++ b/apps/api/src/lobby/lobby-aggregation.service.ts @@ -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(); + 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, + }; + }), + }; + } +} diff --git a/apps/api/src/lobby/lobby.controller.ts b/apps/api/src/lobby/lobby.controller.ts new file mode 100644 index 0000000..9019ea1 --- /dev/null +++ b/apps/api/src/lobby/lobby.controller.ts @@ -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); + } +} diff --git a/apps/api/src/lobby/lobby.module.ts b/apps/api/src/lobby/lobby.module.ts new file mode 100644 index 0000000..7f64baa --- /dev/null +++ b/apps/api/src/lobby/lobby.module.ts @@ -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 {} diff --git a/apps/api/src/memory/memory-consolidation.service.ts b/apps/api/src/memory/memory-consolidation.service.ts index 4004299..5bb2c26 100644 --- a/apps/api/src/memory/memory-consolidation.service.ts +++ b/apps/api/src/memory/memory-consolidation.service.ts @@ -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, }, }); } diff --git a/apps/api/src/personality/personality-resolution.service.test.ts b/apps/api/src/personality/personality-resolution.service.test.ts new file mode 100644 index 0000000..03806e1 --- /dev/null +++ b/apps/api/src/personality/personality-resolution.service.test.ts @@ -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); + }); +}); diff --git a/apps/api/src/personality/personality-resolution.service.ts b/apps/api/src/personality/personality-resolution.service.ts new file mode 100644 index 0000000..1672f66 --- /dev/null +++ b/apps/api/src/personality/personality-resolution.service.ts @@ -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 { + 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 }; + } +} diff --git a/apps/api/src/personality/personality.module.ts b/apps/api/src/personality/personality.module.ts index 175b3d7..ad23fe8 100644 --- a/apps/api/src/personality/personality.module.ts +++ b/apps/api/src/personality/personality.module.ts @@ -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 {} diff --git a/apps/api/src/romance/confession.service.ts b/apps/api/src/romance/confession.service.ts index 9b5410c..ee8df05 100644 --- a/apps/api/src/romance/confession.service.ts +++ b/apps/api/src/romance/confession.service.ts @@ -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 { + async confess(characterId: string, userId: string, now: Date = new Date(), roomId?: string): Promise { + // 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 }; diff --git a/apps/api/src/romance/milestone.service.ts b/apps/api/src/romance/milestone.service.ts index 6635344..c133c92 100644 --- a/apps/api/src/romance/milestone.service.ts +++ b/apps/api/src/romance/milestone.service.ts @@ -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 { + // 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]; diff --git a/apps/api/src/romance/romance-eligibility.service.ts b/apps/api/src/romance/romance-eligibility.service.ts index fc63afb..d6d21eb 100644 --- a/apps/api/src/romance/romance-eligibility.service.ts +++ b/apps/api/src/romance/romance-eligibility.service.ts @@ -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 { - const result = await this.evaluate(characterId, userId); + async checkAdultContentAllowed(characterId: string, userId: string, roomId?: string): Promise { + 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 { + private async evaluate(characterId: string, userId: string, roomId?: string): Promise { + // 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 { diff --git a/apps/api/src/romance/romance.controller.ts b/apps/api/src/romance/romance.controller.ts index b544af3..760c3bf 100644 --- a/apps/api/src/romance/romance.controller.ts +++ b/apps/api/src/romance/romance.controller.ts @@ -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, ); } } diff --git a/apps/api/src/romance/room-boundary.test.ts b/apps/api/src/romance/room-boundary.test.ts new file mode 100644 index 0000000..562939f --- /dev/null +++ b/apps/api/src/romance/room-boundary.test.ts @@ -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); +}); diff --git a/apps/api/src/romance/room-context-guard.ts b/apps/api/src/romance/room-context-guard.ts new file mode 100644 index 0000000..3023b79 --- /dev/null +++ b/apps/api/src/romance/room-context-guard.ts @@ -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 { + if (!roomId) return false; // 未帶 roomId 視為既有非房間情境(相容舊呼叫端),不套用房型邊界 + const room = await prisma.client.room.findUnique({ where: { id: roomId } }); + return room !== null && room.mode !== "SOLO"; +} diff --git a/apps/api/src/room/behavior-selection.service.test.ts b/apps/api/src/room/behavior-selection.service.test.ts new file mode 100644 index 0000000..75b6981 --- /dev/null +++ b/apps/api/src/room/behavior-selection.service.test.ts @@ -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 { + 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); +}); diff --git a/apps/api/src/room/behavior-selection.service.ts b/apps/api/src/room/behavior-selection.service.ts new file mode 100644 index 0000000..4c28bd1 --- /dev/null +++ b/apps/api/src/room/behavior-selection.service.ts @@ -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 { + return tag === "CALM" ? "calm" : (tag.toLowerCase() as "joy" | "sad" | "alert" | "shy" | "grumpy"); +} + +function weightedSample(weights: Partial>, 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 { + 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> = { + 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 }; + } +} diff --git a/apps/api/src/room/character-relationship.service.test.ts b/apps/api/src/room/character-relationship.service.test.ts new file mode 100644 index 0000000..d801849 --- /dev/null +++ b/apps/api/src/room/character-relationship.service.test.ts @@ -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); +}); diff --git a/apps/api/src/room/character-relationship.service.ts b/apps/api/src/room/character-relationship.service.ts index 32d4a65..2650864 100644 --- a/apps/api/src/room/character-relationship.service.ts +++ b/apps/api/src/room/character-relationship.service.ts @@ -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 { + 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 { + 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 { + 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 }, + }); } } diff --git a/apps/api/src/room/constants.ts b/apps/api/src/room/constants.ts index f8c3410..4c3aed8 100644 --- a/apps/api/src/room/constants.ts +++ b/apps/api/src/room/constants.ts @@ -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; diff --git a/apps/api/src/room/cross-work-room.test.ts b/apps/api/src/room/cross-work-room.test.ts new file mode 100644 index 0000000..cbace7b --- /dev/null +++ b/apps/api/src/room/cross-work-room.test.ts @@ -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); +}); diff --git a/apps/api/src/room/cross-world-note.ts b/apps/api/src/room/cross-world-note.ts new file mode 100644 index 0000000..085a62a --- /dev/null +++ b/apps/api/src/room/cross-world-note.ts @@ -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; +} diff --git a/apps/api/src/room/delegate-templates.ts b/apps/api/src/room/delegate-templates.ts new file mode 100644 index 0000000..9b1ecad --- /dev/null +++ b/apps/api/src/room/delegate-templates.ts @@ -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" }, +]; diff --git a/apps/api/src/room/lobby-behavior-params.test.ts b/apps/api/src/room/lobby-behavior-params.test.ts new file mode 100644 index 0000000..6da1484 --- /dev/null +++ b/apps/api/src/room/lobby-behavior-params.test.ts @@ -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([]); + }); +}); diff --git a/apps/api/src/room/lobby-behavior-params.ts b/apps/api/src/room/lobby-behavior-params.ts new file mode 100644 index 0000000..7cffcd6 --- /dev/null +++ b/apps/api/src/room/lobby-behavior-params.ts @@ -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; + }); + }); +} diff --git a/apps/api/src/room/lobby.service.test.ts b/apps/api/src/room/lobby.service.test.ts new file mode 100644 index 0000000..cb40fab --- /dev/null +++ b/apps/api/src/room/lobby.service.test.ts @@ -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(); + }); +}); diff --git a/apps/api/src/room/lobby.service.ts b/apps/api/src/room/lobby.service.ts new file mode 100644 index 0000000..a7cb995 --- /dev/null +++ b/apps/api/src/room/lobby.service.ts @@ -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 { + const last = await this.prisma.client.roomTurn.findFirst({ where: { roomId }, orderBy: { seq: "desc" } }); + return (last?.seq ?? -1) + 1; + } + + async getOrCreateLobby(userId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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), + })), + }; + } +} diff --git a/apps/api/src/room/possession.controller.ts b/apps/api/src/room/possession.controller.ts new file mode 100644 index 0000000..b1fa385 --- /dev/null +++ b/apps/api/src/room/possession.controller.ts @@ -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); + } +} diff --git a/apps/api/src/room/possession.service.test.ts b/apps/api/src/room/possession.service.test.ts new file mode 100644 index 0000000..d6b64c8 --- /dev/null +++ b/apps/api/src/room/possession.service.test.ts @@ -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); // 套用不改變既有的在場判定機制 + }); +}); diff --git a/apps/api/src/room/possession.service.ts b/apps/api/src/room/possession.service.ts new file mode 100644 index 0000000..a5e299d --- /dev/null +++ b/apps/api/src/room/possession.service.ts @@ -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 { + const user = await this.prisma.client.user.findUniqueOrThrow({ where: { id: userId } }); + return { possessingCharacterId: user.possessingCharacterId }; + } + + // 所有「自走/自動回覆」的候選者篩選都要排除目前被(任何使用者)套用中的角色——她的發言只能 + // 來自套用者的輸入,不能被 LLM/自走機制搶著幫她講話。 + async getPossessedCharacterIds(): Promise> { + 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 { + const status = await this.getStatus(userId); + if (status.possessingCharacterId) { + throw new BadRequestException("套用角色期間不可以自己的身分發話,請先退出套用"); + } + } + + // 可套用範圍為任何角色(含日後談、跨作品、buildStatus 未完成者),不設門檻;直接切換到新角色 + // 等同先退出舊的再套用新的(單一使用者一次只能套用一位)。 + async enter(userId: string, characterId: string, now: Date = new Date()): Promise { + 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 { + 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 { + 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 }; + } +} diff --git a/apps/api/src/room/privacy-boundary.test.ts b/apps/api/src/room/privacy-boundary.test.ts new file mode 100644 index 0000000..66b211f --- /dev/null +++ b/apps/api/src/room/privacy-boundary.test.ts @@ -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); +}); diff --git a/apps/api/src/room/room-consolidation.service.ts b/apps/api/src/room/room-consolidation.service.ts index 872b64d..8d6e6b6 100644 --- a/apps/api/src/room/room-consolidation.service.ts +++ b/apps/api/src/room/room-consolidation.service.ts @@ -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 { - 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 { + 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 { diff --git a/apps/api/src/room/room-events.service.test.ts b/apps/api/src/room/room-events.service.test.ts new file mode 100644 index 0000000..bbd7762 --- /dev/null +++ b/apps/api/src/room/room-events.service.test.ts @@ -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); + }); +}); diff --git a/apps/api/src/room/room-events.service.ts b/apps/api/src/room/room-events.service.ts new file mode 100644 index 0000000..a813669 --- /dev/null +++ b/apps/api/src/room/room-events.service.ts @@ -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; +} + +@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); + } +} diff --git a/apps/api/src/room/room-generation-usage.test.ts b/apps/api/src/room/room-generation-usage.test.ts new file mode 100644 index 0000000..8487158 --- /dev/null +++ b/apps/api/src/room/room-generation-usage.test.ts @@ -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); + }); +}); diff --git a/apps/api/src/room/room-generation.service.ts b/apps/api/src/room/room-generation.service.ts index e8e1925..671343c 100644 --- a/apps/api/src/room/room-generation.service.ts +++ b/apps/api/src/room/room-generation.service.ts @@ -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 }; } } diff --git a/apps/api/src/room/room-interval.service.test.ts b/apps/api/src/room/room-interval.service.test.ts new file mode 100644 index 0000000..9b8bad0 --- /dev/null +++ b/apps/api/src/room/room-interval.service.test.ts @@ -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 } }); + }); +}); diff --git a/apps/api/src/room/room-interval.service.ts b/apps/api/src/room/room-interval.service.ts new file mode 100644 index 0000000..d72e9f1 --- /dev/null +++ b/apps/api/src/room/room-interval.service.ts @@ -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 { + 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 { + 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 { + 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((sum, hit) => sum + hit, 0) / characterIds.length); + } + + private async computeBehaviorFactor(characterIds: string[], now: Date): Promise { + 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); + } +} + diff --git a/apps/api/src/room/room-notification.service.ts b/apps/api/src/room/room-notification.service.ts new file mode 100644 index 0000000..b4a1ce1 --- /dev/null +++ b/apps/api/src/room/room-notification.service.ts @@ -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 { + await this.notify(room, `${characterLabel}回你了`, content); + } + + // 自走過程中所有角色各自離場後,房間自動收尾關閉(非使用者主動按下「結束房間」)。 + async notifyRoomClosed(room: Room, reason: string): Promise { + await this.notify(room, "房間已自動關閉", `原因:${reason}`); + } + + async notifyDelegateProposed(room: Room, characterLabel: string, taskContent: string): Promise { + await this.notify(room, `${characterLabel}提出了委託`, taskContent); + } + + async notifyEndingDetectionPending(userId: string, characterLabel: string): Promise { + await this.push.sendToUser( + userId, + "有一段故事可能走向結局", + `${characterLabel}的故事似乎到了轉折點,要不要去看看、確認一下?`, + ); + } + + private async notify(room: Room, title: string, body: string): Promise { + if (room.muted) return; + await this.push.sendToUser(room.userId, title, body); + } +} diff --git a/apps/api/src/room/room-notification.test.ts b/apps/api/src/room/room-notification.test.ts new file mode 100644 index 0000000..30bbf3c --- /dev/null +++ b/apps/api/src/room/room-notification.test.ts @@ -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); +}); diff --git a/apps/api/src/room/room-persistence.integration.test.ts b/apps/api/src/room/room-persistence.integration.test.ts new file mode 100644 index 0000000..a4be659 --- /dev/null +++ b/apps/api/src/room/room-persistence.integration.test.ts @@ -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): Promise { + 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); + }); + }); +}); diff --git a/apps/api/src/room/room-scheduler.service.test.ts b/apps/api/src/room/room-scheduler.service.test.ts new file mode 100644 index 0000000..15de58b --- /dev/null +++ b/apps/api/src/room/room-scheduler.service.test.ts @@ -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); +}); diff --git a/apps/api/src/room/room-scheduler.service.ts b/apps/api/src/room/room-scheduler.service.ts new file mode 100644 index 0000000..de89205 --- /dev/null +++ b/apps/api/src/room/room-scheduler.service.ts @@ -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(); + + 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 { + 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 { + 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; + } +} diff --git a/apps/api/src/room/room-turns-page.test.ts b/apps/api/src/room/room-turns-page.test.ts new file mode 100644 index 0000000..ac9bbc3 --- /dev/null +++ b/apps/api/src/room/room-turns-page.test.ts @@ -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 } }); + } + }); +}); diff --git a/apps/api/src/room/room-unread.test.ts b/apps/api/src/room/room-unread.test.ts new file mode 100644 index 0000000..b6f9e06 --- /dev/null +++ b/apps/api/src/room/room-unread.test.ts @@ -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 逾時偶爾不夠。 +}); diff --git a/apps/api/src/room/room-usage.test.ts b/apps/api/src/room/room-usage.test.ts new file mode 100644 index 0000000..13762fa --- /dev/null +++ b/apps/api/src/room/room-usage.test.ts @@ -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 句"); + }); +}); diff --git a/apps/api/src/room/room.controller.ts b/apps/api/src/room/room.controller.ts index b41f6f6..3941688 100644 --- a/apps/api/src/room/room.controller.ts +++ b/apps/api/src/room/room.controller.ts @@ -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 { + const startAfterSeq = lastEventId !== undefined ? Number(lastEventId) : afterSeqQuery !== undefined ? Number(afterSeqQuery) : -1; + + return new Observable((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 }; } diff --git a/apps/api/src/room/room.module.ts b/apps/api/src/room/room.module.ts index e188d07..2621fe2 100644 --- a/apps/api/src/room/room.module.ts +++ b/apps/api/src/room/room.module.ts @@ -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 {} diff --git a/apps/api/src/room/room.service.ts b/apps/api/src/room/room.service.ts index 4e76c51..1d1d50c 100644 --- a/apps/api/src/room/room.service.ts +++ b/apps/api/src/room/room.service.ts @@ -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; 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(); + 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 { + 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 { + return this.hydrate(roomId); } // K-1 驗收:角色(或呼叫端)可取得完整在場名單。 - roster(roomId: string): string[] { - return [...this.get(roomId).participants.keys()]; + async roster(roomId: string): Promise { + 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 { + 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 { + 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 { + 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 {} + + // 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 { + 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 { + 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 { + 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 { + 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(); + for (const turn of dbRoom.turns) { + if (turn.speakerCharacterId) lastSpokeAtTurn.set(turn.speakerCharacterId, turn.seq); + } + + const participants = new Map(); + 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 { + await this.prisma.client.room.update({ where: { id: roomId }, data: { muted } }); + return this.hydrate(roomId); } } diff --git a/apps/api/src/room/self-chat-delegate.test.ts b/apps/api/src/room/self-chat-delegate.test.ts new file mode 100644 index 0000000..d051dd6 --- /dev/null +++ b/apps/api/src/room/self-chat-delegate.test.ts @@ -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); +}); diff --git a/apps/api/src/room/self-chat.service.test.ts b/apps/api/src/room/self-chat.service.test.ts new file mode 100644 index 0000000..e434835 --- /dev/null +++ b/apps/api/src/room/self-chat.service.test.ts @@ -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); +}); diff --git a/apps/api/src/room/self-chat.service.ts b/apps/api/src/room/self-chat.service.ts index 964e735..8edcca9 100644 --- a/apps/api/src/room/self-chat.service.ts +++ b/apps/api/src/room/self-chat.service.ts @@ -1,62 +1,58 @@ import { Injectable } from "@nestjs/common"; -import { RoomService, USER_SPEAKER_ID, type Room } from "./room.service.js"; +import { RoomService, type Room } from "./room.service.js"; import { SpeakingRightService } from "./speaking-right.service.js"; import { RoomGenerationService } from "./room-generation.service.js"; +import { LobbyService } from "./lobby.service.js"; +import { BehaviorSelectionService, type LeaveReason } from "./behavior-selection.service.js"; +import { DELEGATE_TEMPLATES } from "./delegate-templates.js"; import { PrismaService } from "../prisma/prisma.service.js"; -import { SELF_CHAT_MAX_TURNS, STALL_CHECK_WINDOW, TOPIC_TWIST_LINES, SELF_CHAT_WRAP_UP_LINES } from "./constants.js"; - -function normalize(text: string): string { - return text.trim().toLowerCase(); -} - -// 偵測最近幾輪是否開始空轉:內容高度重複,或字數持續下降(話越講越少,快聊不下去了)。 -function isStalling(recentContents: string[]): boolean { - if (recentContents.length < STALL_CHECK_WINDOW) { - return false; - } - const window = recentContents.slice(-STALL_CHECK_WINDOW); - const normalized = window.map(normalize); - const uniqueCount = new Set(normalized).size; - if (uniqueCount === 1) { - return true; // 完全重複 - } - const lengths = window.map((text) => text.length); - const isMonotonicDecline = lengths.every((length, index) => index === 0 || length <= lengths[index - 1]); - return isMonotonicDecline && lengths[0] > lengths[lengths.length - 1]; -} +import { TaskService } from "../task/task.service.js"; +import { RoomNotificationService } from "./room-notification.service.js"; +import { createSeededRandom, hashToSeed } from "../llm/seeded-random.js"; +import { SELF_CHAT_WRAP_UP_LINES } from "./constants.js"; +import { EpilogueProgressService } from "../epilogue/epilogue-progress.service.js"; +import { PossessionService } from "./possession.service.js"; export interface SelfChatAdvanceResult { ended: boolean; turn?: { characterId: string; content: string }; - reason?: "topic-exhausted" | "turn-cap" | "stalled-out"; + reason?: "all-left"; // 現在只有「所有角色都已個別離開」這一種收斂路徑(見 T6) } -// K-6 角色自聊:話題種子驅動、發言權機制沿用 K-4、空轉偵測注入轉折或收尾、輪數硬上限。 +const LEAVE_LINES: Record = { + SCHEDULE: "啊,我差不多該去忙別的事了,先這樣!", + EMOTIONAL_EXHAUSTION: "……我想自己靜一靜,先離開一下。", + TOPIC_EXHAUSTED: SELF_CHAT_WRAP_UP_LINES[0], + SLEEP_DEBT: "……不行了,真的撐不住了,我要去睡了……", // T32:熬夜累積睡眠債達門檻,明說撐不住想睡 +}; + +// K-6/T6 角色自聊:話題種子驅動、發言權機制沿用 K-4;每輪決定發言者後,行為種類(TALK/ACTION/ +// DELEGATE/LEAVE)改由 BehaviorSelectionService 依人格參數+情緒/行程/話題狀態算權重抽樣決定, +// LLM 只生成內容,不決定「做什麼」。LEAVE 取代舊版「空轉即整室轉折或收尾」的機制——角色離開是 +// 個別發生的(一次只有一位角色離開),房間在還有人在場時就持續運作,不再受 12 輪硬上限節制。 @Injectable() export class SelfChatService { - private readonly twistCounts = new Map(); - constructor( private readonly prisma: PrismaService, private readonly room: RoomService, private readonly speakingRight: SpeakingRightService, private readonly generation: RoomGenerationService, + private readonly lobby: LobbyService, + private readonly behaviorSelection: BehaviorSelectionService, + private readonly task: TaskService, + private readonly roomNotification: RoomNotificationService, + private readonly epilogueProgress: EpilogueProgressService, + private readonly possession: PossessionService, ) {} async start(userId: string, characterIds: string[], topicSeed: string, now: Date = new Date()) { - const room = this.room.create(userId, characterIds, "SELF_CHAT", topicSeed); - this.twistCounts.set(room.id, 0); - return room; + return this.room.create(userId, characterIds, "SELF_CHAT", topicSeed); } async advance(roomId: string, now: Date = new Date()): Promise { - const room = this.room.get(roomId); + const room = await this.room.get(roomId); if (room.mode !== "SELF_CHAT" || room.status !== "ACTIVE") { - return { ended: true, reason: "topic-exhausted" }; - } - - if (room.turnCounter >= SELF_CHAT_MAX_TURNS) { - return this.wrapUp(room, now); + return { ended: true, reason: "all-left" }; } const lastTurn = room.turns.at(-1); @@ -64,31 +60,80 @@ export class SelfChatService { const triggerContent = lastTurn?.content ?? room.topicSeed ?? "今天過得怎麼樣?"; const triggerLabel = lastTurn?.speakerLabel ?? "話題"; - const recentContents = room.turns.slice(-STALL_CHECK_WINDOW).map((turn) => turn.content); - const twistCount = this.twistCounts.get(room.id) ?? 0; - if (isStalling(recentContents)) { - if (twistCount >= 2) { - return this.wrapUp(room, now); - } - return this.injectTwist(room, twistCount, now); - } - - const candidates = await this.speakingRight.decideSpeakers(room, triggerSpeakerId, triggerContent, now); - const speakerId = candidates[0]?.characterId ?? [...room.participants.keys()][0]; + // T43:套用中的角色不能被自走/發言權機制搶著代為發言——排除她們後再決定這輪誰要開口。 + const possessedIds = await this.possession.getPossessedCharacterIds(); + const candidates = await this.speakingRight.decideSpeakers(room, triggerSpeakerId, triggerContent, now, possessedIds); + const eligibleParticipants = [...room.participants.keys()].filter((id) => !possessedIds.has(id)); + const speakerId = candidates[0]?.characterId ?? eligibleParticipants[0]; if (!speakerId) { - return this.wrapUp(room, now); + // 在場者不是真的清空(那才是「全員離場」該收尾的情況),只是全員目前都被套用中——安靜一輪, + // 等套用者以角色身分發話即可,不應該把房間直接收尾關閉。 + if (room.participants.size > 0) { + return { ended: false }; + } + return this.wrapUp(room); } const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: speakerId } }); const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId: speakerId } }); const speakerLabel = alias?.nickname || alias?.formalName || character.id; + // T34:房內自走本身就是「後日談推演」唯一自然發生的時間點——沿著這個既有 tick 順手推進 + // 該角色所屬作品的推演進度時鐘(非日後談作品此方法內部直接 no-op),不另外開一條背景排程。 + if (character.workId) { + await this.epilogueProgress.advanceIfInEpilogue(character.workId, now); + } + + const behavior = await this.behaviorSelection.selectBehavior(room, speakerId, now); + + if (behavior.kind === "LEAVE") { + const line = LEAVE_LINES[behavior.leaveReason!]; + await this.room.addTurn(roomId, speakerId, speakerLabel, line, now, undefined, "LEAVE"); + await this.lobby.leaveSpecificRoom(speakerId, roomId, room.userId, now, `AUTONOMOUS_LEAVE_${behavior.leaveReason}`); + return { ended: false, turn: { characterId: speakerId, content: line } }; + } + + // T7:DELEGATE 台詞與委託內容固定配對(同 LEAVE 的做法,見 delegate-templates.ts 註解), + // 寫入 RoomTurn 的同時直接建立 Task、標記 sourceRoomId,不需要使用者確認即成立。 + if (behavior.kind === "DELEGATE") { + const index = Math.floor(createSeededRandom(hashToSeed(`${roomId}:${room.turnCounter}:${speakerId}:${now.getTime()}:delegate`))() * DELEGATE_TEMPLATES.length); + const template = DELEGATE_TEMPLATES[index]; + await this.room.addTurn(roomId, speakerId, speakerLabel, template.line, now, undefined, "DELEGATE"); + await this.task.create( + speakerId, + room.userId, + { type: template.taskType, content: template.taskContent, rawText: template.line, sourceRoomId: roomId }, + now, + ); + await this.roomNotification.notifyDelegateProposed(room, speakerLabel, template.taskContent); + return { ended: false, turn: { characterId: speakerId, content: template.line } }; + } + const reply = await this.generation.generateReply(room, speakerId, triggerSpeakerId, triggerLabel, triggerContent, { now, }); - this.room.addTurn(roomId, speakerId, speakerLabel, reply.text, now); + // T30:全域靜默暫停或重試全部失敗時 reply.text 為空——這一輪是「靜默 tick」,不寫 RoomTurn、 + // 不計用量、也不對使用者顯示任何提示;lastAdvancedAt 因此不會更新,排程器下次 tick 會再次嘗試。 + if (!reply.text) { + return { ended: false }; + } + const content = behavior.kind === "ACTION" && !reply.text.includes("*") ? `*${reply.text}*` : reply.text; + const turn = await this.room.addTurn(roomId, speakerId, speakerLabel, content, now, undefined, behavior.kind); + if (reply.usage) { + await this.room.recordUsage(roomId, { ...reply.usage, turnSeq: turn.turnIndex }); + } + // T31 被喊或被點名:自走過程中角色主動點名喚使用者(內容提到使用者本名),視為需要使用者注意的 + // 事件;一般角色間閒聊(不提到使用者)不算,不推播。 + if (await this.mentionsUser(room.userId, content)) { + await this.roomNotification.notifyMentioned(room, speakerLabel, content); + } - return { ended: false, turn: { characterId: speakerId, content: reply.text } }; + return { ended: false, turn: { characterId: speakerId, content } }; + } + + private async mentionsUser(userId: string, content: string): Promise { + const user = await this.prisma.client.user.findUnique({ where: { id: userId } }); + return Boolean(user?.name && content.includes(user.name)); } // 使用者插話:切換為群聊模式,插話內容本身視為使用者的第一則群聊訊息。 @@ -96,32 +141,10 @@ export class SelfChatService { return this.room.switchToGroup(roomId); } - private async injectTwist(room: Room, twistCount: number, now: Date): Promise { - const speakerId = [...room.participants.keys()][twistCount % room.participants.size]; - const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: speakerId } }); - const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId: speakerId } }); - const speakerLabel = alias?.nickname || alias?.formalName || character.id; - const line = TOPIC_TWIST_LINES[twistCount % TOPIC_TWIST_LINES.length]; - - this.room.addTurn(room.id, speakerId, speakerLabel, line, now); - this.twistCounts.set(room.id, twistCount + 1); - return { ended: false, turn: { characterId: speakerId, content: line } }; - } - - private async wrapUp(room: Room, now: Date): Promise { - const speakerId = [...room.participants.keys()][0]; - if (speakerId) { - const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: speakerId } }); - const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId: speakerId } }); - const speakerLabel = alias?.nickname || alias?.formalName || character.id; - const line = SELF_CHAT_WRAP_UP_LINES[room.turnCounter % SELF_CHAT_WRAP_UP_LINES.length]; - this.room.addTurn(room.id, speakerId, speakerLabel, line, now); - } - this.twistCounts.delete(room.id); - this.room.end(room.id); - return { - ended: true, - reason: room.turnCounter >= SELF_CHAT_MAX_TURNS ? "turn-cap" : "stalled-out", - }; + // 所有角色都已透過 LEAVE 個別離開(在場名單清空)時,才真的把房間收尾關閉。 + private async wrapUp(room: Room): Promise { + await this.room.end(room.id, "ALL_LEFT"); + await this.roomNotification.notifyRoomClosed(room, "ALL_LEFT"); + return { ended: true, reason: "all-left" }; } } diff --git a/apps/api/src/room/solo-chat.service.ts b/apps/api/src/room/solo-chat.service.ts new file mode 100644 index 0000000..ed5fd35 --- /dev/null +++ b/apps/api/src/room/solo-chat.service.ts @@ -0,0 +1,75 @@ +import { Inject, Injectable, NotFoundException } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service.js"; +import { CLOCK, type Clock } from "../clock/clock.js"; +import { DialogueService, type HandleMessageOptions, type HandleMessageResult } from "../llm/dialogue.service.js"; +import { MemoryConsolidationService } from "../memory/memory-consolidation.service.js"; +import { RoomService, USER_SPEAKER_ID, type Room } from "./room.service.js"; +import { LobbyService } from "./lobby.service.js"; + +// T38:既有 1:1 聊天併入 SOLO 房。以 roomId 取代既有 sessionId 作為 WorkingMemoryService/ +// ContextAssemblerService/MemoryConsolidationService 的鍵值——這三者原本就把 sessionId 當成不透明 +// 字串鍵使用,沒有獨立的 session 資料表,因此「併入」在這幾層完全不需要改動,只需要呼叫端改傳 roomId。 +// 舊 sessionId 產生的工作記憶/長期記憶保留不動、不做資料遷移(舊 session 早已各自過期或已固化)。 +@Injectable() +export class SoloChatService { + constructor( + private readonly prisma: PrismaService, + private readonly room: RoomService, + private readonly lobby: LobbyService, + private readonly dialogue: DialogueService, + private readonly consolidation: MemoryConsolidationService, + @Inject(CLOCK) private readonly clock: Clock, + ) {} + + // 同一使用者對同一角色永遠沿用同一間進行中的 SOLO 房,直到明確呼叫 endRoom() 為止—— + // 對應舊版「使用同一 sessionId 持續同一場對話」的語意,但改由伺服器端管理,呼叫端不需要 + // 自己產生並記住 sessionId。 + async getOrCreateRoom(userId: string, characterId: string): Promise { + const existing = await this.prisma.client.room.findFirst({ + where: { userId, mode: "SOLO", status: "ACTIVE", members: { some: { characterId } } }, + }); + if (existing) return this.room.get(existing.id); + + // characterIds 傳空陣列:成員登記完全交給 lobby.enterRoom() 處理(它會建立帶 activeCharacterId + // 的那一筆),避免這裡再建一筆沒有 activeCharacterId 的重複 RoomMember。 + const created = await this.room.create(userId, [], "SOLO"); + // R5:進入 1:1 房即離開大廳,交由 LobbyService 統一管理跨房唯一性。 + await this.lobby.enterRoom(characterId, created.id, this.clock.now()); + return this.room.get(created.id); + } + + async sendMessage( + roomId: string, + userId: string, + text: string, + options: HandleMessageOptions = {}, + ): Promise { + const room = await this.room.get(roomId); + const characterId = [...room.participants.keys()][0]; + if (!characterId) { + throw new NotFoundException(`房間 ${roomId} 沒有對應角色`); + } + + const result = await this.dialogue.handleMessage(characterId, userId, roomId, text, options); + const now = options.now ?? this.clock.now(); + await this.room.addTurn(roomId, USER_SPEAKER_ID, userId, text, now); + if (result.text) { + const turn = await this.room.addTurn(roomId, characterId, characterId, result.text, now); + if (result.usage) { + await this.room.recordUsage(roomId, { ...result.usage, turnSeq: turn.turnIndex }); + } + } + return result; + } + + // 關房即觸發睡眠固化(取代舊版 POST /session/:sessionId/end),並沿用 RoomService.end() 內建的 + // T24 送回大廳流程。 + async endRoom(roomId: string): Promise { + const room = await this.room.get(roomId); + const characterId = [...room.participants.keys()][0]; + if (characterId) { + await this.consolidation.consolidate(characterId, roomId); + } + await this.room.end(roomId); + } +} diff --git a/apps/api/src/room/speaking-right-n-plus-one.test.ts b/apps/api/src/room/speaking-right-n-plus-one.test.ts new file mode 100644 index 0000000..a584c3f --- /dev/null +++ b/apps/api/src/room/speaking-right-n-plus-one.test.ts @@ -0,0 +1,98 @@ +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 { 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 { SpeakingRightService } from "./speaking-right.service.js"; + +// T33 驗收:多人房單輪的資料庫查詢次數不隨人數線性成長——decideSpeakers() 對 Character/ +// CharacterAlias 改為批次查詢(各一次),不再是每位候選各自查一次(舊版 2N 次)。 +const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url)); +const dbUrl = `file:${join(repoRoot, "prisma", "dev.db")}`; +process.env.DATABASE_URL = dbUrl; + +// 另建一個帶 query 事件記錄的 PrismaClient,只用來數這個測試檔案本身查詢次數,不影響其他測試共用的 +// prisma 實例(其餘服務仍用一般的 createPrismaClient(),行為不變)。 +const queryLog: string[] = []; +const loggingClient = createPrismaClient({ logQueries: true }); +(loggingClient.$on as (event: "query", callback: (e: { query: string }) => void) => void)("query", (event) => { + queryLog.push(event.query); +}); +const prisma = { client: loggingClient } as unknown 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 characterRelationship = new CharacterRelationshipService(prisma); +const speakingRight = new SpeakingRightService(prisma, emotionService, relationshipService, characterRelationship); + +const userId = "test-nplus1-user"; +const CHAR_IDS = Array.from({ length: 8 }, (_, i) => `test-nplus1-char-${i}`); + +afterEach(async () => { + await prisma.client.room.deleteMany({ where: { userId } }); + await prisma.client.character.deleteMany({ where: { id: { in: CHAR_IDS } } }); + 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 } }, + }, + }); +} + +function countTableQueries(table: string, sinceIndex: number): number { + return queryLog + .slice(sinceIndex) + .filter((sql) => sql.toLowerCase().includes(`from \`main\`.\`${table.toLowerCase()}\``)).length; +} + +describe("SpeakingRightService.decideSpeakers 批次查詢(T33)", () => { + it("Character/CharacterAlias 查詢次數固定,不隨候選人數線性成長", async () => { + await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } }); + for (const id of CHAR_IDS) await createCharacter(id); + + const smallRoom = await roomService.create(userId, CHAR_IDS.slice(0, 2), "GROUP"); + const largeRoom = await roomService.create(userId, CHAR_IDS, "GROUP"); + + const beforeSmall = queryLog.length; + await speakingRight.decideSpeakers(smallRoom, USER_SPEAKER_ID, "哈囉大家", clock.now()); + const smallCharacterQueries = countTableQueries("characters", beforeSmall); + const smallAliasQueries = countTableQueries("character_aliases", beforeSmall); + + const beforeLarge = queryLog.length; + await speakingRight.decideSpeakers(largeRoom, USER_SPEAKER_ID, "哈囉大家", clock.now()); + const largeCharacterQueries = countTableQueries("characters", beforeLarge); + const largeAliasQueries = countTableQueries("character_aliases", beforeLarge); + + // 候選人數從 2 增為 8(4 倍),批次查詢後 Character/CharacterAlias 查詢次數應相同(各 1 次), + // 不會隨人數等比成長(若還是舊版 N+1,largeRoom 的查詢數應該是 smallRoom 的數倍)。 + expect(smallCharacterQueries).toBe(1); + expect(largeCharacterQueries).toBe(1); + expect(smallAliasQueries).toBe(1); + expect(largeAliasQueries).toBe(1); + }, 60000); +}); diff --git a/apps/api/src/room/speaking-right-tiebreak.test.ts b/apps/api/src/room/speaking-right-tiebreak.test.ts new file mode 100644 index 0000000..e5923b4 --- /dev/null +++ b/apps/api/src/room/speaking-right-tiebreak.test.ts @@ -0,0 +1,87 @@ +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 { CharacterRelationshipService } from "./character-relationship.service.js"; +import { SpeakingRightService } from "./speaking-right.service.js"; +import { USER_SPEAKER_ID, type Room, type RoomParticipantState } from "./room.service.js"; + +// T18 實測發現的迴歸測試:多位候選分數完全相同時(例如同原型、皆已冷落到 SILENT_STREAK_BONUS_CAP +// 封頂),decideSpeakers() 若只靠 Array.sort 的穩定排序決勝,會永遠偏向陣列(= 建立)順序較前的角色; +// 12 人房實測曾出現 4 位角色 80 輪內完全零發言。修正後分數相同時改以「實際冷落輪數」(未封頂的 +// silentStreak)決勝,冷落最久者優先,才不會被建立順序卡死。 +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 emotionService = new EmotionService(prisma, new RuleBasedEmotionTagger()); +const relationshipService = new RelationshipService(prisma, new EventEmitter2()); +const characterRelationship = new CharacterRelationshipService(prisma); +const speakingRight = new SpeakingRightService(prisma, emotionService, relationshipService, characterRelationship); + +const userId = "test-tiebreak-user"; +const CHAR_A = "test-tiebreak-char-a"; // 冷落 6 輪(剛好觸頂) +const CHAR_B = "test-tiebreak-char-b"; // 冷落 8 輪(也觸頂,但實際冷落更久) +const CHAR_C = "test-tiebreak-char-c"; // 冷落 6 輪,陣列順序排在 A 之後 + +afterEach(async () => { + await prisma.client.character.deleteMany({ where: { id: { in: [CHAR_A, CHAR_B, CHAR_C] } } }); + 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 } }, + }, + }); +} + +function participant(characterId: string, silentStreak: number): RoomParticipantState { + return { characterId, silentStreak, lastSpokeAtTurn: -1 }; +} + +describe("SpeakingRightService.decideSpeakers 同分決勝(T18 迴歸)", () => { + it("分數因 SILENT_STREAK_BONUS_CAP 封頂而完全相同時,改以未封頂的冷落輪數決勝,不受建立順序卡死", async () => { + await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } }); + await createCharacter(CHAR_A); + await createCharacter(CHAR_B); + await createCharacter(CHAR_C); + + const participants = new Map([ + [CHAR_A, participant(CHAR_A, 6)], + [CHAR_B, participant(CHAR_B, 8)], + [CHAR_C, participant(CHAR_C, 6)], + ]); + const room: Room = { + id: "test-tiebreak-room", + userId, + mode: "SELF_CHAT", + status: "ACTIVE", + muted: false, + participants, + turns: [], + turnCounter: 20, + }; + + const result = await speakingRight.decideSpeakers(room, USER_SPEAKER_ID, "哈囉大家", new Date()); + + expect(result[0].characterId).toBe(CHAR_B); // 實際冷落最久,即使加分封頂也該優先 + expect(result[0].score).toBe(result[1].score); // 與其餘同分(皆已觸頂) + expect(result[0].score).toBe(result[2].score); + }, 20000); +}); diff --git a/apps/api/src/room/speaking-right.service.ts b/apps/api/src/room/speaking-right.service.ts index ee14dc9..2b9ea1d 100644 --- a/apps/api/src/room/speaking-right.service.ts +++ b/apps/api/src/room/speaking-right.service.ts @@ -1,10 +1,12 @@ import { Injectable } from "@nestjs/common"; +import type { Character, CharacterAlias } from "@kokorone/db"; import { PrismaService } from "../prisma/prisma.service.js"; import { EmotionService, dominantState } from "../emotion/emotion.service.js"; import { RelationshipService } from "../relationship/relationship.service.js"; import { getArchetypeParams } from "../personality/archetype-params.js"; import { CharacterRelationshipService } from "./character-relationship.service.js"; import { type Room, USER_SPEAKER_ID } from "./room.service.js"; +import { keywordOverlap } from "../llm/keyword-overlap.js"; import { SPEAKING_THRESHOLD, NAMED_BOOST, @@ -24,22 +26,9 @@ export interface SpeakingImpulse { isNamed: boolean; } -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); -} - // K-4 發言權分配:每輪為每位在場角色計算「發言衝動值」,超過門檻者本輪發言。 +// T33:decideSpeakers() 一輪內為全體候選批次查詢 Character/CharacterAlias(各一次查詢,不隨候選人數 +// 線性成長),computeImpulse() 改為接受預先批次好的資料而非各自查詢,避免多人房的 N+1。 @Injectable() export class SpeakingRightService { constructor( @@ -55,11 +44,14 @@ export class SpeakingRightService { speakerId: string, content: string, now: Date, + prefetched?: { character: Character; aliases: CharacterAlias[] }, ): Promise { - const [character, aliases] = await Promise.all([ - this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } }), - this.prisma.client.characterAlias.findMany({ where: { characterId } }), - ]); + const { character, aliases } = + prefetched ?? + (await (async () => ({ + character: await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } }), + aliases: await this.prisma.client.characterAlias.findMany({ where: { characterId } }), + }))()); const archetypeParams = getArchetypeParams(character.personalityArchetype); const isNamed = aliases.some( @@ -107,11 +99,58 @@ export class SpeakingRightService { } // 回傳本輪應該發言的角色(依衝動值高到低排序),排除發言者本人。 - async decideSpeakers(room: Room, speakerId: string, content: string, now: Date = new Date()): Promise { - const candidates = [...room.participants.keys()].filter((characterId) => characterId !== speakerId); - const impulses = await Promise.all( - candidates.map((characterId) => this.computeImpulse(room, characterId, speakerId, content, now)), + // T43:excludeCharacterIds 排除目前被套用中的角色——她們不能被 LLM/自走機制搶著代為發言, + // 只能透過 PossessionService.speakAsPossessed() 由套用者本人的輸入產生她的發言。 + async decideSpeakers( + room: Room, + speakerId: string, + content: string, + now: Date = new Date(), + excludeCharacterIds: ReadonlySet = new Set(), + ): Promise { + const candidates = [...room.participants.keys()].filter( + (characterId) => characterId !== speakerId && !excludeCharacterIds.has(characterId), ); - return impulses.filter((impulse) => impulse.score >= SPEAKING_THRESHOLD).sort((a, b) => b.score - a.score); + if (candidates.length === 0) return []; + + // T33:批次查詢,查詢次數固定為 2(Character/CharacterAlias 各一次),不隨候選人數線性成長。 + const [characters, aliases] = await Promise.all([ + this.prisma.client.character.findMany({ where: { id: { in: candidates } } }), + this.prisma.client.characterAlias.findMany({ where: { characterId: { in: candidates } } }), + ]); + const characterById = new Map(characters.map((c) => [c.id, c])); + const aliasesByCharacterId = new Map(); + for (const alias of aliases) { + const list = aliasesByCharacterId.get(alias.characterId) ?? []; + list.push(alias); + aliasesByCharacterId.set(alias.characterId, list); + } + + const impulses = await Promise.all( + candidates.map((characterId) => { + const character = characterById.get(characterId); + if (!character) { + throw new Error(`角色不存在:${characterId}`); + } + return this.computeImpulse(room, characterId, speakerId, content, now, { + character, + aliases: aliasesByCharacterId.get(characterId) ?? [], + }); + }), + ); + // T18 實測發現:SILENT_STREAK_BONUS_CAP 讓「已冷落夠久」的角色分數被封頂拉平,一旦多位候選同時 + // 觸頂即完全同分;Array.sort 是穩定排序,同分時永遠保留原陣列順序(= room.participants 的建立 + // 順序),導致固定幾位「先建立」的角色每次同分都贏,其餘角色在長段落內可能永遠分不到發言權 + // (8 人房已出現偏斜、12 人房實測 4 位角色 80 輪內完全零發言)。分數相同時改以「實際冷落輪數」 + // (未封頂的 silentStreak)決勝——即使獎勵值封頂,真正被晾最久的角色仍優先,才不會被建立順序 + // 卡死;且下修 SILENT_STREAK_BONUS_CAP 為建立順序偏誤的一環,但真正的修正在這裡的次要排序鍵。 + return impulses + .filter((impulse) => impulse.score >= SPEAKING_THRESHOLD) + .sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + const streakA = room.participants.get(a.characterId)?.silentStreak ?? 0; + const streakB = room.participants.get(b.characterId)?.silentStreak ?? 0; + return streakB - streakA; + }); } } diff --git a/apps/api/src/room/stall-detection.ts b/apps/api/src/room/stall-detection.ts new file mode 100644 index 0000000..632cdd3 --- /dev/null +++ b/apps/api/src/room/stall-detection.ts @@ -0,0 +1,22 @@ +import { STALL_CHECK_WINDOW } from "./constants.js"; + +function normalize(text: string): string { + return text.trim().toLowerCase(); +} + +// 偵測最近幾輪是否開始空轉:內容高度重複,或字數持續下降(話越講越少,快聊不下去了)。 +// 原為 self-chat.service.ts 私有函式,T22 的動態間隔計算需要同一套判定邏輯,抽出共用避免重複實作。 +export function isStalling(recentContents: string[]): boolean { + if (recentContents.length < STALL_CHECK_WINDOW) { + return false; + } + const window = recentContents.slice(-STALL_CHECK_WINDOW); + const normalized = window.map(normalize); + const uniqueCount = new Set(normalized).size; + if (uniqueCount === 1) { + return true; // 完全重複 + } + const lengths = window.map((text) => text.length); + const isMonotonicDecline = lengths.every((length, index) => index === 0 || length <= lengths[index - 1]); + return isMonotonicDecline && lengths[0] > lengths[lengths.length - 1]; +} diff --git a/apps/api/src/room/witness-reaction.test.ts b/apps/api/src/room/witness-reaction.test.ts new file mode 100644 index 0000000..b1310ff --- /dev/null +++ b/apps/api/src/room/witness-reaction.test.ts @@ -0,0 +1,147 @@ +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 { 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"; + +// T35 目擊者反應驗收:使用者跟某角色已到曖昧以上,多人房內其他角色不是空氣——自己對使用者也有戀愛 +// 心思的旁觀者會吃味(關係值下修),單純旁觀的則替對方開心(關係值上修),且都留下事件紀錄。 +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 userId = "test-witness-user"; +const CHAR_TARGET = "test-witness-char-target"; // 使用者曖昧對象 +const CHAR_JEALOUS = "test-witness-char-jealous"; // 自己對使用者也有戀愛心思 +const CHAR_HAPPY = "test-witness-char-happy"; // 純粹旁觀 + +afterEach(async () => { + await prisma.client.characterRelationshipEventLog.deleteMany({ + where: { OR: [{ characterId: { in: [CHAR_JEALOUS, CHAR_HAPPY] } }, { otherCharacterId: CHAR_TARGET }] }, + }); + await prisma.client.characterRelationship.deleteMany({ + where: { characterId: { in: [CHAR_TARGET, CHAR_JEALOUS, CHAR_HAPPY] } }, + }); + await prisma.client.relationship.deleteMany({ where: { userId } }); + await prisma.client.room.deleteMany({ where: { userId } }); + await prisma.client.character.deleteMany({ where: { id: { in: [CHAR_TARGET, CHAR_JEALOUS, CHAR_HAPPY] } } }); + 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 } }, + }, + }); +} + +async function setRomanceStage(characterId: string, romanceStage: "NONE" | "AMBIGUOUS") { + await relationshipService.getOrCreate(characterId, userId, clock.now()); + await prisma.client.relationship.update({ + where: { characterId_userId: { characterId, userId } }, + data: { romanceStage }, + }); +} + +describe("目擊者反應(T35)", () => { + it("使用者與目標角色曖昧時,同樣對使用者有心思的旁觀者吃味、單純旁觀者替對方開心,皆留下事件紀錄", async () => { + await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } }); + await createCharacter(CHAR_TARGET, "小甲"); + await createCharacter(CHAR_JEALOUS, "小乙"); + await createCharacter(CHAR_HAPPY, "小丙"); + await setRomanceStage(CHAR_TARGET, "AMBIGUOUS"); + await setRomanceStage(CHAR_JEALOUS, "AMBIGUOUS"); + await setRomanceStage(CHAR_HAPPY, "NONE"); + + const room = await roomService.create(userId, [CHAR_TARGET, CHAR_JEALOUS, CHAR_HAPPY], "GROUP"); + const reply = await generation.generateReply(room, CHAR_TARGET, USER_SPEAKER_ID, "使用者", "今天也想見到你", { + now: clock.now(), + }); + expect(reply.text).toBeTruthy(); + + const jealousAffinity = await characterRelationship.getAffinity(CHAR_JEALOUS, CHAR_TARGET); + const happyAffinity = await characterRelationship.getAffinity(CHAR_HAPPY, CHAR_TARGET); + expect(jealousAffinity).toBeLessThan(50); // 吃味:關係值下修 + expect(happyAffinity).toBeGreaterThan(50); // 替對方開心:關係值上修 + + const jealousLog = await prisma.client.characterRelationshipEventLog.findFirst({ + where: { characterId: CHAR_JEALOUS, otherCharacterId: CHAR_TARGET }, + }); + const happyLog = await prisma.client.characterRelationshipEventLog.findFirst({ + where: { characterId: CHAR_HAPPY, otherCharacterId: CHAR_TARGET }, + }); + expect(jealousLog).toBeTruthy(); + expect(jealousLog!.delta).toBeLessThan(0); + expect(happyLog).toBeTruthy(); + expect(happyLog!.delta).toBeGreaterThan(0); + }, 20000); + + it("使用者與目標角色尚未曖昧(NONE)時,不觸發目擊者反應", async () => { + await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } }); + await createCharacter(CHAR_TARGET, "小甲"); + await createCharacter(CHAR_JEALOUS, "小乙"); + await setRomanceStage(CHAR_TARGET, "NONE"); + + const room = await roomService.create(userId, [CHAR_TARGET, CHAR_JEALOUS], "GROUP"); + await generation.generateReply(room, CHAR_TARGET, USER_SPEAKER_ID, "使用者", "今天天氣不錯", { now: clock.now() }); + + const affinity = await characterRelationship.getAffinity(CHAR_JEALOUS, CHAR_TARGET); + expect(affinity).toBe(50); // 未達曖昧門檻,維持中性 + + const log = await prisma.client.characterRelationshipEventLog.findFirst({ + where: { characterId: CHAR_JEALOUS, otherCharacterId: CHAR_TARGET }, + }); + expect(log).toBeNull(); + }, 20000); +}); diff --git a/apps/api/src/room/witness-reaction.ts b/apps/api/src/room/witness-reaction.ts new file mode 100644 index 0000000..747862f --- /dev/null +++ b/apps/api/src/room/witness-reaction.ts @@ -0,0 +1,22 @@ +import type { RomanceStage } from "@kokorone/db"; + +export interface WitnessReaction { + delta: number; + description: string; +} + +const ROMANTIC_STAGES: ReadonlySet = new Set(["AMBIGUOUS", "PARTNER", "DEEP_PARTNER"]); + +// T35 目擊者反應:使用者與某角色的曖昧互動被有旁人在場時,其他在場角色不是空氣——各自依「自己跟使用者 +// 是不是也有戀愛心思」決定反應:同樣對使用者有意思的會吃味(對這位角色的關係值下修),單純旁觀的則替 +// 對方開心(小幅上修)。純函式,不觸碰資料庫,方便單獨測試。 +export function isRomanticMoment(targetRomanceStage: RomanceStage): boolean { + return ROMANTIC_STAGES.has(targetRomanceStage); +} + +export function computeWitnessReaction(witnessRomanceStage: RomanceStage, targetLabel: string): WitnessReaction { + if (ROMANTIC_STAGES.has(witnessRomanceStage)) { + return { delta: -4, description: `看到${targetLabel}跟使用者那麼親密,忍不住有點吃味` }; + } + return { delta: 1, description: `看到${targetLabel}跟使用者感情升溫,替對方開心` }; +} diff --git a/apps/api/src/schedule/offline-event.service.test.ts b/apps/api/src/schedule/offline-event.service.test.ts new file mode 100644 index 0000000..13f535a --- /dev/null +++ b/apps/api/src/schedule/offline-event.service.test.ts @@ -0,0 +1,101 @@ +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 { ScheduleService } from "./schedule.service.js"; +import { OfflineEventService } from "./offline-event.service.js"; + +// T41 驗收:在場(大廳/房間)期間不生成假的離線事件,日常素材來自真實 RoomTurn;只有忙碌/睡眠 +// 時段才生成,且生成前必須確認同時段沒有真實紀錄。 +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 scheduleService = new ScheduleService(prisma); +const offlineEvent = new OfflineEventService(prisma, scheduleService); + +const CHARACTER_ID = "test-offline-event-char"; +const ROOM_USER_ID = "test-offline-event-user"; + +afterEach(async () => { + await prisma.client.roomTurn.deleteMany({ where: { speakerCharacterId: CHARACTER_ID } }); + await prisma.client.room.deleteMany({ where: { userId: ROOM_USER_ID } }); + await prisma.client.episodicMemory.deleteMany({ where: { characterId: CHARACTER_ID } }); + await prisma.client.scheduleSlot.deleteMany({ where: { characterId: CHARACTER_ID } }); + await prisma.client.character.deleteMany({ where: { id: CHARACTER_ID } }); + await prisma.client.user.deleteMany({ where: { id: ROOM_USER_ID } }); +}); + +async function createCharacter() { + await prisma.client.character.create({ + data: { + id: CHARACTER_ID, + source: "ORIGINAL", + buildStatus: "BUILT", + basicInfo: "測試", + backgroundStory: "測試", + personalityArchetype: "元氣", + likesDislikes: "測試", + goalsObsessions: "測試", + speechStyle: "測試", + }, + }); +} + +describe("OfflineEventService(T41)", () => { + it("整天只有空閒時段(無忙碌/睡眠):不生成任何離線事件", async () => { + await createCharacter(); + await prisma.client.scheduleSlot.create({ + data: { characterId: CHARACTER_ID, dayType: "WEEKDAY", startMinute: 0, endMinute: 1440, status: "FREE" }, + }); + + // 週三(WEEKDAY)中午 + await offlineEvent.maybeGenerate(CHARACTER_ID, new Date("2026-08-19T05:00:00.000Z")); + + const memories = await prisma.client.episodicMemory.findMany({ + where: { characterId: CHARACTER_ID, source: "OFFLINE_GENERATED" }, + }); + expect(memories).toHaveLength(0); + }); + + it("忙碌時段已有真實 RoomTurn:不生成離線事件、不與真實紀錄衝突", async () => { + await createCharacter(); + await prisma.client.user.create({ data: { id: ROOM_USER_ID, name: "測試使用者" } }); + // 週三(WEEKDAY)12:00~15:00 忙碌 + await prisma.client.scheduleSlot.create({ + data: { characterId: CHARACTER_ID, dayType: "WEEKDAY", startMinute: 720, endMinute: 900, status: "BUSY", label: "社團活動" }, + }); + + const room = await prisma.client.room.create({ + data: { userId: ROOM_USER_ID, mode: "SOLO", status: "ACTIVE" }, + }); + // 忙碌時段內(13:00 台北)留下一筆真實對話。 + await prisma.client.roomTurn.create({ + data: { roomId: room.id, seq: 0, speakerCharacterId: CHARACTER_ID, content: "其實我偷偷跑出來聊天了", createdAt: new Date("2026-08-19T05:00:00.000Z") }, + }); + + // 忙碌時段剛結束後觸發(15:05)。 + await offlineEvent.maybeGenerate(CHARACTER_ID, new Date("2026-08-19T07:05:00.000Z")); + + const memories = await prisma.client.episodicMemory.findMany({ + where: { characterId: CHARACTER_ID, source: "OFFLINE_GENERATED" }, + }); + expect(memories).toHaveLength(0); + }); + + it("忙碌時段沒有真實紀錄:正常生成合理的離線小事", async () => { + await createCharacter(); + await prisma.client.scheduleSlot.create({ + data: { characterId: CHARACTER_ID, dayType: "WEEKDAY", startMinute: 720, endMinute: 900, status: "BUSY", label: "社團活動" }, + }); + + await offlineEvent.maybeGenerate(CHARACTER_ID, new Date("2026-08-19T07:05:00.000Z")); + + const memories = await prisma.client.episodicMemory.findMany({ + where: { characterId: CHARACTER_ID, source: "OFFLINE_GENERATED" }, + }); + expect(memories).toHaveLength(1); + expect(memories[0].content).toContain("社團"); + }); +}); diff --git a/apps/api/src/schedule/offline-event.service.ts b/apps/api/src/schedule/offline-event.service.ts index 899531e..599e80f 100644 --- a/apps/api/src/schedule/offline-event.service.ts +++ b/apps/api/src/schedule/offline-event.service.ts @@ -12,7 +12,10 @@ const OFFLINE_EVENT_TEMPLATES: Record = { }; const DEFAULT_OFFLINE_EVENT_TEMPLATES = ["今天過得普普通通,沒什麼特別的事"]; -// I-6 離線生活:不在線時她在活著,於下次對話開始時按需生成合理的日常小事。 +// I-6/T41 離線生活:日常素材一律以真實 RoomTurn 為優先來源(大廳/房間在場期間發生的事,交由 +// room-consolidation/memory-consolidation 固化為 EpisodicMemory,不在這裡重複生成);本服務只 +// 負責「行程忙碌或睡眠、角色真的不在場」的時段補一則合理的離線小事,且生成前必須確認同時段沒有 +// 真實紀錄(例如使用者其實在那個時段把她呼叫進房聊過天)——有真實紀錄就不生成,不與其衝突。 @Injectable() export class OfflineEventService { constructor( @@ -30,9 +33,27 @@ export class OfflineEventService { } const pastSlots = await this.schedule.getPastSlotsToday(characterId, now); - const labelledSlots = pastSlots.filter((slot) => slot.label && slot.status !== "SLEEP"); - const label = labelledSlots.at(-1)?.label ?? null; + // T41:空閒/半忙碌時角色隨時可能人在大廳或房間裡真的活動過,只有忙碌/睡眠才算「真的不在場」, + // 才需要補一則離線小事;空閒時段沒有對應的離線事件,日常素材交給真實 RoomTurn 紀錄。 + const absentSlots = pastSlots.filter((slot) => slot.status === "BUSY" || slot.status === "SLEEP"); + if (absentSlots.length === 0) { + return; + } + const targetSlot = absentSlots.at(-1)!; + // 生成前檢查同時段是否已有真實紀錄——角色雖然「應該」在忙或睡覺,但實際上被呼叫進房、留下了 + // 真的對話,這個時段就不該用假事件覆蓋。 + const slotStart = new Date(todayStart.getTime() + targetSlot.startMinute * 60_000); + const slotEnd = new Date(todayStart.getTime() + targetSlot.endMinute * 60_000); + const realTurnInSlot = await this.prisma.client.roomTurn.findFirst({ + where: { speakerCharacterId: characterId, createdAt: { gte: slotStart, lt: slotEnd } }, + }); + if (realTurnInSlot) { + return; + } + + // 睡眠時段不當作「做了什麼事」的標籤來源(沒有對應模板,也沒有「睡覺時發生的趣事」的敘事空間)。 + const label = targetSlot.status === "SLEEP" ? null : targetSlot.label; const templates = (label && OFFLINE_EVENT_TEMPLATES[label]) || DEFAULT_OFFLINE_EVENT_TEMPLATES; const content = templates[Math.floor(Math.random() * templates.length)]; diff --git a/apps/api/src/schedule/schedule-emotion-baseline.service.ts b/apps/api/src/schedule/schedule-emotion-baseline.service.ts index 608d526..11efc79 100644 --- a/apps/api/src/schedule/schedule-emotion-baseline.service.ts +++ b/apps/api/src/schedule/schedule-emotion-baseline.service.ts @@ -1,6 +1,8 @@ import { Injectable } from "@nestjs/common"; import type { EmotionTag } from "@kokorone/shared"; +import { PrismaService } from "../prisma/prisma.service.js"; import { ScheduleService } from "./schedule.service.js"; +import { SleepDebtService, SLEEP_DEBT_ACTIVE_THRESHOLD_MINUTES, SLEEP_DEBT_ESCALATION_THRESHOLD_MINUTES } from "./sleep-debt.service.js"; export interface BaselineSignal { tag: EmotionTag; @@ -8,14 +10,28 @@ export interface BaselineSignal { } const CLUB_TIRED_WINDOW_MINUTES = 30; +// T32:睡眠債活躍時「累」傾向的基礎強度/達升級門檻時再加重一截。 +const SLEEP_DEBT_TIRED_INTENSITY = 0.4; +const SLEEP_DEBT_TIRED_INTENSITY_ESCALATED = 0.7; -// I-5 作息驅動情緒基線:社團剛結束的疲憊、假日心情基線偏高等日內波動。 +// I-5 作息驅動情緒基線:社團剛結束的疲憊、假日心情基線偏高等日內波動;T32 加上熬夜累積的睡眠債 +// 加重「累」傾向(優先於社團疲憊,因為睡眠債代表的是更根本、跨天累積的疲勞)。 // 只在本輪對話「沒有明確情緒訊號」時介入,不覆蓋使用者輸入本身觸發的情緒。 @Injectable() export class ScheduleEmotionBaselineService { - constructor(private readonly schedule: ScheduleService) {} + constructor( + private readonly prisma: PrismaService, + private readonly schedule: ScheduleService, + private readonly sleepDebt: SleepDebtService, + ) {} async computeBaseline(characterId: string, now: Date = new Date()): Promise { + const debtMinutes = await this.sleepDebt.getDebtMinutes(characterId); + if (debtMinutes >= SLEEP_DEBT_ACTIVE_THRESHOLD_MINUTES) { + const intensity = debtMinutes >= SLEEP_DEBT_ESCALATION_THRESHOLD_MINUTES ? SLEEP_DEBT_TIRED_INTENSITY_ESCALATED : SLEEP_DEBT_TIRED_INTENSITY; + return { tag: "SAD", intensity }; // 累(熬夜債務) + } + const justEnded = await this.schedule.slotJustEnded(characterId, now, CLUB_TIRED_WINDOW_MINUTES); if (justEnded?.label?.includes("社團")) { return { tag: "SAD", intensity: 0.3 }; // 累 @@ -26,6 +42,30 @@ export class ScheduleEmotionBaselineService { return { tag: "JOY", intensity: 0.3 }; // 放假日心情基線偏高 } - return null; + // T17:以上都沒有更急迫的作息訊號時,才落到章節匯入累積出的長期情緒基調(同一層合成, + // 不是另外疊加或覆蓋——只在作息本身沒有明確意見時才參考「這個角色的故事整體是什麼調性」)。 + return this.canonBaseline(characterId); + } + + private async canonBaseline(characterId: string): Promise { + const rows = await this.prisma.client.characterEmotionBaseline.findMany({ where: { characterId } }); + if (rows.length === 0) return null; + + const sums = new Map(); + for (const row of rows) { + const entry = sums.get(row.emotionTag) ?? { total: 0, count: 0 }; + entry.total += row.intensity; + entry.count += 1; + sums.set(row.emotionTag, entry); + } + + let best: { tag: EmotionTag; avgIntensity: number } | null = null; + for (const [tag, { total, count }] of sums) { + const avgIntensity = total / count; + if (!best || avgIntensity > best.avgIntensity) { + best = { tag, avgIntensity }; + } + } + return best ? { tag: best.tag, intensity: Math.min(1, best.avgIntensity) } : null; } } diff --git a/apps/api/src/schedule/schedule.module.ts b/apps/api/src/schedule/schedule.module.ts index 77e0154..61fcf91 100644 --- a/apps/api/src/schedule/schedule.module.ts +++ b/apps/api/src/schedule/schedule.module.ts @@ -5,12 +5,13 @@ import { ScheduleService } from "./schedule.service.js"; import { ScheduleExceptionService } from "./schedule-exception.service.js"; import { ScheduleEmotionBaselineService } from "./schedule-emotion-baseline.service.js"; import { OfflineEventService } from "./offline-event.service.js"; +import { SleepDebtService } from "./sleep-debt.service.js"; import { ScheduleController } from "./schedule.controller.js"; @Module({ imports: [PrismaModule, RelationshipModule], controllers: [ScheduleController], - providers: [ScheduleService, ScheduleExceptionService, ScheduleEmotionBaselineService, OfflineEventService], - exports: [ScheduleService, ScheduleExceptionService, ScheduleEmotionBaselineService, OfflineEventService], + providers: [ScheduleService, ScheduleExceptionService, ScheduleEmotionBaselineService, OfflineEventService, SleepDebtService], + exports: [ScheduleService, ScheduleExceptionService, ScheduleEmotionBaselineService, OfflineEventService, SleepDebtService], }) export class ScheduleModule {} diff --git a/apps/api/src/schedule/sleep-debt.service.test.ts b/apps/api/src/schedule/sleep-debt.service.test.ts new file mode 100644 index 0000000..cbae093 --- /dev/null +++ b/apps/api/src/schedule/sleep-debt.service.test.ts @@ -0,0 +1,95 @@ +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 { ScheduleService } from "./schedule.service.js"; +import { SleepDebtService, SLEEP_DEBT_ACTIVE_THRESHOLD_MINUTES } from "./sleep-debt.service.js"; + +// T32 驗收:熬夜(睡眠時段內仍在房間活動)累積睡眠債;補眠(睡眠時段內完全沒有活動)後歸零。 +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 scheduleService = new ScheduleService(prisma); +const sleepDebt = new SleepDebtService(prisma, scheduleService); + +const CHARACTER_ID = "test-sleep-debt-char"; +const ROOM_USER_ID = "test-sleep-debt-user"; + +afterEach(async () => { + await prisma.client.roomTurn.deleteMany({ where: { speakerCharacterId: CHARACTER_ID } }); + await prisma.client.room.deleteMany({ where: { userId: ROOM_USER_ID } }); + await prisma.client.sleepDebt.deleteMany({ where: { characterId: CHARACTER_ID } }); + await prisma.client.scheduleSlot.deleteMany({ where: { characterId: CHARACTER_ID } }); + await prisma.client.character.deleteMany({ where: { id: CHARACTER_ID } }); + await prisma.client.user.deleteMany({ where: { id: ROOM_USER_ID } }); +}); + +async function createCharacter() { + await prisma.client.character.create({ + data: { + id: CHARACTER_ID, + source: "ORIGINAL", + buildStatus: "BUILT", + basicInfo: "測試", + backgroundStory: "測試", + personalityArchetype: "元氣", + likesDislikes: "測試", + goalsObsessions: "測試", + speechStyle: "測試", + }, + }); +} + +describe("SleepDebtService(T32)", () => { + it("睡眠時段內有真實活動(熬夜):累積該時段時長為債務", async () => { + await createCharacter(); + await prisma.client.user.create({ data: { id: ROOM_USER_ID, name: "測試使用者" } }); + // 週三(WEEKDAY)00:00~06:30 睡眠(390 分鐘)。 + await prisma.client.scheduleSlot.create({ + data: { characterId: CHARACTER_ID, dayType: "WEEKDAY", startMinute: 0, endMinute: 390, status: "SLEEP" }, + }); + const room = await prisma.client.room.create({ data: { userId: ROOM_USER_ID, mode: "SOLO", status: "ACTIVE" } }); + // 凌晨 02:00(睡眠時段內)留下真實對話——熬夜了。 + await prisma.client.roomTurn.create({ + data: { roomId: room.id, seq: 0, speakerCharacterId: CHARACTER_ID, content: "還沒睡", createdAt: new Date("2026-08-18T18:00:00.000Z") }, + }); + + // 07:00(睡眠時段已結束)觸發結算。 + await sleepDebt.reconcileToday(CHARACTER_ID, new Date("2026-08-18T23:00:00.000Z")); + + const debt = await sleepDebt.getDebtMinutes(CHARACTER_ID); + expect(debt).toBe(390); + expect(debt).toBeGreaterThanOrEqual(SLEEP_DEBT_ACTIVE_THRESHOLD_MINUTES); + }); + + it("睡眠時段內完全沒有活動(補眠):既有債務歸零", async () => { + await createCharacter(); + await prisma.client.scheduleSlot.create({ + data: { characterId: CHARACTER_ID, dayType: "WEEKDAY", startMinute: 0, endMinute: 390, status: "SLEEP" }, + }); + await prisma.client.sleepDebt.create({ data: { characterId: CHARACTER_ID, accumulatedMinutes: 300 } }); + + await sleepDebt.reconcileToday(CHARACTER_ID, new Date("2026-08-18T23:00:00.000Z")); + + expect(await sleepDebt.getDebtMinutes(CHARACTER_ID)).toBe(0); + }); + + it("同一天重複呼叫只結算一次,不會重複累加", async () => { + await createCharacter(); + await prisma.client.user.create({ data: { id: ROOM_USER_ID, name: "測試使用者" } }); + await prisma.client.scheduleSlot.create({ + data: { characterId: CHARACTER_ID, dayType: "WEEKDAY", startMinute: 0, endMinute: 390, status: "SLEEP" }, + }); + const room = await prisma.client.room.create({ data: { userId: ROOM_USER_ID, mode: "SOLO", status: "ACTIVE" } }); + await prisma.client.roomTurn.create({ + data: { roomId: room.id, seq: 0, speakerCharacterId: CHARACTER_ID, content: "還沒睡", createdAt: new Date("2026-08-18T18:00:00.000Z") }, + }); + + await sleepDebt.reconcileToday(CHARACTER_ID, new Date("2026-08-18T23:00:00.000Z")); + await sleepDebt.reconcileToday(CHARACTER_ID, new Date("2026-08-18T23:30:00.000Z")); + + expect(await sleepDebt.getDebtMinutes(CHARACTER_ID)).toBe(390); + }); +}); diff --git a/apps/api/src/schedule/sleep-debt.service.ts b/apps/api/src/schedule/sleep-debt.service.ts new file mode 100644 index 0000000..e42b050 --- /dev/null +++ b/apps/api/src/schedule/sleep-debt.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service.js"; +import { ScheduleService } from "./schedule.service.js"; +import { startOfTaipeiDay, isSameTaipeiDate } from "./time-utils.js"; + +// T32:睡眠債達此門檻,情緒基線/ACTION 權重開始受影響(打瞌睡)。 +export const SLEEP_DEBT_ACTIVE_THRESHOLD_MINUTES = 60; +// T32:睡眠債達此門檻,LEAVE 動因升級為「撐不住想睡」等級(明說撐不住、優先於一般行程理由)。 +export const SLEEP_DEBT_ESCALATION_THRESHOLD_MINUTES = 180; + +@Injectable() +export class SleepDebtService { + constructor( + private readonly prisma: PrismaService, + private readonly schedule: ScheduleService, + ) {} + + async getDebtMinutes(characterId: string): Promise { + const row = await this.prisma.client.sleepDebt.findUnique({ where: { characterId } }); + return row?.accumulatedMinutes ?? 0; + } + + // 對比今天已經結束的 SLEEP 時段:期間若有真實 RoomTurn(沒有真的睡)就把該時段時長計入債務; + // 完全沒有活動(真的補眠了)就把債務歸零。每個台北當地日期只處理一次,避免重複計入。 + async reconcileToday(characterId: string, now: Date = new Date()): Promise { + const existing = await this.prisma.client.sleepDebt.findUnique({ where: { characterId } }); + if (existing?.lastReconciledDate && isSameTaipeiDate(existing.lastReconciledDate, now)) { + return; + } + + const pastSlots = await this.schedule.getPastSlotsToday(characterId, now); + const sleepSlots = pastSlots.filter((slot) => slot.status === "SLEEP"); + if (sleepSlots.length === 0) { + return; // 今天還沒有任何 SLEEP 時段結束,先不處理,避免拿到今天日期卡住之後同一天的重算。 + } + + const todayStart = startOfTaipeiDay(now); + let addedMinutes = 0; + let fullySlept = false; + for (const slot of sleepSlots) { + const slotStart = new Date(todayStart.getTime() + slot.startMinute * 60_000); + const slotEnd = new Date(todayStart.getTime() + slot.endMinute * 60_000); + const activeDuringSleep = await this.prisma.client.roomTurn.count({ + where: { speakerCharacterId: characterId, createdAt: { gte: slotStart, lt: slotEnd } }, + }); + if (activeDuringSleep > 0) { + addedMinutes += slot.endMinute - slot.startMinute; + } else { + fullySlept = true; + } + } + + const baseline = existing?.accumulatedMinutes ?? 0; + const nextMinutes = fullySlept ? 0 : baseline + addedMinutes; + + await this.prisma.client.sleepDebt.upsert({ + where: { characterId }, + update: { accumulatedMinutes: nextMinutes, lastReconciledDate: now }, + create: { characterId, accumulatedMinutes: nextMinutes, lastReconciledDate: now }, + }); + } +} diff --git a/apps/api/src/schedule/timeflow-realtime-boundary.test.ts b/apps/api/src/schedule/timeflow-realtime-boundary.test.ts new file mode 100644 index 0000000..82c10e3 --- /dev/null +++ b/apps/api/src/schedule/timeflow-realtime-boundary.test.ts @@ -0,0 +1,95 @@ +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 { ScheduleService } from "./schedule.service.js"; +import { SleepDebtService } from "./sleep-debt.service.js"; + +// T34 驗收:`FAST` 作品的角色行程與睡眠時間必須與現實一致,只有後日談推演進度較快(見 +// `epilogue/epilogue-progress.service.test.ts`)。這裡鎖定行程判定與睡眠債結算兩個既有機制—— +// 兩者從不讀取 `Work.timeFlowMode`,同一段真實時間下 `FAST` 與 `REALTIME` 作品的角色算出來的結果 +// 必須完全一致,即使其中一個作品的推演進度已經跑得比另一個快很多。 +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 scheduleService = new ScheduleService(prisma); +const sleepDebt = new SleepDebtService(prisma, scheduleService); + +const WORK_FAST = "test-t34-realtime-work-fast"; +const WORK_REALTIME = "test-t34-realtime-work-realtime"; +const CHAR_FAST = "test-t34-realtime-char-fast"; +const CHAR_REALTIME = "test-t34-realtime-char-realtime"; +const ROOM_USER_ID = "test-t34-realtime-user"; + +afterEach(async () => { + await prisma.client.roomTurn.deleteMany({ where: { speakerCharacterId: { in: [CHAR_FAST, CHAR_REALTIME] } } }); + await prisma.client.room.deleteMany({ where: { userId: ROOM_USER_ID } }); + await prisma.client.sleepDebt.deleteMany({ where: { characterId: { in: [CHAR_FAST, CHAR_REALTIME] } } }); + await prisma.client.scheduleSlot.deleteMany({ where: { characterId: { in: [CHAR_FAST, CHAR_REALTIME] } } }); + await prisma.client.character.deleteMany({ where: { id: { in: [CHAR_FAST, CHAR_REALTIME] } } }); + await prisma.client.work.deleteMany({ where: { id: { in: [WORK_FAST, WORK_REALTIME] } } }); + await prisma.client.user.deleteMany({ where: { id: ROOM_USER_ID } }); +}); + +async function createCharacterInWork(characterId: string, workId: string, timeFlowMode: string) { + await prisma.client.work.create({ data: { id: workId, title: "T34 真實時間邊界測試作品", inEpilogue: true, timeFlowMode } }); + await prisma.client.character.create({ + data: { + id: characterId, + workId, + source: "EXISTING_WORK", + buildStatus: "CANDIDATE", + basicInfo: "測試", + backgroundStory: "測試", + personalityArchetype: "元氣", + likesDislikes: "測試", + goalsObsessions: "測試", + speechStyle: "測試", + aliases: { create: { formalName: characterId } }, + }, + }); + // 週三(WEEKDAY)00:00~06:30 睡眠(390 分鐘),兩個角色的行程設定完全相同。 + await prisma.client.scheduleSlot.create({ + data: { characterId, dayType: "WEEKDAY", startMinute: 0, endMinute: 390, status: "SLEEP" }, + }); +} + +describe("timeFlowMode 不影響真實時間流程(T34)", () => { + it("FAST 與 REALTIME 作品的角色,同一段真實時間內的睡眠債結算結果完全一致", async () => { + await prisma.client.user.create({ data: { id: ROOM_USER_ID, name: "測試使用者" } }); + await createCharacterInWork(CHAR_FAST, WORK_FAST, "FAST"); + await createCharacterInWork(CHAR_REALTIME, WORK_REALTIME, "REALTIME"); + + const room = await prisma.client.room.create({ data: { userId: ROOM_USER_ID, mode: "SOLO", status: "ACTIVE" } }); + // 凌晨 02:00(睡眠時段內,兩個角色都一樣)留下真實對話——熬夜了。 + for (const [seq, characterId] of [CHAR_FAST, CHAR_REALTIME].entries()) { + await prisma.client.roomTurn.create({ + data: { roomId: room.id, seq, speakerCharacterId: characterId, content: "還沒睡", createdAt: new Date("2026-08-18T18:00:00.000Z") }, + }); + } + + // 07:00(睡眠時段已結束)觸發結算,兩個角色用同一個 now。 + const now = new Date("2026-08-18T23:00:00.000Z"); + await sleepDebt.reconcileToday(CHAR_FAST, now); + await sleepDebt.reconcileToday(CHAR_REALTIME, now); + + const fastDebt = await sleepDebt.getDebtMinutes(CHAR_FAST); + const realtimeDebt = await sleepDebt.getDebtMinutes(CHAR_REALTIME); + expect(fastDebt).toBe(390); + expect(fastDebt).toBe(realtimeDebt); // FAST 沒有把睡眠時段本身壓縮或拉長 + }); + + it("FAST 與 REALTIME 作品的角色,同一個真實時刻的行程判定(BUSY/FREE/SLEEP)完全一致", async () => { + await createCharacterInWork(CHAR_FAST, WORK_FAST, "FAST"); + await createCharacterInWork(CHAR_REALTIME, WORK_REALTIME, "REALTIME"); + + // 凌晨 02:00(台北)落在 SLEEP 時段內,兩個角色的判定結果應完全一致。 + const now = new Date("2026-08-18T18:00:00.000Z"); + const fastInfo = await scheduleService.getInfo(CHAR_FAST, now); + const realtimeInfo = await scheduleService.getInfo(CHAR_REALTIME, now); + expect(fastInfo.slot?.status).toBe("SLEEP"); + expect(fastInfo.slot?.status).toBe(realtimeInfo.slot?.status); + }); +}); diff --git a/apps/api/src/tachie/expression-group-context.test.ts b/apps/api/src/tachie/expression-group-context.test.ts new file mode 100644 index 0000000..0951910 --- /dev/null +++ b/apps/api/src/tachie/expression-group-context.test.ts @@ -0,0 +1,62 @@ +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 { EmotionService } from "../emotion/emotion.service.js"; +import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js"; +import { RelationshipService } from "../relationship/relationship.service.js"; +import { PersonalityDriftService } from "../epilogue/personality-drift.service.js"; +import { PersonalityResolutionService } from "../personality/personality-resolution.service.js"; +import { ExpressionResolverService } from "./expression-resolver.service.js"; + +// T42 驗收:同一角色在 1:1 會出現的高親密度外觀(BONDED 解鎖的服裝),在多人房下不出現。 +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 emotionService = new EmotionService(prisma, new RuleBasedEmotionTagger()); +const relationshipService = new RelationshipService(prisma, new EventEmitter2()); +const personalityDrift = new PersonalityDriftService(prisma); +const personalityResolution = new PersonalityResolutionService(prisma, personalityDrift); +const expressionResolver = new ExpressionResolverService(emotionService, relationshipService, personalityResolution); + +const CHARACTER_ID = "test-t42-character"; +const USER_ID = "test-t42-user"; + +afterEach(async () => { + await prisma.client.relationship.deleteMany({ where: { characterId: CHARACTER_ID, userId: USER_ID } }); + await prisma.client.emotionState.deleteMany({ where: { characterId: CHARACTER_ID } }); + await prisma.client.character.deleteMany({ where: { id: CHARACTER_ID } }); + await prisma.client.user.deleteMany({ where: { id: USER_ID } }); +}); + +describe("ExpressionResolverService 房型外觀界線(T42)", () => { + it("BONDED 關係在 SOLO(非 group)解析出高親密度服裝,但在 GROUP/LOBBY 下降級為基本服裝", 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.relationship.create({ + data: { characterId: CHARACTER_ID, userId: USER_ID, intimacy: 90, trust: 90, stage: "BONDED" }, + }); + + const soloResult = await expressionResolver.resolveExpression(CHARACTER_ID, USER_ID, { isGroupContext: false }); + expect(soloResult.availableCostumes).toContain("居家服"); + + const groupResult = await expressionResolver.resolveExpression(CHARACTER_ID, USER_ID, { isGroupContext: true }); + expect(groupResult.availableCostumes).toEqual(["制服"]); + expect(groupResult.availableCostumes).not.toContain("居家服"); + }, 20000); +}); diff --git a/apps/api/src/tachie/expression-resolver.service.ts b/apps/api/src/tachie/expression-resolver.service.ts index 46738ea..9ae7f71 100644 --- a/apps/api/src/tachie/expression-resolver.service.ts +++ b/apps/api/src/tachie/expression-resolver.service.ts @@ -1,9 +1,8 @@ import { Injectable } from "@nestjs/common"; import type { EmotionTag, EmotionState } from "@kokorone/shared"; -import { PrismaService } from "../prisma/prisma.service.js"; import { EmotionService, dominantState } from "../emotion/emotion.service.js"; import { RelationshipService } from "../relationship/relationship.service.js"; -import { getArchetypeParams } from "../personality/archetype-params.js"; +import { PersonalityResolutionService } from "../personality/personality-resolution.service.js"; import { EMOTION_EXPRESSION_TABLE, BASIC_TIER_EMOTIONS, @@ -20,6 +19,7 @@ export interface ResolveExpressionOptions { now?: Date; isThinking?: boolean; // O-2 視線:思考時視線飄上 isDeflecting?: boolean; // O-2 視線:說謊/迴避時視線撇開(跟 SHY 的視線飄開共用同一個方向) + isGroupContext?: boolean; // T42:房間目前是 GROUP/LOBBY 時,一律不套用親密度解鎖的服裝與狀態差分 } export interface ExpressionState { @@ -65,9 +65,9 @@ function resolveGaze(emotionTag: EmotionTag, options: ResolveExpressionOptions): @Injectable() export class ExpressionResolverService { constructor( - private readonly prisma: PrismaService, private readonly emotion: EmotionService, private readonly relationship: RelationshipService, + private readonly personalityResolution: PersonalityResolutionService, ) {} async resolveExpression( @@ -76,15 +76,18 @@ export class ExpressionResolverService { options: ResolveExpressionOptions = {}, ): Promise { const now = options.now ?? new Date(); - const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } }); - const archetypeParams = getArchetypeParams(character.personalityArchetype); + // T16:inEpilogue 角色改用漂移後的 expressiveness(僅影響立繪外顯度 magnitude),非日後談角色 + // 維持原始設定;每次都各自查該角色自己的 Work,混房時不會互相污染。 + const archetypeParams = await this.personalityResolution.resolve(characterId); const emotionState = await this.emotion.getState(characterId, now); const rawEmotionTag = dominantState(emotionState); const intensityNormalized = clamp01((emotionState[fieldFor(rawEmotionTag)] as number) / 100); const { relationship } = await this.relationship.getState(characterId, userId, now); - const stage = relationship.stage; + // T42:GROUP/LOBBY 房內一律視同「陌生~認識」層級解析,忽略實際親密度階段——高親密度限定的 + // 服裝與狀態差分(哭臉等)只在 1:1(SOLO)才會出現。 + const stage = options.isGroupContext ? "STRANGER" : relationship.stage; const allowedEmotions = stage === "BONDED" || stage === "CLOSE_AMBIGUOUS" diff --git a/apps/api/src/tachie/tachie.controller.ts b/apps/api/src/tachie/tachie.controller.ts index 09de9f7..1b9cf73 100644 --- a/apps/api/src/tachie/tachie.controller.ts +++ b/apps/api/src/tachie/tachie.controller.ts @@ -1,10 +1,12 @@ import { Controller, Get, Param, Query } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service.js"; import { ManifestService } from "./manifest.service.js"; import { ExpressionResolverService } from "./expression-resolver.service.js"; @Controller("tachie") export class TachieController { constructor( + private readonly prisma: PrismaService, private readonly manifest: ManifestService, private readonly expression: ExpressionResolverService, ) {} @@ -14,6 +16,8 @@ export class TachieController { return this.manifest.getManifest(characterId); } + // T42:帶 roomId 時依該房目前的 mode 決定是否套用親密度解鎖的服裝/狀態差分 + // (GROUP/LOBBY 一律不套用,僅 SOLO 才會依親密度階段解鎖);未帶 roomId 時維持舊行為。 @Get(":characterId/:userId/expression") async getExpression( @Param("characterId") characterId: string, @@ -21,11 +25,19 @@ export class TachieController { @Query("now") now?: string, @Query("isThinking") isThinking?: string, @Query("isDeflecting") isDeflecting?: string, + @Query("roomId") roomId?: string, ) { + let isGroupContext = false; + if (roomId) { + const room = await this.prisma.client.room.findUnique({ where: { id: roomId } }); + isGroupContext = room?.mode === "GROUP" || room?.mode === "LOBBY" || room?.mode === "SELF_CHAT"; + } + return this.expression.resolveExpression(characterId, userId, { now: now ? new Date(now) : undefined, isThinking: isThinking === "true", isDeflecting: isDeflecting === "true", + isGroupContext, }); } } diff --git a/apps/api/src/tachie/tachie.module.ts b/apps/api/src/tachie/tachie.module.ts index 83b28ae..b71023b 100644 --- a/apps/api/src/tachie/tachie.module.ts +++ b/apps/api/src/tachie/tachie.module.ts @@ -2,12 +2,13 @@ import { Module } from "@nestjs/common"; import { PrismaModule } from "../prisma/prisma.module.js"; import { EmotionModule } from "../emotion/emotion.module.js"; import { RelationshipModule } from "../relationship/relationship.module.js"; +import { PersonalityModule } from "../personality/personality.module.js"; import { ManifestService } from "./manifest.service.js"; import { ExpressionResolverService } from "./expression-resolver.service.js"; import { TachieController } from "./tachie.controller.js"; @Module({ - imports: [PrismaModule, EmotionModule, RelationshipModule], + imports: [PrismaModule, EmotionModule, RelationshipModule, PersonalityModule], controllers: [TachieController], providers: [ManifestService, ExpressionResolverService], exports: [ManifestService, ExpressionResolverService], diff --git a/apps/api/src/task/task.service.ts b/apps/api/src/task/task.service.ts index 54a48ee..eb490be 100644 --- a/apps/api/src/task/task.service.ts +++ b/apps/api/src/task/task.service.ts @@ -43,7 +43,15 @@ export class TaskService { async create( characterId: string, userId: string, - input: { type: "ONE_TIME" | "RECURRING" | "CONDITIONAL" | "QUERY_ORGANIZE" | "TODO_TRACKING"; content: string; rawText: string; triggerAt?: Date; cronExpression?: string; condition?: string }, + input: { + type: "ONE_TIME" | "RECURRING" | "CONDITIONAL" | "QUERY_ORGANIZE" | "TODO_TRACKING"; + content: string; + rawText: string; + triggerAt?: Date; + cronExpression?: string; + condition?: string; + sourceRoomId?: string; // T7:房內自走委託的來源房間,供追溯到來源房間與該則發言 + }, now: Date = new Date(), ): Promise { const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } }); @@ -64,6 +72,7 @@ export class TaskService { triggerAt: input.triggerAt, cronExpression: input.cronExpression, condition: input.condition, + sourceRoomId: input.sourceRoomId, }, }); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..ad650fd --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + // 多個測試檔會用各自的 PrismaClient 連線共用同一個 dev.db(better-sqlite3 為同步單一寫入者), + // 平行跑測試檔會造成資料庫鎖死;改為檔案間序列執行,檔案內部仍可平行。 + fileParallelism: false, + // 部分測試需要多次循序 DB 往返;隨著開發用 dev.db 逐漸長大,單次操作耗時會增加,預設 5s 偶爾不夠。 + testTimeout: 20000, + }, +}); diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx index bad4509..bc300bc 100644 --- a/apps/mobile/app/(tabs)/_layout.tsx +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -1,7 +1,11 @@ import { Tabs } from "expo-router"; +import { HeartbeatDot } from "../../components/HeartbeatDot"; +import { useUnread } from "../../lib/unread-context"; // Q-2 底部分頁籤:聊天/角色/日常/設定,同行動網頁版佈局+這一層分頁籤是 APP 相對於行動網頁的唯一差異。 export default function TabsLayout() { + const { hasUnread } = useUnread(); + return ( - + : undefined, + }} + /> diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index 3395e28..1a8a690 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -1,169 +1,30 @@ -import { useState } from "react"; -import { - KeyboardAvoidingView, - Platform, - Pressable, - ScrollView, - StyleSheet, - Text, - TextInput, - View, -} from "react-native"; -import type { EmotionTag } from "@kokorone/shared"; -import { sendChatMessage, fetchExpression, type TachieExpressionResponse } from "../../lib/api"; -import { EMOTION_LABELS } from "../../lib/emotion-labels"; -import { generateSessionId } from "../../lib/id"; +import { useCallback } from "react"; +import { ScrollView, StyleSheet } from "react-native"; +import { useFocusEffect } from "expo-router"; +import { LobbyPreview } from "../../components/LobbyPreview"; +import { useUnread } from "../../lib/unread-context"; -const CHARACTER_ID = "seed-character-genki"; -const USER_ID = "seed-user-primary"; -const EXPRESSION_LEAD_MS = 220; // O-2/Q-2:表情先變化、文字延後一小段時間出現,跟 apps/web 的作法一致。 +// T20-P2:聊天分頁改為大廳優先——取代舊版寫死單一角色的 1:1 聊天畫面(該畫面併入 T38 的 SOLO +// 房後,固定角色的假設已不成立:使用者現在透過大廳喊人,答應者可能是任何角色)。實際對話畫面 +// 移到 app/room/[roomId].tsx,由本畫面的房間清單/大廳呼叫導向。 +export default function LobbyScreen() { + const { markRead } = useUnread(); -interface Message { - role: "user" | "character"; - text: string; -} - -// Q-2 行動版佈局:立繪半身像置於上方 40% 為背景層,對話流覆蓋其上向下延伸—— -// 跟行動網頁版同一份設計原則,只是這裡用 View 的 flex 排版而非 CSS。立繪本體目前是佔位色塊 -// (O 群組的 PixiJS 渲染器是網頁 DOM/Canvas 實作,沒有移植到 React Native,見本群組實作記錄)。 -export default function ChatScreen() { - const [sessionId] = useState(() => generateSessionId()); - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); - const [sending, setSending] = useState(false); - const [dominant, setDominant] = useState("CALM"); - const [intimacy, setIntimacy] = useState(0); - const [expression, setExpression] = useState(null); - const [error, setError] = useState(null); - - async function handleSend() { - const text = input.trim(); - if (!text || sending) { - return; - } - setSending(true); - setError(null); - setInput(""); - setMessages((prev) => [...prev, { role: "user", text }]); - - try { - const result = await sendChatMessage(CHARACTER_ID, USER_ID, sessionId, text); - if (result.availability === "NO_REPLY") { - return; - } - if (result.context) { - setDominant(result.context.emotion.dominant); - setIntimacy(result.context.relationship.intimacy); - } - try { - setExpression(await fetchExpression(CHARACTER_ID, USER_ID)); - } catch { - // 表情查詢失敗不影響對話本身。 - } - await new Promise((resolve) => setTimeout(resolve, EXPRESSION_LEAD_MS)); - setMessages((prev) => [...prev, { role: "character", text: result.text }]); - } catch { - setError("訊息傳送失敗,請確認 api 服務是否已啟動"); - } finally { - setSending(false); - } - } + // Q-4:使用者切回本分頁即視為已讀推播提示(各房未讀數則由 LobbyPreview 每次刷新時各自更新)。 + useFocusEffect( + useCallback(() => { + markRead(); + }, [markRead]), + ); return ( - - - 親密度 {intimacy} - - {EMOTION_LABELS[dominant]} - - - 立繪(表情差分即時變化) - - - - {messages.length === 0 && 傳個訊息,開始今天的對話吧。} - {messages.map((message, index) => ( - - {message.text} - - ))} - - - {error && {error}} - - - - - 送出 - - - + + + ); } const styles = StyleSheet.create({ flex: { flex: 1, backgroundColor: "#FFF5F7" }, - tachieArea: { - height: "40%", - backgroundColor: "#FCE4EC", - alignItems: "center", - justifyContent: "flex-end", - paddingBottom: 16, - }, - tachieLabel: { position: "absolute", top: 14, left: 14, fontSize: 11, color: "#8a6b6f" }, - emotionChip: { - position: "absolute", - top: 14, - right: 14, - backgroundColor: "#FFE0EA", - borderRadius: 999, - paddingHorizontal: 12, - paddingVertical: 4, - }, - emotionChipText: { color: "#FF7E9D", fontWeight: "bold", fontSize: 12 }, - tachiePlaceholder: { - width: 120, - height: 190, - borderTopLeftRadius: 60, - borderTopRightRadius: 60, - borderBottomLeftRadius: 24, - borderBottomRightRadius: 24, - backgroundColor: "#FF9EB5", - }, - tachieCaption: { fontSize: 11, color: "#8a6b6f", marginTop: 8 }, - messages: { flex: 1 }, - messagesContent: { padding: 16, gap: 8 }, - hint: { fontSize: 13, color: "#8a6b6f" }, - bubble: { maxWidth: "80%", borderRadius: 16, padding: 10 }, - bubbleUser: { alignSelf: "flex-end", backgroundColor: "#FF7E9D" }, - bubbleCharacter: { alignSelf: "flex-start", backgroundColor: "#EFEFEF" }, - bubbleText: { color: "#222" }, - error: { color: "#FF7E9D", fontSize: 12, paddingHorizontal: 16 }, - inputRow: { - flexDirection: "row", - alignItems: "center", - gap: 8, - margin: 12, - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 999, - borderWidth: 1, - borderColor: "#E5D5D8", - }, - input: { flex: 1, fontSize: 14 }, - sendButton: { color: "#FF7E9D", fontWeight: "bold" }, - sendButtonDisabled: { opacity: 0.5 }, + content: { paddingBottom: 24 }, }); diff --git a/apps/mobile/app/(tabs)/settings.tsx b/apps/mobile/app/(tabs)/settings.tsx index aeb2c03..a5aed09 100644 --- a/apps/mobile/app/(tabs)/settings.tsx +++ b/apps/mobile/app/(tabs)/settings.tsx @@ -1,8 +1,9 @@ -import { useState } from "react"; -import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import { useEffect, useState } from "react"; +import { Pressable, ScrollView, StyleSheet, Switch, Text, View } from "react-native"; import * as Device from "expo-device"; import * as Notifications from "expo-notifications"; import { fetchHealth, registerPushToken } from "../../lib/api"; +import { isVoiceAutoplayEnabled, setVoiceAutoplayEnabled } from "../../lib/voice-autoplay-settings"; const USER_ID = "seed-user-primary"; @@ -13,6 +14,17 @@ export default function SettingsScreen() { const [healthStatus, setHealthStatus] = useState(null); const [healthError, setHealthError] = useState(null); const [pushStatus, setPushStatus] = useState(null); + // T20-P3:與 apps/web 的 VoiceAutoplaySection.tsx 對應,關閉後房間畫面回到逐則點擊才播放。 + const [voiceAutoplay, setVoiceAutoplay] = useState(true); + + useEffect(() => { + void isVoiceAutoplayEnabled().then(setVoiceAutoplay); + }, []); + + async function handleToggleVoiceAutoplay(next: boolean) { + await setVoiceAutoplayEnabled(next); + setVoiceAutoplay(next); + } async function checkHealth() { setHealthError(null); @@ -64,6 +76,15 @@ export default function SettingsScreen() { {pushStatus && {pushStatus}} + + + 自走語音自動播放 + 開啟後,在房間畫面時角色的新發言會自動依序念出來;關閉則回到逐則點擊才播放。 + + void handleToggleVoiceAutoplay(next)} /> + 在房間時自動播放角色語音 + + ); } @@ -78,4 +99,5 @@ const styles = StyleSheet.create({ buttonText: { color: "#fff", fontWeight: "bold" }, line: { fontSize: 13, color: "#222" }, error: { color: "#FF7E9D", fontSize: 12 }, + switchRow: { flexDirection: "row", alignItems: "center", gap: 10 }, }); diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 5d996a7..3af7e3c 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -1,12 +1,32 @@ +import { useEffect } from "react"; import { Stack } from "expo-router"; +import * as Notifications from "expo-notifications"; import { SafeAreaProvider } from "react-native-safe-area-context"; +import { UnreadProvider, useUnread } from "../lib/unread-context"; // Q-1/Q-2 根佈局:只有一層 Stack 包住底部分頁籤群組,佈局策略跟 apps/web 的 RWD 行動版共用同一份設計原則 // (立繪置頂 40% + 對話流覆蓋其上),只是 React Native 沒有 CSS,改用 View 的 flex 排版達成同樣效果。 +function NotificationBadgeBridge() { + const { markUnread } = useUnread(); + + // Q-4:收到推播(含 App 前景中收到)就點亮聊天分頁的心跳紅點,直到使用者切回聊天分頁才清除。 + useEffect(() => { + const subscription = Notifications.addNotificationReceivedListener(() => { + markUnread(); + }); + return () => subscription.remove(); + }, [markUnread]); + + return null; +} + export default function RootLayout() { return ( - + + + + ); } diff --git a/apps/mobile/app/canon-import.tsx b/apps/mobile/app/canon-import.tsx new file mode 100644 index 0000000..b2ba7d8 --- /dev/null +++ b/apps/mobile/app/canon-import.tsx @@ -0,0 +1,279 @@ +import { useEffect, useState } from "react"; +import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { + importCanonScene, + listCanonWorks, + listCharacters, + type CanonWork, + type CharacterSummary, + type ImportSceneLineInput, + type ImportedSceneResult, + type SceneLineType, +} from "../lib/api"; + +const LINE_TYPE_LABELS: Record = { + DIALOGUE: "台詞", + ACTION: "動作描寫", + MONOLOGUE: "內心獨白", + NARRATION: "敘述", +}; +const LINE_TYPES = Object.keys(LINE_TYPE_LABELS) as SceneLineType[]; + +function emptyLine(): ImportSceneLineInput { + return { lineType: "NARRATION", rawText: "" }; +} + +// T20-P4 匯入章節:與 apps/web 的 app/canon-import 對應同一份契約與流程,改用 RN 元件(chip 取代 +// )。列出隔離區台詞與待人工確認的矛盾,提供逐句裁決/歸屬更正/指紋精煉後重試。 +export default function CanonReviewScreen() { + const router = useRouter(); + const [works, setWorks] = useState([]); + const [workId, setWorkId] = useState(""); + const [characters, setCharacters] = useState([]); + const [quarantined, setQuarantined] = useState([]); + const [confirmed, setConfirmed] = useState([]); + const [contradictions, setContradictions] = useState([]); + const [selection, setSelection] = useState>({}); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + void listCanonWorks() + .then(setWorks) + .catch(() => setError("無法連線到 api 服務,作品清單暫時無法顯示")); + }, []); + + async function refreshWork(targetWorkId: string) { + if (!targetWorkId) return; + setError(null); + try { + const [allCharacters, scenes, contradictionList] = await Promise.all([ + listCharacters(), + listCanonScenes(targetWorkId), + listContradictions(targetWorkId), + ]); + setCharacters(allCharacters.filter((c) => c.workId === targetWorkId)); + setContradictions(contradictionList); + + const details = await Promise.all(scenes.map((scene) => getCanonScene(scene.id))); + const nextQuarantined: LineRow[] = []; + const nextConfirmed: LineRow[] = []; + for (const scene of details) { + for (const line of scene.lines) { + const row = { ...line, sceneId: scene.id }; + if (line.status === "QUARANTINED") { + nextQuarantined.push(row); + } else if (line.status === "CONFIRMED") { + nextConfirmed.push(row); + } + } + } + setQuarantined(nextQuarantined); + setConfirmed(nextConfirmed); + } catch { + setError("待裁決資料查詢失敗,請確認 api 服務是否已啟動"); + } + } + + useEffect(() => { + void refreshWork(workId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workId]); + + async function handleAdjudicate(lineId: string) { + const speakerCharacterId = selection[lineId]; + if (!speakerCharacterId || pending) return; + setPending(true); + setError(null); + try { + await adjudicateLine(lineId, speakerCharacterId); + await refreshWork(workId); + } catch { + setError("裁決失敗,請確認 api 服務是否已啟動"); + } finally { + setPending(false); + } + } + + async function handleCorrect(lineId: string) { + const newSpeakerCharacterId = selection[lineId]; + if (!newSpeakerCharacterId || pending) return; + setPending(true); + setError(null); + try { + await correctLineAttribution(lineId, newSpeakerCharacterId); + await refreshWork(workId); + } catch { + setError("歸屬更正失敗,請確認 api 服務是否已啟動"); + } finally { + setPending(false); + } + } + + async function handleReattempt(sceneId: string) { + if (pending) return; + setPending(true); + setError(null); + try { + await reattemptQuarantined(sceneId); + await refreshWork(workId); + } catch { + setError("重試失敗,請確認 api 服務是否已啟動"); + } finally { + setPending(false); + } + } + + const sceneIdsWithQuarantine = Array.from(new Set(quarantined.map((line) => line.sceneId))); + + return ( + + + 待裁決 + router.back()}> + 回到大廳 + + + + + {works.map((w) => ( + setWorkId(w.id)} style={[styles.pickChip, workId === w.id ? styles.pickChipActive : null]}> + {w.title} + + ))} + + + {error && {error}} + + {workId && ( + <> + + + 隔離區台詞(QUARANTINED) + + {sceneIdsWithQuarantine.map((sceneId) => ( + void handleReattempt(sceneId)} style={styles.pickChip}> + 指紋精煉後重試此場景 + + ))} + {quarantined.length === 0 && 目前沒有隔離中的台詞。} + {quarantined.map((line) => ( + + {line.rawText} + + 信心分數:{(line.confidence * 100).toFixed(0)}%.證據來源: + {line.hasExplicitMarker ? `明示標記→${characterLabel(characters, line.markerCharacterId)}` : "推論(無明示標記)"} + + + {characters.map((character) => ( + setSelection({ ...selection, [line.id]: character.id })} + style={[styles.typeChip, selection[line.id] === character.id ? styles.pickChipActive : null]} + > + {character.aliases[0]?.formalName ?? character.id} + + ))} + + void handleAdjudicate(line.id)} + disabled={pending || !selection[line.id]} + style={styles.confirmButton} + > + 裁決 + + + ))} + + + + 已歸屬台詞(可更正) + {confirmed.length === 0 && 目前沒有已歸屬的台詞。} + {confirmed.map((line) => ( + + {line.rawText} + 目前歸屬:{characterLabel(characters, line.speakerCharacterId)} + + {characters.map((character) => ( + setSelection({ ...selection, [line.id]: character.id })} + style={[styles.typeChip, selection[line.id] === character.id ? styles.pickChipActive : null]} + > + {character.aliases[0]?.formalName ?? character.id} + + ))} + + void handleCorrect(line.id)} disabled={pending || !selection[line.id]} style={styles.pickChip}> + 更正 + + + ))} + + + + 待人工確認的矛盾(PENDING_HUMAN_REVIEW) + {contradictions.filter((c) => c.status === "PENDING_HUMAN_REVIEW").length === 0 && ( + 目前沒有待確認的矛盾。 + )} + {contradictions + .filter((c) => c.status === "PENDING_HUMAN_REVIEW") + .map((contradiction) => ( + + + {contradiction.eventLabel}.{contradiction.factKey} + + {contradiction.description} + + ))} + + + )} + + ); +} + +const styles = StyleSheet.create({ + flex: { flex: 1, backgroundColor: "#FFF5F7" }, + content: { padding: 16, gap: 12, paddingBottom: 40 }, + headerRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, + title: { fontSize: 16, fontWeight: "bold", color: "#FF7E9D" }, + backLink: { fontSize: 12, color: "#8a6b6f" }, + chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 6 }, + pickChip: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 10, paddingVertical: 6 }, + pickChipActive: { backgroundColor: "#FFE1E9" }, + pickChipText: { fontSize: 12, color: "#222" }, + typeChip: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 8, paddingVertical: 4 }, + error: { fontSize: 12, color: "#FF7E9D" }, + section: { gap: 6 }, + label: { fontSize: 12, fontWeight: "bold", color: "#8a6b6f" }, + hint: { fontSize: 11, color: "#8a6b6f" }, + lineCard: { borderRadius: 12, borderWidth: 1, borderColor: "#E5D5D8", padding: 8, gap: 6 }, + lineText: { fontSize: 12, color: "#222" }, + confirmButton: { borderRadius: 999, backgroundColor: "#FF7E9D", paddingHorizontal: 12, paddingVertical: 6, alignSelf: "flex-start" }, + confirmButtonText: { fontSize: 12, fontWeight: "bold", color: "#fff" }, +}); diff --git a/apps/mobile/app/room/[roomId].tsx b/apps/mobile/app/room/[roomId].tsx new file mode 100644 index 0000000..2cd7b01 --- /dev/null +++ b/apps/mobile/app/room/[roomId].tsx @@ -0,0 +1,422 @@ +import { useEffect, useRef, useState } from "react"; +import { FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from "react-native"; +import { useLocalSearchParams } from "expo-router"; +import type { Character } from "@kokorone/shared"; +import { + API_BASE_URL, + ROOM_USER_SPEAKER_ID, + endRoom, + fetchExpression, + getPossessionStatus, + getRoom, + getRoomUsage, + reportRoomRead, + sendRoomMessage, + setRoomMuted, + speakAsPossessed, + subscribeRoomStream, + synthesizeVoice, + type PossessionStatus, + type RoomDetail, + type RoomTurnKind, + type RoomUsageSummary, + type TachieExpressionResponse, +} from "../../lib/api"; +import { RoomStage, type StageParticipant } from "../../components/RoomStage"; +import { isVoiceAutoplayEnabled } from "../../lib/voice-autoplay-settings"; + +const USER_ID = "seed-user-primary"; +const USER_LABEL = "你"; + +// T20-P3:與 apps/web 的 RoomChatView.tsx 對應——佇列積壓過長時只留最近幾則待播。 +const VOICE_QUEUE_MAX_BACKLOG = 3; + +interface VoiceQueueItem { + characterId: string; + text: string; +} + +// T20-P3:與 apps/web 一致,ACTION 沿用既有 ChatBubble 的敘述樣式(此處以斜體呈現),其餘三種標籤。 +const TURN_KIND_LABEL: Partial> = { + DELEGATE: "委託", + LEAVE: "離開", + SYSTEM: "系統", +}; + +type FeedItem = + | { kind: "turn"; seq: number; speakerId: string; speakerLabel: string; content: string; turnKind: RoomTurnKind } + | { kind: "member"; seq: number; characterId: string; action: "joined" | "left" } + | { kind: "closed"; seq: number; reason: string }; + +// T20-P2:通用房間畫面——取代 T20-P1 唯讀最小可用版,改為可發言、以 fetch streaming(見 +// lib/api.ts 的 subscribeRoomStream,非原生 EventSource)即時收新訊息、並依 unread(T11)畫出 +// 第一則未讀分隔線,與 apps/web 的 app/room/[roomId] 對應同一版面與行為。 +export default function RoomScreen() { + const { roomId } = useLocalSearchParams<{ roomId: string }>(); + const [room, setRoom] = useState(null); + const [characters, setCharacters] = useState([]); + const [feed, setFeed] = useState([]); + const [firstUnreadSeq, setFirstUnreadSeq] = useState(null); + const [ended, setEnded] = useState(false); + const [text, setText] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const listRef = useRef>(null); + // T20-P5:套用中時,這個房間的輸入區要換成「以套用角色的身分發話」,不提供以自己身分發話的選項。 + const [possession, setPossession] = useState(null); + + useEffect(() => { + void getPossessionStatus(USER_ID).then(setPossession).catch(() => undefined); + }, [roomId]); + + // T20-P3:立繪舞台(目前發話者高亮)與用量顯示。 + const [expressions, setExpressions] = useState>({}); + const [speakingCharacterId, setSpeakingCharacterId] = useState(null); + const [usage, setUsage] = useState(null); + + // T20-P3:自走語音自動播放——與 apps/web 對應,但行動端無瀏覽器自動播放政策,不需要解鎖手勢; + // 佇列、播放中旗標一律用 ref,避免串流事件回呼讀到註冊當下就固定住的舊值(stale closure)。 + const [autoplayEnabled, setAutoplayEnabled] = useState(true); + const autoplayEnabledRef = useRef(true); + const mutedRef = useRef(false); + const voiceQueueRef = useRef([]); + const voicePlayingRef = useRef(false); + const voiceTimerRef = useRef | null>(null); + const unmountedRef = useRef(false); + + useEffect(() => { + void isVoiceAutoplayEnabled().then((value) => { + autoplayEnabledRef.current = value; + setAutoplayEnabled(value); + }); + return () => { + unmountedRef.current = true; + if (voiceTimerRef.current) clearTimeout(voiceTimerRef.current); + voiceQueueRef.current = []; + }; + }, []); + + function refreshExpression(characterId: string) { + fetchExpression(characterId, USER_ID) + .then((expression) => setExpressions((prev) => ({ ...prev, [characterId]: expression }))) + .catch(() => undefined); + } + + function enqueueVoice(item: VoiceQueueItem) { + if (!autoplayEnabledRef.current || mutedRef.current) return; + voiceQueueRef.current.push(item); + if (voiceQueueRef.current.length > VOICE_QUEUE_MAX_BACKLOG) { + voiceQueueRef.current.splice(0, voiceQueueRef.current.length - VOICE_QUEUE_MAX_BACKLOG); + } + void processVoiceQueue(); + } + + async function processVoiceQueue() { + if (voicePlayingRef.current || unmountedRef.current) return; + const next = voiceQueueRef.current.shift(); + if (!next) return; + voicePlayingRef.current = true; + try { + const result = await synthesizeVoice(next.characterId, USER_ID, next.text); + if (unmountedRef.current) return; + if (result.audioRef.startsWith("mock://")) { + await new Promise((resolve) => { + voiceTimerRef.current = setTimeout(resolve, Math.max(result.durationEstimateMs, 300)); + }); + } + // T20-P3 已知限制:尚未安裝原生音訊播放套件(expo-audio/expo-av),真實音檔(非 mock://) + // 目前僅靜默略過、不會真的播放——見本項 wiki 完成註記;接上真實 TTS Provider 前需另外補上。 + } catch { + // 合成失敗:安靜跳過這一則,不阻斷佇列後續播放。 + } finally { + voicePlayingRef.current = false; + if (!unmountedRef.current) void processVoiceQueue(); + } + } + + useEffect(() => { + fetch(`${API_BASE_URL}/characters`) + .then((res) => res.json() as Promise) + .then(setCharacters) + .catch(() => undefined); + }, []); + + function characterLabel(characterId: string): string { + return characters.find((c) => c.id === characterId)?.aliases?.[0]?.formalName ?? characterId; + } + + async function handleToggleMute() { + if (!room || pending) return; + const next = !room.muted; + try { + await setRoomMuted(room.id, next); + mutedRef.current = next; + setRoom((prev) => (prev ? { ...prev, muted: next } : prev)); + } catch { + setError("靜音設定失敗,請確認 api 服務是否已啟動"); + } + } + + useEffect(() => { + if (!roomId) return; + let cancelled = false; + let unsubscribe: (() => void) | null = null; + + async function init() { + try { + const detail = await getRoom(roomId); + if (cancelled) return; + setRoom(detail); + mutedRef.current = detail.muted; + setEnded(detail.status === "ENDED"); + setFirstUnreadSeq(detail.unread.firstUnreadSeq); + setUsage(detail.usage); + setFeed( + detail.turns.map((turn) => ({ + kind: "turn", + seq: turn.seq, + speakerId: turn.speakerId, + speakerLabel: turn.speakerLabel, + content: turn.content, + turnKind: turn.kind, + })), + ); + for (const characterId of detail.participants) refreshExpression(characterId); + const lastTurn = detail.turns.at(-1); + if (lastTurn && lastTurn.speakerId !== ROOM_USER_SPEAKER_ID) setSpeakingCharacterId(lastTurn.speakerId); + + const lastSeq = detail.turns.length > 0 ? detail.turns[detail.turns.length - 1].seq : -1; + unsubscribe = subscribeRoomStream(roomId, lastSeq, (event) => { + if (event.type === "turn") { + const { kind: turnKind, ...rest } = event.data; + setFeed((prev) => + prev.some((item) => item.kind === "turn" && item.seq === event.seq) + ? prev + : [...prev, { kind: "turn", seq: event.seq, turnKind, ...rest }], + ); + void reportRoomRead(roomId, USER_ID, event.seq).catch(() => undefined); + if (event.data.speakerId !== ROOM_USER_SPEAKER_ID) { + enqueueVoice({ characterId: event.data.speakerId, text: event.data.content }); + setSpeakingCharacterId(event.data.speakerId); + refreshExpression(event.data.speakerId); + void getRoomUsage(roomId).then(setUsage).catch(() => undefined); + } + } else if (event.type === "member") { + setFeed((prev) => [...prev, { kind: "member", seq: event.seq, ...event.data }]); + } else if (event.type === "closed") { + setFeed((prev) => [...prev, { kind: "closed", seq: event.seq, reason: event.data.reason }]); + setEnded(true); + } + }); + } catch { + if (!cancelled) setError("房間查詢失敗,請確認 api 服務是否已啟動"); + } + } + + void init(); + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, [roomId]); + + // T20-P5:只有「套用中且她本人就在這個房間」才切換成以她的身分發話。 + const possessedElsewhere = Boolean(possession?.possessingCharacterId) && !room?.participants.includes(possession!.possessingCharacterId!); + const possessedCharacterId = + possession?.possessingCharacterId && room?.participants.includes(possession.possessingCharacterId) + ? possession.possessingCharacterId + : null; + + async function handleSend() { + const trimmed = text.trim(); + if (!trimmed || pending || ended || !roomId) return; + setPending(true); + setError(null); + try { + if (possessedCharacterId) { + await speakAsPossessed(roomId, USER_ID, trimmed); + } else { + await sendRoomMessage(roomId, USER_ID, USER_LABEL, trimmed); + } + setText(""); // 新訊息透過串流推送進 feed,這裡不自行 append + } catch (err) { + setError(err instanceof Error ? err.message : "發言失敗,請確認 api 服務是否已啟動"); + } finally { + setPending(false); + } + } + + async function handleEnd() { + if (pending || ended || !roomId) return; + setPending(true); + setError(null); + try { + await endRoom(roomId); + setEnded(true); + } catch { + setError("結束房間失敗,請確認 api 服務是否已啟動"); + } finally { + setPending(false); + } + } + + if (error && !room) { + return ( + + {error} + + ); + } + if (!room) { + return ( + + 載入中…… + + ); + } + + return ( + + + + {room.mode}.{ended ? "已結束" : "進行中"} + {usage && usage.generationCount > 0 ? `.${usage.totalPromptTokens + usage.totalCompletionTokens} tokens` : ""} + + + void handleToggleMute()} disabled={ended}> + {room.muted ? "🔇 已靜音" : "🔊 靜音"} + + + 結束房間 + + + + + ((characterId) => ({ + characterId, + label: characterLabel(characterId), + expression: expressions[characterId] ?? null, + }))} + speakingCharacterId={speakingCharacterId} + /> + + `${item.kind}-${item.seq}`} + onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: true })} + ListEmptyComponent={還沒有人說話。} + renderItem={({ item }) => ( + + {firstUnreadSeq !== null && item.seq === firstUnreadSeq && ( + + + 未讀訊息 + + + )} + {item.kind === "turn" && ( + + {TURN_KIND_LABEL[item.turnKind] && {TURN_KIND_LABEL[item.turnKind]}} + + {item.speakerLabel} + {item.content} + + + )} + {item.kind === "member" && ( + + {characterLabel(item.characterId)} {item.action === "joined" ? "進來了" : "離開了"} + + )} + {item.kind === "closed" && 房間已關閉} + + )} + /> + + {error && {error}} + + {!ended && possessedCharacterId && ( + 你正以「{characterLabel(possessedCharacterId)}」的身分發話 + )} + + {!ended && possessedElsewhere && ( + 套用中的角色不在這個房間,無法在此發話——請到她所在的房間,或先退出套用。 + )} + + {!ended && !possessedElsewhere && ( + + + + 送出 + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#FFF5F7" }, + headerRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + paddingTop: 12, + }, + title: { fontSize: 14, fontWeight: "bold", color: "#FF7E9D", flexShrink: 1 }, + headerButtons: { flexDirection: "row", alignItems: "center", gap: 12 }, + endButton: { fontSize: 12, color: "#8a6b6f" }, + endButtonDisabled: { opacity: 0.5 }, + content: { flex: 1 }, + contentInner: { padding: 16, gap: 8 }, + feedItem: { gap: 6 }, + dividerRow: { flexDirection: "row", alignItems: "center", gap: 8 }, + dividerLine: { flex: 1, height: 1, backgroundColor: "#FF7E9D" }, + dividerText: { fontSize: 10, color: "#FF7E9D" }, + hint: { fontSize: 12, color: "#8a6b6f", padding: 16 }, + error: { fontSize: 12, color: "#FF7E9D", paddingHorizontal: 16 }, + possessionNotice: { fontSize: 11, fontWeight: "bold", color: "#FF7E9D", textAlign: "center", paddingVertical: 4 }, + bubble: { maxWidth: "80%", borderRadius: 16, padding: 10, gap: 2 }, + bubbleUser: { alignSelf: "flex-end", backgroundColor: "#FF7E9D" }, + bubbleCharacter: { alignSelf: "flex-start", backgroundColor: "#EFEFEF" }, + speakerLabel: { fontSize: 10, color: "#8a6b6f" }, + bubbleText: { color: "#222" }, + actionText: { fontStyle: "italic", color: "#9B8CFF" }, + turnKindBadge: { + alignSelf: "flex-start", + fontSize: 10, + color: "#FF7E9D", + backgroundColor: "#FFE3EA", + borderRadius: 999, + paddingHorizontal: 8, + paddingVertical: 1, + marginBottom: 2, + }, + systemNote: { alignSelf: "center", fontSize: 10, color: "#8a6b6f" }, + inputRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + margin: 12, + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 999, + borderWidth: 1, + borderColor: "#E5D5D8", + }, + input: { flex: 1, fontSize: 14 }, + sendButton: { color: "#FF7E9D", fontWeight: "bold" }, + sendButtonDisabled: { opacity: 0.5 }, +}); diff --git a/apps/mobile/components/HeartbeatDot.tsx b/apps/mobile/components/HeartbeatDot.tsx new file mode 100644 index 0000000..9c5e6d4 --- /dev/null +++ b/apps/mobile/components/HeartbeatDot.tsx @@ -0,0 +1,42 @@ +import { useEffect, useRef } from "react"; +import { Animated, Easing, type StyleProp, type ViewStyle } from "react-native"; +import { useReduceMotion } from "../lib/reduce-motion"; + +export interface HeartbeatDotProps { + size?: number; + color?: string; + style?: StyleProp; +} + +// Q-4:未讀/推播提示紅點=小心跳,對應 apps/web 的 `.heartbeat-dot`(1.2s 循環的 opacity+scale 脈動)。 +export function HeartbeatDot({ size = 10, color = "#FF7E9D", style }: HeartbeatDotProps) { + const reduceMotion = useReduceMotion(); + const t = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (reduceMotion) { + t.setValue(0); + return; + } + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(t, { toValue: 1, duration: 600, easing: Easing.inOut(Easing.ease), useNativeDriver: true }), + Animated.timing(t, { toValue: 0, duration: 600, easing: Easing.inOut(Easing.ease), useNativeDriver: true }), + ]), + ); + loop.start(); + return () => loop.stop(); + }, [reduceMotion, t]); + + const scale = t.interpolate({ inputRange: [0, 1], outputRange: [1, 1.3] }); + const opacity = t.interpolate({ inputRange: [0, 1], outputRange: [1, 0.7] }); + + return ( + + ); +} diff --git a/apps/mobile/components/HeartbeatWave.tsx b/apps/mobile/components/HeartbeatWave.tsx new file mode 100644 index 0000000..9e9142e --- /dev/null +++ b/apps/mobile/components/HeartbeatWave.tsx @@ -0,0 +1,78 @@ +import { useEffect, useMemo, useRef } from "react"; +import { Animated, Easing, type StyleProp, type ViewStyle } from "react-native"; +import Svg, { Polyline } from "react-native-svg"; +import { useReduceMotion } from "../lib/reduce-motion"; + +const WIDTH = 400; +const MID_Y = 50; +const DASH_LENGTH = 600; + +// 與 apps/web/components/HeartbeatWave.tsx 相同的波形算法,維持三端視覺一致; +// 親密度越高,波峰振幅越大。 +function buildPoints(amplitude: number): string { + const clamped = Math.max(0, Math.min(100, amplitude)); + const scale = 0.4 + (clamped / 100) * 1.1; + const up1 = MID_Y - 15 * scale; + const down = MID_Y + 26 * scale; + const up2 = MID_Y - 22 * scale; + + const points: Array<[number, number]> = [ + [0, MID_Y], + [150, MID_Y], + [168, MID_Y], + [178, up1], + [188, down], + [198, up2], + [208, MID_Y], + [WIDTH, MID_Y], + ]; + + return points.map(([x, y]) => `${x},${y.toFixed(1)}`).join(" "); +} + +const AnimatedPolyline = Animated.createAnimatedComponent(Polyline); + +export interface HeartbeatWaveProps { + amplitude?: number; // 0~100,對應親密度 + animateOnMount?: boolean; // 是否播放「由左至右畫出」的載入動畫 + color?: string; + style?: StyleProp; +} + +export function HeartbeatWave({ amplitude = 50, animateOnMount = true, color = "#FF7E9D", style }: HeartbeatWaveProps) { + const reduceMotion = useReduceMotion(); + const shouldAnimate = animateOnMount && !reduceMotion; + const dashOffset = useRef(new Animated.Value(shouldAnimate ? DASH_LENGTH : 0)).current; + + useEffect(() => { + if (!shouldAnimate) { + dashOffset.setValue(0); + return; + } + dashOffset.setValue(DASH_LENGTH); + Animated.timing(dashOffset, { + toValue: 0, + duration: 1600, + easing: Easing.out(Easing.ease), + // strokeDashoffset 不在原生驅動支援的屬性清單內,只能走 JS 驅動。 + useNativeDriver: false, + }).start(); + }, [shouldAnimate, dashOffset]); + + const points = useMemo(() => buildPoints(amplitude), [amplitude]); + + return ( + + + + ); +} diff --git a/apps/mobile/components/LobbyPreview.tsx b/apps/mobile/components/LobbyPreview.tsx new file mode 100644 index 0000000..7837805 --- /dev/null +++ b/apps/mobile/components/LobbyPreview.tsx @@ -0,0 +1,518 @@ +import { useEffect, useState } from "react"; +import { Pressable, StyleSheet, Text, TextInput, View } from "react-native"; +import { useRouter } from "expo-router"; +import { + confirmEndingDetection, + decideInvitation, + decideLobbyCall, + enterPossession, + exitPossession, + getFullLobby, + getPossessionStatus, + listEndingDetections, + listPendingAdjudication, + listPendingInvitations, + listPendingLobbyCalls, + rejectEndingDetection, + sayToLobby, + type EndingDetectionLog, + type LobbyFullSnapshot, + type LobbyPresenceState, + type PendingInvitation, + type PendingLobbyCall, + type PossessionStatus, +} from "../lib/api"; +import { useUnread } from "../lib/unread-context"; + +const USER_ID = "seed-user-primary"; + +const PRESENCE_LABELS: Record = { + BUILDING: "建置中", + SLEEPING: "睡眠中", + BUSY: "忙碌中", + SEMI_BUSY: "略忙", + IDLE: "空閒", + IN_SOLO_ROOM: "單獨聊天中", + IN_GROUP_ROOM: "多人房中", +}; + +// T20-P2:大廳頁完整版,取代 T20-P1 最小快照(在場名單+房間清單),改用 T4 正式聚合端點, +// 與 apps/web 的 LobbyView 對應同一版面:進行中房間(含未讀)→ 作品分組角色卡片 → 大廳對話輸入。 +export function LobbyPreview() { + const router = useRouter(); + const { setRoomUnreadCounts } = useUnread(); + const [snapshot, setSnapshot] = useState(null); + const [error, setError] = useState(null); + const [text, setText] = useState(""); + const [pending, setPending] = useState(false); + const [lobbyMessage, setLobbyMessage] = useState(null); + const [pendingAdjudicationByWork, setPendingAdjudicationByWork] = useState>({}); + const [endingDetectionsByWork, setEndingDetectionsByWork] = useState>({}); + const [possession, setPossession] = useState(null); + const [pendingInvitations, setPendingInvitations] = useState([]); + const [pendingCalls, setPendingCalls] = useState([]); + + async function refresh() { + try { + const [lobby, possessionStatus] = await Promise.all([getFullLobby(USER_ID), getPossessionStatus(USER_ID)]); + setSnapshot(lobby); + setPossession(possessionStatus); + setError(null); + // T20-P2:把各房未讀數餵給 UnreadContext,聊天分頁籤的紅點才能反映「大廳裡有房間未讀」。 + setRoomUnreadCounts(Object.fromEntries(lobby.activeRooms.map((room) => [room.id, room.unreadCount]))); + } catch { + setError("大廳查詢失敗,請確認 api 服務是否已啟動"); + } + } + + useEffect(() => { + void refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // T20-P5:套用中時查詢待決邀請與待決大廳呼叫,與 apps/web 的 LobbyView 對應同一批次查詢方式。 + useEffect(() => { + if (!possession?.possessingCharacterId) { + setPendingInvitations([]); + setPendingCalls([]); + return; + } + let cancelled = false; + void Promise.all([listPendingInvitations(USER_ID), listPendingLobbyCalls(USER_ID)]).then(([invitations, calls]) => { + if (cancelled) return; + setPendingInvitations(invitations); + setPendingCalls(calls); + }); + return () => { + cancelled = true; + }; + }, [possession]); + + async function handleEnterPossession(characterId: string) { + if (pending) return; + setPending(true); + setError(null); + try { + await enterPossession(USER_ID, characterId); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "套用失敗,請確認 api 服務是否已啟動"); + } finally { + setPending(false); + } + } + + async function handleExitPossession() { + if (pending) return; + setPending(true); + setError(null); + try { + await exitPossession(USER_ID); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "退出套用失敗,請確認 api 服務是否已啟動"); + } finally { + setPending(false); + } + } + + async function handleDecideInvitation(invitationId: string, accept: boolean) { + if (pending) return; + setPending(true); + setError(null); + try { + await decideInvitation(invitationId, accept); + setPendingInvitations(await listPendingInvitations(USER_ID)); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "邀請決定失敗,請確認 api 服務是否已啟動"); + } finally { + setPending(false); + } + } + + async function handleDecideCall(pendingCallId: string, accept: boolean) { + if (pending) return; + setPending(true); + setError(null); + try { + const decision = await decideLobbyCall(pendingCallId, accept); + setPendingCalls(await listPendingLobbyCalls(USER_ID)); + if (decision.roomId) { + router.push(`/room/${decision.roomId}`); + return; + } + } catch (err) { + setError(err instanceof Error ? err.message : "呼叫決定失敗,請確認 api 服務是否已啟動"); + } finally { + setPending(false); + } + } + + // T20-P4:作品卡片標題列的「有待裁決項目」提示與結局偵測待確認清單,與 apps/web 的 + // LobbyView 對應同一批次查詢方式,snapshot 每次刷新都會連動重新取得。 + useEffect(() => { + if (!snapshot) return; + const workIds = snapshot.works.map((w) => w.id); + let cancelled = false; + void Promise.all( + workIds.map(async (workId) => { + const [adjudication, endings] = await Promise.all([listPendingAdjudication(workId), listEndingDetections(workId)]); + return { workId, pendingCount: adjudication.length, endings }; + }), + ).then((results) => { + if (cancelled) return; + setPendingAdjudicationByWork(Object.fromEntries(results.map((r) => [r.workId, r.pendingCount]))); + setEndingDetectionsByWork(Object.fromEntries(results.map((r) => [r.workId, r.endings]))); + }); + return () => { + cancelled = true; + }; + }, [snapshot]); + + async function handleConfirmEnding(logId: string) { + if (pending) return; + setPending(true); + setError(null); + try { + await confirmEndingDetection(logId); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "結局確認失敗,請確認 api 服務是否已啟動"); + } finally { + setPending(false); + } + } + + async function handleRejectEnding(logId: string) { + if (pending) return; + setPending(true); + setError(null); + try { + await rejectEndingDetection(logId); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "結局拒絕失敗,請確認 api 服務是否已啟動"); + } finally { + setPending(false); + } + } + + async function handleSay() { + const trimmed = text.trim(); + if (!trimmed || pending || possession?.possessingCharacterId) return; + setPending(true); + setError(null); + setLobbyMessage(null); + try { + const result = await sayToLobby(USER_ID, trimmed); + setText(""); + if (result.roomId) { + router.push(`/room/${result.roomId}`); + return; + } + if (result.message) { + setLobbyMessage(result.message); + } + await refresh(); + } catch { + setError("大廳呼叫失敗,請確認 api 服務是否已啟動"); + } finally { + setPending(false); + } + } + + if (error) { + return {error}; + } + if (!snapshot) { + return 大廳載入中……; + } + + const workGroups = snapshot.works.map((work) => ({ + work, + characters: snapshot.characters.filter((c) => c.workId === work.id), + })); + const unassigned = snapshot.characters.filter((c) => c.workId === null); + const possessedCharacterId = possession?.possessingCharacterId ?? null; + const possessedCharacterName = possessedCharacterId + ? snapshot.characters.find((c) => c.id === possessedCharacterId)?.formalName ?? possessedCharacterId + : null; + + return ( + + {possessedCharacterId && ( + + + 你現在是「{possessedCharacterName}」 + void handleExitPossession()} disabled={pending}> + 退出套用 + + + 她的自走已暫停;請到她所在的房間以她的身分發話,或在下方決定邀請與呼叫。 + + {pendingCalls.map((call) => ( + + 有人喊了你:「{call.text}」 + + void handleDecideCall(call.id, true)} disabled={pending}> + 接受 + + void handleDecideCall(call.id, false)} disabled={pending}> + 婉拒 + + + + ))} + + {pendingInvitations.map((invitation) => ( + + + {(invitation.invitedByCharacterId && + snapshot.characters.find((c) => c.id === invitation.invitedByCharacterId)?.formalName) ?? + "有人"} + 邀請你到房間裡聊聊 + + + void handleDecideInvitation(invitation.id, true)} disabled={pending}> + 接受 + + void handleDecideInvitation(invitation.id, false)} disabled={pending}> + 婉拒 + + + + ))} + + )} + + 進行中房間({snapshot.activeRooms.length}) + {snapshot.activeRooms.length === 0 ? ( + 目前沒有進行中的房間。 + ) : ( + snapshot.activeRooms.map((room) => ( + router.push(`/room/${room.id}`)}> + + + {room.memberCharacterIds + .map((id) => snapshot.characters.find((c) => c.id === id)?.formalName ?? id) + .join("、") || room.mode} + + {room.unreadCount > 0 && ( + + {room.unreadCount} + + )} + + + )) + )} + + {workGroups.map(({ work, characters }) => { + if (characters.length === 0) return null; + const pendingCount = pendingAdjudicationByWork[work.id] ?? 0; + const endings = endingDetectionsByWork[work.id] ?? []; + return ( + + + + {work.title} + {work.inEpilogue ? "(後日談)" : ""} + + + {pendingCount > 0 && ( + router.push("/canon-review")}> + 有 {pendingCount} 項待裁決 + + )} + router.push(`/canon-import?workId=${work.id}`)}> + 匯入章節 + + + + + + {characters.map((character) => ( + + + {character.formalName}/{PRESENCE_LABELS[character.presenceState]} + + {character.id === possessedCharacterId ? ( + + 套用中 + + ) : ( + !possessedCharacterId && ( + void handleEnterPossession(character.id)} disabled={pending} style={styles.possessButton}> + 套用 + + ) + )} + + ))} + + + {endings.map((ending) => ( + + 偵測到可能的結局章節,是否切入後日談? + 判定理由:{ending.evidence} + + void handleConfirmEnding(ending.id)} disabled={pending}> + 確認.切入後日談 + + void handleRejectEnding(ending.id)} disabled={pending}> + 拒絕 + + + + ))} + + {work.milestones.length > 0 && ( + + {work.milestones.map((milestone, index) => ( + + .{characters.find((c) => c.id === milestone.characterId)?.formalName ?? milestone.characterId}: + {milestone.label} + + ))} + + )} + + ); + })} + + {unassigned.length > 0 && ( + + 未分組角色 + + {unassigned.map((character) => ( + + + {character.formalName}/{PRESENCE_LABELS[character.presenceState]} + + {character.id === possessedCharacterId ? ( + + 套用中 + + ) : ( + !possessedCharacterId && ( + void handleEnterPossession(character.id)} disabled={pending} style={styles.possessButton}> + 套用 + + ) + )} + + ))} + + + )} + + {lobbyMessage && ( + + {lobbyMessage} + + )} + + {possessedCharacterId ? ( + 套用中無法以自己的身分喊人,請先退出套用。 + ) : ( + + + + 送出 + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { paddingHorizontal: 16, paddingTop: 12, paddingBottom: 12, gap: 6 }, + sectionTitle: { fontSize: 12, fontWeight: "bold", color: "#8a6b6f", marginTop: 6 }, + rosterRow: { flexDirection: "row", flexWrap: "wrap", gap: 6 }, + workSection: { gap: 6 }, + workHeaderRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 6 }, + workHeaderActions: { flexDirection: "row", alignItems: "center", gap: 6 }, + pendingBadge: { borderRadius: 999, backgroundColor: "#FF7E9D", paddingHorizontal: 8, paddingVertical: 3 }, + pendingBadgeText: { fontSize: 10, fontWeight: "bold", color: "#fff" }, + importChip: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 8, paddingVertical: 3 }, + importChipText: { fontSize: 10, color: "#8a6b6f" }, + endingCard: { borderRadius: 12, borderWidth: 1, borderColor: "#E5D5D8", padding: 8, gap: 4 }, + endingCardTitle: { fontSize: 11, color: "#222" }, + endingCardEvidence: { fontSize: 11, color: "#8a6b6f" }, + endingCardActions: { flexDirection: "row", gap: 12 }, + confirmButton: { fontSize: 11, fontWeight: "bold", color: "#FF7E9D" }, + rejectButton: { fontSize: 11, color: "#8a6b6f" }, + milestoneList: { gap: 2 }, + milestoneText: { fontSize: 11, color: "#8a6b6f" }, + chip: { + flexDirection: "row", + alignItems: "center", + gap: 4, + borderRadius: 999, + borderWidth: 1, + borderColor: "#E5D5D8", + paddingHorizontal: 10, + paddingVertical: 4, + }, + possessedBadge: { borderRadius: 999, backgroundColor: "#FF7E9D", paddingHorizontal: 6, paddingVertical: 1 }, + possessedBadgeText: { fontSize: 9, fontWeight: "bold", color: "#fff" }, + possessButton: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 6, paddingVertical: 1 }, + possessButtonText: { fontSize: 9, color: "#8a6b6f" }, + possessionBanner: { borderRadius: 12, borderWidth: 2, borderColor: "#FF7E9D", backgroundColor: "#FFE1E9", padding: 10, gap: 6 }, + possessionBannerRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, + possessionBannerTitle: { fontSize: 13, fontWeight: "bold", color: "#FF7E9D" }, + possessionBannerHint: { fontSize: 11, color: "#8a6b6f" }, + exitPossessionLink: { fontSize: 12, fontWeight: "bold", color: "#FF7E9D" }, + chipText: { fontSize: 12, color: "#222" }, + roomCard: { + borderRadius: 12, + borderWidth: 1, + borderColor: "#E5D5D8", + paddingHorizontal: 12, + paddingVertical: 8, + }, + roomCardRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, + roomCardText: { fontSize: 12, color: "#222" }, + unreadBadge: { + backgroundColor: "#FF7E9D", + borderRadius: 999, + paddingHorizontal: 7, + paddingVertical: 2, + minWidth: 18, + alignItems: "center", + }, + unreadBadgeText: { fontSize: 10, fontWeight: "bold", color: "#fff" }, + messageBox: { + borderRadius: 12, + borderWidth: 1, + borderColor: "#E5D5D8", + paddingHorizontal: 12, + paddingVertical: 8, + }, + messageBoxText: { fontSize: 12, color: "#222" }, + sayRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + marginTop: 8, + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 999, + borderWidth: 1, + borderColor: "#E5D5D8", + }, + sayInput: { flex: 1, fontSize: 14 }, + sendButton: { color: "#FF7E9D", fontWeight: "bold" }, + sendButtonDisabled: { opacity: 0.5 }, + hint: { fontSize: 12, color: "#8a6b6f", paddingHorizontal: 16 }, + error: { fontSize: 12, color: "#FF7E9D", paddingHorizontal: 16 }, +}); diff --git a/apps/mobile/components/RoomStage.tsx b/apps/mobile/components/RoomStage.tsx new file mode 100644 index 0000000..642bc73 --- /dev/null +++ b/apps/mobile/components/RoomStage.tsx @@ -0,0 +1,44 @@ +import { ScrollView, StyleSheet, Text, View } from "react-native"; +import { TachieStage, type TachieExpression } from "./TachieStage"; +import { HeartbeatDot } from "./HeartbeatDot"; + +export interface StageParticipant { + characterId: string; + label: string; + expression: TachieExpression | null; +} + +interface RoomStageProps { + participants: StageParticipant[]; + speakingCharacterId: string | null; +} + +// T20-P3:多人房立繪舞台——與 apps/web 的 components/RoomStage.tsx 呈現同等資訊,目前發話者 +// 用 HeartbeatDot(Q-4 既有的心跳提示元件)標示,人多時整體縮小排列。 +export function RoomStage({ participants, speakingCharacterId }: RoomStageProps) { + if (participants.length === 0) return null; + const scale = participants.length <= 3 ? 1 : participants.length <= 6 ? 0.7 : 0.5; + + return ( + + {participants.map((participant) => { + const speaking = participant.characterId === speakingCharacterId; + return ( + + {speaking && } + + {participant.label} + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + row: { flexDirection: "row", alignItems: "flex-end", gap: 8, paddingHorizontal: 12, paddingVertical: 4 }, + item: { alignItems: "center", gap: 2 }, + dot: { marginBottom: 2 }, + label: { fontSize: 10, color: "#8a6b6f" }, + labelSpeaking: { color: "#FF7E9D", fontWeight: "bold" }, +}); diff --git a/apps/mobile/components/TachieStage.impl.tsx b/apps/mobile/components/TachieStage.impl.tsx new file mode 100644 index 0000000..f8278a4 --- /dev/null +++ b/apps/mobile/components/TachieStage.impl.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from "react"; +import { StyleSheet, Text, View } from "react-native"; +import { Canvas, Group, Oval, Path, Rect, RoundedRect } from "@shopify/react-native-skia"; +import type { EmotionTag } from "@kokorone/shared"; + +export type GazeDirection = "UP" | "AWAY" | "DIRECT" | "NEUTRAL"; + +export interface TachieExpression { + emotionTag: EmotionTag; + eyebrow: string; + eye: string; + mouth: string; + effect: string | null; + pose: string; + magnitude: number; + gaze: GazeDirection; +} + +const EFFECT_GLYPH: Record = { + 臉紅: "◕‸◕", + 汗: "💧", + 淚: "😢", + 青筋: "💢", + 音符: "♪", +}; + +const WIDTH = 200; +const HEIGHT = 260; +const BODY_X = 100; +const BODY_Y = 40; + +// Q-5:以 react-native-skia 重現 apps/web/components/TachieStage.tsx(PixiJS)的分層合成與微動態, +// 沿用同一套佔位色塊幾何與 magnitude 縮放公式,維持三端視覺一致。差分選用規則仍在後端 +// (/tachie/:characterId/:userId/expression),這裡只負責依 expression 的欄位畫出對應的差分。 +// 沒有安裝 react-native-reanimated(Skia 此版本已列為選用 peer dependency),改用一般 React state +// 搭配 requestAnimationFrame 驅動眨眼/呼吸這種低頻率的常駐動態,避免多引入一個原生依賴與 babel 設定。 +export function TachieStage({ expression }: { expression: TachieExpression | null }) { + const [breathPhase, setBreathPhase] = useState(0); + const [blinking, setBlinking] = useState(false); + const blinkClockRef = useRef(0); + const lastTickRef = useRef(null); + + useEffect(() => { + let raf: number; + function tick(now: number) { + const last = lastTickRef.current ?? now; + const deltaTime = ((now - last) / 1000) * 60; // 對齊 web 版以「每 frame(60fps 基準)」為單位的節奏 + lastTickRef.current = now; + + setBreathPhase((phase) => phase + deltaTime * 0.03); + blinkClockRef.current += deltaTime; + const cycle = blinkClockRef.current % 180; // 約每 3 秒眨一次眼,與 web 版一致 + setBlinking(cycle > 172); + + raf = requestAnimationFrame(tick); + } + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, []); + + // O-4 外顯度縮放:magnitude 越小,差分動作幅度越小;佔位資產仍保留最低幅度,確保肉眼看得出「有變化」。 + const magnitude = Math.max(0.12, expression?.magnitude ?? 0.12); + const bodyScaleY = 1 + Math.sin(breathPhase) * 0.015; + const eyeScaleY = blinking ? 0.1 : 1; + + const eyebrowY = -26 - 6 * magnitude; + const gazeDx = expression?.gaze === "AWAY" ? -8 : 0; + const gazeDy = expression?.gaze === "UP" ? -5 : 0; + const eyeHeight = 6 + 6 * magnitude; + const isFrown = expression?.emotionTag === "SAD" || expression?.emotionTag === "GRUMPY"; + const curve = 10 * magnitude * (isFrown ? 1 : -1); + const mouthPath = `M -12 6 Q 0 ${6 + curve} 12 6`; + + const glyph = expression?.effect ? (EFFECT_GLYPH[expression.effect] ?? expression.effect) : null; + + return ( + + + {/* O-1/O-4 分層合成(由下而上):身體姿勢 → 服裝層 → 表情層(眉/眼/口)→ 動態層(眨眼/呼吸,常駐)。 */} + + + + + + + + + + + + + + + + + + + + {glyph && {glyph}} + + ); +} + +const styles = StyleSheet.create({ + effectGlyph: { position: "absolute", left: 140, top: 30, fontSize: 20 }, +}); + +// TachieStage.web.tsx 透過 WithSkiaWeb 動態載入這個模組時要求 default export; +// 原生端(TachieStage.tsx)用回具名匯出,兩邊都指向同一份實作,不重複維護兩套繪製邏輯。 +export default TachieStage; diff --git a/apps/mobile/components/TachieStage.tsx b/apps/mobile/components/TachieStage.tsx new file mode 100644 index 0000000..f8e4cc4 --- /dev/null +++ b/apps/mobile/components/TachieStage.tsx @@ -0,0 +1,3 @@ +// 原生端(iOS/Android/Expo Go)直接用 react-native-skia 的原生繪圖引擎,不需要 Web 專用的 +// CanvasKit WASM 載入流程——見 TachieStage.web.tsx 才是那條路徑要走的地方。 +export { TachieStage, type TachieExpression, type GazeDirection } from "./TachieStage.impl"; diff --git a/apps/mobile/components/TachieStage.web.tsx b/apps/mobile/components/TachieStage.web.tsx new file mode 100644 index 0000000..5b4a6ba --- /dev/null +++ b/apps/mobile/components/TachieStage.web.tsx @@ -0,0 +1,20 @@ +import { Text } from "react-native"; +// eslint-disable-next-line import/no-unresolved -- 深層路徑是 react-native-skia 官方文件指定的 Web 專用匯出位置。 +import { WithSkiaWeb } from "@shopify/react-native-skia/lib/module/web"; +import type { GazeDirection, TachieExpression } from "./TachieStage.impl"; + +export type { GazeDirection, TachieExpression }; + +// Expo web 走瀏覽器 CanvasKit(WASM),需要先非同步載入才能畫圖——原生端沒有這個限制, +// 所以原生/網頁各自一份極薄的進入點,實際繪製邏輯全部共用 TachieStage.impl.tsx。 +// canvaskit.wasm 已由 `npx setup-skia-web` 放在 apps/mobile/public/,走 Metro web 的靜態根目錄慣例。 +export function TachieStage(props: { expression: TachieExpression | null }) { + return ( + import("./TachieStage.impl")} + componentProps={props} + fallback={立繪載入中…} + opts={{ locateFile: () => "/canvaskit.wasm" }} + /> + ); +} diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index 8d29000..a4d2461 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -38,16 +38,31 @@ export interface ChatResult { context: ChatContext | null; } -export async function sendChatMessage( - characterId: string, - userId: string, - sessionId: string, - text: string, -): Promise { - const res = await fetch(`${API_BASE_URL}/chat/${characterId}`, { +// T38:既有 1:1 聊天併入 SOLO 房,取代舊版由呼叫端自行產生 sessionId 的 sendChatMessage。 +// 伺服器端為同一使用者+角色配對沿用同一間進行中的 SOLO 房,契約與 apps/web/lib/api.ts 一致。 +export interface SoloRoomSummary { + id: string; + mode: "SOLO"; + status: "ACTIVE" | "CLOSED"; +} + +export async function getOrCreateSoloRoom(userId: string, characterId: string): Promise { + const res = await fetch(`${API_BASE_URL}/room/solo`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId, sessionId, text }), + body: JSON.stringify({ userId, characterId }), + }); + if (!res.ok) { + throw new Error(`房間建立失敗(${res.status})`); + } + return res.json(); +} + +export async function sendSoloMessage(roomId: string, userId: string, text: string): Promise { + const res = await fetch(`${API_BASE_URL}/room/${roomId}/message`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, text }), }); if (!res.ok) { throw new Error(`對話請求失敗(${res.status})`); @@ -78,6 +93,25 @@ export async function fetchExpression(characterId: string, userId: string): Prom return res.json(); } +export interface SynthesizedVoice { + audioRef: string; // TTS_PROVIDER=mock 時是 `mock://` 佔位字串,非真實音檔 + prosodyMarkup: string; + durationEstimateMs: number; +} + +// T20-P3:語音合成——與 apps/web 呼叫同一端點,播放邏輯見 app/room/[roomId].tsx。 +export async function synthesizeVoice(characterId: string, userId: string, text: string): Promise { + const res = await fetch(`${API_BASE_URL}/voice/${characterId}/${userId}/synthesize`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text }), + }); + if (!res.ok) { + throw new Error(`語音合成失敗(${res.status})`); + } + return res.json(); +} + export async function registerPushToken(userId: string, token: string, platform: string): Promise { await fetch(`${API_BASE_URL}/push/register`, { method: "POST", @@ -85,3 +119,618 @@ export async function registerPushToken(userId: string, token: string, platform: body: JSON.stringify({ userId, token, platform }), }); } + +// T20-P1:大廳頁最小可用(在場名單+進行中房間清單)與房間歷程分頁,契約與 apps/web/lib/api.ts 一致 +// (見該檔 T19-P1 段落),此處為 React Native 端各自實作的對應封裝。 +export interface LobbySnapshot { + lobbyRoomId: string; + roster: string[]; + activeRooms: { id: string; mode: string; topicSeed: string | null; memberCharacterIds: string[] }[]; +} + +export async function getLobby(userId: string): Promise { + const res = await fetch(`${API_BASE_URL}/room/lobby?userId=${encodeURIComponent(userId)}`); + if (!res.ok) { + throw new Error(`大廳查詢失敗(${res.status})`); + } + return res.json(); +} + +export type LobbyPresenceState = "BUILDING" | "SLEEPING" | "BUSY" | "SEMI_BUSY" | "IDLE" | "IN_SOLO_ROOM" | "IN_GROUP_ROOM"; + +export interface LobbyMilestoneView { + characterId: string; + label: string; + storyOrder: number; + createdAt: string; +} + +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; +} + +export interface LobbyFullSnapshot { + works: LobbyWorkView[]; + characters: LobbyCharacterView[]; + activeRooms: LobbyActiveRoomView[]; +} + +// T4/T20-P2:正式大廳聚合端點,契約與 apps/web/lib/api.ts 一致(見該檔 T19-P2 段落)。 +export async function getFullLobby(userId: string): Promise { + const res = await fetch(`${API_BASE_URL}/lobby?userId=${encodeURIComponent(userId)}`); + if (!res.ok) { + throw new Error(`大廳查詢失敗(${res.status})`); + } + return res.json(); +} + +export interface LobbySayResult { + calledCharacterIds: string[]; + accepted: string[]; + declined: string[]; + roomId: string | null; + mode: "SOLO" | "GROUP" | null; + message: string | null; + delegatedBy: string | null; + pendingPossessedCharacterIds: string[]; // T43:喊到但目前套用中,改記待決給套用者 +} + +// T5/T20-P2:大廳對話式呼叫,契約與 apps/web/lib/api.ts 一致。 +export async function sayToLobby(userId: string, text: string): Promise { + const res = await fetch(`${API_BASE_URL}/lobby/say`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, text }), + }); + if (!res.ok) { + throw new Error(`大廳呼叫失敗(${res.status})`); + } + return res.json(); +} + +// T20-P4:匯入章節、待裁決、結局確認——契約與 apps/web/lib/api.ts 的 T19-P4 段落一致,mobile 補上 +// 對等的封裝與畫面(`app/canon-import.tsx`/`app/canon-review.tsx`)。 + +export interface CharacterSummary { + id: string; + workId: string | null; + aliases: { formalName: string }[]; +} + +export async function listCharacters(): Promise { + const res = await fetch(`${API_BASE_URL}/characters`); + if (!res.ok) { + throw new Error(`角色清單查詢失敗(${res.status})`); + } + return res.json(); +} + +export interface CanonWork { + id: string; + title: string; + timeFlowMode: "FAST" | "REALTIME" | "SLOW" | "FROZEN"; + anchorStoryOrder: number; + inEpilogue: boolean; +} + +export async function listCanonWorks(): Promise { + const res = await fetch(`${API_BASE_URL}/canon/works`); + if (!res.ok) { + throw new Error(`作品清單查詢失敗(${res.status})`); + } + return res.json(); +} + +export interface CanonScene { + id: string; + workId: string; + storyOrder: number; + projected: boolean; +} + +export async function listCanonScenes(workId: string): Promise { + const res = await fetch(`${API_BASE_URL}/canon/works/${workId}/scenes`); + if (!res.ok) { + throw new Error(`場景清單查詢失敗(${res.status})`); + } + return res.json(); +} + +export type SceneLineStatus = "PENDING" | "CONFIRMED" | "QUARANTINED"; +export type SceneLineType = "DIALOGUE" | "ACTION" | "MONOLOGUE" | "NARRATION"; + +export interface SceneLine { + id: string; + sceneId: string; + lineIndex: number; + lineType: SceneLineType; + rawText: string; + hasExplicitMarker: boolean; + markerCharacterId: string | null; + speakerCharacterId: string | null; + confidence: number; + status: SceneLineStatus; +} + +export interface CanonSceneDetail extends CanonScene { + lines: SceneLine[]; +} + +export async function getCanonScene(sceneId: string): Promise { + const res = await fetch(`${API_BASE_URL}/canon/scenes/${sceneId}`); + if (!res.ok) { + throw new Error(`場景詳情查詢失敗(${res.status})`); + } + return res.json(); +} + +export type ContradictionStatus = "PENDING_HUMAN_REVIEW" | "RESOLVED"; + +export interface SceneContradiction { + id: string; + workId: string; + eventLabel: string; + factKey: string; + description: string; + status: ContradictionStatus; +} + +export async function listContradictions(workId: string): Promise { + const res = await fetch(`${API_BASE_URL}/canon/works/${workId}/contradictions`); + if (!res.ok) { + throw new Error(`矛盾清單查詢失敗(${res.status})`); + } + return res.json(); +} + +export async function adjudicateLine(lineId: string, speakerCharacterId: string): Promise { + const res = await fetch(`${API_BASE_URL}/canon/scene-lines/${lineId}/adjudicate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ speakerCharacterId }), + }); + if (!res.ok) { + throw new Error(`裁決失敗(${res.status})`); + } + return res.json(); +} + +export async function correctLineAttribution(lineId: string, newSpeakerCharacterId: string): Promise { + const res = await fetch(`${API_BASE_URL}/canon/scene-lines/${lineId}/correct`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ newSpeakerCharacterId }), + }); + if (!res.ok) { + throw new Error(`歸屬更正失敗(${res.status})`); + } + return res.json(); +} + +export async function reattemptQuarantined(sceneId: string): Promise<{ released: number }> { + const res = await fetch(`${API_BASE_URL}/canon/scenes/${sceneId}/reattempt-quarantined`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + if (!res.ok) { + throw new Error(`重試失敗(${res.status})`); + } + return res.json(); +} + +export interface ImportSceneLineInput { + lineType: SceneLineType; + rawText: string; + hasExplicitMarker?: boolean; + markerCharacterId?: string; +} + +export interface ImportSceneInput { + workId: string; + volume: number; + chapterLabel: string; + storyOrder: number; + summary: string; + rawText: string; + participantCharacterIds: string[]; + lines: ImportSceneLineInput[]; +} + +export interface ImportedSceneResult extends CanonSceneDetail { + pendingAdjudication: boolean; +} + +export async function importCanonScene(input: ImportSceneInput): Promise { + const res = await fetch(`${API_BASE_URL}/canon/scenes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(typeof body?.message === "string" ? body.message : `章節匯入失敗(${res.status})`); + } + return res.json(); +} + +export async function listPendingAdjudication(workId: string): Promise { + const res = await fetch(`${API_BASE_URL}/canon/works/${workId}/pending-adjudication`); + if (!res.ok) { + throw new Error(`待裁決清單查詢失敗(${res.status})`); + } + return res.json(); +} + +export type EndingDetectionResolution = "PENDING" | "CONFIRMED" | "REJECTED"; + +export interface EndingDetectionLog { + id: string; + sceneId: string; + evidence: string; + resolution: EndingDetectionResolution; + createdAt: string; + respondedAt: string | null; +} + +export async function listEndingDetections(workId: string): Promise { + const res = await fetch(`${API_BASE_URL}/canon/works/${workId}/ending-detections`); + if (!res.ok) { + throw new Error(`結局偵測清單查詢失敗(${res.status})`); + } + return res.json(); +} + +export async function confirmEndingDetection(logId: string): Promise { + const res = await fetch(`${API_BASE_URL}/epilogue/ending-detections/${logId}/confirm`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(typeof body?.message === "string" ? body.message : `結局確認失敗(${res.status})`); + } + return res.json(); +} + +export async function rejectEndingDetection(logId: string): Promise { + const res = await fetch(`${API_BASE_URL}/epilogue/ending-detections/${logId}/reject`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(typeof body?.message === "string" ? body.message : `結局拒絕失敗(${res.status})`); + } + return res.json(); +} + +export const ROOM_USER_SPEAKER_ID = "USER"; + +// T20-P3:TALK/ACTION/DELEGATE/LEAVE/SYSTEM,對應後端 RoomTurnKind(見 T6/T19-P3)。 +export type RoomTurnKind = "TALK" | "ACTION" | "DELEGATE" | "LEAVE" | "SYSTEM"; + +export interface RoomTurn { + seq: number; + speakerId: string; + speakerLabel: string; + content: string; + kind: RoomTurnKind; +} + +export interface RoomUnreadInfo { + lastReadSeq: number; + firstUnreadSeq: number | null; + unreadCount: number; +} + +export interface RoomUsageSummary { + totalPromptTokens: number; + totalCompletionTokens: number; + generationCount: number; +} + +export interface RoomDetail { + id: string; + mode: string; + status: string; + topicSeed: string | null; + participants: string[]; + turnCount: number; + turns: RoomTurn[]; + hasMore: boolean; + nextCursor: number | null; + unread: RoomUnreadInfo; + muted: boolean; // T31:靜音——不推播、不自動播放語音,自走仍照常進行 + usage: RoomUsageSummary; +} + +export async function getRoomUsage(roomId: string): Promise { + const res = await fetch(`${API_BASE_URL}/room/${roomId}/usage`); + if (!res.ok) { + throw new Error(`用量查詢失敗(${res.status})`); + } + return res.json(); +} + +// T31:靜音只影響推播與自動語音播放,自走排程與生成照常進行。 +export async function setRoomMuted(roomId: string, muted: boolean): Promise<{ id: string; muted: boolean }> { + const res = await fetch(`${API_BASE_URL}/room/${roomId}/mute`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ muted }), + }); + if (!res.ok) { + throw new Error(`靜音設定失敗(${res.status})`); + } + return res.json(); +} + +export interface RoomTurnsPageOptions { + afterSeq?: number; + beforeSeq?: number; + limit?: number; +} + +export async function getRoom(roomId: string, page: RoomTurnsPageOptions = {}): Promise { + const params = new URLSearchParams(); + if (page.afterSeq !== undefined) params.set("afterSeq", String(page.afterSeq)); + if (page.beforeSeq !== undefined) params.set("beforeSeq", String(page.beforeSeq)); + if (page.limit !== undefined) params.set("limit", String(page.limit)); + const query = params.toString(); + const res = await fetch(`${API_BASE_URL}/room/${roomId}${query ? `?${query}` : ""}`); + if (!res.ok) { + throw new Error(`房間查詢失敗(${res.status})`); + } + return res.json(); +} + +export interface RoomMessageResult { + speakers: { characterId: string; text: string; score: number }[]; +} + +// T20-P2:通用房間發言——後端依 room.mode 內部分流 SOLO/GROUP,回應形狀不同,但本畫面一律透過 +// SSE/串流收新訊息,不依賴這裡的回傳值本身,故用寬鬆型別接收即可。 +export async function sendRoomMessage(roomId: string, userId: string, userLabel: string, text: string): Promise { + const res = await fetch(`${API_BASE_URL}/room/${roomId}/message`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, userLabel, text }), + }); + if (!res.ok) { + throw new Error(`發言失敗(${res.status})`); + } + return res.json(); +} + +export async function endRoom(roomId: string): Promise<{ ok: boolean }> { + const res = await fetch(`${API_BASE_URL}/room/${roomId}/end`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + if (!res.ok) { + throw new Error(`結束房間失敗(${res.status})`); + } + return res.json(); +} + +// T11/T20-P2:回報已讀游標,契約與 apps/web/lib/api.ts 一致。 +export async function reportRoomRead(roomId: string, userId: string, lastReadSeq: number): Promise { + const res = await fetch(`${API_BASE_URL}/room/${roomId}/read`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, lastReadSeq }), + }); + if (!res.ok) { + throw new Error(`已讀回報失敗(${res.status})`); + } + return res.json(); +} + +export type RoomStreamEvent = + | { type: "turn"; seq: number; data: { speakerId: string; speakerLabel: string; content: string; kind: RoomTurnKind } } + | { type: "member"; seq: number; data: { characterId: string; action: "joined" | "left" } } + | { type: "closed"; seq: number; data: { reason: string } }; + +// T10/T20-P2:房間即時推送——React Native 沒有跨平台一致的 EventSource(見 AGENTS.md +// 「Expo HAS CHANGED」提醒,已查證 Expo SDK 57 官方文件:`expo/fetch` 提供 WinterCG 相容的 +// streaming response body,`EventSource` 則未在文件中記載跨平台可用),故改用 `expo/fetch` 的 +// `resp.body.getReader()` 手動解析標準 SSE 分幀(event/id/data,空行斷幀),語意與 apps/web 用 +// 瀏覽器原生 EventSource 消費的是同一個後端端點、同一套事件格式,只是消費端實作不同。 +// 回傳一個 cancel 函式,呼叫者卸載時務必呼叫以中止底層串流讀取。 +export function subscribeRoomStream( + roomId: string, + afterSeq: number, + onEvent: (event: RoomStreamEvent) => void, + onError?: (err: unknown) => void, +): () => void { + let cancelled = false; + const controller = new AbortController(); + + (async () => { + try { + const { fetch: expoFetch } = await import("expo/fetch"); + const resp = await expoFetch(`${API_BASE_URL}/room/${roomId}/stream?afterSeq=${afterSeq}`, { + headers: { Accept: "text/event-stream" }, + signal: controller.signal, + }); + const reader = resp.body?.getReader(); + if (!reader) return; + const decoder = new TextDecoder(); + let buffer = ""; + + while (!cancelled) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + let frameBreak = buffer.indexOf("\n\n"); + while (frameBreak !== -1) { + const frame = buffer.slice(0, frameBreak); + buffer = buffer.slice(frameBreak + 2); + + let eventType = "message"; + let id: string | null = null; + const dataLines: string[] = []; + for (const line of frame.split("\n")) { + if (line.startsWith("event:")) eventType = line.slice(6).trim(); + else if (line.startsWith("id:")) id = line.slice(3).trim(); + else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim()); + } + if (eventType !== "ping" && dataLines.length > 0 && id !== null) { + try { + const data = JSON.parse(dataLines.join("\n")); + onEvent({ type: eventType as RoomStreamEvent["type"], seq: Number(id), data } as RoomStreamEvent); + } catch { + // 忽略解析失敗的單一分幀,不中斷整條串流。 + } + } + frameBreak = buffer.indexOf("\n\n"); + } + } + } catch (err) { + if (!cancelled) onError?.(err); + } + })(); + + return () => { + cancelled = true; + controller.abort(); + }; +} + +// T20-P5 人格套用:契約與 apps/api/src/room/possession.controller.ts、apps/web/lib/api.ts 一致。 +export interface PossessionStatus { + possessingCharacterId: string | null; +} + +export async function getPossessionStatus(userId: string): Promise { + const res = await fetch(`${API_BASE_URL}/possession/status?userId=${encodeURIComponent(userId)}`); + if (!res.ok) { + throw new Error(`套用狀態查詢失敗(${res.status})`); + } + return res.json(); +} + +export async function enterPossession(userId: string, characterId: string): Promise { + const res = await fetch(`${API_BASE_URL}/possession/enter`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, characterId }), + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(typeof body?.message === "string" ? body.message : `套用失敗(${res.status})`); + } + return res.json(); +} + +export async function exitPossession(userId: string): Promise { + const res = await fetch(`${API_BASE_URL}/possession/exit`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId }), + }); + if (!res.ok) { + throw new Error(`退出套用失敗(${res.status})`); + } + return res.json(); +} + +export async function speakAsPossessed( + roomId: string, + userId: string, + text: string, +): Promise<{ speakers: { characterId: string; text: string; score: number }[] }> { + const res = await fetch(`${API_BASE_URL}/possession/${roomId}/message`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, text }), + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(typeof body?.message === "string" ? body.message : `發話失敗(${res.status})`); + } + return res.json(); +} + +export type RoomInvitationStatus = "PENDING" | "ACCEPTED" | "DECLINED"; + +export interface PendingInvitation { + id: string; + roomId: string; + characterId: string; + invitedByCharacterId: string | null; + status: RoomInvitationStatus; + createdAt: string; +} + +export async function listPendingInvitations(userId: string): Promise { + const res = await fetch(`${API_BASE_URL}/possession/pending-invitations?userId=${encodeURIComponent(userId)}`); + if (!res.ok) { + throw new Error(`待決邀請查詢失敗(${res.status})`); + } + return res.json(); +} + +export async function decideInvitation(invitationId: string, accept: boolean): Promise { + const res = await fetch(`${API_BASE_URL}/possession/invitations/${invitationId}/decide`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accept }), + }); + if (!res.ok) { + throw new Error(`邀請決定失敗(${res.status})`); + } +} + +export interface PendingLobbyCall { + id: string; + userId: string; + characterId: string; + text: string; + createdAt: string; +} + +export async function listPendingLobbyCalls(userId: string): Promise { + const res = await fetch(`${API_BASE_URL}/possession/pending-calls?userId=${encodeURIComponent(userId)}`); + if (!res.ok) { + throw new Error(`待決呼叫查詢失敗(${res.status})`); + } + return res.json(); +} + +export async function decideLobbyCall(pendingCallId: string, accept: boolean): Promise<{ roomId: string | null }> { + const res = await fetch(`${API_BASE_URL}/possession/lobby-calls/${pendingCallId}/decide`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accept }), + }); + if (!res.ok) { + throw new Error(`呼叫決定失敗(${res.status})`); + } + return res.json(); +} diff --git a/apps/mobile/lib/id.ts b/apps/mobile/lib/id.ts deleted file mode 100644 index 6d4423f..0000000 --- a/apps/mobile/lib/id.ts +++ /dev/null @@ -1,7 +0,0 @@ -// 部分 Hermes 版本沒有全域 crypto.randomUUID,這裡提供一個不依賴額外套件的簡易後備方案。 -export function generateSessionId(): string { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID(); - } - return `session-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; -} diff --git a/apps/mobile/lib/reduce-motion.ts b/apps/mobile/lib/reduce-motion.ts new file mode 100644 index 0000000..f98c4aa --- /dev/null +++ b/apps/mobile/lib/reduce-motion.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; +import { AccessibilityInfo } from "react-native"; + +// Q-4:對應 apps/web 的 `prefers-reduced-motion`,兩處視覺母題動畫(HeartbeatWave/HeartbeatDot) +// 都要在系統開啟「減少動態」時關閉動畫,改為靜態呈現。 +export function useReduceMotion(): boolean { + const [reduceMotion, setReduceMotion] = useState(false); + + useEffect(() => { + let mounted = true; + AccessibilityInfo.isReduceMotionEnabled().then((enabled) => { + if (mounted) { + setReduceMotion(enabled); + } + }); + const subscription = AccessibilityInfo.addEventListener("reduceMotionChanged", (enabled: boolean) => { + setReduceMotion(enabled); + }); + return () => { + mounted = false; + subscription.remove(); + }; + }, []); + + return reduceMotion; +} diff --git a/apps/mobile/lib/unread-context.tsx b/apps/mobile/lib/unread-context.tsx new file mode 100644 index 0000000..ab4fa96 --- /dev/null +++ b/apps/mobile/lib/unread-context.tsx @@ -0,0 +1,44 @@ +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; + +interface UnreadContextValue { + hasUnread: boolean; // 推播觸發的提示 或 任一房間有未讀,兩者只要有一個成立即為 true + markUnread: () => void; // Q-4:收到推播時呼叫 + markRead: () => void; // Q-4:使用者切回聊天分頁時呼叫,只清推播提示,不動各房未讀數 + roomUnreadCounts: Record; // T20-P2:各進行中房間的未讀數(來自大廳聚合查詢) + setRoomUnreadCounts: (counts: Record) => void; + totalRoomUnread: number; +} + +const UnreadContext = createContext(null); + +// Q-4:聊天分頁的未讀/推播提示紅點狀態,由根佈局的推播接收監聽器寫入, +// 由聊天分頁在取得焦點時清除(見 app/(tabs)/index.tsx 的 useFocusEffect)。 +// T20-P2:擴充承接大廳多房未讀——分頁籤紅點現在同時反映「有推播尚未看」與「任一房間有未讀訊息」, +// 兩個來源各自獨立更新,互不覆蓋(推播清除不會把房間未讀數也清空,反之亦然)。 +export function UnreadProvider({ children }: { children: ReactNode }) { + const [pushUnread, setPushUnread] = useState(false); + const [roomUnreadCounts, setRoomUnreadCounts] = useState>({}); + const markUnread = useCallback(() => setPushUnread(true), []); + const markRead = useCallback(() => setPushUnread(false), []); + const totalRoomUnread = useMemo( + () => Object.values(roomUnreadCounts).reduce((sum, count) => sum + count, 0), + [roomUnreadCounts], + ); + const hasUnread = pushUnread || totalRoomUnread > 0; + + return ( + + {children} + + ); +} + +export function useUnread(): UnreadContextValue { + const ctx = useContext(UnreadContext); + if (!ctx) { + throw new Error("useUnread 必須在 UnreadProvider 底下使用"); + } + return ctx; +} diff --git a/apps/mobile/lib/voice-autoplay-settings.ts b/apps/mobile/lib/voice-autoplay-settings.ts new file mode 100644 index 0000000..3e08594 --- /dev/null +++ b/apps/mobile/lib/voice-autoplay-settings.ts @@ -0,0 +1,14 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; + +const STORAGE_KEY = "kokorone:voice-autoplay-enabled"; + +// T20-P3:自走語音自動播放開關,對應 apps/web 的 lib/voice-autoplay-settings.ts; +// RN 沒有同步的 localStorage,AsyncStorage 一律回傳 Promise,呼叫端需 await。預設開啟。 +export async function isVoiceAutoplayEnabled(): Promise { + const stored = await AsyncStorage.getItem(STORAGE_KEY); + return stored !== "false"; +} + +export async function setVoiceAutoplayEnabled(enabled: boolean): Promise { + await AsyncStorage.setItem(STORAGE_KEY, String(enabled)); +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 0b4095d..9737281 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -5,6 +5,8 @@ "dependencies": { "@expo/metro-runtime": "~57.0.9", "@kokorone/shared": "workspace:*", + "@react-native-async-storage/async-storage": "^2.2.0", + "@shopify/react-native-skia": "^2.11.0", "expo": "~57.0.12", "expo-constants": "~57.0.10", "expo-device": "~57.0.1", @@ -17,6 +19,7 @@ "react-native": "0.86.2", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", + "react-native-svg": "^15.15.5", "react-native-web": "^0.21.2" }, "devDependencies": { diff --git a/apps/mobile/public/canvaskit.wasm b/apps/mobile/public/canvaskit.wasm new file mode 100644 index 0000000..f241156 Binary files /dev/null and b/apps/mobile/public/canvaskit.wasm differ diff --git a/apps/web/app/canon-import/CanonImportView.tsx b/apps/web/app/canon-import/CanonImportView.tsx new file mode 100644 index 0000000..10acce2 --- /dev/null +++ b/apps/web/app/canon-import/CanonImportView.tsx @@ -0,0 +1,296 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { + importCanonScene, + listCanonWorks, + listCharacters, + type CanonWork, + type CharacterSummary, + type ImportSceneLineInput, + type ImportedSceneResult, + type SceneLineType, +} from "../../lib/api"; + +const LINE_TYPE_LABELS: Record = { + DIALOGUE: "台詞", + ACTION: "動作描寫", + MONOLOGUE: "內心獨白", + NARRATION: "敘述", +}; + +function emptyLine(): ImportSceneLineInput { + return { lineType: "NARRATION", rawText: "" }; +} + +// T19-P4 匯入章節:場景層級的摘要/原文+逐句台詞。多人在場且台詞沒有明示說話者時,M-3/M-11 +// 既有的發言歸屬會把信心不足的句子隔離(QUARANTINED),提交後直接依回應導向待裁決頁或顯示已投影。 +export function CanonImportView() { + const [works, setWorks] = useState([]); + const [workId, setWorkId] = useState(""); + const [characters, setCharacters] = useState([]); + const [chapterLabel, setChapterLabel] = useState(""); + const [volume, setVolume] = useState(1); + const [storyOrder, setStoryOrder] = useState(1); + const [summary, setSummary] = useState(""); + const [rawText, setRawText] = useState(""); + const [participantCharacterIds, setParticipantCharacterIds] = useState([]); + const [lines, setLines] = useState([emptyLine()]); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + useEffect(() => { + void listCanonWorks().then((all) => { + setWorks(all); + const presetWorkId = new URLSearchParams(window.location.search).get("workId"); + if (presetWorkId && all.some((w) => w.id === presetWorkId)) { + setWorkId(presetWorkId); + } + }); + }, []); + + useEffect(() => { + const selected = works.find((w) => w.id === workId); + setStoryOrder((selected?.anchorStoryOrder ?? 0) + 1); + setParticipantCharacterIds([]); + setResult(null); + setError(null); + if (workId) { + void listCharacters().then((all) => setCharacters(all.filter((c) => c.workId === workId))); + } else { + setCharacters([]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workId]); + + function toggleParticipant(characterId: string) { + setParticipantCharacterIds((prev) => + prev.includes(characterId) ? prev.filter((id) => id !== characterId) : [...prev, characterId], + ); + } + + function updateLine(index: number, patch: Partial) { + setLines((prev) => prev.map((line, i) => (i === index ? { ...line, ...patch } : line))); + } + + function removeLine(index: number) { + setLines((prev) => prev.filter((_, i) => i !== index)); + } + + async function handleSubmit() { + if (!workId || !chapterLabel.trim() || !summary.trim() || !rawText.trim() || pending) return; + setPending(true); + setError(null); + setResult(null); + try { + const imported = await importCanonScene({ + workId, + volume, + chapterLabel: chapterLabel.trim(), + storyOrder, + summary: summary.trim(), + rawText: rawText.trim(), + participantCharacterIds, + lines: lines.filter((line) => line.rawText.trim().length > 0), + }); + setResult(imported); + setChapterLabel(""); + setSummary(""); + setRawText(""); + setLines([emptyLine()]); + } catch (err) { + setError(err instanceof Error ? err.message : "章節匯入失敗,請確認 api 服務是否已啟動"); + } finally { + setPending(false); + } + } + + return ( +
+ + + {error && ( +

+ {error} +

+ )} + + {result && ( +
+ {result.pendingAdjudication ? ( + <> +

匯入成功,但有台詞或矛盾待人工確認,暫不投影。

+ + 前往待裁決頁 + + + ) : ( +

匯入成功,已完成投影{result.projected ? "" : "(場景超前目前錨定點,先留存等待錨定點推進)"}。

+ )} +
+ )} + + {workId && ( + <> +
+ setChapterLabel(event.target.value)} + placeholder="章節標題(例如:第十章)" + className="flex-1 rounded-full border bg-transparent px-3 py-2 text-sm outline-none" + style={{ borderColor: "var(--line)", color: "var(--ink)" }} + /> + setVolume(Number(event.target.value))} + placeholder="卷數" + className="w-20 rounded-full border bg-transparent px-3 py-2 text-sm outline-none" + style={{ borderColor: "var(--line)", color: "var(--ink)" }} + /> + setStoryOrder(Number(event.target.value))} + placeholder="storyOrder" + className="w-28 rounded-full border bg-transparent px-3 py-2 text-sm outline-none" + style={{ borderColor: "var(--line)", color: "var(--ink)" }} + /> +
+ +