195 changed files with 735 additions and 17216 deletions
+1 -5
View File
@@ -59,11 +59,7 @@ pnpm run smoke A # 執行 A 群組冒煙測試;all 表示依序執行 A~R
4. **程序常駐與重啟**:用 systemd unit(或 pm2)分別管理 api/web 兩個 4. **程序常駐與重啟**:用 systemd unit(或 pm2)分別管理 api/web 兩個
Node 行程,設定 `Restart=on-failure`;健康檢查沿用 `GET /health` Node 行程,設定 `Restart=on-failure`;健康檢查沿用 `GET /health`
(api)與首頁 200(web),與 `scripts/dev-restart.mjs` 開發環境用的 (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 5. **反向代理/TLS**:nginx 或雲端負載平衡器終止 TLS 後轉發到 api/web
對應埠號,對外只曝露 443。 對應埠號,對外只曝露 443。
+2 -4
View File
@@ -7,8 +7,7 @@
"main": "dist/main.js", "main": "dist/main.js",
"scripts": { "scripts": {
"build": "tsc -b", "build": "tsc -b",
"start": "node dist/main.js", "start": "node dist/main.js"
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@kokorone/db": "workspace:*", "@kokorone/db": "workspace:*",
@@ -24,7 +23,6 @@
"rxjs": "^7.8.2" "rxjs": "^7.8.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11"
"vitest": "^4.1.10"
} }
} }
+2 -4
View File
@@ -7,32 +7,30 @@ import { EmotionModule } from "./emotion/emotion.module.js";
import { RelationshipModule } from "./relationship/relationship.module.js"; import { RelationshipModule } from "./relationship/relationship.module.js";
import { LlmModule } from "./llm/llm.module.js"; import { LlmModule } from "./llm/llm.module.js";
import { PersonalityModule } from "./personality/personality.module.js"; import { PersonalityModule } from "./personality/personality.module.js";
import { ChatModule } from "./chat/chat.module.js";
import { ScheduleModule } from "./schedule/schedule.module.js"; import { ScheduleModule } from "./schedule/schedule.module.js";
import { TaskModule } from "./task/task.module.js"; import { TaskModule } from "./task/task.module.js";
import { RoomModule } from "./room/room.module.js"; import { RoomModule } from "./room/room.module.js";
import { LobbyModule } from "./lobby/lobby.module.js";
import { RomanceModule } from "./romance/romance.module.js"; import { RomanceModule } from "./romance/romance.module.js";
import { CanonModule } from "./canon/canon.module.js"; import { CanonModule } from "./canon/canon.module.js";
import { EpilogueModule } from "./epilogue/epilogue.module.js"; import { EpilogueModule } from "./epilogue/epilogue.module.js";
import { TachieModule } from "./tachie/tachie.module.js"; import { TachieModule } from "./tachie/tachie.module.js";
import { VoiceModule } from "./voice/voice.module.js"; import { VoiceModule } from "./voice/voice.module.js";
import { PushModule } from "./push/push.module.js"; import { PushModule } from "./push/push.module.js";
import { ClockModule } from "./clock/clock.module.js";
@Module({ @Module({
imports: [ imports: [
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
ClockModule,
CharactersModule, CharactersModule,
MemoryModule, MemoryModule,
EmotionModule, EmotionModule,
RelationshipModule, RelationshipModule,
LlmModule, LlmModule,
PersonalityModule, PersonalityModule,
ChatModule,
ScheduleModule, ScheduleModule,
TaskModule, TaskModule,
RoomModule, RoomModule,
LobbyModule,
RomanceModule, RomanceModule,
CanonModule, CanonModule,
EpilogueModule, EpilogueModule,
+4 -4
View File
@@ -29,14 +29,14 @@ export class AnchorService {
} }
// 錨定點後退:她「不再知道」超出新錨定點的後段劇情——移除這些場景先前投影出的所有角色資料。 // 錨定點後退:她「不再知道」超出新錨定點的後段劇情——移除這些場景先前投影出的所有角色資料。
// T17/T25:清除清單與 ProjectionService.clearProjectedData() 共用同一份,避免這裡各自維護一份
// 清單、日後新增的投影資料類型忘了同步加在兩處(先前 CharacterEmotionBaseline/ProceduralRule
// 上線時就漏了這裡,直到本次才發現並補上)。
const toUnproject = await this.prisma.client.scene.findMany({ const toUnproject = await this.prisma.client.scene.findMany({
where: { workId, storyOrder: { gt: newStoryOrder }, projected: true }, where: { workId, storyOrder: { gt: newStoryOrder }, projected: true },
}); });
for (const scene of toUnproject) { for (const scene of toUnproject) {
await this.projection.clearProjectedData(scene.id); 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.prisma.client.scene.update({ where: { id: scene.id }, data: { projected: false } }); await this.prisma.client.scene.update({ where: { id: scene.id }, data: { projected: false } });
} }
@@ -1,143 +0,0 @@
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);
});
+2 -48
View File
@@ -8,7 +8,6 @@ import { WorkRosterService } from "./work-roster.service.js";
import { IllustrationService } from "./illustration.service.js"; import { IllustrationService } from "./illustration.service.js";
import { AnchorService } from "./anchor.service.js"; import { AnchorService } from "./anchor.service.js";
import { TimelineService } from "./timeline.service.js"; import { TimelineService } from "./timeline.service.js";
import { EndingDetectionService } from "./ending-detection.service.js";
@Controller("canon") @Controller("canon")
export class CanonController { export class CanonController {
@@ -21,15 +20,8 @@ export class CanonController {
private readonly illustration: IllustrationService, private readonly illustration: IllustrationService,
private readonly anchor: AnchorService, private readonly anchor: AnchorService,
private readonly timeline: TimelineService, 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") @Post("scenes")
async importScene(@Body() body: ImportSceneInput) { async importScene(@Body() body: ImportSceneInput) {
return this.sceneImport.importScene(body); return this.sceneImport.importScene(body);
@@ -48,48 +40,22 @@ export class CanonController {
return this.prisma.client.scene.findMany({ where: { workId }, orderBy: { storyOrder: "asc" } }); return this.prisma.client.scene.findMany({ where: { workId }, orderBy: { storyOrder: "asc" } });
} }
// T39:裁決完(不再有隔離台詞或待確認矛盾)後才執行階段二投影與角色建置門檻檢查,
// 使用者不需要另外手動觸發——這三個既有端點裁決完成後都會自動接續嘗試。
@Post("scene-lines/:lineId/adjudicate") @Post("scene-lines/:lineId/adjudicate")
async adjudicate(@Param("lineId") lineId: string, @Body() body: { speakerCharacterId: string }) { async adjudicate(@Param("lineId") lineId: string, @Body() body: { speakerCharacterId: string }) {
await this.attribution.adjudicate(lineId, body.speakerCharacterId); await this.attribution.adjudicate(lineId, body.speakerCharacterId);
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } }); return this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
await this.sceneImport.tryProjectScene(line.sceneId);
return line;
} }
@Post("scenes/:sceneId/reattempt-quarantined") @Post("scenes/:sceneId/reattempt-quarantined")
async reattemptQuarantined(@Param("sceneId") sceneId: string) { async reattemptQuarantined(@Param("sceneId") sceneId: string) {
const released = await this.attribution.reattemptQuarantined(sceneId); const released = await this.attribution.reattemptQuarantined(sceneId);
await this.sceneImport.tryProjectScene(sceneId);
return { released }; return { released };
} }
@Post("scene-lines/:lineId/correct") @Post("scene-lines/:lineId/correct")
async correctAttribution(@Param("lineId") lineId: string, @Body() body: { newSpeakerCharacterId: string; now?: string }) { 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); await this.correction.correctLineAttribution(lineId, body.newSpeakerCharacterId, body.now ? new Date(body.now) : undefined);
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } }); return 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") @Get("characters/:characterId/roster-progress")
@@ -137,8 +103,6 @@ export class CanonController {
@Post("works/:workId/detect-contradictions") @Post("works/:workId/detect-contradictions")
async detectContradictions(@Param("workId") workId: string) { async detectContradictions(@Param("workId") workId: string) {
const created = await this.timeline.detectContradictions(workId); const created = await this.timeline.detectContradictions(workId);
// T39:矛盾是作品層級的,解決後可能同時解除多個場景的封鎖,逐一嘗試整個作品尚未投影的場景。
await this.sceneImport.tryProjectPendingScenesForWork(workId);
return { created }; return { created };
} }
@@ -146,14 +110,4 @@ export class CanonController {
async listContradictions(@Param("workId") workId: string) { async listContradictions(@Param("workId") workId: string) {
return this.timeline.listContradictions(workId); return this.timeline.listContradictions(workId);
} }
// T39:人工裁決矛盾——原本沒有任何端點能把 PENDING_HUMAN_REVIEW 的矛盾標記解決,含這類矛盾的作品
// 會永久卡住無法投影;解決後嘗試接續投影整個作品裡尚未投影的場景。
@Post("contradictions/:contradictionId/resolve")
async resolveContradiction(@Param("contradictionId") contradictionId: string, @Body() body: { resolution: string }) {
const contradiction = await this.prisma.client.sceneContradiction.findUniqueOrThrow({ where: { id: contradictionId } });
await this.timeline.resolveContradiction(contradictionId, body.resolution);
await this.sceneImport.tryProjectPendingScenesForWork(contradiction.workId);
return this.prisma.client.sceneContradiction.findUniqueOrThrow({ where: { id: contradictionId } });
}
} }
+1 -5
View File
@@ -1,7 +1,6 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module.js"; import { PrismaModule } from "../prisma/prisma.module.js";
import { EmotionModule } from "../emotion/emotion.module.js"; import { EmotionModule } from "../emotion/emotion.module.js";
import { LlmModule } from "../llm/llm.module.js";
import { FingerprintService } from "./fingerprint.service.js"; import { FingerprintService } from "./fingerprint.service.js";
import { WorkRosterService } from "./work-roster.service.js"; import { WorkRosterService } from "./work-roster.service.js";
import { IllustrationService } from "./illustration.service.js"; import { IllustrationService } from "./illustration.service.js";
@@ -11,11 +10,10 @@ import { SceneImportService } from "./scene-import.service.js";
import { AnchorService } from "./anchor.service.js"; import { AnchorService } from "./anchor.service.js";
import { TimelineService } from "./timeline.service.js"; import { TimelineService } from "./timeline.service.js";
import { CorrectionService } from "./correction.service.js"; import { CorrectionService } from "./correction.service.js";
import { EndingDetectionService } from "./ending-detection.service.js";
import { CanonController } from "./canon.controller.js"; import { CanonController } from "./canon.controller.js";
@Module({ @Module({
imports: [PrismaModule, EmotionModule, LlmModule], imports: [PrismaModule, EmotionModule],
controllers: [CanonController], controllers: [CanonController],
providers: [ providers: [
FingerprintService, FingerprintService,
@@ -27,7 +25,6 @@ import { CanonController } from "./canon.controller.js";
AnchorService, AnchorService,
TimelineService, TimelineService,
CorrectionService, CorrectionService,
EndingDetectionService,
], ],
exports: [ exports: [
FingerprintService, FingerprintService,
@@ -39,7 +36,6 @@ import { CanonController } from "./canon.controller.js";
AnchorService, AnchorService,
TimelineService, TimelineService,
CorrectionService, CorrectionService,
EndingDetectionService,
], ],
}) })
export class CanonModule {} export class CanonModule {}
@@ -1,155 +0,0 @@
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);
});
@@ -1,130 +0,0 @@
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);
});
@@ -1,40 +0,0 @@
import { Inject, Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { LLM_PROVIDER, type LLMProvider } from "../llm/llm-provider.js";
// T13 匯入時的結局偵測與提示:每個場景投影完成後都呼叫,不以章節標題關鍵字預篩(呼叫端只傳場景
// 摘要/原文/作品標題,不傳章節標題)。判定成立只寫 EndingDetectionLog(resolution=PENDING),
// 絕不直接修改 Work——是否真的切入日後談由使用者在 T14 的確認端點決定。
@Injectable()
export class EndingDetectionService {
constructor(
private readonly prisma: PrismaService,
@Inject(LLM_PROVIDER) private readonly provider: LLMProvider,
) {}
async detectForScene(sceneId: string): Promise<void> {
const scene = await this.prisma.client.scene.findUniqueOrThrow({
where: { id: sceneId },
include: { work: true },
});
// 同一場景只投影一次(tryProjectScene 對已投影場景是 no-op),理論上不會重複偵測;
// 仍加一道保險,避免任何未來呼叫路徑重複寫入同一場景的偵測紀錄。
const existing = await this.prisma.client.endingDetectionLog.findFirst({ where: { sceneId } });
if (existing) return;
const result = await this.provider.detectEnding(scene.summary, scene.rawText, scene.work.title);
if (!result.isEnding) return;
await this.prisma.client.endingDetectionLog.create({
data: { sceneId, evidence: result.evidence, resolution: "PENDING" },
});
}
async listPendingForWork(workId: string) {
return this.prisma.client.endingDetectionLog.findMany({
where: { scene: { workId }, resolution: "PENDING" },
include: { scene: true },
});
}
}
+1 -36
View File
@@ -2,7 +2,6 @@ import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js"; import { PrismaService } from "../prisma/prisma.service.js";
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js"; import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
import { HIGH_EMOTION_THRESHOLD } from "../memory/constants.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 CANON_EPOCH_MS = new Date("2000-01-01T00:00:00.000Z").getTime();
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
@@ -20,7 +19,6 @@ export class ProjectionService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly tagger: RuleBasedEmotionTagger, private readonly tagger: RuleBasedEmotionTagger,
private readonly behaviorRule: BehaviorReinforcementService,
) {} ) {}
async projectScene(sceneId: string): Promise<void> { async projectScene(sceneId: string): Promise<void> {
@@ -92,49 +90,16 @@ 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 } }); await this.prisma.client.scene.update({ where: { id: scene.id }, data: { projected: true } });
} }
// M-7 重新投影:修正場景資料庫後,先移除這個場景先前投影出的所有資料,再重新投影一次。 // M-7 重新投影:修正場景資料庫後,先移除這個場景先前投影出的所有資料,再重新投影一次。
// 只清「由這個場景投影而來」的紀錄(sourceSceneId 指到它)——使用者互動記憶從不設這個欄位,永遠不受影響。 // 只清「由這個場景投影而來」的紀錄(sourceSceneId 指到它)——使用者互動記憶從不設這個欄位,永遠不受影響。
async reprojectScene(sceneId: string): Promise<void> { async reprojectScene(sceneId: string): Promise<void> {
await this.clearProjectedData(sceneId);
await this.projectScene(sceneId);
}
// T39:階段二(投影)失敗時,把這個場景已經寫入一部分的投影結果清乾淨,回到「已裁決但未投影」
// 可安全重跑的狀態(不是留一半髒資料卡住),與 reprojectScene() 共用同一份清除邏輯。
async clearProjectedData(sceneId: string): Promise<void> {
await this.prisma.client.episodicMemory.deleteMany({ where: { sourceSceneId: sceneId } }); await this.prisma.client.episodicMemory.deleteMany({ where: { sourceSceneId: sceneId } });
await this.prisma.client.semanticMemory.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.personalityTraitSnapshot.deleteMany({ where: { sourceSceneId: sceneId } });
await this.prisma.client.characterRelationshipEventLog.deleteMany({ where: { sceneId } }); await this.prisma.client.characterRelationshipEventLog.deleteMany({ where: { sceneId } });
// T25:已知簡化——sourceSceneId 只記錄「最近一次」觸發,若這條規則後來又被別的場景強化過, await this.projectScene(sceneId);
// 這裡整條刪掉會連同其他場景的貢獻一起清掉,見 schema.prisma 該欄位的註解。
await this.prisma.client.proceduralRule.deleteMany({ where: { sourceSceneId: sceneId } });
// T17:情緒基線是每場景各自一筆(不像 ProceduralRule 是單列累加),刪除重建不會誤刪其他場景的貢獻。
await this.prisma.client.characterEmotionBaseline.deleteMany({ where: { sourceSceneId: sceneId } });
} }
} }
@@ -1,202 +0,0 @@
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);
});
+7 -102
View File
@@ -4,8 +4,6 @@ import { PrismaService } from "../prisma/prisma.service.js";
import { AttributionService } from "./attribution.service.js"; import { AttributionService } from "./attribution.service.js";
import { ProjectionService } from "./projection.service.js"; import { ProjectionService } from "./projection.service.js";
import { WorkRosterService } from "./work-roster.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 { export interface ImportLineInput {
lineType: SceneLineType; lineType: SceneLineType;
@@ -44,16 +42,6 @@ export interface ImportSceneInput {
// M-3 章節匯入與場景切分:以場景為單位寫入共用場景資料庫(角色中立的客觀記錄)。 // M-3 章節匯入與場景切分:以場景為單位寫入共用場景資料庫(角色中立的客觀記錄)。
// 場景本身的切分(章節文本 → 一個個場景)由呼叫端(人工或前置流程)完成後以結構化輸入送進來—— // 場景本身的切分(章節文本 → 一個個場景)由呼叫端(人工或前置流程)完成後以結構化輸入送進來——
// 這裡不做自由文本的自動場景邊界偵測,理由同 F-7 MockProvider:對應的真實 NLP 能力屬於之後才會補的範疇。 // 這裡不做自由文本的自動場景邊界偵測,理由同 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() @Injectable()
export class SceneImportService { export class SceneImportService {
constructor( constructor(
@@ -61,8 +49,6 @@ export class SceneImportService {
private readonly attribution: AttributionService, private readonly attribution: AttributionService,
private readonly projection: ProjectionService, private readonly projection: ProjectionService,
private readonly roster: WorkRosterService, private readonly roster: WorkRosterService,
private readonly timeline: TimelineService,
private readonly endingDetection: EndingDetectionService,
) {} ) {}
async importScene(input: ImportSceneInput) { async importScene(input: ImportSceneInput) {
@@ -78,7 +64,6 @@ export class SceneImportService {
}, },
}); });
try {
for (const characterId of input.participantCharacterIds) { for (const characterId of input.participantCharacterIds) {
await this.prisma.client.scenePresence.create({ data: { sceneId: scene.id, characterId } }); await this.prisma.client.scenePresence.create({ data: { sceneId: scene.id, characterId } });
} }
@@ -115,98 +100,18 @@ export class SceneImportService {
} }
await this.attribution.attributeScene(scene.id); 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;
}
const pendingAdjudication = await this.isPendingAdjudication(scene.id, input.workId); // M-9 知識邊界:只有在錨定點之內的場景才立刻投影;超前的場景先留在場景資料庫,等錨定點推進時再載入。
if (!pendingAdjudication) { // 建置門檻的「實質互動」次數只在投影後才算數(互動要先套用到關係帳本才算數),因此門檻檢查必須排在投影之後。
await this.tryProjectScene(scene.id); const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: input.workId } });
}
return this.getSceneDetail(scene.id);
}
// 場景自身沒有隔離中的台詞、且所屬作品沒有待人工確認的矛盾,才算「裁決完成」可以往下走階段二。
private async isPendingAdjudication(sceneId: string, workId: string): Promise<boolean> {
const quarantinedCount = await this.prisma.client.sceneLine.count({ where: { sceneId, status: "QUARANTINED" } });
if (quarantinedCount > 0) return true;
const pendingContradictions = await this.prisma.client.sceneContradiction.count({
where: { workId, status: "PENDING_HUMAN_REVIEW" },
});
return pendingContradictions > 0;
}
// 階段二:已裁決完成(不再有隔離台詞或待確認矛盾)才投影+檢查角色建置門檻;已投影過的場景直接
// no-op(避免重複觸發)。回傳是否真的執行了投影,供呼叫端(例如結局偵測)判斷要不要接著跑。
async tryProjectScene(sceneId: string): Promise<boolean> {
const scene = await this.prisma.client.scene.findUniqueOrThrow({
where: { id: sceneId },
include: { participants: true },
});
if (scene.projected) return false;
if (await this.isPendingAdjudication(sceneId, scene.workId)) return false;
// M-9 知識邊界:只有在錨定點之內的場景才投影;超前的場景先留在場景資料庫,等錨定點推進時再載入。
let work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: scene.workId } });
// T15:後日談期間的「日後談續匯入」(真實正史內容)不受知識邊界卡住——匯入代表使用者已經讀到
// 這裡,直接把錨定點推進到覆蓋這批內容再投影;純粹只影響「這篇要不要投影」的判斷,不刪除任何
// 既有 EPILOGUE 場景、也不寫入敘事化的記憶修正(與 canon-reclamation.service.ts 的 reclaim() 不同,
// reclaim() 是使用者主動選擇的覆寫手段,會找出並移除重疊的推演場景;這裡是預設路徑,兩者並存)。
if (work.inEpilogue && scene.sourceType !== "EPILOGUE" && scene.storyOrder > work.anchorStoryOrder) {
work = await this.prisma.client.work.update({
where: { id: work.id },
data: { anchorStoryOrder: scene.storyOrder },
});
}
let didProject = false;
if (scene.storyOrder <= work.anchorStoryOrder) { if (scene.storyOrder <= work.anchorStoryOrder) {
try { await this.projection.projectScene(scene.id);
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);
} }
// T13:於階段二投影完成後才加入結局偵測——場景超前錨定點而沒有真的投影時不偵測,因為錨定點本身 return this.prisma.client.scene.findUniqueOrThrow({ where: { id: scene.id }, include: { participants: true, lines: true } });
// 就代表「還沒推進到這裡」,不該提前提示結局。
if (didProject) {
await this.endingDetection.detectForScene(sceneId);
}
return didProject;
}
// 矛盾裁決是整個作品層級的(SceneContradiction 沒有 sceneId),解決後可能同時解除多個場景的封鎖,
// 因此需要逐一嘗試整個作品裡尚未投影的場景,而不只是觸發裁決當下那一個。
async tryProjectPendingScenesForWork(workId: string): Promise<number> {
const pendingScenes = await this.prisma.client.scene.findMany({ where: { workId, projected: false } });
let projected = 0;
for (const scene of pendingScenes) {
if (await this.tryProjectScene(scene.id)) projected += 1;
}
return projected;
}
async getSceneDetail(sceneId: string) {
const scene = await this.prisma.client.scene.findUniqueOrThrow({
where: { id: sceneId },
include: { participants: true, lines: true },
});
return { ...scene, pendingAdjudication: await this.isPendingAdjudication(sceneId, scene.workId) };
} }
} }
-10
View File
@@ -64,14 +64,4 @@ export class TimelineService {
async listContradictions(workId: string) { async listContradictions(workId: string) {
return this.prisma.client.sceneContradiction.findMany({ where: { workId } }); return this.prisma.client.sceneContradiction.findMany({ where: { workId } });
} }
// T39:detectContradictions() 只能自動裁決「來源優先序不同」的矛盾,剩下標記 PENDING_HUMAN_REVIEW
// 的矛盾原本沒有任何端點可以真的解決掉——這是本次補上的缺口,否則含這類矛盾的作品會永久卡住,
// 沒有任何路徑能讓後續場景繼續投影。
async resolveContradiction(contradictionId: string, resolution: string): Promise<void> {
await this.prisma.client.sceneContradiction.update({
where: { id: contradictionId },
data: { status: "RESOLVED", resolution },
});
}
} }
+39
View File
@@ -0,0 +1,39 @@
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 };
}
}
+10
View File
@@ -0,0 +1,10 @@
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 {}
-9
View File
@@ -1,9 +0,0 @@
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 {}
-7
View File
@@ -1,7 +0,0 @@
// T36:可注入時鐘介面。本計畫所有新程式一律從時鐘取時間、不直接 `new Date()`,
// 以便測試以 ControllableClock(見 controllable-clock.ts)快轉時間而不需真的等待。
export interface Clock {
now(): Date;
}
export const CLOCK = Symbol("CLOCK");
@@ -1,22 +0,0 @@
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");
});
});
-23
View File
@@ -1,23 +0,0 @@
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);
}
}
@@ -1,9 +0,0 @@
import { Injectable } from "@nestjs/common";
import type { Clock } from "./clock.js";
@Injectable()
export class SystemClockService implements Clock {
now(): Date {
return new Date();
}
}
@@ -39,10 +39,12 @@ export class CanonReclamationService {
for (const presence of scene.participants) { for (const presence of scene.participants) {
affectedCharacterIds.add(presence.characterId); affectedCharacterIds.add(presence.characterId);
} }
// 清掉這場推演事件投影出的所有角色資料——與 ProjectionService.clearProjectedData() 共用同一份 // 清掉這場推演事件投影出的所有角色資料——同 M-7 reprojectScene 的刪除邏輯,一樣只篩
// 清除清單(T17/T25 上線時就漏了這裡沒同步更新,本次隨 T15 一併發現並修正),使用者互動記憶 // sourceSceneId/sceneId,使用者互動記憶(source=INTERACTION)從不帶這些欄位,不會被動到。
// (source=INTERACTION)從不帶這些欄位,不會被動到。 await this.prisma.client.episodicMemory.deleteMany({ where: { sourceSceneId: scene.id } });
await this.projection.clearProjectedData(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.prisma.client.sceneLine.deleteMany({ where: { sceneId: 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.scenePresence.deleteMany({ where: { sceneId: scene.id } });
await this.prisma.client.scene.delete({ where: { id: scene.id } }); await this.prisma.client.scene.delete({ where: { id: scene.id } });
-22
View File
@@ -11,25 +11,3 @@ export const MILESTONE_MIN_STORY_GAP = 5;
export const MAX_EXPRESSIVENESS_DRIFT = 0.15; export const MAX_EXPRESSIVENESS_DRIFT = 0.15;
export const MAX_HALF_LIFE_REDUCTION = 0.3; // 情緒衰減最多加快 30% export const MAX_HALF_LIFE_REDUCTION = 0.3; // 情緒衰減最多加快 30%
export const HALF_LIFE_REDUCTION_PER_STORY_UNIT = 0.01; export const HALF_LIFE_REDUCTION_PER_STORY_UNIT = 0.01;
// T34:`timeFlowMode` 唯一真正的消費者——大廳/房間對話、行程判定、睡眠機制完全不讀這個欄位,
// 一律走真實時間;這裡只換算「後日談推演進度」(anchorStoryOrder 這個既有時鐘,在日後談期間
// 繼續往前走的速度),推演進度本身又是 N-2 性格漂移幅度與 N-4 里程碑低頻節流唯二依賴的量尺。
// FROZEN 恆為 0(見 epilogue-progress.service.ts);其餘數值為每個現實日推進的 storyOrder 單位數,
// 僅為示意性量尺(storyOrder 本身就是不帶物理時間意義的整數序),非官方文件規定的精確值。
//
// 刻意用「分子/幾個現實日」的整數比表示速率,而不是先算好一個 storyOrder-per-ms 的浮點常數:
// 後者在 epilogue-progress.service.ts 逐次相乘除時,會因為二進位浮點無法精確表示像 1/28800000
// 這種分數而在多次 tick 後累積出偏差(實測會少算掉整數單位);用整數比讓每次換算都以「先乘、
// 最後才除」的單一步驟完成,被除數與除數都是可精確表示的整數,不會有這種捨入誤差。
export const MS_PER_DAY = 24 * 60 * 60 * 1000;
export interface EpilogueProgressRate {
numerator: number; // 每 `perRealDays` 個現實日推進的 storyOrder 單位數
perRealDays: number;
}
export const EPILOGUE_STORY_ORDER_RATE: Record<TimeFlowMode, EpilogueProgressRate> = {
FROZEN: { numerator: 0, perRealDays: 1 },
SLOW: { numerator: 1, perRealDays: 3 },
REALTIME: { numerator: 1, perRealDays: 1 },
FAST: { numerator: 3, perRealDays: 1 },
};
@@ -1,162 +0,0 @@
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);
});
@@ -1,151 +0,0 @@
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);
});
@@ -1,60 +0,0 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { LifeEventService } from "./life-event.service.js";
import { EPILOGUE_STORY_ORDER_RATE, MS_PER_DAY, type TimeFlowMode } from "./constants.js";
// T34:後日談期間的推演進度時鐘——只在 `work.inEpilogue===true` 時才推進,把「現實流逝了多久」換算成
// `anchorStoryOrder` 該往前走多少個單位(FROZEN 恆為 0)。刻意只做「推進時鐘+提議里程碑」,不呼叫
// AnchorService.setAnchor()/ProjectionService:那條路徑會無條件重新投影任何 `projected=false` 的場景,
// 若剛好有場景還卡在 T39 裁決中,會被這裡的自動推進意外強行投影,破壞裁決阻斷;真正的場景投影
// 一律只透過既有的 SceneImportService/裁決端點觸發,這裡只是純粹的時間換算。
@Injectable()
export class EpilogueProgressService {
constructor(
private readonly prisma: PrismaService,
private readonly lifeEvent: LifeEventService,
) {}
async advanceIfInEpilogue(workId: string, now: Date): Promise<void> {
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: workId } });
if (!work.inEpilogue) return;
// 第一次呼叫(時鐘還沒有起點):只建立起點,不倒推之前「沒被 tick 到」的時間,避免一啟用
// 就把角色創建到現在為止的整段空窗期全部當成已經推演過。
if (work.epilogueProgressCheckpointAt === null) {
await this.prisma.client.work.update({ where: { id: workId }, data: { epilogueProgressCheckpointAt: now } });
return;
}
const checkpoint = work.epilogueProgressCheckpointAt;
const mode = work.timeFlowMode as TimeFlowMode;
const rate = EPILOGUE_STORY_ORDER_RATE[mode] ?? EPILOGUE_STORY_ORDER_RATE.FROZEN;
if (rate.numerator <= 0) {
// FROZEN(或未知模式,同視為凍結):不推進,但把時鐘追到現在,避免解凍後把停滯期間全部補回來。
await this.prisma.client.work.update({ where: { id: workId }, data: { epilogueProgressCheckpointAt: now } });
return;
}
// 先乘、最後才除(見 constants.ts 的說明):分子分母都是整數,被除數在合理的現實時間跨度內
// 仍落在 Number 可精確表示的整數範圍,避免預先算好的浮點速率常數在多次 tick 後累積捨入誤差。
const elapsedMs = now.getTime() - checkpoint.getTime();
const denominator = MS_PER_DAY * rate.perRealDays;
const delta = Math.floor((elapsedMs * rate.numerator) / denominator);
if (delta <= 0) return; // 累積量還不到一個 storyOrder 單位,時鐘先不動,留給下次繼續累積。
const consumedMs = (delta * denominator) / rate.numerator;
const newAnchorStoryOrder = work.anchorStoryOrder + delta;
await this.prisma.client.work.update({
where: { id: workId },
data: { anchorStoryOrder: newAnchorStoryOrder, epilogueProgressCheckpointAt: new Date(checkpoint.getTime() + consumedMs) },
});
// N-4 里程碑:低頻節流與正史伏筆比對交給既有的 proposeMilestone() 自己判斷,這裡對在場的每位
// 角色都嘗試提議一次即可(沒有模板可對應或還沒到節流間隔時,proposeMilestone() 本身回傳 null)。
const characters = await this.prisma.client.character.findMany({ where: { workId } });
for (const character of characters) {
await this.lifeEvent.proposeMilestone(character.id, newAnchorStoryOrder, [], now);
}
}
}
@@ -20,17 +20,6 @@ export class EpilogueController {
return this.timeFlow.checkEpilogueTransition(workId); 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") @Post("works/:workId/time-flow")
async setTimeFlow(@Param("workId") workId: string, @Body() body: { mode: TimeFlowMode }) { async setTimeFlow(@Param("workId") workId: string, @Body() body: { mode: TimeFlowMode }) {
return this.timeFlow.setTimeFlow(workId, body.mode); return this.timeFlow.setTimeFlow(workId, body.mode);
@@ -54,11 +43,6 @@ export class EpilogueController {
); );
} }
@Get("works/:workId/milestones")
async listMilestones(@Param("workId") workId: string) {
return this.lifeEvent.listMilestonesForWork(workId);
}
@Post("works/:workId/reclaim") @Post("works/:workId/reclaim")
async reclaim(@Param("workId") workId: string, @Body() body: { newCanonScenes: ImportSceneInput[] }) { async reclaim(@Param("workId") workId: string, @Body() body: { newCanonScenes: ImportSceneInput[] }) {
return this.reclamation.reclaim(workId, body.newCanonScenes); return this.reclamation.reclaim(workId, body.newCanonScenes);
+2 -3
View File
@@ -6,13 +6,12 @@ import { TimeFlowService } from "./time-flow.service.js";
import { PersonalityDriftService } from "./personality-drift.service.js"; import { PersonalityDriftService } from "./personality-drift.service.js";
import { LifeEventService } from "./life-event.service.js"; import { LifeEventService } from "./life-event.service.js";
import { CanonReclamationService } from "./canon-reclamation.service.js"; import { CanonReclamationService } from "./canon-reclamation.service.js";
import { EpilogueProgressService } from "./epilogue-progress.service.js";
import { EpilogueController } from "./epilogue.controller.js"; import { EpilogueController } from "./epilogue.controller.js";
@Module({ @Module({
imports: [PrismaModule, RomanceModule, CanonModule], imports: [PrismaModule, RomanceModule, CanonModule],
controllers: [EpilogueController], controllers: [EpilogueController],
providers: [TimeFlowService, PersonalityDriftService, LifeEventService, CanonReclamationService, EpilogueProgressService], providers: [TimeFlowService, PersonalityDriftService, LifeEventService, CanonReclamationService],
exports: [TimeFlowService, PersonalityDriftService, LifeEventService, CanonReclamationService, EpilogueProgressService], exports: [TimeFlowService, PersonalityDriftService, LifeEventService, CanonReclamationService],
}) })
export class EpilogueModule {} export class EpilogueModule {}
@@ -110,13 +110,4 @@ export class LifeEventService {
await this.projection.projectScene(scene.id); await this.projection.projectScene(scene.id);
return this.prisma.client.scene.findUniqueOrThrow({ where: { id: 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" },
});
}
} }
@@ -1,115 +0,0 @@
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);
});
+4 -44
View File
@@ -13,11 +13,6 @@ export class TimeFlowService {
) {} ) {}
// 錨定點推進後呼叫:若已達到(或超過)已出版內容的盡頭,切入後日談區間。 // 錨定點推進後呼叫:若已達到(或超過)已出版內容的盡頭,切入後日談區間。
// T14:本方法保留給 T15(日後談續匯入)既有呼叫端(canon-reclamation.service.ts)沿用——那裡處理
// 的是「已經在日後談中的作品」續匯入正史內容,`work.inEpilogue` 早已為 true 時本方法直接 no-op 回傳,
// 不構成新的未經確認自動切換路徑。**尚未進入日後談的作品,唯一合法的切換入口是下方
// `confirmEndingDetection()`**(使用者確認結局偵測結果後才切);本方法不應再被當成「偵測到疑似結局
// 就自動切換」的路徑使用,第 4 輪「偵測到結局先問使用者再切」的決定以 confirmEndingDetection() 落實。
async checkEpilogueTransition(workId: string) { async checkEpilogueTransition(workId: string) {
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: workId } }); const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: workId } });
if (work.inEpilogue) { if (work.inEpilogue) {
@@ -29,49 +24,14 @@ export class TimeFlowService {
}); });
const maxCanonStoryOrder = canonMax._max.storyOrder ?? 0; const maxCanonStoryOrder = canonMax._max.storyOrder ?? 0;
if (maxCanonStoryOrder > 0 && work.anchorStoryOrder >= maxCanonStoryOrder) { if (maxCanonStoryOrder > 0 && work.anchorStoryOrder >= maxCanonStoryOrder) {
return this.switchToEpilogue(work.id, work.anchorStoryOrder); return this.prisma.client.work.update({
where: { id: work.id },
data: { inEpilogue: true, epilogueStartStoryOrder: work.anchorStoryOrder },
});
} }
return work; 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) { async setTimeFlow(workId: string, mode: TimeFlowMode) {
if (mode === "FAST") { if (mode === "FAST") {
const characters = await this.prisma.client.character.findMany({ where: { workId } }); const characters = await this.prisma.client.character.findMany({ where: { workId } });
@@ -62,27 +62,6 @@ export class BehaviorReinforcementService {
} }
} }
// T25:章節匯入時從場景(ACTION 描寫)抽出的行為模式——首見新增規則、已有相同 (situation,
// responsePattern) 則強化既有權重,一律記錄最近一次觸發的來源場景(供追溯與重投影清除,
// 已知簡化見 schema.prisma 的 sourceSceneId 欄位註解:只記錄最近一次,不是完整貢獻清單)。
async recordFromScene(characterId: string, situation: string, responsePattern: string, sceneId: string): Promise<void> {
const existing = await this.prisma.client.proceduralRule.findFirst({
where: { characterId, situation, responsePattern },
});
if (existing) {
await this.prisma.client.proceduralRule.update({
where: { id: existing.id },
data: { weight: existing.weight + REINFORCE_INCREMENT, sourceSceneId: sceneId },
});
return;
}
await this.prisma.client.proceduralRule.create({
data: { characterId, situation, responsePattern, weight: 1, sourceSceneId: sceneId },
});
}
async getTopRule(characterId: string, situation: string) { async getTopRule(characterId: string, situation: string) {
return this.prisma.client.proceduralRule.findFirst({ return this.prisma.client.proceduralRule.findFirst({
where: { characterId, situation }, where: { characterId, situation },
+2 -45
View File
@@ -1,5 +1,5 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import type { CharacterCallCandidate, EndingDetectionResult, LLMProvider } from "./llm-provider.js"; import type { LLMProvider } from "./llm-provider.js";
import type { GenerationContext, GeneratedResponse } from "./types.js"; import type { GenerationContext, GeneratedResponse } from "./types.js";
import { buildMessages } from "./prompt-builder.js"; import { buildMessages } from "./prompt-builder.js";
import { extractActions } from "./action-markup.js"; import { extractActions } from "./action-markup.js";
@@ -8,13 +8,8 @@ interface OpenAiCompatibleChoice {
message?: { content?: string }; message?: { content?: string };
delta?: { content?: string }; delta?: { content?: string };
} }
interface OpenAiCompatibleUsage {
prompt_tokens?: number;
completion_tokens?: number;
}
interface OpenAiCompatibleResponse { interface OpenAiCompatibleResponse {
choices?: OpenAiCompatibleChoice[]; choices?: OpenAiCompatibleChoice[];
usage?: OpenAiCompatibleUsage;
} }
function baseUrl(): string { function baseUrl(): string {
@@ -66,12 +61,7 @@ export class ClaudeProvider implements LLMProvider {
if (!text) { if (!text) {
throw new Error("ClaudeProvider 回應內容為空(choices[0].message.content 缺失)"); throw new Error("ClaudeProvider 回應內容為空(choices[0].message.content 缺失)");
} }
// T9:真實 API 回應的 usage 未提供時(部分相容端點不回傳)以 0 記錄,不因此中斷生成。 return { text, actions: extractActions(text) };
return {
text,
actions: extractActions(text),
usage: { model: model(), promptTokens: json.usage?.prompt_tokens ?? 0, completionTokens: json.usage?.completion_tokens ?? 0 },
};
} }
async *stream(context: GenerationContext): AsyncIterable<string> { async *stream(context: GenerationContext): AsyncIterable<string> {
@@ -108,37 +98,4 @@ export class ClaudeProvider implements LLMProvider {
} }
} }
} }
// T5:LLM 兼底判斷指稱哪個角色;提示詞要求只能回傳候選 id 或 NONE,避免自行編造不存在的角色。
async identifyCharacter(utterance: string, candidates: CharacterCallCandidate[]): Promise<string | null> {
const candidateList = candidates.map((c) => `- ${c.id}:${c.label}(${c.keywords})`).join("\n");
const prompt = `使用者說了一句話,請判斷這句話是在呼喚下面候選名單中的哪一位。只能回傳候選名單裡的 id,不可以自行編造不存在的 id;如果判斷不出來或這句話沒有在呼喚任何人,就回傳 NONE。只回傳 id 或 NONE,不要有其他文字。\n\n候選名單:\n${candidateList}\n\n使用者的話:「${utterance}」`;
const res = await requestChatCompletion({
model: model(),
messages: [{ role: "user", content: prompt }],
stream: false,
});
const json = (await res.json()) as OpenAiCompatibleResponse;
const answer = json.choices?.[0]?.message?.content?.trim() ?? "";
return candidates.some((c) => c.id === answer) ? answer : null;
}
// T13:每章投影完成後都呼叫,判斷這個場景是否為整部作品的最終結局;提示詞刻意不帶章節標題,
// 只給場景摘要與原文本身,避免標題關鍵字(例如「最終回」)預先左右判斷。
async detectEnding(sceneSummary: string, sceneRawText: string, workTitle: string): Promise<EndingDetectionResult> {
const prompt = `你是故事編輯,請只依下面的場景內容本身判斷:這段場景是否為作品「${workTitle}」整部作品的最終結局(整部作品在此完全收尾,不是單一篇章、單元劇或某個角色支線的結尾)。不要用章節標題或任何標題訊號預先假設,只看場景實際描寫的內容。\n\n場景摘要:${sceneSummary}\n\n場景原文:${sceneRawText}\n\n請用以下格式回答,不要有其他文字:\nANSWER: YES 或 NO\nEVIDENCE: 一到兩句話說明你的判斷依據`;
const res = await requestChatCompletion({
model: model(),
messages: [{ role: "user", content: prompt }],
stream: false,
});
const json = (await res.json()) as OpenAiCompatibleResponse;
const text = json.choices?.[0]?.message?.content?.trim() ?? "";
const answerMatch = text.match(/ANSWER:\s*(YES|NO)/i);
const evidenceMatch = text.match(/EVIDENCE:\s*([\s\S]+)/i);
return {
isEnding: (answerMatch?.[1] ?? "").toUpperCase() === "YES",
evidence: evidenceMatch?.[1]?.trim() || text || "(LLM 未回傳可解析的判斷依據)",
};
}
} }
+1 -35
View File
@@ -1,9 +1,6 @@
import { BadRequestException, Body, Controller, Get, Inject, Param, Post, Query } from "@nestjs/common"; import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import { DialogueService } from "./dialogue.service.js"; import { DialogueService } from "./dialogue.service.js";
import { BehaviorReinforcementService } from "./behavior-reinforcement.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 { interface SendMessageBody {
userId: string; userId: string;
@@ -28,39 +25,8 @@ export class DialogueController {
constructor( constructor(
private readonly dialogue: DialogueService, private readonly dialogue: DialogueService,
private readonly reinforcement: BehaviorReinforcementService, 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") @Post(":characterId/sessions/:sessionId/messages")
async sendMessage( async sendMessage(
@Param("characterId") characterId: string, @Param("characterId") characterId: string,
+7 -23
View File
@@ -9,14 +9,12 @@ import { ScheduleEmotionBaselineService } from "../schedule/schedule-emotion-bas
import { OfflineEventService } from "../schedule/offline-event.service.js"; import { OfflineEventService } from "../schedule/offline-event.service.js";
import { availabilityForStatus, type AvailabilityMode, type ExceptionType } from "../schedule/constants.js"; import { availabilityForStatus, type AvailabilityMode, type ExceptionType } from "../schedule/constants.js";
import { LLM_PROVIDER, type LLMProvider } from "./llm-provider.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 { ContextAssemblerService } from "./context-assembler.service.js";
import { OutputFilterService } from "./output-filter.service.js"; import { OutputFilterService } from "./output-filter.service.js";
import { FastChannelDetector, DEFAULT_FAST_REPLIES } from "./fast-channel.service.js"; import { FastChannelDetector, DEFAULT_FAST_REPLIES } from "./fast-channel.service.js";
import { extractActions } from "./action-markup.js"; import { extractActions } from "./action-markup.js";
import { getForbiddenWords, applySpeechQuirks } from "../personality/language-style.js"; import { getForbiddenWords, applySpeechQuirks } from "../personality/language-style.js";
import type { GenerationContext, GenerationUsage } from "./types.js"; import type { GenerationContext } from "./types.js";
export interface HandleMessageOptions { export interface HandleMessageOptions {
seed?: number; seed?: number;
@@ -30,7 +28,6 @@ export interface HandleMessageResult {
availability: AvailabilityMode; availability: AvailabilityMode;
exceptionType: ExceptionType | null; exceptionType: ExceptionType | null;
context: GenerationContext | null; context: GenerationContext | null;
usage?: GenerationUsage; // T9:僅真的呼叫 Provider 生成時才有值(雙速通道/作息延遲不消耗 LLM)
} }
function fastChannelSituation(reason: string): string { function fastChannelSituation(reason: string): string {
@@ -56,7 +53,6 @@ export class DialogueService {
private readonly scheduleBaseline: ScheduleEmotionBaselineService, private readonly scheduleBaseline: ScheduleEmotionBaselineService,
private readonly offlineEvent: OfflineEventService, private readonly offlineEvent: OfflineEventService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly globalPause: GlobalPauseService,
) {} ) {}
async handleMessage( async handleMessage(
@@ -115,7 +111,6 @@ export class DialogueService {
let text: string; let text: string;
let actions: string[]; let actions: string[];
let usage: GenerationUsage | undefined;
if (effectiveAvailability === "DELAYED" && !exceptionType) { if (effectiveAvailability === "DELAYED" && !exceptionType) {
text = DELAYED_ACKNOWLEDGEMENT; text = DELAYED_ACKNOWLEDGEMENT;
@@ -128,27 +123,16 @@ export class DialogueService {
text = rule?.responsePattern ?? DEFAULT_FAST_REPLIES[reason]; text = rule?.responsePattern ?? DEFAULT_FAST_REPLIES[reason];
actions = extractActions(text); actions = extractActions(text);
} else { } else {
// T30:全域靜默暫停或重試全部失敗時回傳 null——比照既有 NO_REPLY 的「這輪沒有真的回覆」語意, const generated = await this.llmProvider.generate(context);
// 回傳空字串,呼叫端(SoloChatService.sendMessage)已經是「text 有內容才寫入 RoomTurn」, text = generated.text;
// 不需要另外判斷;使用者本人的發話在呼叫端已於此之前寫入,不受影響。 actions = generated.actions;
const generated = await generateWithResilience(this.llmProvider, context, this.globalPause);
text = generated?.text ?? "";
actions = generated?.actions ?? [];
usage = generated?.usage;
} }
// I-2 剛醒/睡前:以拖長語尾的簡單標記呈現迷糊演出(完整口吻交由模板庫日後擴充);暫停中沒有 // I-2 剛醒/睡前:以拖長語尾的簡單標記呈現迷糊演出(完整口吻交由模板庫日後擴充)。
// 真的回覆(text 為空)時不套用,避免把「……」誤當成一則真的有內容的回覆。 if (scheduleInfo.isWaking || scheduleInfo.isPreSleep) {
if (text && (scheduleInfo.isWaking || scheduleInfo.isPreSleep)) {
text = `……${text}`; text = `……${text}`;
} }
// T30:暫停中沒有真的回覆(text 為空)時,比照既有 NO_REPLY 語意略過輸出過濾與工作記憶寫入,
// 不留下一則空白的角色訊息。
if (!text) {
return { text: "", actions, isFastChannel, availability: effectiveAvailability, exceptionType, context, usage };
}
const forbiddenWords = getForbiddenWords(context.character.personalityArchetype); const forbiddenWords = getForbiddenWords(context.character.personalityArchetype);
const filteredText = applySpeechQuirks( const filteredText = applySpeechQuirks(
this.outputFilter.filter(text, forbiddenWords), this.outputFilter.filter(text, forbiddenWords),
@@ -156,6 +140,6 @@ export class DialogueService {
); );
await this.workingMemory.append(sessionId, { role: "character", content: filteredText, timestamp: now }); await this.workingMemory.append(sessionId, { role: "character", content: filteredText, timestamp: now });
return { text: filteredText, actions, isFastChannel, availability: effectiveAvailability, exceptionType, context, usage }; return { text: filteredText, actions, isFastChannel, availability: effectiveAvailability, exceptionType, context };
} }
} }
@@ -1,70 +0,0 @@
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);
});
@@ -1,47 +0,0 @@
import type { LLMProvider } from "./llm-provider.js";
import type { GenerationContext, GeneratedResponse } from "./types.js";
import type { GlobalPauseService } from "./global-pause.service.js";
import { RETRY_MAX_ATTEMPTS, RETRY_BASE_BACKOFF_MS } from "./resilience-constants.js";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// T30:自走生成的唯一呼叫入口——指數退避重試+全域靜默暫停旗標的判斷與更新都收斂在這裡,
// RoomGenerationService/DialogueService 兩個呼叫端都改叫這個函式,不直接呼叫 provider.generate()。
// 回傳 null 表示這一輪沒有回覆(暫停中,或重試全部失敗),呼叫端應靜默略過(不寫 RoomTurn/不顯示錯誤),
// 使用者本人的發話仍照常已由呼叫端在此之前寫入,不受影響。
export async function generateWithResilience(
provider: LLMProvider,
context: GenerationContext,
pause: GlobalPauseService,
): Promise<GeneratedResponse | null> {
if (pause.isPaused()) {
// 暫停期間仍每次嘗試「一次」真的呼叫(不額外重試),用來偵測服務是否已恢復;
// 一旦成功就立刻解除暫停,不需要額外的健康檢查機制。
try {
const result = await provider.generate(context);
pause.recordSuccess();
return result;
} catch (err) {
pause.recordFailure(err);
return null;
}
}
let lastError: unknown;
for (let attempt = 0; attempt <= RETRY_MAX_ATTEMPTS; attempt++) {
try {
const result = await provider.generate(context);
pause.recordSuccess();
return result;
} catch (err) {
lastError = err;
if (attempt < RETRY_MAX_ATTEMPTS) {
await sleep(RETRY_BASE_BACKOFF_MS * 2 ** attempt);
}
}
}
pause.recordFailure(lastError);
return null;
}
-38
View File
@@ -1,38 +0,0 @@
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} 次,觸發全域靜默暫停:所有房間與大廳自走生成停止,不對使用者顯示任何提示`,
);
}
}
}
-15
View File
@@ -1,15 +0,0 @@
// 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);
}
-17
View File
@@ -1,26 +1,9 @@
import type { GenerationContext, GeneratedResponse } from "./types.js"; 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 不動引擎任何一行。 // F-1 LLMProvider 介面:切換 Provider 不動引擎任何一行。
export interface LLMProvider { export interface LLMProvider {
generate(context: GenerationContext): Promise<GeneratedResponse>; generate(context: GenerationContext): Promise<GeneratedResponse>;
stream(context: GenerationContext): AsyncIterable<string>; stream(context: GenerationContext): AsyncIterable<string>;
// T5:大廳對話式呼叫的規則比對失敗或指稱含糊時的 LLM 兼底——從候選清單判斷指稱的是哪個角色,
// 必須回傳候選清單中的某個 id 或 null(無法判斷/沒有人被指稱),不可自行編造不存在的角色。
identifyCharacter(utterance: string, candidates: CharacterCallCandidate[]): Promise<string | null>;
// T13:每章投影完成後都呼叫,判斷這個場景是否為整部作品的最終結局;只依場景內容本身判斷,
// 不以章節標題關鍵字預篩(呼叫端不會把標題資訊帶進來影響判斷)。
detectEnding(sceneSummary: string, sceneRawText: string, workTitle: string): Promise<EndingDetectionResult>;
} }
export const LLM_PROVIDER = Symbol("LLM_PROVIDER"); export const LLM_PROVIDER = Symbol("LLM_PROVIDER");
+1 -3
View File
@@ -14,7 +14,6 @@ import { BehaviorReinforcementService } from "./behavior-reinforcement.service.j
import { ContextAssemblerService } from "./context-assembler.service.js"; import { ContextAssemblerService } from "./context-assembler.service.js";
import { DialogueService } from "./dialogue.service.js"; import { DialogueService } from "./dialogue.service.js";
import { DialogueController } from "./dialogue.controller.js"; import { DialogueController } from "./dialogue.controller.js";
import { GlobalPauseService } from "./global-pause.service.js";
// F-2 Provider 切換:LLM_PROVIDER=mock|claude 決定注入哪個實作,切換不動引擎任何一行。 // F-2 Provider 切換:LLM_PROVIDER=mock|claude 決定注入哪個實作,切換不動引擎任何一行。
// R-3:ClaudeProvider 已接上真實 API,這裡不再需要特別的「尚未實作」警告。 // R-3:ClaudeProvider 已接上真實 API,這裡不再需要特別的「尚未實作」警告。
@@ -39,8 +38,7 @@ function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
BehaviorReinforcementService, BehaviorReinforcementService,
ContextAssemblerService, ContextAssemblerService,
DialogueService, DialogueService,
GlobalPauseService,
], ],
exports: [DialogueService, BehaviorReinforcementService, LLM_PROVIDER, GlobalPauseService], exports: [DialogueService, BehaviorReinforcementService, LLM_PROVIDER],
}) })
export class LlmModule {} export class LlmModule {}
@@ -1,75 +0,0 @@
import { describe, expect, it } from "vitest";
import { MockProvider } from "./mock-provider.service.js";
import type { GenerationContext } from "./types.js";
function fixtureContext(overrides: Partial<GenerationContext> = {}): GenerationContext {
return {
character: {
id: "char-1",
personalityArchetype: "TSUNDERE",
speechStyle: "口是心非",
likesDislikes: "喜歡貓,不喜歡被稱讚",
basicInfo: "測試用角色",
},
emotion: { dominant: "CALM", style: { tone: "平穩自然", sentenceLength: "medium", initiative: "medium" } },
relationship: { intimacy: 10, trust: 10, stage: "STRANGER" },
retrievedMemories: [],
history: [],
userInput: "早安",
injectedMemoryIds: [],
seed: 42,
...overrides,
};
}
describe("MockProvider", () => {
it("同 seed 產生相同輸出,全程不呼叫網路(LLM_PROVIDER=mock 的離線保證)", async () => {
const provider = new MockProvider();
const context = fixtureContext();
const first = await provider.generate(context);
const second = await provider.generate(context);
expect(first.text).toBe(second.text);
});
it("enqueueResponse 可指定接下來的回傳內容,用完才回退模板庫", async () => {
const provider = new MockProvider();
provider.enqueueResponse("測試專用固定回覆");
const queued = await provider.generate(fixtureContext());
expect(queued.text).toBe("測試專用固定回覆");
const fallback = await provider.generate(fixtureContext());
expect(fallback.text).not.toBe("測試專用固定回覆");
});
it("enqueueFailure 可指定連續失敗次數,用於驗證重試與全域暫停路徑", async () => {
const provider = new MockProvider();
provider.enqueueFailure(new Error("模擬 LLM 失敗 1"));
provider.enqueueFailure(new Error("模擬 LLM 失敗 2"));
await expect(provider.generate(fixtureContext())).rejects.toThrow("模擬 LLM 失敗 1");
await expect(provider.generate(fixtureContext())).rejects.toThrow("模擬 LLM 失敗 2");
const recovered = await provider.generate(fixtureContext());
expect(recovered.text).toBeTruthy();
});
it("resetQueue 可清空佇列,恢復模板庫行為", async () => {
const provider = new MockProvider();
provider.enqueueResponse("不該被用到");
provider.resetQueue();
const result = await provider.generate(fixtureContext());
expect(result.text).not.toBe("不該被用到");
});
it("T9:generate() 附上非零的 usage 估算值,且隨輸入內容成長而增加", async () => {
const provider = new MockProvider();
const short = await provider.generate(fixtureContext({ userInput: "早" }));
const long = await provider.generate(
fixtureContext({ userInput: "早".repeat(200), history: [{ role: "user", content: "嗨".repeat(200), timestamp: "" }] }),
);
expect(short.usage?.model).toBe("mock");
expect(short.usage?.promptTokens).toBeGreaterThan(0);
expect(short.usage?.completionTokens).toBeGreaterThan(0);
expect(long.usage!.promptTokens).toBeGreaterThan(short.usage!.promptTokens);
});
});
+3 -70
View File
@@ -1,57 +1,14 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import type { CharacterCallCandidate, EndingDetectionResult, LLMProvider } from "./llm-provider.js"; import type { LLMProvider } from "./llm-provider.js";
import type { GenerationContext, GeneratedResponse, GenerationUsage } from "./types.js"; import type { GenerationContext, GeneratedResponse } from "./types.js";
import { extractActions } from "./action-markup.js"; import { extractActions } from "./action-markup.js";
import { createSeededRandom } from "./seeded-random.js"; import { createSeededRandom } from "./seeded-random.js";
import { intimacyTier, pickTemplates } from "./template-library.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 產生可重現輸出。 // F-7 MockProvider:依「性格原型 × 情緒狀態 × 親密度」從模板庫選填回應,同 seed 產生可重現輸出。
// T36:新增測試專用佇列——enqueueResponse/enqueueFailure 可指定接下來 N 次 generate() 的
// 回傳內容或連續失敗,先進先出,用完才回退到原有模板庫邏輯;正式環境(佇列恆空)行為不變。
@Injectable() @Injectable()
export class MockProvider implements LLMProvider { export class MockProvider implements LLMProvider {
private readonly queue: QueuedResult[] = [];
enqueueResponse(text: string, actions?: string[]): void {
this.queue.push({ kind: "success", text, actions });
}
enqueueFailure(error: Error): void {
this.queue.push({ kind: "failure", error });
}
resetQueue(): void {
this.queue.length = 0;
}
async generate(context: GenerationContext): Promise<GeneratedResponse> { async generate(context: GenerationContext): Promise<GeneratedResponse> {
const queued = this.queue.shift();
if (queued) {
if (queued.kind === "failure") throw queued.error;
const text = queued.text;
return { text, actions: queued.actions ?? extractActions(text), usage: estimateUsage(context, text) };
}
const tier = intimacyTier(context.relationship.intimacy); const tier = intimacyTier(context.relationship.intimacy);
const candidates = pickTemplates(context.character.personalityArchetype, context.emotion.dominant, tier); const candidates = pickTemplates(context.character.personalityArchetype, context.emotion.dominant, tier);
@@ -59,7 +16,7 @@ export class MockProvider implements LLMProvider {
const index = Math.floor(random() * candidates.length); const index = Math.floor(random() * candidates.length);
const text = candidates[index]; const text = candidates[index];
return { text, actions: extractActions(text), usage: estimateUsage(context, text) }; return { text, actions: extractActions(text) };
} }
async *stream(context: GenerationContext): AsyncIterable<string> { async *stream(context: GenerationContext): AsyncIterable<string> {
@@ -68,28 +25,4 @@ export class MockProvider implements LLMProvider {
yield char; yield char;
} }
} }
// T5:無真實語言理解能力,以關鍵字重疊度模擬「LLM 兼底」——沿用既有 K-4 的重疊度公式,
// 分數最高且達門檻者視為指稱對象;沒有任何候選達門檻則回傳 null(無法判斷,不可編造)。
async identifyCharacter(utterance: string, candidates: CharacterCallCandidate[]): Promise<string | null> {
let best: { id: string; score: number } | null = null;
for (const candidate of candidates) {
const score = keywordOverlap(utterance, candidate.keywords);
if (score > 0 && (!best || score > best.score)) {
best = { id: candidate.id, score };
}
}
return best && best.score >= IDENTIFY_MATCH_THRESHOLD ? best.id : null;
}
// T13:無真實語言理解能力,以場景內容是否出現明確的結局訊號詞模擬判定(例如「完結」「全劇終」);
// 不使用呼叫端可能帶入的章節標題(本方法本身就沒有標題參數,天然滿足「不以標題關鍵字預篩」)。
async detectEnding(sceneSummary: string, sceneRawText: string, _workTitle: string): Promise<EndingDetectionResult> {
const combined = `${sceneSummary}\n${sceneRawText}`;
const hit = ENDING_SIGNAL_PHRASES.find((phrase) => combined.includes(phrase));
if (hit) {
return { isEnding: true, evidence: `場景內容出現「${hit}」等結局訊號詞(mock 判定,非真實 LLM 推論)` };
}
return { isEnding: false, evidence: "場景內容未見明顯結局訊號詞(mock 判定,非真實 LLM 推論)" };
}
} }
+1 -2
View File
@@ -15,7 +15,7 @@ const RELATIONSHIP_STAGE_LABEL: Record<string, string> = {
}; };
function buildSystemPrompt(context: GenerationContext): string { function buildSystemPrompt(context: GenerationContext): string {
const { character, emotion, relationship, retrievedMemories, crossWorldNote } = context; const { character, emotion, relationship, retrievedMemories } = context;
const stageLabel = RELATIONSHIP_STAGE_LABEL[relationship.stage] ?? relationship.stage; const stageLabel = RELATIONSHIP_STAGE_LABEL[relationship.stage] ?? relationship.stage;
const memoryLines = const memoryLines =
@@ -44,7 +44,6 @@ function buildSystemPrompt(context: GenerationContext): string {
`【相關記憶】`, `【相關記憶】`,
memoryLines, memoryLines,
``, ``,
...(crossWorldNote ? [`【跨作品提示】`, crossWorldNote, ``] : []),
`【輸出格式】`, `【輸出格式】`,
`回應為純文字。若有動作/表情描寫,用全形星號包住,例如 *臉紅撇過頭*;不要用任何其他標記語法,不要加角色名稱前綴。`, `回應為純文字。若有動作/表情描寫,用全形星號包住,例如 *臉紅撇過頭*;不要用任何其他標記語法,不要加角色名稱前綴。`,
``, ``,
-5
View File
@@ -1,5 +0,0 @@
// T30:連續失敗達此次數即觸發全域靜默暫停。
export const GLOBAL_PAUSE_FAILURE_THRESHOLD = 5;
// T30:指數退避重試——單次生成最多重試這麼多次(不含首次嘗試),每次間隔倍增。
export const RETRY_MAX_ATTEMPTS = 3;
export const RETRY_BASE_BACKOFF_MS = 200;
-10
View File
@@ -15,9 +15,6 @@ export interface GenerationContext {
likesDislikes: string; likesDislikes: string;
basicInfo: string; basicInfo: string;
}; };
// T28:跨作品混房時,對「這次要回應的對象」附上一句世界觀提示——這個人來自不同作品,不是自己
// 原本世界就認識的存在,禁止把對方設定當成自身世界既有知識。同作品/對象是使用者本人時不設定。
crossWorldNote?: string;
emotion: { emotion: {
dominant: EmotionTag; dominant: EmotionTag;
style: ResponseStyle; style: ResponseStyle;
@@ -36,14 +33,7 @@ export interface GenerationContext {
seed: number; seed: number;
} }
export interface GenerationUsage {
model: string;
promptTokens: number;
completionTokens: number;
}
export interface GeneratedResponse { export interface GeneratedResponse {
text: string; // 含動作描寫標記,如 *臉紅撇過頭* text: string; // 含動作描寫標記,如 *臉紅撇過頭*
actions: string[]; // 從 text 抽取出的動作描寫(不含*符號) actions: string[]; // 從 text 抽取出的動作描寫(不含*符號)
usage?: GenerationUsage; // T9:每房 LLM 用量統計的來源,僅供觀察,不影響生成行為
} }
-221
View File
@@ -1,221 +0,0 @@
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);
});
-317
View File
@@ -1,317 +0,0 @@
import { Inject, Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { CLOCK, type Clock } from "../clock/clock.js";
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
import { RelationshipService } from "../relationship/relationship.service.js";
import { ScheduleService } from "../schedule/schedule.service.js";
import { ScheduleExceptionService } from "../schedule/schedule-exception.service.js";
import { availabilityForStatus } from "../schedule/constants.js";
import { getArchetypeParams } from "../personality/archetype-params.js";
import { LLM_PROVIDER, type LLMProvider } from "../llm/llm-provider.js";
import { hashToSeed } from "../llm/seeded-random.js";
import { EMOTION_SPEAKING_MODIFIER } from "../room/constants.js";
import { RoomService } from "../room/room.service.js";
import { LobbyService as RoomLobbyService } from "../room/lobby.service.js";
import { PossessionService } from "../room/possession.service.js";
import { CALL_ACCEPTANCE_THRESHOLD } from "./constants.js";
export interface CallJudgment {
characterId: string;
accepted: boolean;
reason: string;
}
export interface CallResult {
calledCharacterIds: string[];
accepted: string[];
declined: string[];
roomId: string | null;
mode: "SOLO" | "GROUP" | null;
message: string | null; // 全部不答應時:本人婉拒台詞,或由代答者說出的理由與可再來的時間
delegatedBy: string | null; // T23:代答者(若有)
pendingPossessedCharacterIds: string[]; // T43:被喊到但目前套用中,改記待決、交由套用者決定
}
const HARD_DECLINE_PREFIXES = ["無法回應(作息)", "已在其他房間"];
// T5:大廳對話式呼叫。角色辨識規則先行(角色名/CharacterAlias 直接比對),比不到才以 LLM 兼底
// (見 MockProvider/ClaudeProvider.identifyCharacter);逐位判定是否答應後,依組房規則決定結果。
@Injectable()
export class CallService {
constructor(
private readonly prisma: PrismaService,
@Inject(CLOCK) private readonly clock: Clock,
private readonly emotion: EmotionService,
private readonly relationship: RelationshipService,
private readonly schedule: ScheduleService,
private readonly scheduleException: ScheduleExceptionService,
@Inject(LLM_PROVIDER) private readonly llmProvider: LLMProvider,
private readonly room: RoomService,
private readonly roomLobby: RoomLobbyService,
private readonly possession: PossessionService,
) {}
async say(userId: string, text: string, now?: Date): Promise<CallResult> {
const effectiveNow = now ?? this.clock.now();
const calledCharacterIds = await this.identifyCandidates(text);
if (calledCharacterIds.length === 0) {
return {
calledCharacterIds: [],
accepted: [],
declined: [],
roomId: null,
mode: null,
message: null,
delegatedBy: null,
pendingPossessedCharacterIds: [],
};
}
// T43:被喊到的角色若正被(任一使用者)套用中,人格系統不得代她判斷接受/婉拒——記一筆待決給
// 套用者自己決定,不參與這一輪的組房/代答邏輯。
const possessedIds = await this.possession.getPossessedCharacterIds();
const possessedCalledIds = calledCharacterIds.filter((id) => possessedIds.has(id));
for (const characterId of possessedCalledIds) {
await this.possession.createPendingCall(characterId, text, effectiveNow);
}
const judgeableCharacterIds = calledCharacterIds.filter((id) => !possessedIds.has(id));
if (judgeableCharacterIds.length === 0) {
return {
calledCharacterIds,
accepted: [],
declined: [],
roomId: null,
mode: null,
message: null,
delegatedBy: null,
pendingPossessedCharacterIds: possessedCalledIds,
};
}
const judgments = await Promise.all(
judgeableCharacterIds.map((characterId) => this.judgeAcceptance(characterId, userId, text, effectiveNow)),
);
const accepted = judgments.filter((j) => j.accepted).map((j) => j.characterId);
const declined = judgments.filter((j) => !j.accepted).map((j) => j.characterId);
let roomId: string | null = null;
let mode: "SOLO" | "GROUP" | null = null;
let message: string | null = null;
let delegatedBy: string | null = null;
if (accepted.length >= 2) {
const room = await this.openRoom(userId, accepted, "GROUP", text, effectiveNow);
roomId = room.id;
mode = "GROUP";
} else if (accepted.length === 1) {
const room = await this.openRoom(userId, accepted, "SOLO", text, effectiveNow);
roomId = room.id;
mode = "SOLO";
} else {
// 全部不答應:軟性婉拒(人清醒、不在其他房間,單純不想理你)由本人親自回一句話;
// 硬性無法回應(睡眠/忙碌/已在其他房間)改由關係最近且當下清醒未入房的角色代答(T23)。
const primaryId = declined[0];
const primaryJudgment = judgments.find((j) => j.characterId === primaryId)!;
const isHardDecline = HARD_DECLINE_PREFIXES.some((prefix) => primaryJudgment.reason.startsWith(prefix));
if (!isHardDecline) {
message = await this.declineMessage(primaryId, userId, text, effectiveNow);
} else {
const delegateId = await this.findDelegate(primaryId, effectiveNow);
if (delegateId) {
delegatedBy = delegateId;
const nextAvailable = await this.describeNextAvailable(primaryId, primaryJudgment.reason, effectiveNow);
message = await this.delegateMessage(delegateId, primaryId, userId, text, nextAvailable, effectiveNow);
}
// 找不到合適代答者:大廳沉默,message 維持 null,不輸出系統訊息。
}
}
await this.prisma.client.callAttemptLog.createMany({
data: judgments.map((j) => ({
userId,
characterId: j.characterId,
accepted: j.accepted,
reason: j.reason,
delegatedByCharacterId: j.characterId === declined[0] ? delegatedBy : null,
resultingRoomId: j.accepted ? roomId : null,
createdAt: effectiveNow,
})),
});
return { calledCharacterIds, accepted, declined, roomId, mode, message, delegatedBy, pendingPossessedCharacterIds: possessedCalledIds };
}
// 角色辨識:規則先行(角色名與 CharacterAlias 直接比對),比不到或指稱含糊才以 LLM 兼底。
private async identifyCandidates(text: string): Promise<string[]> {
const characters = await this.prisma.client.character.findMany({
where: { buildStatus: "BUILT" },
include: { aliases: true },
});
const ruleMatches = characters.filter((character) =>
character.aliases.some(
(alias) =>
(alias.formalName && text.includes(alias.formalName)) ||
(alias.nickname && text.includes(alias.nickname)) ||
(alias.calledByOthers && text.includes(alias.calledByOthers)),
),
);
if (ruleMatches.length > 0) {
return ruleMatches.map((c) => c.id);
}
const candidateList = characters.map((character) => ({
id: character.id,
label: character.aliases[0]?.formalName ?? character.id,
keywords: [character.personalityArchetype, character.likesDislikes, character.goalsObsessions].join("、"),
}));
const identified = await this.llmProvider.identifyCharacter(text, candidateList);
return identified ? [identified] : [];
}
private async judgeAcceptance(characterId: string, userId: string, text: string, now: Date): Promise<CallJudgment> {
// R5:已在其他(非大廳)房間在場者無法接受新的呼叫。
const activeMembership = await this.prisma.client.roomMember.findFirst({
where: { characterId, activeCharacterId: characterId },
include: { room: true },
});
if (activeMembership && activeMembership.room.mode !== "LOBBY") {
return { characterId, accepted: false, reason: "已在其他房間" };
}
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
const scheduleInfo = await this.schedule.getInfo(characterId, now);
const availability = availabilityForStatus(scheduleInfo.slot?.status ?? null);
const emotionState = await this.emotion.getState(characterId, now);
const dominant = dominantState(emotionState);
const { relationship } = await this.relationship.getState(characterId, userId, now);
let effectiveAvailability = availability;
if (availability !== "NORMAL") {
const exceptionType = await this.scheduleException.tryApply(
characterId,
userId,
availability,
{ intimacy: relationship.intimacy, dominantEmotion: dominant, userInput: text },
now,
);
if (exceptionType) effectiveAvailability = "NORMAL";
}
if (effectiveAvailability === "NO_REPLY") {
return { characterId, accepted: false, reason: "無法回應(作息)" };
}
const archetypeParams = getArchetypeParams(character.personalityArchetype);
const emotionModifier = EMOTION_SPEAKING_MODIFIER[dominant];
const relationshipModifier = (relationship.intimacy - 50) / 100;
const score = archetypeParams.conversationBaseline + emotionModifier + relationshipModifier;
const accepted = score >= CALL_ACCEPTANCE_THRESHOLD;
return { characterId, accepted, reason: accepted ? `答應(score=${score.toFixed(2)})` : `衝動值不足(score=${score.toFixed(2)})` };
}
private async openRoom(userId: string, characterIds: string[], mode: "SOLO" | "GROUP", topicSeed: string, now: Date) {
const room = await this.room.create(userId, [], mode, topicSeed);
for (const characterId of characterIds) {
await this.roomLobby.enterRoom(characterId, room.id, now);
}
return room;
}
private async declineMessage(characterId: string, userId: string, userInput: string, now: Date): Promise<string> {
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
const emotionState = await this.emotion.getState(characterId, now);
const dominant = dominantState(emotionState);
const { relationship } = await this.relationship.getState(characterId, userId, now);
const seed = hashToSeed(`lobby-decline:${characterId}:${userInput}`);
const generated = await this.llmProvider.generate({
character: {
id: character.id,
personalityArchetype: character.personalityArchetype,
speechStyle: character.speechStyle,
likesDislikes: character.likesDislikes,
basicInfo: character.basicInfo,
},
emotion: { dominant, style: { tone: "", sentenceLength: "short", initiative: "low" } },
relationship: { intimacy: relationship.intimacy, trust: relationship.trust, stage: relationship.stage },
retrievedMemories: [],
history: [],
userInput,
injectedMemoryIds: [],
seed,
});
return generated.text;
}
// T23:找與被喊角色關係最近、當下清醒(非睡眠)且未入其他房間(含大廳都算「有空」)的代答者。
private async findDelegate(characterId: string, now: Date): Promise<string | null> {
const relationships = await this.prisma.client.characterRelationship.findMany({
where: { characterId },
orderBy: { affinity: "desc" },
});
for (const rel of relationships) {
const candidateId = rel.otherCharacterId;
const scheduleInfo = await this.schedule.getInfo(candidateId, now);
if (availabilityForStatus(scheduleInfo.slot?.status ?? null) === "NO_REPLY") continue; // 睡著跳過
const activeMembership = await this.prisma.client.roomMember.findFirst({
where: { characterId: candidateId, activeCharacterId: candidateId },
include: { room: true },
});
if (activeMembership && activeMembership.room.mode !== "LOBBY") continue; // 已入其他房間跳過
return candidateId;
}
return null;
}
// T23:「下次可找的時間」一律取自行程資料本身,不可由 LLM 自行編時間。
private async describeNextAvailable(characterId: string, declineReason: string, now: Date): Promise<string> {
if (declineReason.startsWith("已在其他房間")) {
return "等她忙完手邊的事";
}
const scheduleInfo = await this.schedule.getInfo(characterId, now);
if (!scheduleInfo.slot) {
return "晚一點";
}
const hh = String(Math.floor(scheduleInfo.slot.endMinute / 60) % 24).padStart(2, "0");
const mm = String(scheduleInfo.slot.endMinute % 60).padStart(2, "0");
return `${hh}:${mm}`;
}
private async delegateMessage(
delegateId: string,
aboutCharacterId: string,
userId: string,
userInput: string,
nextAvailable: string,
now: Date,
): Promise<string> {
const delegate = await this.prisma.client.character.findUniqueOrThrow({ where: { id: delegateId } });
const aboutAlias = await this.prisma.client.characterAlias.findFirst({ where: { characterId: aboutCharacterId } });
const aboutLabel = aboutAlias?.formalName ?? aboutCharacterId;
const emotionState = await this.emotion.getState(delegateId, now);
const dominant = dominantState(emotionState);
const { relationship } = await this.relationship.getState(delegateId, userId, now);
const seed = hashToSeed(`lobby-delegate:${delegateId}:${aboutCharacterId}:${userInput}`);
const generated = await this.llmProvider.generate({
character: {
id: delegate.id,
personalityArchetype: delegate.personalityArchetype,
speechStyle: delegate.speechStyle,
likesDislikes: delegate.likesDislikes,
basicInfo: delegate.basicInfo,
},
emotion: { dominant, style: { tone: "", sentenceLength: "short", initiative: "low" } },
relationship: { intimacy: relationship.intimacy, trust: relationship.trust, stage: relationship.stage },
retrievedMemories: [],
history: [],
userInput,
injectedMemoryIds: [],
seed,
});
// 「下次可找的時間」由程式碼直接拼接,不讓 LLM 生成的那段話決定時間內容。
return `${generated.text}${aboutLabel}大概${nextAvailable}才有空,到時候再來找她吧。`;
}
}
-5
View File
@@ -1,5 +0,0 @@
// 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;
@@ -1,184 +0,0 @@
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);
});
-168
View File
@@ -1,168 +0,0 @@
import { Inject, Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { CLOCK, type Clock } from "../clock/clock.js";
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
import { ScheduleService } from "../schedule/schedule.service.js";
import { availabilityForStatus } from "../schedule/constants.js";
import { getArchetypeParams } from "../personality/archetype-params.js";
import { EMOTION_SPEAKING_MODIFIER } from "../room/constants.js";
import { RoomService } from "../room/room.service.js";
import { LobbyService as RoomLobbyService } from "../room/lobby.service.js";
import { CharacterRelationshipService } from "../room/character-relationship.service.js";
import { PossessionService } from "../room/possession.service.js";
import { CALL_ACCEPTANCE_THRESHOLD } from "./constants.js";
// T29:使用者呼叫(T5)與房內角色邀請走同一套 RoomInvitation。房內角色邀請不在場角色時,
// 若該角色同時有多筆 PENDING 邀請(例如同時被兩個不同房間邀請),由人格系統依關係/情緒選出
// 一筆 ACCEPTED,其餘標 DECLINED,並在每一個邀請來源各留一則發話後才真正入房。
@Injectable()
export class InvitationService {
constructor(
private readonly prisma: PrismaService,
@Inject(CLOCK) private readonly clock: Clock,
private readonly emotion: EmotionService,
private readonly schedule: ScheduleService,
private readonly characterRelationship: CharacterRelationshipService,
private readonly room: RoomService,
private readonly roomLobby: RoomLobbyService,
private readonly possession: PossessionService,
) {}
// invitedByCharacterId 為 null 表使用者從大廳呼叫;非 null 表房內角色邀請。
// 只登記邀請意向(PENDING 或依 R5 立即 DECLINED),不在此立刻裁決——同一角色可能「同時」被
// 多處邀請(例如兩個不同房間各自邀請),必須等呼叫端明確觸發 resolvePendingFor() 才裁決,
// 否則最先送達的邀請永遠會在還沒有對手可比較時就單獨勝出,變成先到先贏而非依人格系統選擇。
async invite(roomId: string, characterId: string, invitedByCharacterId: string | null, now?: Date): Promise<void> {
const effectiveNow = now ?? this.clock.now();
// R5:已在其他(非大廳)房間在場者只能婉拒,不進入待裁決佇列。
const activeMembership = await this.prisma.client.roomMember.findFirst({
where: { characterId, activeCharacterId: characterId },
include: { room: true },
});
if (activeMembership && activeMembership.room.mode !== "LOBBY" && activeMembership.roomId !== roomId) {
await this.prisma.client.roomInvitation.create({
data: { roomId, characterId, invitedByCharacterId, status: "DECLINED", respondedAt: effectiveNow },
});
return;
}
await this.prisma.client.roomInvitation.create({
data: { roomId, characterId, invitedByCharacterId, status: "PENDING" },
});
}
// 依關係/情緒選出一筆 ACCEPTED,其餘 DECLINED;每個來源各留一則發話後才真正入房。
async resolvePendingFor(characterId: string, now?: Date): Promise<void> {
const effectiveNow = now ?? this.clock.now();
const pending = await this.prisma.client.roomInvitation.findMany({
where: { characterId, status: "PENDING" },
include: { room: true },
});
if (pending.length === 0) return;
// T43:套用中的角色不能讓人格系統代她判斷接受/婉拒——維持 PENDING,交由套用者透過
// decideAsPossessed() 明確決定。
const possessedIds = await this.possession.getPossessedCharacterIds();
if (possessedIds.has(characterId)) return;
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
const scheduleInfo = await this.schedule.getInfo(characterId, effectiveNow);
const asleep = availabilityForStatus(scheduleInfo.slot?.status ?? null) === "NO_REPLY";
const emotionState = await this.emotion.getState(characterId, effectiveNow);
const dominant = dominantState(emotionState);
const archetypeParams = getArchetypeParams(character.personalityArchetype);
const emotionModifier = EMOTION_SPEAKING_MODIFIER[dominant];
const scored = await Promise.all(
pending.map(async (invitation) => {
if (asleep) return { invitation, score: -Infinity };
const affinity = invitation.invitedByCharacterId
? await this.characterRelationship.getAffinity(characterId, invitation.invitedByCharacterId)
: 50; // 使用者從大廳呼叫:以中性關係修正為基準(呼叫端多半已另行以 T5 的完整關係計分判斷過一次)
const relationshipModifier = (affinity - 50) / 100;
const score = archetypeParams.conversationBaseline + emotionModifier + relationshipModifier;
return { invitation, score };
}),
);
const winner = scored.reduce((best, cur) => (cur.score > best.score ? cur : best), scored[0]);
const accept = winner.score >= CALL_ACCEPTANCE_THRESHOLD;
for (const { invitation } of scored) {
const isWinner = accept && invitation.id === winner.invitation.id;
await this.prisma.client.roomInvitation.update({
where: { id: invitation.id },
data: { status: isWinner ? "ACCEPTED" : "DECLINED", respondedAt: effectiveNow },
});
if (!isWinner) {
await this.leaveReply(invitation.roomId, characterId, effectiveNow, false);
}
}
if (accept) {
// T42:邀請對象答應加入原本是 SOLO 的房間時,房型立即改為 GROUP(不支援之後降回 SOLO,
// 沒有任何程式路徑會把 GROUP 改回 SOLO——離開只會減少在場人數,不影響 mode)。
const targetRoom = await this.prisma.client.room.findUniqueOrThrow({ where: { id: winner.invitation.roomId } });
if (targetRoom.mode === "SOLO") {
await this.room.switchToGroup(winner.invitation.roomId);
}
await this.roomLobby.enterRoom(characterId, winner.invitation.roomId, effectiveNow);
await this.leaveReply(winner.invitation.roomId, characterId, effectiveNow, true);
}
}
// T43:套用中角色的待決邀請清單,供套用者查看並決定。
async listPendingFor(characterId: string) {
return this.prisma.client.roomInvitation.findMany({
where: { characterId, status: "PENDING" },
include: { room: true },
orderBy: { createdAt: "asc" },
});
}
// T43:套用者以角色本人的身分決定一筆待決邀請——不經過人格系統的關係/情緒評分。答應時,同角色
// 其餘待決邀請一併婉拒(與 resolvePendingFor() 的「選一個、其餘逐一回覆」語意一致)。
async decideAsPossessed(invitationId: string, accept: boolean, now?: Date): Promise<void> {
const effectiveNow = now ?? this.clock.now();
const invitation = await this.prisma.client.roomInvitation.findUniqueOrThrow({ where: { id: invitationId } });
if (invitation.status !== "PENDING") return;
await this.prisma.client.roomInvitation.update({
where: { id: invitationId },
data: { status: accept ? "ACCEPTED" : "DECLINED", respondedAt: effectiveNow },
});
if (!accept) {
await this.leaveReply(invitation.roomId, invitation.characterId, effectiveNow, false);
return;
}
const otherPending = await this.prisma.client.roomInvitation.findMany({
where: { characterId: invitation.characterId, status: "PENDING", id: { not: invitationId } },
});
for (const other of otherPending) {
await this.prisma.client.roomInvitation.update({
where: { id: other.id },
data: { status: "DECLINED", respondedAt: effectiveNow },
});
await this.leaveReply(other.roomId, invitation.characterId, effectiveNow, false);
}
const targetRoom = await this.prisma.client.room.findUniqueOrThrow({ where: { id: invitation.roomId } });
if (targetRoom.mode === "SOLO") {
await this.room.switchToGroup(invitation.roomId);
}
await this.roomLobby.enterRoom(invitation.characterId, invitation.roomId, effectiveNow);
await this.leaveReply(invitation.roomId, invitation.characterId, effectiveNow, true);
}
// 在邀請來源留下一則角色台詞(婉拒說明,或答應後的入房招呼),而不是靜默消失或系統訊息。
private async leaveReply(roomId: string, characterId: string, now: Date, accepted: boolean): Promise<void> {
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId } });
const label = alias?.formalName ?? character.id;
const text = accepted ? "好,我來了!" : "抱歉,我這邊有點事,這次不能過去了。";
await this.room.addTurn(roomId, characterId, label, text, now);
}
}
@@ -1,120 +0,0 @@
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);
});
@@ -1,151 +0,0 @@
import { Injectable } from "@nestjs/common";
import { deriveLobbyPresenceState, type LobbyPresenceState, type LobbyPresenceRoomMode } from "@kokorone/shared";
import { PrismaService } from "../prisma/prisma.service.js";
import { ScheduleService } from "../schedule/schedule.service.js";
import { LobbyService as RoomLobbyService } from "../room/lobby.service.js";
export interface LobbyMilestoneView {
characterId: string;
label: string;
storyOrder: number;
createdAt: Date;
}
export interface LobbyWorkView {
id: string;
title: string;
inEpilogue: boolean;
milestones: LobbyMilestoneView[]; // T34:自走一段時間後自主成立的人生變化,明顯呈現在大廳作品卡片
}
export interface LobbyCharacterView {
id: string;
workId: string | null;
formalName: string;
buildStatus: string;
presenceState: LobbyPresenceState;
roomId: string | null; // 目前所在的非大廳房間(若有)
}
export interface LobbyActiveRoomView {
id: string;
mode: string;
topicSeed: string | null;
memberCharacterIds: string[];
unreadCount: number;
totalTokens: number; // T9:該房累計 LLM 用量(promptTokens + completionTokens),僅供觀察
}
// T4:大廳聚合端點。單一請求聚合所有作品、旗下角色與 T1 定義的狀態、以及該使用者所有 ACTIVE
// 房間與各房未讀數,以批次查詢(findMany + groupBy)避免逐一角色/房間各自查詢造成 N+1;
// 唯一的例外是 ScheduleService.getInfo(每位角色的作息判定邏輯本身就是逐角色計算,改以
// Promise.all 併發查詢而非序列 N+1 阻塞)。
@Injectable()
export class LobbyAggregationService {
constructor(
private readonly prisma: PrismaService,
private readonly schedule: ScheduleService,
private readonly roomLobby: RoomLobbyService,
) {}
async getLobby(
userId: string,
now: Date = new Date(),
): Promise<{ works: LobbyWorkView[]; characters: LobbyCharacterView[]; activeRooms: LobbyActiveRoomView[] }> {
// T24 常駐流轉的一致性前提:先確保所有角色都已正確登記在場(大廳或其他房間)。
await this.roomLobby.syncMembership(userId);
const [works, characters, activeMemberships, readStates, activeRooms] = await Promise.all([
this.prisma.client.work.findMany({ orderBy: { createdAt: "asc" } }),
this.prisma.client.character.findMany({ include: { aliases: true }, orderBy: { createdAt: "asc" } }),
this.prisma.client.roomMember.findMany({ where: { activeCharacterId: { not: null } }, include: { room: true } }),
this.prisma.client.roomReadState.findMany({ where: { userId } }),
this.prisma.client.room.findMany({
where: { userId, status: "ACTIVE", mode: { not: "LOBBY" } },
include: { members: { where: { leftAt: null } } },
}),
]);
const roomByCharacter = new Map(activeMemberships.map((m) => [m.characterId, m.room]));
const lastReadSeqByRoom = new Map(readStates.map((r) => [r.roomId, r.lastReadSeq]));
const roomIds = activeRooms.map((r) => r.id);
const latestTurns = roomIds.length
? await this.prisma.client.roomTurn.groupBy({ by: ["roomId"], where: { roomId: { in: roomIds } }, _max: { seq: true } })
: [];
const turnCounterByRoom = new Map(latestTurns.map((t) => [t.roomId, (t._max.seq ?? -1) + 1]));
// T9:每房累計用量,批次查詢避免逐房各自聚合造成 N+1。
const usageTotals = roomIds.length
? await this.prisma.client.roomUsageLog.groupBy({
by: ["roomId"],
where: { roomId: { in: roomIds } },
_sum: { promptTokens: true, completionTokens: true },
})
: [];
const totalTokensByRoom = new Map(
usageTotals.map((u) => [u.roomId, (u._sum.promptTokens ?? 0) + (u._sum.completionTokens ?? 0)]),
);
// T34:批次查詢所有角色的里程碑紀錄,依角色所屬作品分組——同既有慣例,避免逐作品各自查詢造成 N+1。
const milestoneLogs = characters.length
? await this.prisma.client.epilogueMilestoneLog.findMany({
where: { characterId: { in: characters.map((c) => c.id) } },
orderBy: { storyOrder: "asc" },
})
: [];
const workIdByCharacter = new Map(characters.map((c) => [c.id, c.workId]));
const milestonesByWork = new Map<string, LobbyMilestoneView[]>();
for (const log of milestoneLogs) {
const workId = workIdByCharacter.get(log.characterId);
if (!workId) continue;
const list = milestonesByWork.get(workId) ?? [];
list.push({ characterId: log.characterId, label: log.label, storyOrder: log.storyOrder, createdAt: log.createdAt });
milestonesByWork.set(workId, list);
}
const scheduleStatuses = await Promise.all(
characters.map(async (c) => ({ id: c.id, status: (await this.schedule.getInfo(c.id, now)).slot?.status ?? null })),
);
const scheduleStatusByCharacter = new Map(scheduleStatuses.map((s) => [s.id, s.status]));
const characterViews: LobbyCharacterView[] = characters.map((character) => {
const room = roomByCharacter.get(character.id);
const roomMode: LobbyPresenceRoomMode = room && room.mode === "SOLO" ? "SOLO" : room && room.mode === "GROUP" ? "GROUP" : null;
return {
id: character.id,
workId: character.workId,
formalName: character.aliases[0]?.formalName ?? character.id,
buildStatus: character.buildStatus,
presenceState: deriveLobbyPresenceState({
buildStatus: character.buildStatus,
scheduleSlotStatus: scheduleStatusByCharacter.get(character.id) ?? "FREE",
roomMode,
}),
roomId: roomMode ? (room!.id ?? null) : null,
};
});
return {
works: works.map((work) => ({
id: work.id,
title: work.title,
inEpilogue: work.inEpilogue,
milestones: milestonesByWork.get(work.id) ?? [],
})),
characters: characterViews,
activeRooms: activeRooms.map((room) => {
const turnCounter = turnCounterByRoom.get(room.id) ?? 0;
const lastReadSeq = lastReadSeqByRoom.get(room.id) ?? -1;
return {
id: room.id,
mode: room.mode,
topicSeed: room.topicSeed,
memberCharacterIds: room.members.map((m) => m.characterId),
unreadCount: Math.max(0, turnCounter - (lastReadSeq + 1)),
totalTokens: totalTokensByRoom.get(room.id) ?? 0,
};
}),
};
}
}
-34
View File
@@ -1,34 +0,0 @@
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);
}
}
-19
View File
@@ -1,19 +0,0 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module.js";
import { ScheduleModule } from "../schedule/schedule.module.js";
import { RoomModule } from "../room/room.module.js";
import { EmotionModule } from "../emotion/emotion.module.js";
import { RelationshipModule } from "../relationship/relationship.module.js";
import { LlmModule } from "../llm/llm.module.js";
import { ClockModule } from "../clock/clock.module.js";
import { LobbyAggregationService } from "./lobby-aggregation.service.js";
import { CallService } from "./call.service.js";
import { LobbyController } from "./lobby.controller.js";
@Module({
imports: [PrismaModule, ScheduleModule, RoomModule, EmotionModule, RelationshipModule, LlmModule, ClockModule],
controllers: [LobbyController],
providers: [LobbyAggregationService, CallService],
exports: [LobbyAggregationService, CallService],
})
export class LobbyModule {}
@@ -8,9 +8,7 @@ function normalize(content: string): string {
return content.trim().toLowerCase(); return content.trim().toLowerCase();
} }
// C-4/T40 睡眠固化:對話中只累積在工作記憶,session 結束時才批次萃取寫入長期記憶。 // C-4 睡眠固化:對話中只累積在工作記憶,session 結束時才批次萃取寫入長期記憶。
// T40:本服務固化的內容一律來自 1:1(SOLO)房間,因此一律標記 isPrivate=true——多人房生成
// (RoomGenerationService)預設排除私密記憶,口風不緊原型除外(見 K-3/T40 隱私邊界)。
@Injectable() @Injectable()
export class MemoryConsolidationService { export class MemoryConsolidationService {
constructor( constructor(
@@ -51,7 +49,6 @@ export class MemoryConsolidationService {
emotionIntensity, emotionIntensity,
source: "INTERACTION", source: "INTERACTION",
weight: isHighEmotion ? 1 + emotionIntensity : 0.5, weight: isHighEmotion ? 1 + emotionIntensity : 0.5,
isPrivate: true,
}, },
}); });
} }
@@ -1,86 +0,0 @@
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);
});
});
@@ -1,29 +0,0 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { PersonalityDriftService } from "../epilogue/personality-drift.service.js";
import { getArchetypeParams, type ArchetypeParams } from "./archetype-params.js";
// T16:混房的人格一致性——inEpilogue 作品的角色改取漂移後人格(目前只有 expressiveness 會被漂移,
// 見 PersonalityDriftService 的說明:性格核心鎖定不變,只有外顯度依正史已顯現的成長軌跡小幅位移),
// 其餘(非日後談)角色沿用本篇原始設定。每次呼叫都各自查該角色自己的 Work,不共用、不快取跨角色
// 的結果,確保混房時彼此的設定不會互相污染。
@Injectable()
export class PersonalityResolutionService {
constructor(
private readonly prisma: PrismaService,
private readonly personalityDrift: PersonalityDriftService,
) {}
async resolve(characterId: string): Promise<ArchetypeParams> {
const character = await this.prisma.client.character.findUniqueOrThrow({
where: { id: characterId },
include: { work: true },
});
const base = getArchetypeParams(character.personalityArchetype);
if (!character.work?.inEpilogue) {
return base;
}
const drift = await this.personalityDrift.computeDrift(characterId, character.work.anchorStoryOrder);
return { ...base, expressiveness: drift.expressiveness };
}
}
@@ -1,16 +1,14 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module.js"; import { PrismaModule } from "../prisma/prisma.module.js";
import { EmotionModule } from "../emotion/emotion.module.js"; import { EmotionModule } from "../emotion/emotion.module.js";
import { EpilogueModule } from "../epilogue/epilogue.module.js";
import { ContrastBehaviorService } from "./contrast-behavior.service.js"; import { ContrastBehaviorService } from "./contrast-behavior.service.js";
import { CharacterCreationService } from "./character-creation.service.js"; import { CharacterCreationService } from "./character-creation.service.js";
import { PersonalityResolutionService } from "./personality-resolution.service.js";
import { PersonalityController } from "./personality.controller.js"; import { PersonalityController } from "./personality.controller.js";
@Module({ @Module({
imports: [PrismaModule, EmotionModule, EpilogueModule], imports: [PrismaModule, EmotionModule],
controllers: [PersonalityController], controllers: [PersonalityController],
providers: [ContrastBehaviorService, CharacterCreationService, PersonalityResolutionService], providers: [ContrastBehaviorService, CharacterCreationService],
exports: [ContrastBehaviorService, CharacterCreationService, PersonalityResolutionService], exports: [ContrastBehaviorService, CharacterCreationService],
}) })
export class PersonalityModule {} export class PersonalityModule {}
+1 -7
View File
@@ -3,7 +3,6 @@ import { PrismaService } from "../prisma/prisma.service.js";
import { EmotionService, dominantState } from "../emotion/emotion.service.js"; import { EmotionService, dominantState } from "../emotion/emotion.service.js";
import { RomanceService } from "./romance.service.js"; import { RomanceService } from "./romance.service.js";
import { RomanceEligibilityService } from "./romance-eligibility.service.js"; import { RomanceEligibilityService } from "./romance-eligibility.service.js";
import { isOutsideSoloRoom } from "./room-context-guard.js";
import { import {
CONFESSION_METER_THRESHOLD, CONFESSION_METER_THRESHOLD,
AWKWARD_PERIOD_DAYS, AWKWARD_PERIOD_DAYS,
@@ -38,12 +37,7 @@ export class ConfessionService {
private readonly emotion: EmotionService, private readonly emotion: EmotionService,
) {} ) {}
async confess(characterId: string, userId: string, now: Date = new Date(), roomId?: string): Promise<ConfessionResult> { async confess(characterId: string, userId: string, now: Date = new Date()): Promise<ConfessionResult> {
// T42:房型已轉為 GROUP/LOBBY/SELF_CHAT 時,告白一律不可用——不消耗嘗試次數、不記錄拒絕。
if (await isOutsideSoloRoom(this.prisma, roomId)) {
return { outcome: "INELIGIBLE", romanceMeter: 0, consecutiveRejections: 0 };
}
const eligible = await this.eligibility.getEligibility(characterId); const eligible = await this.eligibility.getEligibility(characterId);
if (!eligible.romanceAxisEligible) { if (!eligible.romanceAxisEligible) {
return { outcome: "INELIGIBLE", romanceMeter: 0, consecutiveRejections: 0 }; return { outcome: "INELIGIBLE", romanceMeter: 0, consecutiveRejections: 0 };
@@ -2,7 +2,6 @@ import { Injectable } from "@nestjs/common";
import type { Archetype } from "../personality/archetype-params.js"; import type { Archetype } from "../personality/archetype-params.js";
import { PrismaService } from "../prisma/prisma.service.js"; import { PrismaService } from "../prisma/prisma.service.js";
import { RomanceService } from "./romance.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"; import { MILESTONE_MIN_TIER, PARTNER_STABLE_EVENT_COUNT, type MilestoneType } from "./constants.js";
export interface MilestoneResult { export interface MilestoneResult {
@@ -48,13 +47,7 @@ export class MilestoneService {
userId: string, userId: string,
milestoneType: MilestoneType, milestoneType: MilestoneType,
now: Date = new Date(), now: Date = new Date(),
roomId?: string,
): Promise<MilestoneResult> { ): Promise<MilestoneResult> {
// T42:房型已轉為 GROUP/LOBBY/SELF_CHAT 時,親密表現里程碑一律禁止——視同尚未達到任何層級。
if (await isOutsideSoloRoom(this.prisma, roomId)) {
return { allowed: false, isFirstTime: false, reactionLine: "……這裡人多,不太方便。" };
}
const relationship = await this.romance.getState(characterId, userId, now); const relationship = await this.romance.getState(characterId, userId, now);
const currentTier = tierFor(relationship.romanceStage, relationship.romanceEventsInStage); const currentTier = tierFor(relationship.romanceStage, relationship.romanceEventsInStage);
const requiredTier = MILESTONE_MIN_TIER[milestoneType]; const requiredTier = MILESTONE_MIN_TIER[milestoneType];
@@ -1,7 +1,6 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js"; import { PrismaService } from "../prisma/prisma.service.js";
import { AgeDeterminationService } from "./age-determination.service.js"; import { AgeDeterminationService } from "./age-determination.service.js";
import { isOutsideSoloRoom } from "./room-context-guard.js";
export interface RomanceEligibility { export interface RomanceEligibility {
romanceAxisEligible: boolean; // 戀愛軸是否可啟動(含後日談自然成年者) romanceAxisEligible: boolean; // 戀愛軸是否可啟動(含後日談自然成年者)
@@ -29,20 +28,15 @@ export class RomanceEligibilityService {
return { romanceAxisEligible: currentAdult, adultModeEligible: canonAdult, isEpilogueAdult }; return { romanceAxisEligible: currentAdult, adultModeEligible: canonAdult, isEpilogueAdult };
} }
async checkAdultContentAllowed(characterId: string, userId: string, roomId?: string): Promise<AdultContentCheckResult> { async checkAdultContentAllowed(characterId: string, userId: string): Promise<AdultContentCheckResult> {
const result = await this.evaluate(characterId, userId, roomId); const result = await this.evaluate(characterId, userId);
await this.prisma.client.adultContentAttemptLog.create({ await this.prisma.client.adultContentAttemptLog.create({
data: { characterId, userId, allowed: result.allowed, reason: result.reason }, data: { characterId, userId, allowed: result.allowed, reason: result.reason },
}); });
return result; return result;
} }
private async evaluate(characterId: string, userId: string, roomId?: string): Promise<AdultContentCheckResult> { private async evaluate(characterId: string, userId: string): Promise<AdultContentCheckResult> {
// T42:房型已轉為 GROUP/LOBBY/SELF_CHAT 時,成人內容一律禁止——房型本身是硬邊界,
// 不受角色資格或使用者 opt-in 狀態影響,優先於下方其他判斷。
if (await isOutsideSoloRoom(this.prisma, roomId)) {
return { allowed: false, reason: "目前在多人房(GROUP/LOBBY/SELF_CHAT),成人內容僅限 1:1 房間" };
}
const eligibility = await this.getEligibility(characterId); const eligibility = await this.getEligibility(characterId);
if (!eligibility.adultModeEligible) { if (!eligibility.adultModeEligible) {
return { return {
+2 -5
View File
@@ -25,14 +25,12 @@ interface RomanceEventBody {
interface UserOnlyBody { interface UserOnlyBody {
userId: string; userId: string;
now?: string; now?: string;
roomId?: string; // T42:帶房間時依房型套用邊界(僅 SOLO 可用)
} }
interface MilestoneBody { interface MilestoneBody {
userId: string; userId: string;
milestoneType: MilestoneType; milestoneType: MilestoneType;
now?: string; now?: string;
roomId?: string; // T42:帶房間時依房型套用邊界(僅 SOLO 可用)
} }
interface AdultConsentBody { interface AdultConsentBody {
@@ -79,7 +77,7 @@ export class RomanceController {
// L-7 引擎層唯一入口:任何嘗試皆記錄,不論放行與否。 // L-7 引擎層唯一入口:任何嘗試皆記錄,不論放行與否。
@Post(":characterId/adult-content-check") @Post(":characterId/adult-content-check")
async checkAdultContent(@Param("characterId") characterId: string, @Body() body: UserOnlyBody) { async checkAdultContent(@Param("characterId") characterId: string, @Body() body: UserOnlyBody) {
return this.eligibility.checkAdultContentAllowed(characterId, body.userId, body.roomId); return this.eligibility.checkAdultContentAllowed(characterId, body.userId);
} }
@Post("adult-mode/consent") @Post("adult-mode/consent")
@@ -110,7 +108,7 @@ export class RomanceController {
@Post(":characterId/confess") @Post(":characterId/confess")
async confess(@Param("characterId") characterId: string, @Body() body: UserOnlyBody) { async confess(@Param("characterId") characterId: string, @Body() body: UserOnlyBody) {
return this.confession.confess(characterId, body.userId, body.now ? new Date(body.now) : undefined, body.roomId); return this.confession.confess(characterId, body.userId, body.now ? new Date(body.now) : undefined);
} }
@Post(":characterId/milestone") @Post(":characterId/milestone")
@@ -120,7 +118,6 @@ export class RomanceController {
body.userId, body.userId,
body.milestoneType, body.milestoneType,
body.now ? new Date(body.now) : undefined, body.now ? new Date(body.now) : undefined,
body.roomId,
); );
} }
} }
-103
View File
@@ -1,103 +0,0 @@
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);
});
@@ -1,9 +0,0 @@
import { PrismaService } from "../prisma/prisma.service.js";
// T42:戀愛里程碑/告白/成人內容一律限 1:1(SOLO)房間;房型一旦轉為 GROUP/LOBBY/SELF_CHAT,
// 這些互動全面禁止——不看關係階段、不看使用者是否已 opt-in,房型本身就是硬邊界。
export async function isOutsideSoloRoom(prisma: PrismaService, roomId: string | undefined): Promise<boolean> {
if (!roomId) return false; // 未帶 roomId 視為既有非房間情境(相容舊呼叫端),不套用房型邊界
const room = await prisma.client.room.findUnique({ where: { id: roomId } });
return room !== null && room.mode !== "SOLO";
}
@@ -1,143 +0,0 @@
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { afterEach, describe, expect, it } from "vitest";
import { createPrismaClient } from "@kokorone/db";
import type { PrismaService } from "../prisma/prisma.service.js";
import { ControllableClock } from "../clock/controllable-clock.js";
import { EmotionService } from "../emotion/emotion.service.js";
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
import { ScheduleService } from "../schedule/schedule.service.js";
import { SleepDebtService } from "../schedule/sleep-debt.service.js";
import { PersonalityDriftService } from "../epilogue/personality-drift.service.js";
import { PersonalityResolutionService } from "../personality/personality-resolution.service.js";
import { LobbyService } from "./lobby.service.js";
import { RoomEventsService } from "./room-events.service.js";
import { RoomService, type Room } from "./room.service.js";
import { BehaviorSelectionService, type BehaviorSelectionResult } from "./behavior-selection.service.js";
// T6 驗收:離開只在三項動因(行程/情緒耗盡/話題耗盡)之一成立時才進入候選;多輪抽樣下四種行為皆會出現。
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
const prisma = { client: createPrismaClient() } as PrismaService;
const clock = new ControllableClock("2026-08-19T05:00:00.000Z"); // 週三 13:00 台北,FREE 時段
const roomEvents = new RoomEventsService(new EventEmitter2());
const lobbyService = new LobbyService(prisma, clock, roomEvents);
const roomService = new RoomService(prisma, clock, lobbyService, roomEvents);
const emotionService = new EmotionService(prisma, new RuleBasedEmotionTagger());
const scheduleService = new ScheduleService(prisma);
const sleepDebtService = new SleepDebtService(prisma, scheduleService);
const behaviorSelection = new BehaviorSelectionService(prisma, emotionService, scheduleService, sleepDebtService, new PersonalityResolutionService(prisma, new PersonalityDriftService(prisma)));
const userId = "test-behavior-user";
const CHAR_A = "test-behavior-char-a";
afterEach(async () => {
await prisma.client.room.deleteMany({ where: { userId } });
await prisma.client.emotionState.deleteMany({ where: { characterId: CHAR_A } });
await prisma.client.scheduleSlot.deleteMany({ where: { characterId: CHAR_A } });
await prisma.client.character.deleteMany({ where: { id: CHAR_A } });
await prisma.client.user.deleteMany({ where: { id: userId } });
});
// 依序(非併發)抽樣:better-sqlite3 是單一寫入者,併發大量 Promise.all 會在資料量大的共用 dev.db 上
// 造成明顯的鎖等待,序列執行較穩定不易逾時。
async function sampleMany(room: Room, characterId: string, baseTime: Date, count: number): Promise<BehaviorSelectionResult[]> {
const results: BehaviorSelectionResult[] = [];
for (let i = 0; i < count; i++) {
results.push(await behaviorSelection.selectBehavior(room, characterId, new Date(baseTime.getTime() + i * 1000)));
}
return results;
}
async function createCharacter(archetype: string) {
await prisma.client.character.create({
data: {
id: CHAR_A,
source: "ORIGINAL",
buildStatus: "BUILT",
basicInfo: "測試",
backgroundStory: "測試",
personalityArchetype: archetype,
likesDislikes: "測試",
goalsObsessions: "測試",
speechStyle: "測試",
},
});
}
describe("BehaviorSelectionService(T6)", () => {
it("沒有任何離開動因成立時,LEAVE 權重為 0、絕不會被抽中", async () => {
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
await createCharacter("元氣"); // FREE 時段、預設情緒 CALM、無行程
const room = await roomService.create(userId, [CHAR_A], "SELF_CHAT", "測試話題");
const results = await sampleMany(room, CHAR_A, clock.now(), 15);
expect(results.every((r) => r.kind !== "LEAVE")).toBe(true);
}, 30000);
it("行程忙碌(BUSY)時,LEAVE 有非零權重且 leaveReason 為 SCHEDULE", async () => {
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
await createCharacter("元氣");
await prisma.client.scheduleSlot.create({
data: { characterId: CHAR_A, dayType: "WEEKDAY", startMinute: 720, endMinute: 900, status: "BUSY" },
});
const room = await roomService.create(userId, [CHAR_A], "SELF_CHAT", "測試話題");
// 多次抽樣,統計是否確實可能抽到 LEAVE(機率性行為,抽夠多次應該至少中一次)。
const results = await sampleMany(room, CHAR_A, clock.now(), 25);
const leaveResults = results.filter((r) => r.kind === "LEAVE");
expect(leaveResults.length).toBeGreaterThan(0);
expect(leaveResults.every((r) => r.leaveReason === "SCHEDULE")).toBe(true);
}, 30000);
it("情緒耗盡(SAD 高強度)時,LEAVE 的 leaveReason 為 EMOTIONAL_EXHAUSTION", async () => {
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
await createCharacter("元氣");
await prisma.client.emotionState.create({ data: { characterId: CHAR_A, sad: 90, calm: 10 } });
const room = await roomService.create(userId, [CHAR_A], "SELF_CHAT", "測試話題");
const results = await sampleMany(room, CHAR_A, clock.now(), 25);
const leaveResults = results.filter((r) => r.kind === "LEAVE");
expect(leaveResults.length).toBeGreaterThan(0);
expect(leaveResults.every((r) => r.leaveReason === "EMOTIONAL_EXHAUSTION")).toBe(true);
}, 30000);
it("多輪抽樣下 TALK/ACTION/DELEGATE 都可能出現(元氣:conversationBaseline 與 expressiveness 皆高)", async () => {
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
await createCharacter("元氣");
const room = await roomService.create(userId, [CHAR_A], "SELF_CHAT", "測試話題");
const kinds = new Set((await sampleMany(room, CHAR_A, clock.now(), 35)).map((r) => r.kind));
expect(kinds.has("TALK")).toBe(true);
expect(kinds.has("ACTION")).toBe(true);
expect(kinds.has("DELEGATE")).toBe(true);
}, 30000);
it("T32:睡眠債達升級門檻時,LEAVE 的 leaveReason 為 SLEEP_DEBT,優先於一般行程理由 SCHEDULE", async () => {
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
await createCharacter("元氣");
// 同時符合一般行程忙碌動因,驗證睡眠債升級後會優先蓋過 SCHEDULE。
await prisma.client.scheduleSlot.create({
data: { characterId: CHAR_A, dayType: "WEEKDAY", startMinute: 720, endMinute: 900, status: "BUSY" },
});
await prisma.client.sleepDebt.create({ data: { characterId: CHAR_A, accumulatedMinutes: 200 } });
const room = await roomService.create(userId, [CHAR_A], "SELF_CHAT", "測試話題");
const results = await sampleMany(room, CHAR_A, clock.now(), 25);
const leaveResults = results.filter((r) => r.kind === "LEAVE");
expect(leaveResults.length).toBeGreaterThan(0);
expect(leaveResults.every((r) => r.leaveReason === "SLEEP_DEBT")).toBe(true);
}, 30000);
it("T32:睡眠債活躍(未達升級門檻)時,ACTION 權重提高,更容易抽到打瞌睡演出", async () => {
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
await createCharacter("三無"); // expressiveness 極低,平常幾乎不會抽到 ACTION
await prisma.client.sleepDebt.create({ data: { characterId: CHAR_A, accumulatedMinutes: 90 } });
const room = await roomService.create(userId, [CHAR_A], "SELF_CHAT", "測試話題");
const kinds = (await sampleMany(room, CHAR_A, clock.now(), 40)).map((r) => r.kind);
expect(kinds).toContain("ACTION");
}, 30000);
});
@@ -1,98 +0,0 @@
import { Injectable } from "@nestjs/common";
import type { RoomTurnKind } from "@kokorone/db";
import type { EmotionState } from "@kokorone/shared";
import { PrismaService } from "../prisma/prisma.service.js";
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
import { ScheduleService } from "../schedule/schedule.service.js";
import { SleepDebtService, SLEEP_DEBT_ACTIVE_THRESHOLD_MINUTES, SLEEP_DEBT_ESCALATION_THRESHOLD_MINUTES } from "../schedule/sleep-debt.service.js";
import { PersonalityResolutionService } from "../personality/personality-resolution.service.js";
import { createSeededRandom, hashToSeed } from "../llm/seeded-random.js";
import { isStalling } from "./stall-detection.js";
import { STALL_CHECK_WINDOW } from "./constants.js";
import type { Room } from "./room.service.js";
export type LeaveReason = "SCHEDULE" | "EMOTIONAL_EXHAUSTION" | "TOPIC_EXHAUSTED" | "SLEEP_DEBT";
export interface BehaviorSelectionResult {
kind: RoomTurnKind;
leaveReason?: LeaveReason; // 只有 kind === "LEAVE" 時才有值,對應四項離開動因之一
}
const DELEGATE_BASELINE_WEIGHT = 0.15;
const ACTION_WEIGHT_SCALE = 0.4;
const TALK_WEIGHT_FLOOR = 0.2; // 即使 conversationBaseline 極低(如三無),TALK 仍保留最低權重不至於完全消失
const HIGH_EMOTION_INTENSITY_THRESHOLD = 70; // EmotionState 各維度 0~100,達此值視為「情緒耗盡」等級
const LEAVE_WEIGHT_WHEN_ELIGIBLE = 0.5;
const LEAVE_WEIGHT_WHEN_SLEEP_DEBT_ESCALATED = 0.75; // T32:撐不住想睡,比一般行程理由更強烈
const SLEEP_DEBT_ACTION_WEIGHT_BONUS = 0.3; // T32:打瞌睡演出——睡眠債活躍時 ACTION 權重額外加成
function emotionFieldFor(tag: string): keyof Omit<EmotionState, "characterId" | "updatedAt"> {
return tag === "CALM" ? "calm" : (tag.toLowerCase() as "joy" | "sad" | "alert" | "shy" | "grumpy");
}
function weightedSample(weights: Partial<Record<RoomTurnKind, number>>, seed: number): RoomTurnKind {
const entries = (Object.entries(weights) as [RoomTurnKind, number][]).filter(([, weight]) => weight > 0);
const total = entries.reduce((sum, [, weight]) => sum + weight, 0);
if (total <= 0) return "TALK"; // 保底:理論上不會發生(TALK 恆有 floor 權重),防禦性 fallback
const roll = createSeededRandom(seed)() * total;
let acc = 0;
for (const [kind, weight] of entries) {
acc += weight;
if (roll < acc) return kind;
}
return entries[entries.length - 1][0];
}
// T6/T32:由程式依 ARCHETYPE_PARAMS+情緒/行程/話題狀態/睡眠債算權重抽樣決定行為種類(TALK/
// ACTION/DELEGATE/LEAVE),不交給 LLM 決定——LLM 只負責生成該行為的內容。LEAVE 只在動因成立時
// 才會有非零權重、才可能被抽中:行程(忙碌/睡眠)、情緒(情緒耗盡)、話題耗盡(isStalling)、
// 睡眠債(T32:熬夜累積的債務達門檻,升級為「撐不住想睡」,優先於一般行程理由)。
@Injectable()
export class BehaviorSelectionService {
constructor(
private readonly prisma: PrismaService,
private readonly emotion: EmotionService,
private readonly schedule: ScheduleService,
private readonly sleepDebt: SleepDebtService,
private readonly personalityResolution: PersonalityResolutionService,
) {}
async selectBehavior(room: Room, characterId: string, now: Date = new Date()): Promise<BehaviorSelectionResult> {
await this.sleepDebt.reconcileToday(characterId, now);
// T16:inEpilogue 角色改用漂移後的 expressiveness(僅影響 ACTION 權重),非日後談角色維持原始
// 設定;每次都各自查該角色自己的 Work,混房時不會互相污染。
const archetypeParams = await this.personalityResolution.resolve(characterId);
const emotionState = await this.emotion.getState(characterId, now);
const dominant = dominantState(emotionState);
const intensity = emotionState[emotionFieldFor(dominant)] as number;
const scheduleInfo = await this.schedule.getInfo(characterId, now);
const scheduleEligible = scheduleInfo.slot?.status === "BUSY" || scheduleInfo.slot?.status === "SLEEP";
const emotionalExhaustion = (dominant === "SAD" || dominant === "GRUMPY") && intensity >= HIGH_EMOTION_INTENSITY_THRESHOLD;
const topicExhausted = isStalling(room.turns.slice(-STALL_CHECK_WINDOW).map((turn) => turn.content));
const debtMinutes = await this.sleepDebt.getDebtMinutes(characterId);
const sleepDebtEscalated = debtMinutes >= SLEEP_DEBT_ESCALATION_THRESHOLD_MINUTES;
// T32:睡眠債達升級門檻時,優先於一般行程理由——「撐不住想睡」比「行程到了」更強烈、更明說。
let leaveReason: LeaveReason | undefined;
if (sleepDebtEscalated) leaveReason = "SLEEP_DEBT";
else if (scheduleEligible) leaveReason = "SCHEDULE";
else if (emotionalExhaustion) leaveReason = "EMOTIONAL_EXHAUSTION";
else if (topicExhausted) leaveReason = "TOPIC_EXHAUSTED";
const sleepDebtActive = debtMinutes >= SLEEP_DEBT_ACTIVE_THRESHOLD_MINUTES;
const weights: Partial<Record<RoomTurnKind, number>> = {
TALK: TALK_WEIGHT_FLOOR + archetypeParams.conversationBaseline,
ACTION: archetypeParams.expressiveness * ACTION_WEIGHT_SCALE + (sleepDebtActive ? SLEEP_DEBT_ACTION_WEIGHT_BONUS : 0),
DELEGATE: DELEGATE_BASELINE_WEIGHT,
LEAVE: leaveReason ? (sleepDebtEscalated ? LEAVE_WEIGHT_WHEN_SLEEP_DEBT_ESCALATED : LEAVE_WEIGHT_WHEN_ELIGIBLE) : 0,
};
const seed = hashToSeed(`${room.id}:${room.turnCounter}:${characterId}:${now.getTime()}:behavior`);
const kind = weightedSample(weights, seed);
return { kind, leaveReason: kind === "LEAVE" ? leaveReason : undefined };
}
}
@@ -1,55 +0,0 @@
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { createPrismaClient } from "@kokorone/db";
import type { PrismaService } from "../prisma/prisma.service.js";
import { CharacterRelationshipService } from "./character-relationship.service.js";
// T33 回歸測試:加上 Redis 讀寫快取後,get/set 的既有語意(未設定回傳中性值 50、set 後讀到新值、
// 更新後不會讀到舊值)必須維持不變——本環境沒有可用的 Redis(同 EmotionService 既有的快取層一樣,
// 未連線時一律安全退回直接讀寫 Prisma,見 character-relationship.service.ts 的 try/catch),
// 因此這裡實際測到的是「Redis 不可用」的退回路徑,與快取命中路徑的正確性需在有 Redis 的環境另行確認。
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
const prisma = { client: createPrismaClient() } as PrismaService;
const service = new CharacterRelationshipService(prisma);
const CHAR_A = "test-charrel-a";
const CHAR_B = "test-charrel-b";
afterEach(async () => {
await prisma.client.characterRelationship.deleteMany({ where: { characterId: CHAR_A } });
await prisma.client.character.deleteMany({ where: { id: { in: [CHAR_A, CHAR_B] } } });
});
async function createCharacter(id: string) {
await prisma.client.character.create({
data: {
id,
source: "ORIGINAL",
buildStatus: "BUILT",
basicInfo: "測試",
backgroundStory: "測試",
personalityArchetype: "元氣",
likesDislikes: "測試",
goalsObsessions: "測試",
speechStyle: "測試",
},
});
}
describe("CharacterRelationshipService(T33 快取回歸)", () => {
it("未設定時回傳中性值 50;set 後讀到新值;再次 set 更新後讀到最新值(不是舊快取)", async () => {
await createCharacter(CHAR_A);
await createCharacter(CHAR_B);
expect(await service.getAffinity(CHAR_A, CHAR_B)).toBe(50);
await service.set(CHAR_A, CHAR_B, 80, "喜歡");
expect(await service.getAffinity(CHAR_A, CHAR_B)).toBe(80);
await service.set(CHAR_A, CHAR_B, 20, "吵架");
expect(await service.getAffinity(CHAR_A, CHAR_B)).toBe(20);
}, 20000);
});
@@ -1,78 +1,25 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js"; 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 角色間關係:同作品角色互損、拆台、護短、吃醋等化學反應的資料來源。 // K-2/K-4 角色間關係:同作品角色互損、拆台、護短、吃醋等化學反應的資料來源。
// 有向邊:characterId 對 otherCharacterId 的觀感,兩邊可以各自設定、不必對稱。 // 有向邊:characterId 對 otherCharacterId 的觀感,兩邊可以各自設定、不必對稱。
// T33:多人房每輪每位候選都要查一次 affinity,改走 Redis 快取當熱路徑——Prisma/SQLite 仍是唯一
// 持久真實來源,Redis 不可用或未命中都直接退回原本讀 Prisma 的行為(同 EmotionService 既有慣例);
// set() 寫入時一併使該筆快取失效,不會有「改了關係但快取還是舊值」的問題。
@Injectable() @Injectable()
export class CharacterRelationshipService { export class CharacterRelationshipService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async set(characterId: string, otherCharacterId: string, affinity: number, dynamic?: string) { async set(characterId: string, otherCharacterId: string, affinity: number, dynamic?: string) {
const result = await this.prisma.client.characterRelationship.upsert({ return this.prisma.client.characterRelationship.upsert({
where: { characterId_otherCharacterId: { characterId, otherCharacterId } }, where: { characterId_otherCharacterId: { characterId, otherCharacterId } },
update: { affinity, dynamic }, update: { affinity, dynamic },
create: { characterId, otherCharacterId, affinity, dynamic }, create: { characterId, otherCharacterId, affinity, dynamic },
}); });
try {
await getRedisClient().del(cacheKey(characterId, otherCharacterId));
} catch {
// Redis 不可用:沒有快取可失效,下一次 getAffinity 本來就會直接讀 Prisma。
}
return result;
} }
// 未設定關係時,回傳中性值(50)——同作品角色預設不特別親近也不特別有心結。 // 未設定關係時,回傳中性值(50)——同作品角色預設不特別親近也不特別有心結。
async getAffinity(characterId: string, otherCharacterId: string): Promise<number> { async getAffinity(characterId: string, otherCharacterId: string): Promise<number> {
const key = cacheKey(characterId, otherCharacterId);
try {
const cached = await getRedisClient().get(key);
if (cached !== null) return Number(cached);
} catch {
// Redis 不可用:退回直接讀 Prisma。
}
const row = await this.prisma.client.characterRelationship.findUnique({ const row = await this.prisma.client.characterRelationship.findUnique({
where: { characterId_otherCharacterId: { characterId, otherCharacterId } }, where: { characterId_otherCharacterId: { characterId, otherCharacterId } },
}); });
const affinity = row?.affinity ?? 50; return row?.affinity ?? 50;
try {
await getRedisClient().set(key, String(affinity));
} catch {
// Redis 不可用:純粹跳過快取寫入,不影響回傳值。
}
return affinity;
}
// T28:同房互動後的關係值累積——初次同房(尚無既有關係列)視為中性 50 起算,之後每次固化都朝
// 更熟悉一點小幅位移,夾在 0~100 之間。呼叫端(RoomConsolidationService)於房間關房固化時,
// 對每一對「同一場都有真的發言」的角色雙向各呼叫一次。
async recordInteraction(characterId: string, otherCharacterId: string): Promise<void> {
const current = await this.getAffinity(characterId, otherCharacterId);
const next = Math.max(0, Math.min(100, current + INTERACTION_AFFINITY_DELTA));
await this.set(characterId, otherCharacterId, next);
}
// T35 目擊者反應:旁觀角色對「使用者與另一位角色曖昧互動」的反應,同時調整關係值並留下事件紀錄
// (CharacterRelationshipEventLog),供未來人格對話/關係史回顧引用這段「吃味/替對方開心」的事件。
async recordWitnessReaction(witnessId: string, targetId: string, delta: number, description: string): Promise<void> {
const current = await this.getAffinity(witnessId, targetId);
const next = Math.max(0, Math.min(100, current + delta));
await this.set(witnessId, targetId, next);
await this.prisma.client.characterRelationshipEventLog.create({
data: { characterId: witnessId, otherCharacterId: targetId, delta, description },
});
} }
} }
+5 -19
View File
@@ -28,25 +28,11 @@ export const GROUP_RESERVE_INTIMACY_PENALTY = 35;
export const TSUNDERE_EXTRA_GROUP_PENALTY = 15; export const TSUNDERE_EXTRA_GROUP_PENALTY = 15;
// K-6 角色自聊 // K-6 角色自聊
// T6:不再是「累積到此輪數就整室結束」的硬上限——結束改由每位角色各自的 LEAVE 判定驅動(見
// BehaviorSelectionService),房間可連續推進超過此值。保留常數供未來「單次推進批次上限」使用
// (T8 排程器一次 tick 最多處理幾輪),目前尚無呼叫端消費。
export const SELF_CHAT_MAX_TURNS = 12; export const SELF_CHAT_MAX_TURNS = 12;
export const STALL_CHECK_WINDOW = 3; // 檢查最近幾輪是否開始空轉 export const STALL_CHECK_WINDOW = 3; // 檢查最近幾輪是否開始空轉
// T6:LEAVE 動因之一(話題耗盡)離場時沿用的台詞。 export const TOPIC_TWIST_LINES = [
"對了,說到這個,我突然想到另一件事……",
"欸,先別說這個了,你們聽說了嗎——",
"話說回來,今天發生了一件蠻好玩的事。",
];
export const SELF_CHAT_WRAP_UP_LINES = ["好啦,先聊到這裡吧,晚點再聊!", "時間也差不多了,先這樣吧~"]; 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;
-140
View File
@@ -1,140 +0,0 @@
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);
});
-12
View File
@@ -1,12 +0,0 @@
// 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;
}
-17
View File
@@ -1,17 +0,0 @@
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" },
];
@@ -1,48 +0,0 @@
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([]);
});
});
@@ -1,38 +0,0 @@
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;
});
});
}
-88
View File
@@ -1,88 +0,0 @@
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();
});
});
-195
View File
@@ -1,195 +0,0 @@
import { Inject, Injectable } from "@nestjs/common";
import type { Room as PrismaRoom } from "@kokorone/db";
import { PrismaService } from "../prisma/prisma.service.js";
import { CLOCK, type Clock } from "../clock/clock.js";
import { RoomEventsService } from "./room-events.service.js";
// T24:大廳常駐房與在場流轉。每位使用者恆有唯一一間 mode=LOBBY 的房間,status 永久 ACTIVE、
// 不套用自動關房(T8 的自動關房掃描必須排除 LOBBY,見該項);所有未在其他房間在場的角色以
// RoomMember 記在這間房。角色進房/離房只是把 activeCharacterId 從大廳那一筆搬到目標房那一筆
// (或搬回來),R5「角色不可跨房」由 T2 的 RoomMember.activeCharacterId 唯一索引在 DB 層擋下。
@Injectable()
export class LobbyService {
constructor(
private readonly prisma: PrismaService,
@Inject(CLOCK) private readonly clock: Clock,
private readonly roomEvents: RoomEventsService,
) {}
// T10:member 事件的序號沿用當下 turnCounter(不落地、盡力而為,見 RoomEventsService 註解)。
private async currentTurnCounter(roomId: string): Promise<number> {
const last = await this.prisma.client.roomTurn.findFirst({ where: { roomId }, orderBy: { seq: "desc" } });
return (last?.seq ?? -1) + 1;
}
async getOrCreateLobby(userId: string): Promise<PrismaRoom> {
const existing = await this.prisma.client.room.findFirst({ where: { userId, mode: "LOBBY" } });
if (existing) return existing;
return this.prisma.client.room.create({
data: { userId, mode: "LOBBY", status: "ACTIVE", autoAdvanceEnabled: false },
});
}
// 讓「目前沒有在任何房間在場」的角色補進大廳;已在其他房間在場(含已在大廳)的角色不重複處理。
async syncMembership(userId: string): Promise<void> {
const lobby = await this.getOrCreateLobby(userId);
const [allCharacters, activeMemberships] = await Promise.all([
this.prisma.client.character.findMany({ select: { id: true } }),
this.prisma.client.roomMember.findMany({ where: { activeCharacterId: { not: null } }, select: { characterId: true } }),
]);
const alreadyActive = new Set(activeMemberships.map((m) => m.characterId));
const missing = allCharacters.filter((c) => !alreadyActive.has(c.id));
if (missing.length === 0) return;
await this.prisma.client.roomMember.createMany({
data: missing.map((c) => ({ roomId: lobby.id, characterId: c.id, activeCharacterId: c.id })),
});
}
// 角色從目前所在房間(通常是大廳)搬進 targetRoomId;已經在 targetRoomId 則視為已完成(幂等)。
async enterRoom(characterId: string, targetRoomId: string, now?: Date): Promise<void> {
const effectiveNow = now ?? this.clock.now();
const current = await this.prisma.client.roomMember.findFirst({ where: { activeCharacterId: characterId } });
if (current?.roomId === targetRoomId) return;
await this.prisma.client.$transaction(async (tx) => {
if (current) {
await tx.roomMember.update({
where: { id: current.id },
data: { leftAt: effectiveNow, leaveReason: "ENTER_ROOM", activeCharacterId: null },
});
}
await tx.roomMember.create({
data: { roomId: targetRoomId, characterId, activeCharacterId: characterId },
});
});
if (current) {
this.roomEvents.emit(current.roomId, {
type: "member",
seq: await this.currentTurnCounter(current.roomId),
data: { characterId, action: "left" },
});
}
this.roomEvents.emit(targetRoomId, {
type: "member",
seq: await this.currentTurnCounter(targetRoomId),
data: { characterId, action: "joined" },
});
}
// 角色離開目前所在房間,補回大廳一筆在場紀錄;若目前所在房間本身就是大廳,視為已完成(幂等)。
async returnToLobby(characterId: string, userId: string, now?: Date, leaveReason = "LEFT_ROOM"): Promise<void> {
const effectiveNow = now ?? this.clock.now();
const lobby = await this.getOrCreateLobby(userId);
const current = await this.prisma.client.roomMember.findFirst({ where: { activeCharacterId: characterId } });
if (current?.roomId === lobby.id) return;
await this.prisma.client.$transaction(async (tx) => {
if (current) {
await tx.roomMember.update({
where: { id: current.id },
data: { leftAt: effectiveNow, leaveReason, activeCharacterId: null },
});
}
await tx.roomMember.create({
data: { roomId: lobby.id, characterId, activeCharacterId: characterId },
});
});
if (current) {
this.roomEvents.emit(current.roomId, {
type: "member",
seq: await this.currentTurnCounter(current.roomId),
data: { characterId, action: "left" },
});
}
this.roomEvents.emit(lobby.id, {
type: "member",
seq: await this.currentTurnCounter(lobby.id),
data: { characterId, action: "joined" },
});
}
// T6:角色離開「指定的」房間並回大廳——與 returnToLobby() 不同之處在於明確帶入 roomId,不依賴
// activeCharacterId 找出目前所在房間。既有 room.service.ts create() 對直接帶入 characterIds 建立
// 的房間(K 群組沿用至今的舊式 GROUP/SELF_CHAT 測試房)刻意不設 activeCharacterId(見該處註解,
// 讓同一角色可同時存在於多個測試房間),若 T6 的自走 LEAVE 沿用 returnToLobby() 的
// activeCharacterId 查找方式會找不到「目前這個房間」的在場列,離開會失敗。改以 roomId+characterId
// 直接鎖定要離開的那一列;回大廳的登記採「先查再寫」,避免與可能已存在(例如 syncMembership 誤判
// 舊式房間角色為未在場而先建立的)大廳列衝突到 activeCharacterId 唯一索引。
async leaveSpecificRoom(characterId: string, roomId: string, userId: string, now?: Date, leaveReason = "LEFT_ROOM"): Promise<void> {
const effectiveNow = now ?? this.clock.now();
const lobby = await this.getOrCreateLobby(userId);
if (roomId === lobby.id) return;
const currentInRoom = await this.prisma.client.roomMember.findFirst({ where: { roomId, characterId, leftAt: null } });
if (!currentInRoom) return; // 幂等:已經不在這個房間了
await this.prisma.client.$transaction(async (tx) => {
await tx.roomMember.update({
where: { id: currentInRoom.id },
data: { leftAt: effectiveNow, leaveReason, activeCharacterId: null },
});
const alreadyActiveElsewhere = await tx.roomMember.findFirst({ where: { activeCharacterId: characterId } });
if (!alreadyActiveElsewhere) {
await tx.roomMember.create({ data: { roomId: lobby.id, characterId, activeCharacterId: characterId } });
}
});
this.roomEvents.emit(roomId, {
type: "member",
seq: await this.currentTurnCounter(roomId),
data: { characterId, action: "left" },
});
this.roomEvents.emit(lobby.id, {
type: "member",
seq: await this.currentTurnCounter(lobby.id),
data: { characterId, action: "joined" },
});
}
// 房間關閉時,把仍在場的每一位成員都送回(房主使用者的)大廳。對 LOBBY 房本身不生效
// (大廳永不關閉);對從未有人以 activeCharacterId 在場登記過的房間(例如既有 K 群組
// 自聊/群聊測試房)安全地什麼都不做。
async closeRoomAndReturnMembers(roomId: string, now?: Date): Promise<void> {
const room = await this.prisma.client.room.findUnique({
where: { id: roomId },
include: { members: { where: { activeCharacterId: { not: null } } } },
});
if (!room || room.mode === "LOBBY") return;
for (const member of room.members) {
await this.returnToLobby(member.characterId, room.userId, now, "ROOM_CLOSED");
}
}
// T19-P1/T20-P1 最小可用大廳畫面所需的簡化快照:在場名單+進行中房間清單。
// 正式的完整大廳聚合端點(作品分組、角色狀態、各房未讀數)是 T4(P2),屆時取代本方法的呼叫端。
async getLobbySnapshot(userId: string): Promise<{
lobbyRoomId: string;
roster: string[];
activeRooms: { id: string; mode: string; topicSeed: string | null; memberCharacterIds: string[] }[];
}> {
await this.syncMembership(userId);
const lobby = await this.getOrCreateLobby(userId);
const [lobbyMembers, activeRooms] = await Promise.all([
this.prisma.client.roomMember.findMany({ where: { roomId: lobby.id, leftAt: null } }),
this.prisma.client.room.findMany({
where: { userId, status: "ACTIVE", mode: { not: "LOBBY" } },
include: { members: { where: { leftAt: null } } },
}),
]);
return {
lobbyRoomId: lobby.id,
roster: lobbyMembers.map((m) => m.characterId),
activeRooms: activeRooms.map((room) => ({
id: room.id,
mode: room.mode,
topicSeed: room.topicSeed,
memberCharacterIds: room.members.map((m) => m.characterId),
})),
};
}
}
@@ -1,78 +0,0 @@
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);
}
}
@@ -1,241 +0,0 @@
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); // 套用不改變既有的在場判定機制
});
});
-147
View File
@@ -1,147 +0,0 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { RoomService } from "./room.service.js";
import { SpeakingRightService } from "./speaking-right.service.js";
import { RoomGenerationService } from "./room-generation.service.js";
import { LobbyService } from "./lobby.service.js";
export interface PossessionStatus {
possessingCharacterId: string | null;
}
// R8/T43 人格套用:使用者套用某個角色的身分——她的自走暫停(見 SpeakingRightService/
// SelfChatService 對 getPossessedCharacterIds() 的排除邏輯),改由使用者輸入產生她的
// RoomTurn;其他角色不知情,一律當她本人在場反應(沿用既有 RoomGenerationService,不特殊處理);
// 記憶/關係變化照常記在她名下(沿用既有的以 RoomTurn 為底的固化機制,未特殊處理)。
@Injectable()
export class PossessionService {
constructor(
private readonly prisma: PrismaService,
private readonly room: RoomService,
private readonly speakingRight: SpeakingRightService,
private readonly generation: RoomGenerationService,
private readonly roomLobby: LobbyService,
) {}
async getStatus(userId: string): Promise<PossessionStatus> {
const user = await this.prisma.client.user.findUniqueOrThrow({ where: { id: userId } });
return { possessingCharacterId: user.possessingCharacterId };
}
// 所有「自走/自動回覆」的候選者篩選都要排除目前被(任何使用者)套用中的角色——她的發言只能
// 來自套用者的輸入,不能被 LLM/自走機制搶著幫她講話。
async getPossessedCharacterIds(): Promise<Set<string>> {
const users = await this.prisma.client.user.findMany({
where: { possessingCharacterId: { not: null } },
select: { possessingCharacterId: true },
});
return new Set(users.map((u) => u.possessingCharacterId!));
}
async assertNotPossessing(userId: string): Promise<void> {
const status = await this.getStatus(userId);
if (status.possessingCharacterId) {
throw new BadRequestException("套用角色期間不可以自己的身分發話,請先退出套用");
}
}
// 可套用範圍為任何角色(含日後談、跨作品、buildStatus 未完成者),不設門檻;直接切換到新角色
// 等同先退出舊的再套用新的(單一使用者一次只能套用一位)。
async enter(userId: string, characterId: string, now: Date = new Date()): Promise<PossessionStatus> {
await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
const alreadyPossessedBy = await this.prisma.client.user.findFirst({
where: { possessingCharacterId: characterId, id: { not: userId } },
});
if (alreadyPossessedBy) {
throw new BadRequestException("這個角色目前已被其他使用者套用中");
}
await this.prisma.client.user.update({ where: { id: userId }, data: { possessingCharacterId: characterId } });
void now; // 保留參數對齊其餘服務簽名慣例(時鐘可控),本方法本身不需要用到時間
return { possessingCharacterId: characterId };
}
async exit(userId: string): Promise<PossessionStatus> {
await this.prisma.client.user.update({ where: { id: userId }, data: { possessingCharacterId: null } });
return { possessingCharacterId: null };
}
// 使用者以套用中的角色身分在某個房間發話——沿用既有 RoomController.message() 對「一輪發言後
// 誰會回應」的處理方式(decideSpeakers 找出想回應的其他在場角色逐位生成回覆),差別只在觸發者
// 是她(characterId)而不是 USER_SPEAKER_ID;不能用在 SOLO/LOBBY 房——那兩種房型的「對象」
// 就是使用者本人,套用她之後沒有其他人可以對話。
async speakAsPossessed(
userId: string,
roomId: string,
text: string,
now: Date = new Date(),
): Promise<{ speakers: { characterId: string; text: string; score: number }[] }> {
const status = await this.getStatus(userId);
const characterId = status.possessingCharacterId;
if (!characterId) {
throw new BadRequestException("目前沒有套用任何角色,無法以角色身分發話");
}
let room = await this.room.get(roomId);
if (room.mode === "SOLO") {
throw new BadRequestException("SOLO 房沒有其他角色可以對話,不能在這裡以套用角色的身分發話");
}
if (!room.participants.has(characterId)) {
throw new NotFoundException("套用中的角色目前不在這個房間");
}
const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId } });
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
const speakerLabel = alias?.nickname || alias?.formalName || character.id;
await this.room.addTurn(roomId, characterId, speakerLabel, text, now);
room = await this.room.get(roomId);
const possessedIds = await this.getPossessedCharacterIds();
const speakers = await this.speakingRight.decideSpeakers(room, characterId, text, now, possessedIds);
const replies: { characterId: string; text: string; score: number }[] = [];
for (const candidate of speakers) {
const reactingCharacter = await this.prisma.client.character.findUniqueOrThrow({ where: { id: candidate.characterId } });
const reactingAlias = await this.prisma.client.characterAlias.findFirst({ where: { characterId: candidate.characterId } });
const reactingLabel = reactingAlias?.nickname || reactingAlias?.formalName || reactingCharacter.id;
const reply = await this.generation.generateReply(room, candidate.characterId, characterId, speakerLabel, text, { now });
if (reply.text) {
const turn = await this.room.addTurn(roomId, candidate.characterId, reactingLabel, reply.text, now);
if (reply.usage) {
await this.room.recordUsage(roomId, { ...reply.usage, turnSeq: turn.turnIndex });
}
room = await this.room.get(roomId);
replies.push({ characterId: candidate.characterId, text: reply.text, score: candidate.score });
}
}
return { speakers: replies };
}
// T5 大廳呼叫時識別到的角色目前被套用中:不能讓人格系統代她判斷接受/婉拒,記一筆待決給套用者。
// 待決的歸戶對象是「目前正在套用她的那個使用者」,不是打這通呼叫的使用者——兩者不一定相同
// (呼叫者甚至可能不知道對方正被套用),故這裡自己查出套用者,不接受呼叫端傳入的 userId。
async createPendingCall(characterId: string, text: string, now: Date = new Date()): Promise<void> {
const possessor = await this.prisma.client.user.findFirstOrThrow({ where: { possessingCharacterId: characterId } });
await this.prisma.client.pendingLobbyCall.create({ data: { userId: possessor.id, characterId, text, createdAt: now } });
}
async listPendingCalls(userId: string) {
const status = await this.getStatus(userId);
if (!status.possessingCharacterId) return [];
return this.prisma.client.pendingLobbyCall.findMany({
where: { userId, characterId: status.possessingCharacterId },
orderBy: { createdAt: "asc" },
});
}
async decidePendingCall(pendingCallId: string, accept: boolean, now: Date = new Date()) {
const pending = await this.prisma.client.pendingLobbyCall.findUniqueOrThrow({ where: { id: pendingCallId } });
await this.prisma.client.pendingLobbyCall.delete({ where: { id: pendingCallId } });
if (!accept) return { roomId: null };
// 與一般呼叫接受時的入房方式一致(見 CallService.openRoom()):開一間 SOLO 房,再透過既有的
// LobbyService.enterRoom() 登記在場(會自動處理「原本在哪個房間」的離場轉移)。
const room = await this.room.create(pending.userId, [], "SOLO", pending.text);
await this.roomLobby.enterRoom(pending.characterId, room.id, now);
return { roomId: room.id };
}
}
-174
View File
@@ -1,174 +0,0 @@
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { createPrismaClient } from "@kokorone/db";
import { afterEach, describe, expect, it } from "vitest";
import type { PrismaService } from "../prisma/prisma.service.js";
import { EmotionService } from "../emotion/emotion.service.js";
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
import { RelationshipService } from "../relationship/relationship.service.js";
import { RomanceService } from "../romance/romance.service.js";
import { RomanceEligibilityService } from "../romance/romance-eligibility.service.js";
import { AgeDeterminationService } from "../romance/age-determination.service.js";
import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js";
import { MockProvider } from "../llm/mock-provider.service.js";
import { GlobalPauseService } from "../llm/global-pause.service.js";
import { LobbyService } from "./lobby.service.js";
import { RoomEventsService } from "./room-events.service.js";
import { RoomService, USER_SPEAKER_ID } from "./room.service.js";
import { CharacterRelationshipService } from "./character-relationship.service.js";
import { RoomGenerationService } from "./room-generation.service.js";
import { RoomConsolidationService } from "./room-consolidation.service.js";
import { ControllableClock } from "../clock/controllable-clock.js";
import { SLIP_INTIMACY_PENALTY } from "./constants.js";
// T40 驗收:K-3 隱私邊界不再是沒人寫入的空殼——一般原型在群聊中不會洩漏私密(1:1)記憶;
// 口風不緊原型(天然呆)偶爾會洩漏,一旦洩漏視為真實事件,對使用者的親密度給予懲罰。
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
const prisma = { client: createPrismaClient() } as PrismaService;
const clock = new ControllableClock("2026-08-19T05:00:00.000Z");
const roomEvents = new RoomEventsService(new EventEmitter2());
const lobbyService = new LobbyService(prisma, clock, roomEvents);
const roomService = new RoomService(prisma, clock, lobbyService, roomEvents);
const emotionService = new EmotionService(prisma, new RuleBasedEmotionTagger());
const relationshipService = new RelationshipService(prisma, new EventEmitter2());
const retriever = new KeywordMemoryRetriever(prisma);
const characterRelationship = new CharacterRelationshipService(prisma);
const romanceService = new RomanceService(prisma, relationshipService, new RomanceEligibilityService(prisma, new AgeDeterminationService(prisma)));
const mockProvider = new MockProvider();
const generation = new RoomGenerationService(
prisma,
emotionService,
relationshipService,
retriever,
characterRelationship,
romanceService,
mockProvider,
new GlobalPauseService(),
);
const consolidation = new RoomConsolidationService(prisma, roomService, new RuleBasedEmotionTagger(), characterRelationship);
const userId = "test-privacy-user";
const CHAR_DISCREET = "test-privacy-char-discreet"; // 元氣:非口風不緊
const CHAR_LOOSE = "test-privacy-char-loose"; // 天然呆:口風不緊
const CHAR_OTHER = "test-privacy-char-other"; // 房內第三人,純粹製造「有旁人在場」的群聊情境
afterEach(async () => {
await prisma.client.episodicMemory.deleteMany({
where: { characterId: { in: [CHAR_DISCREET, CHAR_LOOSE, CHAR_OTHER] } },
});
await prisma.client.characterRelationship.deleteMany({
where: { characterId: { in: [CHAR_DISCREET, CHAR_LOOSE, CHAR_OTHER] } },
});
await prisma.client.relationship.deleteMany({ where: { userId } });
await prisma.client.room.deleteMany({ where: { userId } });
await prisma.client.character.deleteMany({ where: { id: { in: [CHAR_DISCREET, CHAR_LOOSE, CHAR_OTHER] } } });
await prisma.client.user.deleteMany({ where: { id: userId } });
});
async function createCharacter(id: string, archetype: string, formalName: string) {
await prisma.client.character.create({
data: {
id,
source: "ORIGINAL",
buildStatus: "BUILT",
basicInfo: "測試",
backgroundStory: "測試",
personalityArchetype: archetype,
likesDislikes: "測試",
goalsObsessions: "測試",
speechStyle: "測試",
aliases: { create: { formalName } },
},
});
}
async function seedPrivateMemory(characterId: string) {
await prisma.client.episodicMemory.create({
data: {
characterId,
content: "秘密基地的事只有我們兩個知道",
occurredAt: clock.now(),
emotionTag: "JOY",
emotionIntensity: 0.9,
source: "INTERACTION",
weight: 1.9,
isPrivate: true,
},
});
}
describe("隱私邊界與口風不緊(T40)", () => {
it("一般原型(非口風不緊)在群聊生成中不會檢索到私密記憶,也不觸發懲罰", async () => {
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
await createCharacter(CHAR_DISCREET, "元氣", "小甲");
await createCharacter(CHAR_OTHER, "元氣", "小丙");
await seedPrivateMemory(CHAR_DISCREET);
await relationshipService.getOrCreate(CHAR_DISCREET, userId, clock.now());
await prisma.client.relationship.update({
where: { characterId_userId: { characterId: CHAR_DISCREET, userId } },
data: { intimacy: 50 },
});
const room = await roomService.create(userId, [CHAR_DISCREET, CHAR_OTHER], "GROUP");
const reply = await generation.generateReply(room, CHAR_DISCREET, USER_SPEAKER_ID, "使用者", "秘密基地的事", {
now: clock.now(),
});
expect(reply.text).toBeTruthy();
const { relationship } = await relationshipService.getState(CHAR_DISCREET, userId, clock.now());
expect(relationship.intimacy).toBe(50); // 未洩漏私密記憶,親密度不受影響
}, 20000);
it("口風不緊原型可能在群聊中洩漏私密記憶,洩漏時對使用者的親密度給予懲罰", async () => {
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
await createCharacter(CHAR_LOOSE, "天然呆", "小乙");
await createCharacter(CHAR_OTHER, "元氣", "小丙");
await seedPrivateMemory(CHAR_LOOSE);
await relationshipService.getOrCreate(CHAR_LOOSE, userId, clock.now());
await prisma.client.relationship.update({
where: { characterId_userId: { characterId: CHAR_LOOSE, userId } },
data: { intimacy: 50 },
});
const room = await roomService.create(userId, [CHAR_LOOSE, CHAR_OTHER], "GROUP");
const reply = await generation.generateReply(room, CHAR_LOOSE, USER_SPEAKER_ID, "使用者", "秘密基地的事", {
now: clock.now(),
});
expect(reply.text).toBeTruthy();
const { relationship } = await relationshipService.getState(CHAR_LOOSE, userId, clock.now());
expect(relationship.intimacy).toBe(50 + SLIP_INTIMACY_PENALTY); // 說溜嘴:親密度扣分
}, 20000);
it("K-5 各自視角固化:同一場群聊,不同角色各自萃取出的記憶內容與權重不同", async () => {
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
await createCharacter(CHAR_DISCREET, "元氣", "小甲");
await createCharacter(CHAR_LOOSE, "天然呆", "小乙");
const room = await roomService.create(userId, [CHAR_DISCREET, CHAR_LOOSE], "GROUP");
// 「好可愛」觸發高情緒(PRAISE,強度剛好達門檻 0.6):小甲自己說的話因情緒夠強而留存;
// 小乙被提到(aboutMe)則無論情緒強度一律留存,且權重比小甲自己主觀敘述更高。
await roomService.addTurn(room.id, CHAR_DISCREET, "小甲", "小乙你好可愛,喜歡你啦!!!", clock.now());
await consolidation.consolidate(room.id);
const memoriesDiscreet = await prisma.client.episodicMemory.findMany({ where: { characterId: CHAR_DISCREET } });
const memoriesLoose = await prisma.client.episodicMemory.findMany({ where: { characterId: CHAR_LOOSE } });
expect(memoriesDiscreet.length).toBeGreaterThan(0);
expect(memoriesLoose.length).toBeGreaterThan(0);
// 小乙被小甲提到(虧她可愛)——小乙記得這件事是「被說到自己」,權重比小甲自己主觀的敘述更高。
const looseAboutSelf = memoriesLoose.find((m) => m.content.includes("小乙你好可愛"));
const discreetOwnLine = memoriesDiscreet.find((m) => m.content.includes("小乙你好可愛"));
expect(looseAboutSelf).toBeTruthy();
expect(discreetOwnLine).toBeTruthy();
expect(looseAboutSelf!.weight).toBeGreaterThan(discreetOwnLine!.weight);
// 群聊固化的記憶並非來自 1:1,不應標記為私密。
expect(memoriesDiscreet.every((m) => m.isPrivate === false)).toBe(true);
expect(memoriesLoose.every((m) => m.isPrivate === false)).toBe(true);
}, 20000);
});
@@ -3,7 +3,6 @@ import { PrismaService } from "../prisma/prisma.service.js";
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js"; import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
import { HIGH_EMOTION_THRESHOLD } from "../memory/constants.js"; import { HIGH_EMOTION_THRESHOLD } from "../memory/constants.js";
import { RoomService, USER_SPEAKER_ID, type Room, type RoomTurnRecord } from "./room.service.js"; import { RoomService, USER_SPEAKER_ID, type Room, type RoomTurnRecord } from "./room.service.js";
import { CharacterRelationshipService } from "./character-relationship.service.js";
async function isAboutCharacter( async function isAboutCharacter(
prisma: PrismaService, prisma: PrismaService,
@@ -27,29 +26,15 @@ export class RoomConsolidationService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly room: RoomService, private readonly room: RoomService,
private readonly tagger: RuleBasedEmotionTagger, private readonly tagger: RuleBasedEmotionTagger,
private readonly characterRelationship: CharacterRelationshipService,
) {} ) {}
async consolidate(roomId: string): Promise<void> { async consolidate(roomId: string): Promise<void> {
const room = await this.room.get(roomId); const room = this.room.get(roomId);
for (const characterId of room.participants.keys()) { for (const characterId of room.participants.keys()) {
await this.consolidateForCharacter(room, characterId); await this.consolidateForCharacter(room, characterId);
} }
await this.accumulateRelationships(room); this.room.end(roomId);
await this.room.end(roomId); this.room.clear(roomId);
await this.room.clear(roomId);
}
// T28:初次同房從中性關係開始、之後每次固化都朝更熟悉一點小幅累積——對每一對「這場都有真的
// 發言過」的角色(不含使用者本人),雙向各累積一次;跨作品/同作品一視同仁。
private async accumulateRelationships(room: Room): Promise<void> {
const speakerIds = [...new Set(room.turns.map((turn) => turn.speakerId).filter((id) => id !== USER_SPEAKER_ID))];
for (let i = 0; i < speakerIds.length; i++) {
for (let j = i + 1; j < speakerIds.length; j++) {
await this.characterRelationship.recordInteraction(speakerIds[i], speakerIds[j]);
await this.characterRelationship.recordInteraction(speakerIds[j], speakerIds[i]);
}
}
} }
private async consolidateForCharacter(room: Room, characterId: string): Promise<void> { private async consolidateForCharacter(room: Room, characterId: string): Promise<void> {
@@ -1,34 +0,0 @@
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);
});
});
-34
View File
@@ -1,34 +0,0 @@
import { Injectable } from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
// T10:房間 SSE 推送的即時(live)事件匯流排。以 RoomTurn.seq 為事件序號空間——
// turn 事件用真正的 RoomTurn.seq(可經 GET /room/:roomId 的分頁機制完整回補,見 getTurnsSince);
// member/closed 事件用當下的 turnCounter(單調遞增、與 turn 事件同一號碼軸),但這兩類事件本身
// 不落地成 RoomTurn,斷線期間錯過的 member/closed 屬於盡力而為通知,不保證重連後補送——
// 重連後呼叫端可另外呼叫 GET /room/:roomId 取得當下最新的在場名單與房間狀態以校正。
export type RoomStreamEventType = "turn" | "member" | "closed";
export interface RoomStreamEvent {
type: RoomStreamEventType;
seq: number;
data: Record<string, unknown>;
}
@Injectable()
export class RoomEventsService {
constructor(private readonly emitter: EventEmitter2) {}
private channel(roomId: string): string {
return `room-stream.${roomId}`;
}
emit(roomId: string, event: RoomStreamEvent): void {
this.emitter.emit(this.channel(roomId), event);
}
subscribe(roomId: string, handler: (event: RoomStreamEvent) => void): () => void {
const channel = this.channel(roomId);
this.emitter.on(channel, handler);
return () => this.emitter.off(channel, handler);
}
}
@@ -1,81 +0,0 @@
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { afterEach, describe, expect, it } from "vitest";
import { createPrismaClient } from "@kokorone/db";
import type { PrismaService } from "../prisma/prisma.service.js";
import { ControllableClock } from "../clock/controllable-clock.js";
import { EmotionService } from "../emotion/emotion.service.js";
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
import { RelationshipService } from "../relationship/relationship.service.js";
import { RomanceService } from "../romance/romance.service.js";
import { RomanceEligibilityService } from "../romance/romance-eligibility.service.js";
import { AgeDeterminationService } from "../romance/age-determination.service.js";
import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js";
import { MockProvider } from "../llm/mock-provider.service.js";
import { GlobalPauseService } from "../llm/global-pause.service.js";
import { LobbyService } from "./lobby.service.js";
import { RoomEventsService } from "./room-events.service.js";
import { RoomService, USER_SPEAKER_ID } from "./room.service.js";
import { CharacterRelationshipService } from "./character-relationship.service.js";
import { RoomGenerationService } from "./room-generation.service.js";
// T9 回歸測試:generateReply() 必須把 Provider 回傳的 usage 原樣往上傳,不可在組裝 RoomReply 時遺漏
// (曾發生過:回傳字面量只挑了 text/actions/address,漏了 usage,導致 GROUP 房用量永遠是 0)。
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`;
const prisma = { client: createPrismaClient() } as PrismaService;
const clock = new ControllableClock("2026-08-19T12:00:00.000Z");
const roomEvents = new RoomEventsService(new EventEmitter2());
const lobbyService = new LobbyService(prisma, clock, roomEvents);
const roomService = new RoomService(prisma, clock, lobbyService, roomEvents);
const emotionService = new EmotionService(prisma, new RuleBasedEmotionTagger());
const relationshipService = new RelationshipService(prisma, new EventEmitter2());
const retriever = new KeywordMemoryRetriever(prisma);
const characterRelationship = new CharacterRelationshipService(prisma);
const romanceService = new RomanceService(prisma, relationshipService, new RomanceEligibilityService(prisma, new AgeDeterminationService(prisma)));
const mockProvider = new MockProvider();
const generation = new RoomGenerationService(prisma, emotionService, relationshipService, retriever, characterRelationship, romanceService, mockProvider, new GlobalPauseService());
const userId = "test-room-gen-usage-user";
const characterId = "test-room-gen-usage-char";
afterEach(async () => {
await prisma.client.room.deleteMany({ where: { userId } });
await prisma.client.relationship.deleteMany({ where: { characterId, userId } });
await prisma.client.character.deleteMany({ where: { id: characterId } });
await prisma.client.user.deleteMany({ where: { id: userId } });
});
describe("RoomGenerationService.generateReply(T9 回歸)", () => {
it("回傳的 RoomReply 帶有 Provider 提供的 usage,可直接交給 RoomService.recordUsage", async () => {
await prisma.client.user.create({ data: { id: userId, name: "測試使用者" } });
await prisma.client.character.create({
data: {
id: characterId,
source: "ORIGINAL",
buildStatus: "BUILT",
basicInfo: "測試",
backgroundStory: "測試",
personalityArchetype: "元氣",
likesDislikes: "測試",
goalsObsessions: "測試",
speechStyle: "測試",
},
});
const room = await roomService.create(userId, [characterId], "GROUP");
const reply = await generation.generateReply(room, characterId, USER_SPEAKER_ID, "測試使用者", "哈囉", { now: clock.now() });
expect(reply.usage).toBeDefined();
expect(reply.usage!.model).toBe("mock");
expect(reply.usage!.promptTokens).toBeGreaterThan(0);
const turn = await roomService.addTurn(room.id, characterId, "測試角色", reply.text, clock.now());
await roomService.recordUsage(room.id, { ...reply.usage!, turnSeq: turn.turnIndex });
const summary = await roomService.getUsageSummary(room.id);
expect(summary.generationCount).toBe(1);
expect(summary.totalPromptTokens).toBeGreaterThan(0);
});
});
+3 -48
View File
@@ -3,21 +3,16 @@ import { PrismaService } from "../prisma/prisma.service.js";
import { EmotionService, dominantState } from "../emotion/emotion.service.js"; import { EmotionService, dominantState } from "../emotion/emotion.service.js";
import { toResponseStyle } from "../emotion/response-style.js"; import { toResponseStyle } from "../emotion/response-style.js";
import { RelationshipService } from "../relationship/relationship.service.js"; import { RelationshipService } from "../relationship/relationship.service.js";
import { RomanceService } from "../romance/romance.service.js";
import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js"; import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js";
import { LLM_PROVIDER, type LLMProvider } from "../llm/llm-provider.js"; import { LLM_PROVIDER, type LLMProvider } from "../llm/llm-provider.js";
import { hashToSeed } from "../llm/seeded-random.js"; import { hashToSeed } from "../llm/seeded-random.js";
import type { GenerationContext, GeneratedResponse } from "../llm/types.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 { getArchetypeParams } from "../personality/archetype-params.js";
import { resolveAddress } from "../personality/language-style.js"; import { resolveAddress } from "../personality/language-style.js";
import { stageForIntimacy } from "../relationship/constants.js"; import { stageForIntimacy } from "../relationship/constants.js";
import { CharacterRelationshipService } from "./character-relationship.service.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 { USER_SPEAKER_ID, type Room } from "./room.service.js";
import { GROUP_RESERVE_INTIMACY_PENALTY, SLIP_INTIMACY_PENALTY, TSUNDERE_EXTRA_GROUP_PENALTY } from "./constants.js"; import { GROUP_RESERVE_INTIMACY_PENALTY, TSUNDERE_EXTRA_GROUP_PENALTY } from "./constants.js";
import { computeWitnessReaction, isRomanticMoment } from "./witness-reaction.js";
export interface RoomReplyOptions { export interface RoomReplyOptions {
seed?: number; seed?: number;
@@ -37,9 +32,7 @@ export class RoomGenerationService {
private readonly relationship: RelationshipService, private readonly relationship: RelationshipService,
private readonly retriever: KeywordMemoryRetriever, private readonly retriever: KeywordMemoryRetriever,
private readonly characterRelationship: CharacterRelationshipService, private readonly characterRelationship: CharacterRelationshipService,
private readonly romance: RomanceService,
@Inject(LLM_PROVIDER) private readonly provider: LLMProvider, @Inject(LLM_PROVIDER) private readonly provider: LLMProvider,
private readonly globalPause: GlobalPauseService,
) {} ) {}
async generateReply( async generateReply(
@@ -62,41 +55,15 @@ export class RoomGenerationService {
let baseIntimacy: number; let baseIntimacy: number;
let trust: number; let trust: number;
let address: string; let address: string;
let crossWorldNote: string | undefined;
if (speakerId === USER_SPEAKER_ID) { if (speakerId === USER_SPEAKER_ID) {
const { relationship } = await this.relationship.getState(characterId, room.userId, now); const { relationship } = await this.relationship.getState(characterId, room.userId, now);
baseIntimacy = relationship.intimacy; baseIntimacy = relationship.intimacy;
trust = relationship.trust; trust = relationship.trust;
address = resolveAddress(speakerLabel, relationship.intimacy); 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 { } else {
baseIntimacy = await this.characterRelationship.getAffinity(characterId, speakerId); baseIntimacy = await this.characterRelationship.getAffinity(characterId, speakerId);
trust = baseIntimacy; trust = baseIntimacy;
address = speakerLabel; 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)自然選到更收斂的回應; // K-2 群聊矜持:實際餵給生成的親密度下修,讓既有的模板分層(intimacyTier)自然選到更收斂的回應;
@@ -114,12 +81,6 @@ export class RoomGenerationService {
excludePrivate, 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) => ({ const history = room.turns.slice(-10).map((turn) => ({
role: (turn.speakerId === USER_SPEAKER_ID ? "user" : "character") as "user" | "character", role: (turn.speakerId === USER_SPEAKER_ID ? "user" : "character") as "user" | "character",
content: `${turn.speakerLabel}:${turn.content}`, content: `${turn.speakerLabel}:${turn.content}`,
@@ -143,15 +104,9 @@ export class RoomGenerationService {
userInput, userInput,
injectedMemoryIds: retrievedMemories.map((memory) => memory.id), injectedMemoryIds: retrievedMemories.map((memory) => memory.id),
seed, seed,
crossWorldNote,
}; };
// T30:全域靜默暫停或重試全部失敗時回傳 null——這裡回傳空字串,呼叫端(room.controller.ts/ const generated = await this.provider.generate(context);
// self-chat.service.ts)一律以「reply.text 是否有內容」決定要不要寫入 RoomTurn,不需要另外判斷。 return { text: `${address},${generated.text}`, actions: generated.actions, address };
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 };
} }
} }
@@ -1,140 +0,0 @@
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 } });
});
});
-159
View File
@@ -1,159 +0,0 @@
import { Injectable } from "@nestjs/common";
import { log } from "@kokorone/shared";
import { PrismaService } from "../prisma/prisma.service.js";
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
import { ScheduleService } from "../schedule/schedule.service.js";
import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js";
import { SleepDebtService, SLEEP_DEBT_ACTIVE_THRESHOLD_MINUTES } from "../schedule/sleep-debt.service.js";
import { EMOTION_SPEAKING_MODIFIER, ROOM_ADVANCE_INTERVAL_LOWER_MS, ROOM_ADVANCE_INTERVAL_UPPER_MS, STALL_CHECK_WINDOW } from "./constants.js";
import { isStalling } from "./stall-detection.js";
import type { Room } from "./room.service.js";
export interface IntervalFactors {
atmosphere: number; // 0~1:近期發言密度、互相點名、空轉判定
emotion: number; // 0~1:在場角色情緒對發言意願的影響
memory: number; // 0~1:目前話題是否命中既有記憶
behavior: number; // 0~1:行程狀態與既有行為規則的活躍程度
}
export interface IntervalResult {
intervalMs: number;
heatScore: number; // 0~1,四項因子加權後的熱度
factors: IntervalFactors;
silent: boolean; // T22:靜默 tick(沒有實際產生 RoomTurn)一律貼上限,不計入熱度計算
}
const FACTOR_WEIGHTS = { atmosphere: 0.35, emotion: 0.25, memory: 0.2, behavior: 0.2 };
// 發言密度的參考間隔:近期發言平均間隔 ≦ 此值視為「很熱絡」(density 分數趨近 1)。
const DENSITY_REFERENCE_GAP_MS = 2 * 60_000;
const RECENT_TURN_WINDOW = 5;
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
function densityScore(timestamps: Date[]): number {
if (timestamps.length < 2) return 0;
const window = timestamps.slice(-RECENT_TURN_WINDOW);
const gaps: number[] = [];
for (let i = 1; i < window.length; i++) {
gaps.push(window[i].getTime() - window[i - 1].getTime());
}
const avgGap = gaps.reduce((sum, gap) => sum + gap, 0) / gaps.length;
return clamp01(1 - avgGap / DENSITY_REFERENCE_GAP_MS);
}
function mutualNamingScore(recentTurns: { speakerLabel: string; content: string }[], presentLabels: string[]): number {
if (recentTurns.length === 0 || presentLabels.length < 2) return 0;
const hits = recentTurns.filter((turn) => presentLabels.some((label) => label !== turn.speakerLabel && turn.content.includes(label))).length;
return clamp01(hits / recentTurns.length);
}
// T22:把推進間隔改為每輪重算——房內氣氛(發言密度+互相點名,空轉判定會直接歸零)、在場角色情緒、
// 話題是否命中既有記憶、行程狀態與既有行為規則四項因子算出熱度分數,再以單調遞減映射換成間隔並夾在
// 上下限之間;計算全程不參考 SSE 連線數與用量(見各因子計算內容,皆與這兩者無關)。
@Injectable()
export class RoomIntervalService {
constructor(
private readonly prisma: PrismaService,
private readonly emotion: EmotionService,
private readonly schedule: ScheduleService,
private readonly retriever: KeywordMemoryRetriever,
private readonly sleepDebt: SleepDebtService,
) {}
// 靜默 tick(本輪沒有產生任何 RoomTurn,例如沒有角色被判定該發言):不計入熱度計算,間隔直接貼上限。
silentInterval(roomId: string): IntervalResult {
const result: IntervalResult = {
intervalMs: ROOM_ADVANCE_INTERVAL_UPPER_MS,
heatScore: 0,
factors: { atmosphere: 0, emotion: 0, memory: 0, behavior: 0 },
silent: true,
};
log("自走間隔計算", "INF", `房間 ${roomId} 本輪靜默(無新 RoomTurn),間隔貼上限 ${result.intervalMs}ms`);
return result;
}
async computeInterval(room: Room, now: Date = new Date()): Promise<IntervalResult> {
const presentCharacterIds = [...room.participants.keys()];
const recentTurns = room.turns.slice(-RECENT_TURN_WINDOW);
const recentContents = recentTurns.map((turn) => turn.content);
const atmosphere = isStalling(room.turns.slice(-STALL_CHECK_WINDOW).map((turn) => turn.content))
? 0
: clamp01(
densityScore(recentTurns.map((turn) => turn.timestamp)) * 0.7 +
mutualNamingScore(recentTurns, recentTurns.map((turn) => turn.speakerLabel)) * 0.3,
);
const emotion = await this.computeEmotionFactor(presentCharacterIds, now);
const memory = await this.computeMemoryFactor(presentCharacterIds, recentContents);
const behavior = await this.computeBehaviorFactor(presentCharacterIds, now);
const factors: IntervalFactors = { atmosphere, emotion, memory, behavior };
const heatScore = clamp01(
factors.atmosphere * FACTOR_WEIGHTS.atmosphere +
factors.emotion * FACTOR_WEIGHTS.emotion +
factors.memory * FACTOR_WEIGHTS.memory +
factors.behavior * FACTOR_WEIGHTS.behavior,
);
// 單調遞減映射:熱度 1 → 下限,熱度 0 → 上限。
const intervalMs = Math.round(
ROOM_ADVANCE_INTERVAL_LOWER_MS + (ROOM_ADVANCE_INTERVAL_UPPER_MS - ROOM_ADVANCE_INTERVAL_LOWER_MS) * (1 - heatScore),
);
log(
"自走間隔計算",
"INF",
`房間 ${room.id} 熱度=${heatScore.toFixed(2)}(氣氛=${atmosphere.toFixed(2)}、情緒=${emotion.toFixed(2)}、記憶=${memory.toFixed(2)}、行為=${behavior.toFixed(2)})→ 間隔 ${intervalMs}ms`,
);
return { intervalMs, heatScore, factors, silent: false };
}
private async computeEmotionFactor(characterIds: string[], now: Date): Promise<number> {
if (characterIds.length === 0) return 0;
const modifiers = await Promise.all(
characterIds.map(async (characterId) => {
const state = await this.emotion.getState(characterId, now);
return EMOTION_SPEAKING_MODIFIER[dominantState(state)];
}),
);
const avg = modifiers.reduce((sum, modifier) => sum + modifier, 0) / modifiers.length;
// EMOTION_SPEAKING_MODIFIER 範圍約 -0.3~0.3,正規化到 0~1。
return clamp01((avg + 0.3) / 0.6);
}
private async computeMemoryFactor(characterIds: string[], recentContents: string[]): Promise<number> {
if (characterIds.length === 0 || recentContents.length === 0) return 0;
const query = recentContents.join(" ");
const hits = await Promise.all(
characterIds.map(async (characterId) => {
const memories = await this.retriever.retrieve(characterId, query, { limit: 1 });
return memories.length > 0 ? 1 : 0;
}),
);
return clamp01(hits.reduce<number>((sum, hit) => sum + hit, 0) / characterIds.length);
}
private async computeBehaviorFactor(characterIds: string[], now: Date): Promise<number> {
if (characterIds.length === 0) return 0;
const perCharacter = await Promise.all(
characterIds.map(async (characterId) => {
const info = await this.schedule.getInfo(characterId, now);
const scheduleComponent = info.slot?.status === "FREE" ? 1 : info.slot?.status === "BUSY" ? 0.3 : 0;
const rules = await this.prisma.client.proceduralRule.findMany({ where: { characterId } });
const ruleWeightSum = rules.reduce((sum, rule) => sum + rule.weight, 0);
const ruleComponent = clamp01(ruleWeightSum / 5);
// T32:睡眠債活躍時對該角色加懲罰項,拖低房間整體熱度(等同「回覆變慢」)。
const debtMinutes = await this.sleepDebt.getDebtMinutes(characterId);
const sleepDebtPenalty = debtMinutes >= SLEEP_DEBT_ACTIVE_THRESHOLD_MINUTES ? 0.4 : 0;
return clamp01((scheduleComponent + ruleComponent) / 2 - sleepDebtPenalty);
}),
);
return clamp01(perCharacter.reduce((sum, value) => sum + value, 0) / perCharacter.length);
}
}
@@ -1,39 +0,0 @@
import { Injectable } from "@nestjs/common";
import { PushNotificationService } from "../push/push-notification.service.js";
import type { Room } from "./room.service.js";
// T31 靜音與重要事件推播:房間 `muted` 為真時完全不推播(但自走照常進行,不是暫停);
// 閒聊本身一律不推播,只有下面四類事件才會走這裡。第四類(結局偵測待確認)的偵測邏輯本身屬於
// P4「結局偵測」的範疇、尚未實作,這裡先備好可直接呼叫的推播入口——等 P4 該功能落地時直接呼叫
// notifyEndingDetectionPending(),不需要再回頭改動推播模組本身。
@Injectable()
export class RoomNotificationService {
constructor(private readonly push: PushNotificationService) {}
// 角色直接回覆使用者發言(被喊/被點名),非角色之間的自走閒聊。
async notifyMentioned(room: Room, characterLabel: string, content: string): Promise<void> {
await this.notify(room, `${characterLabel}回你了`, content);
}
// 自走過程中所有角色各自離場後,房間自動收尾關閉(非使用者主動按下「結束房間」)。
async notifyRoomClosed(room: Room, reason: string): Promise<void> {
await this.notify(room, "房間已自動關閉", `原因:${reason}`);
}
async notifyDelegateProposed(room: Room, characterLabel: string, taskContent: string): Promise<void> {
await this.notify(room, `${characterLabel}提出了委託`, taskContent);
}
async notifyEndingDetectionPending(userId: string, characterLabel: string): Promise<void> {
await this.push.sendToUser(
userId,
"有一段故事可能走向結局",
`${characterLabel}的故事似乎到了轉折點,要不要去看看、確認一下?`,
);
}
private async notify(room: Room, title: string, body: string): Promise<void> {
if (room.muted) return;
await this.push.sendToUser(room.userId, title, body);
}
}
-199
View File
@@ -1,199 +0,0 @@
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);
});
@@ -1,97 +0,0 @@
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, describe, expect, it } from "vitest";
import { createPrismaClient } from "@kokorone/db";
// T2 驗收:migration 可套用;RoomMember 的 activeCharacterId 唯一索引讓「重複讓同一角色入房」直接被 DB 擋下(R5)。
// 沿用已套用過 migration 的開發用 dev.db,每個案例包在互動式交易內並強制丟出 sentinel 觸發回滾,
// 確保測試資料不會殘留(`prisma migrate deploy` 對全新資料庫套用 16 筆 migration 實測約需 78 秒,
// 不適合每次跑測試都重建資料庫)。
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
const devDbUrl = `file:${join(repoRoot, "prisma", "dev.db")}`;
process.env.DATABASE_URL = devDbUrl;
const prisma = createPrismaClient();
const ROLLBACK = Symbol("rollback");
async function inRolledBackTransaction(fn: (tx: typeof prisma) => Promise<void>): Promise<unknown> {
try {
await prisma.$transaction(async (tx) => {
await fn(tx as typeof prisma);
throw ROLLBACK;
});
} catch (error) {
if (error !== ROLLBACK) throw error;
return undefined;
}
}
afterAll(async () => {
await prisma.$disconnect();
});
describe("Room 持久化 schema(T2)", () => {
it("角色不可同時在場於兩個房間:第二筆 activeCharacterId 會被唯一索引擋下", async () => {
await inRolledBackTransaction(async (tx) => {
const user = await tx.user.create({ data: { name: "測試使用者" } });
const character = await tx.character.create({
data: {
source: "ORIGINAL",
buildStatus: "BUILT",
basicInfo: "測試角色",
backgroundStory: "測試",
personalityArchetype: "TSUNDERE",
likesDislikes: "測試",
goalsObsessions: "測試",
speechStyle: "測試",
},
});
const roomA = await tx.room.create({ data: { userId: user.id, mode: "SOLO" } });
const roomB = await tx.room.create({ data: { userId: user.id, mode: "SOLO" } });
await tx.roomMember.create({
data: { roomId: roomA.id, characterId: character.id, activeCharacterId: character.id },
});
await expect(
tx.roomMember.create({
data: { roomId: roomB.id, characterId: character.id, activeCharacterId: character.id },
}),
).rejects.toThrow();
});
});
it("角色離場(activeCharacterId 設為 null)後可在另一間房重新登記為在場", async () => {
await inRolledBackTransaction(async (tx) => {
const user = await tx.user.create({ data: { name: "測試使用者2" } });
const character = await tx.character.create({
data: {
source: "ORIGINAL",
buildStatus: "BUILT",
basicInfo: "測試角色2",
backgroundStory: "測試",
personalityArchetype: "TSUNDERE",
likesDislikes: "測試",
goalsObsessions: "測試",
speechStyle: "測試",
},
});
const roomA = await tx.room.create({ data: { userId: user.id, mode: "SOLO" } });
const roomB = await tx.room.create({ data: { userId: user.id, mode: "SOLO" } });
const membershipA = await tx.roomMember.create({
data: { roomId: roomA.id, characterId: character.id, activeCharacterId: character.id },
});
await tx.roomMember.update({
where: { id: membershipA.id },
data: { leftAt: new Date(), leaveReason: "ENTER_ROOM", activeCharacterId: null },
});
const membershipB = await tx.roomMember.create({
data: { roomId: roomB.id, characterId: character.id, activeCharacterId: character.id },
});
expect(membershipB.activeCharacterId).toBe(character.id);
});
});
});
@@ -1,142 +0,0 @@
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);
});
@@ -1,78 +0,0 @@
import { Inject, Injectable, OnModuleInit } from "@nestjs/common";
import { log } from "@kokorone/shared";
import { PrismaService } from "../prisma/prisma.service.js";
import { JOB_QUEUE, type JobQueue } from "../memory/job-queue.js";
import { CLOCK, type Clock } from "../clock/clock.js";
import { RoomService } from "./room.service.js";
import { SelfChatService } from "./self-chat.service.js";
import { RoomIntervalService } from "./room-interval.service.js";
import { ROOM_SCHEDULER_TICK_CRON } from "./constants.js";
// T37/T8:推進行程的部署與重啟恢復,且不降頻——排程與 api 同行程(沿用 InProcessJobQueue/node-cron
// 慣例,不等主計畫 R-6 的 BullMQ 化)。啟動時立刻掃一次所有 ACTIVE 房間並依 lastAdvancedAt 判斷是否
// 已到期,到期就推進「一次」讓它接上目前時間——停機期間錯過的推進不補齊(不因為停機 1 小時就
// 補打 120 次),之後交給週期 tick 持續運作。
//
// 前提:單一 api 實例(README 已於部署章節註明),故不需要分散式鎖;本排程器目前查詢量小,
// 沒有導入 Redis 加速的需要,因此也沒有任何硬性 Redis 依賴——「Redis 不可用時退回查庫」對本
// 排程器而言是「本來就只查庫」,不是額外的容錯路徑。
//
// T8:到期判斷改為每房各自呼叫 T22 的 RoomIntervalService 動態算出應有間隔(不再是固定值),
// 完全不參考 SSE 連線數/用量——關掉所有前端連線、沒有任何人在看,房間仍會依這裡算出的節奏持續推進;
// 全員離開的自動關房已由 SelfChatService 內部的 LEAVE/wrapUp(見該檔)處理,本排程器只需照常
// 掃描 status=ACTIVE 的房間,房間關閉後自然不再被掃到。
@Injectable()
export class RoomSchedulerService implements OnModuleInit {
// T18 實測發現:多人房單輪耗時可能超過 tick 週期(12 人房 p95 實測達數十秒,遠超 10 秒 tick),
// 若不擋下,下一次 tick 會在同一房間的 advance() 還沒跑完時又觸發一次,造成同一房間被併發推進兩次
// (turnCounter/發言權計算都假設單一 in-flight advance,併發會產生競態)。用一個記憶體內的
// in-flight 名單擋下:單一 api 實例前提下(見檔頭註解)足夠,房間推進完(成功或失敗)都會移除。
private readonly advancing = new Set<string>();
constructor(
private readonly prisma: PrismaService,
@Inject(JOB_QUEUE) private readonly jobQueue: JobQueue,
@Inject(CLOCK) private readonly clock: Clock,
private readonly room: RoomService,
private readonly selfChat: SelfChatService,
private readonly interval: RoomIntervalService,
) {}
onModuleInit(): void {
this.jobQueue.now("room-scheduler:startup-scan", () => this.scanAndAdvance());
this.jobQueue.every(ROOM_SCHEDULER_TICK_CRON, "room-scheduler:tick", () => this.scanAndAdvance());
}
async scanAndAdvance(): Promise<void> {
const now = this.clock.now();
const candidates = await this.prisma.client.room.findMany({
where: { status: "ACTIVE", mode: "SELF_CHAT", autoAdvanceEnabled: true },
select: { id: true, lastAdvancedAt: true },
});
// 支援多房並行:每間房各自獨立判斷、獨立推進,一間失敗不影響其他房間。
await Promise.all(
candidates.map(async ({ id: roomId, lastAdvancedAt }) => {
// 檢查與標記必須同步、中間不能有 await,否則兩個幾乎同時的 tick 會在彼此標記完成前
// 都通過檢查、一起把同一房間送進 advance()——這正是本段要擋下的併發。
if (this.advancing.has(roomId)) return; // 上一次 advance() 還沒跑完,這次 tick 先跳過。
this.advancing.add(roomId);
try {
if (!(await this.isDue(roomId, lastAdvancedAt, now))) return;
await this.selfChat.advance(roomId, now);
} catch (err) {
log("排程", "ERR", `房間 ${roomId} 自走推進失敗:${err instanceof Error ? err.message : String(err)}`);
} finally {
this.advancing.delete(roomId);
}
}),
);
}
private async isDue(roomId: string, lastAdvancedAt: Date | null, now: Date): Promise<boolean> {
if (!lastAdvancedAt) return true; // 從未推進過,立刻接上。
const room = await this.room.get(roomId);
const { intervalMs } = await this.interval.computeInterval(room, now);
return now.getTime() - lastAdvancedAt.getTime() >= intervalMs;
}
}
-90
View File
@@ -1,90 +0,0 @@
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 } });
}
});
});
-59
View File
@@ -1,59 +0,0 @@
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 逾時偶爾不夠。
});
-65
View File
@@ -1,65 +0,0 @@
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 句");
});
});
+11 -205
View File
@@ -1,6 +1,4 @@
import { Body, Controller, Get, Headers, Param, Post, Query, Sse } from "@nestjs/common"; import { Body, Controller, Get, Param, Post } from "@nestjs/common";
import type { MessageEvent } from "@nestjs/common";
import { Observable } from "rxjs";
import { PrismaService } from "../prisma/prisma.service.js"; import { PrismaService } from "../prisma/prisma.service.js";
import { RoomService, USER_SPEAKER_ID } from "./room.service.js"; import { RoomService, USER_SPEAKER_ID } from "./room.service.js";
import { SpeakingRightService } from "./speaking-right.service.js"; import { SpeakingRightService } from "./speaking-right.service.js";
@@ -8,11 +6,6 @@ import { RoomGenerationService } from "./room-generation.service.js";
import { RoomConsolidationService } from "./room-consolidation.service.js"; import { RoomConsolidationService } from "./room-consolidation.service.js";
import { SelfChatService } from "./self-chat.service.js"; import { SelfChatService } from "./self-chat.service.js";
import { CharacterRelationshipService } from "./character-relationship.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 { interface CreateRoomBody {
userId: string; userId: string;
@@ -21,16 +14,10 @@ interface CreateRoomBody {
topicSeed?: string; topicSeed?: string;
} }
interface CreateSoloRoomBody {
userId: string;
characterId: string;
}
interface MessageBody { interface MessageBody {
userId: string; userId: string;
userLabel?: string; userLabel?: string;
text: string; text: string;
seed?: number;
now?: string; now?: string;
} }
@@ -40,21 +27,6 @@ interface RelationshipBody {
dynamic?: string; dynamic?: string;
} }
interface ReadReceiptBody {
userId: string;
lastReadSeq: number;
}
interface InviteBody {
characterId: string;
invitedByCharacterId: string; // 房內角色邀請:邀請人必須是 :roomId 目前在場的角色
now?: string;
}
interface MuteBody {
muted: boolean;
}
@Controller("room") @Controller("room")
export class RoomController { export class RoomController {
constructor( constructor(
@@ -65,190 +37,39 @@ export class RoomController {
private readonly consolidation: RoomConsolidationService, private readonly consolidation: RoomConsolidationService,
private readonly selfChat: SelfChatService, private readonly selfChat: SelfChatService,
private readonly characterRelationship: CharacterRelationshipService, 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() @Post()
async create(@Body() body: CreateRoomBody) { create(@Body() body: CreateRoomBody) {
const room = await this.room.create(body.userId, body.characterIds, body.mode ?? "GROUP", body.topicSeed); const room = 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 }; 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 驗收:角色(或呼叫端)可取得完整在場名單。 // K-1 驗收:角色(或呼叫端)可取得完整在場名單。
// T12 驗收:支援從指定游標(afterSeq/beforeSeq)分頁取歷程;未帶游標時預設回傳最新一頁,
// 讓重新進入未關閉的房間可以看到先前對話(含 hasMore/nextCursor 供前端接續翻頁)。
// T11 驗收:附上 unread(lastReadSeq/firstUnreadSeq/unreadCount),供前端定位第一則未讀分隔線;
// 不帶 userId 時預設查房主本人(本系統目前為單一使用者情境,房主即唯一讀者)。
@Get(":roomId") @Get(":roomId")
async get( get(@Param("roomId") roomId: string) {
@Param("roomId") roomId: string, const room = this.room.get(roomId);
@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 { return {
id: room.id, id: room.id,
mode: room.mode, mode: room.mode,
status: room.status, status: room.status,
topicSeed: room.topicSeed, topicSeed: room.topicSeed,
muted: room.muted, participants: this.room.roster(roomId),
participants: await this.room.roster(roomId),
turnCount: room.turnCounter, turnCount: room.turnCounter,
turns: page.turns.map((turn) => ({ turns: room.turns.map((turn) => ({ speakerId: turn.speakerId, speakerLabel: turn.speakerLabel, content: turn.content })),
seq: turn.turnIndex,
speakerId: turn.speakerId,
speakerLabel: turn.speakerLabel,
content: turn.content,
kind: turn.kind,
})),
hasMore: page.hasMore,
nextCursor: page.nextCursor,
unread,
usage,
}; };
} }
// T10:SSE 推送。turn/member/closed 三類事件,id 為 RoomTurn.seq(同一號碼軸,見
// RoomEventsService 註解);支援 Last-Event-ID(瀏覽器重連自動帶)與 ?afterSeq= 手動指定,
// 兩者擇一(Last-Event-ID 優先),斷線期間錯過的 turn 一律從 DB 補齊、不遺漏。
@Sse(":roomId/stream")
stream(
@Param("roomId") roomId: string,
@Query("afterSeq") afterSeqQuery?: string,
@Headers("last-event-id") lastEventId?: string,
): Observable<MessageEvent> {
const startAfterSeq = lastEventId !== undefined ? Number(lastEventId) : afterSeqQuery !== undefined ? Number(afterSeqQuery) : -1;
return new Observable<MessageEvent>((subscriber) => {
let lastSentSeq = startAfterSeq;
let replaying = true;
const buffered: RoomStreamEvent[] = [];
const forward = (event: RoomStreamEvent) => {
if (event.seq <= lastSentSeq && event.type === "turn") return; // turn 事件有真正的 seq,避免補播時重複
lastSentSeq = Math.max(lastSentSeq, event.seq);
subscriber.next({ id: String(event.seq), type: event.type, data: event.data });
};
// 先訂閱即時事件(暫存到 replay 查完再送),避免「查 DB 期間」發生的新事件被漏掉。
const unsubscribe = this.roomEvents.subscribe(roomId, (event) => {
if (replaying) {
buffered.push(event);
return;
}
forward(event);
});
this.room
.getTurnsSince(roomId, startAfterSeq)
.then((missed) => {
for (const turn of missed) {
forward({
type: "turn",
seq: turn.turnIndex,
data: { speakerId: turn.speakerId, speakerLabel: turn.speakerLabel, content: turn.content, kind: turn.kind },
});
}
replaying = false;
for (const event of buffered) forward(event);
})
.catch((err) => subscriber.error(err));
const heartbeat = setInterval(() => subscriber.next({ type: "ping", data: {} }), 15000);
return () => {
clearInterval(heartbeat);
unsubscribe();
};
});
}
// T11:回報已讀游標(單調遞增、不可回退)。
@Post(":roomId/read")
async reportRead(@Param("roomId") roomId: string, @Body() body: ReadReceiptBody) {
await this.room.reportRead(roomId, body.userId, body.lastReadSeq);
return this.room.getUnreadInfo(roomId, body.userId);
}
// K-2/K-4 群聊訊息:使用者發言後,依發言權分配決定哪些角色本輪回應,依衝動值高到低依序生成。 // K-2/K-4 群聊訊息:使用者發言後,依發言權分配決定哪些角色本輪回應,依衝動值高到低依序生成。
// T38:SOLO 房走既有 1:1 對話管線(DialogueService,含快速通道/作息可用度/破例規則),
// 回傳形狀與舊版 POST /chat/:characterId 一致,不是 GROUP/SELF_CHAT 的 {speakers:[...]}。
@Post(":roomId/message") @Post(":roomId/message")
async message(@Param("roomId") roomId: string, @Body() body: MessageBody) { 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 now = body.now ? new Date(body.now) : new Date();
const room = this.room.get(roomId);
const userLabel = body.userLabel ?? body.userId; const userLabel = body.userLabel ?? body.userId;
const soloRoomCheck = await this.room.get(roomId); this.room.addTurn(roomId, USER_SPEAKER_ID, userLabel, body.text, now);
if (soloRoomCheck.mode === "SOLO") {
return this.soloChat.sendMessage(roomId, body.userId, body.text, { seed: body.seed, now });
}
// 舊版 room 是共享的可變物件,addTurn 會就地 push/遞增,因此決定發言者與逐位生成時看到的 const speakers = await this.speakingRight.decideSpeakers(room, USER_SPEAKER_ID, body.text, now);
// 都是「加上這一輪最新發言後」的狀態;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 }[] = []; const replies: { characterId: string; text: string; score: number }[] = [];
for (const candidate of speakers) { for (const candidate of speakers) {
const character = await this.prisma.client.character.findUniqueOrThrow({ const character = await this.prisma.client.character.findUniqueOrThrow({
@@ -261,17 +82,9 @@ export class RoomController {
const reply = await this.generation.generateReply(room, candidate.characterId, USER_SPEAKER_ID, userLabel, body.text, { const reply = await this.generation.generateReply(room, candidate.characterId, USER_SPEAKER_ID, userLabel, body.text, {
now, now,
}); });
// T30:全域靜默暫停或重試全部失敗時 reply.text 為空——靜默略過,不寫 RoomTurn、不對使用者 this.room.addTurn(roomId, candidate.characterId, speakerLabel, reply.text, now);
// 顯示任何提示;使用者本人的發話已於上方寫入,不受影響,恢復後的下一次發言會正常繼續。
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 }); replies.push({ characterId: candidate.characterId, text: reply.text, score: candidate.score });
} }
}
return { speakers: replies }; return { speakers: replies };
} }
@@ -287,15 +100,8 @@ export class RoomController {
return this.message(roomId, body); return this.message(roomId, body);
} }
// T38:SOLO 房關房走既有睡眠固化(MemoryConsolidationService,取代舊版 POST /session/:id/end);
// GROUP/SELF_CHAT 房沿用既有多人群聊記憶投影(RoomConsolidationService)。
@Post(":roomId/end") @Post(":roomId/end")
async end(@Param("roomId") roomId: string) { 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); await this.consolidation.consolidate(roomId);
return { ok: true }; return { ok: true };
} }
+3 -45
View File
@@ -4,34 +4,17 @@ import { EmotionModule } from "../emotion/emotion.module.js";
import { RelationshipModule } from "../relationship/relationship.module.js"; import { RelationshipModule } from "../relationship/relationship.module.js";
import { MemoryModule } from "../memory/memory.module.js"; import { MemoryModule } from "../memory/memory.module.js";
import { LlmModule } from "../llm/llm.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 { RoomService } from "./room.service.js";
import { CharacterRelationshipService } from "./character-relationship.service.js"; import { CharacterRelationshipService } from "./character-relationship.service.js";
import { SpeakingRightService } from "./speaking-right.service.js"; import { SpeakingRightService } from "./speaking-right.service.js";
import { RoomGenerationService } from "./room-generation.service.js"; import { RoomGenerationService } from "./room-generation.service.js";
import { RoomConsolidationService } from "./room-consolidation.service.js"; import { RoomConsolidationService } from "./room-consolidation.service.js";
import { SelfChatService } from "./self-chat.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 { 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({ @Module({
imports: [PrismaModule, EmotionModule, RelationshipModule, MemoryModule, LlmModule, ClockModule, ScheduleModule, TaskModule, PersonalityModule, RomanceModule, PushModule, EpilogueModule], imports: [PrismaModule, EmotionModule, RelationshipModule, MemoryModule, LlmModule],
controllers: [RoomController, PossessionController], controllers: [RoomController],
providers: [ providers: [
RoomService, RoomService,
CharacterRelationshipService, CharacterRelationshipService,
@@ -39,32 +22,7 @@ import { PossessionController } from "./possession.controller.js";
RoomGenerationService, RoomGenerationService,
RoomConsolidationService, RoomConsolidationService,
SelfChatService, 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 {} export class RoomModule {}
+60 -283
View File
@@ -1,12 +1,7 @@
import { Inject, Injectable, NotFoundException } from "@nestjs/common"; import { Injectable, NotFoundException } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared"; 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" | "SOLO"; // T38:SOLO 為既有 1:1 聊天併入房間後的房型 export type RoomMode = "GROUP" | "SELF_CHAT";
export type RoomStatus = "ACTIVE" | "ENDED"; export type RoomStatus = "ACTIVE" | "ENDED";
export const USER_SPEAKER_ID = "USER"; export const USER_SPEAKER_ID = "USER";
@@ -21,7 +16,6 @@ export interface RoomTurnRecord {
speakerId: string; // characterId,或使用者固定用 USER_SPEAKER_ID speakerId: string; // characterId,或使用者固定用 USER_SPEAKER_ID
speakerLabel: string; speakerLabel: string;
content: string; content: string;
kind: RoomTurnKind; // T6:TALK/ACTION/DELEGATE/LEAVE/SYSTEM,API 對外契約尚未擴充(見 T19-P3)
emotionTag?: EmotionTag; emotionTag?: EmotionTag;
emotionIntensity?: number; emotionIntensity?: number;
timestamp: Date; timestamp: Date;
@@ -33,320 +27,103 @@ export interface Room {
mode: RoomMode; mode: RoomMode;
status: RoomStatus; status: RoomStatus;
topicSeed?: string; topicSeed?: string;
muted: boolean; // T31:靜音——不推播、不自動播放語音,但自走照常進行(不是暫停)
participants: Map<string, RoomParticipantState>; participants: Map<string, RoomParticipantState>;
turns: RoomTurnRecord[]; turns: RoomTurnRecord[];
turnCounter: number; turnCounter: number;
} }
// T3:K-1 在場者模型與 K-2~K-6 群聊/自聊改為 Prisma 持久化(破壞性替換,不留記憶體相容層)。 let roomSeq = 0;
// 對外方法語意維持原樣(create/get/roster/addTurn/switchToGroup/end),供既有呼叫端 function nextRoomId(): string {
// (room.controller/self-chat/speaking-right/room-consolidation)改為 await 後直接沿用。 roomSeq += 1;
// return `room-${roomSeq}-${roomSeq * 31 + 7}`;
// silentStreak/lastSpokeAtTurn 不落地成獨立欄位,改由 RoomTurn 歷史在 hydrate() 時重新算出: }
// 對任一角色 c,lastSpokeAtTurn(c) = 最後一則由 c 發言的 turn.seq(無則 -1);
// silentStreak(c) = (turnCounter - 1) - lastSpokeAtTurn(c)(從未發言則等於 turnCounter)。 // K-1 在場者模型:群聊/自聊 session 的參與者名冊與共用場景記錄。
// 這與舊版「每輪逐一遞增/發言者歸零」的逐步更新結果完全等價(純函數重算,無需額外欄位)。 // 刻意採用純記憶體儲存(同 C-1 WorkingMemoryService 的設計),因為這是 session 期間的暫存場景,
// // session 結束後會由 K-5 的固化流程萃取成各角色的長期記憶,原始記錄本身不需要落地保存。
// speakerLabel 舊版是呼叫端逐輪傳入的即時稱呼;DB 版改為 hydrate 時依當下的角色別名/使用者名稱
// 重新解析(USER_SPEAKER_ID 一律取房主 User.name),準確度等同於「以目前名稱回溯」,不逐輪落地。
@Injectable() @Injectable()
export class RoomService { export class RoomService {
constructor( private readonly rooms = new Map<string, Room>();
private readonly prisma: PrismaService,
@Inject(CLOCK) private readonly clock: Clock,
private readonly lobby: LobbyService,
private readonly roomEvents: RoomEventsService,
) {}
async create(userId: string, characterIds: string[], mode: RoomMode = "GROUP", topicSeed?: string): Promise<Room> { create(userId: string, characterIds: string[], mode: RoomMode, topicSeed?: string): Room {
const dbRoom = await this.prisma.client.room.create({ const room: Room = {
data: { id: nextRoomId(),
userId, userId,
mode, mode,
status: "ACTIVE", status: "ACTIVE",
topicSeed, topicSeed,
// 舊版 K-1~K-6 的房間是無「不可跨房」約束的暫存場景(同一角色可同時存在於多個測試房間), participants: new Map(
// 這裡不設 activeCharacterId(維持 null),R5 的跨房唯一約束由 T24 起的大廳常駐流轉負責啟用。 characterIds.map((characterId) => [characterId, { characterId, silentStreak: 0, lastSpokeAtTurn: -1 }]),
members: { create: characterIds.map((characterId) => ({ characterId })) }, ),
}, turns: [],
}); turnCounter: 0,
return this.hydrate(dbRoom.id); };
this.rooms.set(room.id, room);
return room;
} }
async get(roomId: string): Promise<Room> { get(roomId: string): Room {
return this.hydrate(roomId); const room = this.rooms.get(roomId);
if (!room) {
throw new NotFoundException(`聊天室不存在:${roomId}`);
}
return room;
} }
// K-1 驗收:角色(或呼叫端)可取得完整在場名單。 // K-1 驗收:角色(或呼叫端)可取得完整在場名單。
async roster(roomId: string): Promise<string[]> { roster(roomId: string): string[] {
const room = await this.hydrate(roomId); return [...this.get(roomId).participants.keys()];
return [...room.participants.keys()];
} }
async addTurn( addTurn(
roomId: string, roomId: string,
speakerId: string, speakerId: string,
speakerLabel: string, speakerLabel: string,
content: string, content: string,
now?: Date, now: Date = new Date(),
emotion?: { tag: EmotionTag; intensity: number }, emotion?: { tag: EmotionTag; intensity: number },
kind: RoomTurnKind = "TALK", ): RoomTurnRecord {
): Promise<RoomTurnRecord> { const room = this.get(roomId);
const effectiveNow = now ?? this.clock.now(); const turnIndex = room.turnCounter;
const speakerCharacterId = speakerId === USER_SPEAKER_ID ? null : speakerId; room.turnCounter += 1;
const turn = await this.prisma.client.$transaction(async (tx) => { const turn: RoomTurnRecord = {
const last = await tx.roomTurn.findFirst({ where: { roomId }, orderBy: { seq: "desc" } }); turnIndex,
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, speakerId,
speakerLabel, speakerLabel,
content, content,
kind,
emotionTag: emotion?.tag, emotionTag: emotion?.tag,
emotionIntensity: emotion?.intensity, emotionIntensity: emotion?.intensity,
timestamp: turn.createdAt, timestamp: now,
}; };
// T10:新 RoomTurn 即時推送給所有已連上 SSE 的訂閱者。 room.turns.push(turn);
// T19-P3:kind 隨 SSE 一併推送,前端才能依 TALK/ACTION/DELEGATE/LEAVE/SYSTEM 分別呈現。
this.roomEvents.emit(roomId, { type: "turn", seq: turn.seq, data: { speakerId, speakerLabel, content, kind } }); // 發言冷卻/被冷落累積:發言者本輪清空沉默計數,其他在場角色沉默計數 +1。
return record; for (const participant of room.participants.values()) {
if (participant.characterId === speakerId) {
participant.lastSpokeAtTurn = turnIndex;
participant.silentStreak = 0;
} else {
participant.silentStreak += 1;
}
} }
async switchToGroup(roomId: string): Promise<Room> { return turn;
await this.prisma.client.room.update({ where: { id: roomId }, data: { mode: "GROUP" } });
return this.hydrate(roomId);
} }
async end(roomId: string, closeReason: string = "SESSION_ENDED"): Promise<Room> { switchToGroup(roomId: string): Room {
const now = this.clock.now(); const room = this.get(roomId);
await this.prisma.client.room.update({ room.mode = "GROUP";
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; return room;
} }
// 舊版記憶體實作於 session 結束後整筆丟棄;DB 版改為保留歷史紀錄供 T12 重新進入與稽核,故改為 no-op, end(roomId: string): Room {
// 不做實體刪除(呼叫端沿用既有「end() 後接著 clear()」的呼叫順序,維持語意相容不需改呼叫端)。 const room = this.get(roomId);
async clear(_roomId: string): Promise<void> {} room.status = "ENDED";
return room;
// 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` 斷線期間錯過的訊息 clear(roomId: string): void {
// 一律補齊),依 seq 由舊到新排序。 this.rooms.delete(roomId);
async getTurnsSince(roomId: string, afterSeq: number): Promise<RoomTurnRecord[]> {
const room = await this.prisma.client.room.findUnique({ where: { id: roomId }, include: { user: true } });
if (!room) {
throw new NotFoundException(`聊天室不存在:${roomId}`);
}
const rows = await this.prisma.client.roomTurn.findMany({
where: { roomId, seq: { gt: afterSeq } },
orderBy: { seq: "asc" },
});
const resolveLabel = await this.buildLabelResolver(rows.flatMap((t) => (t.speakerCharacterId ? [t.speakerCharacterId] : [])));
return rows.map((turn) => ({
turnIndex: turn.seq,
speakerId: turn.speakerCharacterId ?? USER_SPEAKER_ID,
speakerLabel: turn.speakerCharacterId ? resolveLabel(turn.speakerCharacterId) : room.user.name,
content: turn.content,
kind: turn.kind,
timestamp: turn.createdAt,
}));
}
// T11:回報已讀游標,單調遞增、不可回退——即使呼叫端傳來比目前紀錄舊的 seq 也只取兩者較大值。
async reportRead(roomId: string, userId: string, lastReadSeq: number): Promise<void> {
const existing = await this.prisma.client.roomReadState.findUnique({
where: { roomId_userId: { roomId, userId } },
});
const nextValue = Math.max(existing?.lastReadSeq ?? -1, lastReadSeq);
await this.prisma.client.roomReadState.upsert({
where: { roomId_userId: { roomId, userId } },
update: { lastReadSeq: nextValue },
create: { roomId, userId, lastReadSeq: nextValue },
});
}
// T11:進房時依 lastReadSeq 定位到第一則未讀;未讀一律全部保留,不做摘要也不折疊。
async getUnreadInfo(
roomId: string,
userId: string,
): Promise<{ lastReadSeq: number; firstUnreadSeq: number | null; unreadCount: number }> {
const [readState, room] = await Promise.all([
this.prisma.client.roomReadState.findUnique({ where: { roomId_userId: { roomId, userId } } }),
this.prisma.client.room.findUnique({ where: { id: roomId } }),
]);
if (!room) {
throw new NotFoundException(`聊天室不存在:${roomId}`);
}
const lastReadSeq = readState?.lastReadSeq ?? -1;
const latestTurn = await this.prisma.client.roomTurn.findFirst({ where: { roomId }, orderBy: { seq: "desc" } });
const turnCounter = latestTurn ? latestTurn.seq + 1 : 0;
const unreadCount = Math.max(0, turnCounter - (lastReadSeq + 1));
return { lastReadSeq, firstUnreadSeq: unreadCount > 0 ? lastReadSeq + 1 : null, unreadCount };
}
// T9:每次角色發言生成後寫入一筆用量紀錄,僅供觀察,不因用量而暫停或降頻——呼叫端(RoomGenerationService/
// DialogueService 的呼叫方)在拿到 Provider 回傳的 usage 後才呼叫本方法,沒有 usage 就不記錄。
async recordUsage(roomId: string, usage: { model: string; promptTokens: number; completionTokens: number; turnSeq: number }): Promise<void> {
await this.prisma.client.roomUsageLog.create({
data: {
roomId,
model: usage.model,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
turnSeq: usage.turnSeq,
},
});
}
// T9:房間頁與大廳呈現用的累計用量——一律以 roomId 聚合(T33 亦沿用同一個聚合方式,避免逐筆查詢)。
async getUsageSummary(roomId: string): Promise<{ totalPromptTokens: number; totalCompletionTokens: number; generationCount: number }> {
const aggregate = await this.prisma.client.roomUsageLog.aggregate({
where: { roomId },
_sum: { promptTokens: true, completionTokens: true },
_count: { _all: true },
});
return {
totalPromptTokens: aggregate._sum.promptTokens ?? 0,
totalCompletionTokens: aggregate._sum.completionTokens ?? 0,
generationCount: aggregate._count._all,
};
}
private async buildLabelResolver(characterIds: string[]): Promise<(characterId: string) => string> {
const ids = [...new Set(characterIds)];
const [characters, aliases] = await Promise.all([
this.prisma.client.character.findMany({ where: { id: { in: ids } } }),
this.prisma.client.characterAlias.findMany({ where: { characterId: { in: ids } } }),
]);
return (characterId: string) => {
const alias = aliases.find((a) => a.characterId === characterId);
if (alias?.nickname) return alias.nickname;
if (alias?.formalName) return alias.formalName;
return characters.find((c) => c.id === characterId)?.id ?? characterId;
};
}
private async hydrate(roomId: string): Promise<Room> {
const dbRoom = await this.prisma.client.room.findUnique({
where: { id: roomId },
include: {
user: true,
members: { where: { leftAt: null } },
turns: { orderBy: { seq: "asc" } },
},
});
if (!dbRoom) {
throw new NotFoundException(`聊天室不存在:${roomId}`);
}
const characterIds = [
...dbRoom.members.map((member) => member.characterId),
...dbRoom.turns.flatMap((turn) => (turn.speakerCharacterId ? [turn.speakerCharacterId] : [])),
];
const labelFor = await this.buildLabelResolver(characterIds);
const turnCounter = dbRoom.turns.length === 0 ? 0 : dbRoom.turns[dbRoom.turns.length - 1].seq + 1;
const lastSpokeAtTurn = new Map<string, number>();
for (const turn of dbRoom.turns) {
if (turn.speakerCharacterId) lastSpokeAtTurn.set(turn.speakerCharacterId, turn.seq);
}
const participants = new Map<string, RoomParticipantState>();
for (const member of dbRoom.members) {
const last = lastSpokeAtTurn.get(member.characterId) ?? -1;
participants.set(member.characterId, {
characterId: member.characterId,
lastSpokeAtTurn: last,
silentStreak: last === -1 ? turnCounter : turnCounter - 1 - last,
});
}
const turns: RoomTurnRecord[] = dbRoom.turns.map((turn) => ({
turnIndex: turn.seq,
speakerId: turn.speakerCharacterId ?? USER_SPEAKER_ID,
speakerLabel: turn.speakerCharacterId ? labelFor(turn.speakerCharacterId) : dbRoom.user.name,
content: turn.content,
kind: turn.kind,
timestamp: turn.createdAt,
}));
return {
id: dbRoom.id,
userId: dbRoom.userId,
mode: dbRoom.mode as RoomMode,
status: dbRoom.status === "ACTIVE" ? "ACTIVE" : "ENDED",
topicSeed: dbRoom.topicSeed ?? undefined,
muted: dbRoom.muted,
participants,
turns,
turnCounter,
};
}
// T31:靜音開關——不提供「暫停」,自走排程與生成不受影響,只影響是否推播/自動播語音(由呼叫端判斷)。
async setMuted(roomId: string, muted: boolean): Promise<Room> {
await this.prisma.client.room.update({ where: { id: roomId }, data: { muted } });
return this.hydrate(roomId);
} }
} }
@@ -1,148 +0,0 @@
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);
});

Some files were not shown because too many files have changed in this diff Show More